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