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