v0.9.1 406ddf4

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

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