v0.2.16 a7ef8c2

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

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