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