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