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