v0.38.0 58133ea

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

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