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