Write requested help to stdout
- commit
- a99532125e3d969741a756adcf4d09359668e708
- author
- patriceckhart · · 2026-08-21 17:37 UTC
- parent
- b3121d6
- signature
- unsigned
3 files · +78 -20
M packages/agent/args.go +22 -18
| @@ -325,12 +325,16 @@ func ParseArgs(in []string) (Args, error) { | |||
| 325 | 325 | return a, nil | |
| 326 | 326 | } | |
| 327 | 327 | ||
| 328 | - | // PrintHelp writes the help text to stderr. When stderr is a TTY it | |
| 329 | - | // uses the same palette as zot's TUI; when redirected it falls back to | |
| 330 | - | // plain text with no ANSI escapes. | |
| 328 | + | // PrintHelp writes the help text to stderr for argument-error paths. | |
| 331 | 329 | func PrintHelp(version string) { | |
| 330 | + | printHelp(os.Stderr, version) | |
| 331 | + | } | |
| 332 | + | ||
| 333 | + | // printHelp writes help to out. When out is a TTY it uses the same palette as | |
| 334 | + | // zot's TUI; when redirected it falls back to plain text with no ANSI escapes. | |
| 335 | + | func printHelp(out *os.File, version string) { | |
| 332 | 336 | th := tui.Dark | |
| 333 | - | fd := int(os.Stderr.Fd()) | |
| 337 | + | fd := int(out.Fd()) | |
| 334 | 338 | useColor := term.IsTerminal(fd) | |
| 335 | 339 | style := func(c int, s string) string { | |
| 336 | 340 | if !useColor { | |
| @@ -364,35 +368,35 @@ func PrintHelp(version string) { | |||
| 364 | 368 | } | |
| 365 | 369 | type row struct{ left, right string } | |
| 366 | 370 | section := func(title string, rows ...row) { | |
| 367 | - | fmt.Fprintln(os.Stderr) | |
| 368 | - | fmt.Fprintln(os.Stderr, assistant(title)) | |
| 369 | - | fmt.Fprintln(os.Stderr, rule) | |
| 371 | + | fmt.Fprintln(out) | |
| 372 | + | fmt.Fprintln(out, assistant(title)) | |
| 373 | + | fmt.Fprintln(out, rule) | |
| 370 | 374 | narrow := width < 100 | |
| 371 | 375 | for _, r := range rows { | |
| 372 | 376 | if narrow { | |
| 373 | - | fmt.Fprintf(os.Stderr, " %s\n", fg(r.left)) | |
| 374 | - | fmt.Fprintf(os.Stderr, " %s\n", muted(r.right)) | |
| 375 | - | fmt.Fprintln(os.Stderr) | |
| 377 | + | fmt.Fprintf(out, " %s\n", fg(r.left)) | |
| 378 | + | fmt.Fprintf(out, " %s\n", muted(r.right)) | |
| 379 | + | fmt.Fprintln(out) | |
| 376 | 380 | continue | |
| 377 | 381 | } | |
| 378 | 382 | left := r.left | |
| 379 | 383 | if len([]rune(left)) < leftW { | |
| 380 | 384 | left += strings.Repeat(" ", leftW-len([]rune(left))) | |
| 381 | 385 | } | |
| 382 | - | fmt.Fprintf(os.Stderr, " %s %s\n", fg(left), muted(r.right)) | |
| 386 | + | fmt.Fprintf(out, " %s %s\n", fg(left), muted(r.right)) | |
| 383 | 387 | } | |
| 384 | 388 | } | |
| 385 | 389 | ||
| 386 | - | fmt.Fprintln(os.Stderr) | |
| 390 | + | fmt.Fprintln(out) | |
| 387 | 391 | var headline string | |
| 388 | 392 | if useColor { | |
| 389 | 393 | headline = th.AccentBar(th.Assistant) + assistant(tui.Bold("zot. yet another coding agent harness.")) | |
| 390 | 394 | } else { | |
| 391 | 395 | headline = "zot. yet another coding agent harness." | |
| 392 | 396 | } | |
| 393 | - | fmt.Fprintln(os.Stderr, headline) | |
| 394 | - | fmt.Fprintln(os.Stderr, muted("ask anything, or type /help inside the tui to see commands.")) | |
| 395 | - | fmt.Fprintf(os.Stderr, "%s %s\n", muted("version:"), fg(version)) | |
| 397 | + | fmt.Fprintln(out, headline) | |
| 398 | + | fmt.Fprintln(out, muted("ask anything, or type /help inside the tui to see commands.")) | |
| 399 | + | fmt.Fprintf(out, "%s %s\n", muted("version:"), fg(version)) | |
| 396 | 400 | ||
| 397 | 401 | section("modes", | |
| 398 | 402 | row{"zot", "interactive tui"}, | |
| @@ -457,7 +461,7 @@ func PrintHelp(version string) { | |||
| 457 | 461 | row{"-h, --help", "show this help"}, | |
| 458 | 462 | row{"-v, --version", "show version info"}, | |
| 459 | 463 | ) | |
| 460 | - | fmt.Fprintln(os.Stderr) | |
| 461 | - | fmt.Fprintln(os.Stderr, assistant("see also: docs/extensions.md, docs/rpc.md, docs/skills.md")) | |
| 462 | - | fmt.Fprintln(os.Stderr) | |
| 464 | + | fmt.Fprintln(out) | |
| 465 | + | fmt.Fprintln(out, assistant("see also: docs/extensions.md, docs/rpc.md, docs/skills.md")) | |
| 466 | + | fmt.Fprintln(out) | |
| 463 | 467 | } | |
| @@ -325,12 +325,16 @@ func ParseArgs(in []string) (Args, error) { | |||||
| 325 | return a, nil | 325 | return a, nil | ||
| 326 | } | 326 | } | ||
| 327 | 327 | ||||
| 328 | - | // PrintHelp writes the help text to stderr. When stderr is a TTY it | 328 | + | // PrintHelp writes the help text to stderr for argument-error paths. |
| 329 | - | // uses the same palette as zot's TUI; when redirected it falls back to | |||
| 330 | - | // plain text with no ANSI escapes. | |||
| 331 | func PrintHelp(version string) { | 329 | func PrintHelp(version string) { | ||
| 330 | + | printHelp(os.Stderr, version) | |||
| 331 | + | } | |||
| 332 | + | ||||
| 333 | + | // printHelp writes help to out. When out is a TTY it uses the same palette as | |||
| 334 | + | // zot's TUI; when redirected it falls back to plain text with no ANSI escapes. | |||
| 335 | + | func printHelp(out *os.File, version string) { | |||
| 332 | th := tui.Dark | 336 | th := tui.Dark | ||
| 333 | - | fd := int(os.Stderr.Fd()) | 337 | + | fd := int(out.Fd()) |
| 334 | useColor := term.IsTerminal(fd) | 338 | useColor := term.IsTerminal(fd) | ||
| 335 | style := func(c int, s string) string { | 339 | style := func(c int, s string) string { | ||
| 336 | if !useColor { | 340 | if !useColor { | ||
| @@ -364,35 +368,35 @@ func PrintHelp(version string) { | |||||
| 364 | } | 368 | } | ||
| 365 | type row struct{ left, right string } | 369 | type row struct{ left, right string } | ||
| 366 | section := func(title string, rows ...row) { | 370 | section := func(title string, rows ...row) { | ||
| 367 | - | fmt.Fprintln(os.Stderr) | 371 | + | fmt.Fprintln(out) |
| 368 | - | fmt.Fprintln(os.Stderr, assistant(title)) | 372 | + | fmt.Fprintln(out, assistant(title)) |
| 369 | - | fmt.Fprintln(os.Stderr, rule) | 373 | + | fmt.Fprintln(out, rule) |
| 370 | narrow := width < 100 | 374 | narrow := width < 100 | ||
| 371 | for _, r := range rows { | 375 | for _, r := range rows { | ||
| 372 | if narrow { | 376 | if narrow { | ||
| 373 | - | fmt.Fprintf(os.Stderr, " %s\n", fg(r.left)) | 377 | + | fmt.Fprintf(out, " %s\n", fg(r.left)) |
| 374 | - | fmt.Fprintf(os.Stderr, " %s\n", muted(r.right)) | 378 | + | fmt.Fprintf(out, " %s\n", muted(r.right)) |
| 375 | - | fmt.Fprintln(os.Stderr) | 379 | + | fmt.Fprintln(out) |
| 376 | continue | 380 | continue | ||
| 377 | } | 381 | } | ||
| 378 | left := r.left | 382 | left := r.left | ||
| 379 | if len([]rune(left)) < leftW { | 383 | if len([]rune(left)) < leftW { | ||
| 380 | left += strings.Repeat(" ", leftW-len([]rune(left))) | 384 | left += strings.Repeat(" ", leftW-len([]rune(left))) | ||
| 381 | } | 385 | } | ||
| 382 | - | fmt.Fprintf(os.Stderr, " %s %s\n", fg(left), muted(r.right)) | 386 | + | fmt.Fprintf(out, " %s %s\n", fg(left), muted(r.right)) |
| 383 | } | 387 | } | ||
| 384 | } | 388 | } | ||
| 385 | 389 | ||||
| 386 | - | fmt.Fprintln(os.Stderr) | 390 | + | fmt.Fprintln(out) |
| 387 | var headline string | 391 | var headline string | ||
| 388 | if useColor { | 392 | if useColor { | ||
| 389 | headline = th.AccentBar(th.Assistant) + assistant(tui.Bold("zot. yet another coding agent harness.")) | 393 | headline = th.AccentBar(th.Assistant) + assistant(tui.Bold("zot. yet another coding agent harness.")) | ||
| 390 | } else { | 394 | } else { | ||
| 391 | headline = "zot. yet another coding agent harness." | 395 | headline = "zot. yet another coding agent harness." | ||
| 392 | } | 396 | } | ||
| 393 | - | fmt.Fprintln(os.Stderr, headline) | 397 | + | fmt.Fprintln(out, headline) |
| 394 | - | fmt.Fprintln(os.Stderr, muted("ask anything, or type /help inside the tui to see commands.")) | 398 | + | fmt.Fprintln(out, muted("ask anything, or type /help inside the tui to see commands.")) |
| 395 | - | fmt.Fprintf(os.Stderr, "%s %s\n", muted("version:"), fg(version)) | 399 | + | fmt.Fprintf(out, "%s %s\n", muted("version:"), fg(version)) |
| 396 | 400 | ||||
| 397 | section("modes", | 401 | section("modes", | ||
| 398 | row{"zot", "interactive tui"}, | 402 | row{"zot", "interactive tui"}, | ||
| @@ -457,7 +461,7 @@ func PrintHelp(version string) { | |||||
| 457 | row{"-h, --help", "show this help"}, | 461 | row{"-h, --help", "show this help"}, | ||
| 458 | row{"-v, --version", "show version info"}, | 462 | row{"-v, --version", "show version info"}, | ||
| 459 | ) | 463 | ) | ||
| 460 | - | fmt.Fprintln(os.Stderr) | 464 | + | fmt.Fprintln(out) |
| 461 | - | fmt.Fprintln(os.Stderr, assistant("see also: docs/extensions.md, docs/rpc.md, docs/skills.md")) | 465 | + | fmt.Fprintln(out, assistant("see also: docs/extensions.md, docs/rpc.md, docs/skills.md")) |
| 462 | - | fmt.Fprintln(os.Stderr) | 466 | + | fmt.Fprintln(out) |
| 463 | } | 467 | } | ||
M packages/agent/args_test.go +55 -1
| @@ -1,6 +1,12 @@ | |||
| 1 | 1 | package agent | |
| 2 | 2 | ||
| 3 | - | import "testing" | |
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "os" | |
| 6 | + | "os/exec" | |
| 7 | + | "strings" | |
| 8 | + | "testing" | |
| 9 | + | ) | |
| 4 | 10 | ||
| 5 | 11 | func TestParseArgsExplicitEmptySystemPromptIsSet(t *testing.T) { | |
| 6 | 12 | args, err := ParseArgs([]string{"--system-prompt", ""}) | |
| @@ -81,3 +87,51 @@ func TestParseArgsStream(t *testing.T) { | |||
| 81 | 87 | t.Fatalf("Mode=%q Prompt=%q", args.Mode, args.Prompt) | |
| 82 | 88 | } | |
| 83 | 89 | } | |
| 90 | + | ||
| 91 | + | func TestRunHelpHelperProcess(t *testing.T) { | |
| 92 | + | if os.Getenv("ZOT_HELP_HELPER") == "" { | |
| 93 | + | return | |
| 94 | + | } | |
| 95 | + | if err := runWithArgsRaw(strings.Fields(os.Getenv("ZOT_HELP_HELPER")), "test"); err != nil { | |
| 96 | + | os.Exit(1) | |
| 97 | + | } | |
| 98 | + | os.Exit(0) | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | func TestHelpOutputStreams(t *testing.T) { | |
| 102 | + | run := func(args string) (stdout, stderr string, err error) { | |
| 103 | + | t.Helper() | |
| 104 | + | cmd := exec.Command(os.Args[0], "-test.run=^TestRunHelpHelperProcess$") | |
| 105 | + | cmd.Env = append(os.Environ(), "ZOT_HELP_HELPER="+args) | |
| 106 | + | var outBuf, errBuf bytes.Buffer | |
| 107 | + | cmd.Stdout = &outBuf | |
| 108 | + | cmd.Stderr = &errBuf | |
| 109 | + | err = cmd.Run() | |
| 110 | + | return outBuf.String(), errBuf.String(), err | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | stdout, stderr, err := run("--help") | |
| 114 | + | if err != nil { | |
| 115 | + | t.Fatalf("--help returned %v; stderr:\n%s", err, stderr) | |
| 116 | + | } | |
| 117 | + | if !strings.Contains(stdout, "zot. yet another coding agent harness.") { | |
| 118 | + | t.Fatalf("stdout does not contain help text:\n%s", stdout) | |
| 119 | + | } | |
| 120 | + | if stderr != "" { | |
| 121 | + | t.Fatalf("stderr = %q, want empty", stderr) | |
| 122 | + | } | |
| 123 | + | if strings.Contains(stdout, "\x1b[") { | |
| 124 | + | t.Fatalf("redirected help contains ANSI escapes: %q", stdout) | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | stdout, stderr, err = run("--unknown") | |
| 128 | + | if err == nil { | |
| 129 | + | t.Fatal("unknown flag exited successfully") | |
| 130 | + | } | |
| 131 | + | if stdout != "" { | |
| 132 | + | t.Fatalf("stdout = %q, want empty for an argument error", stdout) | |
| 133 | + | } | |
| 134 | + | if !strings.Contains(stderr, "zot. yet another coding agent harness.") { | |
| 135 | + | t.Fatalf("stderr does not contain help text:\n%s", stderr) | |
| 136 | + | } | |
| 137 | + | } | |
| @@ -1,6 +1,12 @@ | |||||
| 1 | package agent | 1 | package agent | ||
| 2 | 2 | ||||
| 3 | - | import "testing" | 3 | + | import ( |
| 4 | + | "bytes" | |||
| 5 | + | "os" | |||
| 6 | + | "os/exec" | |||
| 7 | + | "strings" | |||
| 8 | + | "testing" | |||
| 9 | + | ) | |||
| 4 | 10 | ||||
| 5 | func TestParseArgsExplicitEmptySystemPromptIsSet(t *testing.T) { | 11 | func TestParseArgsExplicitEmptySystemPromptIsSet(t *testing.T) { | ||
| 6 | args, err := ParseArgs([]string{"--system-prompt", ""}) | 12 | args, err := ParseArgs([]string{"--system-prompt", ""}) | ||
| @@ -81,3 +87,51 @@ func TestParseArgsStream(t *testing.T) { | |||||
| 81 | t.Fatalf("Mode=%q Prompt=%q", args.Mode, args.Prompt) | 87 | t.Fatalf("Mode=%q Prompt=%q", args.Mode, args.Prompt) | ||
| 82 | } | 88 | } | ||
| 83 | } | 89 | } | ||
| 90 | + | ||||
| 91 | + | func TestRunHelpHelperProcess(t *testing.T) { | |||
| 92 | + | if os.Getenv("ZOT_HELP_HELPER") == "" { | |||
| 93 | + | return | |||
| 94 | + | } | |||
| 95 | + | if err := runWithArgsRaw(strings.Fields(os.Getenv("ZOT_HELP_HELPER")), "test"); err != nil { | |||
| 96 | + | os.Exit(1) | |||
| 97 | + | } | |||
| 98 | + | os.Exit(0) | |||
| 99 | + | } | |||
| 100 | + | ||||
| 101 | + | func TestHelpOutputStreams(t *testing.T) { | |||
| 102 | + | run := func(args string) (stdout, stderr string, err error) { | |||
| 103 | + | t.Helper() | |||
| 104 | + | cmd := exec.Command(os.Args[0], "-test.run=^TestRunHelpHelperProcess$") | |||
| 105 | + | cmd.Env = append(os.Environ(), "ZOT_HELP_HELPER="+args) | |||
| 106 | + | var outBuf, errBuf bytes.Buffer | |||
| 107 | + | cmd.Stdout = &outBuf | |||
| 108 | + | cmd.Stderr = &errBuf | |||
| 109 | + | err = cmd.Run() | |||
| 110 | + | return outBuf.String(), errBuf.String(), err | |||
| 111 | + | } | |||
| 112 | + | ||||
| 113 | + | stdout, stderr, err := run("--help") | |||
| 114 | + | if err != nil { | |||
| 115 | + | t.Fatalf("--help returned %v; stderr:\n%s", err, stderr) | |||
| 116 | + | } | |||
| 117 | + | if !strings.Contains(stdout, "zot. yet another coding agent harness.") { | |||
| 118 | + | t.Fatalf("stdout does not contain help text:\n%s", stdout) | |||
| 119 | + | } | |||
| 120 | + | if stderr != "" { | |||
| 121 | + | t.Fatalf("stderr = %q, want empty", stderr) | |||
| 122 | + | } | |||
| 123 | + | if strings.Contains(stdout, "\x1b[") { | |||
| 124 | + | t.Fatalf("redirected help contains ANSI escapes: %q", stdout) | |||
| 125 | + | } | |||
| 126 | + | ||||
| 127 | + | stdout, stderr, err = run("--unknown") | |||
| 128 | + | if err == nil { | |||
| 129 | + | t.Fatal("unknown flag exited successfully") | |||
| 130 | + | } | |||
| 131 | + | if stdout != "" { | |||
| 132 | + | t.Fatalf("stdout = %q, want empty for an argument error", stdout) | |||
| 133 | + | } | |||
| 134 | + | if !strings.Contains(stderr, "zot. yet another coding agent harness.") { | |||
| 135 | + | t.Fatalf("stderr does not contain help text:\n%s", stderr) | |||
| 136 | + | } | |||
| 137 | + | } | |||
M packages/agent/cli.go +1 -1
| @@ -231,7 +231,7 @@ func runWithArgsRaw(rawArgs []string, version string) error { | |||
| 231 | 231 | return err | |
| 232 | 232 | } | |
| 233 | 233 | if args.Help { | |
| 234 | - | PrintHelp(version) | |
| 234 | + | printHelp(os.Stdout, version) | |
| 235 | 235 | return nil | |
| 236 | 236 | } | |
| 237 | 237 | if args.Version { | |
| @@ -231,7 +231,7 @@ func runWithArgsRaw(rawArgs []string, version string) error { | |||||
| 231 | return err | 231 | return err | ||
| 232 | } | 232 | } | ||
| 233 | if args.Help { | 233 | if args.Help { | ||
| 234 | - | PrintHelp(version) | 234 | + | printHelp(os.Stdout, version) |
| 235 | return nil | 235 | return nil | ||
| 236 | } | 236 | } | ||
| 237 | if args.Version { | 237 | if args.Version { | ||