v0.12.0 0c5f8d5

args.go RawHistoryFolder

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

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