v0.2.17 323df7f

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		// amazon-bedrock is included so an env-only bedrock setup
321		// (AWS_BEARER_TOKEN_BEDROCK / AWS_PROFILE / IAM keys) is still
322		// discovered when no config.json pins the provider, e.g. after
323		// pointing ZOT_HOME at a fresh home dir.
324		for _, other := range []string{"anthropic", "openai", "openai-codex", "kimi", "deepseek", "google", "amazon-bedrock"} {
325			if other == provName {
326				continue
327			}
328			if c, m, a, err := ResolveCredentialFull(other, args.APIKey); err == nil {
329				provName = other
330				cred, method, accountID, credErr = c, m, a, err
331				break
332			}
333		}
334	}
335
336	model := firstNonEmpty(args.Model, cfg.Model)
337	if model == "" {
338		if provName == "ollama" {
339			return Resolved{}, fmt.Errorf("ollama requires --model (e.g. --model llama3)")
340		}
341		model = defaultModelForProvider(provName)
342	}
343	// If the resolved model belongs to a different provider (e.g. config
344	// says gpt-5 but we fell back to anthropic), pick that provider's default.
345	if provName != "ollama" {
346		if m, err := provider.FindModel("", model); err == nil && m.Provider != provName {
347			model = defaultModelForProvider(provName)
348		}
349	}
350	resolvedModel, err := provider.FindModel(provName, model)
351	if err != nil && provName == "ollama" {
352		// ollama is intentionally open-catalogue: any model id the
353		// local server understands is valid, even if not in the
354		// baked-in catalog.
355		resolvedModel = provider.Model{
356			Provider:      "ollama",
357			ID:            model,
358			DisplayName:   model,
359			ContextWindow: 32768,
360			MaxOutput:     8192,
361			BaseURL:       args.BaseURL,
362			Source:        "ollama",
363		}
364		err = nil
365	}
366	if err != nil {
367		// The model the user (or persisted config) asked for is no
368		// longer in the active catalogue — they probably removed it
369		// from their models.json or upgraded zot and the id changed.
370		// Refusing to launch is the wrong move: it strands the user
371		// with no way to even open the TUI and pick a new model.
372		// Fall back to the provider's default, warn on stderr, and,
373		// when the stale id came from the persisted config (not an
374		// explicit --model flag), repair the config so the warning
375		// doesn't repeat on every launch.
376		fallback := defaultModelForProvider(provName)
377		fm, ferr := provider.FindModel(provName, fallback)
378		if ferr != nil {
379			// Even the provider default is gone (catastrophic
380			// catalogue trim). Last resort: any model on this
381			// provider, then the global DefaultModel.
382			if candidates := provider.ModelsForProvider(provName); len(candidates) > 0 {
383				fm = candidates[0]
384				ferr = nil
385			} else {
386				fm = provider.DefaultModel
387				ferr = nil
388			}
389		}
390		fmt.Fprintf(os.Stderr,
391			"zot: model %q is not in the active catalogue; using %q instead. Pick a different model with --model or /model.\n",
392			model, fm.ID)
393		if args.Model == "" && cfg.Model == model {
394			cfg.Model = fm.ID
395			_ = SaveConfig(cfg)
396		}
397		resolvedModel = fm
398		model = fm.ID
399	}
400
401	// If the model defines a base URL (e.g. local ollama) and the
402	// user didn't pass --base-url, use the model's URL. For ollama,
403	// keep http://localhost:11434 as a fallback only after the model
404	// metadata has had a chance to provide a custom baseUrl.
405	if args.BaseURL == "" && resolvedModel.BaseURL != "" {
406		args.BaseURL = resolvedModel.BaseURL
407	}
408	if args.BaseURL == "" && provName == "ollama" {
409		args.BaseURL = "http://localhost:11434"
410	}
411
412	// If the model has a base URL, credentials are optional (local
413	// models like ollama don't need real API keys).
414	if resolvedModel.BaseURL != "" && credErr != nil {
415		cred = "ollama"
416		credErr = nil
417		requireCred = false
418	}
419
420	if credErr != nil && requireCred {
421		return Resolved{}, fmt.Errorf("%w; set %s_API_KEY, pass --api-key, or run `zot` and /login",
422			credErr, envVarName(provName))
423	}
424
425	sandbox := tools.NewSandbox(args.CWD)
426	reg := buildToolRegistry(args, args.CWD, sandbox)
427
428	docsDir, _ := zotdocs.EnsureInstalled(ZotHome())
429
430	// Skill discovery: scan project + global locations + built-in
431	// skills shipped with the binary. If any are found, register
432	// the on-demand `skill` loader tool plus a system-prompt
433	// manifest so the model knows what's available.
434	//
435	// --no-skill bypasses the entire mechanism: no manifest in the
436	// system prompt, no `skill` tool in the registry. Useful for a
437	// clean-room run with zero extra context biasing the model.
438	var (
439		discovered    []*skills.Skill
440		skillTool     *skills.Tool
441		skillAddendum string
442	)
443	if !args.NoSkill {
444		homeDir, _ := os.UserHomeDir()
445		discovered, _ = skills.Discover(ZotHome(), args.CWD, homeDir, args.WithSkills)
446		if len(discovered) > 0 {
447			skillTool = skills.NewTool(discovered)
448			reg[skillTool.Name()] = skillTool
449			skillAddendum = skills.SystemPromptAddendum(discovered)
450		}
451	}
452	_ = skillTool
453
454	summaries := toolSummaries(reg, args)
455
456	append_ := append([]string(nil), args.AppendSystemPrompt...)
457	if agentsAddendum := readAgentsContext(args.CWD, ZotHome()); agentsAddendum != "" {
458		append_ = append(append_, agentsAddendum)
459	}
460	if skillAddendum != "" {
461		append_ = append(append_, skillAddendum)
462	}
463	if AutoSwarmEnabled() {
464		append_ = append(append_, AutoSwarmSystemAddendum)
465	}
466
467	// Custom system prompt resolution order:
468	//   1. --system-prompt flag (highest priority; ad-hoc per run)
469	//   2. $ZOT_HOME/SYSTEM.md (persistent user override)
470	//   3. built-in default (defaultIdentity + defaultGuidelines)
471	custom := args.SystemPrompt
472	if custom == "" {
473		custom = readUserSystemPrompt(ZotHome())
474	}
475
476	sys := BuildSystemPrompt(SystemPromptOpts{
477		CWD:        args.CWD,
478		Tools:      summaries,
479		Custom:     custom,
480		Append:     append_,
481		ZotDocsDir: docsDir,
482	})
483
484	reasoning := provider.NormalizeReasoning(firstNonEmpty(args.Reasoning, cfg.Reasoning))
485
486	max := args.MaxSteps // 0 = unlimited
487
488	return Resolved{
489		Provider:         provName,
490		Model:            model,
491		Credential:       cred,
492		AuthMethod:       method,
493		AccountID:        accountID,
494		BaseURL:          args.BaseURL,
495		CWD:              args.CWD,
496		Reasoning:        reasoning,
497		ToolRegistry:     reg,
498		ToolSummary:      summaries,
499		SystemPrompt:     sys,
500		MaxSteps:         max,
501		Sandbox:          sandbox,
502		SkillTool:        skillTool,
503		systemAppend:     append_,
504		systemCustom:     custom,
505		toolDescriptions: descMapFromSummaries(summaries),
506	}, nil
507}
508
509// readUserSystemPrompt looks for $ZOT_HOME/SYSTEM.md and returns its
510// trimmed contents, or "" when the file is missing / unreadable /
511// empty. Errors are intentionally swallowed: the file is optional,
512// and any failure to read it should fall back to the built-in
513// default system prompt rather than crash the run.
514func readUserSystemPrompt(zotHome string) string {
515	if zotHome == "" {
516		return ""
517	}
518	path := filepath.Join(zotHome, "SYSTEM.md")
519	raw, err := os.ReadFile(path)
520	if err != nil {
521		return ""
522	}
523	return strings.TrimSpace(string(raw))
524}
525
526// readAgentsContext loads optional AGENTS.md instruction files. No
527// default file is created or required: zot only includes files that
528// already exist. Global instructions ($ZOT_HOME/AGENTS.md) come first,
529// followed by project instructions from the top-most parent down to cwd.
530func readAgentsContext(cwd, zotHome string) string {
531	type contextFile struct {
532		path    string
533		content string
534	}
535	var files []contextFile
536	seen := map[string]bool{}
537	add := func(path string) {
538		if path == "" {
539			return
540		}
541		abs, err := filepath.Abs(path)
542		if err == nil {
543			path = abs
544		}
545		if seen[path] {
546			return
547		}
548		raw, err := os.ReadFile(path)
549		if err != nil {
550			return
551		}
552		content := strings.TrimSpace(string(raw))
553		if content == "" {
554			return
555		}
556		seen[path] = true
557		files = append(files, contextFile{path: path, content: content})
558	}
559	addFirstFromDir := func(dir string) {
560		if dir == "" {
561			return
562		}
563		for _, name := range []string{"AGENTS.md", "AGENTS.MD"} {
564			path := filepath.Join(dir, name)
565			if _, err := os.Stat(path); err == nil {
566				add(path)
567				return
568			}
569		}
570	}
571
572	addFirstFromDir(zotHome)
573
574	if cwd != "" {
575		abs, err := filepath.Abs(cwd)
576		if err == nil {
577			cwd = abs
578		}
579		var dirs []string
580		for dir := filepath.Clean(cwd); ; dir = filepath.Dir(dir) {
581			dirs = append(dirs, dir)
582			parent := filepath.Dir(dir)
583			if parent == dir {
584				break
585			}
586		}
587		for i := len(dirs) - 1; i >= 0; i-- {
588			addFirstFromDir(dirs[i])
589		}
590	}
591
592	if len(files) == 0 {
593		return ""
594	}
595	var sb strings.Builder
596	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")
597	for _, f := range files {
598		fmt.Fprintf(&sb, "\n## %s\n\n%s\n", f.path, f.content)
599	}
600	return strings.TrimSpace(sb.String())
601}
602
603// descMapFromSummaries indexes the human-readable descriptions for
604// the renderToolsSection rebuild path.
605func descMapFromSummaries(summaries []ToolSummary) map[string]string {
606	out := make(map[string]string, len(summaries))
607	for _, s := range summaries {
608		out[s.Name] = s.Description
609	}
610	return out
611}
612
613// NewClient returns a provider.Client for r, choosing the auth mode
614// based on r.AuthMethod. Panics if no credential is present; callers
615// must check HasCredential() first.
616func (r Resolved) NewClient() provider.Client {
617	if !r.HasCredential() {
618		panic("NewClient called without credential; check HasCredential first")
619	}
620	switch r.Provider {
621	case "ollama":
622		return provider.NewOpenAI(r.Credential, r.BaseURL)
623	case "kimi":
624		// kimi-coding speaks anthropic-messages on api.kimi.com/coding.
625		// Subscription OAuth (refreshed) wraps the same Anthropic-shaped client.
626		inner := provider.NewKimiCodingWithHeaders(r.Credential, r.BaseURL, kimiCodeHeaders())
627		if r.AuthMethod == "oauth" {
628			return r.wrapWithRefresh(inner)
629		}
630		return inner
631	case "moonshotai":
632		return provider.NewMoonshot(r.Credential, r.BaseURL)
633	case "moonshotai-cn":
634		return provider.NewMoonshotCN(r.Credential, r.BaseURL)
635	case "deepseek":
636		return provider.NewDeepSeek(r.Credential, r.BaseURL)
637	case "openai":
638		return provider.NewOpenAI(r.Credential, r.BaseURL)
639	case "openai-codex":
640		inner := provider.NewOpenAICodex(r.Credential, r.AccountID, r.BaseURL)
641		return r.wrapWithRefresh(inner)
642	case "openai-responses":
643		// Public OpenAI Responses API (api.openai.com/v1/responses) via
644		// API key. Separate provider from `openai` (Chat Completions) and
645		// from `openai-codex` (ChatGPT subscription OAuth).
646		return provider.NewOpenAIResponses(r.Credential, r.BaseURL)
647	case "google":
648		return provider.NewGemini(r.Credential, r.BaseURL)
649	case "cerebras":
650		return provider.NewCerebras(r.Credential, r.BaseURL)
651	case "groq":
652		return provider.NewGroq(r.Credential, r.BaseURL)
653	case "xai":
654		return provider.NewXAI(r.Credential, r.BaseURL)
655	case "together":
656		return provider.NewTogether(r.Credential, r.BaseURL)
657	case "huggingface":
658		return provider.NewHuggingFace(r.Credential, r.BaseURL)
659	case "openrouter":
660		return provider.NewOpenRouter(r.Credential, r.BaseURL)
661	case "zai":
662		return provider.NewZAI(r.Credential, r.BaseURL)
663	case "xiaomi":
664		return provider.NewXiaomi(r.Credential, r.BaseURL)
665	case "xiaomi-token-plan-ams":
666		return provider.NewXiaomiTokenPlan("ams", r.Credential, r.BaseURL)
667	case "xiaomi-token-plan-cn":
668		return provider.NewXiaomiTokenPlan("cn", r.Credential, r.BaseURL)
669	case "xiaomi-token-plan-sgp":
670		return provider.NewXiaomiTokenPlan("sgp", r.Credential, r.BaseURL)
671	case "opencode":
672		return provider.NewOpenCode(r.Credential, r.BaseURL)
673	case "opencode-go":
674		return provider.NewOpenCodeGo(r.Credential, r.BaseURL)
675	case "minimax":
676		return provider.NewMinimaxAnthropic(r.Credential, r.BaseURL)
677	case "minimax-cn":
678		return provider.NewMinimaxCNAnthropic(r.Credential, r.BaseURL)
679	case "fireworks":
680		return provider.NewFireworksAnthropic(r.Credential, r.BaseURL)
681	case "vercel-ai-gateway":
682		return provider.NewVercelGatewayAnthropic(r.Credential, r.BaseURL)
683	case "mistral":
684		return provider.NewMistral(r.Credential, r.BaseURL)
685	case "amazon-bedrock":
686		return provider.NewBedrock(r.Credential, r.BaseURL)
687	case "google-vertex":
688		return provider.NewGoogleVertex(r.Credential, r.BaseURL)
689	case "azure-openai-responses":
690		return provider.NewAzureOpenAIResponses(r.Credential, r.BaseURL)
691	case "github-copilot":
692		return provider.NewGithubCopilot(r.Credential, r.BaseURL)
693	case "cloudflare-workers-ai":
694		return provider.NewCloudflareWorkersAI(r.Credential, r.BaseURL)
695	case "cloudflare-ai-gateway":
696		return provider.NewCloudflareAIGateway(r.Credential, r.BaseURL)
697	default:
698		if r.AuthMethod == "oauth" {
699			inner := provider.NewAnthropicOAuth(r.Credential, r.BaseURL)
700			return r.wrapWithRefresh(inner)
701		}
702		return provider.NewAnthropic(r.Credential, r.BaseURL)
703	}
704}
705
706// wrapWithRefresh wraps an OAuth client so the access token is
707// refreshed automatically before each API call. Without this, long
708// sessions (hours) silently fail when the 1-hour token expires.
709func (r Resolved) wrapWithRefresh(inner provider.Client) provider.Client {
710	provName := r.Provider
711	tokenProvider := provName
712	if provName == "openai-codex" {
713		tokenProvider = "openai"
714	}
715	baseURL := r.BaseURL
716	accountID := r.AccountID
717
718	refreshFn := func(ctx context.Context) (string, error) {
719		tok, err := refreshIfExpired(tokenProvider, loadOAuthToken(tokenProvider))
720		if err != nil {
721			return "", err
722		}
723		return tok.AccessToken, nil
724	}
725
726	factory := func(token string) provider.Client {
727		switch provName {
728		case "openai-codex":
729			return provider.NewOpenAICodex(token, accountID, baseURL)
730		case "kimi":
731			// anthropic-messages on api.kimi.com/coding.
732			return provider.NewKimiCodingWithHeaders(token, baseURL, kimiCodeHeaders())
733		default:
734			return provider.NewAnthropicOAuth(token, baseURL)
735		}
736	}
737
738	return provider.NewRefreshingClient(inner, refreshFn, factory)
739}
740
741// UseSandbox replaces the sandbox pointer that every tool in r's
742// registry references. Used to keep the /jail state stable across
743// agent rebuilds (e.g. /login, /model switching providers).
744func (r *Resolved) UseSandbox(s *tools.Sandbox) {
745	if s == nil || r == nil {
746		return
747	}
748	r.Sandbox = s
749	for name, t := range r.ToolRegistry {
750		switch v := t.(type) {
751		case *tools.ReadTool:
752			v.Sandbox = s
753		case *tools.WriteTool:
754			v.Sandbox = s
755		case *tools.EditTool:
756			v.Sandbox = s
757		case *tools.BashTool:
758			v.Sandbox = s
759		}
760		_ = name
761	}
762}
763
764// NewAgent constructs a core.Agent from r. Requires a credential.
765func (r Resolved) NewAgent() *core.Agent {
766	a := core.NewAgent(r.NewClient(), r.Model, r.SystemPrompt, r.ToolRegistry)
767	a.MaxSteps = r.MaxSteps
768	a.Reasoning = r.Reasoning
769	return a
770}
771
772func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox) core.Registry {
773	if args.NoTools {
774		return core.Registry{}
775	}
776	all := map[string]core.Tool{
777		"read":  &tools.ReadTool{CWD: cwd, Sandbox: sandbox},
778		"write": &tools.WriteTool{CWD: cwd, Sandbox: sandbox},
779		"edit":  &tools.EditTool{CWD: cwd, Sandbox: sandbox},
780		"bash":  &tools.BashTool{CWD: cwd, Sandbox: sandbox},
781	}
782	reg := core.Registry{}
783	if len(args.Tools) == 0 {
784		for _, t := range all {
785			reg[t.Name()] = t
786		}
787		return reg
788	}
789	for _, name := range args.Tools {
790		if t, ok := all[name]; ok {
791			reg[name] = t
792		}
793	}
794	return reg
795}
796
797func toolSummaries(reg core.Registry, args Args) []ToolSummary {
798	order := []string{"read", "write", "edit", "bash"}
799	var out []ToolSummary
800	for _, name := range order {
801		if t, ok := reg[name]; ok {
802			out = append(out, ToolSummary{Name: t.Name(), Description: t.Description()})
803		}
804	}
805	return out
806}
807
808func firstNonEmpty(vals ...string) string {
809	for _, v := range vals {
810		if v != "" {
811			return v
812		}
813	}
814	return ""
815}
816
817func kimiCodeHeaders() map[string]string {
818	host, _ := os.Hostname()
819	if host == "" {
820		host = "unknown"
821	}
822	deviceID := ""
823	if home, err := os.UserHomeDir(); err == nil {
824		if b, err := os.ReadFile(filepath.Join(home, ".kimi", "device_id")); err == nil {
825			deviceID = strings.TrimSpace(string(b))
826		}
827	}
828	if deviceID == "" {
829		deviceID = "zot"
830	}
831	return map[string]string{
832		"User-Agent":         "KimiCLI/1.41.0",
833		"X-Msh-Platform":     "kimi_cli",
834		"X-Msh-Version":      "1.41.0",
835		"X-Msh-Device-Name":  host,
836		"X-Msh-Device-Model": runtime.GOOS + "-" + runtime.GOARCH,
837		"X-Msh-Os-Version":   runtime.GOOS,
838		"X-Msh-Device-Id":    deviceID,
839	}
840}
841
842func envVarName(provider string) string {
843	switch provider {
844	case "openai", "openai-codex", "openai-responses":
845		return "OPENAI"
846	case "kimi":
847		return "KIMI"
848	case "deepseek":
849		return "DEEPSEEK"
850	case "google":
851		return "GEMINI"
852	case "ollama":
853		return "OLLAMA"
854	case "moonshotai", "moonshotai-cn":
855		return "MOONSHOT"
856	case "groq":
857		return "GROQ"
858	case "xai":
859		return "XAI"
860	case "cerebras":
861		return "CEREBRAS"
862	case "together":
863		return "TOGETHER"
864	case "huggingface":
865		return "HF"
866	case "openrouter":
867		return "OPENROUTER"
868	case "mistral":
869		return "MISTRAL"
870	case "zai":
871		return "ZAI"
872	case "xiaomi":
873		return "XIAOMI"
874	case "xiaomi-token-plan-ams":
875		return "XIAOMI_TOKEN_PLAN_AMS"
876	case "xiaomi-token-plan-cn":
877		return "XIAOMI_TOKEN_PLAN_CN"
878	case "xiaomi-token-plan-sgp":
879		return "XIAOMI_TOKEN_PLAN_SGP"
880	case "minimax":
881		return "MINIMAX"
882	case "minimax-cn":
883		return "MINIMAX_CN"
884	case "fireworks":
885		return "FIREWORKS"
886	case "vercel-ai-gateway":
887		return "AI_GATEWAY"
888	case "opencode", "opencode-go":
889		return "OPENCODE"
890	case "github-copilot":
891		return "COPILOT_GITHUB_TOKEN"
892	case "cloudflare-workers-ai", "cloudflare-ai-gateway":
893		return "CLOUDFLARE"
894	case "amazon-bedrock":
895		return "AWS"
896	case "google-vertex":
897		return "GOOGLE_CLOUD"
898	case "azure-openai-responses":
899		return "AZURE_OPENAI"
900	default:
901		return "ANTHROPIC"
902	}
903}

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