v0.1.93 757e158

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
409	// Custom system prompt resolution order:
410	//   1. --system-prompt flag (highest priority; ad-hoc per run)
411	//   2. $ZOT_HOME/SYSTEM.md (persistent user override)
412	//   3. built-in default (defaultIdentity + defaultGuidelines)
413	custom := args.SystemPrompt
414	if custom == "" {
415		custom = readUserSystemPrompt(ZotHome())
416	}
417
418	sys := BuildSystemPrompt(SystemPromptOpts{
419		CWD:        args.CWD,
420		Tools:      summaries,
421		Custom:     custom,
422		Append:     append_,
423		ZotDocsDir: docsDir,
424	})
425
426	reasoning := firstNonEmpty(args.Reasoning, cfg.Reasoning)
427
428	max := args.MaxSteps // 0 = unlimited
429
430	return Resolved{
431		Provider:         provName,
432		Model:            model,
433		Credential:       cred,
434		AuthMethod:       method,
435		AccountID:        accountID,
436		BaseURL:          args.BaseURL,
437		CWD:              args.CWD,
438		Reasoning:        reasoning,
439		ToolRegistry:     reg,
440		ToolSummary:      summaries,
441		SystemPrompt:     sys,
442		MaxSteps:         max,
443		Sandbox:          sandbox,
444		SkillTool:        skillTool,
445		systemAppend:     append_,
446		systemCustom:     custom,
447		toolDescriptions: descMapFromSummaries(summaries),
448	}, nil
449}
450
451// readUserSystemPrompt looks for $ZOT_HOME/SYSTEM.md and returns its
452// trimmed contents, or "" when the file is missing / unreadable /
453// empty. Errors are intentionally swallowed: the file is optional,
454// and any failure to read it should fall back to the built-in
455// default system prompt rather than crash the run.
456func readUserSystemPrompt(zotHome string) string {
457	if zotHome == "" {
458		return ""
459	}
460	path := filepath.Join(zotHome, "SYSTEM.md")
461	raw, err := os.ReadFile(path)
462	if err != nil {
463		return ""
464	}
465	return strings.TrimSpace(string(raw))
466}
467
468// readAgentsContext loads optional AGENTS.md instruction files. No
469// default file is created or required: zot only includes files that
470// already exist. Global instructions ($ZOT_HOME/AGENTS.md) come first,
471// followed by project instructions from the top-most parent down to cwd.
472func readAgentsContext(cwd, zotHome string) string {
473	type contextFile struct {
474		path    string
475		content string
476	}
477	var files []contextFile
478	seen := map[string]bool{}
479	add := func(path string) {
480		if path == "" {
481			return
482		}
483		abs, err := filepath.Abs(path)
484		if err == nil {
485			path = abs
486		}
487		if seen[path] {
488			return
489		}
490		raw, err := os.ReadFile(path)
491		if err != nil {
492			return
493		}
494		content := strings.TrimSpace(string(raw))
495		if content == "" {
496			return
497		}
498		seen[path] = true
499		files = append(files, contextFile{path: path, content: content})
500	}
501	addFirstFromDir := func(dir string) {
502		if dir == "" {
503			return
504		}
505		for _, name := range []string{"AGENTS.md", "AGENTS.MD"} {
506			path := filepath.Join(dir, name)
507			if _, err := os.Stat(path); err == nil {
508				add(path)
509				return
510			}
511		}
512	}
513
514	addFirstFromDir(zotHome)
515
516	if cwd != "" {
517		abs, err := filepath.Abs(cwd)
518		if err == nil {
519			cwd = abs
520		}
521		var dirs []string
522		for dir := filepath.Clean(cwd); ; dir = filepath.Dir(dir) {
523			dirs = append(dirs, dir)
524			parent := filepath.Dir(dir)
525			if parent == dir {
526				break
527			}
528		}
529		for i := len(dirs) - 1; i >= 0; i-- {
530			addFirstFromDir(dirs[i])
531		}
532	}
533
534	if len(files) == 0 {
535		return ""
536	}
537	var sb strings.Builder
538	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")
539	for _, f := range files {
540		fmt.Fprintf(&sb, "\n## %s\n\n%s\n", f.path, f.content)
541	}
542	return strings.TrimSpace(sb.String())
543}
544
545// descMapFromSummaries indexes the human-readable descriptions for
546// the renderToolsSection rebuild path.
547func descMapFromSummaries(summaries []ToolSummary) map[string]string {
548	out := make(map[string]string, len(summaries))
549	for _, s := range summaries {
550		out[s.Name] = s.Description
551	}
552	return out
553}
554
555// NewClient returns a provider.Client for r, choosing the auth mode
556// based on r.AuthMethod. Panics if no credential is present; callers
557// must check HasCredential() first.
558func (r Resolved) NewClient() provider.Client {
559	if !r.HasCredential() {
560		panic("NewClient called without credential; check HasCredential first")
561	}
562	switch r.Provider {
563	case "ollama":
564		return provider.NewOpenAI(r.Credential, r.BaseURL)
565	case "kimi":
566		// kimi-coding speaks anthropic-messages on api.kimi.com/coding.
567		// Subscription OAuth (refreshed) wraps the same Anthropic-shaped client.
568		inner := provider.NewKimiCodingWithHeaders(r.Credential, r.BaseURL, kimiCodeHeaders())
569		if r.AuthMethod == "oauth" {
570			return r.wrapWithRefresh(inner)
571		}
572		return inner
573	case "moonshotai":
574		return provider.NewMoonshot(r.Credential, r.BaseURL)
575	case "moonshotai-cn":
576		return provider.NewMoonshotCN(r.Credential, r.BaseURL)
577	case "deepseek":
578		return provider.NewDeepSeek(r.Credential, r.BaseURL)
579	case "openai":
580		return provider.NewOpenAI(r.Credential, r.BaseURL)
581	case "openai-codex":
582		inner := provider.NewOpenAICodex(r.Credential, r.AccountID, r.BaseURL)
583		return r.wrapWithRefresh(inner)
584	case "openai-responses":
585		// Public OpenAI Responses API (api.openai.com/v1/responses) via
586		// API key. Separate provider from `openai` (Chat Completions) and
587		// from `openai-codex` (ChatGPT subscription OAuth).
588		return provider.NewOpenAIResponses(r.Credential, r.BaseURL)
589	case "google":
590		return provider.NewGemini(r.Credential, r.BaseURL)
591	case "cerebras":
592		return provider.NewCerebras(r.Credential, r.BaseURL)
593	case "groq":
594		return provider.NewGroq(r.Credential, r.BaseURL)
595	case "xai":
596		return provider.NewXAI(r.Credential, r.BaseURL)
597	case "together":
598		return provider.NewTogether(r.Credential, r.BaseURL)
599	case "huggingface":
600		return provider.NewHuggingFace(r.Credential, r.BaseURL)
601	case "openrouter":
602		return provider.NewOpenRouter(r.Credential, r.BaseURL)
603	case "zai":
604		return provider.NewZAI(r.Credential, r.BaseURL)
605	case "xiaomi":
606		return provider.NewXiaomi(r.Credential, r.BaseURL)
607	case "xiaomi-token-plan-ams":
608		return provider.NewXiaomiTokenPlan("ams", r.Credential, r.BaseURL)
609	case "xiaomi-token-plan-cn":
610		return provider.NewXiaomiTokenPlan("cn", r.Credential, r.BaseURL)
611	case "xiaomi-token-plan-sgp":
612		return provider.NewXiaomiTokenPlan("sgp", r.Credential, r.BaseURL)
613	case "opencode":
614		return provider.NewOpenCode(r.Credential, r.BaseURL)
615	case "opencode-go":
616		return provider.NewOpenCodeGo(r.Credential, r.BaseURL)
617	case "minimax":
618		return provider.NewMinimaxAnthropic(r.Credential, r.BaseURL)
619	case "minimax-cn":
620		return provider.NewMinimaxCNAnthropic(r.Credential, r.BaseURL)
621	case "fireworks":
622		return provider.NewFireworksAnthropic(r.Credential, r.BaseURL)
623	case "vercel-ai-gateway":
624		return provider.NewVercelGatewayAnthropic(r.Credential, r.BaseURL)
625	case "mistral":
626		return provider.NewMistral(r.Credential, r.BaseURL)
627	case "amazon-bedrock":
628		return provider.NewBedrock(r.Credential, r.BaseURL)
629	case "google-vertex":
630		return provider.NewGoogleVertex(r.Credential, r.BaseURL)
631	case "azure-openai-responses":
632		return provider.NewAzureOpenAIResponses(r.Credential, r.BaseURL)
633	case "github-copilot":
634		return provider.NewGithubCopilot(r.Credential, r.BaseURL)
635	case "cloudflare-workers-ai":
636		return provider.NewCloudflareWorkersAI(r.Credential, r.BaseURL)
637	case "cloudflare-ai-gateway":
638		return provider.NewCloudflareAIGateway(r.Credential, r.BaseURL)
639	default:
640		if r.AuthMethod == "oauth" {
641			inner := provider.NewAnthropicOAuth(r.Credential, r.BaseURL)
642			return r.wrapWithRefresh(inner)
643		}
644		return provider.NewAnthropic(r.Credential, r.BaseURL)
645	}
646}
647
648// wrapWithRefresh wraps an OAuth client so the access token is
649// refreshed automatically before each API call. Without this, long
650// sessions (hours) silently fail when the 1-hour token expires.
651func (r Resolved) wrapWithRefresh(inner provider.Client) provider.Client {
652	provName := r.Provider
653	tokenProvider := provName
654	if provName == "openai-codex" {
655		tokenProvider = "openai"
656	}
657	baseURL := r.BaseURL
658	accountID := r.AccountID
659
660	refreshFn := func(ctx context.Context) (string, error) {
661		tok, err := refreshIfExpired(tokenProvider, loadOAuthToken(tokenProvider))
662		if err != nil {
663			return "", err
664		}
665		return tok.AccessToken, nil
666	}
667
668	factory := func(token string) provider.Client {
669		switch provName {
670		case "openai-codex":
671			return provider.NewOpenAICodex(token, accountID, baseURL)
672		case "kimi":
673			// anthropic-messages on api.kimi.com/coding.
674			return provider.NewKimiCodingWithHeaders(token, baseURL, kimiCodeHeaders())
675		default:
676			return provider.NewAnthropicOAuth(token, baseURL)
677		}
678	}
679
680	return provider.NewRefreshingClient(inner, refreshFn, factory)
681}
682
683// UseSandbox replaces the sandbox pointer that every tool in r's
684// registry references. Used to keep the /jail state stable across
685// agent rebuilds (e.g. /login, /model switching providers).
686func (r *Resolved) UseSandbox(s *tools.Sandbox) {
687	if s == nil || r == nil {
688		return
689	}
690	r.Sandbox = s
691	for name, t := range r.ToolRegistry {
692		switch v := t.(type) {
693		case *tools.ReadTool:
694			v.Sandbox = s
695		case *tools.WriteTool:
696			v.Sandbox = s
697		case *tools.EditTool:
698			v.Sandbox = s
699		case *tools.BashTool:
700			v.Sandbox = s
701		}
702		_ = name
703	}
704}
705
706// NewAgent constructs a core.Agent from r. Requires a credential.
707func (r Resolved) NewAgent() *core.Agent {
708	a := core.NewAgent(r.NewClient(), r.Model, r.SystemPrompt, r.ToolRegistry)
709	a.MaxSteps = r.MaxSteps
710	a.Reasoning = r.Reasoning
711	return a
712}
713
714func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox) core.Registry {
715	if args.NoTools {
716		return core.Registry{}
717	}
718	all := map[string]core.Tool{
719		"read":  &tools.ReadTool{CWD: cwd, Sandbox: sandbox},
720		"write": &tools.WriteTool{CWD: cwd, Sandbox: sandbox},
721		"edit":  &tools.EditTool{CWD: cwd, Sandbox: sandbox},
722		"bash":  &tools.BashTool{CWD: cwd, Sandbox: sandbox},
723	}
724	reg := core.Registry{}
725	if len(args.Tools) == 0 {
726		for _, t := range all {
727			reg[t.Name()] = t
728		}
729		return reg
730	}
731	for _, name := range args.Tools {
732		if t, ok := all[name]; ok {
733			reg[name] = t
734		}
735	}
736	return reg
737}
738
739func toolSummaries(reg core.Registry, args Args) []ToolSummary {
740	order := []string{"read", "write", "edit", "bash"}
741	var out []ToolSummary
742	for _, name := range order {
743		if t, ok := reg[name]; ok {
744			out = append(out, ToolSummary{Name: t.Name(), Description: t.Description()})
745		}
746	}
747	return out
748}
749
750func firstNonEmpty(vals ...string) string {
751	for _, v := range vals {
752		if v != "" {
753			return v
754		}
755	}
756	return ""
757}
758
759func kimiCodeHeaders() map[string]string {
760	host, _ := os.Hostname()
761	if host == "" {
762		host = "unknown"
763	}
764	deviceID := ""
765	if home, err := os.UserHomeDir(); err == nil {
766		if b, err := os.ReadFile(filepath.Join(home, ".kimi", "device_id")); err == nil {
767			deviceID = strings.TrimSpace(string(b))
768		}
769	}
770	if deviceID == "" {
771		deviceID = "zot"
772	}
773	return map[string]string{
774		"User-Agent":         "KimiCLI/1.41.0",
775		"X-Msh-Platform":     "kimi_cli",
776		"X-Msh-Version":      "1.41.0",
777		"X-Msh-Device-Name":  host,
778		"X-Msh-Device-Model": runtime.GOOS + "-" + runtime.GOARCH,
779		"X-Msh-Os-Version":   runtime.GOOS,
780		"X-Msh-Device-Id":    deviceID,
781	}
782}
783
784func envVarName(provider string) string {
785	switch provider {
786	case "openai", "openai-codex", "openai-responses":
787		return "OPENAI"
788	case "kimi":
789		return "KIMI"
790	case "deepseek":
791		return "DEEPSEEK"
792	case "google":
793		return "GEMINI"
794	case "ollama":
795		return "OLLAMA"
796	case "moonshotai", "moonshotai-cn":
797		return "MOONSHOT"
798	case "groq":
799		return "GROQ"
800	case "xai":
801		return "XAI"
802	case "cerebras":
803		return "CEREBRAS"
804	case "together":
805		return "TOGETHER"
806	case "huggingface":
807		return "HF"
808	case "openrouter":
809		return "OPENROUTER"
810	case "mistral":
811		return "MISTRAL"
812	case "zai":
813		return "ZAI"
814	case "xiaomi":
815		return "XIAOMI"
816	case "xiaomi-token-plan-ams":
817		return "XIAOMI_TOKEN_PLAN_AMS"
818	case "xiaomi-token-plan-cn":
819		return "XIAOMI_TOKEN_PLAN_CN"
820	case "xiaomi-token-plan-sgp":
821		return "XIAOMI_TOKEN_PLAN_SGP"
822	case "minimax":
823		return "MINIMAX"
824	case "minimax-cn":
825		return "MINIMAX_CN"
826	case "fireworks":
827		return "FIREWORKS"
828	case "vercel-ai-gateway":
829		return "AI_GATEWAY"
830	case "opencode", "opencode-go":
831		return "OPENCODE"
832	case "github-copilot":
833		return "COPILOT_GITHUB_TOKEN"
834	case "cloudflare-workers-ai", "cloudflare-ai-gateway":
835		return "CLOUDFLARE"
836	case "amazon-bedrock":
837		return "AWS"
838	case "google-vertex":
839		return "GOOGLE_CLOUD"
840	case "azure-openai-responses":
841		return "AZURE_OPENAI"
842	default:
843		return "ANTHROPIC"
844	}
845}

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