v0.16.0 042538b

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