1package agent
2
3import (
4 "fmt"
5 "os"
6 "strconv"
7 "strings"
8 "time"
9
10 "github.com/bnema/zut/packages/agent/subagents"
11 "github.com/bnema/zut/packages/agent/tools"
12 "github.com/bnema/zut/packages/tui"
13 "github.com/google/uuid"
14 "golang.org/x/term"
15)
16
17// Mode is the CLI run mode.
18type Mode string
19
20const (
21 ModeInteractive Mode = "interactive"
22 ModePrint Mode = "print"
23 ModeStream Mode = "stream"
24 ModeJSON Mode = "json"
25 ModeRPC Mode = "rpc"
26 // ModeSubagentWorker is the long-lived, headless daemon mode used by
27 // subagent-spawned agents. The binary opens a unix-socket inbox at
28 // the path provided by --subagent-worker, reads versioned JSONL
29 // supervisor messages, runs each user turn against a persistent session,
30 // and streams JSONL events on
31 // stdout.
32 ModeSubagentWorker Mode = "subagent-worker"
33)
34
35// Args holds parsed command-line options.
36type Args struct {
37 Mode Mode
38 Orchestrate bool
39 Provider string
40 Model string
41 APIKey string
42
43 // inheritedCredential is populated only by subagent-worker mode from
44 // the supervisor's stdin. It is never accepted as a CLI argument.
45 inheritedCredential string
46 inheritedAuthMethod string
47 inheritedAccountID string
48
49 BaseURL string // override provider base URL (for tests/self-hosted)
50 SystemPrompt string
51 AppendSystemPrompt []string
52 Reasoning string
53 Temperature *float32
54
55 // FastMode is an internal subagent-child propagation flag. Normal users
56 // enable fast mode through the persisted config /settings toggle.
57 FastMode bool
58 // FastModeSet distinguishes an explicit child override (including
59 // --no-fast-mode) from an omitted flag so persisted subagent settings
60 // survive a parent config change.
61 FastModeSet bool
62
63 Continue bool
64 Resume bool
65 ResumeSessionID string
66 Session string
67 NoSess bool
68
69 CWD string
70 NoTools bool
71 NoLSP bool
72 Tools []string
73 // ToolsSet distinguishes an omitted --tools flag from an explicitly
74 // supplied empty list. Other built-in tools intentionally retain their
75 // historical empty-list behavior; web search uses this provenance as a
76 // capability allowlist.
77 ToolsSet bool
78
79 // WebSearchPolicy is an internal capability override. Normal CLI args
80 // leave it at Inherit so Resolve applies config and --tools provenance;
81 // SDK, bot, and subagent-worker callers set it explicitly.
82 WebSearchPolicy subagents.WebSearchPolicy
83 MaxSteps int
84
85 // Exts is a list of directory paths the user passed via --ext.
86 // Each must contain an extension.json. Loaded for one session
87 // only; never persisted. Take precedence over installed exts of
88 // the same name.
89 Exts []string
90
91 // NoExt disables extension discovery + spawn entirely for this
92 // run. --ext PATH still works (explicit beats implicit) so you
93 // can run "with only this one extension" via --no-ext --ext PATH.
94 NoExt bool
95
96 // NoSkill disables ALL skill discovery for this run, including
97 // the built-in skills compiled into the binary. The system
98 // prompt loses its "Available skills" manifest and the `skill`
99 // tool isn't registered. Useful for running zut without any
100 // extra context biasing the model.
101 NoSkill bool
102
103 // WithSkills controls loading user-installed skills from
104 // $ZUT_HOME/skills/, .zut/skills/, .claude/skills/, and
105 // .agents/skills/. It defaults to true; --no-skill disables all
106 // skill discovery, including built-ins.
107 WithSkills bool
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
113 // InsecureTLS skips TLS verification for custom inference endpoints.
114 InsecureTLS bool
115
116 // NoYolo turns on per-tool confirmation. Before each tool
117 // invocation the TUI prompts the user with the tool name + args
118 // and waits for an explicit yes/no. The user can also pick
119 // "always for this tool this session" or "always for anything
120 // this session" to stop being prompted again. Defaults off
121 // (yolo mode): tools run without asking.
122 //
123 // No effect in -p / --json / rpc modes, which have no
124 // interactive prompt. A warning is printed to stderr on startup
125 // so scripts know the flag is ignored, but tools still run
126 // freely so automated workflows keep working.
127 NoYolo bool
128
129 // Yes accepts zutfile launch consent without an interactive
130 // Allow? prompt (zut run -y / --yes). Durable consent receipts
131 // are still written for modes other than bash ask.
132 Yes bool
133
134 ListModels bool
135 Help bool
136 Version bool
137 StatsPath string // write print-mode generation statistics as JSON
138
139 Prompt string // concatenated positional args
140
141 // StartupPre is an optional zutfile entry.pre value. Interactive
142 // mode auto-submits it once at startup before InitialInput handling.
143 StartupPre string
144
145 // SubagentWorker is the inbox-socket path when this process is a
146 // subagent worker. Empty in every other mode. Set by either
147 // --subagent-worker.
148 SubagentWorker string
149
150 // SubagentTurnTimeout limits each active delegated turn while leaving the
151 // worker process alive between turns for explicit follow-ups.
152 SubagentTurnTimeout time.Duration
153 // SubagentLifetimeTurns and SubagentRunTurns are supervisor-provided
154 // counters used to resume a worker without losing observability or its
155 // current-run budget. They are internal worker flags, not user policy.
156 SubagentLifetimeTurns int
157 SubagentRunTurns int
158
159 // Subagent selects a named markdown profile for a subagent child.
160 // It is intentionally an internal child flag; the parent subagent tool
161 // passes only the profile name and the child discovers the definition
162 // locally.
163 Subagent string
164
165 // AgentName/AgentDataDir/PermissionSet are populated by `zut run`
166 // for local zutfile agents. They scope sessions and enforce the
167 // manifest's declared file/bash permissions.
168 AgentName string
169 AgentDataDir string
170 PermissionSet *tools.PermissionSet
171}
172
173// ParseArgs parses the process arguments (excluding argv[0]).
174func ParseArgs(in []string) (Args, error) {
175 a := Args{Mode: ModeInteractive, MaxSteps: 0, WithSkills: true}
176 positional := []string{}
177 webSearchPolicySet := false
178
179 want := func(i *int, flag string) (string, error) {
180 *i++
181 if *i >= len(in) {
182 return "", fmt.Errorf("%s requires a value", flag)
183 }
184 return in[*i], nil
185 }
186
187 for i := 0; i < len(in); i++ {
188 arg := in[i]
189 switch arg {
190 case "--":
191 // Everything after the terminator is prompt text, including
192 // values that begin with a dash. This is used by subagent
193 // workers and is also a conventional CLI escape hatch.
194 positional = append(positional, in[i+1:]...)
195 i = len(in)
196 case "-h", "--help":
197 a.Help = true
198 case "-v", "--version":
199 a.Version = true
200 case "-p", "--print":
201 a.Mode = ModePrint
202 case "--stream":
203 a.Mode = ModeStream
204 case "--json":
205 a.Mode = ModeJSON
206 case "--orchestrate":
207 a.Orchestrate = true
208 case "--rpc":
209 a.Mode = ModeRPC
210 case "-c", "--continue":
211 a.Continue = true
212 case "-r", "--resume":
213 a.Resume = true
214 if i+1 < len(in) {
215 if id, err := uuid.Parse(in[i+1]); err == nil {
216 a.ResumeSessionID = id.String()
217 i++
218 }
219 }
220 case "--no-session":
221 a.NoSess = true
222 case "--no-tools":
223 a.NoTools = true
224 case "--no-lsp":
225 a.NoLSP = true
226 case "--list-models":
227 a.ListModels = true
228 case "--experimental-oauth":
229 // deprecated: subscription login is always available.
230 // accepted silently for backwards compatibility.
231 case "--provider":
232 v, err := want(&i, arg)
233 if err != nil {
234 return a, err
235 }
236 a.Provider = v
237 case "--model":
238 v, err := want(&i, arg)
239 if err != nil {
240 return a, err
241 }
242 a.Model = v
243 case "--api-key":
244 v, err := want(&i, arg)
245 if err != nil {
246 return a, err
247 }
248 a.APIKey = v
249 case "--base-url":
250 v, err := want(&i, arg)
251 if err != nil {
252 return a, err
253 }
254 a.BaseURL = v
255 case "--system-prompt":
256 v, err := want(&i, arg)
257 if err != nil {
258 return a, err
259 }
260 a.SystemPrompt = v
261 case "--append-system-prompt":
262 v, err := want(&i, arg)
263 if err != nil {
264 return a, err
265 }
266 a.AppendSystemPrompt = append(a.AppendSystemPrompt, v)
267 case "--ext", "-e":
268 v, err := want(&i, arg)
269 if err != nil {
270 return a, err
271 }
272 // Repeatable; each value is a directory containing an
273 // extension.json. Resolved to absolute later so paths like
274 // "." survive a later cwd change.
275 a.Exts = append(a.Exts, v)
276 case "--no-ext", "--no-extensions":
277 a.NoExt = true
278 case "--no-skill", "--no-skills":
279 a.NoSkill = true
280 case "--no-context-files", "-nc":
281 a.NoContextFiles = true
282 case "--with-skills", "--with-skill":
283 // Deprecated no-op: user skills are loaded by default.
284 a.WithSkills = true
285 case "--insecure":
286 a.InsecureTLS = true
287 case "--no-yolo":
288 a.NoYolo = true
289 case "-y", "--yes":
290 a.Yes = true
291 case "--reasoning":
292 v, err := want(&i, arg)
293 if err != nil {
294 return a, err
295 }
296 switch strings.ToLower(v) {
297 case "", "off", "minimum", "minimal", "low", "medium", "high", "xhigh", "maximum", "max":
298 a.Reasoning = strings.ToLower(v)
299 default:
300 return a, fmt.Errorf("--reasoning must be off|minimum|low|medium|high|xhigh|max")
301 }
302 case "--temperature":
303 v, err := want(&i, arg)
304 if err != nil {
305 return a, err
306 }
307 f, err := strconv.ParseFloat(v, 32)
308 if err != nil || f < 0 || f > 2 {
309 return a, fmt.Errorf("--temperature must be a number between 0 and 2")
310 }
311 t := float32(f)
312 a.Temperature = &t
313 case "--fast-mode":
314 a.FastMode = true
315 a.FastModeSet = true
316 case "--no-fast-mode":
317 a.FastMode = false
318 a.FastModeSet = true
319 case "--stats":
320 v, err := want(&i, arg)
321 if err != nil {
322 return a, err
323 }
324 a.StatsPath = v
325 case "--session":
326 v, err := want(&i, arg)
327 if err != nil {
328 return a, err
329 }
330 a.Session = v
331 case "--cwd":
332 v, err := want(&i, arg)
333 if err != nil {
334 return a, err
335 }
336 a.CWD = v
337 case "--subagent-worker":
338 v, err := want(&i, arg)
339 if err != nil {
340 return a, err
341 }
342 a.SubagentWorker = v
343 a.Mode = ModeSubagentWorker
344 case "--subagent":
345 v, err := want(&i, arg)
346 if err != nil {
347 return a, err
348 }
349 a.Subagent = v
350 case "--web-search-policy":
351 v, err := want(&i, arg)
352 if err != nil {
353 return a, err
354 }
355 webSearchPolicySet = true
356 switch strings.ToLower(strings.TrimSpace(v)) {
357 case "inherit":
358 a.WebSearchPolicy = subagents.WebSearchInherit
359 case "deny", "off":
360 a.WebSearchPolicy = subagents.WebSearchDeny
361 case "allow", "on":
362 a.WebSearchPolicy = subagents.WebSearchAllow
363 default:
364 return a, fmt.Errorf("--web-search-policy must be inherit|allow|deny")
365 }
366 case "--tools":
367 v, err := want(&i, arg)
368 if err != nil {
369 return a, err
370 }
371 a.ToolsSet = true
372 for _, t := range strings.Split(v, ",") {
373 t = strings.TrimSpace(t)
374 if t != "" {
375 a.Tools = append(a.Tools, t)
376 }
377 }
378 case "--max-steps":
379 v, err := want(&i, arg)
380 if err != nil {
381 return a, err
382 }
383 var n int
384 if _, err := fmt.Sscanf(v, "%d", &n); err != nil || n <= 0 {
385 return a, fmt.Errorf("--max-steps must be a positive integer")
386 }
387 a.MaxSteps = n
388 case "--subagent-turn-timeout":
389 v, err := want(&i, arg)
390 if err != nil {
391 return a, err
392 }
393 timeout, err := time.ParseDuration(v)
394 if err != nil || timeout <= 0 {
395 return a, fmt.Errorf("--subagent-turn-timeout must be a positive duration")
396 }
397 a.SubagentTurnTimeout = timeout
398 case "--subagent-lifetime-turns":
399 v, err := want(&i, arg)
400 if err != nil {
401 return a, err
402 }
403 var n int
404 if _, err := fmt.Sscanf(v, "%d", &n); err != nil || n < 0 {
405 return a, fmt.Errorf("--subagent-lifetime-turns must be a non-negative integer")
406 }
407 a.SubagentLifetimeTurns = n
408 case "--subagent-run-turns":
409 v, err := want(&i, arg)
410 if err != nil {
411 return a, err
412 }
413 var n int
414 if _, err := fmt.Sscanf(v, "%d", &n); err != nil || n < 0 {
415 return a, fmt.Errorf("--subagent-run-turns must be a non-negative integer")
416 }
417 a.SubagentRunTurns = n
418 default:
419 if strings.HasPrefix(arg, "-") && arg != "-" {
420 return a, fmt.Errorf("unknown flag %q", arg)
421 }
422 positional = append(positional, arg)
423 }
424 }
425
426 if len(positional) > 0 {
427 a.Prompt = strings.Join(positional, " ")
428 }
429
430 if webSearchPolicySet && a.Mode != ModeSubagentWorker {
431 return a, fmt.Errorf("--web-search-policy requires --subagent-worker")
432 }
433 if a.StatsPath != "" && a.Mode != ModePrint {
434 return a, fmt.Errorf("--stats requires -p or --print")
435 }
436 if a.CWD == "" {
437 a.CWD, _ = os.Getwd()
438 }
439 return a, nil
440}
441
442// PrintHelp writes the help text to stderr. When stderr is a TTY it
443// uses the same palette as zut's TUI; when redirected it falls back to
444// plain text with no ANSI escapes.
445func PrintHelp(version string) {
446 th := tui.Dark
447 fd := int(os.Stderr.Fd())
448 useColor := term.IsTerminal(fd)
449 if useColor && tui.DetectTrueColor(os.Getenv("TERM"), os.Getenv("COLORTERM")) {
450 th.Terminal.TrueColor = true
451 }
452 style := func(c tui.TerminalColor, s string) string {
453 if !useColor {
454 return s
455 }
456 return th.FGColor(c, s)
457 }
458 assistant := func(s string) string { return style(th.Assistant, s) }
459 muted := func(s string) string { return style(th.Muted, s) }
460 fg := func(s string) string { return style(th.FG, s) }
461 width := 96
462 if useColor {
463 if w, _, err := term.GetSize(fd); err == nil && w > 20 {
464 width = w
465 }
466 }
467 ruleWidth := width
468 if ruleWidth < 40 {
469 ruleWidth = 40
470 }
471 rule := strings.Repeat("─", ruleWidth)
472 if useColor {
473 rule = muted(rule)
474 }
475 leftW := 34
476 if width >= 120 {
477 leftW = 40
478 }
479 if width >= 140 {
480 leftW = 46
481 }
482 type row struct{ left, right string }
483 section := func(title string, rows ...row) {
484 fmt.Fprintln(os.Stderr)
485 fmt.Fprintln(os.Stderr, assistant(title))
486 fmt.Fprintln(os.Stderr, rule)
487 narrow := width < 100
488 for _, r := range rows {
489 if narrow {
490 fmt.Fprintf(os.Stderr, " %s\n", fg(r.left))
491 fmt.Fprintf(os.Stderr, " %s\n", muted(r.right))
492 fmt.Fprintln(os.Stderr)
493 continue
494 }
495 left := r.left
496 if len([]rune(left)) < leftW {
497 left += strings.Repeat(" ", leftW-len([]rune(left)))
498 }
499 fmt.Fprintf(os.Stderr, " %s %s\n", fg(left), muted(r.right))
500 }
501 }
502
503 fmt.Fprintln(os.Stderr)
504 var headline string
505 if useColor {
506 headline = th.AccentBar(th.Assistant) + assistant(tui.Bold("zut. yet another coding agent harness."))
507 } else {
508 headline = "zut. yet another coding agent harness."
509 }
510 fmt.Fprintln(os.Stderr, headline)
511 fmt.Fprintln(os.Stderr, muted("ask anything, or type /help inside the tui to see commands."))
512 fmt.Fprintf(os.Stderr, "%s %s\n", muted("version:"), fg(version))
513
514 section("modes",
515 row{"zut", "interactive tui"},
516 row{"zut \"prompt\"", "interactive, pre-filled prompt"},
517 row{"zut -p \"prompt\"", "print final text, exit"},
518 row{"zut -p --orchestrate \"prompt\"", "delegate independent work, print final synthesis, exit"},
519 row{"zut --stream \"prompt\"", "stream assistant text live, exit"},
520 row{"zut --json \"prompt\"", "newline-delimited json events, exit"},
521 row{"zut rpc", "json-rpc loop on stdin/stdout (see docs/rpc.md)"},
522 )
523 section("extensions",
524 row{"zut ext list", "list installed extensions"},
525 row{"zut ext install <path|git-url>", "install from a local path or Git URL"},
526 row{"zut ext install --build=go <path>", "build local Go source, then install"},
527 row{"zut --ext ./path/to/ext", "load an extension for this run only"},
528 row{"zut ext help", "show all extension subcommands"},
529 )
530 section("self-update",
531 row{"zut update", "download and install the latest release"},
532 row{"zut update --check", "show whether a new release is available"},
533 )
534 section("telegram",
535 row{"zut telegram-bot setup", "configure a telegram bot (from BotFather)"},
536 row{"zut telegram-bot run", "foreground bridge (ctrl+c to stop)"},
537 row{"zut telegram-bot start", "background bridge (detached)"},
538 row{"zut telegram-bot stop", "stop the background bridge"},
539 row{"zut telegram-bot logs [-f]", "tail the background bridge log"},
540 row{"zut telegram-bot status", "config + running state"},
541 row{"zut telegram-bot reset", "forget saved token"},
542 row{"zut tg ...", "short alias for telegram-bot"},
543 )
544 section("provider and model flags",
545 row{"--provider", "provider to use (anthropic|openai|openai-codex|kimi|deepseek|google|ollama|llama.cpp)"},
546 row{"--model ID", "model id (see --list-models)"},
547 row{"--api-key KEY", "api key for this run (env / auth.json fallback)"},
548 row{"--base-url URL", "override provider api base url"},
549 row{"--insecure", "skip TLS certificate verification (for self-signed-cert endpoints)"},
550 row{"--reasoning off|minimum|low|medium|high|xhigh|max", "set reasoning level on supported models"},
551 row{"--temperature N", "sampling temperature, 0 to 2 (omit for provider default)"},
552 )
553 section("prompt and session flags",
554 row{"--orchestrate", "headless delegation and final synthesis (print, stream, or json modes)"},
555 row{"--stats PATH", "write print-mode generation stats as json"},
556 row{"--system-prompt TEXT", "replace the default system prompt"},
557 row{"--append-system-prompt TEXT", "append to the system prompt (repeatable)"},
558 row{"-c, --continue", "continue the most recent session for this cwd"},
559 row{"-r, --resume [UUID]", "pick a session, or resume the persisted UUID"},
560 row{"--session PATH", "resume a specific session file"},
561 row{"--no-session", "do not read or write a session file"},
562 )
563 section("workspace, tools, skills",
564 row{"--cwd PATH", "treat PATH as the working directory"},
565 row{"--no-tools", "disable all tools"},
566 row{"--no-lsp", "disable the built-in LSP/linter tool"},
567 row{"--tools csv", "only enable listed tools (include lsp and web_search explicitly)"},
568 row{"--no-yolo", "ask before running every tool call"},
569 row{"-y, --yes", "accept zut run consent without prompting"},
570 row{"--no-ext", "skip extension discovery for this run"},
571 row{"--no-skill", "skip all skill discovery for this run"},
572 row{"--no-context-files, -nc", "skip AGENTS.md discovery for this run"},
573 )
574 section("misc",
575 row{"--max-steps N", "agent loop iteration cap (default: unlimited)"},
576 row{"--list-models", "print known models and exit"},
577 row{"-h, --help", "show this help"},
578 row{"-v, --version", "show version info"},
579 )
580 fmt.Fprintln(os.Stderr)
581 fmt.Fprintln(os.Stderr, assistant("see also: docs/extensions.md, docs/rpc.md, docs/skills.md"))
582 fmt.Fprintln(os.Stderr)
583}