v0.12.1 b9ff687

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

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 477

↑↓ moveenter openesc close