v0.31.0 9467429

build.go RawHistoryFolder

1package agent
2
3import (
4	"context"
5	"fmt"
6	"os"
7	"path/filepath"
8	"runtime"
9	"sort"
10	"strings"
11
12	zutdocs "github.com/bnema/zut"
13	"github.com/bnema/zut/packages/agent/lsp"
14	"github.com/bnema/zut/packages/agent/skills"
15	"github.com/bnema/zut/packages/agent/subagents"
16	"github.com/bnema/zut/packages/agent/tools"
17	"github.com/bnema/zut/packages/core"
18	"github.com/bnema/zut/packages/provider"
19)
20
21// ContextFile is an instruction file loaded into the system prompt.
22type ContextFile struct {
23	Path    string
24	Content string
25}
26
27// Resolved is the effective configuration after merging CLI, config, defaults.
28type Resolved struct {
29	Provider        string
30	Model           string
31	Credential      string // api key or oauth access token
32	AuthMethod      string // "apikey" | "oauth" | "" (no credential yet)
33	AccountID       string // ChatGPT account id (for openai oauth), "" otherwise
34	BaseURL         string
35	InsecureTLS     bool
36	CWD             string
37	Reasoning       string
38	Temperature     *float32
39	FastMode        bool
40	WebSearchPolicy subagents.WebSearchPolicy
41
42	ToolRegistry core.Registry
43	ToolSummary  []ToolSummary
44	SystemPrompt string
45	MaxSteps     int
46	Sandbox      *tools.Sandbox
47
48	// ContextWindow and MaxOutput retain the effective model metadata,
49	// including metadata synthesized for valid open-catalog local and routed
50	// models that provider.FindModel cannot rediscover later.
51	ContextWindow int
52
53	// MaxOutput is the resolved model's maximum output-token budget
54	// (from the catalog). Passed to the agent so each turn requests
55	// the model's full output capacity instead of the provider's
56	// conservative default (e.g. Bedrock's 4096, which truncates
57	// long writes/edits with stopReason=length).
58	MaxOutput int
59
60	// SkillTool is the on-demand skill loader registered with the
61	// agent's tool registry, or nil if no SKILL.md files were
62	// discovered. Exposed so the tui can list / preview skills.
63	SkillTool *skills.Tool
64
65	// ContextFiles records the AGENTS.md files appended to SystemPrompt,
66	// in effective load order. Interactive mode uses this metadata to make
67	// otherwise invisible startup context inspectable without adding fake
68	// messages to the provider transcript.
69	ContextFiles []ContextFile
70
71	// skillsEnabled is true when discovery ran, even when it found no
72	// skills. It is false only for an explicit clean-room disable.
73	skillsEnabled bool
74
75	// Bookkeeping for MergeExtensionTools. Captured at Resolve time
76	// so the system prompt can be rebuilt later without re-running
77	// resolve.
78	systemAppend     []string
79	systemCustom     string
80	skillAddendum    string
81	toolDescriptions map[string]string
82}
83
84// HasCredential reports whether a credential was resolved.
85func (r Resolved) HasCredential() bool { return r.Credential != "" }
86
87// MergeExtensionTools folds every tool registered by an extension
88// into r's ToolRegistry and re-renders the system prompt's tool
89// summary so the model sees both built-in and extension tools.
90//
91// Idempotent: calling twice with the same manager state has no
92// effect on the second pass (existing names are preserved). Built-in
93// tools always win on conflict.
94func (r *Resolved) MergeExtensionTools(mgr ExtensionToolSource) {
95	if mgr == nil {
96		return
97	}
98	changed := false
99	for _, info := range mgr.Tools() {
100		// Web capability and other native names remain reserved even when their
101		// policy excludes the current session. An extension must not turn a
102		// normal CLI opt-out into a differently implemented capability.
103		if tools.IsWebCapabilityName(info.Name) || info.Name == "grep" || info.Name == "schedule" || info.Name == tools.UpdateGoalToolName || info.Name == tools.UpdatePlanToolName {
104			continue
105		}
106		if _, exists := r.ToolRegistry[info.Name]; exists {
107			continue
108		}
109		r.ToolRegistry[info.Name] = mgr.NewExtensionTool(info)
110		changed = true
111	}
112
113	// A nil SkillTool can mean discovery ran and found nothing, so use the
114	// explicit disable flag rather than the discovery result as the gate.
115	if source, ok := mgr.(ExtensionSkillSource); ok && r.skillsEnabled {
116		if bundled := source.Skills(); len(bundled) > 0 {
117			merged := mergeExtensionSkills(r.SkillTool, bundled)
118			if r.SkillTool == nil {
119				r.SkillTool = skills.NewTool(merged)
120				r.ToolRegistry[r.SkillTool.Name()] = r.SkillTool
121			} else {
122				r.SkillTool.SetSkills(merged)
123			}
124			append_ := append([]string(nil), r.systemAppend...)
125			if r.skillAddendum != "" {
126				filtered := append_[:0]
127				for _, item := range append_ {
128					if item != r.skillAddendum {
129						filtered = append(filtered, item)
130					}
131				}
132				append_ = filtered
133			}
134			r.skillAddendum = skills.SystemPromptAddendum(merged)
135			if r.skillAddendum != "" {
136				append_ = append(append_, r.skillAddendum)
137			}
138			r.systemAppend = append_
139			changed = true
140		}
141	}
142	if !changed {
143		return
144	}
145	// Re-render the system prompt with the merged tool and skill lists.
146	r.ToolSummary = toolSummariesFromRegistry(r.ToolRegistry, r.toolDescriptions)
147	r.SystemPrompt = BuildSystemPrompt(SystemPromptOpts{
148		CWD:        r.CWD,
149		Tools:      r.ToolSummary,
150		Custom:     r.systemCustom,
151		Append:     r.systemAppend,
152		ZutDocsDir: filepath.Join(ZutHome(), "docs"),
153	})
154}
155
156func mergeExtensionSkills(tool *skills.Tool, bundled []*skills.Skill) []*skills.Skill {
157	var existing []*skills.Skill
158	if tool != nil {
159		existing = tool.Skills()
160	}
161	merged := make([]*skills.Skill, 0, len(existing)+len(bundled))
162	seen := map[string]bool{}
163	add := func(list []*skills.Skill) {
164		for _, skill := range list {
165			if skill == nil || skill.Name == "" || seen[skill.Name] {
166				continue
167			}
168			seen[skill.Name] = true
169			merged = append(merged, skill)
170		}
171	}
172	var user, builtins []*skills.Skill
173	for _, skill := range existing {
174		if skill == nil || strings.HasPrefix(skill.Source, "extension ") {
175			continue
176		}
177		if skill.Builtin {
178			builtins = append(builtins, skill)
179		} else {
180			user = append(user, skill)
181		}
182	}
183	add(user)
184	add(bundled)
185	add(builtins)
186	return merged
187}
188
189// ExtensionToolSource is the slice of the extension manager that
190// MergeExtensionTools needs. Lives here as an interface so the
191// build package doesn't import packages/agent/extensions (which
192// imports core, which imports... avoid the cycle).
193type ExtensionToolSource interface {
194	Tools() []ExtensionToolInfo
195	NewExtensionTool(info ExtensionToolInfo) core.Tool
196}
197
198// ExtensionSkillSource exposes the optional bundled-skill surface without
199// forcing older ExtensionToolSource implementations to add it.
200type ExtensionSkillSource interface {
201	Skills() []*skills.Skill
202}
203
204// ExtensionToolInfo mirrors extensions.ToolInfo so we can declare
205// ExtensionToolSource here without importing the extensions
206// package. The cli wires a tiny adapter to bridge them.
207type ExtensionToolInfo struct {
208	Extension   string
209	Name        string
210	Description string
211	Schema      []byte
212	Deferred    bool
213}
214
215// toolSummariesFromRegistry rebuilds the system-prompt tool list
216// from a (possibly extended) registry, using cached descriptions for
217// the human-readable summary text.
218func toolSummariesFromRegistry(reg core.Registry, cached map[string]string) []ToolSummary {
219	out := make([]ToolSummary, 0, len(reg))
220	seen := make(map[string]bool, len(nativeToolSummaryOrder))
221	appendSummary := func(name string) {
222		t, ok := reg[name]
223		if !ok {
224			return
225		}
226		seen[name] = true
227		if d, ok := t.(interface{ Deferred() bool }); ok && d.Deferred() {
228			return
229		}
230		desc := t.Description()
231		if d, ok := cached[name]; ok && d != "" {
232			desc = d
233		}
234		out = append(out, ToolSummary{Name: name, Description: desc})
235	}
236	for _, name := range nativeToolSummaryOrder {
237		appendSummary(name)
238	}
239	remaining := make([]string, 0, len(reg)-len(seen))
240	for name := range reg {
241		if !seen[name] {
242			remaining = append(remaining, name)
243		}
244	}
245	sort.Strings(remaining)
246	for _, name := range remaining {
247		appendSummary(name)
248	}
249	return out
250}
251
252// defaultModelForProvider returns the model id zut prefers when the
253// caller didn't pick one. Mirrors the per-provider switch used at
254// multiple points in Resolve; centralised so the unknown-model
255// recovery path and the no-model-configured path can't drift.
256//
257// Returns the empty string for "ollama", which has no built-in
258// default — the caller is expected to special-case ollama and
259// error or use whatever the user passed.
260func defaultModelForProvider(prov string) string {
261	switch prov {
262	case "openai":
263		return "gpt-5"
264	case "openai-codex":
265		return "gpt-5.5"
266	case "openai-responses":
267		return "gpt-5"
268	case "kimi":
269		return "kimi-for-coding"
270	case "deepseek":
271		return "deepseek-v4-pro"
272	case "google":
273		return "gemini-2.5-pro"
274	case "ollama", provider.LlamaCPPProviderID:
275		return ""
276	case "moonshotai", "moonshotai-cn":
277		return "kimi-k2.6"
278	case "cerebras":
279		return "qwen-3-235b-a22b-instruct-2507"
280	case "groq":
281		return "llama-3.3-70b-versatile"
282	case "xai":
283		return "grok-4.5"
284	case "together":
285		return "Qwen/Qwen3-Coder-480B-A35B-Instruct"
286	case "huggingface":
287		return "moonshotai/Kimi-K2-Instruct"
288	case "openrouter":
289		return "anthropic/claude-sonnet-4.5"
290	case "mistral":
291		return "mistral-large-latest"
292	case "zai":
293		return "glm-4.7"
294	case "xiaomi", "xiaomi-token-plan-ams", "xiaomi-token-plan-cn", "xiaomi-token-plan-sgp":
295		return "mimo-v2.5"
296	case "minimax", "minimax-cn":
297		return "MiniMax-M2.7"
298	case "fireworks":
299		return "accounts/fireworks/models/kimi-k2p6"
300	case "vercel-ai-gateway":
301		return "anthropic/claude-sonnet-4.5"
302	case "opencode":
303		return "claude-sonnet-4-5"
304	case "opencode-go":
305		return "kimi-k2.6"
306	case "amazon-bedrock":
307		return "anthropic.claude-sonnet-4-5-20250929-v1:0"
308	case "google-vertex":
309		return "gemini-2.5-pro"
310	case "azure-openai-responses":
311		return "gpt-5"
312	case "github-copilot":
313		return "claude-sonnet-4.5"
314	default:
315		// Custom providers: pick the first model from the catalog for
316		// that provider, or fall back to the global default.
317		if models := provider.ModelsForProvider(prov); len(models) > 0 {
318			return models[0].ID
319		}
320		return provider.DefaultModel.ID
321	}
322}
323
324// knownProviders is the set of provider ids zut recognises. Used by
325// Resolve to validate args.Provider, by extension-callers, and by the
326// auto-fallback logic that picks any logged-in provider when the user's
327// preferred one has no credentials.
328var knownProviders = []string{
329	"anthropic", "openai", "openai-codex", "openai-responses", "kimi", "deepseek", "google", "ollama", provider.LlamaCPPProviderID,
330	"moonshotai", "moonshotai-cn",
331	"cerebras", "groq", "xai", "together", "huggingface", "openrouter",
332	"mistral", "zai",
333	"xiaomi", "xiaomi-token-plan-ams", "xiaomi-token-plan-cn", "xiaomi-token-plan-sgp",
334	"minimax", "minimax-cn",
335	"fireworks", "vercel-ai-gateway",
336	"opencode", "opencode-go",
337	"amazon-bedrock", "google-vertex", "azure-openai-responses",
338	"github-copilot", "cloudflare-workers-ai", "cloudflare-ai-gateway",
339}
340
341func isKnownProvider(name string) bool {
342	for _, p := range knownProviders {
343		if p == name {
344			return true
345		}
346	}
347	_, ok := provider.CustomProviders()[name]
348	return ok
349}
350
351// isBuiltinProvider reports whether name is in the hardcoded
352// knownProviders list (not a user-defined custom provider).
353func isBuiltinProvider(name string) bool {
354	for _, p := range knownProviders {
355		if p == name {
356			return true
357		}
358	}
359	return false
360}
361
362// providerAliases maps common short / alternate provider names to the
363// canonical id in knownProviders. Users (and other agents) reach for
364// "bedrock" or "vertex" far more naturally than the fully-qualified
365// "amazon-bedrock" / "google-vertex"; without this mapping an alias is
366// treated as an unknown provider and Resolve silently falls back to
367// anthropic, producing a misleading "no credential for anthropic" error
368// after the user explicitly picked, say, bedrock.
369var providerAliases = map[string]string{
370	"bedrock":      "amazon-bedrock",
371	"aws-bedrock":  "amazon-bedrock",
372	"amazon":       "amazon-bedrock",
373	"vertex":       "google-vertex",
374	"gcp-vertex":   "google-vertex",
375	"gemini":       "google",
376	"googleai":     "google",
377	"google-ai":    "google",
378	"azure":        "azure-openai-responses",
379	"azure-openai": "azure-openai-responses",
380	"copilot":      "github-copilot",
381	"github":       "github-copilot",
382	"codex":        "openai-codex",
383	"moonshot":     "moonshotai",
384	"kimi-code":    "kimi",
385	"ai-gateway":   "vercel-ai-gateway",
386	"vercel":       "vercel-ai-gateway",
387	"cloudflare":   "cloudflare-workers-ai",
388	"workers-ai":   "cloudflare-workers-ai",
389	"hf":           "huggingface",
390	"llama":        provider.LlamaCPPProviderID,
391	"llamacpp":     provider.LlamaCPPProviderID,
392}
393
394// canonicalProvider normalises a user-supplied provider name: trims
395// surrounding whitespace, lower-cases it, and resolves any known alias
396// to its canonical id. Unknown names are returned trimmed/lower-cased
397// and unchanged so the existing unknown-provider handling still runs.
398func canonicalProvider(name string) string {
399	n := strings.ToLower(strings.TrimSpace(name))
400	if n == "" {
401		return n
402	}
403	if canon, ok := providerAliases[n]; ok {
404		return canon
405	}
406	return n
407}
408
409func findSubagentProfile(cwd, name string) (*subagents.Profile, error) {
410	name = strings.TrimSpace(name)
411	if name == "" {
412		return nil, nil
413	}
414	home, _ := os.UserHomeDir()
415	profiles, discoveryErrors := subagents.Discover(cwd, home)
416	if len(discoveryErrors) > 0 {
417		return nil, fmt.Errorf("discover subagent profiles: %d profile discovery error(s)", len(discoveryErrors))
418	}
419	profile := subagents.Find(profiles, name)
420	if profile == nil {
421		return nil, fmt.Errorf("subagent profile %q not found (looked in ~/.agents/agents and configured ZUT_AGENT_PROFILES paths)", name)
422	}
423	return profile, nil
424}
425
426// resolveWebSearchPolicy applies capability precedence at the owning resolver
427// boundary. Permission, tool-list, and named-profile restrictions are ceilings
428// on every caller-provided policy.
429func resolveWebSearchPolicy(args Args, cfg Config, cfgErr error) subagents.WebSearchPolicy {
430	if args.NoTools || args.PermissionSet != nil {
431		return subagents.WebSearchDeny
432	}
433	if args.ToolsSet && !toolListContains(args.Tools, "web_search") {
434		return subagents.WebSearchDeny
435	}
436
437	switch args.WebSearchPolicy {
438	case subagents.WebSearchDeny:
439		return subagents.WebSearchDeny
440	case subagents.WebSearchAllow:
441		return subagents.WebSearchAllow
442	case subagents.WebSearchInherit:
443		// Continue through normal-mode defaults below. Inherit is not a valid
444		// propagated worker decision.
445	default:
446		return subagents.WebSearchDeny
447	}
448	if args.ToolsSet {
449		return subagents.WebSearchAllow
450	}
451	if cfgErr != nil {
452		return subagents.WebSearchDeny
453	}
454	if cfg.WebSearchEnabledForCLI() {
455		return subagents.WebSearchAllow
456	}
457	return subagents.WebSearchDeny
458}
459
460func toolListContains(names []string, target string) bool {
461	for _, name := range names {
462		if name == target {
463			return true
464		}
465	}
466	return false
467}
468
469// webSearchAllowedForRegistry is kept separate from the resolver so direct
470// registry tests and callers that build a registry without persisted config
471// still get the normal default-on behavior.
472func webSearchAllowedForRegistry(args Args) bool {
473	if args.NoTools || args.PermissionSet != nil {
474		return false
475	}
476	if args.ToolsSet && !toolListContains(args.Tools, "web_search") {
477		return false
478	}
479	switch args.WebSearchPolicy {
480	case subagents.WebSearchAllow:
481		return true
482	case subagents.WebSearchDeny:
483		return false
484	default:
485		return true
486	}
487}
488
489// Resolve merges args, config, and env into a Resolved set.
490//
491// Unlike the earlier version, Resolve NEVER returns an error for
492// missing credentials: the TUI can start without them and launch a
493// login flow. requireCred controls whether missing credentials are a
494// hard error (used by print/json modes).
495func Resolve(args Args, requireCred bool) (Resolved, error) {
496	cfg, cfgErr := LoadConfig()
497	profileSlot := cfg.applyActiveModelProfile()
498	repairConfig := false
499	// Programmatic callers historically supplied Tools without the parser's
500	// provenance bit. Treat a non-empty list as explicit before a named profile
501	// can contribute its defaults.
502	if len(args.Tools) > 0 {
503		args.ToolsSet = true
504	}
505	webSearchPolicy := resolveWebSearchPolicy(args, cfg, cfgErr)
506	args.WebSearchPolicy = webSearchPolicy
507
508	// User-requested provider (explicit > config > default).
509	// Normalise common aliases (e.g. "bedrock" -> "amazon-bedrock")
510	// before validation so an alias is never mistaken for an unknown
511	// provider and silently downgraded to anthropic.
512	argProvider := canonicalProvider(args.Provider)
513	provName := firstNonEmpty(argProvider, canonicalProvider(cfg.Provider), "anthropic")
514	if !isKnownProvider(provName) {
515		// Unknown provider (maybe removed or renamed). Fall back to
516		// the first provider that has credentials, or anthropic.
517		// Custom providers (from models.json) are already accepted
518		// by isKnownProvider, so we only reach here for truly unknown
519		// names.
520		provName = "anthropic"
521		if CredentialAvailable("openai") {
522			provName = "openai"
523		}
524		if CredentialAvailable("openai-codex") {
525			provName = "openai-codex"
526		}
527		if CredentialAvailable("kimi") {
528			provName = "kimi"
529		}
530		if CredentialAvailable("deepseek") {
531			provName = "deepseek"
532		}
533		if CredentialAvailable("anthropic") {
534			provName = "anthropic"
535		}
536		// Reset the saved config so this doesn't keep happening.
537		cfg.Provider = provName
538		cfg.Model = ""
539		repairConfig = true
540	}
541
542	var (
543		cred      string
544		method    string
545		accountID string
546		credErr   error
547	)
548	if provName == "ollama" {
549		cred = firstNonEmpty(args.APIKey, "ollama")
550		method = "apikey"
551	} else {
552		cred, method, accountID, credErr = ResolveCredentialFull(provName, args.APIKey)
553	}
554
555	// Persist --api-key for custom providers so subsequent runs don't
556	// need to pass it again.
557	if !isBuiltinProvider(provName) && args.APIKey != "" {
558		if store := AuthStoreFor(); store != nil {
559			_ = store.SetAPIKey(provName, args.APIKey)
560		}
561	}
562
563	// If the user did NOT explicitly pick a provider (neither via --provider
564	// nor by saving one in config.json) and the default one has no
565	// credentials, auto-fall-back to whichever provider is actually logged
566	// in. That way running plain `zut` after `/login` (any provider) never
567	// shows a "not logged in" banner.
568	//
569	// Critical: when the user HAS saved a provider in config.json (e.g.
570	// "deepseek" with "deepseek-v4-flash"), that choice is respected even
571	// if no credential is found — the tui will show the login dialog or
572	// surface a clear credential error rather than silently switching to
573	// a completely different provider and model.
574	// autoFellBack tracks whether the provider was changed by the
575	// auto-fallback loop (no explicit provider from CLI or config,
576	// and the default had no credentials). When false, the provider
577	// came from the user's explicit choice (--provider or config.json)
578	// and must be respected, including any model id that may belong
579	// to a different provider in the catalog — for example when using
580	// a gateway/router like OpenRouter with "deepseek/deepseek-v4-flash".
581	var autoFellBack bool
582	userPickedProvider := args.Provider != "" || cfg.Provider != ""
583	if credErr != nil && !userPickedProvider && provName != "ollama" {
584		autoFellBack = true
585		// Scan every known provider (not a hardcoded subset) so any
586		// env-based credential is discovered, e.g. an env-only
587		// amazon-bedrock setup (AWS_BEARER_TOKEN_BEDROCK / AWS_PROFILE /
588		// IAM keys) when no config.json pins the provider, such as after
589		// pointing ZUT_HOME at a fresh home dir. Iteration order of
590		// knownProviders defines fallback priority. Local providers without a
591		// default model are skipped because selecting either one here would
592		// fail before the user can choose a model.
593		for _, other := range knownProviders {
594			if other == provName || other == "ollama" || other == provider.LlamaCPPProviderID {
595				continue
596			}
597			if !CredentialAvailable(other) {
598				continue
599			}
600			// An explicit launch key belongs to the initially selected provider;
601			// automatic fallback must never reuse it for another provider.
602			c, m, a, err := ResolveCredentialFull(other, "")
603			provName = other
604			cred, method, accountID, credErr = c, m, a, err
605			break
606		}
607	}
608
609	model := firstNonEmpty(args.Model, cfg.Model)
610	if model == "" {
611		if provName == "ollama" || provName == provider.LlamaCPPProviderID {
612			return Resolved{}, fmt.Errorf("%s requires --model or a model selected from its manager", provName)
613		}
614		model = defaultModelForProvider(provName)
615	}
616	// If the resolved model belongs to a different provider (e.g. config
617	// says gpt-5 but we auto-fell back to anthropic), pick that provider's
618	// default. This override only fires when the provider was auto-fallback-
619	// changed, NOT when the user explicitly configured the pair — gateway/
620	// router providers (openrouter, vercel-ai-gateway, etc.) can serve
621	// models from any provider in the catalog.
622	if autoFellBack && provName != "ollama" {
623		if m, err := provider.FindModel("", model); err == nil && m.Provider != provName {
624			model = defaultModelForProvider(provName)
625		}
626	}
627	resolvedModel, err := provider.FindModel(provName, model)
628	if err != nil && (provName == "ollama" || provName == provider.LlamaCPPProviderID) {
629		// Local providers are intentionally open-catalogue: any model id the
630		// configured server understands is valid, even if it is not cached.
631		resolvedModel = provider.Model{
632			Provider:      provName,
633			ID:            model,
634			DisplayName:   model,
635			ContextWindow: 128000,
636			MaxOutput:     16384,
637			BaseURL:       args.BaseURL,
638			Source:        provName,
639		}
640		err = nil
641	}
642	// Custom providers are open-catalogue like ollama: any model id the
643	// endpoint understands is valid. Use the provider-level base URL.
644	if err != nil {
645		if cfg, ok := provider.CustomProviders()[provName]; ok {
646			resolvedModel = provider.Model{
647				Provider:      provName,
648				ID:            model,
649				DisplayName:   model,
650				ContextWindow: 128000,
651				MaxOutput:     16384,
652				BaseURL:       cfg.BaseURL,
653				Source:        "user",
654			}
655			err = nil
656		}
657	}
658	if cfg, ok := provider.CustomProviders()[provName]; ok && err == nil && resolvedModel.BaseURL == "" && cfg.BaseURL != "" {
659		// Fall back to the provider-level base URL when the model does
660		// not define its own endpoint.
661		resolvedModel.BaseURL = cfg.BaseURL
662	}
663	if err != nil {
664		// The model the user (or persisted config) asked for is no
665		// longer in the active catalogue — they probably removed it
666		// from their models.json or upgraded zut and the id changed.
667		// Refusing to launch is the wrong move: it strands the user
668		// with no way to even open the TUI and pick a new model.
669		// Fall back to the provider's default, warn on stderr, and,
670		// when the stale id came from the persisted config (not an
671		// explicit --model flag), repair the config so the warning
672		// doesn't repeat on every launch.
673
674		// Gateway providers can accept route-qualified ids that are not in
675		// zut's local catalog yet, for example OpenRouter's
676		// "deepseek/deepseek-v4-flash". Preserve only route-qualified ids;
677		// plain unknown values are likely typos and should still fall back.
678		if isGatewayProvider(provName) && isGatewayRoutedModelID(model) {
679			resolvedModel = provider.Model{
680				Provider:      provName,
681				ID:            model,
682				DisplayName:   model,
683				ContextWindow: 1000000,
684				MaxOutput:     64000,
685				BaseURL:       "",
686				Source:        "gateway",
687			}
688			// Don't repair config — the routed model id may be valid upstream.
689		} else {
690			fallback := defaultModelForProvider(provName)
691			fm, ferr := provider.FindModel(provName, fallback)
692			if ferr != nil {
693				// Even the provider default is gone (catastrophic
694				// catalogue trim). Last resort: any model on this
695				// provider, then the global DefaultModel.
696				if candidates := provider.ModelsForProvider(provName); len(candidates) > 0 {
697					fm = candidates[0]
698				} else {
699					fm = provider.DefaultModel
700				}
701			}
702			fmt.Fprintf(os.Stderr,
703				"zut: model %q is not in the active catalogue; using %q instead. Pick a different model with --model or /model.\n",
704				model, fm.ID)
705			if args.Model == "" && cfg.Model == model {
706				cfg.Model = fm.ID
707				repairConfig = true
708			}
709			resolvedModel = fm
710			model = fm.ID
711		}
712	}
713
714	if repairConfig {
715		// Repair the source favorite as well as legacy defaults, but never
716		// overwrite it because an explicit CLI override required fallback.
717		if profileSlot > 0 && args.Provider == "" && args.Model == "" {
718			cfg.Provider, cfg.Model = provName, model
719			cfg.Reasoning = provider.ClampReasoningForModel(resolvedModel, cfg.Reasoning)
720			cfg.QuickModelShortcuts[profileSlot-1] = QuickModelShortcut{
721				Provider: cfg.Provider, Model: cfg.Model, Reasoning: cfg.Reasoning,
722				FastMode: cfg.FastMode != nil && *cfg.FastMode,
723			}
724			cfg.ActiveModelProfile = profileSlot
725		}
726		_ = SaveConfig(cfg)
727	}
728
729	explicitBaseURL := args.BaseURL != "" || (resolvedModel.Source == "user" && resolvedModel.BaseURL != "")
730
731	// If the model defines a base URL (e.g. local ollama) and the
732	// user didn't pass --base-url, use the model's URL. For ollama,
733	// keep http://localhost:11434 as a fallback only after the model
734	// metadata has had a chance to provide a custom baseUrl.
735	if args.BaseURL == "" && resolvedModel.BaseURL != "" {
736		args.BaseURL = resolvedModel.BaseURL
737	}
738	if args.BaseURL == "" && provName == "ollama" {
739		args.BaseURL = "http://localhost:11434"
740	}
741	if args.BaseURL == "" && provName == provider.LlamaCPPProviderID {
742		managementURL, _, configErr := ResolveLlamaCPPConfig()
743		if configErr != nil {
744			return Resolved{}, configErr
745		}
746		var baseErr error
747		args.BaseURL, baseErr = provider.LlamaCPPInferenceURL(managementURL)
748		if baseErr != nil {
749			return Resolved{}, baseErr
750		}
751	}
752
753	// Insecure TLS is intentionally scoped to explicit custom endpoints.
754	// Built-in provider base URLs, auth calls, and model discovery keep normal
755	// certificate verification even when --insecure is present.
756	insecureTLS := (args.InsecureTLS || cfg.Insecure) && explicitBaseURL
757
758	// If the model has a base URL, credentials are optional (local
759	// models like ollama don't need real API keys).
760	if resolvedModel.BaseURL != "" && credErr != nil {
761		cred = "ollama"
762		credErr = nil
763		requireCred = false
764	}
765
766	if credErr != nil && requireCred {
767		return Resolved{}, fmt.Errorf("%w; set %s_API_KEY, pass --api-key, or run `zut` and /login",
768			credErr, envVarName(provName))
769	}
770
771	sandbox := tools.NewSandbox(args.CWD)
772	if cfg.JailByDefault != nil && *cfg.JailByDefault {
773		sandbox.Lock()
774	}
775	if args.PermissionSet != nil {
776		sandbox.SetPermissions(args.PermissionSet)
777	}
778	lspEnabled := !args.NoLSP && cfg.LSPEnabledFor(false)
779	reg := buildToolRegistry(args, args.CWD, sandbox, lspEnabled, cfg.LSPDiagnosticsOnWriteEnabled(false), cfg.LSPDiagnosticsOnEditEnabled(false))
780
781	docsDir, _ := zutdocs.EnsureInstalled(ZutHome())
782
783	// Skill discovery: scan project + global locations + built-in
784	// skills shipped with the binary. If any are found, register
785	// the on-demand `skill` loader tool plus a system-prompt
786	// manifest so the model knows what's available.
787	//
788	// --no-skill bypasses the entire mechanism: no manifest in the
789	// system prompt, no `skill` tool in the registry. Useful for a
790	// clean-room run with zero extra context biasing the model.
791	var (
792		discovered    []*skills.Skill
793		skillTool     *skills.Tool
794		skillAddendum string
795		skillsEnabled bool
796	)
797	if !args.NoSkill {
798		skillsEnabled = true
799		homeDir, _ := os.UserHomeDir()
800		discovered, _ = skills.Discover(ZutHome(), args.CWD, homeDir, args.WithSkills)
801		if len(discovered) > 0 {
802			skillTool = skills.NewTool(discovered)
803			reg[skillTool.Name()] = skillTool
804			skillAddendum = skills.SystemPromptAddendum(discovered)
805		}
806	}
807	_ = skillTool
808
809	summaries := toolSummaries(reg, args)
810
811	contextFiles := []ContextFile(nil)
812	if !args.NoContextFiles {
813		contextFiles = loadAgentsContext(args.CWD, ZutHome())
814	}
815	append_ := []string(nil)
816	if cfg.PonytailModeEnabled() {
817		if ponytailAddendum := PonytailSystemAddendum(); ponytailAddendum != "" {
818			append_ = append(append_, ponytailAddendum)
819		}
820	}
821	append_ = append(append_, args.AppendSystemPrompt...)
822	if agentsAddendum := formatAgentsContext(contextFiles); agentsAddendum != "" {
823		append_ = append(append_, agentsAddendum)
824	}
825	if skillAddendum != "" {
826		append_ = append(append_, skillAddendum)
827	}
828	interactiveMode := args.Mode == "" || args.Mode == ModeInteractive
829	primaryInteractive := interactiveMode && !args.ResidentChild
830	primaryOrchestrator := args.Orchestrate && !args.ResidentChild
831	if (primaryInteractive || primaryOrchestrator) && autoSubagentsToolAllowed(args) {
832		homeDir, _ := os.UserHomeDir()
833		profiles, _ := subagents.Discover(args.CWD, homeDir)
834		if subagentsAddendum := subagents.SystemPromptAddendum(profiles); subagentsAddendum != "" {
835			append_ = append(append_, subagentsAddendum)
836		}
837	}
838	if primaryOrchestrator {
839		// Headless orchestration owns its strict prompt contract rather than
840		// inheriting the interactive collaboration setting.
841		append_ = append(append_, StrictOrchestratorSystemAddendumFor(
842			autoSubagentsToolAllowed(args),
843			autoSubagentsStopToolAllowed(args),
844			autoSubagentsResumeToolAllowed(args),
845		))
846	} else if primaryInteractive && cfg.AutoSubagentsEnabled != nil && *cfg.AutoSubagentsEnabled {
847		append_ = append(append_, ProactiveSubagentsSystemAddendumFor(
848			autoSubagentsToolAllowed(args),
849			autoSubagentsStopToolAllowed(args),
850			autoSubagentsResumeToolAllowed(args),
851		))
852	} else if primaryInteractive && autoSubagentsAnyToolAllowed(args) {
853		append_ = append(append_, OnDemandSubagentsSystemAddendum)
854	}
855	// Custom system prompt resolution order:
856	//   1. --system-prompt flag (highest priority; ad-hoc per run)
857	//   2. $ZUT_HOME/SYSTEM.md (persistent user override)
858	//   3. built-in default (defaultIdentity + defaultGuidelines)
859	custom := args.SystemPrompt
860	if custom == "" {
861		custom = readUserSystemPrompt(ZutHome())
862	}
863
864	sys := BuildSystemPrompt(SystemPromptOpts{
865		CWD:        args.CWD,
866		Tools:      summaries,
867		Custom:     custom,
868		Append:     append_,
869		ZutDocsDir: docsDir,
870	})
871
872	reasoning := provider.NormalizeReasoning(firstNonEmpty(args.Reasoning, cfg.Reasoning))
873	temperature := args.Temperature
874	if temperature == nil {
875		temperature = cfg.Temperature
876	}
877	fastMode := cfg.FastMode != nil && *cfg.FastMode
878	if args.FastModeSet {
879		fastMode = args.FastMode
880	}
881	max := args.MaxSteps // 0 = unlimited
882
883	return Resolved{
884		Provider:         provName,
885		Model:            model,
886		Credential:       cred,
887		AuthMethod:       method,
888		AccountID:        accountID,
889		BaseURL:          args.BaseURL,
890		InsecureTLS:      insecureTLS,
891		CWD:              args.CWD,
892		Reasoning:        reasoning,
893		Temperature:      temperature,
894		FastMode:         fastMode,
895		WebSearchPolicy:  webSearchPolicy,
896		ToolRegistry:     reg,
897		ToolSummary:      summaries,
898		SystemPrompt:     sys,
899		MaxSteps:         max,
900		ContextWindow:    resolvedModel.ContextWindow,
901		MaxOutput:        resolvedModel.MaxOutput,
902		Sandbox:          sandbox,
903		SkillTool:        skillTool,
904		skillsEnabled:    skillsEnabled,
905		ContextFiles:     contextFiles,
906		systemAppend:     append_,
907		systemCustom:     custom,
908		skillAddendum:    skillAddendum,
909		toolDescriptions: descMapFromSummaries(summaries),
910	}, nil
911}
912
913// readUserSystemPrompt looks for $ZUT_HOME/SYSTEM.md and returns its
914// trimmed contents, or "" when the file is missing / unreadable /
915// empty. Errors are intentionally swallowed: the file is optional,
916// and any failure to read it should fall back to the built-in
917// default system prompt rather than crash the run.
918func readUserSystemPrompt(zutHome string) string {
919	if zutHome == "" {
920		return ""
921	}
922	path := filepath.Join(zutHome, "SYSTEM.md")
923	raw, err := os.ReadFile(path)
924	if err != nil {
925		return ""
926	}
927	return strings.TrimSpace(string(raw))
928}
929
930// loadAgentsContext loads optional AGENTS.md instruction files. No
931// default file is created or required: zut only includes files that
932// already exist. Global instructions ($ZUT_HOME/AGENTS.md) come first,
933// followed by project instructions from the filesystem root down to cwd.
934func loadAgentsContext(cwd, zutHome string) []ContextFile {
935	var files []ContextFile
936	seen := map[string]bool{}
937	add := func(path string) {
938		if path == "" {
939			return
940		}
941		abs, err := filepath.Abs(path)
942		if err == nil {
943			path = abs
944		}
945		if seen[path] {
946			return
947		}
948		raw, err := os.ReadFile(path)
949		if err != nil {
950			return
951		}
952		content := strings.TrimSpace(string(raw))
953		if content == "" {
954			return
955		}
956		seen[path] = true
957		files = append(files, ContextFile{Path: path, Content: content})
958	}
959	addFirstFromDir := func(dir string) {
960		if dir == "" {
961			return
962		}
963		for _, name := range []string{"AGENTS.md", "AGENTS.MD"} {
964			path := filepath.Join(dir, name)
965			if _, err := os.Stat(path); err == nil {
966				add(path)
967				return
968			}
969		}
970	}
971
972	addFirstFromDir(zutHome)
973
974	if cwd != "" {
975		abs, err := filepath.Abs(cwd)
976		if err == nil {
977			cwd = abs
978		}
979		var dirs []string
980		for dir := filepath.Clean(cwd); ; dir = filepath.Dir(dir) {
981			dirs = append(dirs, dir)
982			parent := filepath.Dir(dir)
983			if parent == dir {
984				break
985			}
986		}
987		for i := len(dirs) - 1; i >= 0; i-- {
988			addFirstFromDir(dirs[i])
989		}
990	}
991
992	return files
993}
994
995func formatAgentsContext(files []ContextFile) string {
996	if len(files) == 0 {
997		return ""
998	}
999	var sb strings.Builder
1000	sb.WriteString("Project context instructions loaded from AGENTS.md files. Follow them when working in this repository. Later files are more specific and may override earlier ones.\n")
1001	for _, f := range files {
1002		fmt.Fprintf(&sb, "\n## %s\n\n%s\n", f.Path, f.Content)
1003	}
1004	return strings.TrimSpace(sb.String())
1005}
1006
1007// descMapFromSummaries indexes the human-readable descriptions for
1008// the renderToolsSection rebuild path.
1009func descMapFromSummaries(summaries []ToolSummary) map[string]string {
1010	out := make(map[string]string, len(summaries))
1011	for _, s := range summaries {
1012		out[s.Name] = s.Description
1013	}
1014	return out
1015}
1016
1017// NewClient returns a provider.Client for r, choosing the auth mode
1018// based on r.AuthMethod. Panics if no credential is present; callers
1019// must check HasCredential() first.
1020func (r Resolved) NewClient() provider.Client {
1021	if !r.HasCredential() {
1022		panic("NewClient called without credential; check HasCredential first")
1023	}
1024	wrap := r.withHTTPClient
1025	switch r.Provider {
1026	case "ollama":
1027		return wrap(provider.NewOpenAICompat("ollama", r.Credential, r.BaseURL, ""))
1028	case provider.LlamaCPPProviderID:
1029		credential := r.Credential
1030		if credential == "local" {
1031			credential = ""
1032		}
1033		return wrap(provider.NewOpenAICompat(provider.LlamaCPPProviderID, credential, r.BaseURL, ""))
1034	case "kimi":
1035		// kimi-coding speaks anthropic-messages on api.kimi.com/coding.
1036		// Subscription OAuth (refreshed) wraps the same Anthropic-shaped client.
1037		inner := wrap(provider.NewKimiCodingWithHeaders(r.Credential, r.BaseURL, kimiCodeHeaders()))
1038		if r.AuthMethod == "oauth" {
1039			return r.wrapWithRefresh(inner)
1040		}
1041		return inner
1042	case "moonshotai":
1043		return wrap(provider.NewMoonshot(r.Credential, r.BaseURL))
1044	case "moonshotai-cn":
1045		return wrap(provider.NewMoonshotCN(r.Credential, r.BaseURL))
1046	case "deepseek":
1047		return wrap(provider.NewDeepSeek(r.Credential, r.BaseURL))
1048	case "openai":
1049		return provider.NewModelRouter("openai",
1050			wrap(provider.NewOpenAI(r.Credential, r.BaseURL)),
1051			map[string]provider.Client{
1052				provider.APIResponses: wrap(provider.NewOpenAIResponsesNamed(r.Credential, r.BaseURL, "openai")),
1053			})
1054	case "openai-codex":
1055		inner := wrap(provider.NewOpenAICodex(r.Credential, r.AccountID, r.BaseURL))
1056		return r.wrapWithRefresh(inner)
1057	case "openai-responses":
1058		// Public OpenAI Responses API (api.openai.com/v1/responses) via
1059		// API key. Separate provider from `openai` (Chat Completions) and
1060		// from `openai-codex` (ChatGPT subscription OAuth).
1061		return wrap(provider.NewOpenAIResponses(r.Credential, r.BaseURL))
1062	case "google":
1063		return wrap(provider.NewGemini(r.Credential, r.BaseURL))
1064	case "cerebras":
1065		return wrap(provider.NewCerebras(r.Credential, r.BaseURL))
1066	case "groq":
1067		return wrap(provider.NewGroq(r.Credential, r.BaseURL))
1068	case "xai":
1069		inner := r.newXAIClient(r.Credential)
1070		if r.AuthMethod == "oauth" {
1071			return r.wrapWithRefresh(inner)
1072		}
1073		return inner
1074	case "together":
1075		return wrap(provider.NewTogether(r.Credential, r.BaseURL))
1076	case "huggingface":
1077		return wrap(provider.NewHuggingFace(r.Credential, r.BaseURL))
1078	case "openrouter":
1079		return wrap(provider.NewOpenRouter(r.Credential, r.BaseURL))
1080	case "zai":
1081		return wrap(provider.NewZAI(r.Credential, r.BaseURL))
1082	case "xiaomi":
1083		return wrap(provider.NewXiaomi(r.Credential, r.BaseURL))
1084	case "xiaomi-token-plan-ams":
1085		return wrap(provider.NewXiaomiTokenPlan("ams", r.Credential, r.BaseURL))
1086	case "xiaomi-token-plan-cn":
1087		return wrap(provider.NewXiaomiTokenPlan("cn", r.Credential, r.BaseURL))
1088	case "xiaomi-token-plan-sgp":
1089		return wrap(provider.NewXiaomiTokenPlan("sgp", r.Credential, r.BaseURL))
1090	case "opencode":
1091		return wrap(provider.NewOpenCode(r.Credential, r.BaseURL))
1092	case "opencode-go":
1093		return wrap(provider.NewOpenCodeGo(r.Credential, r.BaseURL))
1094	case "minimax":
1095		return wrap(provider.NewMinimaxAnthropic(r.Credential, r.BaseURL))
1096	case "minimax-cn":
1097		return wrap(provider.NewMinimaxCNAnthropic(r.Credential, r.BaseURL))
1098	case "fireworks":
1099		return wrap(provider.NewFireworksAnthropic(r.Credential, r.BaseURL))
1100	case "vercel-ai-gateway":
1101		return wrap(provider.NewVercelGatewayAnthropic(r.Credential, r.BaseURL))
1102	case "mistral":
1103		return wrap(provider.NewMistral(r.Credential, r.BaseURL))
1104	case "amazon-bedrock":
1105		return wrap(provider.NewBedrock(r.Credential, r.BaseURL))
1106	case "google-vertex":
1107		return wrap(provider.NewGoogleVertex(r.Credential, r.BaseURL))
1108	case "azure-openai-responses":
1109		return wrap(provider.NewAzureOpenAIResponses(r.Credential, r.BaseURL))
1110	case "github-copilot":
1111		return wrap(provider.NewGithubCopilot(r.Credential, r.BaseURL))
1112	case "cloudflare-workers-ai":
1113		return wrap(provider.NewCloudflareWorkersAI(r.Credential, r.BaseURL))
1114	case "cloudflare-ai-gateway":
1115		return wrap(provider.NewCloudflareAIGateway(r.Credential, r.BaseURL))
1116	default:
1117		// Custom providers: choose wire format from the models.json api field.
1118		if cfg, ok := provider.CustomProviders()[r.Provider]; ok {
1119			switch cfg.API {
1120			case provider.APIResponses:
1121				return wrap(provider.NewOpenAIResponsesNamed(r.Credential, r.BaseURL, r.Provider))
1122			case "anthropic":
1123				return wrap(provider.NewAnthropicCompat(r.Provider, r.Credential, r.BaseURL))
1124			default: // "openai"
1125				return wrap(provider.NewOpenAICompat(r.Provider, r.Credential, r.BaseURL, ""))
1126			}
1127		}
1128		if r.AuthMethod == "oauth" {
1129			inner := wrap(provider.NewAnthropicOAuth(r.Credential, r.BaseURL))
1130			return r.wrapWithRefresh(inner)
1131		}
1132		return wrap(provider.NewAnthropic(r.Credential, r.BaseURL))
1133	}
1134}
1135
1136func (r Resolved) withHTTPClient(c provider.Client) provider.Client {
1137	if !r.InsecureTLS {
1138		return c
1139	}
1140	return provider.WithHTTPClient(c, provider.NewHTTPClient(true))
1141}
1142
1143func (r Resolved) newXAIClient(token string) provider.Client {
1144	return provider.NewModelRouter("xai",
1145		r.withHTTPClient(provider.NewXAI(token, r.BaseURL)),
1146		map[string]provider.Client{
1147			provider.APIResponses: r.withHTTPClient(provider.NewOpenAIResponsesNamed(token, r.BaseURL, "xai")),
1148		})
1149}
1150
1151// wrapWithRefresh wraps an OAuth client so the access token is
1152// refreshed automatically before each API call. Without this, long
1153// sessions (hours) silently fail when the 1-hour token expires.
1154func (r Resolved) wrapWithRefresh(inner provider.Client) provider.Client {
1155	provName := r.Provider
1156	tokenProvider := provName
1157	if provName == "openai-codex" {
1158		tokenProvider = "openai"
1159	}
1160	baseURL := r.BaseURL
1161	accountID := r.AccountID
1162
1163	refreshFn := func(ctx context.Context) (string, error) {
1164		tok, err := refreshIfExpired(tokenProvider, loadOAuthToken(tokenProvider))
1165		if err != nil {
1166			return "", err
1167		}
1168		return tok.AccessToken, nil
1169	}
1170
1171	factory := func(token string) provider.Client {
1172		switch provName {
1173		case "openai-codex":
1174			return r.withHTTPClient(provider.NewOpenAICodex(token, accountID, baseURL))
1175		case "kimi":
1176			// anthropic-messages on api.kimi.com/coding.
1177			return r.withHTTPClient(provider.NewKimiCodingWithHeaders(token, baseURL, kimiCodeHeaders()))
1178		case "xai":
1179			return r.newXAIClient(token)
1180		default:
1181			return r.withHTTPClient(provider.NewAnthropicOAuth(token, baseURL))
1182		}
1183	}
1184
1185	return provider.NewRefreshingClient(inner, refreshFn, factory)
1186}
1187
1188// UseSandbox replaces the sandbox pointer that every tool in r's
1189// registry references. Used to keep the /jail state stable across
1190// agent rebuilds (e.g. /login, /model switching providers).
1191func (r *Resolved) UseSandbox(s *tools.Sandbox) {
1192	if s == nil || r == nil {
1193		return
1194	}
1195	if r.Sandbox != nil && r.Sandbox.Permissions != nil {
1196		s.Permissions = r.Sandbox.Permissions
1197	}
1198	r.Sandbox = s
1199	for name, t := range r.ToolRegistry {
1200		switch v := t.(type) {
1201		case *tools.ReadTool:
1202			v.Sandbox = s
1203		case *tools.WriteTool:
1204			v.Sandbox = s
1205		case *tools.EditTool:
1206			v.Sandbox = s
1207		case *tools.BashTool:
1208			v.Sandbox = s
1209		case *tools.CreateWorktreeTool:
1210			v.Sandbox = s
1211		case *tools.LSPTool:
1212			v.Sandbox = s
1213		case *tools.GrepTool:
1214			v.Sandbox = s
1215		}
1216		_ = name
1217	}
1218}
1219
1220// NewAgent constructs a core.Agent from r. Requires a credential.
1221func (r Resolved) NewAgent() *core.Agent {
1222	a := core.NewAgent(r.NewClient(), r.Model, r.SystemPrompt, r.ToolRegistry)
1223	a.MaxSteps = r.MaxSteps
1224	a.ContextWindow = r.ContextWindow
1225	a.MaxTokens = r.MaxOutput
1226	a.Reasoning = r.Reasoning
1227	a.Temperature = r.Temperature
1228	a.FastMode = r.FastMode
1229	return a
1230}
1231
1232func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox, lspEnabled, diagnosticsOnWrite, diagnosticsOnEdit bool) core.Registry {
1233	if args.NoTools {
1234		return core.Registry{}
1235	}
1236	var manager *lsp.Manager
1237	if lspEnabled && lspManagerNeeded(args, diagnosticsOnWrite, diagnosticsOnEdit) {
1238		options := lsp.ManagerOptions{}
1239		if sandbox != nil {
1240			options.CheckWritePath = sandbox.CheckWritePath
1241		}
1242		manager = lsp.NewManagerWithOptions(options)
1243	}
1244	all := map[string]core.Tool{
1245		"read":            &tools.ReadTool{CWD: cwd, Sandbox: sandbox},
1246		"write":           &tools.WriteTool{CWD: cwd, Sandbox: sandbox, LSP: manager, LSPDiagnostics: diagnosticsOnWrite},
1247		"edit":            &tools.EditTool{CWD: cwd, Sandbox: sandbox, LSP: manager, LSPDiagnostics: diagnosticsOnEdit},
1248		"bash":            &tools.BashTool{CWD: cwd, Sandbox: sandbox},
1249		"create_worktree": &tools.CreateWorktreeTool{CWD: cwd, Sandbox: sandbox},
1250		"grep":            &tools.GrepTool{CWD: cwd, Sandbox: sandbox},
1251		"update_goal":     &tools.UpdateGoalTool{},
1252		"update_plan":     &tools.UpdatePlanTool{},
1253	}
1254	if webSearchAllowedForRegistry(args) {
1255		for name, tool := range tools.NewWebTools() {
1256			all[name] = tool
1257		}
1258	}
1259	if manager != nil {
1260		lspTool := tools.NewLSPTool(cwd, manager)
1261		lspTool.Sandbox = sandbox
1262		all["lsp"] = lspTool
1263	}
1264	reg := core.Registry{}
1265	if len(args.Tools) == 0 {
1266		for _, t := range all {
1267			reg[t.Name()] = t
1268		}
1269	} else {
1270		for _, name := range args.Tools {
1271			if name == "web_search" {
1272				for webName, tool := range all {
1273					if tools.IsWebCapabilityName(webName) {
1274						reg[webName] = tool
1275					}
1276				}
1277				continue
1278			}
1279			if t, ok := all[name]; ok {
1280				reg[name] = t
1281			}
1282		}
1283	}
1284	if orchestratorExplorationToolsAllowed(args) {
1285		reg["read"] = all["read"]
1286		reg["grep"] = all["grep"]
1287		if args.PermissionSet == nil {
1288			for name, tool := range tools.NewWebTools() {
1289				reg[name] = tool
1290			}
1291		}
1292	}
1293	return reg
1294}
1295
1296func lspManagerNeeded(args Args, diagnosticsOnWrite, diagnosticsOnEdit bool) bool {
1297	if len(args.Tools) == 0 {
1298		return true
1299	}
1300	for _, name := range args.Tools {
1301		switch name {
1302		case "lsp":
1303			return true
1304		case "write":
1305			if diagnosticsOnWrite {
1306				return true
1307			}
1308		case "edit":
1309			if diagnosticsOnEdit {
1310				return true
1311			}
1312		}
1313	}
1314	return false
1315}
1316
1317func autoSubagentsToolAllowed(args Args) bool {
1318	return autoSubagentsToolAllowedFor(args, "subagent_spawn")
1319}
1320
1321// orchestratorExplorationToolsAllowed keeps the primary orchestrator's
1322// read-only exploration tools available without enabling implementation tools.
1323func orchestratorExplorationToolsAllowed(args Args) bool {
1324	return args.Orchestrate && !args.NoTools
1325}
1326
1327func autoSubagentsStatusToolAllowed(args Args) bool {
1328	return autoSubagentsToolAllowedFor(args, "subagent_status")
1329}
1330
1331func autoSubagentsStopToolAllowed(args Args) bool {
1332	return autoSubagentsToolAllowedFor(args, "subagent_stop")
1333}
1334
1335func autoSubagentsResumeToolAllowed(args Args) bool {
1336	return autoSubagentsToolAllowedFor(args, "subagent_resume")
1337}
1338
1339func autoSubagentsAnyToolAllowed(args Args) bool {
1340	return autoSubagentsToolAllowed(args) ||
1341		autoSubagentsStatusToolAllowed(args) ||
1342		autoSubagentsStopToolAllowed(args) ||
1343		autoSubagentsResumeToolAllowed(args)
1344}
1345
1346func autoSubagentsToolAllowedFor(args Args, toolName string) bool {
1347	if args.NoTools || args.PermissionSet != nil {
1348		return false
1349	}
1350	if args.ToolsSet && len(args.Tools) == 0 {
1351		return false
1352	}
1353	if len(args.Tools) == 0 {
1354		return true
1355	}
1356	for _, name := range args.Tools {
1357		if name == toolName {
1358			return true
1359		}
1360	}
1361	return false
1362}
1363
1364var nativeToolSummaryOrder = []string{"read", "write", "edit", "grep", "bash", "create_worktree", "lsp", "web_search", "web_open", "web_find", "web_click", "update_goal", "update_plan"}
1365
1366func toolSummaries(reg core.Registry, args Args) []ToolSummary {
1367	var out []ToolSummary
1368	for _, name := range nativeToolSummaryOrder {
1369		if t, ok := reg[name]; ok {
1370			out = append(out, ToolSummary{Name: t.Name(), Description: t.Description()})
1371		}
1372	}
1373	return out
1374}
1375
1376func firstNonEmpty(vals ...string) string {
1377	for _, v := range vals {
1378		if v != "" {
1379			return v
1380		}
1381	}
1382	return ""
1383}
1384
1385func kimiCodeHeaders() map[string]string {
1386	host, _ := os.Hostname()
1387	if host == "" {
1388		host = "unknown"
1389	}
1390	deviceID := ""
1391	if home, err := os.UserHomeDir(); err == nil {
1392		if b, err := os.ReadFile(filepath.Join(home, ".kimi", "device_id")); err == nil {
1393			deviceID = strings.TrimSpace(string(b))
1394		}
1395	}
1396	if deviceID == "" {
1397		deviceID = "zut"
1398	}
1399	return map[string]string{
1400		"User-Agent":         "KimiCLI/1.41.0",
1401		"X-Msh-Platform":     "kimi_cli",
1402		"X-Msh-Version":      "1.41.0",
1403		"X-Msh-Device-Name":  host,
1404		"X-Msh-Device-Model": runtime.GOOS + "-" + runtime.GOARCH,
1405		"X-Msh-Os-Version":   runtime.GOOS,
1406		"X-Msh-Device-Id":    deviceID,
1407	}
1408}
1409
1410func envVarName(provider string) string {
1411	switch provider {
1412	case "openai", "openai-codex", "openai-responses":
1413		return "OPENAI"
1414	case "kimi":
1415		return "KIMI"
1416	case "deepseek":
1417		return "DEEPSEEK"
1418	case "google":
1419		return "GEMINI"
1420	case "ollama":
1421		return "OLLAMA"
1422	case "llama.cpp":
1423		return "LLAMA"
1424	case "moonshotai", "moonshotai-cn":
1425		return "MOONSHOT"
1426	case "groq":
1427		return "GROQ"
1428	case "xai":
1429		return "XAI"
1430	case "cerebras":
1431		return "CEREBRAS"
1432	case "together":
1433		return "TOGETHER"
1434	case "huggingface":
1435		return "HF"
1436	case "openrouter":
1437		return "OPENROUTER"
1438	case "mistral":
1439		return "MISTRAL"
1440	case "zai":
1441		return "ZAI"
1442	case "xiaomi":
1443		return "XIAOMI"
1444	case "xiaomi-token-plan-ams":
1445		return "XIAOMI_TOKEN_PLAN_AMS"
1446	case "xiaomi-token-plan-cn":
1447		return "XIAOMI_TOKEN_PLAN_CN"
1448	case "xiaomi-token-plan-sgp":
1449		return "XIAOMI_TOKEN_PLAN_SGP"
1450	case "minimax":
1451		return "MINIMAX"
1452	case "minimax-cn":
1453		return "MINIMAX_CN"
1454	case "fireworks":
1455		return "FIREWORKS"
1456	case "vercel-ai-gateway":
1457		return "AI_GATEWAY"
1458	case "opencode", "opencode-go":
1459		return "OPENCODE"
1460	case "github-copilot":
1461		return "COPILOT_GITHUB_TOKEN"
1462	case "cloudflare-workers-ai", "cloudflare-ai-gateway":
1463		return "CLOUDFLARE"
1464	case "amazon-bedrock":
1465		return "AWS"
1466	case "google-vertex":
1467		return "GOOGLE_CLOUD"
1468	case "azure-openai-responses":
1469		return "AZURE_OPENAI"
1470	default:
1471		return "ANTHROPIC"
1472	}
1473}

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