v0.3.1 5c1a9ec

build.go RawHistoryFolder

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

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 477

↑↓ moveenter openesc close