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 // SubagentMaxTurns limits message turns in one worker run.
151 SubagentMaxTurns int
152 // SubagentTurnTimeout limits each active delegated turn while leaving the
153 // worker process alive between turns for explicit follow-ups.
154 SubagentTurnTimeout time.Duration
155 // SubagentLifetimeTurns and SubagentRunTurns are supervisor-provided
156 // counters used to resume a worker without losing observability or its
157 // current-run budget. They are internal worker flags, not user policy.
158 SubagentLifetimeTurns int
159 SubagentRunTurns int
160
161 // Subagent selects a named markdown profile for a subagent child.
162 // It is intentionally an internal child flag; the parent subagent tool
163 // passes only the profile name and the child discovers the definition
164 // locally.
165 Subagent string
166
167 // AgentName/AgentDataDir/PermissionSet are populated by `zut run`
168 // for local zutfile agents. They scope sessions and enforce the
169 // manifest's declared file/bash permissions.
170 AgentName string
171 AgentDataDir string
172 PermissionSet *tools.PermissionSet
173}
174
175// ParseArgs parses the process arguments (excluding argv[0]).
176func ParseArgs(in []string) (Args, error) {
177 a := Args{Mode: ModeInteractive, MaxSteps: 0, WithSkills: true}
178 positional := []string{}
179 webSearchPolicySet := false
180
181 want := func(i *int, flag string) (string, error) {
182 *i++
183 if *i >= len(in) {
184 return "", fmt.Errorf("%s requires a value", flag)
185 }
186 return in[*i], nil
187 }
188
189 for i := 0; i < len(in); i++ {
190 arg := in[i]
191 switch arg {
192 case "--":
193 // Everything after the terminator is prompt text, including
194 // values that begin with a dash. This is used by subagent
195 // workers and is also a conventional CLI escape hatch.
196 positional = append(positional, in[i+1:]...)
197 i = len(in)
198 case "-h", "--help":
199 a.Help = true
200 case "-v", "--version":
201 a.Version = true
202 case "-p", "--print":
203 a.Mode = ModePrint
204 case "--stream":
205 a.Mode = ModeStream
206 case "--json":
207 a.Mode = ModeJSON
208 case "--orchestrate":
209 a.Orchestrate = true
210 case "--rpc":
211 a.Mode = ModeRPC
212 case "-c", "--continue":
213 a.Continue = true
214 case "-r", "--resume":
215 a.Resume = true
216 if i+1 < len(in) {
217 if id, err := uuid.Parse(in[i+1]); err == nil {
218 a.ResumeSessionID = id.String()
219 i++
220 }
221 }
222 case "--no-session":
223 a.NoSess = true
224 case "--no-tools":
225 a.NoTools = true
226 case "--no-lsp":
227 a.NoLSP = true
228 case "--list-models":
229 a.ListModels = true
230 case "--experimental-oauth":
231 // deprecated: subscription login is always available.
232 // accepted silently for backwards compatibility.
233 case "--provider":
234 v, err := want(&i, arg)
235 if err != nil {
236 return a, err
237 }
238 a.Provider = v
239 case "--model":
240 v, err := want(&i, arg)
241 if err != nil {
242 return a, err
243 }
244 a.Model = v
245 case "--api-key":
246 v, err := want(&i, arg)
247 if err != nil {
248 return a, err
249 }
250 a.APIKey = v
251 case "--base-url":
252 v, err := want(&i, arg)
253 if err != nil {
254 return a, err
255 }
256 a.BaseURL = v
257 case "--system-prompt":
258 v, err := want(&i, arg)
259 if err != nil {
260 return a, err
261 }
262 a.SystemPrompt = v
263 case "--append-system-prompt":
264 v, err := want(&i, arg)
265 if err != nil {
266 return a, err
267 }
268 a.AppendSystemPrompt = append(a.AppendSystemPrompt, v)
269 case "--ext", "-e":
270 v, err := want(&i, arg)
271 if err != nil {
272 return a, err
273 }
274 // Repeatable; each value is a directory containing an
275 // extension.json. Resolved to absolute later so paths like
276 // "." survive a later cwd change.
277 a.Exts = append(a.Exts, v)
278 case "--no-ext", "--no-extensions":
279 a.NoExt = true
280 case "--no-skill", "--no-skills":
281 a.NoSkill = true
282 case "--no-context-files", "-nc":
283 a.NoContextFiles = true
284 case "--with-skills", "--with-skill":
285 // Deprecated no-op: user skills are loaded by default.
286 a.WithSkills = true
287 case "--insecure":
288 a.InsecureTLS = true
289 case "--no-yolo":
290 a.NoYolo = true
291 case "-y", "--yes":
292 a.Yes = true
293 case "--reasoning":
294 v, err := want(&i, arg)
295 if err != nil {
296 return a, err
297 }
298 switch strings.ToLower(v) {
299 case "", "off", "minimum", "minimal", "low", "medium", "high", "xhigh", "maximum", "max":
300 a.Reasoning = strings.ToLower(v)
301 default:
302 return a, fmt.Errorf("--reasoning must be off|minimum|low|medium|high|xhigh|max")
303 }
304 case "--temperature":
305 v, err := want(&i, arg)
306 if err != nil {
307 return a, err
308 }
309 f, err := strconv.ParseFloat(v, 32)
310 if err != nil || f < 0 || f > 2 {
311 return a, fmt.Errorf("--temperature must be a number between 0 and 2")
312 }
313 t := float32(f)
314 a.Temperature = &t
315 case "--fast-mode":
316 a.FastMode = true
317 a.FastModeSet = true
318 case "--no-fast-mode":
319 a.FastMode = false
320 a.FastModeSet = true
321 case "--stats":
322 v, err := want(&i, arg)
323 if err != nil {
324 return a, err
325 }
326 a.StatsPath = v
327 case "--session":
328 v, err := want(&i, arg)
329 if err != nil {
330 return a, err
331 }
332 a.Session = v
333 case "--cwd":
334 v, err := want(&i, arg)
335 if err != nil {
336 return a, err
337 }
338 a.CWD = v
339 case "--subagent-worker":
340 v, err := want(&i, arg)
341 if err != nil {
342 return a, err
343 }
344 a.SubagentWorker = v
345 a.Mode = ModeSubagentWorker
346 case "--subagent":
347 v, err := want(&i, arg)
348 if err != nil {
349 return a, err
350 }
351 a.Subagent = v
352 case "--web-search-policy":
353 v, err := want(&i, arg)
354 if err != nil {
355 return a, err
356 }
357 webSearchPolicySet = true
358 switch strings.ToLower(strings.TrimSpace(v)) {
359 case "inherit":
360 a.WebSearchPolicy = subagents.WebSearchInherit
361 case "deny", "off":
362 a.WebSearchPolicy = subagents.WebSearchDeny
363 case "allow", "on":
364 a.WebSearchPolicy = subagents.WebSearchAllow
365 default:
366 return a, fmt.Errorf("--web-search-policy must be inherit|allow|deny")
367 }
368 case "--tools":
369 v, err := want(&i, arg)
370 if err != nil {
371 return a, err
372 }
373 a.ToolsSet = true
374 for _, t := range strings.Split(v, ",") {
375 t = strings.TrimSpace(t)
376 if t != "" {
377 a.Tools = append(a.Tools, t)
378 }
379 }
380 case "--max-steps":
381 v, err := want(&i, arg)
382 if err != nil {
383 return a, err
384 }
385 var n int
386 if _, err := fmt.Sscanf(v, "%d", &n); err != nil || n <= 0 {
387 return a, fmt.Errorf("--max-steps must be a positive integer")
388 }
389 a.MaxSteps = n
390 case "--max-turns":
391 v, err := want(&i, arg)
392 if err != nil {
393 return a, err
394 }
395 var n int
396 if _, err := fmt.Sscanf(v, "%d", &n); err != nil || n <= 0 {
397 return a, fmt.Errorf("--max-turns must be a positive integer")
398 }
399 a.SubagentMaxTurns = n
400 case "--subagent-turn-timeout":
401 v, err := want(&i, arg)
402 if err != nil {
403 return a, err
404 }
405 timeout, err := time.ParseDuration(v)
406 if err != nil || timeout <= 0 {
407 return a, fmt.Errorf("--subagent-turn-timeout must be a positive duration")
408 }
409 a.SubagentTurnTimeout = timeout
410 case "--subagent-lifetime-turns":
411 v, err := want(&i, arg)
412 if err != nil {
413 return a, err
414 }
415 var n int
416 if _, err := fmt.Sscanf(v, "%d", &n); err != nil || n < 0 {
417 return a, fmt.Errorf("--subagent-lifetime-turns must be a non-negative integer")
418 }
419 a.SubagentLifetimeTurns = n
420 case "--subagent-run-turns":
421 v, err := want(&i, arg)
422 if err != nil {
423 return a, err
424 }
425 var n int
426 if _, err := fmt.Sscanf(v, "%d", &n); err != nil || n < 0 {
427 return a, fmt.Errorf("--subagent-run-turns must be a non-negative integer")
428 }
429 a.SubagentRunTurns = n
430 default:
431 if strings.HasPrefix(arg, "-") && arg != "-" {
432 return a, fmt.Errorf("unknown flag %q", arg)
433 }
434 positional = append(positional, arg)
435 }
436 }
437
438 if len(positional) > 0 {
439 a.Prompt = strings.Join(positional, " ")
440 }
441
442 if webSearchPolicySet && a.Mode != ModeSubagentWorker {
443 return a, fmt.Errorf("--web-search-policy requires --subagent-worker")
444 }
445 if a.StatsPath != "" && a.Mode != ModePrint {
446 return a, fmt.Errorf("--stats requires -p or --print")
447 }
448 if a.CWD == "" {
449 a.CWD, _ = os.Getwd()
450 }
451 return a, nil
452}
453
454// PrintHelp writes the help text to stderr. When stderr is a TTY it
455// uses the same palette as zut's TUI; when redirected it falls back to
456// plain text with no ANSI escapes.
457func PrintHelp(version string) {
458 th := tui.Dark
459 fd := int(os.Stderr.Fd())
460 useColor := term.IsTerminal(fd)
461 if useColor && tui.DetectTrueColor(os.Getenv("TERM"), os.Getenv("COLORTERM")) {
462 th.Terminal.TrueColor = true
463 }
464 style := func(c tui.TerminalColor, s string) string {
465 if !useColor {
466 return s
467 }
468 return th.FGColor(c, s)
469 }
470 assistant := func(s string) string { return style(th.Assistant, s) }
471 muted := func(s string) string { return style(th.Muted, s) }
472 fg := func(s string) string { return style(th.FG, s) }
473 width := 96
474 if useColor {
475 if w, _, err := term.GetSize(fd); err == nil && w > 20 {
476 width = w
477 }
478 }
479 ruleWidth := width
480 if ruleWidth < 40 {
481 ruleWidth = 40
482 }
483 rule := strings.Repeat("─", ruleWidth)
484 if useColor {
485 rule = muted(rule)
486 }
487 leftW := 34
488 if width >= 120 {
489 leftW = 40
490 }
491 if width >= 140 {
492 leftW = 46
493 }
494 type row struct{ left, right string }
495 section := func(title string, rows ...row) {
496 fmt.Fprintln(os.Stderr)
497 fmt.Fprintln(os.Stderr, assistant(title))
498 fmt.Fprintln(os.Stderr, rule)
499 narrow := width < 100
500 for _, r := range rows {
501 if narrow {
502 fmt.Fprintf(os.Stderr, " %s\n", fg(r.left))
503 fmt.Fprintf(os.Stderr, " %s\n", muted(r.right))
504 fmt.Fprintln(os.Stderr)
505 continue
506 }
507 left := r.left
508 if len([]rune(left)) < leftW {
509 left += strings.Repeat(" ", leftW-len([]rune(left)))
510 }
511 fmt.Fprintf(os.Stderr, " %s %s\n", fg(left), muted(r.right))
512 }
513 }
514
515 fmt.Fprintln(os.Stderr)
516 var headline string
517 if useColor {
518 headline = th.AccentBar(th.Assistant) + assistant(tui.Bold("zut. yet another coding agent harness."))
519 } else {
520 headline = "zut. yet another coding agent harness."
521 }
522 fmt.Fprintln(os.Stderr, headline)
523 fmt.Fprintln(os.Stderr, muted("ask anything, or type /help inside the tui to see commands."))
524 fmt.Fprintf(os.Stderr, "%s %s\n", muted("version:"), fg(version))
525
526 section("modes",
527 row{"zut", "interactive tui"},
528 row{"zut \"prompt\"", "interactive, pre-filled prompt"},
529 row{"zut -p \"prompt\"", "print final text, exit"},
530 row{"zut -p --orchestrate \"prompt\"", "delegate independent work, print final synthesis, exit"},
531 row{"zut --stream \"prompt\"", "stream assistant text live, exit"},
532 row{"zut --json \"prompt\"", "newline-delimited json events, exit"},
533 row{"zut rpc", "json-rpc loop on stdin/stdout (see docs/rpc.md)"},
534 )
535 section("extensions",
536 row{"zut ext list", "list installed extensions"},
537 row{"zut ext install <path|git-url>", "install from a local path or Git URL"},
538 row{"zut ext install --build=go <path>", "build local Go source, then install"},
539 row{"zut --ext ./path/to/ext", "load an extension for this run only"},
540 row{"zut ext help", "show all extension subcommands"},
541 )
542 section("self-update",
543 row{"zut update", "download and install the latest release"},
544 row{"zut update --check", "show whether a new release is available"},
545 )
546 section("telegram",
547 row{"zut telegram-bot setup", "configure a telegram bot (from BotFather)"},
548 row{"zut telegram-bot run", "foreground bridge (ctrl+c to stop)"},
549 row{"zut telegram-bot start", "background bridge (detached)"},
550 row{"zut telegram-bot stop", "stop the background bridge"},
551 row{"zut telegram-bot logs [-f]", "tail the background bridge log"},
552 row{"zut telegram-bot status", "config + running state"},
553 row{"zut telegram-bot reset", "forget saved token"},
554 row{"zut tg ...", "short alias for telegram-bot"},
555 )
556 section("provider and model flags",
557 row{"--provider", "provider to use (anthropic|openai|openai-codex|kimi|deepseek|google|ollama|llama.cpp)"},
558 row{"--model ID", "model id (see --list-models)"},
559 row{"--api-key KEY", "api key for this run (env / auth.json fallback)"},
560 row{"--base-url URL", "override provider api base url"},
561 row{"--insecure", "skip TLS certificate verification (for self-signed-cert endpoints)"},
562 row{"--reasoning off|minimum|low|medium|high|xhigh|max", "set reasoning level on supported models"},
563 row{"--temperature N", "sampling temperature, 0 to 2 (omit for provider default)"},
564 )
565 section("prompt and session flags",
566 row{"--orchestrate", "headless delegation and final synthesis (print, stream, or json modes)"},
567 row{"--stats PATH", "write print-mode generation stats as json"},
568 row{"--system-prompt TEXT", "replace the default system prompt"},
569 row{"--append-system-prompt TEXT", "append to the system prompt (repeatable)"},
570 row{"-c, --continue", "continue the most recent session for this cwd"},
571 row{"-r, --resume [UUID]", "pick a session, or resume the persisted UUID"},
572 row{"--session PATH", "resume a specific session file"},
573 row{"--no-session", "do not read or write a session file"},
574 )
575 section("workspace, tools, skills",
576 row{"--cwd PATH", "treat PATH as the working directory"},
577 row{"--no-tools", "disable all tools"},
578 row{"--no-lsp", "disable the built-in LSP/linter tool"},
579 row{"--tools csv", "only enable listed tools (include lsp and web_search explicitly)"},
580 row{"--no-yolo", "ask before running every tool call"},
581 row{"-y, --yes", "accept zut run consent without prompting"},
582 row{"--no-ext", "skip extension 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"},
585 )
586 section("misc",
587 row{"--max-steps N", "agent loop iteration cap (default: unlimited)"},
588 row{"--list-models", "print known models and exit"},
589 row{"-h, --help", "show this help"},
590 row{"-v, --version", "show version info"},
591 )
592 fmt.Fprintln(os.Stderr)
593 fmt.Fprintln(os.Stderr, assistant("see also: docs/extensions.md, docs/rpc.md, docs/skills.md"))
594 fmt.Fprintln(os.Stderr)
595}