Infer print mode from piped stdin
- commit
- f0f836b5bf1ac6905fc656497798eea8a2aec6bf
- author
- patriceckhart · · 2026-08-07 05:23 UTC
- parent
- 086a9fe
- signature
- unsigned
- refs
- tag: v0.3.37
4 files · +62 -2
M README.md +2 -1
| @@ -201,6 +201,7 @@ The first time you launch a newer zot binary, the TUI shows the GitHub release n | |||
| 201 | 201 | zot # interactive tui | |
| 202 | 202 | zot "fix the failing test" # tui, pre-filled prompt | |
| 203 | 203 | zot -p "list all go files" # print final text, exit | |
| 204 | + | echo "list all go files" | zot # piped stdin implies print mode | |
| 204 | 205 | cat README.md | zot -p "summarize this text" # combine stdin with the prompt | |
| 205 | 206 | zot -p --stats stats.json "task" # print final text and write generation stats | |
| 206 | 207 | zot --json "refactor main.go" # newline-delimited json events, exit | |
| @@ -253,7 +254,7 @@ When the sandbox is on (see `/jail`), all four tools refuse paths outside the se | |||
| 253 | 254 | - **Interactive** (default): chat TUI with streaming output, spinner, cost meter, slash commands. | |
| 254 | 255 | - **Print**: `zot -p "prompt"` runs the agent to completion and writes only the final assistant text to stdout. | |
| 255 | 256 | - **Stream**: `zot --stream "prompt"` runs without the TUI and writes assistant text to stdout as it arrives. Tool activity goes to stderr. | |
| 256 | - | - **Piped input**: print, stream, and JSON modes prepend piped stdin to the positional prompt, separated by a newline. For example, `cat README.md | zot -p "summarize this text"`. | |
| 257 | + | - **Piped input**: when no mode is specified, piped stdin selects print mode. Print, stream, and JSON modes prepend piped stdin to the positional prompt, separated by a newline. For example, `echo "list all go files" | zot` or `cat README.md | zot -p "summarize this text"`. | |
| 257 | 258 | - **JSON**: `zot --json "prompt"` emits one JSON object per agent event to stdout, newline-delimited. The schema is documented in [docs/rpc.md](docs/rpc.md). | |
| 258 | 259 | - **RPC**: `zot rpc` runs as a long-lived child process; commands in on stdin, events and responses out on stdout, both as NDJSON. Designed for embedding zot 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. | |
| 259 | 260 | ||
| @@ -201,6 +201,7 @@ The first time you launch a newer zot binary, the TUI shows the GitHub release n | |||||
| 201 | zot # interactive tui | 201 | zot # interactive tui | ||
| 202 | zot "fix the failing test" # tui, pre-filled prompt | 202 | zot "fix the failing test" # tui, pre-filled prompt | ||
| 203 | zot -p "list all go files" # print final text, exit | 203 | zot -p "list all go files" # print final text, exit | ||
| 204 | + | echo "list all go files" | zot # piped stdin implies print mode | |||
| 204 | cat README.md | zot -p "summarize this text" # combine stdin with the prompt | 205 | cat README.md | zot -p "summarize this text" # combine stdin with the prompt | ||
| 205 | zot -p --stats stats.json "task" # print final text and write generation stats | 206 | zot -p --stats stats.json "task" # print final text and write generation stats | ||
| 206 | zot --json "refactor main.go" # newline-delimited json events, exit | 207 | zot --json "refactor main.go" # newline-delimited json events, exit | ||
| @@ -253,7 +254,7 @@ When the sandbox is on (see `/jail`), all four tools refuse paths outside the se | |||||
| 253 | - **Interactive** (default): chat TUI with streaming output, spinner, cost meter, slash commands. | 254 | - **Interactive** (default): chat TUI with streaming output, spinner, cost meter, slash commands. | ||
| 254 | - **Print**: `zot -p "prompt"` runs the agent to completion and writes only the final assistant text to stdout. | 255 | - **Print**: `zot -p "prompt"` runs the agent to completion and writes only the final assistant text to stdout. | ||
| 255 | - **Stream**: `zot --stream "prompt"` runs without the TUI and writes assistant text to stdout as it arrives. Tool activity goes to stderr. | 256 | - **Stream**: `zot --stream "prompt"` runs without the TUI and writes assistant text to stdout as it arrives. Tool activity goes to stderr. | ||
| 256 | - | - **Piped input**: print, stream, and JSON modes prepend piped stdin to the positional prompt, separated by a newline. For example, `cat README.md | zot -p "summarize this text"`. | 257 | + | - **Piped input**: when no mode is specified, piped stdin selects print mode. Print, stream, and JSON modes prepend piped stdin to the positional prompt, separated by a newline. For example, `echo "list all go files" | zot` or `cat README.md | zot -p "summarize this text"`. |
| 257 | - **JSON**: `zot --json "prompt"` emits one JSON object per agent event to stdout, newline-delimited. The schema is documented in [docs/rpc.md](docs/rpc.md). | 258 | - **JSON**: `zot --json "prompt"` emits one JSON object per agent event to stdout, newline-delimited. The schema is documented in [docs/rpc.md](docs/rpc.md). | ||
| 258 | - **RPC**: `zot rpc` runs as a long-lived child process; commands in on stdin, events and responses out on stdout, both as NDJSON. Designed for embedding zot 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. | 259 | - **RPC**: `zot rpc` runs as a long-lived child process; commands in on stdin, events and responses out on stdout, both as NDJSON. Designed for embedding zot 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. | ||
| 259 | 260 | ||||
M packages/agent/args.go +1 -0
| @@ -394,6 +394,7 @@ func PrintHelp(version string) { | |||
| 394 | 394 | row{"zot", "interactive tui"}, | |
| 395 | 395 | row{"zot \"prompt\"", "interactive, pre-filled prompt"}, | |
| 396 | 396 | row{"zot -p \"prompt\"", "print final text, exit"}, | |
| 397 | + | row{"echo \"prompt\" | zot", "piped stdin implies print mode"}, | |
| 397 | 398 | row{"zot --stream \"prompt\"", "stream assistant text live, exit"}, | |
| 398 | 399 | row{"zot --json \"prompt\"", "newline-delimited json events, exit"}, | |
| 399 | 400 | row{"zot rpc", "json-rpc loop on stdin/stdout (see docs/rpc.md)"}, | |
| @@ -394,6 +394,7 @@ func PrintHelp(version string) { | |||||
| 394 | row{"zot", "interactive tui"}, | 394 | row{"zot", "interactive tui"}, | ||
| 395 | row{"zot \"prompt\"", "interactive, pre-filled prompt"}, | 395 | row{"zot \"prompt\"", "interactive, pre-filled prompt"}, | ||
| 396 | row{"zot -p \"prompt\"", "print final text, exit"}, | 396 | row{"zot -p \"prompt\"", "print final text, exit"}, | ||
| 397 | + | row{"echo \"prompt\" | zot", "piped stdin implies print mode"}, | |||
| 397 | row{"zot --stream \"prompt\"", "stream assistant text live, exit"}, | 398 | row{"zot --stream \"prompt\"", "stream assistant text live, exit"}, | ||
| 398 | row{"zot --json \"prompt\"", "newline-delimited json events, exit"}, | 399 | row{"zot --json \"prompt\"", "newline-delimited json events, exit"}, | ||
| 399 | row{"zot rpc", "json-rpc loop on stdin/stdout (see docs/rpc.md)"}, | 400 | row{"zot rpc", "json-rpc loop on stdin/stdout (see docs/rpc.md)"}, | ||
M packages/agent/cli.go +10 -0
| @@ -222,6 +222,9 @@ func runWithArgsRaw(rawArgs []string, version string) error { | |||
| 222 | 222 | rawArgs = append([]string{"--rpc"}, rawArgs[1:]...) | |
| 223 | 223 | } | |
| 224 | 224 | ||
| 225 | + | if fi, err := os.Stdin.Stat(); err == nil { | |
| 226 | + | rawArgs = argsForStdin(rawArgs, fi.Mode()) | |
| 227 | + | } | |
| 225 | 228 | args, err := ParseArgs(rawArgs) | |
| 226 | 229 | if err != nil { | |
| 227 | 230 | PrintHelp(version) | |
| @@ -1571,6 +1574,13 @@ func writeNewTranscriptLocked(ag *core.Agent, sess *core.Session, from int) { | |||
| 1571 | 1574 | _ = sess.AppendUsage(cum, cum) | |
| 1572 | 1575 | } | |
| 1573 | 1576 | ||
| 1577 | + | func argsForStdin(rawArgs []string, mode os.FileMode) []string { | |
| 1578 | + | if mode&os.ModeCharDevice != 0 { | |
| 1579 | + | return rawArgs | |
| 1580 | + | } | |
| 1581 | + | return append([]string{"--print"}, rawArgs...) | |
| 1582 | + | } | |
| 1583 | + | ||
| 1574 | 1584 | func combinePromptInput(stdin, positional string) string { | |
| 1575 | 1585 | parts := make([]string, 0, 2) | |
| 1576 | 1586 | if stdin = strings.TrimSpace(stdin); stdin != "" { | |
| @@ -222,6 +222,9 @@ func runWithArgsRaw(rawArgs []string, version string) error { | |||||
| 222 | rawArgs = append([]string{"--rpc"}, rawArgs[1:]...) | 222 | rawArgs = append([]string{"--rpc"}, rawArgs[1:]...) | ||
| 223 | } | 223 | } | ||
| 224 | 224 | ||||
| 225 | + | if fi, err := os.Stdin.Stat(); err == nil { | |||
| 226 | + | rawArgs = argsForStdin(rawArgs, fi.Mode()) | |||
| 227 | + | } | |||
| 225 | args, err := ParseArgs(rawArgs) | 228 | args, err := ParseArgs(rawArgs) | ||
| 226 | if err != nil { | 229 | if err != nil { | ||
| 227 | PrintHelp(version) | 230 | PrintHelp(version) | ||
| @@ -1571,6 +1574,13 @@ func writeNewTranscriptLocked(ag *core.Agent, sess *core.Session, from int) { | |||||
| 1571 | _ = sess.AppendUsage(cum, cum) | 1574 | _ = sess.AppendUsage(cum, cum) | ||
| 1572 | } | 1575 | } | ||
| 1573 | 1576 | ||||
| 1577 | + | func argsForStdin(rawArgs []string, mode os.FileMode) []string { | |||
| 1578 | + | if mode&os.ModeCharDevice != 0 { | |||
| 1579 | + | return rawArgs | |||
| 1580 | + | } | |||
| 1581 | + | return append([]string{"--print"}, rawArgs...) | |||
| 1582 | + | } | |||
| 1583 | + | ||||
| 1574 | func combinePromptInput(stdin, positional string) string { | 1584 | func combinePromptInput(stdin, positional string) string { | ||
| 1575 | parts := make([]string, 0, 2) | 1585 | parts := make([]string, 0, 2) | ||
| 1576 | if stdin = strings.TrimSpace(stdin); stdin != "" { | 1586 | if stdin = strings.TrimSpace(stdin); stdin != "" { | ||
M packages/agent/prompt_input_test.go +49 -1
| @@ -1,6 +1,54 @@ | |||
| 1 | 1 | package agent | |
| 2 | 2 | ||
| 3 | - | import "testing" | |
| 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 | + | } | |
| 4 | 52 | ||
| 5 | 53 | func TestCombinePromptInput(t *testing.T) { | |
| 6 | 54 | tests := []struct { | |
| @@ -1,6 +1,54 @@ | |||||
| 1 | package agent | 1 | package agent | ||
| 2 | 2 | ||||
| 3 | - | import "testing" | 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 | + | } | |||
| 4 | 52 | ||||
| 5 | func TestCombinePromptInput(t *testing.T) { | 53 | func TestCombinePromptInput(t *testing.T) { | ||
| 6 | tests := []struct { | 54 | tests := []struct { | ||