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