v0.24.0 3dbf466

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 == "update_goal" {
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 compatibility locations)", 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	// Programmatic callers historically supplied Tools without the parser's
498	// provenance bit. Treat a non-empty list as explicit before a named profile
499	// can contribute its defaults.
500	if len(args.Tools) > 0 {
501		args.ToolsSet = true
502	}
503	webSearchPolicy := resolveWebSearchPolicy(args, cfg, cfgErr)
504	args.WebSearchPolicy = webSearchPolicy
505
506	// User-requested provider (explicit > config > default).
507	// Normalise common aliases (e.g. "bedrock" -> "amazon-bedrock")
508	// before validation so an alias is never mistaken for an unknown
509	// provider and silently downgraded to anthropic.
510	argProvider := canonicalProvider(args.Provider)
511	provName := firstNonEmpty(argProvider, canonicalProvider(cfg.Provider), "anthropic")
512	if !isKnownProvider(provName) {
513		// Unknown provider (maybe removed or renamed). Fall back to
514		// the first provider that has credentials, or anthropic.
515		// Custom providers (from models.json) are already accepted
516		// by isKnownProvider, so we only reach here for truly unknown
517		// names.
518		provName = "anthropic"
519		if CredentialAvailable("openai") {
520			provName = "openai"
521		}
522		if CredentialAvailable("openai-codex") {
523			provName = "openai-codex"
524		}
525		if CredentialAvailable("kimi") {
526			provName = "kimi"
527		}
528		if CredentialAvailable("deepseek") {
529			provName = "deepseek"
530		}
531		if CredentialAvailable("anthropic") {
532			provName = "anthropic"
533		}
534		// Reset the saved config so this doesn't keep happening.
535		cfg.Provider = provName
536		cfg.Model = ""
537		_ = SaveConfig(cfg)
538	}
539
540	var (
541		cred      string
542		method    string
543		accountID string
544		credErr   error
545	)
546	if provName == "ollama" {
547		cred = firstNonEmpty(args.APIKey, "ollama")
548		method = "apikey"
549	} else {
550		cred, method, accountID, credErr = ResolveCredentialFull(provName, args.APIKey)
551	}
552
553	// Persist --api-key for custom providers so subsequent runs don't
554	// need to pass it again.
555	if !isBuiltinProvider(provName) && args.APIKey != "" {
556		if store := AuthStoreFor(); store != nil {
557			_ = store.SetAPIKey(provName, args.APIKey)
558		}
559	}
560
561	// If the user did NOT explicitly pick a provider (neither via --provider
562	// nor by saving one in config.json) and the default one has no
563	// credentials, auto-fall-back to whichever provider is actually logged
564	// in. That way running plain `zut` after `/login` (any provider) never
565	// shows a "not logged in" banner.
566	//
567	// Critical: when the user HAS saved a provider in config.json (e.g.
568	// "deepseek" with "deepseek-v4-flash"), that choice is respected even
569	// if no credential is found — the tui will show the login dialog or
570	// surface a clear credential error rather than silently switching to
571	// a completely different provider and model.
572	// autoFellBack tracks whether the provider was changed by the
573	// auto-fallback loop (no explicit provider from CLI or config,
574	// and the default had no credentials). When false, the provider
575	// came from the user's explicit choice (--provider or config.json)
576	// and must be respected, including any model id that may belong
577	// to a different provider in the catalog — for example when using
578	// a gateway/router like OpenRouter with "deepseek/deepseek-v4-flash".
579	var autoFellBack bool
580	userPickedProvider := args.Provider != "" || cfg.Provider != ""
581	if credErr != nil && !userPickedProvider && provName != "ollama" {
582		autoFellBack = true
583		// Scan every known provider (not a hardcoded subset) so any
584		// env-based credential is discovered, e.g. an env-only
585		// amazon-bedrock setup (AWS_BEARER_TOKEN_BEDROCK / AWS_PROFILE /
586		// IAM keys) when no config.json pins the provider, such as after
587		// pointing ZUT_HOME at a fresh home dir. Iteration order of
588		// knownProviders defines fallback priority. Local providers without a
589		// default model are skipped because selecting either one here would
590		// fail before the user can choose a model.
591		for _, other := range knownProviders {
592			if other == provName || other == "ollama" || other == provider.LlamaCPPProviderID {
593				continue
594			}
595			if !CredentialAvailable(other) {
596				continue
597			}
598			// An explicit launch key belongs to the initially selected provider;
599			// automatic fallback must never reuse it for another provider.
600			c, m, a, err := ResolveCredentialFull(other, "")
601			provName = other
602			cred, method, accountID, credErr = c, m, a, err
603			break
604		}
605	}
606
607	model := firstNonEmpty(args.Model, cfg.Model)
608	if model == "" {
609		if provName == "ollama" || provName == provider.LlamaCPPProviderID {
610			return Resolved{}, fmt.Errorf("%s requires --model or a model selected from its manager", provName)
611		}
612		model = defaultModelForProvider(provName)
613	}
614	// If the resolved model belongs to a different provider (e.g. config
615	// says gpt-5 but we auto-fell back to anthropic), pick that provider's
616	// default. This override only fires when the provider was auto-fallback-
617	// changed, NOT when the user explicitly configured the pair — gateway/
618	// router providers (openrouter, vercel-ai-gateway, etc.) can serve
619	// models from any provider in the catalog.
620	if autoFellBack && provName != "ollama" {
621		if m, err := provider.FindModel("", model); err == nil && m.Provider != provName {
622			model = defaultModelForProvider(provName)
623		}
624	}
625	resolvedModel, err := provider.FindModel(provName, model)
626	if err != nil && (provName == "ollama" || provName == provider.LlamaCPPProviderID) {
627		// Local providers are intentionally open-catalogue: any model id the
628		// configured server understands is valid, even if it is not cached.
629		resolvedModel = provider.Model{
630			Provider:      provName,
631			ID:            model,
632			DisplayName:   model,
633			ContextWindow: 128000,
634			MaxOutput:     16384,
635			BaseURL:       args.BaseURL,
636			Source:        provName,
637		}
638		err = nil
639	}
640	// Custom providers are open-catalogue like ollama: any model id the
641	// endpoint understands is valid. Use the provider-level base URL.
642	if err != nil {
643		if cfg, ok := provider.CustomProviders()[provName]; ok {
644			resolvedModel = provider.Model{
645				Provider:      provName,
646				ID:            model,
647				DisplayName:   model,
648				ContextWindow: 128000,
649				MaxOutput:     16384,
650				BaseURL:       cfg.BaseURL,
651				Source:        "user",
652			}
653			err = nil
654		}
655	}
656	if cfg, ok := provider.CustomProviders()[provName]; ok && err == nil && resolvedModel.BaseURL == "" && cfg.BaseURL != "" {
657		// Fall back to the provider-level base URL when the model does
658		// not define its own endpoint.
659		resolvedModel.BaseURL = cfg.BaseURL
660	}
661	if err != nil {
662		// The model the user (or persisted config) asked for is no
663		// longer in the active catalogue — they probably removed it
664		// from their models.json or upgraded zut and the id changed.
665		// Refusing to launch is the wrong move: it strands the user
666		// with no way to even open the TUI and pick a new model.
667		// Fall back to the provider's default, warn on stderr, and,
668		// when the stale id came from the persisted config (not an
669		// explicit --model flag), repair the config so the warning
670		// doesn't repeat on every launch.
671
672		// Gateway providers can accept route-qualified ids that are not in
673		// zut's local catalog yet, for example OpenRouter's
674		// "deepseek/deepseek-v4-flash". Preserve only route-qualified ids;
675		// plain unknown values are likely typos and should still fall back.
676		if isGatewayProvider(provName) && isGatewayRoutedModelID(model) {
677			resolvedModel = provider.Model{
678				Provider:      provName,
679				ID:            model,
680				DisplayName:   model,
681				ContextWindow: 1000000,
682				MaxOutput:     64000,
683				BaseURL:       "",
684				Source:        "gateway",
685			}
686			// Don't repair config — the routed model id may be valid upstream.
687		} else {
688			fallback := defaultModelForProvider(provName)
689			fm, ferr := provider.FindModel(provName, fallback)
690			if ferr != nil {
691				// Even the provider default is gone (catastrophic
692				// catalogue trim). Last resort: any model on this
693				// provider, then the global DefaultModel.
694				if candidates := provider.ModelsForProvider(provName); len(candidates) > 0 {
695					fm = candidates[0]
696				} else {
697					fm = provider.DefaultModel
698				}
699			}
700			fmt.Fprintf(os.Stderr,
701				"zut: model %q is not in the active catalogue; using %q instead. Pick a different model with --model or /model.\n",
702				model, fm.ID)
703			if args.Model == "" && cfg.Model == model {
704				cfg.Model = fm.ID
705				_ = SaveConfig(cfg)
706			}
707			resolvedModel = fm
708			model = fm.ID
709		}
710	}
711
712	explicitBaseURL := args.BaseURL != "" || (resolvedModel.Source == "user" && resolvedModel.BaseURL != "")
713
714	// If the model defines a base URL (e.g. local ollama) and the
715	// user didn't pass --base-url, use the model's URL. For ollama,
716	// keep http://localhost:11434 as a fallback only after the model
717	// metadata has had a chance to provide a custom baseUrl.
718	if args.BaseURL == "" && resolvedModel.BaseURL != "" {
719		args.BaseURL = resolvedModel.BaseURL
720	}
721	if args.BaseURL == "" && provName == "ollama" {
722		args.BaseURL = "http://localhost:11434"
723	}
724	if args.BaseURL == "" && provName == provider.LlamaCPPProviderID {
725		managementURL, _, configErr := ResolveLlamaCPPConfig()
726		if configErr != nil {
727			return Resolved{}, configErr
728		}
729		var baseErr error
730		args.BaseURL, baseErr = provider.LlamaCPPInferenceURL(managementURL)
731		if baseErr != nil {
732			return Resolved{}, baseErr
733		}
734	}
735
736	// Insecure TLS is intentionally scoped to explicit custom endpoints.
737	// Built-in provider base URLs, auth calls, and model discovery keep normal
738	// certificate verification even when --insecure is present.
739	insecureTLS := (args.InsecureTLS || cfg.Insecure) && explicitBaseURL
740
741	// If the model has a base URL, credentials are optional (local
742	// models like ollama don't need real API keys).
743	if resolvedModel.BaseURL != "" && credErr != nil {
744		cred = "ollama"
745		credErr = nil
746		requireCred = false
747	}
748
749	if credErr != nil && requireCred {
750		return Resolved{}, fmt.Errorf("%w; set %s_API_KEY, pass --api-key, or run `zut` and /login",
751			credErr, envVarName(provName))
752	}
753
754	sandbox := tools.NewSandbox(args.CWD)
755	if cfg.JailByDefault != nil && *cfg.JailByDefault {
756		sandbox.Lock()
757	}
758	if args.PermissionSet != nil {
759		sandbox.SetPermissions(args.PermissionSet)
760	}
761	lspEnabled := !args.NoLSP && cfg.LSPEnabledFor(false)
762	reg := buildToolRegistry(args, args.CWD, sandbox, lspEnabled, cfg.LSPDiagnosticsOnWriteEnabled(false), cfg.LSPDiagnosticsOnEditEnabled(false))
763
764	docsDir, _ := zutdocs.EnsureInstalled(ZutHome())
765
766	// Skill discovery: scan project + global locations + built-in
767	// skills shipped with the binary. If any are found, register
768	// the on-demand `skill` loader tool plus a system-prompt
769	// manifest so the model knows what's available.
770	//
771	// --no-skill bypasses the entire mechanism: no manifest in the
772	// system prompt, no `skill` tool in the registry. Useful for a
773	// clean-room run with zero extra context biasing the model.
774	var (
775		discovered    []*skills.Skill
776		skillTool     *skills.Tool
777		skillAddendum string
778		skillsEnabled bool
779	)
780	if !args.NoSkill {
781		skillsEnabled = true
782		homeDir, _ := os.UserHomeDir()
783		discovered, _ = skills.Discover(ZutHome(), args.CWD, homeDir, args.WithSkills)
784		if len(discovered) > 0 {
785			skillTool = skills.NewTool(discovered)
786			reg[skillTool.Name()] = skillTool
787			skillAddendum = skills.SystemPromptAddendum(discovered)
788		}
789	}
790	_ = skillTool
791
792	summaries := toolSummaries(reg, args)
793
794	contextFiles := []ContextFile(nil)
795	if !args.NoContextFiles {
796		contextFiles = loadAgentsContext(args.CWD, ZutHome())
797	}
798	append_ := []string(nil)
799	if cfg.PonytailModeEnabled() {
800		if ponytailAddendum := PonytailSystemAddendum(); ponytailAddendum != "" {
801			append_ = append(append_, ponytailAddendum)
802		}
803	}
804	append_ = append(append_, args.AppendSystemPrompt...)
805	if agentsAddendum := formatAgentsContext(contextFiles); agentsAddendum != "" {
806		append_ = append(append_, agentsAddendum)
807	}
808	if skillAddendum != "" {
809		append_ = append(append_, skillAddendum)
810	}
811	interactiveMode := args.Mode == "" || args.Mode == ModeInteractive
812	primaryInteractive := interactiveMode
813	primaryOrchestrator := args.Orchestrate
814	if (primaryInteractive || primaryOrchestrator) && autoSubagentsToolAllowed(args) {
815		homeDir, _ := os.UserHomeDir()
816		profiles, _ := subagents.Discover(args.CWD, homeDir)
817		if subagentsAddendum := subagents.SystemPromptAddendum(profiles); subagentsAddendum != "" {
818			append_ = append(append_, subagentsAddendum)
819		}
820	}
821	if primaryOrchestrator {
822		// Headless orchestration owns its strict prompt contract rather than
823		// inheriting the interactive setting.
824		append_ = append(append_, AutoSubagentsSystemAddendumFor(
825			autoSubagentsToolAllowed(args),
826			autoSubagentsStopToolAllowed(args),
827			autoSubagentsResumeToolAllowed(args),
828		))
829	} else if primaryInteractive && cfg.AutoSubagentsEnabled != nil && *cfg.AutoSubagentsEnabled {
830		append_ = append(append_, AutoSubagentsSystemAddendumFor(
831			autoSubagentsToolAllowed(args),
832			autoSubagentsStopToolAllowed(args),
833			autoSubagentsResumeToolAllowed(args),
834		))
835	} else if primaryInteractive && autoSubagentsAnyToolAllowed(args) {
836		append_ = append(append_, OnDemandSubagentsSystemAddendum)
837	}
838	// Custom system prompt resolution order:
839	//   1. --system-prompt flag (highest priority; ad-hoc per run)
840	//   2. $ZUT_HOME/SYSTEM.md (persistent user override)
841	//   3. built-in default (defaultIdentity + defaultGuidelines)
842	custom := args.SystemPrompt
843	if custom == "" {
844		custom = readUserSystemPrompt(ZutHome())
845	}
846
847	sys := BuildSystemPrompt(SystemPromptOpts{
848		CWD:        args.CWD,
849		Tools:      summaries,
850		Custom:     custom,
851		Append:     append_,
852		ZutDocsDir: docsDir,
853	})
854
855	reasoning := provider.NormalizeReasoning(firstNonEmpty(args.Reasoning, cfg.Reasoning))
856	temperature := args.Temperature
857	if temperature == nil {
858		temperature = cfg.Temperature
859	}
860	fastMode := cfg.FastMode != nil && *cfg.FastMode
861	if args.FastModeSet {
862		fastMode = args.FastMode
863	}
864	max := args.MaxSteps // 0 = unlimited
865
866	return Resolved{
867		Provider:         provName,
868		Model:            model,
869		Credential:       cred,
870		AuthMethod:       method,
871		AccountID:        accountID,
872		BaseURL:          args.BaseURL,
873		InsecureTLS:      insecureTLS,
874		CWD:              args.CWD,
875		Reasoning:        reasoning,
876		Temperature:      temperature,
877		FastMode:         fastMode,
878		WebSearchPolicy:  webSearchPolicy,
879		ToolRegistry:     reg,
880		ToolSummary:      summaries,
881		SystemPrompt:     sys,
882		MaxSteps:         max,
883		ContextWindow:    resolvedModel.ContextWindow,
884		MaxOutput:        resolvedModel.MaxOutput,
885		Sandbox:          sandbox,
886		SkillTool:        skillTool,
887		skillsEnabled:    skillsEnabled,
888		ContextFiles:     contextFiles,
889		systemAppend:     append_,
890		systemCustom:     custom,
891		skillAddendum:    skillAddendum,
892		toolDescriptions: descMapFromSummaries(summaries),
893	}, nil
894}
895
896// readUserSystemPrompt looks for $ZUT_HOME/SYSTEM.md and returns its
897// trimmed contents, or "" when the file is missing / unreadable /
898// empty. Errors are intentionally swallowed: the file is optional,
899// and any failure to read it should fall back to the built-in
900// default system prompt rather than crash the run.
901func readUserSystemPrompt(zutHome string) string {
902	if zutHome == "" {
903		return ""
904	}
905	path := filepath.Join(zutHome, "SYSTEM.md")
906	raw, err := os.ReadFile(path)
907	if err != nil {
908		return ""
909	}
910	return strings.TrimSpace(string(raw))
911}
912
913// loadAgentsContext loads optional AGENTS.md instruction files. No
914// default file is created or required: zut only includes files that
915// already exist. Global instructions ($ZUT_HOME/AGENTS.md) come first,
916// followed by project instructions from the filesystem root down to cwd.
917func loadAgentsContext(cwd, zutHome string) []ContextFile {
918	var files []ContextFile
919	seen := map[string]bool{}
920	add := func(path string) {
921		if path == "" {
922			return
923		}
924		abs, err := filepath.Abs(path)
925		if err == nil {
926			path = abs
927		}
928		if seen[path] {
929			return
930		}
931		raw, err := os.ReadFile(path)
932		if err != nil {
933			return
934		}
935		content := strings.TrimSpace(string(raw))
936		if content == "" {
937			return
938		}
939		seen[path] = true
940		files = append(files, ContextFile{Path: path, Content: content})
941	}
942	addFirstFromDir := func(dir string) {
943		if dir == "" {
944			return
945		}
946		for _, name := range []string{"AGENTS.md", "AGENTS.MD"} {
947			path := filepath.Join(dir, name)
948			if _, err := os.Stat(path); err == nil {
949				add(path)
950				return
951			}
952		}
953	}
954
955	addFirstFromDir(zutHome)
956
957	if cwd != "" {
958		abs, err := filepath.Abs(cwd)
959		if err == nil {
960			cwd = abs
961		}
962		var dirs []string
963		for dir := filepath.Clean(cwd); ; dir = filepath.Dir(dir) {
964			dirs = append(dirs, dir)
965			parent := filepath.Dir(dir)
966			if parent == dir {
967				break
968			}
969		}
970		for i := len(dirs) - 1; i >= 0; i-- {
971			addFirstFromDir(dirs[i])
972		}
973	}
974
975	return files
976}
977
978func formatAgentsContext(files []ContextFile) string {
979	if len(files) == 0 {
980		return ""
981	}
982	var sb strings.Builder
983	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")
984	for _, f := range files {
985		fmt.Fprintf(&sb, "\n## %s\n\n%s\n", f.Path, f.Content)
986	}
987	return strings.TrimSpace(sb.String())
988}
989
990// descMapFromSummaries indexes the human-readable descriptions for
991// the renderToolsSection rebuild path.
992func descMapFromSummaries(summaries []ToolSummary) map[string]string {
993	out := make(map[string]string, len(summaries))
994	for _, s := range summaries {
995		out[s.Name] = s.Description
996	}
997	return out
998}
999
1000// NewClient returns a provider.Client for r, choosing the auth mode
1001// based on r.AuthMethod. Panics if no credential is present; callers
1002// must check HasCredential() first.
1003func (r Resolved) NewClient() provider.Client {
1004	if !r.HasCredential() {
1005		panic("NewClient called without credential; check HasCredential first")
1006	}
1007	wrap := r.withHTTPClient
1008	switch r.Provider {
1009	case "ollama":
1010		return wrap(provider.NewOpenAICompat("ollama", r.Credential, r.BaseURL, ""))
1011	case provider.LlamaCPPProviderID:
1012		credential := r.Credential
1013		if credential == "local" {
1014			credential = ""
1015		}
1016		return wrap(provider.NewOpenAICompat(provider.LlamaCPPProviderID, credential, r.BaseURL, ""))
1017	case "kimi":
1018		// kimi-coding speaks anthropic-messages on api.kimi.com/coding.
1019		// Subscription OAuth (refreshed) wraps the same Anthropic-shaped client.
1020		inner := wrap(provider.NewKimiCodingWithHeaders(r.Credential, r.BaseURL, kimiCodeHeaders()))
1021		if r.AuthMethod == "oauth" {
1022			return r.wrapWithRefresh(inner)
1023		}
1024		return inner
1025	case "moonshotai":
1026		return wrap(provider.NewMoonshot(r.Credential, r.BaseURL))
1027	case "moonshotai-cn":
1028		return wrap(provider.NewMoonshotCN(r.Credential, r.BaseURL))
1029	case "deepseek":
1030		return wrap(provider.NewDeepSeek(r.Credential, r.BaseURL))
1031	case "openai":
1032		return provider.NewModelRouter("openai",
1033			wrap(provider.NewOpenAI(r.Credential, r.BaseURL)),
1034			map[string]provider.Client{
1035				provider.APIResponses: wrap(provider.NewOpenAIResponsesNamed(r.Credential, r.BaseURL, "openai")),
1036			})
1037	case "openai-codex":
1038		inner := wrap(provider.NewOpenAICodex(r.Credential, r.AccountID, r.BaseURL))
1039		return r.wrapWithRefresh(inner)
1040	case "openai-responses":
1041		// Public OpenAI Responses API (api.openai.com/v1/responses) via
1042		// API key. Separate provider from `openai` (Chat Completions) and
1043		// from `openai-codex` (ChatGPT subscription OAuth).
1044		return wrap(provider.NewOpenAIResponses(r.Credential, r.BaseURL))
1045	case "google":
1046		return wrap(provider.NewGemini(r.Credential, r.BaseURL))
1047	case "cerebras":
1048		return wrap(provider.NewCerebras(r.Credential, r.BaseURL))
1049	case "groq":
1050		return wrap(provider.NewGroq(r.Credential, r.BaseURL))
1051	case "xai":
1052		inner := r.newXAIClient(r.Credential)
1053		if r.AuthMethod == "oauth" {
1054			return r.wrapWithRefresh(inner)
1055		}
1056		return inner
1057	case "together":
1058		return wrap(provider.NewTogether(r.Credential, r.BaseURL))
1059	case "huggingface":
1060		return wrap(provider.NewHuggingFace(r.Credential, r.BaseURL))
1061	case "openrouter":
1062		return wrap(provider.NewOpenRouter(r.Credential, r.BaseURL))
1063	case "zai":
1064		return wrap(provider.NewZAI(r.Credential, r.BaseURL))
1065	case "xiaomi":
1066		return wrap(provider.NewXiaomi(r.Credential, r.BaseURL))
1067	case "xiaomi-token-plan-ams":
1068		return wrap(provider.NewXiaomiTokenPlan("ams", r.Credential, r.BaseURL))
1069	case "xiaomi-token-plan-cn":
1070		return wrap(provider.NewXiaomiTokenPlan("cn", r.Credential, r.BaseURL))
1071	case "xiaomi-token-plan-sgp":
1072		return wrap(provider.NewXiaomiTokenPlan("sgp", r.Credential, r.BaseURL))
1073	case "opencode":
1074		return wrap(provider.NewOpenCode(r.Credential, r.BaseURL))
1075	case "opencode-go":
1076		return wrap(provider.NewOpenCodeGo(r.Credential, r.BaseURL))
1077	case "minimax":
1078		return wrap(provider.NewMinimaxAnthropic(r.Credential, r.BaseURL))
1079	case "minimax-cn":
1080		return wrap(provider.NewMinimaxCNAnthropic(r.Credential, r.BaseURL))
1081	case "fireworks":
1082		return wrap(provider.NewFireworksAnthropic(r.Credential, r.BaseURL))
1083	case "vercel-ai-gateway":
1084		return wrap(provider.NewVercelGatewayAnthropic(r.Credential, r.BaseURL))
1085	case "mistral":
1086		return wrap(provider.NewMistral(r.Credential, r.BaseURL))
1087	case "amazon-bedrock":
1088		return wrap(provider.NewBedrock(r.Credential, r.BaseURL))
1089	case "google-vertex":
1090		return wrap(provider.NewGoogleVertex(r.Credential, r.BaseURL))
1091	case "azure-openai-responses":
1092		return wrap(provider.NewAzureOpenAIResponses(r.Credential, r.BaseURL))
1093	case "github-copilot":
1094		return wrap(provider.NewGithubCopilot(r.Credential, r.BaseURL))
1095	case "cloudflare-workers-ai":
1096		return wrap(provider.NewCloudflareWorkersAI(r.Credential, r.BaseURL))
1097	case "cloudflare-ai-gateway":
1098		return wrap(provider.NewCloudflareAIGateway(r.Credential, r.BaseURL))
1099	default:
1100		// Custom providers: choose wire format from the models.json api field.
1101		if cfg, ok := provider.CustomProviders()[r.Provider]; ok {
1102			switch cfg.API {
1103			case provider.APIResponses:
1104				return wrap(provider.NewOpenAIResponsesNamed(r.Credential, r.BaseURL, r.Provider))
1105			case "anthropic":
1106				return wrap(provider.NewAnthropicCompat(r.Provider, r.Credential, r.BaseURL))
1107			default: // "openai"
1108				return wrap(provider.NewOpenAICompat(r.Provider, r.Credential, r.BaseURL, ""))
1109			}
1110		}
1111		if r.AuthMethod == "oauth" {
1112			inner := wrap(provider.NewAnthropicOAuth(r.Credential, r.BaseURL))
1113			return r.wrapWithRefresh(inner)
1114		}
1115		return wrap(provider.NewAnthropic(r.Credential, r.BaseURL))
1116	}
1117}
1118
1119func (r Resolved) withHTTPClient(c provider.Client) provider.Client {
1120	if !r.InsecureTLS {
1121		return c
1122	}
1123	return provider.WithHTTPClient(c, provider.NewHTTPClient(true))
1124}
1125
1126func (r Resolved) newXAIClient(token string) provider.Client {
1127	return provider.NewModelRouter("xai",
1128		r.withHTTPClient(provider.NewXAI(token, r.BaseURL)),
1129		map[string]provider.Client{
1130			provider.APIResponses: r.withHTTPClient(provider.NewOpenAIResponsesNamed(token, r.BaseURL, "xai")),
1131		})
1132}
1133
1134// wrapWithRefresh wraps an OAuth client so the access token is
1135// refreshed automatically before each API call. Without this, long
1136// sessions (hours) silently fail when the 1-hour token expires.
1137func (r Resolved) wrapWithRefresh(inner provider.Client) provider.Client {
1138	provName := r.Provider
1139	tokenProvider := provName
1140	if provName == "openai-codex" {
1141		tokenProvider = "openai"
1142	}
1143	baseURL := r.BaseURL
1144	accountID := r.AccountID
1145
1146	refreshFn := func(ctx context.Context) (string, error) {
1147		tok, err := refreshIfExpired(tokenProvider, loadOAuthToken(tokenProvider))
1148		if err != nil {
1149			return "", err
1150		}
1151		return tok.AccessToken, nil
1152	}
1153
1154	factory := func(token string) provider.Client {
1155		switch provName {
1156		case "openai-codex":
1157			return r.withHTTPClient(provider.NewOpenAICodex(token, accountID, baseURL))
1158		case "kimi":
1159			// anthropic-messages on api.kimi.com/coding.
1160			return r.withHTTPClient(provider.NewKimiCodingWithHeaders(token, baseURL, kimiCodeHeaders()))
1161		case "xai":
1162			return r.newXAIClient(token)
1163		default:
1164			return r.withHTTPClient(provider.NewAnthropicOAuth(token, baseURL))
1165		}
1166	}
1167
1168	return provider.NewRefreshingClient(inner, refreshFn, factory)
1169}
1170
1171// UseSandbox replaces the sandbox pointer that every tool in r's
1172// registry references. Used to keep the /jail state stable across
1173// agent rebuilds (e.g. /login, /model switching providers).
1174func (r *Resolved) UseSandbox(s *tools.Sandbox) {
1175	if s == nil || r == nil {
1176		return
1177	}
1178	if r.Sandbox != nil && r.Sandbox.Permissions != nil {
1179		s.Permissions = r.Sandbox.Permissions
1180	}
1181	r.Sandbox = s
1182	for name, t := range r.ToolRegistry {
1183		switch v := t.(type) {
1184		case *tools.ReadTool:
1185			v.Sandbox = s
1186		case *tools.WriteTool:
1187			v.Sandbox = s
1188		case *tools.EditTool:
1189			v.Sandbox = s
1190		case *tools.BashTool:
1191			v.Sandbox = s
1192		case *tools.CreateWorktreeTool:
1193			v.Sandbox = s
1194		case *tools.LSPTool:
1195			v.Sandbox = s
1196		case *tools.GrepTool:
1197			v.Sandbox = s
1198		}
1199		_ = name
1200	}
1201}
1202
1203// NewAgent constructs a core.Agent from r. Requires a credential.
1204func (r Resolved) NewAgent() *core.Agent {
1205	a := core.NewAgent(r.NewClient(), r.Model, r.SystemPrompt, r.ToolRegistry)
1206	a.MaxSteps = r.MaxSteps
1207	a.ContextWindow = r.ContextWindow
1208	a.MaxTokens = r.MaxOutput
1209	a.Reasoning = r.Reasoning
1210	a.Temperature = r.Temperature
1211	a.FastMode = r.FastMode
1212	return a
1213}
1214
1215func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox, lspEnabled, diagnosticsOnWrite, diagnosticsOnEdit bool) core.Registry {
1216	if args.NoTools {
1217		return core.Registry{}
1218	}
1219	var manager *lsp.Manager
1220	if lspEnabled && lspManagerNeeded(args, diagnosticsOnWrite, diagnosticsOnEdit) {
1221		options := lsp.ManagerOptions{}
1222		if sandbox != nil {
1223			options.CheckWritePath = sandbox.CheckWritePath
1224		}
1225		manager = lsp.NewManagerWithOptions(options)
1226	}
1227	all := map[string]core.Tool{
1228		"read":            &tools.ReadTool{CWD: cwd, Sandbox: sandbox},
1229		"write":           &tools.WriteTool{CWD: cwd, Sandbox: sandbox, LSP: manager, LSPDiagnostics: diagnosticsOnWrite},
1230		"edit":            &tools.EditTool{CWD: cwd, Sandbox: sandbox, LSP: manager, LSPDiagnostics: diagnosticsOnEdit},
1231		"bash":            &tools.BashTool{CWD: cwd, Sandbox: sandbox},
1232		"create_worktree": &tools.CreateWorktreeTool{CWD: cwd, Sandbox: sandbox},
1233		"grep":            &tools.GrepTool{CWD: cwd, Sandbox: sandbox},
1234		"update_goal":     &tools.UpdateGoalTool{},
1235	}
1236	if webSearchAllowedForRegistry(args) {
1237		for name, tool := range tools.NewWebTools() {
1238			all[name] = tool
1239		}
1240	}
1241	if manager != nil {
1242		lspTool := tools.NewLSPTool(cwd, manager)
1243		lspTool.Sandbox = sandbox
1244		all["lsp"] = lspTool
1245	}
1246	reg := core.Registry{}
1247	if len(args.Tools) == 0 {
1248		for _, t := range all {
1249			reg[t.Name()] = t
1250		}
1251	} else {
1252		for _, name := range args.Tools {
1253			if name == "web_search" {
1254				for webName, tool := range all {
1255					if tools.IsWebCapabilityName(webName) {
1256						reg[webName] = tool
1257					}
1258				}
1259				continue
1260			}
1261			if t, ok := all[name]; ok {
1262				reg[name] = t
1263			}
1264		}
1265	}
1266	if orchestratorExplorationToolsAllowed(args) {
1267		reg["read"] = all["read"]
1268		reg["grep"] = all["grep"]
1269		if args.PermissionSet == nil {
1270			for name, tool := range tools.NewWebTools() {
1271				reg[name] = tool
1272			}
1273		}
1274	}
1275	return reg
1276}
1277
1278func lspManagerNeeded(args Args, diagnosticsOnWrite, diagnosticsOnEdit bool) bool {
1279	if len(args.Tools) == 0 {
1280		return true
1281	}
1282	for _, name := range args.Tools {
1283		switch name {
1284		case "lsp":
1285			return true
1286		case "write":
1287			if diagnosticsOnWrite {
1288				return true
1289			}
1290		case "edit":
1291			if diagnosticsOnEdit {
1292				return true
1293			}
1294		}
1295	}
1296	return false
1297}
1298
1299func autoSubagentsToolAllowed(args Args) bool {
1300	return autoSubagentsToolAllowedFor(args, "subagent_spawn")
1301}
1302
1303// orchestratorExplorationToolsAllowed keeps the primary orchestrator's
1304// read-only exploration tools available without enabling implementation tools.
1305func orchestratorExplorationToolsAllowed(args Args) bool {
1306	return args.Orchestrate && !args.NoTools
1307}
1308
1309func autoSubagentsStatusToolAllowed(args Args) bool {
1310	return autoSubagentsToolAllowedFor(args, "subagent_status")
1311}
1312
1313func autoSubagentsStopToolAllowed(args Args) bool {
1314	return autoSubagentsToolAllowedFor(args, "subagent_stop")
1315}
1316
1317func autoSubagentsResumeToolAllowed(args Args) bool {
1318	return autoSubagentsToolAllowedFor(args, "subagent_resume")
1319}
1320
1321func autoSubagentsAnyToolAllowed(args Args) bool {
1322	return autoSubagentsToolAllowed(args) ||
1323		autoSubagentsStatusToolAllowed(args) ||
1324		autoSubagentsStopToolAllowed(args) ||
1325		autoSubagentsResumeToolAllowed(args)
1326}
1327
1328func autoSubagentsToolAllowedFor(args Args, toolName string) bool {
1329	if args.NoTools || args.PermissionSet != nil {
1330		return false
1331	}
1332	if args.ToolsSet && len(args.Tools) == 0 {
1333		return false
1334	}
1335	if len(args.Tools) == 0 {
1336		return true
1337	}
1338	for _, name := range args.Tools {
1339		if name == toolName {
1340			return true
1341		}
1342	}
1343	return false
1344}
1345
1346var nativeToolSummaryOrder = []string{"read", "write", "edit", "grep", "bash", "create_worktree", "lsp", "web_search", "web_open", "web_find", "web_click", "update_goal"}
1347
1348func toolSummaries(reg core.Registry, args Args) []ToolSummary {
1349	var out []ToolSummary
1350	for _, name := range nativeToolSummaryOrder {
1351		if t, ok := reg[name]; ok {
1352			out = append(out, ToolSummary{Name: t.Name(), Description: t.Description()})
1353		}
1354	}
1355	return out
1356}
1357
1358func firstNonEmpty(vals ...string) string {
1359	for _, v := range vals {
1360		if v != "" {
1361			return v
1362		}
1363	}
1364	return ""
1365}
1366
1367func kimiCodeHeaders() map[string]string {
1368	host, _ := os.Hostname()
1369	if host == "" {
1370		host = "unknown"
1371	}
1372	deviceID := ""
1373	if home, err := os.UserHomeDir(); err == nil {
1374		if b, err := os.ReadFile(filepath.Join(home, ".kimi", "device_id")); err == nil {
1375			deviceID = strings.TrimSpace(string(b))
1376		}
1377	}
1378	if deviceID == "" {
1379		deviceID = "zut"
1380	}
1381	return map[string]string{
1382		"User-Agent":         "KimiCLI/1.41.0",
1383		"X-Msh-Platform":     "kimi_cli",
1384		"X-Msh-Version":      "1.41.0",
1385		"X-Msh-Device-Name":  host,
1386		"X-Msh-Device-Model": runtime.GOOS + "-" + runtime.GOARCH,
1387		"X-Msh-Os-Version":   runtime.GOOS,
1388		"X-Msh-Device-Id":    deviceID,
1389	}
1390}
1391
1392func envVarName(provider string) string {
1393	switch provider {
1394	case "openai", "openai-codex", "openai-responses":
1395		return "OPENAI"
1396	case "kimi":
1397		return "KIMI"
1398	case "deepseek":
1399		return "DEEPSEEK"
1400	case "google":
1401		return "GEMINI"
1402	case "ollama":
1403		return "OLLAMA"
1404	case "llama.cpp":
1405		return "LLAMA"
1406	case "moonshotai", "moonshotai-cn":
1407		return "MOONSHOT"
1408	case "groq":
1409		return "GROQ"
1410	case "xai":
1411		return "XAI"
1412	case "cerebras":
1413		return "CEREBRAS"
1414	case "together":
1415		return "TOGETHER"
1416	case "huggingface":
1417		return "HF"
1418	case "openrouter":
1419		return "OPENROUTER"
1420	case "mistral":
1421		return "MISTRAL"
1422	case "zai":
1423		return "ZAI"
1424	case "xiaomi":
1425		return "XIAOMI"
1426	case "xiaomi-token-plan-ams":
1427		return "XIAOMI_TOKEN_PLAN_AMS"
1428	case "xiaomi-token-plan-cn":
1429		return "XIAOMI_TOKEN_PLAN_CN"
1430	case "xiaomi-token-plan-sgp":
1431		return "XIAOMI_TOKEN_PLAN_SGP"
1432	case "minimax":
1433		return "MINIMAX"
1434	case "minimax-cn":
1435		return "MINIMAX_CN"
1436	case "fireworks":
1437		return "FIREWORKS"
1438	case "vercel-ai-gateway":
1439		return "AI_GATEWAY"
1440	case "opencode", "opencode-go":
1441		return "OPENCODE"
1442	case "github-copilot":
1443		return "COPILOT_GITHUB_TOKEN"
1444	case "cloudflare-workers-ai", "cloudflare-ai-gateway":
1445		return "CLOUDFLARE"
1446	case "amazon-bedrock":
1447		return "AWS"
1448	case "google-vertex":
1449		return "GOOGLE_CLOUD"
1450	case "azure-openai-responses":
1451		return "AZURE_OPENAI"
1452	default:
1453		return "ANTHROPIC"
1454	}
1455}

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