v0.11.4 020512d

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 && cfg.AutoSubagentsEnabled != nil && *cfg.AutoSubagentsEnabled {
854		if autoSubagentsToolAllowed(args) {
855			homeDir, _ := os.UserHomeDir()
856			profiles, _ := subagents.Discover(args.CWD, homeDir)
857			if subagentsAddendum := subagents.SystemPromptAddendum(profiles); subagentsAddendum != "" {
858				append_ = append(append_, subagentsAddendum)
859			}
860		}
861		append_ = append(append_, AutoSubagentsSystemAddendumFor(
862			autoSubagentsToolAllowed(args),
863			autoSubagentsStopToolAllowed(args),
864			autoSubagentsResumeToolAllowed(args),
865		))
866	} else if selectedProfile == nil && interactiveMode && autoSubagentsAnyToolAllowed(args) {
867		append_ = append(append_, OnDemandSubagentsSystemAddendum)
868	}
869	if selectedProfile != nil && selectedProfile.SystemPromptMode != "replace" && selectedProfile.SystemPrompt != "" {
870		append_ = append(append_, selectedProfile.SystemPrompt)
871	}
872
873	// Custom system prompt resolution order:
874	//   1. --system-prompt flag (highest priority; ad-hoc per run)
875	//   2. $ZUT_HOME/SYSTEM.md (persistent user override)
876	//   3. built-in default (defaultIdentity + defaultGuidelines)
877	custom := args.SystemPrompt
878	if selectedProfile != nil && selectedProfile.SystemPromptMode == "replace" {
879		custom = selectedProfile.SystemPrompt
880	} else if custom == "" {
881		custom = readUserSystemPrompt(ZutHome())
882	}
883
884	sys := BuildSystemPrompt(SystemPromptOpts{
885		CWD:        args.CWD,
886		Tools:      summaries,
887		Custom:     custom,
888		Append:     append_,
889		ZutDocsDir: docsDir,
890	})
891
892	reasoning := provider.NormalizeReasoning(firstNonEmpty(args.Reasoning, cfg.Reasoning))
893	temperature := args.Temperature
894	if temperature == nil {
895		temperature = cfg.Temperature
896	}
897	fastMode := cfg.FastMode != nil && *cfg.FastMode
898	if args.FastModeSet {
899		fastMode = args.FastMode
900	}
901	// A profile's explicit fastMode setting overrides the global setting
902	// for this child. This preserves fastMode: false as an opt-out while
903	// allowing fastMode: true to request the fast tier for a focused worker.
904	if selectedProfile != nil && selectedProfile.FastMode != nil {
905		fastMode = *selectedProfile.FastMode
906	}
907
908	max := args.MaxSteps // 0 = unlimited
909
910	return Resolved{
911		Provider:         provName,
912		Model:            model,
913		Credential:       cred,
914		AuthMethod:       method,
915		AccountID:        accountID,
916		BaseURL:          args.BaseURL,
917		InsecureTLS:      insecureTLS,
918		CWD:              args.CWD,
919		Reasoning:        reasoning,
920		Temperature:      temperature,
921		FastMode:         fastMode,
922		WebSearchPolicy:  webSearchPolicy,
923		ToolRegistry:     reg,
924		ToolSummary:      summaries,
925		SystemPrompt:     sys,
926		MaxSteps:         max,
927		MaxOutput:        resolvedModel.MaxOutput,
928		Sandbox:          sandbox,
929		SkillTool:        skillTool,
930		skillsEnabled:    skillsEnabled,
931		ContextFiles:     contextFiles,
932		systemAppend:     append_,
933		systemCustom:     custom,
934		skillAddendum:    skillAddendum,
935		toolDescriptions: descMapFromSummaries(summaries),
936	}, nil
937}
938
939// readUserSystemPrompt looks for $ZUT_HOME/SYSTEM.md and returns its
940// trimmed contents, or "" when the file is missing / unreadable /
941// empty. Errors are intentionally swallowed: the file is optional,
942// and any failure to read it should fall back to the built-in
943// default system prompt rather than crash the run.
944func readUserSystemPrompt(zutHome string) string {
945	if zutHome == "" {
946		return ""
947	}
948	path := filepath.Join(zutHome, "SYSTEM.md")
949	raw, err := os.ReadFile(path)
950	if err != nil {
951		return ""
952	}
953	return strings.TrimSpace(string(raw))
954}
955
956// loadAgentsContext loads optional AGENTS.md instruction files. No
957// default file is created or required: zut only includes files that
958// already exist. Global instructions ($ZUT_HOME/AGENTS.md) come first,
959// followed by project instructions from the filesystem root down to cwd.
960func loadAgentsContext(cwd, zutHome string) []ContextFile {
961	var files []ContextFile
962	seen := map[string]bool{}
963	add := func(path string) {
964		if path == "" {
965			return
966		}
967		abs, err := filepath.Abs(path)
968		if err == nil {
969			path = abs
970		}
971		if seen[path] {
972			return
973		}
974		raw, err := os.ReadFile(path)
975		if err != nil {
976			return
977		}
978		content := strings.TrimSpace(string(raw))
979		if content == "" {
980			return
981		}
982		seen[path] = true
983		files = append(files, ContextFile{Path: path, Content: content})
984	}
985	addFirstFromDir := func(dir string) {
986		if dir == "" {
987			return
988		}
989		for _, name := range []string{"AGENTS.md", "AGENTS.MD"} {
990			path := filepath.Join(dir, name)
991			if _, err := os.Stat(path); err == nil {
992				add(path)
993				return
994			}
995		}
996	}
997
998	addFirstFromDir(zutHome)
999
1000	if cwd != "" {
1001		abs, err := filepath.Abs(cwd)
1002		if err == nil {
1003			cwd = abs
1004		}
1005		var dirs []string
1006		for dir := filepath.Clean(cwd); ; dir = filepath.Dir(dir) {
1007			dirs = append(dirs, dir)
1008			parent := filepath.Dir(dir)
1009			if parent == dir {
1010				break
1011			}
1012		}
1013		for i := len(dirs) - 1; i >= 0; i-- {
1014			addFirstFromDir(dirs[i])
1015		}
1016	}
1017
1018	return files
1019}
1020
1021func formatAgentsContext(files []ContextFile) string {
1022	if len(files) == 0 {
1023		return ""
1024	}
1025	var sb strings.Builder
1026	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")
1027	for _, f := range files {
1028		fmt.Fprintf(&sb, "\n## %s\n\n%s\n", f.Path, f.Content)
1029	}
1030	return strings.TrimSpace(sb.String())
1031}
1032
1033// descMapFromSummaries indexes the human-readable descriptions for
1034// the renderToolsSection rebuild path.
1035func descMapFromSummaries(summaries []ToolSummary) map[string]string {
1036	out := make(map[string]string, len(summaries))
1037	for _, s := range summaries {
1038		out[s.Name] = s.Description
1039	}
1040	return out
1041}
1042
1043// NewClient returns a provider.Client for r, choosing the auth mode
1044// based on r.AuthMethod. Panics if no credential is present; callers
1045// must check HasCredential() first.
1046func (r Resolved) NewClient() provider.Client {
1047	if !r.HasCredential() {
1048		panic("NewClient called without credential; check HasCredential first")
1049	}
1050	wrap := r.withHTTPClient
1051	switch r.Provider {
1052	case "ollama":
1053		return wrap(provider.NewOpenAICompat("ollama", r.Credential, r.BaseURL, ""))
1054	case provider.LlamaCPPProviderID:
1055		credential := r.Credential
1056		if credential == "local" {
1057			credential = ""
1058		}
1059		return wrap(provider.NewOpenAICompat(provider.LlamaCPPProviderID, credential, r.BaseURL, ""))
1060	case "kimi":
1061		// kimi-coding speaks anthropic-messages on api.kimi.com/coding.
1062		// Subscription OAuth (refreshed) wraps the same Anthropic-shaped client.
1063		inner := wrap(provider.NewKimiCodingWithHeaders(r.Credential, r.BaseURL, kimiCodeHeaders()))
1064		if r.AuthMethod == "oauth" {
1065			return r.wrapWithRefresh(inner)
1066		}
1067		return inner
1068	case "moonshotai":
1069		return wrap(provider.NewMoonshot(r.Credential, r.BaseURL))
1070	case "moonshotai-cn":
1071		return wrap(provider.NewMoonshotCN(r.Credential, r.BaseURL))
1072	case "deepseek":
1073		return wrap(provider.NewDeepSeek(r.Credential, r.BaseURL))
1074	case "openai":
1075		return provider.NewModelRouter("openai",
1076			wrap(provider.NewOpenAI(r.Credential, r.BaseURL)),
1077			map[string]provider.Client{
1078				provider.APIResponses: wrap(provider.NewOpenAIResponsesNamed(r.Credential, r.BaseURL, "openai")),
1079			})
1080	case "openai-codex":
1081		inner := wrap(provider.NewOpenAICodex(r.Credential, r.AccountID, r.BaseURL))
1082		return r.wrapWithRefresh(inner)
1083	case "openai-responses":
1084		// Public OpenAI Responses API (api.openai.com/v1/responses) via
1085		// API key. Separate provider from `openai` (Chat Completions) and
1086		// from `openai-codex` (ChatGPT subscription OAuth).
1087		return wrap(provider.NewOpenAIResponses(r.Credential, r.BaseURL))
1088	case "google":
1089		return wrap(provider.NewGemini(r.Credential, r.BaseURL))
1090	case "cerebras":
1091		return wrap(provider.NewCerebras(r.Credential, r.BaseURL))
1092	case "groq":
1093		return wrap(provider.NewGroq(r.Credential, r.BaseURL))
1094	case "xai":
1095		inner := r.newXAIClient(r.Credential)
1096		if r.AuthMethod == "oauth" {
1097			return r.wrapWithRefresh(inner)
1098		}
1099		return inner
1100	case "together":
1101		return wrap(provider.NewTogether(r.Credential, r.BaseURL))
1102	case "huggingface":
1103		return wrap(provider.NewHuggingFace(r.Credential, r.BaseURL))
1104	case "openrouter":
1105		return wrap(provider.NewOpenRouter(r.Credential, r.BaseURL))
1106	case "zai":
1107		return wrap(provider.NewZAI(r.Credential, r.BaseURL))
1108	case "xiaomi":
1109		return wrap(provider.NewXiaomi(r.Credential, r.BaseURL))
1110	case "xiaomi-token-plan-ams":
1111		return wrap(provider.NewXiaomiTokenPlan("ams", r.Credential, r.BaseURL))
1112	case "xiaomi-token-plan-cn":
1113		return wrap(provider.NewXiaomiTokenPlan("cn", r.Credential, r.BaseURL))
1114	case "xiaomi-token-plan-sgp":
1115		return wrap(provider.NewXiaomiTokenPlan("sgp", r.Credential, r.BaseURL))
1116	case "opencode":
1117		return wrap(provider.NewOpenCode(r.Credential, r.BaseURL))
1118	case "opencode-go":
1119		return wrap(provider.NewOpenCodeGo(r.Credential, r.BaseURL))
1120	case "minimax":
1121		return wrap(provider.NewMinimaxAnthropic(r.Credential, r.BaseURL))
1122	case "minimax-cn":
1123		return wrap(provider.NewMinimaxCNAnthropic(r.Credential, r.BaseURL))
1124	case "fireworks":
1125		return wrap(provider.NewFireworksAnthropic(r.Credential, r.BaseURL))
1126	case "vercel-ai-gateway":
1127		return wrap(provider.NewVercelGatewayAnthropic(r.Credential, r.BaseURL))
1128	case "mistral":
1129		return wrap(provider.NewMistral(r.Credential, r.BaseURL))
1130	case "amazon-bedrock":
1131		return wrap(provider.NewBedrock(r.Credential, r.BaseURL))
1132	case "google-vertex":
1133		return wrap(provider.NewGoogleVertex(r.Credential, r.BaseURL))
1134	case "azure-openai-responses":
1135		return wrap(provider.NewAzureOpenAIResponses(r.Credential, r.BaseURL))
1136	case "github-copilot":
1137		return wrap(provider.NewGithubCopilot(r.Credential, r.BaseURL))
1138	case "cloudflare-workers-ai":
1139		return wrap(provider.NewCloudflareWorkersAI(r.Credential, r.BaseURL))
1140	case "cloudflare-ai-gateway":
1141		return wrap(provider.NewCloudflareAIGateway(r.Credential, r.BaseURL))
1142	default:
1143		// Custom providers: choose wire format from the models.json api field.
1144		if cfg, ok := provider.CustomProviders()[r.Provider]; ok {
1145			switch cfg.API {
1146			case provider.APIResponses:
1147				return wrap(provider.NewOpenAIResponsesNamed(r.Credential, r.BaseURL, r.Provider))
1148			case "anthropic":
1149				return wrap(provider.NewAnthropicCompat(r.Provider, r.Credential, r.BaseURL))
1150			default: // "openai"
1151				return wrap(provider.NewOpenAICompat(r.Provider, r.Credential, r.BaseURL, ""))
1152			}
1153		}
1154		if r.AuthMethod == "oauth" {
1155			inner := wrap(provider.NewAnthropicOAuth(r.Credential, r.BaseURL))
1156			return r.wrapWithRefresh(inner)
1157		}
1158		return wrap(provider.NewAnthropic(r.Credential, r.BaseURL))
1159	}
1160}
1161
1162func (r Resolved) withHTTPClient(c provider.Client) provider.Client {
1163	if !r.InsecureTLS {
1164		return c
1165	}
1166	return provider.WithHTTPClient(c, provider.NewHTTPClient(true))
1167}
1168
1169func (r Resolved) newXAIClient(token string) provider.Client {
1170	return provider.NewModelRouter("xai",
1171		r.withHTTPClient(provider.NewXAI(token, r.BaseURL)),
1172		map[string]provider.Client{
1173			provider.APIResponses: r.withHTTPClient(provider.NewOpenAIResponsesNamed(token, r.BaseURL, "xai")),
1174		})
1175}
1176
1177// wrapWithRefresh wraps an OAuth client so the access token is
1178// refreshed automatically before each API call. Without this, long
1179// sessions (hours) silently fail when the 1-hour token expires.
1180func (r Resolved) wrapWithRefresh(inner provider.Client) provider.Client {
1181	provName := r.Provider
1182	tokenProvider := provName
1183	if provName == "openai-codex" {
1184		tokenProvider = "openai"
1185	}
1186	baseURL := r.BaseURL
1187	accountID := r.AccountID
1188
1189	refreshFn := func(ctx context.Context) (string, error) {
1190		tok, err := refreshIfExpired(tokenProvider, loadOAuthToken(tokenProvider))
1191		if err != nil {
1192			return "", err
1193		}
1194		return tok.AccessToken, nil
1195	}
1196
1197	factory := func(token string) provider.Client {
1198		switch provName {
1199		case "openai-codex":
1200			return r.withHTTPClient(provider.NewOpenAICodex(token, accountID, baseURL))
1201		case "kimi":
1202			// anthropic-messages on api.kimi.com/coding.
1203			return r.withHTTPClient(provider.NewKimiCodingWithHeaders(token, baseURL, kimiCodeHeaders()))
1204		case "xai":
1205			return r.newXAIClient(token)
1206		default:
1207			return r.withHTTPClient(provider.NewAnthropicOAuth(token, baseURL))
1208		}
1209	}
1210
1211	return provider.NewRefreshingClient(inner, refreshFn, factory)
1212}
1213
1214// UseSandbox replaces the sandbox pointer that every tool in r's
1215// registry references. Used to keep the /jail state stable across
1216// agent rebuilds (e.g. /login, /model switching providers).
1217func (r *Resolved) UseSandbox(s *tools.Sandbox) {
1218	if s == nil || r == nil {
1219		return
1220	}
1221	if r.Sandbox != nil && r.Sandbox.Permissions != nil {
1222		s.Permissions = r.Sandbox.Permissions
1223	}
1224	r.Sandbox = s
1225	for name, t := range r.ToolRegistry {
1226		switch v := t.(type) {
1227		case *tools.ReadTool:
1228			v.Sandbox = s
1229		case *tools.WriteTool:
1230			v.Sandbox = s
1231		case *tools.EditTool:
1232			v.Sandbox = s
1233		case *tools.BashTool:
1234			v.Sandbox = s
1235		case *tools.CreateWorktreeTool:
1236			v.Sandbox = s
1237		case *tools.LSPTool:
1238			v.Sandbox = s
1239		}
1240		_ = name
1241	}
1242}
1243
1244// NewAgent constructs a core.Agent from r. Requires a credential.
1245func (r Resolved) NewAgent() *core.Agent {
1246	a := core.NewAgent(r.NewClient(), r.Model, r.SystemPrompt, r.ToolRegistry)
1247	a.MaxSteps = r.MaxSteps
1248	a.MaxTokens = r.MaxOutput
1249	a.Reasoning = r.Reasoning
1250	a.Temperature = r.Temperature
1251	a.FastMode = r.FastMode
1252	return a
1253}
1254
1255func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox, lspEnabled, diagnosticsOnWrite, diagnosticsOnEdit bool) core.Registry {
1256	if args.NoTools {
1257		return core.Registry{}
1258	}
1259	var manager *lsp.Manager
1260	if lspEnabled && lspManagerNeeded(args, diagnosticsOnWrite, diagnosticsOnEdit) {
1261		options := lsp.ManagerOptions{}
1262		if sandbox != nil {
1263			options.CheckWritePath = sandbox.CheckWritePath
1264		}
1265		manager = lsp.NewManagerWithOptions(options)
1266	}
1267	all := map[string]core.Tool{
1268		"read":            &tools.ReadTool{CWD: cwd, Sandbox: sandbox},
1269		"write":           &tools.WriteTool{CWD: cwd, Sandbox: sandbox, LSP: manager, LSPDiagnostics: diagnosticsOnWrite},
1270		"edit":            &tools.EditTool{CWD: cwd, Sandbox: sandbox, LSP: manager, LSPDiagnostics: diagnosticsOnEdit},
1271		"bash":            &tools.BashTool{CWD: cwd, Sandbox: sandbox},
1272		"create_worktree": &tools.CreateWorktreeTool{CWD: cwd, Sandbox: sandbox},
1273	}
1274	if webSearchAllowedForRegistry(args) {
1275		all["web_search"] = tools.NewWebSearchTool()
1276	}
1277	if manager != nil {
1278		lspTool := tools.NewLSPTool(cwd, manager)
1279		lspTool.Sandbox = sandbox
1280		all["lsp"] = lspTool
1281	}
1282	reg := core.Registry{}
1283	if len(args.Tools) == 0 {
1284		for _, t := range all {
1285			reg[t.Name()] = t
1286		}
1287		return reg
1288	}
1289	for _, name := range args.Tools {
1290		if t, ok := all[name]; ok {
1291			reg[name] = t
1292		}
1293	}
1294	return reg
1295}
1296
1297func lspManagerNeeded(args Args, diagnosticsOnWrite, diagnosticsOnEdit bool) bool {
1298	if len(args.Tools) == 0 {
1299		return true
1300	}
1301	for _, name := range args.Tools {
1302		switch name {
1303		case "lsp":
1304			return true
1305		case "write":
1306			if diagnosticsOnWrite {
1307				return true
1308			}
1309		case "edit":
1310			if diagnosticsOnEdit {
1311				return true
1312			}
1313		}
1314	}
1315	return false
1316}
1317
1318func autoSubagentsToolAllowed(args Args) bool {
1319	return autoSubagentsToolAllowedFor(args, "subagent_spawn")
1320}
1321
1322func autoSubagentsStatusToolAllowed(args Args) bool {
1323	return autoSubagentsToolAllowedFor(args, "subagent_status")
1324}
1325
1326func autoSubagentsStopToolAllowed(args Args) bool {
1327	return autoSubagentsToolAllowedFor(args, "subagent_stop")
1328}
1329
1330func autoSubagentsResumeToolAllowed(args Args) bool {
1331	return autoSubagentsToolAllowedFor(args, "subagent_resume")
1332}
1333
1334func autoSubagentsAnyToolAllowed(args Args) bool {
1335	return autoSubagentsToolAllowed(args) ||
1336		autoSubagentsStatusToolAllowed(args) ||
1337		autoSubagentsStopToolAllowed(args) ||
1338		autoSubagentsResumeToolAllowed(args)
1339}
1340
1341func autoSubagentsToolAllowedFor(args Args, toolName string) bool {
1342	if args.NoTools || args.PermissionSet != nil {
1343		return false
1344	}
1345	if args.ToolsSet && len(args.Tools) == 0 {
1346		return false
1347	}
1348	if len(args.Tools) == 0 {
1349		return true
1350	}
1351	for _, name := range args.Tools {
1352		if name == toolName {
1353			return true
1354		}
1355	}
1356	return false
1357}
1358
1359var nativeToolSummaryOrder = []string{"read", "write", "edit", "bash", "create_worktree", "lsp", "web_search"}
1360
1361func toolSummaries(reg core.Registry, args Args) []ToolSummary {
1362	var out []ToolSummary
1363	for _, name := range nativeToolSummaryOrder {
1364		if t, ok := reg[name]; ok {
1365			out = append(out, ToolSummary{Name: t.Name(), Description: t.Description()})
1366		}
1367	}
1368	return out
1369}
1370
1371func firstNonEmpty(vals ...string) string {
1372	for _, v := range vals {
1373		if v != "" {
1374			return v
1375		}
1376	}
1377	return ""
1378}
1379
1380func kimiCodeHeaders() map[string]string {
1381	host, _ := os.Hostname()
1382	if host == "" {
1383		host = "unknown"
1384	}
1385	deviceID := ""
1386	if home, err := os.UserHomeDir(); err == nil {
1387		if b, err := os.ReadFile(filepath.Join(home, ".kimi", "device_id")); err == nil {
1388			deviceID = strings.TrimSpace(string(b))
1389		}
1390	}
1391	if deviceID == "" {
1392		deviceID = "zut"
1393	}
1394	return map[string]string{
1395		"User-Agent":         "KimiCLI/1.41.0",
1396		"X-Msh-Platform":     "kimi_cli",
1397		"X-Msh-Version":      "1.41.0",
1398		"X-Msh-Device-Name":  host,
1399		"X-Msh-Device-Model": runtime.GOOS + "-" + runtime.GOARCH,
1400		"X-Msh-Os-Version":   runtime.GOOS,
1401		"X-Msh-Device-Id":    deviceID,
1402	}
1403}
1404
1405func envVarName(provider string) string {
1406	switch provider {
1407	case "openai", "openai-codex", "openai-responses":
1408		return "OPENAI"
1409	case "kimi":
1410		return "KIMI"
1411	case "deepseek":
1412		return "DEEPSEEK"
1413	case "google":
1414		return "GEMINI"
1415	case "ollama":
1416		return "OLLAMA"
1417	case "llama.cpp":
1418		return "LLAMA"
1419	case "moonshotai", "moonshotai-cn":
1420		return "MOONSHOT"
1421	case "groq":
1422		return "GROQ"
1423	case "xai":
1424		return "XAI"
1425	case "cerebras":
1426		return "CEREBRAS"
1427	case "together":
1428		return "TOGETHER"
1429	case "huggingface":
1430		return "HF"
1431	case "openrouter":
1432		return "OPENROUTER"
1433	case "mistral":
1434		return "MISTRAL"
1435	case "zai":
1436		return "ZAI"
1437	case "xiaomi":
1438		return "XIAOMI"
1439	case "xiaomi-token-plan-ams":
1440		return "XIAOMI_TOKEN_PLAN_AMS"
1441	case "xiaomi-token-plan-cn":
1442		return "XIAOMI_TOKEN_PLAN_CN"
1443	case "xiaomi-token-plan-sgp":
1444		return "XIAOMI_TOKEN_PLAN_SGP"
1445	case "minimax":
1446		return "MINIMAX"
1447	case "minimax-cn":
1448		return "MINIMAX_CN"
1449	case "fireworks":
1450		return "FIREWORKS"
1451	case "vercel-ai-gateway":
1452		return "AI_GATEWAY"
1453	case "opencode", "opencode-go":
1454		return "OPENCODE"
1455	case "github-copilot":
1456		return "COPILOT_GITHUB_TOKEN"
1457	case "cloudflare-workers-ai", "cloudflare-ai-gateway":
1458		return "CLOUDFLARE"
1459	case "amazon-bedrock":
1460		return "AWS"
1461	case "google-vertex":
1462		return "GOOGLE_CLOUD"
1463	case "azure-openai-responses":
1464		return "AZURE_OPENAI"
1465	default:
1466		return "ANTHROPIC"
1467	}
1468}

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