Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions docs/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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`. |
Expand Down
24 changes: 24 additions & 0 deletions internal/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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:])
}
Expand Down Expand Up @@ -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.
Expand Down
23 changes: 23 additions & 0 deletions internal/cli/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"context"
"io"
"os"
"reflect"
"strings"
"testing"
)
Expand Down Expand Up @@ -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)
}
}
33 changes: 33 additions & 0 deletions internal/cli/smoke_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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 {
Expand Down
Loading