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