v0.2.14 63e33d9

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

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