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