diff --git a/README.md b/README.md index ee97d02..163cdd2 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,15 @@ INTENT_PIPE_FROM=intent i --from-intent --json "if upstream output indicates fai The inter-intent JSON envelope also carries the upstream prompt and cwd alongside the response metadata and captured stdout, so downstream invocations can keep path context instead of treating bare filenames as if they came from the current directory. +Use `--literal` when your prompt starts with a word that is also a subcommand or when later words should stay prompt text instead of being parsed as intent-mode flags. + +```sh +i --dry --json --literal version +i --literal explain --raw grep output +``` + +With `--literal`, everything after the flag is treated as natural-language prompt text. + --- ## Managing models diff --git a/docs/SPEC.md b/docs/SPEC.md index cb6f800..0dbb0ec 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -15,12 +15,12 @@ The project ships two binaries: ### 1.2 Invocation modes -`intent` selects a mode by inspecting the first non-flag argument: +`intent` selects a mode by inspecting the first non-flag argument, unless `--literal` forces natural-language mode: | Mode | Trigger | Example | |---|---|---| | **Subcommand** | First non-flag arg matches a known subcommand | `i config get model` | -| **Natural language** | Any other input | `i check if google's dns is up` | +| **Natural language** | Any other input, or any argv tail after `--literal` | `i check if google's dns is up` | There is no "shell" or "REPL" mode in v1. Conversational follow-up (`i and now sort by date`) is single-shot and uses the cached previous turn for context; it is not a persistent session. @@ -59,6 +59,7 @@ Top-level flags that are *not* subcommands but change global behavior: |---|---|---| | `--yes`, `-y` | off | Auto-confirm `safe` and `network` risk levels. Never auto-confirms `mutates`, `destructive`, or `sudo`. | | `--dry` | off | Print what would happen; do not execute. Sets `risk` policy to never run. | +| `--literal` | off | Treat everything after this flag as natural-language prompt text, even if it looks like a subcommand or another intent-mode flag. | | `--sandbox` | off | Execute under platform sandbox (`bwrap` on Linux, `sandbox-exec` on macOS). | | `--ro` | off | Cwd bind-mounted read-only inside sandbox. Implies `--sandbox`. | | `--json` | auto | Emit structured response on stdout. Auto-on when stdout is not a TTY and stdin is from another `i`. | diff --git a/internal/cli/cli.go b/internal/cli/cli.go index f2bbc19..64701e9 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -37,8 +37,27 @@ var knownSubcommands = map[string]commandHandler{ type commandHandler func(ctx context.Context, args []string) int +// rewriteLiteralArgs collapses everything after the first --literal flag +// into one prompt token and reports that dispatcher-level natural-language +// mode was explicitly requested. +func rewriteLiteralArgs(args []string) ([]string, bool) { + for i, a := range args { + if a != "--literal" { + continue + } + out := append([]string{}, args[:i]...) + if tail := strings.TrimSpace(strings.Join(args[i+1:], " ")); tail != "" { + out = append(out, tail) + } + return out, true + } + return args, false +} + // Run is the program entrypoint. Returns the exit code. func Run(ctx context.Context, args []string) int { + args, forceLiteral := rewriteLiteralArgs(args) + // Dispatch top-level flags before stripping so that --version and // --help are not consumed by stripGlobalFlags before they can be matched. if len(args) > 0 { @@ -75,6 +94,10 @@ func Run(ctx context.Context, args []string) int { l.KV("cwd", mustCwd()) } + if forceLiteral { + return cmdIntent(ctx, args) + } + if h, ok := knownSubcommands[args[0]]; ok { return h(ctx, args[1:]) } @@ -158,6 +181,7 @@ Subcommands: Flags (in natural-language mode): -y, --yes Auto-confirm safe and network risk levels. --dry Don't execute; print what would happen. + --literal Treat everything after this flag as natural language. --sandbox Execute under a platform sandbox. --ro Cwd bind-mounted read-only (implies --sandbox). --json Emit structured response on stdout. diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 584d368..149dcdb 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -5,6 +5,7 @@ import ( "context" "io" "os" + "reflect" "strings" "testing" ) @@ -47,3 +48,25 @@ func TestVersionFlags(t *testing.T) { }) } } + +func TestRewriteLiteralArgs_NoFlag(t *testing.T) { + in := []string{"report", "bug"} + got, forced := rewriteLiteralArgs(in) + if forced { + t.Fatal("rewriteLiteralArgs should not force literal mode without --literal") + } + if !reflect.DeepEqual(got, in) { + t.Fatalf("rewriteLiteralArgs changed args without --literal: got %v want %v", got, in) + } +} + +func TestRewriteLiteralArgs_CollapsesTailIntoPrompt(t *testing.T) { + got, forced := rewriteLiteralArgs([]string{"--dry", "--json", "--literal", "list", "--raw", "files"}) + if !forced { + t.Fatal("rewriteLiteralArgs should force literal mode when --literal is present") + } + want := []string{"--dry", "--json", "list --raw files"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("rewriteLiteralArgs mismatch: got %v want %v", got, want) + } +} diff --git a/internal/cli/smoke_test.go b/internal/cli/smoke_test.go index 51313f6..508da27 100644 --- a/internal/cli/smoke_test.go +++ b/internal/cli/smoke_test.go @@ -112,6 +112,9 @@ func TestHelpFlag(t *testing.T) { if !strings.Contains(stdout, "--from-intent") { t.Fatalf("expected help to include --from-intent flag docs, got: %q", stdout) } + if !strings.Contains(stdout, "--literal") { + t.Fatalf("expected help to include --literal flag docs, got: %q", stdout) + } } func TestNoArgs(t *testing.T) { @@ -151,6 +154,36 @@ func TestMockDryJSON(t *testing.T) { } } +func TestLiteralForcesNaturalLanguageInsteadOfSubcommand(t *testing.T) { + stdout, _, exitCode := run(t, []string{"INTENT_FORCE_BACKEND=mock"}, "--dry", "--json", "--literal", "version") + if exitCode != 0 { + t.Fatalf("expected exit 0, got %d", exitCode) + } + var result map[string]any + if err := json.Unmarshal([]byte(stdout), &result); err != nil { + t.Fatalf("expected valid JSON from natural-language mode, got: %q; err: %v", stdout, err) + } + gotPrompt, _ := result["prompt"].(string) + if gotPrompt != "version" { + t.Fatalf("expected prompt to stay in natural-language mode, got %#v", result) + } +} + +func TestLiteralKeepsTailFlagsInsidePrompt(t *testing.T) { + stdout, _, exitCode := run(t, []string{"INTENT_FORCE_BACKEND=mock"}, "--dry", "--json", "--literal", "list", "--raw", "files") + if exitCode != 0 { + t.Fatalf("expected exit 0, got %d", exitCode) + } + var result map[string]any + if err := json.Unmarshal([]byte(stdout), &result); err != nil { + t.Fatalf("expected valid JSON from natural-language mode, got: %q; err: %v", stdout, err) + } + gotPrompt, _ := result["prompt"].(string) + if gotPrompt != "list --raw files" { + t.Fatalf("expected literal tail to remain in prompt, got %#v", result) + } +} + func TestMockExecuteJSON(t *testing.T) { stdout, _, exitCode := run(t, []string{"INTENT_FORCE_BACKEND=mock"}, "--yes", "--json", "list", "files") if exitCode != 0 {