v0.2.22 3d031dd

build.go RawHistoryFolder

1package agent
2
3import (
4	"context"
5	"fmt"
6	"os"
7	"path/filepath"
8	"runtime"
9	"strings"
10
11	zotdocs "github.com/patriceckhart/zot"
12	"github.com/patriceckhart/zot/packages/agent/skills"
13	"github.com/patriceckhart/zot/packages/agent/tools"
14	"github.com/patriceckhart/zot/packages/core"
15	"github.com/patriceckhart/zot/packages/provider"
16)
17
18// Resolved is the effective configuration after merging CLI, config, defaults.
19type Resolved struct {
20	Provider   string
21	Model      string
22	Credential string // api key or oauth access token
23	AuthMethod string // "apikey" | "oauth" | "" (no credential yet)
24	AccountID  string // ChatGPT account id (for openai oauth), "" otherwise
25	BaseURL    string
26	CWD        string
27	Reasoning  string
28
29	ToolRegistry core.Registry
30	ToolSummary  []ToolSummary
31	SystemPrompt string
32	MaxSteps     int
33	Sandbox      *tools.Sandbox
34
35	// SkillTool is the on-demand skill loader registered with the
36	// agent's tool registry, or nil if no SKILL.md files were
37	// discovered. Exposed so the tui can list / preview skills.
38	SkillTool *skills.Tool
39
40	// Bookkeeping for MergeExtensionTools. Captured at Resolve time
41	// so the system prompt can be rebuilt later without re-running
42	// resolve.
43	systemAppend     []string
44	systemCustom     string
45	toolDescriptions map[string]string
46}
47
48// HasCredential reports whether a credential was resolved.
49func (r Resolved) HasCredential() bool { return r.Credential != "" }
50
51// MergeExtensionTools folds every tool registered by an extension
52// into r's ToolRegistry and re-renders the system prompt's tool
53// summary so the model sees both built-in and extension tools.
54//
55// Idempotent: calling twice with the same manager state has no
56// effect on the second pass (existing names are preserved). Built-in
57// tools always win on conflict.
58func (r *Resolved) MergeExtensionTools(mgr ExtensionToolSource) {
59	if mgr == nil {
60		return
61	}
62	infos := mgr.Tools()
63	if len(infos) == 0 {
64		return
65	}
66	changed := false
67	for _, info := range infos {
68		if _, exists := r.ToolRegistry[info.Name]; exists {
69			continue
70		}
71		r.ToolRegistry[info.Name] = mgr.NewExtensionTool(info)
72		changed = true
73	}
74	if !changed {
75		return
76	}
77	// Re-render the system prompt with the merged tool list. Skill
78	// addendum is preserved by walking the existing append slice.
79	append_ := r.systemAppend
80	r.SystemPrompt = BuildSystemPrompt(SystemPromptOpts{
81		CWD:        r.CWD,
82		Tools:      toolSummariesFromRegistry(r.ToolRegistry, r.toolDescriptions),
83		Custom:     r.systemCustom,
84		Append:     append_,
85		ZotDocsDir: filepath.Join(ZotHome(), "docs"),
86	})
87}
88
89// ExtensionToolSource is the slice of the extension manager that
90// MergeExtensionTools needs. Lives here as an interface so the
91// build package doesn't import packages/agent/extensions (which
92// imports core, which imports... avoid the cycle).
93type ExtensionToolSource interface {
94	Tools() []ExtensionToolInfo
95	NewExtensionTool(info ExtensionToolInfo) core.Tool
96}
97
98// ExtensionToolInfo mirrors extensions.ToolInfo so we can declare
99// ExtensionToolSource here without importing the extensions
100// package. The cli wires a tiny adapter to bridge them.
101type ExtensionToolInfo struct {
102	Extension   string
103	Name        string
104	Description string
105	Schema      []byte
106}
107
108// toolSummariesFromRegistry rebuilds the system-prompt tool list
109// from a (possibly extended) registry, using cached descriptions for
110// the human-readable summary text.
111func toolSummariesFromRegistry(reg core.Registry, cached map[string]string) []ToolSummary {
112	out := make([]ToolSummary, 0, len(reg))
113	for name, t := range reg {
114		desc := t.Description()
115		if d, ok := cached[name]; ok && d != "" {
116			desc = d
117		}
118		out = append(out, ToolSummary{Name: name, Description: desc})
119	}
120	return out
121}
122
123// defaultModelForProvider returns the model id zot prefers when the
124// caller didn't pick one. Mirrors the per-provider switch used at
125// multiple points in Resolve; centralised so the unknown-model
126// recovery path and the no-model-configured path can't drift.
127//
128// Returns the empty string for "ollama", which has no built-in
129// default — the caller is expected to special-case ollama and
130// error or use whatever the user passed.
131func defaultModelForProvider(prov string) string {
132	switch prov {
133	case "openai":
134		return "gpt-5"
135	case "openai-codex":
136		return "gpt-5.5"
137	case "openai-responses":
138		return "gpt-5"
139	case "kimi":
140		return "kimi-for-coding"
141	case "deepseek":
142		return "deepseek-v4-pro"
143	case "google":
144		return "gemini-2.5-pro"
145	case "ollama":
146		return ""
147	case "moonshotai", "moonshotai-cn":
148		return "kimi-k2.6"
149	case "cerebras":
150		return "qwen-3-235b-a22b-instruct-2507"
151	case "groq":
152		return "llama-3.3-70b-versatile"
153	case "xai":
154		return "grok-code-fast-1"
155	case "together":
156		return "Qwen/Qwen3-Coder-480B-A35B-Instruct"
157	case "huggingface":
158		return "moonshotai/Kimi-K2-Instruct"
159	case "openrouter":
160		return "anthropic/claude-sonnet-4.5"
161	case "mistral":
162		return "mistral-large-latest"
163	case "zai":
164		return "glm-4.7"
165	case "xiaomi", "xiaomi-token-plan-ams", "xiaomi-token-plan-cn", "xiaomi-token-plan-sgp":
166		return "mimo-v2.5"
167	case "minimax", "minimax-cn":
168		return "MiniMax-M2.7"
169	case "fireworks":
170		return "accounts/fireworks/models/kimi-k2p6"
171	case "vercel-ai-gateway":
172		return "anthropic/claude-sonnet-4.5"
173	case "opencode":
174		return "claude-sonnet-4-5"
175	case "opencode-go":
176		return "kimi-k2.6"
177	case "amazon-bedrock":
178		return "anthropic.claude-sonnet-4-5-20250929-v1:0"
179	case "google-vertex":
180		return "gemini-2.5-pro"
181	case "azure-openai-responses":
182		return "gpt-5"
183	case "github-copilot":
184		return "claude-sonnet-4.5"
185	default:
186		return provider.DefaultModel.ID
187	}
188}
189
190// knownProviders is the set of provider ids zot recognises. Used by
191// Resolve to validate args.Provider, by extension-callers, and by the
192// auto-fallback logic that picks any logged-in provider when the user's
193// preferred one has no credentials.
194var knownProviders = []string{
195	"anthropic", "openai", "openai-codex", "openai-responses", "kimi", "deepseek", "google", "ollama",
196	"moonshotai", "moonshotai-cn",
197	"cerebras", "groq", "xai", "together", "huggingface", "openrouter",
198	"mistral", "zai",
199	"xiaomi", "xiaomi-token-plan-ams", "xiaomi-token-plan-cn", "xiaomi-token-plan-sgp",
200	"minimax", "minimax-cn",
201	"fireworks", "vercel-ai-gateway",
202	"opencode", "opencode-go",
203	"amazon-bedrock", "google-vertex", "azure-openai-responses",
204	"github-copilot", "cloudflare-workers-ai", "cloudflare-ai-gateway",
205}
206
207func isKnownProvider(name string) bool {
208	for _, p := range knownProviders {
209		if p == name {
210			return true
211		}
212	}
213	return false
214}
215
216// providerAliases maps common short / alternate provider names to the
217// canonical id in knownProviders. Users (and other agents) reach for
218// "bedrock" or "vertex" far more naturally than the fully-qualified
219// "amazon-bedrock" / "google-vertex"; without this mapping an alias is
220// treated as an unknown provider and Resolve silently falls back to
221// anthropic, producing a misleading "no credential for anthropic" error
222// after the user explicitly picked, say, bedrock.
223var providerAliases = map[string]string{
224	"bedrock":      "amazon-bedrock",
225	"aws-bedrock":  "amazon-bedrock",
226	"amazon":       "amazon-bedrock",
227	"vertex":       "google-vertex",
228	"gcp-vertex":   "google-vertex",
229	"gemini":       "google",
230	"googleai":     "google",
231	"google-ai":    "google",
232	"azure":        "azure-openai-responses",
233	"azure-openai": "azure-openai-responses",
234	"copilot":      "github-copilot",
235	"github":       "github-copilot",
236	"codex":        "openai-codex",
237	"moonshot":     "moonshotai",
238	"kimi-code":    "kimi",
239	"ai-gateway":   "vercel-ai-gateway",
240	"vercel":       "vercel-ai-gateway",
241	"cloudflare":   "cloudflare-workers-ai",
242	"workers-ai":   "cloudflare-workers-ai",
243	"hf":           "huggingface",
244}
245
246// canonicalProvider normalises a user-supplied provider name: trims
247// surrounding whitespace, lower-cases it, and resolves any known alias
248// to its canonical id. Unknown names are returned trimmed/lower-cased
249// and unchanged so the existing unknown-provider handling still runs.
250func canonicalProvider(name string) string {
251	n := strings.ToLower(strings.TrimSpace(name))
252	if n == "" {
253		return n
254	}
255	if canon, ok := providerAliases[n]; ok {
256		return canon
257	}
258	return n
259}
260
261// Resolve merges args, config, and env into a Resolved set.
262//
263// Unlike the earlier version, Resolve NEVER returns an error for
264// missing credentials: the TUI can start without them and launch a
265// login flow. requireCred controls whether missing credentials are a
266// hard error (used by print/json modes).
267func Resolve(args Args, requireCred bool) (Resolved, error) {
268	cfg, _ := LoadConfig()
269
270	// User-requested provider (explicit > config > default).
271	// Normalise common aliases (e.g. "bedrock" -> "amazon-bedrock")
272	// before validation so an alias is never mistaken for an unknown
273	// provider and silently downgraded to anthropic.
274	argProvider := canonicalProvider(args.Provider)
275	provName := firstNonEmpty(argProvider, canonicalProvider(cfg.Provider), "anthropic")
276	if !isKnownProvider(provName) {
277		// Unknown provider (maybe removed or renamed). Fall back to
278		// the first provider that has credentials, or anthropic.
279		provName = "anthropic"
280		if _, _, _, err := ResolveCredentialFull("openai", ""); err == nil {
281			provName = "openai"
282		}
283		if _, _, _, err := ResolveCredentialFull("openai-codex", ""); err == nil {
284			provName = "openai-codex"
285		}
286		if _, _, _, err := ResolveCredentialFull("kimi", ""); err == nil {
287			provName = "kimi"
288		}
289		if _, _, _, err := ResolveCredentialFull("deepseek", ""); err == nil {
290			provName = "deepseek"
291		}
292		if _, _, _, err := ResolveCredentialFull("anthropic", ""); err == nil {
293			provName = "anthropic"
294		}
295		// Reset the saved config so this doesn't keep happening.
296		cfg.Provider = provName
297		cfg.Model = ""
298		_ = SaveConfig(cfg)
299	}
300
301	var (
302		cred      string
303		method    string
304		accountID string
305		credErr   error
306	)
307	if provName == "ollama" {
308		cred = firstNonEmpty(args.APIKey, "ollama")
309		method = "apikey"
310	} else {
311		cred, method, accountID, credErr = ResolveCredentialFull(provName, args.APIKey)
312	}
313
314	// If the user did NOT explicitly pick a provider and the default one
315	// has no credentials, auto-fall-back to whichever provider is actually
316	// logged in. That way running plain `zot` after `/login` (any provider)
317	// never shows a "not logged in" banner.
318	userPickedProvider := args.Provider != ""
319	if credErr != nil && !userPickedProvider && provName != "ollama" {
320		// Scan every known provider (not a hardcoded subset) so any
321		// env-based credential is discovered, e.g. an env-only
322		// amazon-bedrock setup (AWS_BEARER_TOKEN_BEDROCK / AWS_PROFILE /
323		// IAM keys) when no config.json pins the provider, such as after
324		// pointing ZOT_HOME at a fresh home dir. Iteration order of
325		// knownProviders defines fallback priority. ollama is skipped:
326		// it has no credential and would always "match".
327		for _, other := range knownProviders {
328			if other == provName || other == "ollama" {
329				continue
330			}
331			if c, m, a, err := ResolveCredentialFull(other, args.APIKey); err == nil {
332				provName = other
333				cred, method, accountID, credErr = c, m, a, err
334				break
335			}
336		}
337	}
338
339	model := firstNonEmpty(args.Model, cfg.Model)
340	if model == "" {
341		if provName == "ollama" {
342			return Resolved{}, fmt.Errorf("ollama requires --model (e.g. --model llama3)")
343		}
344		model = defaultModelForProvider(provName)
345	}
346	// If the resolved model belongs to a different provider (e.g. config
347	// says gpt-5 but we fell back to anthropic), pick that provider's default.
348	if provName != "ollama" {
349		if m, err := provider.FindModel("", model); err == nil && m.Provider != provName {
350			model = defaultModelForProvider(provName)
351		}
352	}
353	resolvedModel, err := provider.FindModel(provName, model)
354	if err != nil && provName == "ollama" {
355		// ollama is intentionally open-catalogue: any model id the
356		// local server understands is valid, even if not in the
357		// baked-in catalog.
358		resolvedModel = provider.Model{
359			Provider:      "ollama",
360			ID:            model,
361			DisplayName:   model,
362			ContextWindow: 32768,
363			MaxOutput:     8192,
364			BaseURL:       args.BaseURL,
365			Source:        "ollama",
366		}
367		err = nil
368	}
369	if err != nil {
370		// The model the user (or persisted config) asked for is no
371		// longer in the active catalogue — they probably removed it
372		// from their models.json or upgraded zot and the id changed.
373		// Refusing to launch is the wrong move: it strands the user
374		// with no way to even open the TUI and pick a new model.
375		// Fall back to the provider's default, warn on stderr, and,
376		// when the stale id came from the persisted config (not an
377		// explicit --model flag), repair the config so the warning
378		// doesn't repeat on every launch.
379		fallback := defaultModelForProvider(provName)
380		fm, ferr := provider.FindModel(provName, fallback)
381		if ferr != nil {
382			// Even the provider default is gone (catastrophic
383			// catalogue trim). Last resort: any model on this
384			// provider, then the global DefaultModel.
385			if candidates := provider.ModelsForProvider(provName); len(candidates) > 0 {
386				fm = candidates[0]
387				ferr = nil
388			} else {
389				fm = provider.DefaultModel
390				ferr = nil
391			}
392		}
393		fmt.Fprintf(os.Stderr,
394			"zot: model %q is not in the active catalogue; using %q instead. Pick a different model with --model or /model.\n",
395			model, fm.ID)
396		if args.Model == "" && cfg.Model == model {
397			cfg.Model = fm.ID
398			_ = SaveConfig(cfg)
399		}
400		resolvedModel = fm
401		model = fm.ID
402	}
403
404	// If the model defines a base URL (e.g. local ollama) and the
405	// user didn't pass --base-url, use the model's URL. For ollama,
406	// keep http://localhost:11434 as a fallback only after the model
407	// metadata has had a chance to provide a custom baseUrl.
408	if args.BaseURL == "" && resolvedModel.BaseURL != "" {
409		args.BaseURL = resolvedModel.BaseURL
410	}
411	if args.BaseURL == "" && provName == "ollama" {
412		args.BaseURL = "http://localhost:11434"
413	}
414
415	// If the model has a base URL, credentials are optional (local
416	// models like ollama don't need real API keys).
417	if resolvedModel.BaseURL != "" && credErr != nil {
418		cred = "ollama"
419		credErr = nil
420		requireCred = false
421	}
422
423	if credErr != nil && requireCred {
424		return Resolved{}, fmt.Errorf("%w; set %s_API_KEY, pass --api-key, or run `zot` and /login",
425			credErr, envVarName(provName))
426	}
427
428	sandbox := tools.NewSandbox(args.CWD)
429	reg := buildToolRegistry(args, args.CWD, sandbox)
430
431	docsDir, _ := zotdocs.EnsureInstalled(ZotHome())
432
433	// Skill discovery: scan project + global locations + built-in
434	// skills shipped with the binary. If any are found, register
435	// the on-demand `skill` loader tool plus a system-prompt
436	// manifest so the model knows what's available.
437	//
438	// --no-skill bypasses the entire mechanism: no manifest in the
439	// system prompt, no `skill` tool in the registry. Useful for a
440	// clean-room run with zero extra context biasing the model.
441	var (
442		discovered    []*skills.Skill
443		skillTool     *skills.Tool
444		skillAddendum string
445	)
446	if !args.NoSkill {
447		homeDir, _ := os.UserHomeDir()
448		discovered, _ = skills.Discover(ZotHome(), args.CWD, homeDir, args.WithSkills)
449		if len(discovered) > 0 {
450			skillTool = skills.NewTool(discovered)
451			reg[skillTool.Name()] = skillTool
452			skillAddendum = skills.SystemPromptAddendum(discovered)
453		}
454	}
455	_ = skillTool
456
457	summaries := toolSummaries(reg, args)
458
459	append_ := append([]string(nil), args.AppendSystemPrompt...)
460	if agentsAddendum := readAgentsContext(args.CWD, ZotHome()); agentsAddendum != "" {
461		append_ = append(append_, agentsAddendum)
462	}
463	if skillAddendum != "" {
464		append_ = append(append_, skillAddendum)
465	}
466	if AutoSwarmEnabled() {
467		append_ = append(append_, AutoSwarmSystemAddendum)
468	}
469
470	// Custom system prompt resolution order:
471	//   1. --system-prompt flag (highest priority; ad-hoc per run)
472	//   2. $ZOT_HOME/SYSTEM.md (persistent user override)
473	//   3. built-in default (defaultIdentity + defaultGuidelines)
474	custom := args.SystemPrompt
475	if custom == "" {
476		custom = readUserSystemPrompt(ZotHome())
477	}
478
479	sys := BuildSystemPrompt(SystemPromptOpts{
480		CWD:        args.CWD,
481		Tools:      summaries,
482		Custom:     custom,
483		Append:     append_,
484		ZotDocsDir: docsDir,
485	})
486
487	reasoning := provider.NormalizeReasoning(firstNonEmpty(args.Reasoning, cfg.Reasoning))
488
489	max := args.MaxSteps // 0 = unlimited
490
491	return Resolved{
492		Provider:         provName,
493		Model:            model,
494		Credential:       cred,
495		AuthMethod:       method,
496		AccountID:        accountID,
497		BaseURL:          args.BaseURL,
498		CWD:              args.CWD,
499		Reasoning:        reasoning,
500		ToolRegistry:     reg,
501		ToolSummary:      summaries,
502		SystemPrompt:     sys,
503		MaxSteps:         max,
504		Sandbox:          sandbox,
505		SkillTool:        skillTool,
506		systemAppend:     append_,
507		systemCustom:     custom,
508		toolDescriptions: descMapFromSummaries(summaries),
509	}, nil
510}
511
512// readUserSystemPrompt looks for $ZOT_HOME/SYSTEM.md and returns its
513// trimmed contents, or "" when the file is missing / unreadable /
514// empty. Errors are intentionally swallowed: the file is optional,
515// and any failure to read it should fall back to the built-in
516// default system prompt rather than crash the run.
517func readUserSystemPrompt(zotHome string) string {
518	if zotHome == "" {
519		return ""
520	}
521	path := filepath.Join(zotHome, "SYSTEM.md")
522	raw, err := os.ReadFile(path)
523	if err != nil {
524		return ""
525	}
526	return strings.TrimSpace(string(raw))
527}
528
529// readAgentsContext loads optional AGENTS.md instruction files. No
530// default file is created or required: zot only includes files that
531// already exist. Global instructions ($ZOT_HOME/AGENTS.md) come first,
532// followed by project instructions from the top-most parent down to cwd.
533func readAgentsContext(cwd, zotHome string) string {
534	type contextFile struct {
535		path    string
536		content string
537	}
538	var files []contextFile
539	seen := map[string]bool{}
540	add := func(path string) {
541		if path == "" {
542			return
543		}
544		abs, err := filepath.Abs(path)
545		if err == nil {
546			path = abs
547		}
548		if seen[path] {
549			return
550		}
551		raw, err := os.ReadFile(path)
552		if err != nil {
553			return
554		}
555		content := strings.TrimSpace(string(raw))
556		if content == "" {
557			return
558		}
559		seen[path] = true
560		files = append(files, contextFile{path: path, content: content})
561	}
562	addFirstFromDir := func(dir string) {
563		if dir == "" {
564			return
565		}
566		for _, name := range []string{"AGENTS.md", "AGENTS.MD"} {
567			path := filepath.Join(dir, name)
568			if _, err := os.Stat(path); err == nil {
569				add(path)
570				return
571			}
572		}
573	}
574
575	addFirstFromDir(zotHome)
576
577	if cwd != "" {
578		abs, err := filepath.Abs(cwd)
579		if err == nil {
580			cwd = abs
581		}
582		var dirs []string
583		for dir := filepath.Clean(cwd); ; dir = filepath.Dir(dir) {
584			dirs = append(dirs, dir)
585			parent := filepath.Dir(dir)
586			if parent == dir {
587				break
588			}
589		}
590		for i := len(dirs) - 1; i >= 0; i-- {
591			addFirstFromDir(dirs[i])
592		}
593	}
594
595	if len(files) == 0 {
596		return ""
597	}
598	var sb strings.Builder
599	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")
600	for _, f := range files {
601		fmt.Fprintf(&sb, "\n## %s\n\n%s\n", f.path, f.content)
602	}
603	return strings.TrimSpace(sb.String())
604}
605
606// descMapFromSummaries indexes the human-readable descriptions for
607// the renderToolsSection rebuild path.
608func descMapFromSummaries(summaries []ToolSummary) map[string]string {
609	out := make(map[string]string, len(summaries))
610	for _, s := range summaries {
611		out[s.Name] = s.Description
612	}
613	return out
614}
615
616// NewClient returns a provider.Client for r, choosing the auth mode
617// based on r.AuthMethod. Panics if no credential is present; callers
618// must check HasCredential() first.
619func (r Resolved) NewClient() provider.Client {
620	if !r.HasCredential() {
621		panic("NewClient called without credential; check HasCredential first")
622	}
623	switch r.Provider {
624	case "ollama":
625		return provider.NewOpenAI(r.Credential, r.BaseURL)
626	case "kimi":
627		// kimi-coding speaks anthropic-messages on api.kimi.com/coding.
628		// Subscription OAuth (refreshed) wraps the same Anthropic-shaped client.
629		inner := provider.NewKimiCodingWithHeaders(r.Credential, r.BaseURL, kimiCodeHeaders())
630		if r.AuthMethod == "oauth" {
631			return r.wrapWithRefresh(inner)
632		}
633		return inner
634	case "moonshotai":
635		return provider.NewMoonshot(r.Credential, r.BaseURL)
636	case "moonshotai-cn":
637		return provider.NewMoonshotCN(r.Credential, r.BaseURL)
638	case "deepseek":
639		return provider.NewDeepSeek(r.Credential, r.BaseURL)
640	case "openai":
641		return provider.NewOpenAI(r.Credential, r.BaseURL)
642	case "openai-codex":
643		inner := provider.NewOpenAICodex(r.Credential, r.AccountID, r.BaseURL)
644		return r.wrapWithRefresh(inner)
645	case "openai-responses":
646		// Public OpenAI Responses API (api.openai.com/v1/responses) via
647		// API key. Separate provider from `openai` (Chat Completions) and
648		// from `openai-codex` (ChatGPT subscription OAuth).
649		return provider.NewOpenAIResponses(r.Credential, r.BaseURL)
650	case "google":
651		return provider.NewGemini(r.Credential, r.BaseURL)
652	case "cerebras":
653		return provider.NewCerebras(r.Credential, r.BaseURL)
654	case "groq":
655		return provider.NewGroq(r.Credential, r.BaseURL)
656	case "xai":
657		return provider.NewXAI(r.Credential, r.BaseURL)
658	case "together":
659		return provider.NewTogether(r.Credential, r.BaseURL)
660	case "huggingface":
661		return provider.NewHuggingFace(r.Credential, r.BaseURL)
662	case "openrouter":
663		return provider.NewOpenRouter(r.Credential, r.BaseURL)
664	case "zai":
665		return provider.NewZAI(r.Credential, r.BaseURL)
666	case "xiaomi":
667		return provider.NewXiaomi(r.Credential, r.BaseURL)
668	case "xiaomi-token-plan-ams":
669		return provider.NewXiaomiTokenPlan("ams", r.Credential, r.BaseURL)
670	case "xiaomi-token-plan-cn":
671		return provider.NewXiaomiTokenPlan("cn", r.Credential, r.BaseURL)
672	case "xiaomi-token-plan-sgp":
673		return provider.NewXiaomiTokenPlan("sgp", r.Credential, r.BaseURL)
674	case "opencode":
675		return provider.NewOpenCode(r.Credential, r.BaseURL)
676	case "opencode-go":
677		return provider.NewOpenCodeGo(r.Credential, r.BaseURL)
678	case "minimax":
679		return provider.NewMinimaxAnthropic(r.Credential, r.BaseURL)
680	case "minimax-cn":
681		return provider.NewMinimaxCNAnthropic(r.Credential, r.BaseURL)
682	case "fireworks":
683		return provider.NewFireworksAnthropic(r.Credential, r.BaseURL)
684	case "vercel-ai-gateway":
685		return provider.NewVercelGatewayAnthropic(r.Credential, r.BaseURL)
686	case "mistral":
687		return provider.NewMistral(r.Credential, r.BaseURL)
688	case "amazon-bedrock":
689		return provider.NewBedrock(r.Credential, r.BaseURL)
690	case "google-vertex":
691		return provider.NewGoogleVertex(r.Credential, r.BaseURL)
692	case "azure-openai-responses":
693		return provider.NewAzureOpenAIResponses(r.Credential, r.BaseURL)
694	case "github-copilot":
695		return provider.NewGithubCopilot(r.Credential, r.BaseURL)
696	case "cloudflare-workers-ai":
697		return provider.NewCloudflareWorkersAI(r.Credential, r.BaseURL)
698	case "cloudflare-ai-gateway":
699		return provider.NewCloudflareAIGateway(r.Credential, r.BaseURL)
700	default:
701		if r.AuthMethod == "oauth" {
702			inner := provider.NewAnthropicOAuth(r.Credential, r.BaseURL)
703			return r.wrapWithRefresh(inner)
704		}
705		return provider.NewAnthropic(r.Credential, r.BaseURL)
706	}
707}
708
709// wrapWithRefresh wraps an OAuth client so the access token is
710// refreshed automatically before each API call. Without this, long
711// sessions (hours) silently fail when the 1-hour token expires.
712func (r Resolved) wrapWithRefresh(inner provider.Client) provider.Client {
713	provName := r.Provider
714	tokenProvider := provName
715	if provName == "openai-codex" {
716		tokenProvider = "openai"
717	}
718	baseURL := r.BaseURL
719	accountID := r.AccountID
720
721	refreshFn := func(ctx context.Context) (string, error) {
722		tok, err := refreshIfExpired(tokenProvider, loadOAuthToken(tokenProvider))
723		if err != nil {
724			return "", err
725		}
726		return tok.AccessToken, nil
727	}
728
729	factory := func(token string) provider.Client {
730		switch provName {
731		case "openai-codex":
732			return provider.NewOpenAICodex(token, accountID, baseURL)
733		case "kimi":
734			// anthropic-messages on api.kimi.com/coding.
735			return provider.NewKimiCodingWithHeaders(token, baseURL, kimiCodeHeaders())
736		default:
737			return provider.NewAnthropicOAuth(token, baseURL)
738		}
739	}
740
741	return provider.NewRefreshingClient(inner, refreshFn, factory)
742}
743
744// UseSandbox replaces the sandbox pointer that every tool in r's
745// registry references. Used to keep the /jail state stable across
746// agent rebuilds (e.g. /login, /model switching providers).
747func (r *Resolved) UseSandbox(s *tools.Sandbox) {
748	if s == nil || r == nil {
749		return
750	}
751	r.Sandbox = s
752	for name, t := range r.ToolRegistry {
753		switch v := t.(type) {
754		case *tools.ReadTool:
755			v.Sandbox = s
756		case *tools.WriteTool:
757			v.Sandbox = s
758		case *tools.EditTool:
759			v.Sandbox = s
760		case *tools.BashTool:
761			v.Sandbox = s
762		}
763		_ = name
764	}
765}
766
767// NewAgent constructs a core.Agent from r. Requires a credential.
768func (r Resolved) NewAgent() *core.Agent {
769	a := core.NewAgent(r.NewClient(), r.Model, r.SystemPrompt, r.ToolRegistry)
770	a.MaxSteps = r.MaxSteps
771	a.Reasoning = r.Reasoning
772	return a
773}
774
775func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox) core.Registry {
776	if args.NoTools {
777		return core.Registry{}
778	}
779	all := map[string]core.Tool{
780		"read":  &tools.ReadTool{CWD: cwd, Sandbox: sandbox},
781		"write": &tools.WriteTool{CWD: cwd, Sandbox: sandbox},
782		"edit":  &tools.EditTool{CWD: cwd, Sandbox: sandbox},
783		"bash":  &tools.BashTool{CWD: cwd, Sandbox: sandbox},
784	}
785	reg := core.Registry{}
786	if len(args.Tools) == 0 {
787		for _, t := range all {
788			reg[t.Name()] = t
789		}
790		return reg
791	}
792	for _, name := range args.Tools {
793		if t, ok := all[name]; ok {
794			reg[name] = t
795		}
796	}
797	return reg
798}
799
800func toolSummaries(reg core.Registry, args Args) []ToolSummary {
801	order := []string{"read", "write", "edit", "bash"}
802	var out []ToolSummary
803	for _, name := range order {
804		if t, ok := reg[name]; ok {
805			out = append(out, ToolSummary{Name: t.Name(), Description: t.Description()})
806		}
807	}
808	return out
809}
810
811func firstNonEmpty(vals ...string) string {
812	for _, v := range vals {
813		if v != "" {
814			return v
815		}
816	}
817	return ""
818}
819
820func kimiCodeHeaders() map[string]string {
821	host, _ := os.Hostname()
822	if host == "" {
823		host = "unknown"
824	}
825	deviceID := ""
826	if home, err := os.UserHomeDir(); err == nil {
827		if b, err := os.ReadFile(filepath.Join(home, ".kimi", "device_id")); err == nil {
828			deviceID = strings.TrimSpace(string(b))
829		}
830	}
831	if deviceID == "" {
832		deviceID = "zot"
833	}
834	return map[string]string{
835		"User-Agent":         "KimiCLI/1.41.0",
836		"X-Msh-Platform":     "kimi_cli",
837		"X-Msh-Version":      "1.41.0",
838		"X-Msh-Device-Name":  host,
839		"X-Msh-Device-Model": runtime.GOOS + "-" + runtime.GOARCH,
840		"X-Msh-Os-Version":   runtime.GOOS,
841		"X-Msh-Device-Id":    deviceID,
842	}
843}
844
845func envVarName(provider string) string {
846	switch provider {
847	case "openai", "openai-codex", "openai-responses":
848		return "OPENAI"
849	case "kimi":
850		return "KIMI"
851	case "deepseek":
852		return "DEEPSEEK"
853	case "google":
854		return "GEMINI"
855	case "ollama":
856		return "OLLAMA"
857	case "moonshotai", "moonshotai-cn":
858		return "MOONSHOT"
859	case "groq":
860		return "GROQ"
861	case "xai":
862		return "XAI"
863	case "cerebras":
864		return "CEREBRAS"
865	case "together":
866		return "TOGETHER"
867	case "huggingface":
868		return "HF"
869	case "openrouter":
870		return "OPENROUTER"
871	case "mistral":
872		return "MISTRAL"
873	case "zai":
874		return "ZAI"
875	case "xiaomi":
876		return "XIAOMI"
877	case "xiaomi-token-plan-ams":
878		return "XIAOMI_TOKEN_PLAN_AMS"
879	case "xiaomi-token-plan-cn":
880		return "XIAOMI_TOKEN_PLAN_CN"
881	case "xiaomi-token-plan-sgp":
882		return "XIAOMI_TOKEN_PLAN_SGP"
883	case "minimax":
884		return "MINIMAX"
885	case "minimax-cn":
886		return "MINIMAX_CN"
887	case "fireworks":
888		return "FIREWORKS"
889	case "vercel-ai-gateway":
890		return "AI_GATEWAY"
891	case "opencode", "opencode-go":
892		return "OPENCODE"
893	case "github-copilot":
894		return "COPILOT_GITHUB_TOKEN"
895	case "cloudflare-workers-ai", "cloudflare-ai-gateway":
896		return "CLOUDFLARE"
897	case "amazon-bedrock":
898		return "AWS"
899	case "google-vertex":
900		return "GOOGLE_CLOUD"
901	case "azure-openai-responses":
902		return "AZURE_OPENAI"
903	default:
904		return "ANTHROPIC"
905	}
906}

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