feat(agent): support context-free piped CLI input
- commit
- d451e98e530812c125a751d1076d82700ab3063f
- author
- brice · · 2026-08-10 13:56 UTC
- committer
- brice · · 2026-08-11 07:21 UTC
- parent
- 2dc650c
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
8 files · +154 -18
M README.md +3 -0
| @@ -202,6 +202,7 @@ The first time you launch a newer zut binary, the TUI shows the GitHub release n | |||
| 202 | 202 | zut # interactive tui | |
| 203 | 203 | zut "fix the failing test" # tui, pre-filled prompt | |
| 204 | 204 | zut -p "list all go files" # print final text, exit | |
| 205 | + | cat README.md | zut "summarize this text" # pipe input and infer print mode | |
| 205 | 206 | zut -p --stats stats.json "task" # print final text and write generation stats | |
| 206 | 207 | zut --json "refactor main.go" # newline-delimited json events, exit | |
| 207 | 208 | zut -p --orchestrate --provider openai --model gpt-5 "implement and synthesize" | |
| @@ -240,6 +241,7 @@ Print-mode stats contain `provider`, `model`, `prompt_tokens`, `reasoning_tokens | |||
| 240 | 241 | | `-e`, `--ext <path>` | Load an extension from `<path>` for this run (repeatable; wins against installed extensions of the same name). | | |
| 241 | 242 | | `--no-ext` | Skip extension discovery for this run. `--ext` still works on top, so `--no-ext --ext ./x` runs only `x`. | | |
| 242 | 243 | | `--no-skill` | Disable all skills, including built-ins. No `skill` tool is registered and the system prompt has no skill manifest. | | |
| 244 | + | | `--no-context-files`, `-nc` | Disable discovery and loading of global and project `AGENTS.md` files. | | |
| 243 | 245 | | `--no-yolo` | Confirm every tool call before it runs (interactive TUI only). A dialog shows the tool name and a one-line preview of its args, while edit calls show the proposed diff in the tool panel, with four choices: yes, yes-always-this-tool-this-session, yes-always-this-session, no. Press `/` while the dialog is focused to run a slash command first; closing its input or child dialog returns to the pending confirmation. Ignored with a stderr warning in print / json / rpc modes, where tools still run freely so scripts and automation keep working. | | |
| 244 | 246 | ||
| 245 | 247 | ## Tools | |
| @@ -260,6 +262,7 @@ When the sandbox is on (see `/jail`), filesystem tools and LSP workspace edits r | |||
| 260 | 262 | - **Interactive** (default): chat TUI with streaming output, spinner, cost meter, slash commands. | |
| 261 | 263 | - **Print**: `zut -p "prompt"` runs the agent to completion and writes only the final assistant text to stdout. | |
| 262 | 264 | - **Stream**: `zut --stream "prompt"` runs without the TUI and writes assistant text to stdout as it arrives. Tool activity goes to stderr. | |
| 265 | + | - **Piped input**: stdin implies print mode when no explicit mode is selected. Print, stream, and JSON modes prepend piped input to the positional prompt, separated by a newline. For example, `cat README.md | zut "summarize this text"`. | |
| 263 | 266 | - **JSON**: `zut --json "prompt"` emits one JSON object per agent event to stdout, newline-delimited. The schema is documented in [docs/rpc.md](docs/rpc.md). | |
| 264 | 267 | - **RPC**: `zut rpc` runs as a long-lived child process; commands in on stdin, events and responses out on stdout, both as NDJSON. Designed for embedding zut in third-party apps written in any language. See [docs/rpc.md](docs/rpc.md) for the wire schema and `examples/rpc/{python,node,shell,go}` for working clients. | |
| 265 | 268 | ||
| @@ -202,6 +202,7 @@ The first time you launch a newer zut binary, the TUI shows the GitHub release n | |||||
| 202 | zut # interactive tui | 202 | zut # interactive tui | ||
| 203 | zut "fix the failing test" # tui, pre-filled prompt | 203 | zut "fix the failing test" # tui, pre-filled prompt | ||
| 204 | zut -p "list all go files" # print final text, exit | 204 | zut -p "list all go files" # print final text, exit | ||
| 205 | + | cat README.md | zut "summarize this text" # pipe input and infer print mode | |||
| 205 | zut -p --stats stats.json "task" # print final text and write generation stats | 206 | zut -p --stats stats.json "task" # print final text and write generation stats | ||
| 206 | zut --json "refactor main.go" # newline-delimited json events, exit | 207 | zut --json "refactor main.go" # newline-delimited json events, exit | ||
| 207 | zut -p --orchestrate --provider openai --model gpt-5 "implement and synthesize" | 208 | zut -p --orchestrate --provider openai --model gpt-5 "implement and synthesize" | ||
| @@ -240,6 +241,7 @@ Print-mode stats contain `provider`, `model`, `prompt_tokens`, `reasoning_tokens | |||||
| 240 | | `-e`, `--ext <path>` | Load an extension from `<path>` for this run (repeatable; wins against installed extensions of the same name). | | 241 | | `-e`, `--ext <path>` | Load an extension from `<path>` for this run (repeatable; wins against installed extensions of the same name). | | ||
| 241 | | `--no-ext` | Skip extension discovery for this run. `--ext` still works on top, so `--no-ext --ext ./x` runs only `x`. | | 242 | | `--no-ext` | Skip extension discovery for this run. `--ext` still works on top, so `--no-ext --ext ./x` runs only `x`. | | ||
| 242 | | `--no-skill` | Disable all skills, including built-ins. No `skill` tool is registered and the system prompt has no skill manifest. | | 243 | | `--no-skill` | Disable all skills, including built-ins. No `skill` tool is registered and the system prompt has no skill manifest. | | ||
| 244 | + | | `--no-context-files`, `-nc` | Disable discovery and loading of global and project `AGENTS.md` files. | | |||
| 243 | | `--no-yolo` | Confirm every tool call before it runs (interactive TUI only). A dialog shows the tool name and a one-line preview of its args, while edit calls show the proposed diff in the tool panel, with four choices: yes, yes-always-this-tool-this-session, yes-always-this-session, no. Press `/` while the dialog is focused to run a slash command first; closing its input or child dialog returns to the pending confirmation. Ignored with a stderr warning in print / json / rpc modes, where tools still run freely so scripts and automation keep working. | | 245 | | `--no-yolo` | Confirm every tool call before it runs (interactive TUI only). A dialog shows the tool name and a one-line preview of its args, while edit calls show the proposed diff in the tool panel, with four choices: yes, yes-always-this-tool-this-session, yes-always-this-session, no. Press `/` while the dialog is focused to run a slash command first; closing its input or child dialog returns to the pending confirmation. Ignored with a stderr warning in print / json / rpc modes, where tools still run freely so scripts and automation keep working. | | ||
| 244 | 246 | ||||
| 245 | ## Tools | 247 | ## Tools | ||
| @@ -260,6 +262,7 @@ When the sandbox is on (see `/jail`), filesystem tools and LSP workspace edits r | |||||
| 260 | - **Interactive** (default): chat TUI with streaming output, spinner, cost meter, slash commands. | 262 | - **Interactive** (default): chat TUI with streaming output, spinner, cost meter, slash commands. | ||
| 261 | - **Print**: `zut -p "prompt"` runs the agent to completion and writes only the final assistant text to stdout. | 263 | - **Print**: `zut -p "prompt"` runs the agent to completion and writes only the final assistant text to stdout. | ||
| 262 | - **Stream**: `zut --stream "prompt"` runs without the TUI and writes assistant text to stdout as it arrives. Tool activity goes to stderr. | 264 | - **Stream**: `zut --stream "prompt"` runs without the TUI and writes assistant text to stdout as it arrives. Tool activity goes to stderr. | ||
| 265 | + | - **Piped input**: stdin implies print mode when no explicit mode is selected. Print, stream, and JSON modes prepend piped input to the positional prompt, separated by a newline. For example, `cat README.md | zut "summarize this text"`. | |||
| 263 | - **JSON**: `zut --json "prompt"` emits one JSON object per agent event to stdout, newline-delimited. The schema is documented in [docs/rpc.md](docs/rpc.md). | 266 | - **JSON**: `zut --json "prompt"` emits one JSON object per agent event to stdout, newline-delimited. The schema is documented in [docs/rpc.md](docs/rpc.md). | ||
| 264 | - **RPC**: `zut rpc` runs as a long-lived child process; commands in on stdin, events and responses out on stdout, both as NDJSON. Designed for embedding zut in third-party apps written in any language. See [docs/rpc.md](docs/rpc.md) for the wire schema and `examples/rpc/{python,node,shell,go}` for working clients. | 267 | - **RPC**: `zut rpc` runs as a long-lived child process; commands in on stdin, events and responses out on stdout, both as NDJSON. Designed for embedding zut in third-party apps written in any language. See [docs/rpc.md](docs/rpc.md) for the wire schema and `examples/rpc/{python,node,shell,go}` for working clients. | ||
| 265 | 268 | ||||
M docs/rpc.md +2 -2
| @@ -23,9 +23,9 @@ You'll see one JSON object per line on stdout: a response acknowledging the prom | |||
| 23 | 23 | ||
| 24 | 24 | ## Flags | |
| 25 | 25 | ||
| 26 | - | `zut rpc` accepts the same flags as the other modes: `--provider`, `--model`, `--cwd`, `--api-key`, `--base-url`, `--system-prompt`, `--append-system-prompt`, `--reasoning`, `--max-steps`, `--no-tools`, `--tools`. Sessions are disabled by default in RPC mode — the embedding application owns persistence. | |
| 26 | + | `zut rpc` accepts the same flags as the other modes: `--provider`, `--model`, `--cwd`, `--api-key`, `--base-url`, `--system-prompt`, `--append-system-prompt`, `--reasoning`, `--max-steps`, `--no-tools`, `--tools`, `--no-context-files`. Sessions are disabled by default in RPC mode — the embedding application owns persistence. | |
| 27 | 27 | ||
| 28 | - | RPC agents use the same resolved system-prompt policy as other modes. Ponytail coding mode is enabled when `ponytail_enabled` is missing or `true` in `$ZUT_HOME/config.json`, and disabled when it is `false`. A custom `--system-prompt` replaces the built-in identity, while enabled append addenda remain present. | |
| 28 | + | RPC agents use the same resolved system-prompt policy as other modes. Ponytail coding mode is enabled when `ponytail_enabled` is missing or `true` in `$ZUT_HOME/config.json`, and disabled when it is `false`. A custom `--system-prompt` replaces the built-in identity and docs guidance, while enabled append addenda remain present. | |
| 29 | 29 | ||
| 30 | 30 | ## Auth | |
| 31 | 31 | ||
| @@ -23,9 +23,9 @@ You'll see one JSON object per line on stdout: a response acknowledging the prom | |||||
| 23 | 23 | ||||
| 24 | ## Flags | 24 | ## Flags | ||
| 25 | 25 | ||||
| 26 | - | `zut rpc` accepts the same flags as the other modes: `--provider`, `--model`, `--cwd`, `--api-key`, `--base-url`, `--system-prompt`, `--append-system-prompt`, `--reasoning`, `--max-steps`, `--no-tools`, `--tools`. Sessions are disabled by default in RPC mode — the embedding application owns persistence. | 26 | + | `zut rpc` accepts the same flags as the other modes: `--provider`, `--model`, `--cwd`, `--api-key`, `--base-url`, `--system-prompt`, `--append-system-prompt`, `--reasoning`, `--max-steps`, `--no-tools`, `--tools`, `--no-context-files`. Sessions are disabled by default in RPC mode — the embedding application owns persistence. |
| 27 | 27 | ||||
| 28 | - | RPC agents use the same resolved system-prompt policy as other modes. Ponytail coding mode is enabled when `ponytail_enabled` is missing or `true` in `$ZUT_HOME/config.json`, and disabled when it is `false`. A custom `--system-prompt` replaces the built-in identity, while enabled append addenda remain present. | 28 | + | RPC agents use the same resolved system-prompt policy as other modes. Ponytail coding mode is enabled when `ponytail_enabled` is missing or `true` in `$ZUT_HOME/config.json`, and disabled when it is `false`. A custom `--system-prompt` replaces the built-in identity and docs guidance, while enabled append addenda remain present. |
| 29 | 29 | ||||
| 30 | ## Auth | 30 | ## Auth | ||
| 31 | 31 | ||||
M packages/agent/args.go +7 -0
| @@ -106,6 +106,10 @@ type Args struct { | |||
| 106 | 106 | // skill discovery, including built-ins. | |
| 107 | 107 | WithSkills bool | |
| 108 | 108 | ||
| 109 | + | // NoContextFiles disables discovery and loading of AGENTS.md files | |
| 110 | + | // from $ZUT_HOME and the cwd's ancestor directories. | |
| 111 | + | NoContextFiles bool | |
| 112 | + | ||
| 109 | 113 | // InsecureTLS skips TLS verification for custom inference endpoints. | |
| 110 | 114 | InsecureTLS bool | |
| 111 | 115 | ||
| @@ -275,6 +279,8 @@ func ParseArgs(in []string) (Args, error) { | |||
| 275 | 279 | a.NoExt = true | |
| 276 | 280 | case "--no-skill", "--no-skills": | |
| 277 | 281 | a.NoSkill = true | |
| 282 | + | case "--no-context-files", "-nc": | |
| 283 | + | a.NoContextFiles = true | |
| 278 | 284 | case "--with-skills", "--with-skill": | |
| 279 | 285 | // Deprecated no-op: user skills are loaded by default. | |
| 280 | 286 | a.WithSkills = true | |
| @@ -575,6 +581,7 @@ func PrintHelp(version string) { | |||
| 575 | 581 | row{"-y, --yes", "accept zut run consent without prompting"}, | |
| 576 | 582 | row{"--no-ext", "skip extension discovery for this run"}, | |
| 577 | 583 | row{"--no-skill", "skip all skill discovery for this run"}, | |
| 584 | + | row{"--no-context-files, -nc", "skip AGENTS.md discovery for this run"}, | |
| 578 | 585 | ) | |
| 579 | 586 | section("misc", | |
| 580 | 587 | row{"--max-steps N", "agent loop iteration cap (default: unlimited)"}, | |
| @@ -106,6 +106,10 @@ type Args struct { | |||||
| 106 | // skill discovery, including built-ins. | 106 | // skill discovery, including built-ins. | ||
| 107 | WithSkills bool | 107 | WithSkills bool | ||
| 108 | 108 | ||||
| 109 | + | // NoContextFiles disables discovery and loading of AGENTS.md files | |||
| 110 | + | // from $ZUT_HOME and the cwd's ancestor directories. | |||
| 111 | + | NoContextFiles bool | |||
| 112 | + | ||||
| 109 | // InsecureTLS skips TLS verification for custom inference endpoints. | 113 | // InsecureTLS skips TLS verification for custom inference endpoints. | ||
| 110 | InsecureTLS bool | 114 | InsecureTLS bool | ||
| 111 | 115 | ||||
| @@ -275,6 +279,8 @@ func ParseArgs(in []string) (Args, error) { | |||||
| 275 | a.NoExt = true | 279 | a.NoExt = true | ||
| 276 | case "--no-skill", "--no-skills": | 280 | case "--no-skill", "--no-skills": | ||
| 277 | a.NoSkill = true | 281 | a.NoSkill = true | ||
| 282 | + | case "--no-context-files", "-nc": | |||
| 283 | + | a.NoContextFiles = true | |||
| 278 | case "--with-skills", "--with-skill": | 284 | case "--with-skills", "--with-skill": | ||
| 279 | // Deprecated no-op: user skills are loaded by default. | 285 | // Deprecated no-op: user skills are loaded by default. | ||
| 280 | a.WithSkills = true | 286 | a.WithSkills = true | ||
| @@ -575,6 +581,7 @@ func PrintHelp(version string) { | |||||
| 575 | row{"-y, --yes", "accept zut run consent without prompting"}, | 581 | row{"-y, --yes", "accept zut run consent without prompting"}, | ||
| 576 | row{"--no-ext", "skip extension discovery for this run"}, | 582 | row{"--no-ext", "skip extension discovery for this run"}, | ||
| 577 | row{"--no-skill", "skip all skill discovery for this run"}, | 583 | row{"--no-skill", "skip all skill discovery for this run"}, | ||
| 584 | + | row{"--no-context-files, -nc", "skip AGENTS.md discovery for this run"}, | |||
| 578 | ) | 585 | ) | ||
| 579 | section("misc", | 586 | section("misc", | ||
| 580 | row{"--max-steps N", "agent loop iteration cap (default: unlimited)"}, | 587 | row{"--max-steps N", "agent loop iteration cap (default: unlimited)"}, | ||
M packages/agent/args_test.go +12 -0
| @@ -188,6 +188,18 @@ func TestParseArgsStatsRequiresPrintMode(t *testing.T) { | |||
| 188 | 188 | } | |
| 189 | 189 | } | |
| 190 | 190 | ||
| 191 | + | func TestParseArgsNoContextFiles(t *testing.T) { | |
| 192 | + | for _, flag := range []string{"--no-context-files", "-nc"} { | |
| 193 | + | args, err := ParseArgs([]string{flag}) | |
| 194 | + | if err != nil { | |
| 195 | + | t.Fatalf("ParseArgs(%q): %v", flag, err) | |
| 196 | + | } | |
| 197 | + | if !args.NoContextFiles { | |
| 198 | + | t.Fatalf("ParseArgs(%q): NoContextFiles = false", flag) | |
| 199 | + | } | |
| 200 | + | } | |
| 201 | + | } | |
| 202 | + | ||
| 191 | 203 | func TestParseArgsStream(t *testing.T) { | |
| 192 | 204 | args, err := ParseArgs([]string{"--stream", "hi"}) | |
| 193 | 205 | if err != nil { | |
| @@ -188,6 +188,18 @@ func TestParseArgsStatsRequiresPrintMode(t *testing.T) { | |||||
| 188 | } | 188 | } | ||
| 189 | } | 189 | } | ||
| 190 | 190 | ||||
| 191 | + | func TestParseArgsNoContextFiles(t *testing.T) { | |||
| 192 | + | for _, flag := range []string{"--no-context-files", "-nc"} { | |||
| 193 | + | args, err := ParseArgs([]string{flag}) | |||
| 194 | + | if err != nil { | |||
| 195 | + | t.Fatalf("ParseArgs(%q): %v", flag, err) | |||
| 196 | + | } | |||
| 197 | + | if !args.NoContextFiles { | |||
| 198 | + | t.Fatalf("ParseArgs(%q): NoContextFiles = false", flag) | |||
| 199 | + | } | |||
| 200 | + | } | |||
| 201 | + | } | |||
| 202 | + | ||||
| 191 | func TestParseArgsStream(t *testing.T) { | 203 | func TestParseArgsStream(t *testing.T) { | ||
| 192 | args, err := ParseArgs([]string{"--stream", "hi"}) | 204 | args, err := ParseArgs([]string{"--stream", "hi"}) | ||
| 193 | if err != nil { | 205 | if err != nil { | ||
M packages/agent/build.go +1 -1
| @@ -842,7 +842,7 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||
| 842 | 842 | summaries := toolSummaries(reg, args) | |
| 843 | 843 | ||
| 844 | 844 | contextFiles := []ContextFile(nil) | |
| 845 | - | if selectedProfile == nil || selectedProfile.InheritProjectContext == nil || *selectedProfile.InheritProjectContext { | |
| 845 | + | if !args.NoContextFiles && (selectedProfile == nil || selectedProfile.InheritProjectContext == nil || *selectedProfile.InheritProjectContext) { | |
| 846 | 846 | contextFiles = loadAgentsContext(args.CWD, ZutHome()) | |
| 847 | 847 | } | |
| 848 | 848 | append_ := []string(nil) | |
| @@ -842,7 +842,7 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||||
| 842 | summaries := toolSummaries(reg, args) | 842 | summaries := toolSummaries(reg, args) | ||
| 843 | 843 | ||||
| 844 | contextFiles := []ContextFile(nil) | 844 | contextFiles := []ContextFile(nil) | ||
| 845 | - | if selectedProfile == nil || selectedProfile.InheritProjectContext == nil || *selectedProfile.InheritProjectContext { | 845 | + | if !args.NoContextFiles && (selectedProfile == nil || selectedProfile.InheritProjectContext == nil || *selectedProfile.InheritProjectContext) { |
| 846 | contextFiles = loadAgentsContext(args.CWD, ZutHome()) | 846 | contextFiles = loadAgentsContext(args.CWD, ZutHome()) | ||
| 847 | } | 847 | } | ||
| 848 | append_ := []string(nil) | 848 | append_ := []string(nil) | ||
M packages/agent/build_test.go +29 -0
| @@ -708,6 +708,35 @@ func TestReadAgentsContextMissingFilesIsEmpty(t *testing.T) { | |||
| 708 | 708 | } | |
| 709 | 709 | } | |
| 710 | 710 | ||
| 711 | + | func TestResolveNoContextFilesSkipsAgentsInstructions(t *testing.T) { | |
| 712 | + | zutHome := t.TempDir() | |
| 713 | + | cwd := t.TempDir() | |
| 714 | + | t.Setenv("ZUT_HOME", zutHome) | |
| 715 | + | t.Setenv("OPENAI_API_KEY", "test-key") | |
| 716 | + | if err := os.WriteFile(filepath.Join(zutHome, "AGENTS.md"), []byte("global rule"), 0o644); err != nil { | |
| 717 | + | t.Fatal(err) | |
| 718 | + | } | |
| 719 | + | if err := os.WriteFile(filepath.Join(cwd, "AGENTS.md"), []byte("project rule"), 0o644); err != nil { | |
| 720 | + | t.Fatal(err) | |
| 721 | + | } | |
| 722 | + | ||
| 723 | + | r, err := Resolve(Args{ | |
| 724 | + | Provider: "openai", | |
| 725 | + | Model: "gpt-5", | |
| 726 | + | CWD: cwd, | |
| 727 | + | NoContextFiles: true, | |
| 728 | + | }, false) | |
| 729 | + | if err != nil { | |
| 730 | + | t.Fatal(err) | |
| 731 | + | } | |
| 732 | + | if len(r.ContextFiles) != 0 { | |
| 733 | + | t.Fatalf("ContextFiles = %#v, want empty", r.ContextFiles) | |
| 734 | + | } | |
| 735 | + | if strings.Contains(r.SystemPrompt, "global rule") || strings.Contains(r.SystemPrompt, "project rule") { | |
| 736 | + | t.Fatalf("system prompt contains disabled context files:\n%s", r.SystemPrompt) | |
| 737 | + | } | |
| 738 | + | } | |
| 739 | + | ||
| 711 | 740 | // TestResolveFallsBackWhenConfiguredModelIsGone reproduces the | |
| 712 | 741 | // startup failure caught by the user's screenshot: the persisted | |
| 713 | 742 | // config.json points at a model id that's no longer in the active | |
| @@ -708,6 +708,35 @@ func TestReadAgentsContextMissingFilesIsEmpty(t *testing.T) { | |||||
| 708 | } | 708 | } | ||
| 709 | } | 709 | } | ||
| 710 | 710 | ||||
| 711 | + | func TestResolveNoContextFilesSkipsAgentsInstructions(t *testing.T) { | |||
| 712 | + | zutHome := t.TempDir() | |||
| 713 | + | cwd := t.TempDir() | |||
| 714 | + | t.Setenv("ZUT_HOME", zutHome) | |||
| 715 | + | t.Setenv("OPENAI_API_KEY", "test-key") | |||
| 716 | + | if err := os.WriteFile(filepath.Join(zutHome, "AGENTS.md"), []byte("global rule"), 0o644); err != nil { | |||
| 717 | + | t.Fatal(err) | |||
| 718 | + | } | |||
| 719 | + | if err := os.WriteFile(filepath.Join(cwd, "AGENTS.md"), []byte("project rule"), 0o644); err != nil { | |||
| 720 | + | t.Fatal(err) | |||
| 721 | + | } | |||
| 722 | + | ||||
| 723 | + | r, err := Resolve(Args{ | |||
| 724 | + | Provider: "openai", | |||
| 725 | + | Model: "gpt-5", | |||
| 726 | + | CWD: cwd, | |||
| 727 | + | NoContextFiles: true, | |||
| 728 | + | }, false) | |||
| 729 | + | if err != nil { | |||
| 730 | + | t.Fatal(err) | |||
| 731 | + | } | |||
| 732 | + | if len(r.ContextFiles) != 0 { | |||
| 733 | + | t.Fatalf("ContextFiles = %#v, want empty", r.ContextFiles) | |||
| 734 | + | } | |||
| 735 | + | if strings.Contains(r.SystemPrompt, "global rule") || strings.Contains(r.SystemPrompt, "project rule") { | |||
| 736 | + | t.Fatalf("system prompt contains disabled context files:\n%s", r.SystemPrompt) | |||
| 737 | + | } | |||
| 738 | + | } | |||
| 739 | + | ||||
| 711 | // TestResolveFallsBackWhenConfiguredModelIsGone reproduces the | 740 | // TestResolveFallsBackWhenConfiguredModelIsGone reproduces the | ||
| 712 | // startup failure caught by the user's screenshot: the persisted | 741 | // startup failure caught by the user's screenshot: the persisted | ||
| 713 | // config.json points at a model id that's no longer in the active | 742 | // config.json points at a model id that's no longer in the active | ||
M packages/agent/cli.go +27 -15
| @@ -804,6 +804,9 @@ func runWithArgsRaw(rawArgs []string, version string) error { | |||
| 804 | 804 | rawArgs = append([]string{"--rpc"}, rawArgs[1:]...) | |
| 805 | 805 | } | |
| 806 | 806 | ||
| 807 | + | if fi, err := os.Stdin.Stat(); err == nil { | |
| 808 | + | rawArgs = argsForStdin(rawArgs, fi.Mode()) | |
| 809 | + | } | |
| 807 | 810 | args, err := ParseArgs(rawArgs) | |
| 808 | 811 | if err != nil { | |
| 809 | 812 | PrintHelp(version) | |
| @@ -1009,11 +1012,8 @@ func runPrintMode(ctx context.Context, args Args, version string) (runErr error) | |||
| 1009 | 1012 | defer func() { runErr = errors.Join(runErr, sess.FlushRetryLifecycle(ag.LastTurnUsage(), ag.Cost())) }() | |
| 1010 | 1013 | } | |
| 1011 | 1014 | ||
| 1012 | - | prompt := args.Prompt | |
| 1013 | - | if prompt == "" { | |
| 1014 | - | piped, _ := readAllStdin() | |
| 1015 | - | prompt = strings.TrimSpace(piped) | |
| 1016 | - | } | |
| 1015 | + | piped, _ := readAllStdin() | |
| 1016 | + | prompt := combinePromptInput(piped, args.Prompt) | |
| 1017 | 1017 | if prompt == "" { | |
| 1018 | 1018 | return fmt.Errorf("print mode requires a prompt (arg or stdin)") | |
| 1019 | 1019 | } | |
| @@ -1094,11 +1094,8 @@ func runStreamMode(ctx context.Context, args Args, version string) (runErr error | |||
| 1094 | 1094 | defer func() { runErr = errors.Join(runErr, sess.FlushRetryLifecycle(ag.LastTurnUsage(), ag.Cost())) }() | |
| 1095 | 1095 | } | |
| 1096 | 1096 | ||
| 1097 | - | prompt := args.Prompt | |
| 1098 | - | if prompt == "" { | |
| 1099 | - | piped, _ := readAllStdin() | |
| 1100 | - | prompt = strings.TrimSpace(piped) | |
| 1101 | - | } | |
| 1097 | + | piped, _ := readAllStdin() | |
| 1098 | + | prompt := combinePromptInput(piped, args.Prompt) | |
| 1102 | 1099 | if prompt == "" { | |
| 1103 | 1100 | return fmt.Errorf("stream mode requires a prompt (arg or stdin)") | |
| 1104 | 1101 | } | |
| @@ -1218,11 +1215,8 @@ func runJSONMode(ctx context.Context, args Args, version string) (runErr error) | |||
| 1218 | 1215 | defer func() { runErr = errors.Join(runErr, sess.FlushRetryLifecycle(ag.LastTurnUsage(), ag.Cost())) }() | |
| 1219 | 1216 | } | |
| 1220 | 1217 | ||
| 1221 | - | prompt := args.Prompt | |
| 1222 | - | if prompt == "" { | |
| 1223 | - | piped, _ := readAllStdin() | |
| 1224 | - | prompt = strings.TrimSpace(piped) | |
| 1225 | - | } | |
| 1218 | + | piped, _ := readAllStdin() | |
| 1219 | + | prompt := combinePromptInput(piped, args.Prompt) | |
| 1226 | 1220 | if prompt == "" { | |
| 1227 | 1221 | return fmt.Errorf("json mode requires a prompt (arg or stdin)") | |
| 1228 | 1222 | } | |
| @@ -2891,6 +2885,24 @@ func writeNewTranscriptLocked(ag *core.Agent, sess *core.Session, from int) (nex | |||
| 2891 | 2885 | return next, nil | |
| 2892 | 2886 | } | |
| 2893 | 2887 | ||
| 2888 | + | func argsForStdin(rawArgs []string, mode os.FileMode) []string { | |
| 2889 | + | if mode&os.ModeCharDevice != 0 { | |
| 2890 | + | return rawArgs | |
| 2891 | + | } | |
| 2892 | + | return append([]string{"--print"}, rawArgs...) | |
| 2893 | + | } | |
| 2894 | + | ||
| 2895 | + | func combinePromptInput(stdin, positional string) string { | |
| 2896 | + | parts := make([]string, 0, 2) | |
| 2897 | + | if stdin = strings.TrimSpace(stdin); stdin != "" { | |
| 2898 | + | parts = append(parts, stdin) | |
| 2899 | + | } | |
| 2900 | + | if positional = strings.TrimSpace(positional); positional != "" { | |
| 2901 | + | parts = append(parts, positional) | |
| 2902 | + | } | |
| 2903 | + | return strings.Join(parts, "\n") | |
| 2904 | + | } | |
| 2905 | + | ||
| 2894 | 2906 | func readAllStdin() (string, error) { | |
| 2895 | 2907 | fi, err := os.Stdin.Stat() | |
| 2896 | 2908 | if err != nil { | |
| @@ -804,6 +804,9 @@ func runWithArgsRaw(rawArgs []string, version string) error { | |||||
| 804 | rawArgs = append([]string{"--rpc"}, rawArgs[1:]...) | 804 | rawArgs = append([]string{"--rpc"}, rawArgs[1:]...) | ||
| 805 | } | 805 | } | ||
| 806 | 806 | ||||
| 807 | + | if fi, err := os.Stdin.Stat(); err == nil { | |||
| 808 | + | rawArgs = argsForStdin(rawArgs, fi.Mode()) | |||
| 809 | + | } | |||
| 807 | args, err := ParseArgs(rawArgs) | 810 | args, err := ParseArgs(rawArgs) | ||
| 808 | if err != nil { | 811 | if err != nil { | ||
| 809 | PrintHelp(version) | 812 | PrintHelp(version) | ||
| @@ -1009,11 +1012,8 @@ func runPrintMode(ctx context.Context, args Args, version string) (runErr error) | |||||
| 1009 | defer func() { runErr = errors.Join(runErr, sess.FlushRetryLifecycle(ag.LastTurnUsage(), ag.Cost())) }() | 1012 | defer func() { runErr = errors.Join(runErr, sess.FlushRetryLifecycle(ag.LastTurnUsage(), ag.Cost())) }() | ||
| 1010 | } | 1013 | } | ||
| 1011 | 1014 | ||||
| 1012 | - | prompt := args.Prompt | 1015 | + | piped, _ := readAllStdin() |
| 1013 | - | if prompt == "" { | 1016 | + | prompt := combinePromptInput(piped, args.Prompt) |
| 1014 | - | piped, _ := readAllStdin() | |||
| 1015 | - | prompt = strings.TrimSpace(piped) | |||
| 1016 | - | } | |||
| 1017 | if prompt == "" { | 1017 | if prompt == "" { | ||
| 1018 | return fmt.Errorf("print mode requires a prompt (arg or stdin)") | 1018 | return fmt.Errorf("print mode requires a prompt (arg or stdin)") | ||
| 1019 | } | 1019 | } | ||
| @@ -1094,11 +1094,8 @@ func runStreamMode(ctx context.Context, args Args, version string) (runErr error | |||||
| 1094 | defer func() { runErr = errors.Join(runErr, sess.FlushRetryLifecycle(ag.LastTurnUsage(), ag.Cost())) }() | 1094 | defer func() { runErr = errors.Join(runErr, sess.FlushRetryLifecycle(ag.LastTurnUsage(), ag.Cost())) }() | ||
| 1095 | } | 1095 | } | ||
| 1096 | 1096 | ||||
| 1097 | - | prompt := args.Prompt | 1097 | + | piped, _ := readAllStdin() |
| 1098 | - | if prompt == "" { | 1098 | + | prompt := combinePromptInput(piped, args.Prompt) |
| 1099 | - | piped, _ := readAllStdin() | |||
| 1100 | - | prompt = strings.TrimSpace(piped) | |||
| 1101 | - | } | |||
| 1102 | if prompt == "" { | 1099 | if prompt == "" { | ||
| 1103 | return fmt.Errorf("stream mode requires a prompt (arg or stdin)") | 1100 | return fmt.Errorf("stream mode requires a prompt (arg or stdin)") | ||
| 1104 | } | 1101 | } | ||
| @@ -1218,11 +1215,8 @@ func runJSONMode(ctx context.Context, args Args, version string) (runErr error) | |||||
| 1218 | defer func() { runErr = errors.Join(runErr, sess.FlushRetryLifecycle(ag.LastTurnUsage(), ag.Cost())) }() | 1215 | defer func() { runErr = errors.Join(runErr, sess.FlushRetryLifecycle(ag.LastTurnUsage(), ag.Cost())) }() | ||
| 1219 | } | 1216 | } | ||
| 1220 | 1217 | ||||
| 1221 | - | prompt := args.Prompt | 1218 | + | piped, _ := readAllStdin() |
| 1222 | - | if prompt == "" { | 1219 | + | prompt := combinePromptInput(piped, args.Prompt) |
| 1223 | - | piped, _ := readAllStdin() | |||
| 1224 | - | prompt = strings.TrimSpace(piped) | |||
| 1225 | - | } | |||
| 1226 | if prompt == "" { | 1220 | if prompt == "" { | ||
| 1227 | return fmt.Errorf("json mode requires a prompt (arg or stdin)") | 1221 | return fmt.Errorf("json mode requires a prompt (arg or stdin)") | ||
| 1228 | } | 1222 | } | ||
| @@ -2891,6 +2885,24 @@ func writeNewTranscriptLocked(ag *core.Agent, sess *core.Session, from int) (nex | |||||
| 2891 | return next, nil | 2885 | return next, nil | ||
| 2892 | } | 2886 | } | ||
| 2893 | 2887 | ||||
| 2888 | + | func argsForStdin(rawArgs []string, mode os.FileMode) []string { | |||
| 2889 | + | if mode&os.ModeCharDevice != 0 { | |||
| 2890 | + | return rawArgs | |||
| 2891 | + | } | |||
| 2892 | + | return append([]string{"--print"}, rawArgs...) | |||
| 2893 | + | } | |||
| 2894 | + | ||||
| 2895 | + | func combinePromptInput(stdin, positional string) string { | |||
| 2896 | + | parts := make([]string, 0, 2) | |||
| 2897 | + | if stdin = strings.TrimSpace(stdin); stdin != "" { | |||
| 2898 | + | parts = append(parts, stdin) | |||
| 2899 | + | } | |||
| 2900 | + | if positional = strings.TrimSpace(positional); positional != "" { | |||
| 2901 | + | parts = append(parts, positional) | |||
| 2902 | + | } | |||
| 2903 | + | return strings.Join(parts, "\n") | |||
| 2904 | + | } | |||
| 2905 | + | ||||
| 2894 | func readAllStdin() (string, error) { | 2906 | func readAllStdin() (string, error) { | ||
| 2895 | fi, err := os.Stdin.Stat() | 2907 | fi, err := os.Stdin.Stat() | ||
| 2896 | if err != nil { | 2908 | if err != nil { | ||
A packages/agent/prompt_input_test.go +73 -0
| @@ -0,0 +1,73 @@ | |||
| 1 | + | package agent | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "os" | |
| 5 | + | "testing" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | func TestArgsForStdinSelectsPrintModeForPipe(t *testing.T) { | |
| 9 | + | args, err := ParseArgs(argsForStdin([]string{"summarize"}, os.ModeNamedPipe)) | |
| 10 | + | if err != nil { | |
| 11 | + | t.Fatal(err) | |
| 12 | + | } | |
| 13 | + | if args.Mode != ModePrint || args.Prompt != "summarize" { | |
| 14 | + | t.Fatalf("Mode=%q Prompt=%q, want print mode with prompt", args.Mode, args.Prompt) | |
| 15 | + | } | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | func TestArgsForStdinPreservesTerminalAndExplicitModes(t *testing.T) { | |
| 19 | + | tests := []struct { | |
| 20 | + | name string | |
| 21 | + | in []string | |
| 22 | + | mode os.FileMode | |
| 23 | + | want Mode | |
| 24 | + | }{ | |
| 25 | + | {name: "terminal stays interactive", mode: os.ModeCharDevice, want: ModeInteractive}, | |
| 26 | + | {name: "explicit stream overrides pipe default", in: []string{"--stream", "prompt"}, mode: os.ModeNamedPipe, want: ModeStream}, | |
| 27 | + | {name: "explicit json overrides redirected file default", in: []string{"--json", "prompt"}, want: ModeJSON}, | |
| 28 | + | {name: "explicit rpc overrides pipe default", in: []string{"--rpc"}, mode: os.ModeNamedPipe, want: ModeRPC}, | |
| 29 | + | } | |
| 30 | + | for _, tc := range tests { | |
| 31 | + | t.Run(tc.name, func(t *testing.T) { | |
| 32 | + | args, err := ParseArgs(argsForStdin(tc.in, tc.mode)) | |
| 33 | + | if err != nil { | |
| 34 | + | t.Fatal(err) | |
| 35 | + | } | |
| 36 | + | if args.Mode != tc.want { | |
| 37 | + | t.Fatalf("Mode=%q, want %q", args.Mode, tc.want) | |
| 38 | + | } | |
| 39 | + | }) | |
| 40 | + | } | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | func TestArgsForStdinAllowsPrintStats(t *testing.T) { | |
| 44 | + | args, err := ParseArgs(argsForStdin([]string{"--stats", "stats.json"}, os.ModeNamedPipe)) | |
| 45 | + | if err != nil { | |
| 46 | + | t.Fatal(err) | |
| 47 | + | } | |
| 48 | + | if args.Mode != ModePrint || args.StatsPath != "stats.json" { | |
| 49 | + | t.Fatalf("Mode=%q StatsPath=%q, want print mode with stats path", args.Mode, args.StatsPath) | |
| 50 | + | } | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | func TestCombinePromptInput(t *testing.T) { | |
| 54 | + | tests := []struct { | |
| 55 | + | name string | |
| 56 | + | stdin string | |
| 57 | + | positional string | |
| 58 | + | want string | |
| 59 | + | }{ | |
| 60 | + | {name: "stdin and instruction", stdin: "input text\n", positional: "summarize it", want: "input text\nsummarize it"}, | |
| 61 | + | {name: "stdin only", stdin: " input text\n", want: "input text"}, | |
| 62 | + | {name: "positional only", positional: " summarize it ", want: "summarize it"}, | |
| 63 | + | {name: "empty", stdin: " \n", positional: " ", want: ""}, | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | for _, tc := range tests { | |
| 67 | + | t.Run(tc.name, func(t *testing.T) { | |
| 68 | + | if got := combinePromptInput(tc.stdin, tc.positional); got != tc.want { | |
| 69 | + | t.Fatalf("combinePromptInput(%q, %q) = %q, want %q", tc.stdin, tc.positional, got, tc.want) | |
| 70 | + | } | |
| 71 | + | }) | |
| 72 | + | } | |
| 73 | + | } | |
| @@ -0,0 +1,73 @@ | |||||
| 1 | + | package agent | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "os" | |||
| 5 | + | "testing" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | func TestArgsForStdinSelectsPrintModeForPipe(t *testing.T) { | |||
| 9 | + | args, err := ParseArgs(argsForStdin([]string{"summarize"}, os.ModeNamedPipe)) | |||
| 10 | + | if err != nil { | |||
| 11 | + | t.Fatal(err) | |||
| 12 | + | } | |||
| 13 | + | if args.Mode != ModePrint || args.Prompt != "summarize" { | |||
| 14 | + | t.Fatalf("Mode=%q Prompt=%q, want print mode with prompt", args.Mode, args.Prompt) | |||
| 15 | + | } | |||
| 16 | + | } | |||
| 17 | + | ||||
| 18 | + | func TestArgsForStdinPreservesTerminalAndExplicitModes(t *testing.T) { | |||
| 19 | + | tests := []struct { | |||
| 20 | + | name string | |||
| 21 | + | in []string | |||
| 22 | + | mode os.FileMode | |||
| 23 | + | want Mode | |||
| 24 | + | }{ | |||
| 25 | + | {name: "terminal stays interactive", mode: os.ModeCharDevice, want: ModeInteractive}, | |||
| 26 | + | {name: "explicit stream overrides pipe default", in: []string{"--stream", "prompt"}, mode: os.ModeNamedPipe, want: ModeStream}, | |||
| 27 | + | {name: "explicit json overrides redirected file default", in: []string{"--json", "prompt"}, want: ModeJSON}, | |||
| 28 | + | {name: "explicit rpc overrides pipe default", in: []string{"--rpc"}, mode: os.ModeNamedPipe, want: ModeRPC}, | |||
| 29 | + | } | |||
| 30 | + | for _, tc := range tests { | |||
| 31 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 32 | + | args, err := ParseArgs(argsForStdin(tc.in, tc.mode)) | |||
| 33 | + | if err != nil { | |||
| 34 | + | t.Fatal(err) | |||
| 35 | + | } | |||
| 36 | + | if args.Mode != tc.want { | |||
| 37 | + | t.Fatalf("Mode=%q, want %q", args.Mode, tc.want) | |||
| 38 | + | } | |||
| 39 | + | }) | |||
| 40 | + | } | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | func TestArgsForStdinAllowsPrintStats(t *testing.T) { | |||
| 44 | + | args, err := ParseArgs(argsForStdin([]string{"--stats", "stats.json"}, os.ModeNamedPipe)) | |||
| 45 | + | if err != nil { | |||
| 46 | + | t.Fatal(err) | |||
| 47 | + | } | |||
| 48 | + | if args.Mode != ModePrint || args.StatsPath != "stats.json" { | |||
| 49 | + | t.Fatalf("Mode=%q StatsPath=%q, want print mode with stats path", args.Mode, args.StatsPath) | |||
| 50 | + | } | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | func TestCombinePromptInput(t *testing.T) { | |||
| 54 | + | tests := []struct { | |||
| 55 | + | name string | |||
| 56 | + | stdin string | |||
| 57 | + | positional string | |||
| 58 | + | want string | |||
| 59 | + | }{ | |||
| 60 | + | {name: "stdin and instruction", stdin: "input text\n", positional: "summarize it", want: "input text\nsummarize it"}, | |||
| 61 | + | {name: "stdin only", stdin: " input text\n", want: "input text"}, | |||
| 62 | + | {name: "positional only", positional: " summarize it ", want: "summarize it"}, | |||
| 63 | + | {name: "empty", stdin: " \n", positional: " ", want: ""}, | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | for _, tc := range tests { | |||
| 67 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 68 | + | if got := combinePromptInput(tc.stdin, tc.positional); got != tc.want { | |||
| 69 | + | t.Fatalf("combinePromptInput(%q, %q) = %q, want %q", tc.stdin, tc.positional, got, tc.want) | |||
| 70 | + | } | |||
| 71 | + | }) | |||
| 72 | + | } | |||
| 73 | + | } | |||