diff --git a/README.md b/README.md index 76adeb7..1cc04e4 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,8 @@ $ i "check if google's dns server is online" It is **local-first** by default (no network required after first run, no prompts leave your machine), **safe by construction** (risk-classified, deterministic guards, audit log), and **composable** (`i "ping google's dns" | i "if reachable exit 0 else exit 1"`). +That composability applies to subcommands that consume natural language too: `i report "first problem" < extra-notes.txt` appends the piped text after the command-line text before proposing issues. + > **Status: pre-alpha.** The binary builds and the mock backend round-trips the full prompt → propose → confirm → run loop, but the local model runtime, daemon, and self-update flows are still being wired up. See [`INTENT.md`](./INTENT.md) for the full project charter, [`docs/SPEC.md`](./docs/SPEC.md) for the implementation contract, and [open issues](https://github.com/CoreyRDean/intent/issues) for the roadmap. ## Building from source diff --git a/docs/SPEC.md b/docs/SPEC.md index 8e84bb4..7f65093 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -487,6 +487,8 @@ Update behavior: Converts natural language into one or more GitHub issues against `CoreyRDean/intent`: +`i report` accepts natural language from argv, stdin, or both. When both are present, argv text comes first and stdin is appended after it. + 1. Parse the user's input. The model returns an array of `{title, body, labels, kind}` proposals. 2. For each proposal, query GitHub Search (`is:issue repo:CoreyRDean/intent `) for the top 5 candidates. 3. For each candidate, compute a similarity score (token-set ratio + embedding-similarity if a small embedding model is available; otherwise just token-set ratio). If ≥0.85, treat as duplicate. diff --git a/internal/cli/report.go b/internal/cli/report.go index 27b41af..4ba79a2 100644 --- a/internal/cli/report.go +++ b/internal/cli/report.go @@ -31,7 +31,9 @@ func cmdReport(ctx context.Context, args []string) int { prompt = append(prompt, a) } } - if len(prompt) == 0 { + stdinData, _ := readStdinIfPiped(reportStdinWait(ctx)) + userInput := buildReportUserInput(prompt, stdinData) + if userInput == "" { errf("usage: i report ") return 1 } @@ -54,7 +56,6 @@ func cmdReport(ctx context.Context, args []string) int { errf("i report requires a real backend — run 'i doctor' to diagnose") return 3 } - userInput := strings.Join(prompt, " ") vl := verbose.FromContext(ctx) vl.Section("report") @@ -248,6 +249,28 @@ func trim(s string, n int) string { return s[:n] + "..." } +func reportStdinWait(ctx context.Context) time.Duration { + if deadline, ok := ctx.Deadline(); ok { + if wait := time.Until(deadline); wait > 0 { + return wait + } + } + return 60 * time.Second +} + +func buildReportUserInput(promptArgs []string, stdinData string) string { + argText := strings.TrimSpace(strings.Join(promptArgs, " ")) + stdinText := strings.TrimSpace(stdinData) + switch { + case argText == "": + return stdinText + case stdinText == "": + return argText + default: + return argText + "\n\n" + stdinText + } +} + // reportSchemaJSON is the JSON schema the model's output must conform // to when we go through the structured path. The backend's grammar/ // response_format enforces this at token generation, so the model diff --git a/internal/cli/report_test.go b/internal/cli/report_test.go index a5d1c8f..3fc8b25 100644 --- a/internal/cli/report_test.go +++ b/internal/cli/report_test.go @@ -106,3 +106,42 @@ func TestSynthesizeTitle(t *testing.T) { } } } + +func TestBuildReportUserInput(t *testing.T) { + cases := []struct { + name string + args []string + stdin string + want string + }{ + { + name: "args_only", + args: []string{"first", "natural", "language"}, + want: "first natural language", + }, + { + name: "stdin_only", + stdin: "second natural language\n", + want: "second natural language", + }, + { + name: "args_then_stdin", + args: []string{"first", "natural", "language"}, + stdin: "second natural language\n", + want: "first natural language\n\nsecond natural language", + }, + { + name: "ignores_blank_stdin", + args: []string{"first", "natural", "language"}, + stdin: "\n\n", + want: "first natural language", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := buildReportUserInput(tc.args, tc.stdin); got != tc.want { + t.Fatalf("buildReportUserInput(%q, %q)=%q want %q", tc.args, tc.stdin, got, tc.want) + } + }) + } +}