v0.14.0 75d4151

args.go RawHistoryFolder

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

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close