v0.1.99 fb3e1af

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

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