detached a41cda5

Add built-in Kimi provider support

commit
a41cda50935ce121ff660ec58c7548eb76613596
author
patriceckhart · · 2026-05-05 06:40 UTC
parent
ff1af01
signature
unsigned
17 files · +549 -60
M internal/agent/args.go +1 -1
@@ -334,7 +334,7 @@ func PrintHelp(version string) {
334334 row{"zot tg ...", "short alias for telegram-bot"},
335335 )
336336 section("provider and model flags",
337- row{"--provider", "provider to use (anthropic|openai|ollama)"},
337+ row{"--provider", "provider to use (anthropic|openai|kimi|ollama)"},
338338 row{"--model ID", "model id (see --list-models)"},
339339 row{"--api-key KEY", "api key for this run (env / auth.json fallback)"},
340340 row{"--base-url URL", "override provider api base url"},
@@ -334,7 +334,7 @@ func PrintHelp(version string) {
334 row{"zot tg ...", "short alias for telegram-bot"},334 row{"zot tg ...", "short alias for telegram-bot"},
335 )335 )
336 section("provider and model flags",336 section("provider and model flags",
337- row{"--provider", "provider to use (anthropic|openai|ollama)"},337+ row{"--provider", "provider to use (anthropic|openai|kimi|ollama)"},
338 row{"--model ID", "model id (see --list-models)"},338 row{"--model ID", "model id (see --list-models)"},
339 row{"--api-key KEY", "api key for this run (env / auth.json fallback)"},339 row{"--api-key KEY", "api key for this run (env / auth.json fallback)"},
340 row{"--base-url URL", "override provider api base url"},340 row{"--base-url URL", "override provider api base url"},
M internal/agent/build.go +52 -10
@@ -5,6 +5,7 @@ import (
55 "fmt"
66 "os"
77 "path/filepath"
8+ "runtime"
89 "strings"
910
1011 "github.com/patriceckhart/zot/internal/agent/tools"
@@ -128,13 +129,16 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
128129
129130 // User-requested provider (explicit > config > default).
130131 provName := firstNonEmpty(args.Provider, cfg.Provider, "anthropic")
131- if provName != "anthropic" && provName != "openai" && provName != "ollama" {
132+ if provName != "anthropic" && provName != "openai" && provName != "kimi" && provName != "ollama" {
132133 // Unknown provider (maybe removed or renamed). Fall back to
133134 // the first provider that has credentials, or anthropic.
134135 provName = "anthropic"
135136 if _, _, _, err := ResolveCredentialFull("openai", ""); err == nil {
136137 provName = "openai"
137138 }
139+ if _, _, _, err := ResolveCredentialFull("kimi", ""); err == nil {
140+ provName = "kimi"
141+ }
138142 if _, _, _, err := ResolveCredentialFull("anthropic", ""); err == nil {
139143 provName = "anthropic"
140144 }
@@ -166,13 +170,15 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
166170 // never shows a "not logged in" banner.
167171 userPickedProvider := args.Provider != ""
168172 if credErr != nil && !userPickedProvider && provName != "ollama" {
169- other := "openai"
170- if provName == "openai" {
171- other = "anthropic"
172- }
173- if c, m, a, err := ResolveCredentialFull(other, args.APIKey); err == nil {
174- provName = other
175- cred, method, accountID, credErr = c, m, a, err
173+ for _, other := range []string{"anthropic", "openai", "kimi"} {
174+ if other == provName {
175+ continue
176+ }
177+ if c, m, a, err := ResolveCredentialFull(other, args.APIKey); err == nil {
178+ provName = other
179+ cred, method, accountID, credErr = c, m, a, err
180+ break
181+ }
176182 }
177183 }
178184
@@ -181,6 +187,8 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
181187 switch provName {
182188 case "openai":
183189 model = "gpt-5"
190+ case "kimi":
191+ model = "kimi-for-coding"
184192 case "ollama":
185193 return Resolved{}, fmt.Errorf("ollama requires --model (e.g. --model llama3)")
186194 default:
@@ -191,9 +199,12 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
191199 // says gpt-5 but we fell back to anthropic), pick that provider's default.
192200 if provName != "ollama" {
193201 if m, err := provider.FindModel("", model); err == nil && m.Provider != provName {
194- if provName == "openai" {
202+ switch provName {
203+ case "openai":
195204 model = "gpt-5"
196- } else {
205+ case "kimi":
206+ model = "kimi-for-coding"
207+ default:
197208 model = provider.DefaultModel.ID
198209 }
199210 }
@@ -345,6 +356,8 @@ func (r Resolved) NewClient() provider.Client {
345356 switch r.Provider {
346357 case "ollama":
347358 return provider.NewOpenAI(r.Credential, r.BaseURL)
359+ case "kimi":
360+ return provider.NewKimiWithHeaders(r.Credential, r.BaseURL, kimiCodeHeaders())
348361 case "openai":
349362 if r.AuthMethod == "oauth" {
350363 inner := provider.NewOpenAICodex(r.Credential, r.AccountID, r.BaseURL)
@@ -380,6 +393,8 @@ func (r Resolved) wrapWithRefresh(inner provider.Client) provider.Client {
380393 switch provName {
381394 case "openai":
382395 return provider.NewOpenAICodex(token, accountID, baseURL)
396+ case "kimi":
397+ return provider.NewKimiWithHeaders(token, baseURL, kimiCodeHeaders())
383398 default:
384399 return provider.NewAnthropicOAuth(token, baseURL)
385400 }
@@ -464,10 +479,37 @@ func firstNonEmpty(vals ...string) string {
464479 return ""
465480 }
466481
482+func kimiCodeHeaders() map[string]string {
483+ host, _ := os.Hostname()
484+ if host == "" {
485+ host = "unknown"
486+ }
487+ deviceID := ""
488+ if home, err := os.UserHomeDir(); err == nil {
489+ if b, err := os.ReadFile(filepath.Join(home, ".kimi", "device_id")); err == nil {
490+ deviceID = strings.TrimSpace(string(b))
491+ }
492+ }
493+ if deviceID == "" {
494+ deviceID = "zot"
495+ }
496+ return map[string]string{
497+ "User-Agent": "KimiCLI/1.41.0",
498+ "X-Msh-Platform": "kimi_cli",
499+ "X-Msh-Version": "1.41.0",
500+ "X-Msh-Device-Name": host,
501+ "X-Msh-Device-Model": runtime.GOOS + "-" + runtime.GOARCH,
502+ "X-Msh-Os-Version": runtime.GOOS,
503+ "X-Msh-Device-Id": deviceID,
504+ }
505+}
506+
467507 func envVarName(provider string) string {
468508 switch provider {
469509 case "openai":
470510 return "OPENAI"
511+ case "kimi":
512+ return "KIMI"
471513 case "ollama":
472514 return "OLLAMA"
473515 default:
@@ -5,6 +5,7 @@ import (
5 "fmt"5 "fmt"
6 "os"6 "os"
7 "path/filepath"7 "path/filepath"
8+ "runtime"
8 "strings"9 "strings"
9 10
10 "github.com/patriceckhart/zot/internal/agent/tools"11 "github.com/patriceckhart/zot/internal/agent/tools"
@@ -128,13 +129,16 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
128 129
129 // User-requested provider (explicit > config > default).130 // User-requested provider (explicit > config > default).
130 provName := firstNonEmpty(args.Provider, cfg.Provider, "anthropic")131 provName := firstNonEmpty(args.Provider, cfg.Provider, "anthropic")
131- if provName != "anthropic" && provName != "openai" && provName != "ollama" {132+ if provName != "anthropic" && provName != "openai" && provName != "kimi" && provName != "ollama" {
132 // Unknown provider (maybe removed or renamed). Fall back to133 // Unknown provider (maybe removed or renamed). Fall back to
133 // the first provider that has credentials, or anthropic.134 // the first provider that has credentials, or anthropic.
134 provName = "anthropic"135 provName = "anthropic"
135 if _, _, _, err := ResolveCredentialFull("openai", ""); err == nil {136 if _, _, _, err := ResolveCredentialFull("openai", ""); err == nil {
136 provName = "openai"137 provName = "openai"
137 }138 }
139+ if _, _, _, err := ResolveCredentialFull("kimi", ""); err == nil {
140+ provName = "kimi"
141+ }
138 if _, _, _, err := ResolveCredentialFull("anthropic", ""); err == nil {142 if _, _, _, err := ResolveCredentialFull("anthropic", ""); err == nil {
139 provName = "anthropic"143 provName = "anthropic"
140 }144 }
@@ -166,13 +170,15 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
166 // never shows a "not logged in" banner.170 // never shows a "not logged in" banner.
167 userPickedProvider := args.Provider != ""171 userPickedProvider := args.Provider != ""
168 if credErr != nil && !userPickedProvider && provName != "ollama" {172 if credErr != nil && !userPickedProvider && provName != "ollama" {
169- other := "openai"173+ for _, other := range []string{"anthropic", "openai", "kimi"} {
170- if provName == "openai" {174+ if other == provName {
171- other = "anthropic"175+ continue
172- }176+ }
173- if c, m, a, err := ResolveCredentialFull(other, args.APIKey); err == nil {177+ if c, m, a, err := ResolveCredentialFull(other, args.APIKey); err == nil {
174- provName = other178+ provName = other
175- cred, method, accountID, credErr = c, m, a, err179+ cred, method, accountID, credErr = c, m, a, err
180+ break
181+ }
176 }182 }
177 }183 }
178 184
@@ -181,6 +187,8 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
181 switch provName {187 switch provName {
182 case "openai":188 case "openai":
183 model = "gpt-5"189 model = "gpt-5"
190+ case "kimi":
191+ model = "kimi-for-coding"
184 case "ollama":192 case "ollama":
185 return Resolved{}, fmt.Errorf("ollama requires --model (e.g. --model llama3)")193 return Resolved{}, fmt.Errorf("ollama requires --model (e.g. --model llama3)")
186 default:194 default:
@@ -191,9 +199,12 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
191 // says gpt-5 but we fell back to anthropic), pick that provider's default.199 // says gpt-5 but we fell back to anthropic), pick that provider's default.
192 if provName != "ollama" {200 if provName != "ollama" {
193 if m, err := provider.FindModel("", model); err == nil && m.Provider != provName {201 if m, err := provider.FindModel("", model); err == nil && m.Provider != provName {
194- if provName == "openai" {202+ switch provName {
203+ case "openai":
195 model = "gpt-5"204 model = "gpt-5"
196- } else {205+ case "kimi":
206+ model = "kimi-for-coding"
207+ default:
197 model = provider.DefaultModel.ID208 model = provider.DefaultModel.ID
198 }209 }
199 }210 }
@@ -345,6 +356,8 @@ func (r Resolved) NewClient() provider.Client {
345 switch r.Provider {356 switch r.Provider {
346 case "ollama":357 case "ollama":
347 return provider.NewOpenAI(r.Credential, r.BaseURL)358 return provider.NewOpenAI(r.Credential, r.BaseURL)
359+ case "kimi":
360+ return provider.NewKimiWithHeaders(r.Credential, r.BaseURL, kimiCodeHeaders())
348 case "openai":361 case "openai":
349 if r.AuthMethod == "oauth" {362 if r.AuthMethod == "oauth" {
350 inner := provider.NewOpenAICodex(r.Credential, r.AccountID, r.BaseURL)363 inner := provider.NewOpenAICodex(r.Credential, r.AccountID, r.BaseURL)
@@ -380,6 +393,8 @@ func (r Resolved) wrapWithRefresh(inner provider.Client) provider.Client {
380 switch provName {393 switch provName {
381 case "openai":394 case "openai":
382 return provider.NewOpenAICodex(token, accountID, baseURL)395 return provider.NewOpenAICodex(token, accountID, baseURL)
396+ case "kimi":
397+ return provider.NewKimiWithHeaders(token, baseURL, kimiCodeHeaders())
383 default:398 default:
384 return provider.NewAnthropicOAuth(token, baseURL)399 return provider.NewAnthropicOAuth(token, baseURL)
385 }400 }
@@ -464,10 +479,37 @@ func firstNonEmpty(vals ...string) string {
464 return ""479 return ""
465 }480 }
466 481
482+func kimiCodeHeaders() map[string]string {
483+ host, _ := os.Hostname()
484+ if host == "" {
485+ host = "unknown"
486+ }
487+ deviceID := ""
488+ if home, err := os.UserHomeDir(); err == nil {
489+ if b, err := os.ReadFile(filepath.Join(home, ".kimi", "device_id")); err == nil {
490+ deviceID = strings.TrimSpace(string(b))
491+ }
492+ }
493+ if deviceID == "" {
494+ deviceID = "zot"
495+ }
496+ return map[string]string{
497+ "User-Agent": "KimiCLI/1.41.0",
498+ "X-Msh-Platform": "kimi_cli",
499+ "X-Msh-Version": "1.41.0",
500+ "X-Msh-Device-Name": host,
501+ "X-Msh-Device-Model": runtime.GOOS + "-" + runtime.GOARCH,
502+ "X-Msh-Os-Version": runtime.GOOS,
503+ "X-Msh-Device-Id": deviceID,
504+ }
505+}
506+
467 func envVarName(provider string) string {507 func envVarName(provider string) string {
468 switch provider {508 switch provider {
469 case "openai":509 case "openai":
470 return "OPENAI"510 return "OPENAI"
511+ case "kimi":
512+ return "KIMI"
471 case "ollama":513 case "ollama":
472 return "OLLAMA"514 return "OLLAMA"
473 default:515 default:
M internal/agent/cli.go +24 -23
@@ -664,31 +664,32 @@ func runInteractive(ctx context.Context, args Args, version string) error {
664664
665665 initialCfg, _ := LoadConfig()
666666 iv = modes.NewInteractive(modes.InteractiveConfig{
667- Terminal: term,
668- Theme: tui.DetectThemeFromBackground(80 * time.Millisecond),
669- InlineImagesEnabled: initialCfg.InlineImagesEnabled,
670- SettingsStore: configSettingsStore{},
671- Model: r.Model,
672- Provider: r.Provider,
673- AuthMethod: r.AuthMethod,
674- BaseURL: r.BaseURL,
675- Reasoning: r.Reasoning,
676- SystemPrompt: r.SystemPrompt,
677- Tools: r.ToolRegistry,
678- MaxSteps: r.MaxSteps,
679- CWD: r.CWD,
680- ZotHome: ZotHome(),
681- Version: version,
682- UpdateInfoChan: updateCh,
683- Sandbox: sharedSandbox,
684- Agent: ag,
685- InitialInput: args.Prompt,
686- AuthManager: mgr,
687- BuildAgent: buildAgent,
688- BuildAgentFor: buildAgentFor,
667+ Terminal: term,
668+ Theme: tui.DetectThemeFromBackground(80 * time.Millisecond),
669+ InlineImagesEnabled: initialCfg.InlineImagesEnabled,
670+ SettingsStore: configSettingsStore{},
671+ Model: r.Model,
672+ Provider: r.Provider,
673+ AuthMethod: r.AuthMethod,
674+ BaseURL: r.BaseURL,
675+ Reasoning: r.Reasoning,
676+ SystemPrompt: r.SystemPrompt,
677+ Tools: r.ToolRegistry,
678+ MaxSteps: r.MaxSteps,
679+ CWD: r.CWD,
680+ ZotHome: ZotHome(),
681+ Version: version,
682+ UpdateInfoChan: updateCh,
683+ Sandbox: sharedSandbox,
684+ Agent: ag,
685+ InitialInput: args.Prompt,
686+ AuthManager: mgr,
687+ BuildAgent: buildAgent,
688+ SetKimiCLIFallbackDisabled: SetKimiCLIFallbackDisabled,
689+ BuildAgentFor: buildAgentFor,
689690 LoggedInProviders: func() []string {
690691 var out []string
691- for _, p := range []string{"anthropic", "openai"} {
692+ for _, p := range []string{"anthropic", "openai", "kimi"} {
692693 if _, _, err := ResolveCredential(p, ""); err == nil {
693694 out = append(out, p)
694695 }
@@ -664,31 +664,32 @@ func runInteractive(ctx context.Context, args Args, version string) error {
664 664
665 initialCfg, _ := LoadConfig()665 initialCfg, _ := LoadConfig()
666 iv = modes.NewInteractive(modes.InteractiveConfig{666 iv = modes.NewInteractive(modes.InteractiveConfig{
667- Terminal: term,667+ Terminal: term,
668- Theme: tui.DetectThemeFromBackground(80 * time.Millisecond),668+ Theme: tui.DetectThemeFromBackground(80 * time.Millisecond),
669- InlineImagesEnabled: initialCfg.InlineImagesEnabled,669+ InlineImagesEnabled: initialCfg.InlineImagesEnabled,
670- SettingsStore: configSettingsStore{},670+ SettingsStore: configSettingsStore{},
671- Model: r.Model,671+ Model: r.Model,
672- Provider: r.Provider,672+ Provider: r.Provider,
673- AuthMethod: r.AuthMethod,673+ AuthMethod: r.AuthMethod,
674- BaseURL: r.BaseURL,674+ BaseURL: r.BaseURL,
675- Reasoning: r.Reasoning,675+ Reasoning: r.Reasoning,
676- SystemPrompt: r.SystemPrompt,676+ SystemPrompt: r.SystemPrompt,
677- Tools: r.ToolRegistry,677+ Tools: r.ToolRegistry,
678- MaxSteps: r.MaxSteps,678+ MaxSteps: r.MaxSteps,
679- CWD: r.CWD,679+ CWD: r.CWD,
680- ZotHome: ZotHome(),680+ ZotHome: ZotHome(),
681- Version: version,681+ Version: version,
682- UpdateInfoChan: updateCh,682+ UpdateInfoChan: updateCh,
683- Sandbox: sharedSandbox,683+ Sandbox: sharedSandbox,
684- Agent: ag,684+ Agent: ag,
685- InitialInput: args.Prompt,685+ InitialInput: args.Prompt,
686- AuthManager: mgr,686+ AuthManager: mgr,
687- BuildAgent: buildAgent,687+ BuildAgent: buildAgent,
688- BuildAgentFor: buildAgentFor,688+ SetKimiCLIFallbackDisabled: SetKimiCLIFallbackDisabled,
689+ BuildAgentFor: buildAgentFor,
689 LoggedInProviders: func() []string {690 LoggedInProviders: func() []string {
690 var out []string691 var out []string
691- for _, p := range []string{"anthropic", "openai"} {692+ for _, p := range []string{"anthropic", "openai", "kimi"} {
692 if _, _, err := ResolveCredential(p, ""); err == nil {693 if _, _, err := ResolveCredential(p, ""); err == nil {
693 out = append(out, p)694 out = append(out, p)
694 }695 }
M internal/agent/config.go +89 -0
@@ -68,6 +68,12 @@ func ConfigPath() string { return filepath.Join(ZotHome(), "config.json") }
6868 // AuthPath returns the path to auth.json.
6969 func AuthPath() string { return filepath.Join(ZotHome(), "auth.json") }
7070
71+// KimiCLIFallbackDisabledPath returns a sentinel that disables falling
72+// back to the official Kimi Code CLI token after `zot /logout kimi`.
73+func KimiCLIFallbackDisabledPath() string {
74+ return filepath.Join(ZotHome(), "kimi-cli-fallback-disabled")
75+}
76+
7177 // SessionsPath returns the directory holding session files.
7278 func SessionsPath() string { return filepath.Join(ZotHome(), "sessions") }
7379
@@ -135,6 +141,13 @@ func ResolveCredentialFull(provider, explicit string) (cred, method, accountID s
135141 if v := os.Getenv("OPENAI_API_KEY"); v != "" {
136142 return v, "apikey", "", nil
137143 }
144+ case "kimi":
145+ if v := os.Getenv("KIMI_API_KEY"); v != "" {
146+ return v, "apikey", "", nil
147+ }
148+ if v := os.Getenv("MOONSHOT_API_KEY"); v != "" {
149+ return v, "apikey", "", nil
150+ }
138151 }
139152 c, err := AuthStoreFor().Load()
140153 if err != nil {
@@ -157,10 +170,76 @@ func ResolveCredentialFull(provider, explicit string) (cred, method, accountID s
157170 tok, _ := refreshIfExpired("openai", c.OpenAI.OAuth)
158171 return tok.AccessToken, "oauth", tok.AccountID, nil
159172 }
173+ case "kimi":
174+ if c.Kimi.APIKey != "" {
175+ return c.Kimi.APIKey, "apikey", "", nil
176+ }
177+ if c.Kimi.OAuth != nil && c.Kimi.OAuth.AccessToken != "" {
178+ tok, _ := refreshIfExpired("kimi", c.Kimi.OAuth)
179+ return tok.AccessToken, "oauth", "", nil
180+ }
181+ if kimiCLIFallbackDisabled() {
182+ break
183+ }
184+ if tok := loadKimiCodeCLIToken(); tok != nil && tok.AccessToken != "" {
185+ tok, _ = refreshIfExpired("kimi", tok)
186+ return tok.AccessToken, "oauth", "", nil
187+ }
160188 }
161189 return "", "", "", fmt.Errorf("no credential for %s", provider)
162190 }
163191
192+func kimiCLIFallbackDisabled() bool {
193+ _, err := os.Stat(KimiCLIFallbackDisabledPath())
194+ return err == nil
195+}
196+
197+func SetKimiCLIFallbackDisabled(disabled bool) error {
198+ path := KimiCLIFallbackDisabledPath()
199+ if !disabled {
200+ if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
201+ return err
202+ }
203+ return nil
204+ }
205+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
206+ return err
207+ }
208+ return os.WriteFile(path, []byte("disabled\n"), 0o600)
209+}
210+
211+func loadKimiCodeCLIToken() *auth.OAuthToken {
212+ home, err := os.UserHomeDir()
213+ if err != nil {
214+ return nil
215+ }
216+ b, err := os.ReadFile(filepath.Join(home, ".kimi", "credentials", "kimi-code.json"))
217+ if err != nil {
218+ return nil
219+ }
220+ var raw struct {
221+ AccessToken string `json:"access_token"`
222+ RefreshToken string `json:"refresh_token"`
223+ TokenType string `json:"token_type"`
224+ ExpiresAt float64 `json:"expires_at"`
225+ Scope string `json:"scope"`
226+ ExpiresIn float64 `json:"expires_in"`
227+ }
228+ if err := json.Unmarshal(b, &raw); err != nil || raw.AccessToken == "" {
229+ return nil
230+ }
231+ sec := int64(raw.ExpiresAt)
232+ nsec := int64((raw.ExpiresAt - float64(sec)) * 1e9)
233+ return &auth.OAuthToken{
234+ AccessToken: raw.AccessToken,
235+ RefreshToken: raw.RefreshToken,
236+ TokenType: raw.TokenType,
237+ Scope: raw.Scope,
238+ ClientID: auth.KimiOAuth.ClientID,
239+ Expiry: time.Unix(sec, nsec),
240+ }
241+}
242+
164243 // loadOAuthToken reads the current OAuth token from auth.json for the
165244 // given provider. Returns nil if no token is stored.
166245 func loadOAuthToken(providerName string) *auth.OAuthToken {
@@ -177,6 +256,14 @@ func loadOAuthToken(providerName string) *auth.OAuthToken {
177256 if c.OpenAI.OAuth != nil {
178257 return c.OpenAI.OAuth
179258 }
259+ case "kimi":
260+ if c.Kimi.OAuth != nil {
261+ return c.Kimi.OAuth
262+ }
263+ if kimiCLIFallbackDisabled() {
264+ return nil
265+ }
266+ return loadKimiCodeCLIToken()
180267 }
181268 return nil
182269 }
@@ -205,6 +292,8 @@ func refreshIfExpired(providerName string, tok *auth.OAuthToken) (*auth.OAuthTok
205292 op = auth.AnthropicOAuth
206293 case "openai":
207294 op = auth.OpenAIOAuth
295+ case "kimi":
296+ op = auth.KimiOAuth
208297 default:
209298 return tok, fmt.Errorf("unknown provider %q", providerName)
210299 }
@@ -68,6 +68,12 @@ func ConfigPath() string { return filepath.Join(ZotHome(), "config.json") }
68 // AuthPath returns the path to auth.json.68 // AuthPath returns the path to auth.json.
69 func AuthPath() string { return filepath.Join(ZotHome(), "auth.json") }69 func AuthPath() string { return filepath.Join(ZotHome(), "auth.json") }
70 70
71+// KimiCLIFallbackDisabledPath returns a sentinel that disables falling
72+// back to the official Kimi Code CLI token after `zot /logout kimi`.
73+func KimiCLIFallbackDisabledPath() string {
74+ return filepath.Join(ZotHome(), "kimi-cli-fallback-disabled")
75+}
76+
71 // SessionsPath returns the directory holding session files.77 // SessionsPath returns the directory holding session files.
72 func SessionsPath() string { return filepath.Join(ZotHome(), "sessions") }78 func SessionsPath() string { return filepath.Join(ZotHome(), "sessions") }
73 79
@@ -135,6 +141,13 @@ func ResolveCredentialFull(provider, explicit string) (cred, method, accountID s
135 if v := os.Getenv("OPENAI_API_KEY"); v != "" {141 if v := os.Getenv("OPENAI_API_KEY"); v != "" {
136 return v, "apikey", "", nil142 return v, "apikey", "", nil
137 }143 }
144+ case "kimi":
145+ if v := os.Getenv("KIMI_API_KEY"); v != "" {
146+ return v, "apikey", "", nil
147+ }
148+ if v := os.Getenv("MOONSHOT_API_KEY"); v != "" {
149+ return v, "apikey", "", nil
150+ }
138 }151 }
139 c, err := AuthStoreFor().Load()152 c, err := AuthStoreFor().Load()
140 if err != nil {153 if err != nil {
@@ -157,10 +170,76 @@ func ResolveCredentialFull(provider, explicit string) (cred, method, accountID s
157 tok, _ := refreshIfExpired("openai", c.OpenAI.OAuth)170 tok, _ := refreshIfExpired("openai", c.OpenAI.OAuth)
158 return tok.AccessToken, "oauth", tok.AccountID, nil171 return tok.AccessToken, "oauth", tok.AccountID, nil
159 }172 }
173+ case "kimi":
174+ if c.Kimi.APIKey != "" {
175+ return c.Kimi.APIKey, "apikey", "", nil
176+ }
177+ if c.Kimi.OAuth != nil && c.Kimi.OAuth.AccessToken != "" {
178+ tok, _ := refreshIfExpired("kimi", c.Kimi.OAuth)
179+ return tok.AccessToken, "oauth", "", nil
180+ }
181+ if kimiCLIFallbackDisabled() {
182+ break
183+ }
184+ if tok := loadKimiCodeCLIToken(); tok != nil && tok.AccessToken != "" {
185+ tok, _ = refreshIfExpired("kimi", tok)
186+ return tok.AccessToken, "oauth", "", nil
187+ }
160 }188 }
161 return "", "", "", fmt.Errorf("no credential for %s", provider)189 return "", "", "", fmt.Errorf("no credential for %s", provider)
162 }190 }
163 191
192+func kimiCLIFallbackDisabled() bool {
193+ _, err := os.Stat(KimiCLIFallbackDisabledPath())
194+ return err == nil
195+}
196+
197+func SetKimiCLIFallbackDisabled(disabled bool) error {
198+ path := KimiCLIFallbackDisabledPath()
199+ if !disabled {
200+ if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
201+ return err
202+ }
203+ return nil
204+ }
205+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
206+ return err
207+ }
208+ return os.WriteFile(path, []byte("disabled\n"), 0o600)
209+}
210+
211+func loadKimiCodeCLIToken() *auth.OAuthToken {
212+ home, err := os.UserHomeDir()
213+ if err != nil {
214+ return nil
215+ }
216+ b, err := os.ReadFile(filepath.Join(home, ".kimi", "credentials", "kimi-code.json"))
217+ if err != nil {
218+ return nil
219+ }
220+ var raw struct {
221+ AccessToken string `json:"access_token"`
222+ RefreshToken string `json:"refresh_token"`
223+ TokenType string `json:"token_type"`
224+ ExpiresAt float64 `json:"expires_at"`
225+ Scope string `json:"scope"`
226+ ExpiresIn float64 `json:"expires_in"`
227+ }
228+ if err := json.Unmarshal(b, &raw); err != nil || raw.AccessToken == "" {
229+ return nil
230+ }
231+ sec := int64(raw.ExpiresAt)
232+ nsec := int64((raw.ExpiresAt - float64(sec)) * 1e9)
233+ return &auth.OAuthToken{
234+ AccessToken: raw.AccessToken,
235+ RefreshToken: raw.RefreshToken,
236+ TokenType: raw.TokenType,
237+ Scope: raw.Scope,
238+ ClientID: auth.KimiOAuth.ClientID,
239+ Expiry: time.Unix(sec, nsec),
240+ }
241+}
242+
164 // loadOAuthToken reads the current OAuth token from auth.json for the243 // loadOAuthToken reads the current OAuth token from auth.json for the
165 // given provider. Returns nil if no token is stored.244 // given provider. Returns nil if no token is stored.
166 func loadOAuthToken(providerName string) *auth.OAuthToken {245 func loadOAuthToken(providerName string) *auth.OAuthToken {
@@ -177,6 +256,14 @@ func loadOAuthToken(providerName string) *auth.OAuthToken {
177 if c.OpenAI.OAuth != nil {256 if c.OpenAI.OAuth != nil {
178 return c.OpenAI.OAuth257 return c.OpenAI.OAuth
179 }258 }
259+ case "kimi":
260+ if c.Kimi.OAuth != nil {
261+ return c.Kimi.OAuth
262+ }
263+ if kimiCLIFallbackDisabled() {
264+ return nil
265+ }
266+ return loadKimiCodeCLIToken()
180 }267 }
181 return nil268 return nil
182 }269 }
@@ -205,6 +292,8 @@ func refreshIfExpired(providerName string, tok *auth.OAuthToken) (*auth.OAuthTok
205 op = auth.AnthropicOAuth292 op = auth.AnthropicOAuth
206 case "openai":293 case "openai":
207 op = auth.OpenAIOAuth294 op = auth.OpenAIOAuth
295+ case "kimi":
296+ op = auth.KimiOAuth
208 default:297 default:
209 return tok, fmt.Errorf("unknown provider %q", providerName)298 return tok, fmt.Errorf("unknown provider %q", providerName)
210 }299 }
M internal/agent/modelsync.go +9 -0
@@ -72,6 +72,15 @@ func refreshModels() {
7272 all = append(all, live...)
7373 }
7474 }
75+ if cred, method, err := ResolveCredential("kimi", ""); err == nil && method == "apikey" {
76+ if live, err := provider.DiscoverOpenAI(ctx, cred, "https://api.kimi.com/coding/v1"); err == nil {
77+ for i := range live {
78+ live[i].Provider = "kimi"
79+ live[i].Source = "live"
80+ }
81+ all = append(all, live...)
82+ }
83+ }
7584
7685 if len(all) == 0 {
7786 return
@@ -72,6 +72,15 @@ func refreshModels() {
72 all = append(all, live...)72 all = append(all, live...)
73 }73 }
74 }74 }
75+ if cred, method, err := ResolveCredential("kimi", ""); err == nil && method == "apikey" {
76+ if live, err := provider.DiscoverOpenAI(ctx, cred, "https://api.kimi.com/coding/v1"); err == nil {
77+ for i := range live {
78+ live[i].Provider = "kimi"
79+ live[i].Source = "live"
80+ }
81+ all = append(all, live...)
82+ }
83+ }
75 84
76 if len(all) == 0 {85 if len(all) == 0 {
77 return86 return
M internal/agent/modes/interactive.go +21 -5
@@ -54,6 +54,10 @@ type InteractiveConfig struct {
5454 // the concrete provider/model in use.
5555 BuildAgent func() (*core.Agent, string, string, error)
5656
57+ // SetKimiCLIFallbackDisabled controls whether zot may fall back to
58+ // the official Kimi Code CLI token when zot has no stored Kimi token.
59+ SetKimiCLIFallbackDisabled func(disabled bool) error
60+
5761 // BuildAgentFor rebuilds the agent with an explicit provider/model
5862 // override (used by the /model picker when switching providers).
5963 // If providerOverride is empty, the current provider is kept.
@@ -2397,7 +2401,7 @@ func (i *Interactive) openLogoutDialog() {
23972401 }
23982402
23992403 var items []logoutItem
2400- for _, p := range []string{"anthropic", "openai"} {
2404+ for _, p := range []string{"anthropic", "openai", "kimi"} {
24012405 if creds.Has(p) {
24022406 method := creds.Method(p)
24032407 if method == "oauth" {
@@ -2431,7 +2435,7 @@ func (i *Interactive) openLogoutDialog() {
24312435 // is torn down so the user is forced through /login before their next
24322436 // prompt.
24332437 //
2434-// target: "anthropic" | "openai" | "all"
2438+// target: "anthropic" | "openai" | "kimi" | "all"
24352439 func (i *Interactive) doLogout(target string) {
24362440 if i.cfg.AuthManager == nil {
24372441 i.mu.Lock()
@@ -2450,12 +2454,12 @@ func (i *Interactive) doLogout(target string) {
24502454 var providers []string
24512455 switch target {
24522456 case "", "all":
2453- providers = []string{"anthropic", "openai"}
2454- case "anthropic", "openai":
2457+ providers = []string{"anthropic", "openai", "kimi"}
2458+ case "anthropic", "openai", "kimi":
24552459 providers = []string{target}
24562460 default:
24572461 i.mu.Lock()
2458- i.statusErr = "unknown provider: " + target + " (use anthropic, openai, or all)"
2462+ i.statusErr = "unknown provider: " + target + " (use anthropic, openai, kimi, or all)"
24592463 i.mu.Unlock()
24602464 return
24612465 }
@@ -2467,6 +2471,12 @@ func (i *Interactive) doLogout(target string) {
24672471 errs = append(errs, p+": "+err.Error())
24682472 continue
24692473 }
2474+ if p == "kimi" && i.cfg.SetKimiCLIFallbackDisabled != nil {
2475+ if err := i.cfg.SetKimiCLIFallbackDisabled(true); err != nil {
2476+ errs = append(errs, p+": "+err.Error())
2477+ continue
2478+ }
2479+ }
24702480 if p == i.cfg.Provider {
24712481 clearedCurrent = true
24722482 }
@@ -2491,6 +2501,9 @@ func (i *Interactive) doLogout(target string) {
24912501 }
24922502
24932503 func (i *Interactive) startAPIKeyFlow(provider string) {
2504+ if provider == "kimi" && i.cfg.SetKimiCLIFallbackDisabled != nil {
2505+ _ = i.cfg.SetKimiCLIFallbackDisabled(false)
2506+ }
24942507 url, err := i.cfg.AuthManager.StartAPIKey(provider)
24952508 if err != nil {
24962509 i.dialog.ShowResult(false, err.Error())
@@ -2500,6 +2513,9 @@ func (i *Interactive) startAPIKeyFlow(provider string) {
25002513 }
25012514
25022515 func (i *Interactive) startOAuthFlow(provider string) {
2516+ if provider == "kimi" && i.cfg.SetKimiCLIFallbackDisabled != nil {
2517+ _ = i.cfg.SetKimiCLIFallbackDisabled(false)
2518+ }
25032519 // Always run the manual/copy-code flow in parallel with the local
25042520 // callback server so headless environments (docker, SSH) can paste
25052521 // the authorization code directly without first pressing 'p'.
@@ -54,6 +54,10 @@ type InteractiveConfig struct {
54 // the concrete provider/model in use.54 // the concrete provider/model in use.
55 BuildAgent func() (*core.Agent, string, string, error)55 BuildAgent func() (*core.Agent, string, string, error)
56 56
57+ // SetKimiCLIFallbackDisabled controls whether zot may fall back to
58+ // the official Kimi Code CLI token when zot has no stored Kimi token.
59+ SetKimiCLIFallbackDisabled func(disabled bool) error
60+
57 // BuildAgentFor rebuilds the agent with an explicit provider/model61 // BuildAgentFor rebuilds the agent with an explicit provider/model
58 // override (used by the /model picker when switching providers).62 // override (used by the /model picker when switching providers).
59 // If providerOverride is empty, the current provider is kept.63 // If providerOverride is empty, the current provider is kept.
@@ -2397,7 +2401,7 @@ func (i *Interactive) openLogoutDialog() {
2397 }2401 }
2398 2402
2399 var items []logoutItem2403 var items []logoutItem
2400- for _, p := range []string{"anthropic", "openai"} {2404+ for _, p := range []string{"anthropic", "openai", "kimi"} {
2401 if creds.Has(p) {2405 if creds.Has(p) {
2402 method := creds.Method(p)2406 method := creds.Method(p)
2403 if method == "oauth" {2407 if method == "oauth" {
@@ -2431,7 +2435,7 @@ func (i *Interactive) openLogoutDialog() {
2431 // is torn down so the user is forced through /login before their next2435 // is torn down so the user is forced through /login before their next
2432 // prompt.2436 // prompt.
2433 //2437 //
2434-// target: "anthropic" | "openai" | "all"2438+// target: "anthropic" | "openai" | "kimi" | "all"
2435 func (i *Interactive) doLogout(target string) {2439 func (i *Interactive) doLogout(target string) {
2436 if i.cfg.AuthManager == nil {2440 if i.cfg.AuthManager == nil {
2437 i.mu.Lock()2441 i.mu.Lock()
@@ -2450,12 +2454,12 @@ func (i *Interactive) doLogout(target string) {
2450 var providers []string2454 var providers []string
2451 switch target {2455 switch target {
2452 case "", "all":2456 case "", "all":
2453- providers = []string{"anthropic", "openai"}2457+ providers = []string{"anthropic", "openai", "kimi"}
2454- case "anthropic", "openai":2458+ case "anthropic", "openai", "kimi":
2455 providers = []string{target}2459 providers = []string{target}
2456 default:2460 default:
2457 i.mu.Lock()2461 i.mu.Lock()
2458- i.statusErr = "unknown provider: " + target + " (use anthropic, openai, or all)"2462+ i.statusErr = "unknown provider: " + target + " (use anthropic, openai, kimi, or all)"
2459 i.mu.Unlock()2463 i.mu.Unlock()
2460 return2464 return
2461 }2465 }
@@ -2467,6 +2471,12 @@ func (i *Interactive) doLogout(target string) {
2467 errs = append(errs, p+": "+err.Error())2471 errs = append(errs, p+": "+err.Error())
2468 continue2472 continue
2469 }2473 }
2474+ if p == "kimi" && i.cfg.SetKimiCLIFallbackDisabled != nil {
2475+ if err := i.cfg.SetKimiCLIFallbackDisabled(true); err != nil {
2476+ errs = append(errs, p+": "+err.Error())
2477+ continue
2478+ }
2479+ }
2470 if p == i.cfg.Provider {2480 if p == i.cfg.Provider {
2471 clearedCurrent = true2481 clearedCurrent = true
2472 }2482 }
@@ -2491,6 +2501,9 @@ func (i *Interactive) doLogout(target string) {
2491 }2501 }
2492 2502
2493 func (i *Interactive) startAPIKeyFlow(provider string) {2503 func (i *Interactive) startAPIKeyFlow(provider string) {
2504+ if provider == "kimi" && i.cfg.SetKimiCLIFallbackDisabled != nil {
2505+ _ = i.cfg.SetKimiCLIFallbackDisabled(false)
2506+ }
2494 url, err := i.cfg.AuthManager.StartAPIKey(provider)2507 url, err := i.cfg.AuthManager.StartAPIKey(provider)
2495 if err != nil {2508 if err != nil {
2496 i.dialog.ShowResult(false, err.Error())2509 i.dialog.ShowResult(false, err.Error())
@@ -2500,6 +2513,9 @@ func (i *Interactive) startAPIKeyFlow(provider string) {
2500 }2513 }
2501 2514
2502 func (i *Interactive) startOAuthFlow(provider string) {2515 func (i *Interactive) startOAuthFlow(provider string) {
2516+ if provider == "kimi" && i.cfg.SetKimiCLIFallbackDisabled != nil {
2517+ _ = i.cfg.SetKimiCLIFallbackDisabled(false)
2518+ }
2503 // Always run the manual/copy-code flow in parallel with the local2519 // Always run the manual/copy-code flow in parallel with the local
2504 // callback server so headless environments (docker, SSH) can paste2520 // callback server so headless environments (docker, SSH) can paste
2505 // the authorization code directly without first pressing 'p'.2521 // the authorization code directly without first pressing 'p'.
M internal/agent/modes/login_dialog.go +14 -9
@@ -17,7 +17,7 @@ type loginStep int
1717 const (
1818 loginStepClosed loginStep = iota
1919 loginStepMethod // pick apikey vs subscription
20- loginStepProvider // pick anthropic vs openai
20+ loginStepProvider // pick anthropic vs openai vs kimi
2121 loginStepWaiting // browser open, waiting for callback
2222 loginStepPasteCode // user pastes the auth code here
2323 loginStepDone // success or error, waiting for key to dismiss
@@ -28,7 +28,7 @@ const (
2828 type loginDialog struct {
2929 step loginStep
3030 method string // "apikey" | "oauth"
31- provider string // "anthropic" | "openai"
31+ provider string // "anthropic" | "openai" | "kimi"
3232 message string
3333 success bool
3434 url string
@@ -39,7 +39,7 @@ type loginDialog struct {
3939 // provider, captured when Open() runs. Rendered above the
4040 // method picker so the user can see whether they're already
4141 // logged in (and how) before starting a new flow. Keys:
42- // "anthropic", "openai". Value is "apikey", "oauth", or ""
42+ // "anthropic", "openai", "kimi". Value is "apikey", "oauth", or ""
4343 // (not logged in).
4444 status map[string]string
4545 }
@@ -65,7 +65,7 @@ func (d *loginDialog) Open(zotHome string) {
6565 d.success = false
6666 d.url = ""
6767 d.cursor = 0
68- d.status = map[string]string{"anthropic": "", "openai": ""}
68+ d.status = map[string]string{"anthropic": "", "openai": "", "kimi": ""}
6969 // Best-effort: if the auth file can't be read, treat every
7070 // provider as not-logged-in. The status line just won't show
7171 // anything useful in that case, which is fine — the user
@@ -74,6 +74,7 @@ func (d *loginDialog) Open(zotHome string) {
7474 if creds, err := auth.NewStore(path).Load(); err == nil {
7575 d.status["anthropic"] = creds.Method("anthropic")
7676 d.status["openai"] = creds.Method("openai")
77+ d.status["kimi"] = creds.Method("kimi")
7778 }
7879 }
7980
@@ -93,7 +94,7 @@ func (d *loginDialog) Render(th tui.Theme, width int) []string {
9394 case loginStepMethod:
9495 opts := []string{
9596 "api key",
96- "subscription (claude pro/max - chatgpt plus/pro)",
97+ "subscription (claude pro/max - chatgpt plus/pro - kimi code)",
9798 }
9899 lines = append(lines, frameHeader(th, "login", width))
99100 for _, l := range d.renderStatusLines(th) {
@@ -110,7 +111,7 @@ func (d *loginDialog) Render(th tui.Theme, width int) []string {
110111 }
111112 lines = append(lines, frameRule(th, width))
112113 case loginStepProvider:
113- opts := []string{"anthropic", "openai"}
114+ opts := []string{"anthropic", "openai", "kimi"}
114115 lines = append(lines, frameHeader(th, "login - "+d.method, width))
115116 for _, l := range d.renderStatusLines(th) {
116117 lines = append(lines, l)
@@ -201,6 +202,8 @@ func providerLabel(id string) string {
201202 return "Anthropic (Claude Pro/Max)"
202203 case "openai":
203204 return "OpenAI (ChatGPT Plus/Pro)"
205+ case "kimi":
206+ return "Kimi Code"
204207 }
205208 return id
206209 }
@@ -218,7 +221,8 @@ func providerLabel(id string) string {
218221 func (d *loginDialog) renderStatusLines(th tui.Theme) []string {
219222 anth := d.status["anthropic"]
220223 op := d.status["openai"]
221- if anth == "" && op == "" {
224+ kimi := d.status["kimi"]
225+ if anth == "" && op == "" && kimi == "" {
222226 return nil
223227 }
224228 row := func(id, method string) string {
@@ -240,6 +244,7 @@ func (d *loginDialog) renderStatusLines(th tui.Theme) []string {
240244 return []string{
241245 row("anthropic", anth),
242246 row("openai", op),
247+ row("kimi", kimi),
243248 "",
244249 }
245250 }
@@ -305,14 +310,14 @@ func (d *loginDialog) handleProviderKey(k tui.Key) loginDialogAction {
305310 d.cursor--
306311 }
307312 case tui.KeyDown:
308- if d.cursor < 1 {
313+ if d.cursor < 2 {
309314 d.cursor++
310315 }
311316 case tui.KeyEsc:
312317 d.Close()
313318 return loginDialogAction{Close: true}
314319 case tui.KeyEnter:
315- providers := []string{"anthropic", "openai"}
320+ providers := []string{"anthropic", "openai", "kimi"}
316321 d.provider = providers[d.cursor]
317322 d.step = loginStepWaiting
318323 if d.method == "apikey" {
@@ -17,7 +17,7 @@ type loginStep int
17 const (17 const (
18 loginStepClosed loginStep = iota18 loginStepClosed loginStep = iota
19 loginStepMethod // pick apikey vs subscription19 loginStepMethod // pick apikey vs subscription
20- loginStepProvider // pick anthropic vs openai20+ loginStepProvider // pick anthropic vs openai vs kimi
21 loginStepWaiting // browser open, waiting for callback21 loginStepWaiting // browser open, waiting for callback
22 loginStepPasteCode // user pastes the auth code here22 loginStepPasteCode // user pastes the auth code here
23 loginStepDone // success or error, waiting for key to dismiss23 loginStepDone // success or error, waiting for key to dismiss
@@ -28,7 +28,7 @@ const (
28 type loginDialog struct {28 type loginDialog struct {
29 step loginStep29 step loginStep
30 method string // "apikey" | "oauth"30 method string // "apikey" | "oauth"
31- provider string // "anthropic" | "openai"31+ provider string // "anthropic" | "openai" | "kimi"
32 message string32 message string
33 success bool33 success bool
34 url string34 url string
@@ -39,7 +39,7 @@ type loginDialog struct {
39 // provider, captured when Open() runs. Rendered above the39 // provider, captured when Open() runs. Rendered above the
40 // method picker so the user can see whether they're already40 // method picker so the user can see whether they're already
41 // logged in (and how) before starting a new flow. Keys:41 // logged in (and how) before starting a new flow. Keys:
42- // "anthropic", "openai". Value is "apikey", "oauth", or ""42+ // "anthropic", "openai", "kimi". Value is "apikey", "oauth", or ""
43 // (not logged in).43 // (not logged in).
44 status map[string]string44 status map[string]string
45 }45 }
@@ -65,7 +65,7 @@ func (d *loginDialog) Open(zotHome string) {
65 d.success = false65 d.success = false
66 d.url = ""66 d.url = ""
67 d.cursor = 067 d.cursor = 0
68- d.status = map[string]string{"anthropic": "", "openai": ""}68+ d.status = map[string]string{"anthropic": "", "openai": "", "kimi": ""}
69 // Best-effort: if the auth file can't be read, treat every69 // Best-effort: if the auth file can't be read, treat every
70 // provider as not-logged-in. The status line just won't show70 // provider as not-logged-in. The status line just won't show
71 // anything useful in that case, which is fine — the user71 // anything useful in that case, which is fine — the user
@@ -74,6 +74,7 @@ func (d *loginDialog) Open(zotHome string) {
74 if creds, err := auth.NewStore(path).Load(); err == nil {74 if creds, err := auth.NewStore(path).Load(); err == nil {
75 d.status["anthropic"] = creds.Method("anthropic")75 d.status["anthropic"] = creds.Method("anthropic")
76 d.status["openai"] = creds.Method("openai")76 d.status["openai"] = creds.Method("openai")
77+ d.status["kimi"] = creds.Method("kimi")
77 }78 }
78 }79 }
79 80
@@ -93,7 +94,7 @@ func (d *loginDialog) Render(th tui.Theme, width int) []string {
93 case loginStepMethod:94 case loginStepMethod:
94 opts := []string{95 opts := []string{
95 "api key",96 "api key",
96- "subscription (claude pro/max - chatgpt plus/pro)",97+ "subscription (claude pro/max - chatgpt plus/pro - kimi code)",
97 }98 }
98 lines = append(lines, frameHeader(th, "login", width))99 lines = append(lines, frameHeader(th, "login", width))
99 for _, l := range d.renderStatusLines(th) {100 for _, l := range d.renderStatusLines(th) {
@@ -110,7 +111,7 @@ func (d *loginDialog) Render(th tui.Theme, width int) []string {
110 }111 }
111 lines = append(lines, frameRule(th, width))112 lines = append(lines, frameRule(th, width))
112 case loginStepProvider:113 case loginStepProvider:
113- opts := []string{"anthropic", "openai"}114+ opts := []string{"anthropic", "openai", "kimi"}
114 lines = append(lines, frameHeader(th, "login - "+d.method, width))115 lines = append(lines, frameHeader(th, "login - "+d.method, width))
115 for _, l := range d.renderStatusLines(th) {116 for _, l := range d.renderStatusLines(th) {
116 lines = append(lines, l)117 lines = append(lines, l)
@@ -201,6 +202,8 @@ func providerLabel(id string) string {
201 return "Anthropic (Claude Pro/Max)"202 return "Anthropic (Claude Pro/Max)"
202 case "openai":203 case "openai":
203 return "OpenAI (ChatGPT Plus/Pro)"204 return "OpenAI (ChatGPT Plus/Pro)"
205+ case "kimi":
206+ return "Kimi Code"
204 }207 }
205 return id208 return id
206 }209 }
@@ -218,7 +221,8 @@ func providerLabel(id string) string {
218 func (d *loginDialog) renderStatusLines(th tui.Theme) []string {221 func (d *loginDialog) renderStatusLines(th tui.Theme) []string {
219 anth := d.status["anthropic"]222 anth := d.status["anthropic"]
220 op := d.status["openai"]223 op := d.status["openai"]
221- if anth == "" && op == "" {224+ kimi := d.status["kimi"]
225+ if anth == "" && op == "" && kimi == "" {
222 return nil226 return nil
223 }227 }
224 row := func(id, method string) string {228 row := func(id, method string) string {
@@ -240,6 +244,7 @@ func (d *loginDialog) renderStatusLines(th tui.Theme) []string {
240 return []string{244 return []string{
241 row("anthropic", anth),245 row("anthropic", anth),
242 row("openai", op),246 row("openai", op),
247+ row("kimi", kimi),
243 "",248 "",
244 }249 }
245 }250 }
@@ -305,14 +310,14 @@ func (d *loginDialog) handleProviderKey(k tui.Key) loginDialogAction {
305 d.cursor--310 d.cursor--
306 }311 }
307 case tui.KeyDown:312 case tui.KeyDown:
308- if d.cursor < 1 {313+ if d.cursor < 2 {
309 d.cursor++314 d.cursor++
310 }315 }
311 case tui.KeyEsc:316 case tui.KeyEsc:
312 d.Close()317 d.Close()
313 return loginDialogAction{Close: true}318 return loginDialogAction{Close: true}
314 case tui.KeyEnter:319 case tui.KeyEnter:
315- providers := []string{"anthropic", "openai"}320+ providers := []string{"anthropic", "openai", "kimi"}
316 d.provider = providers[d.cursor]321 d.provider = providers[d.cursor]
317 d.step = loginStepWaiting322 d.step = loginStepWaiting
318 if d.method == "apikey" {323 if d.method == "apikey" {
A internal/auth/kimi.go +118 -0
@@ -0,0 +1,118 @@
1+package auth
2+
3+import (
4+ "bytes"
5+ "context"
6+ "encoding/json"
7+ "fmt"
8+ "io"
9+ "net/http"
10+ "time"
11+)
12+
13+// KimiDeviceAuthorization is the OAuth 2 device-code response used by
14+// the official Kimi Code CLI.
15+type KimiDeviceAuthorization struct {
16+ UserCode string `json:"user_code"`
17+ DeviceCode string `json:"device_code"`
18+ VerificationURI string `json:"verification_uri"`
19+ VerificationURIComplete string `json:"verification_uri_complete"`
20+ ExpiresIn int `json:"expires_in"`
21+ Interval int `json:"interval"`
22+}
23+
24+// RequestKimiDeviceAuthorization starts Kimi Code's device-code login.
25+func RequestKimiDeviceAuthorization(ctx context.Context) (KimiDeviceAuthorization, error) {
26+ form := bytes.NewBufferString("client_id=" + KimiOAuth.ClientID)
27+ req, err := http.NewRequestWithContext(ctx, "POST", "https://auth.kimi.com/api/oauth/device_authorization", form)
28+ if err != nil {
29+ return KimiDeviceAuthorization{}, err
30+ }
31+ req.Header.Set("content-type", "application/x-www-form-urlencoded")
32+ req.Header.Set("accept", "application/json")
33+ req.Header.Set("user-agent", "zot")
34+
35+ resp, err := (&http.Client{Timeout: 30 * time.Second}).Do(req)
36+ if err != nil {
37+ return KimiDeviceAuthorization{}, fmt.Errorf("kimi device authorization: %w", err)
38+ }
39+ defer resp.Body.Close()
40+ body, _ := io.ReadAll(resp.Body)
41+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
42+ return KimiDeviceAuthorization{}, fmt.Errorf("kimi device authorization http %d: %s", resp.StatusCode, string(body))
43+ }
44+ var out KimiDeviceAuthorization
45+ if err := json.Unmarshal(body, &out); err != nil {
46+ return out, fmt.Errorf("parse kimi device authorization: %w", err)
47+ }
48+ if out.Interval <= 0 {
49+ out.Interval = 5
50+ }
51+ return out, nil
52+}
53+
54+// PollKimiDeviceToken polls until the browser/device-code login completes.
55+func PollKimiDeviceToken(ctx context.Context, auth KimiDeviceAuthorization) (*OAuthToken, error) {
56+ interval := time.Duration(auth.Interval) * time.Second
57+ if interval <= 0 {
58+ interval = 5 * time.Second
59+ }
60+ for {
61+ tok, retry, err := pollKimiDeviceTokenOnce(ctx, auth.DeviceCode)
62+ if err != nil {
63+ return nil, err
64+ }
65+ if tok != nil {
66+ return tok, nil
67+ }
68+ select {
69+ case <-ctx.Done():
70+ return nil, ctx.Err()
71+ case <-time.After(retry):
72+ }
73+ }
74+}
75+
76+func pollKimiDeviceTokenOnce(ctx context.Context, deviceCode string) (*OAuthToken, time.Duration, error) {
77+ form := bytes.NewBufferString("client_id=" + KimiOAuth.ClientID + "&device_code=" + deviceCode + "&grant_type=urn:ietf:params:oauth:grant-type:device_code")
78+ req, err := http.NewRequestWithContext(ctx, "POST", KimiOAuth.TokenURL, form)
79+ if err != nil {
80+ return nil, 0, err
81+ }
82+ req.Header.Set("content-type", "application/x-www-form-urlencoded")
83+ req.Header.Set("accept", "application/json")
84+ req.Header.Set("user-agent", "zot")
85+ resp, err := (&http.Client{Timeout: 30 * time.Second}).Do(req)
86+ if err != nil {
87+ return nil, 0, fmt.Errorf("kimi token poll: %w", err)
88+ }
89+ defer resp.Body.Close()
90+ body, _ := io.ReadAll(resp.Body)
91+ var raw struct {
92+ AccessToken string `json:"access_token"`
93+ RefreshToken string `json:"refresh_token"`
94+ TokenType string `json:"token_type"`
95+ ExpiresIn float64 `json:"expires_in"`
96+ Scope string `json:"scope"`
97+ Error string `json:"error"`
98+ ErrorDescription string `json:"error_description"`
99+ }
100+ _ = json.Unmarshal(body, &raw)
101+ if resp.StatusCode == http.StatusOK && raw.AccessToken != "" {
102+ return &OAuthToken{
103+ AccessToken: raw.AccessToken,
104+ RefreshToken: raw.RefreshToken,
105+ TokenType: raw.TokenType,
106+ Scope: raw.Scope,
107+ ClientID: KimiOAuth.ClientID,
108+ Expiry: time.Now().Add(time.Duration(raw.ExpiresIn) * time.Second),
109+ }, 0, nil
110+ }
111+ if raw.Error == "authorization_pending" || raw.Error == "slow_down" || resp.StatusCode == http.StatusBadRequest {
112+ return nil, 5 * time.Second, nil
113+ }
114+ if raw.Error != "" {
115+ return nil, 0, fmt.Errorf("kimi token poll: %s: %s", raw.Error, raw.ErrorDescription)
116+ }
117+ return nil, 0, fmt.Errorf("kimi token poll http %d: %s", resp.StatusCode, string(body))
118+}
@@ -0,0 +1,118 @@
1+package auth
2+
3+import (
4+ "bytes"
5+ "context"
6+ "encoding/json"
7+ "fmt"
8+ "io"
9+ "net/http"
10+ "time"
11+)
12+
13+// KimiDeviceAuthorization is the OAuth 2 device-code response used by
14+// the official Kimi Code CLI.
15+type KimiDeviceAuthorization struct {
16+ UserCode string `json:"user_code"`
17+ DeviceCode string `json:"device_code"`
18+ VerificationURI string `json:"verification_uri"`
19+ VerificationURIComplete string `json:"verification_uri_complete"`
20+ ExpiresIn int `json:"expires_in"`
21+ Interval int `json:"interval"`
22+}
23+
24+// RequestKimiDeviceAuthorization starts Kimi Code's device-code login.
25+func RequestKimiDeviceAuthorization(ctx context.Context) (KimiDeviceAuthorization, error) {
26+ form := bytes.NewBufferString("client_id=" + KimiOAuth.ClientID)
27+ req, err := http.NewRequestWithContext(ctx, "POST", "https://auth.kimi.com/api/oauth/device_authorization", form)
28+ if err != nil {
29+ return KimiDeviceAuthorization{}, err
30+ }
31+ req.Header.Set("content-type", "application/x-www-form-urlencoded")
32+ req.Header.Set("accept", "application/json")
33+ req.Header.Set("user-agent", "zot")
34+
35+ resp, err := (&http.Client{Timeout: 30 * time.Second}).Do(req)
36+ if err != nil {
37+ return KimiDeviceAuthorization{}, fmt.Errorf("kimi device authorization: %w", err)
38+ }
39+ defer resp.Body.Close()
40+ body, _ := io.ReadAll(resp.Body)
41+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
42+ return KimiDeviceAuthorization{}, fmt.Errorf("kimi device authorization http %d: %s", resp.StatusCode, string(body))
43+ }
44+ var out KimiDeviceAuthorization
45+ if err := json.Unmarshal(body, &out); err != nil {
46+ return out, fmt.Errorf("parse kimi device authorization: %w", err)
47+ }
48+ if out.Interval <= 0 {
49+ out.Interval = 5
50+ }
51+ return out, nil
52+}
53+
54+// PollKimiDeviceToken polls until the browser/device-code login completes.
55+func PollKimiDeviceToken(ctx context.Context, auth KimiDeviceAuthorization) (*OAuthToken, error) {
56+ interval := time.Duration(auth.Interval) * time.Second
57+ if interval <= 0 {
58+ interval = 5 * time.Second
59+ }
60+ for {
61+ tok, retry, err := pollKimiDeviceTokenOnce(ctx, auth.DeviceCode)
62+ if err != nil {
63+ return nil, err
64+ }
65+ if tok != nil {
66+ return tok, nil
67+ }
68+ select {
69+ case <-ctx.Done():
70+ return nil, ctx.Err()
71+ case <-time.After(retry):
72+ }
73+ }
74+}
75+
76+func pollKimiDeviceTokenOnce(ctx context.Context, deviceCode string) (*OAuthToken, time.Duration, error) {
77+ form := bytes.NewBufferString("client_id=" + KimiOAuth.ClientID + "&device_code=" + deviceCode + "&grant_type=urn:ietf:params:oauth:grant-type:device_code")
78+ req, err := http.NewRequestWithContext(ctx, "POST", KimiOAuth.TokenURL, form)
79+ if err != nil {
80+ return nil, 0, err
81+ }
82+ req.Header.Set("content-type", "application/x-www-form-urlencoded")
83+ req.Header.Set("accept", "application/json")
84+ req.Header.Set("user-agent", "zot")
85+ resp, err := (&http.Client{Timeout: 30 * time.Second}).Do(req)
86+ if err != nil {
87+ return nil, 0, fmt.Errorf("kimi token poll: %w", err)
88+ }
89+ defer resp.Body.Close()
90+ body, _ := io.ReadAll(resp.Body)
91+ var raw struct {
92+ AccessToken string `json:"access_token"`
93+ RefreshToken string `json:"refresh_token"`
94+ TokenType string `json:"token_type"`
95+ ExpiresIn float64 `json:"expires_in"`
96+ Scope string `json:"scope"`
97+ Error string `json:"error"`
98+ ErrorDescription string `json:"error_description"`
99+ }
100+ _ = json.Unmarshal(body, &raw)
101+ if resp.StatusCode == http.StatusOK && raw.AccessToken != "" {
102+ return &OAuthToken{
103+ AccessToken: raw.AccessToken,
104+ RefreshToken: raw.RefreshToken,
105+ TokenType: raw.TokenType,
106+ Scope: raw.Scope,
107+ ClientID: KimiOAuth.ClientID,
108+ Expiry: time.Now().Add(time.Duration(raw.ExpiresIn) * time.Second),
109+ }, 0, nil
110+ }
111+ if raw.Error == "authorization_pending" || raw.Error == "slow_down" || resp.StatusCode == http.StatusBadRequest {
112+ return nil, 5 * time.Second, nil
113+ }
114+ if raw.Error != "" {
115+ return nil, 0, fmt.Errorf("kimi token poll: %s: %s", raw.Error, raw.ErrorDescription)
116+ }
117+ return nil, 0, fmt.Errorf("kimi token poll http %d: %s", resp.StatusCode, string(body))
118+}
M internal/auth/manager.go +45 -4
@@ -78,8 +78,8 @@ func (m *Manager) Close() {
7878
7979 // StartAPIKey launches the API-key login flow.
8080 func (m *Manager) StartAPIKey(provider string) (string, error) {
81- if provider != "anthropic" && provider != "openai" {
82- return "", fmt.Errorf("provider must be anthropic or openai")
81+ if provider != "anthropic" && provider != "openai" && provider != "kimi" {
82+ return "", fmt.Errorf("provider must be anthropic, openai, or kimi")
8383 }
8484 if err := m.ensureKeyServer(); err != nil {
8585 return "", err
@@ -125,6 +125,9 @@ func (m *Manager) consumeKeyServerResults() {
125125 // Only one oauth flow may be in progress at a time (because the
126126 // callback port is fixed per provider and re-used by the official CLIs).
127127 func (m *Manager) StartOAuth(provider string) (string, error) {
128+ if provider == "kimi" {
129+ return m.StartKimiDeviceOAuth()
130+ }
128131 var op OAuthProvider
129132 switch provider {
130133 case "anthropic":
@@ -132,7 +135,7 @@ func (m *Manager) StartOAuth(provider string) (string, error) {
132135 case "openai":
133136 op = OpenAIOAuth
134137 default:
135- return "", fmt.Errorf("provider must be anthropic or openai")
138+ return "", fmt.Errorf("provider must be anthropic, openai, or kimi")
136139 }
137140
138141 m.mu.Lock()
@@ -203,11 +206,49 @@ func (m *Manager) awaitOAuth(ctx context.Context, op OAuthProvider, cs *Callback
203206 m.emit(Event{Kind: "success", Provider: op.Name, Method: "oauth"})
204207 }
205208
209+// StartKimiDeviceOAuth starts Kimi Code's device-code subscription login.
210+func (m *Manager) StartKimiDeviceOAuth() (string, error) {
211+ ctx, cancel := context.WithCancel(context.Background())
212+ m.mu.Lock()
213+ if m.oauthCancel != nil {
214+ m.oauthCancel()
215+ }
216+ m.oauthCtx = ctx
217+ m.oauthCancel = cancel
218+ m.mu.Unlock()
219+
220+ dev, err := RequestKimiDeviceAuthorization(ctx)
221+ if err != nil {
222+ return "", err
223+ }
224+ url := dev.VerificationURIComplete
225+ go m.maybeOpen(url)
226+ m.emit(Event{Kind: "started", Provider: "kimi", Method: "oauth", URL: url})
227+ go func() {
228+ tok, err := PollKimiDeviceToken(ctx, dev)
229+ if err != nil {
230+ if ctx.Err() == nil {
231+ m.emit(Event{Kind: "error", Provider: "kimi", Method: "oauth", Message: err.Error()})
232+ }
233+ return
234+ }
235+ if err := m.store.SetOAuth("kimi", *tok); err != nil {
236+ m.emit(Event{Kind: "error", Provider: "kimi", Method: "oauth", Message: err.Error()})
237+ return
238+ }
239+ m.emit(Event{Kind: "success", Provider: "kimi", Method: "oauth"})
240+ }()
241+ return url, nil
242+}
243+
206244 // StartManualOAuth begins an OAuth flow but does NOT start a local
207245 // callback server or open a browser. The returned URL is shown to the
208246 // user so they can complete the authorization on another device; the
209247 // resulting code is pasted back via CompleteManualOAuth.
210248 func (m *Manager) StartManualOAuth(provider string) (string, error) {
249+ if provider == "kimi" {
250+ return m.StartKimiDeviceOAuth()
251+ }
211252 var op OAuthProvider
212253 switch provider {
213254 case "anthropic":
@@ -215,7 +256,7 @@ func (m *Manager) StartManualOAuth(provider string) (string, error) {
215256 case "openai":
216257 op = OpenAIOAuth
217258 default:
218- return "", fmt.Errorf("provider must be anthropic or openai")
259+ return "", fmt.Errorf("provider must be anthropic, openai, or kimi")
219260 }
220261
221262 pkce, err := NewPKCE()
@@ -78,8 +78,8 @@ func (m *Manager) Close() {
78 78
79 // StartAPIKey launches the API-key login flow.79 // StartAPIKey launches the API-key login flow.
80 func (m *Manager) StartAPIKey(provider string) (string, error) {80 func (m *Manager) StartAPIKey(provider string) (string, error) {
81- if provider != "anthropic" && provider != "openai" {81+ if provider != "anthropic" && provider != "openai" && provider != "kimi" {
82- return "", fmt.Errorf("provider must be anthropic or openai")82+ return "", fmt.Errorf("provider must be anthropic, openai, or kimi")
83 }83 }
84 if err := m.ensureKeyServer(); err != nil {84 if err := m.ensureKeyServer(); err != nil {
85 return "", err85 return "", err
@@ -125,6 +125,9 @@ func (m *Manager) consumeKeyServerResults() {
125 // Only one oauth flow may be in progress at a time (because the125 // Only one oauth flow may be in progress at a time (because the
126 // callback port is fixed per provider and re-used by the official CLIs).126 // callback port is fixed per provider and re-used by the official CLIs).
127 func (m *Manager) StartOAuth(provider string) (string, error) {127 func (m *Manager) StartOAuth(provider string) (string, error) {
128+ if provider == "kimi" {
129+ return m.StartKimiDeviceOAuth()
130+ }
128 var op OAuthProvider131 var op OAuthProvider
129 switch provider {132 switch provider {
130 case "anthropic":133 case "anthropic":
@@ -132,7 +135,7 @@ func (m *Manager) StartOAuth(provider string) (string, error) {
132 case "openai":135 case "openai":
133 op = OpenAIOAuth136 op = OpenAIOAuth
134 default:137 default:
135- return "", fmt.Errorf("provider must be anthropic or openai")138+ return "", fmt.Errorf("provider must be anthropic, openai, or kimi")
136 }139 }
137 140
138 m.mu.Lock()141 m.mu.Lock()
@@ -203,11 +206,49 @@ func (m *Manager) awaitOAuth(ctx context.Context, op OAuthProvider, cs *Callback
203 m.emit(Event{Kind: "success", Provider: op.Name, Method: "oauth"})206 m.emit(Event{Kind: "success", Provider: op.Name, Method: "oauth"})
204 }207 }
205 208
209+// StartKimiDeviceOAuth starts Kimi Code's device-code subscription login.
210+func (m *Manager) StartKimiDeviceOAuth() (string, error) {
211+ ctx, cancel := context.WithCancel(context.Background())
212+ m.mu.Lock()
213+ if m.oauthCancel != nil {
214+ m.oauthCancel()
215+ }
216+ m.oauthCtx = ctx
217+ m.oauthCancel = cancel
218+ m.mu.Unlock()
219+
220+ dev, err := RequestKimiDeviceAuthorization(ctx)
221+ if err != nil {
222+ return "", err
223+ }
224+ url := dev.VerificationURIComplete
225+ go m.maybeOpen(url)
226+ m.emit(Event{Kind: "started", Provider: "kimi", Method: "oauth", URL: url})
227+ go func() {
228+ tok, err := PollKimiDeviceToken(ctx, dev)
229+ if err != nil {
230+ if ctx.Err() == nil {
231+ m.emit(Event{Kind: "error", Provider: "kimi", Method: "oauth", Message: err.Error()})
232+ }
233+ return
234+ }
235+ if err := m.store.SetOAuth("kimi", *tok); err != nil {
236+ m.emit(Event{Kind: "error", Provider: "kimi", Method: "oauth", Message: err.Error()})
237+ return
238+ }
239+ m.emit(Event{Kind: "success", Provider: "kimi", Method: "oauth"})
240+ }()
241+ return url, nil
242+}
243+
206 // StartManualOAuth begins an OAuth flow but does NOT start a local244 // StartManualOAuth begins an OAuth flow but does NOT start a local
207 // callback server or open a browser. The returned URL is shown to the245 // callback server or open a browser. The returned URL is shown to the
208 // user so they can complete the authorization on another device; the246 // user so they can complete the authorization on another device; the
209 // resulting code is pasted back via CompleteManualOAuth.247 // resulting code is pasted back via CompleteManualOAuth.
210 func (m *Manager) StartManualOAuth(provider string) (string, error) {248 func (m *Manager) StartManualOAuth(provider string) (string, error) {
249+ if provider == "kimi" {
250+ return m.StartKimiDeviceOAuth()
251+ }
211 var op OAuthProvider252 var op OAuthProvider
212 switch provider {253 switch provider {
213 case "anthropic":254 case "anthropic":
@@ -215,7 +256,7 @@ func (m *Manager) StartManualOAuth(provider string) (string, error) {
215 case "openai":256 case "openai":
216 op = OpenAIOAuth257 op = OpenAIOAuth
217 default:258 default:
218- return "", fmt.Errorf("provider must be anthropic or openai")259+ return "", fmt.Errorf("provider must be anthropic, openai, or kimi")
219 }260 }
220 261
221 pkce, err := NewPKCE()262 pkce, err := NewPKCE()
M internal/auth/oauth.go +8 -0
@@ -111,6 +111,14 @@ var (
111111 "originator": "zot",
112112 },
113113 }
114+
115+ // Kimi Code subscription: used by the official Kimi Code CLI.
116+ // Kimi uses OAuth 2 device-code flow instead of a loopback callback.
117+ KimiOAuth = OAuthProvider{
118+ Name: "kimi",
119+ TokenURL: "https://auth.kimi.com/api/oauth/token",
120+ ClientID: "17e5f671-d194-4dfb-9706-5516cb48c098",
121+ }
114122 )
115123
116124 // PKCE holds a verifier/challenge pair.
@@ -111,6 +111,14 @@ var (
111 "originator": "zot",111 "originator": "zot",
112 },112 },
113 }113 }
114+
115+ // Kimi Code subscription: used by the official Kimi Code CLI.
116+ // Kimi uses OAuth 2 device-code flow instead of a loopback callback.
117+ KimiOAuth = OAuthProvider{
118+ Name: "kimi",
119+ TokenURL: "https://auth.kimi.com/api/oauth/token",
120+ ClientID: "17e5f671-d194-4dfb-9706-5516cb48c098",
121+ }
114 )122 )
115 123
116 // PKCE holds a verifier/challenge pair.124 // PKCE holds a verifier/challenge pair.
M internal/auth/probe.go +6 -0
@@ -31,6 +31,12 @@ func ProbeAPIKey(ctx context.Context, provider, key string) error {
3131 return err
3232 }
3333 req.Header.Set("authorization", "Bearer "+key)
34+ case "kimi":
35+ req, err = http.NewRequestWithContext(ctx, "GET", "https://api.kimi.com/coding/v1/models", nil)
36+ if err != nil {
37+ return err
38+ }
39+ req.Header.Set("authorization", "Bearer "+key)
3440 default:
3541 return fmt.Errorf("unknown provider %q", provider)
3642 }
@@ -31,6 +31,12 @@ func ProbeAPIKey(ctx context.Context, provider, key string) error {
31 return err31 return err
32 }32 }
33 req.Header.Set("authorization", "Bearer "+key)33 req.Header.Set("authorization", "Bearer "+key)
34+ case "kimi":
35+ req, err = http.NewRequestWithContext(ctx, "GET", "https://api.kimi.com/coding/v1/models", nil)
36+ if err != nil {
37+ return err
38+ }
39+ req.Header.Set("authorization", "Bearer "+key)
34 default:40 default:
35 return fmt.Errorf("unknown provider %q", provider)41 return fmt.Errorf("unknown provider %q", provider)
36 }42 }
M internal/auth/server.go +6 -5
@@ -111,8 +111,8 @@ func (s *Server) handleAPIKey(w http.ResponseWriter, r *http.Request) {
111111 switch r.Method {
112112 case http.MethodGet:
113113 provider := r.URL.Query().Get("provider")
114- if provider != "anthropic" && provider != "openai" {
115- http.Error(w, "provider must be anthropic or openai", http.StatusBadRequest)
114+ if provider != "anthropic" && provider != "openai" && provider != "kimi" {
115+ http.Error(w, "provider must be anthropic, openai, or kimi", http.StatusBadRequest)
116116 return
117117 }
118118 tpl.ExecuteTemplate(w, "apikey", map[string]any{"Provider": provider})
@@ -223,13 +223,14 @@ var tpl = template.Must(template.New("index").Parse(`<!doctype html><html lang="
223223 ` + logoTag + `
224224 <h1><span class="zot">zot</span> login</h1>
225225 <hr class="rule">
226-<p>paste an api key for anthropic or openai. <span class="zot">zot</span> probes the provider once, then saves the key to <span class="mono">~/Library/Application Support/zot/auth.json</span>.</p>
226+<p>paste an api key for anthropic, openai, or kimi. <span class="zot">zot</span> probes the provider once, then saves the key to <span class="mono">~/Library/Application Support/zot/auth.json</span>.</p>
227227 <p>
228228 <a href="/apikey?provider=anthropic">anthropic api key →</a><br>
229- <a href="/apikey?provider=openai">openai api key →</a>
229+ <a href="/apikey?provider=openai">openai api key →</a><br>
230+ <a href="/apikey?provider=kimi">kimi api key →</a>
230231 </p>
231232 <hr class="rule">
232-<p class="muted">for a subscription login (claude pro/max - chatgpt plus/pro), close this tab and run /login inside <span class="zot">zot</span>.</p>
233+<p class="muted">for a subscription login (claude pro/max - chatgpt plus/pro - kimi code), close this tab and run /login inside <span class="zot">zot</span>.</p>
233234 </body></html>`))
234235
235236 func init() {
@@ -111,8 +111,8 @@ func (s *Server) handleAPIKey(w http.ResponseWriter, r *http.Request) {
111 switch r.Method {111 switch r.Method {
112 case http.MethodGet:112 case http.MethodGet:
113 provider := r.URL.Query().Get("provider")113 provider := r.URL.Query().Get("provider")
114- if provider != "anthropic" && provider != "openai" {114+ if provider != "anthropic" && provider != "openai" && provider != "kimi" {
115- http.Error(w, "provider must be anthropic or openai", http.StatusBadRequest)115+ http.Error(w, "provider must be anthropic, openai, or kimi", http.StatusBadRequest)
116 return116 return
117 }117 }
118 tpl.ExecuteTemplate(w, "apikey", map[string]any{"Provider": provider})118 tpl.ExecuteTemplate(w, "apikey", map[string]any{"Provider": provider})
@@ -223,13 +223,14 @@ var tpl = template.Must(template.New("index").Parse(`<!doctype html><html lang="
223 ` + logoTag + `223 ` + logoTag + `
224 <h1><span class="zot">zot</span> login</h1>224 <h1><span class="zot">zot</span> login</h1>
225 <hr class="rule">225 <hr class="rule">
226-<p>paste an api key for anthropic or openai. <span class="zot">zot</span> probes the provider once, then saves the key to <span class="mono">~/Library/Application Support/zot/auth.json</span>.</p>226+<p>paste an api key for anthropic, openai, or kimi. <span class="zot">zot</span> probes the provider once, then saves the key to <span class="mono">~/Library/Application Support/zot/auth.json</span>.</p>
227 <p>227 <p>
228 <a href="/apikey?provider=anthropic">anthropic api key →</a><br>228 <a href="/apikey?provider=anthropic">anthropic api key →</a><br>
229- <a href="/apikey?provider=openai">openai api key →</a>229+ <a href="/apikey?provider=openai">openai api key →</a><br>
230+ <a href="/apikey?provider=kimi">kimi api key →</a>
230 </p>231 </p>
231 <hr class="rule">232 <hr class="rule">
232-<p class="muted">for a subscription login (claude pro/max - chatgpt plus/pro), close this tab and run /login inside <span class="zot">zot</span>.</p>233+<p class="muted">for a subscription login (claude pro/max - chatgpt plus/pro - kimi code), close this tab and run /login inside <span class="zot">zot</span>.</p>
233 </body></html>`))234 </body></html>`))
234 235
235 func init() {236 func init() {
M internal/auth/store.go +3 -0
@@ -18,6 +18,7 @@ import (
1818 type Credentials struct {
1919 Anthropic ProviderCreds `json:"anthropic,omitempty"`
2020 OpenAI ProviderCreds `json:"openai,omitempty"`
21+ Kimi ProviderCreds `json:"kimi,omitempty"`
2122 }
2223
2324 // ProviderCreds holds credentials for a single provider. Only one of
@@ -80,6 +81,8 @@ func (c *Credentials) get(provider string) *ProviderCreds {
8081 return &c.Anthropic
8182 case "openai":
8283 return &c.OpenAI
84+ case "kimi":
85+ return &c.Kimi
8386 }
8487 return nil
8588 }
@@ -18,6 +18,7 @@ import (
18 type Credentials struct {18 type Credentials struct {
19 Anthropic ProviderCreds `json:"anthropic,omitempty"`19 Anthropic ProviderCreds `json:"anthropic,omitempty"`
20 OpenAI ProviderCreds `json:"openai,omitempty"`20 OpenAI ProviderCreds `json:"openai,omitempty"`
21+ Kimi ProviderCreds `json:"kimi,omitempty"`
21 }22 }
22 23
23 // ProviderCreds holds credentials for a single provider. Only one of24 // ProviderCreds holds credentials for a single provider. Only one of
@@ -80,6 +81,8 @@ func (c *Credentials) get(provider string) *ProviderCreds {
80 return &c.Anthropic81 return &c.Anthropic
81 case "openai":82 case "openai":
82 return &c.OpenAI83 return &c.OpenAI
84+ case "kimi":
85+ return &c.Kimi
83 }86 }
84 return nil87 return nil
85 }88 }
M internal/provider/models.go +7 -0
@@ -92,6 +92,13 @@ var Catalog = []Model{
9292 PriceInput: 15.00, PriceOutput: 75.00, PriceCacheRead: 1.50, PriceCacheWrite: 18.75,
9393 },
9494
95+ // ---- Kimi / Kimi Code ----
96+ {
97+ Provider: "kimi", ID: "kimi-for-coding", DisplayName: "Kimi-k2.6",
98+ ContextWindow: 262144, MaxOutput: 32000, Reasoning: true,
99+ BaseURL: "https://api.kimi.com/coding/v1",
100+ },
101+
95102 // ---- OpenAI / GPT-5 family ----
96103 {
97104 Provider: "openai", ID: "gpt-5", DisplayName: "GPT-5",
@@ -92,6 +92,13 @@ var Catalog = []Model{
92 PriceInput: 15.00, PriceOutput: 75.00, PriceCacheRead: 1.50, PriceCacheWrite: 18.75,92 PriceInput: 15.00, PriceOutput: 75.00, PriceCacheRead: 1.50, PriceCacheWrite: 18.75,
93 },93 },
94 94
95+ // ---- Kimi / Kimi Code ----
96+ {
97+ Provider: "kimi", ID: "kimi-for-coding", DisplayName: "Kimi-k2.6",
98+ ContextWindow: 262144, MaxOutput: 32000, Reasoning: true,
99+ BaseURL: "https://api.kimi.com/coding/v1",
100+ },
101+
95 // ---- OpenAI / GPT-5 family ----102 // ---- OpenAI / GPT-5 family ----
96 {103 {
97 Provider: "openai", ID: "gpt-5", DisplayName: "GPT-5",104 Provider: "openai", ID: "gpt-5", DisplayName: "GPT-5",
M internal/provider/openai.go +39 -3
@@ -17,7 +17,9 @@ const openaiDefaultBaseURL = "https://api.openai.com"
1717 type openaiClient struct {
1818 apiKey string
1919 baseURL string
20+ name string
2021 oauth bool // when true, apiKey actually holds an OAuth access token
22+ headers map[string]string
2123 http *http.Client
2224 }
2325
@@ -29,6 +31,7 @@ func NewOpenAI(apiKey, baseURL string) Client {
2931 return &openaiClient{
3032 apiKey: apiKey,
3133 baseURL: strings.TrimRight(baseURL, "/"),
34+ name: "openai",
3235 http: &http.Client{Timeout: 0},
3336 }
3437 }
@@ -42,12 +45,38 @@ func NewOpenAIOAuth(accessToken, baseURL string) Client {
4245 return &openaiClient{
4346 apiKey: accessToken,
4447 baseURL: strings.TrimRight(baseURL, "/"),
48+ name: "openai",
4549 oauth: true,
4650 http: &http.Client{Timeout: 0},
4751 }
4852 }
4953
50-func (c *openaiClient) Name() string { return "openai" }
54+// NewKimi creates a Kimi/Moonshot client. Kimi's chat API is OpenAI-compatible.
55+func NewKimi(apiKey, baseURL string) Client {
56+ return NewKimiWithHeaders(apiKey, baseURL, nil)
57+}
58+
59+// NewKimiWithHeaders creates a Kimi/Moonshot client with extra headers.
60+// Subscription tokens from Kimi Code need the official CLI's X-Msh-* headers.
61+func NewKimiWithHeaders(apiKey, baseURL string, headers map[string]string) Client {
62+ if baseURL == "" {
63+ baseURL = "https://api.kimi.com/coding/v1"
64+ }
65+ return &openaiClient{
66+ apiKey: apiKey,
67+ baseURL: strings.TrimRight(baseURL, "/"),
68+ name: "kimi",
69+ headers: headers,
70+ http: &http.Client{Timeout: 0},
71+ }
72+}
73+
74+func (c *openaiClient) Name() string {
75+ if c.name != "" {
76+ return c.name
77+ }
78+ return "openai"
79+}
5180
5281 // ---- wire types ----
5382
@@ -285,6 +314,10 @@ func buildOAIContentBlocks(blocks []Content, isError bool) []interface{} {
285314 // ---- streaming ----
286315
287316 func (c *openaiClient) Stream(ctx context.Context, req Request) (<-chan Event, error) {
317+ apiPath := "/v1/chat/completions"
318+ if strings.HasSuffix(c.baseURL, "/v1") {
319+ apiPath = "/chat/completions"
320+ }
288321 wire, err := c.buildRequest(req)
289322 if err != nil {
290323 return nil, err
@@ -293,13 +326,16 @@ func (c *openaiClient) Stream(ctx context.Context, req Request) (<-chan Event, e
293326 if err != nil {
294327 return nil, err
295328 }
296- httpReq, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/v1/chat/completions", bytes.NewReader(body))
329+ httpReq, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+apiPath, bytes.NewReader(body))
297330 if err != nil {
298331 return nil, err
299332 }
300333 httpReq.Header.Set("content-type", "application/json")
301334 httpReq.Header.Set("accept", "text/event-stream")
302335 httpReq.Header.Set("authorization", "Bearer "+c.apiKey)
336+ for k, v := range c.headers {
337+ httpReq.Header.Set(k, v)
338+ }
303339
304340 resp, err := c.http.Do(httpReq)
305341 if err != nil {
@@ -321,7 +357,7 @@ func (c *openaiClient) runStream(ctx context.Context, resp *http.Response, req R
321357 defer resp.Body.Close()
322358
323359 model, _ := FindModel("", req.Model)
324- out <- EventStart{Model: req.Model, Provider: "openai"}
360+ out <- EventStart{Model: req.Model, Provider: c.Name()}
325361
326362 raw := make(chan sseEvent, 16)
327363 go readSSE(resp.Body, raw)
@@ -17,7 +17,9 @@ const openaiDefaultBaseURL = "https://api.openai.com"
17 type openaiClient struct {17 type openaiClient struct {
18 apiKey string18 apiKey string
19 baseURL string19 baseURL string
20+ name string
20 oauth bool // when true, apiKey actually holds an OAuth access token21 oauth bool // when true, apiKey actually holds an OAuth access token
22+ headers map[string]string
21 http *http.Client23 http *http.Client
22 }24 }
23 25
@@ -29,6 +31,7 @@ func NewOpenAI(apiKey, baseURL string) Client {
29 return &openaiClient{31 return &openaiClient{
30 apiKey: apiKey,32 apiKey: apiKey,
31 baseURL: strings.TrimRight(baseURL, "/"),33 baseURL: strings.TrimRight(baseURL, "/"),
34+ name: "openai",
32 http: &http.Client{Timeout: 0},35 http: &http.Client{Timeout: 0},
33 }36 }
34 }37 }
@@ -42,12 +45,38 @@ func NewOpenAIOAuth(accessToken, baseURL string) Client {
42 return &openaiClient{45 return &openaiClient{
43 apiKey: accessToken,46 apiKey: accessToken,
44 baseURL: strings.TrimRight(baseURL, "/"),47 baseURL: strings.TrimRight(baseURL, "/"),
48+ name: "openai",
45 oauth: true,49 oauth: true,
46 http: &http.Client{Timeout: 0},50 http: &http.Client{Timeout: 0},
47 }51 }
48 }52 }
49 53
50-func (c *openaiClient) Name() string { return "openai" }54+// NewKimi creates a Kimi/Moonshot client. Kimi's chat API is OpenAI-compatible.
55+func NewKimi(apiKey, baseURL string) Client {
56+ return NewKimiWithHeaders(apiKey, baseURL, nil)
57+}
58+
59+// NewKimiWithHeaders creates a Kimi/Moonshot client with extra headers.
60+// Subscription tokens from Kimi Code need the official CLI's X-Msh-* headers.
61+func NewKimiWithHeaders(apiKey, baseURL string, headers map[string]string) Client {
62+ if baseURL == "" {
63+ baseURL = "https://api.kimi.com/coding/v1"
64+ }
65+ return &openaiClient{
66+ apiKey: apiKey,
67+ baseURL: strings.TrimRight(baseURL, "/"),
68+ name: "kimi",
69+ headers: headers,
70+ http: &http.Client{Timeout: 0},
71+ }
72+}
73+
74+func (c *openaiClient) Name() string {
75+ if c.name != "" {
76+ return c.name
77+ }
78+ return "openai"
79+}
51 80
52 // ---- wire types ----81 // ---- wire types ----
53 82
@@ -285,6 +314,10 @@ func buildOAIContentBlocks(blocks []Content, isError bool) []interface{} {
285 // ---- streaming ----314 // ---- streaming ----
286 315
287 func (c *openaiClient) Stream(ctx context.Context, req Request) (<-chan Event, error) {316 func (c *openaiClient) Stream(ctx context.Context, req Request) (<-chan Event, error) {
317+ apiPath := "/v1/chat/completions"
318+ if strings.HasSuffix(c.baseURL, "/v1") {
319+ apiPath = "/chat/completions"
320+ }
288 wire, err := c.buildRequest(req)321 wire, err := c.buildRequest(req)
289 if err != nil {322 if err != nil {
290 return nil, err323 return nil, err
@@ -293,13 +326,16 @@ func (c *openaiClient) Stream(ctx context.Context, req Request) (<-chan Event, e
293 if err != nil {326 if err != nil {
294 return nil, err327 return nil, err
295 }328 }
296- httpReq, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/v1/chat/completions", bytes.NewReader(body))329+ httpReq, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+apiPath, bytes.NewReader(body))
297 if err != nil {330 if err != nil {
298 return nil, err331 return nil, err
299 }332 }
300 httpReq.Header.Set("content-type", "application/json")333 httpReq.Header.Set("content-type", "application/json")
301 httpReq.Header.Set("accept", "text/event-stream")334 httpReq.Header.Set("accept", "text/event-stream")
302 httpReq.Header.Set("authorization", "Bearer "+c.apiKey)335 httpReq.Header.Set("authorization", "Bearer "+c.apiKey)
336+ for k, v := range c.headers {
337+ httpReq.Header.Set(k, v)
338+ }
303 339
304 resp, err := c.http.Do(httpReq)340 resp, err := c.http.Do(httpReq)
305 if err != nil {341 if err != nil {
@@ -321,7 +357,7 @@ func (c *openaiClient) runStream(ctx context.Context, resp *http.Response, req R
321 defer resp.Body.Close()357 defer resp.Body.Close()
322 358
323 model, _ := FindModel("", req.Model)359 model, _ := FindModel("", req.Model)
324- out <- EventStart{Model: req.Model, Provider: "openai"}360+ out <- EventStart{Model: req.Model, Provider: c.Name()}
325 361
326 raw := make(chan sseEvent, 16)362 raw := make(chan sseEvent, 16)
327 go readSSE(resp.Body, raw)363 go readSSE(resp.Body, raw)
M internal/provider/usermodels.go +2 -0
@@ -78,6 +78,8 @@ func LoadUserModels(path string) []Model {
7878 normalized = "openai"
7979 case "anthropic-messages":
8080 normalized = "anthropic"
81+ case "moonshot", "moonshot-ai", "kimi-code":
82+ normalized = "kimi"
8183 }
8284
8385 for _, um := range prov.Models {
@@ -78,6 +78,8 @@ func LoadUserModels(path string) []Model {
78 normalized = "openai"78 normalized = "openai"
79 case "anthropic-messages":79 case "anthropic-messages":
80 normalized = "anthropic"80 normalized = "anthropic"
81+ case "moonshot", "moonshot-ai", "kimi-code":
82+ normalized = "kimi"
81 }83 }
82 84
83 for _, um := range prov.Models {85 for _, um := range prov.Models {
M internal/tui/view.go +105 -0
@@ -7,7 +7,9 @@ import (
77 "regexp"
88 "strconv"
99 "strings"
10+ "unicode/utf8"
1011
12+ "github.com/mattn/go-runewidth"
1113 "github.com/patriceckhart/zot/internal/provider"
1214 )
1315
@@ -1440,6 +1442,7 @@ func (v *View) renderNumberedFile(text, sourcePath string) []string {
14401442 // in muted type, everything else on the default tool-output color.
14411443 // Called from renderToolText when the first line starts with "$ ".
14421444 func (v *View) renderBashResult(lines []string, width, defaultColor int) []string {
1445+ lines = normalizeBashOutputLines(lines)
14431446 // Identify the footer line (exit + timing). The bash tool writes
14441447 // it as the last non-empty line of the result.
14451448 footerIdx := -1
@@ -1477,6 +1480,108 @@ func (v *View) renderBashResult(lines []string, width, defaultColor int) []strin
14771480 return out
14781481 }
14791482
1483+// normalizeBashOutputLines turns arbitrary terminal output into plain,
1484+// box-safe rows before wrapping. Unlike read/write/edit output, bash
1485+// stdout/stderr may contain tabs, carriage returns, ANSI/OSC escapes,
1486+// and other C0 controls from subprocesses. If those reach the bordered
1487+// tool box, the width calculator can undercount what the terminal will
1488+// draw and the right edge appears broken. Keep printable text, expand
1489+// tabs to spaces, split carriage-return progress rows, and drop escape
1490+// / control sequences.
1491+func normalizeBashOutputLines(lines []string) []string {
1492+ var out []string
1493+ for _, line := range lines {
1494+ for _, part := range strings.Split(strings.ReplaceAll(line, "\r", "\n"), "\n") {
1495+ out = append(out, normalizeBashOutputLine(part))
1496+ }
1497+ }
1498+ if len(out) == 0 {
1499+ return []string{""}
1500+ }
1501+ return out
1502+}
1503+
1504+func normalizeBashOutputLine(s string) string {
1505+ var b strings.Builder
1506+ col := 0
1507+ for i := 0; i < len(s); {
1508+ c := s[i]
1509+ if c == 0x1b { // ESC: strip CSI/OSC/DCS and simple escapes.
1510+ i = skipEscapeSequence(s, i)
1511+ continue
1512+ }
1513+ r, size := utf8.DecodeRuneInString(s[i:])
1514+ if r == utf8.RuneError && size == 1 {
1515+ i++
1516+ continue
1517+ }
1518+ switch r {
1519+ case '\t':
1520+ spaces := 8 - (col % 8)
1521+ if spaces == 0 {
1522+ spaces = 8
1523+ }
1524+ b.WriteString(strings.Repeat(" ", spaces))
1525+ col += spaces
1526+ case '\b':
1527+ // Backspace-overstrike output is common in spinners/progress bars.
1528+ // Dropping it is safer than moving the TUI cursor backwards.
1529+ case '\n', '\r':
1530+ // Already split by normalizeBashOutputLines.
1531+ default:
1532+ if r < 0x20 || r == 0x7f {
1533+ // Drop non-printing controls.
1534+ break
1535+ }
1536+ b.WriteRune(r)
1537+ col += runewidth.RuneWidth(r)
1538+ }
1539+ i += size
1540+ }
1541+ return b.String()
1542+}
1543+
1544+func skipEscapeSequence(s string, i int) int {
1545+ if i >= len(s) || s[i] != 0x1b {
1546+ return i + 1
1547+ }
1548+ if i+1 >= len(s) {
1549+ return len(s)
1550+ }
1551+ switch s[i+1] {
1552+ case '[': // CSI: ESC [ ... final byte 0x40-0x7e
1553+ j := i + 2
1554+ for j < len(s) {
1555+ c := s[j]
1556+ j++
1557+ if c >= 0x40 && c <= 0x7e {
1558+ break
1559+ }
1560+ }
1561+ return j
1562+ case ']': // OSC: ESC ] ... BEL or ST
1563+ return skipStringEscape(s, i+2)
1564+ case 'P', '_', '^', 'X': // DCS/APC/PM/SOS: ESC P ... ST, etc.
1565+ return skipStringEscape(s, i+2)
1566+ default:
1567+ // Two-byte escape (cursor save/restore, charset select, etc.).
1568+ return i + 2
1569+ }
1570+}
1571+
1572+func skipStringEscape(s string, i int) int {
1573+ for i < len(s) {
1574+ if s[i] == 0x07 { // BEL
1575+ return i + 1
1576+ }
1577+ if s[i] == 0x1b && i+1 < len(s) && s[i+1] == '\\' { // ST
1578+ return i + 2
1579+ }
1580+ i++
1581+ }
1582+ return len(s)
1583+}
1584+
14801585 // looksLikeUnifiedDiff reports whether text is a context diff as
14811586 // emitted by the edit tool: rows start with '+', '-', ' ', or
14821587 // literal "..." (context-break marker). The presence of at least
@@ -7,7 +7,9 @@ import (
7 "regexp"7 "regexp"
8 "strconv"8 "strconv"
9 "strings"9 "strings"
10+ "unicode/utf8"
10 11
12+ "github.com/mattn/go-runewidth"
11 "github.com/patriceckhart/zot/internal/provider"13 "github.com/patriceckhart/zot/internal/provider"
12 )14 )
13 15
@@ -1440,6 +1442,7 @@ func (v *View) renderNumberedFile(text, sourcePath string) []string {
1440 // in muted type, everything else on the default tool-output color.1442 // in muted type, everything else on the default tool-output color.
1441 // Called from renderToolText when the first line starts with "$ ".1443 // Called from renderToolText when the first line starts with "$ ".
1442 func (v *View) renderBashResult(lines []string, width, defaultColor int) []string {1444 func (v *View) renderBashResult(lines []string, width, defaultColor int) []string {
1445+ lines = normalizeBashOutputLines(lines)
1443 // Identify the footer line (exit + timing). The bash tool writes1446 // Identify the footer line (exit + timing). The bash tool writes
1444 // it as the last non-empty line of the result.1447 // it as the last non-empty line of the result.
1445 footerIdx := -11448 footerIdx := -1
@@ -1477,6 +1480,108 @@ func (v *View) renderBashResult(lines []string, width, defaultColor int) []strin
1477 return out1480 return out
1478 }1481 }
1479 1482
1483+// normalizeBashOutputLines turns arbitrary terminal output into plain,
1484+// box-safe rows before wrapping. Unlike read/write/edit output, bash
1485+// stdout/stderr may contain tabs, carriage returns, ANSI/OSC escapes,
1486+// and other C0 controls from subprocesses. If those reach the bordered
1487+// tool box, the width calculator can undercount what the terminal will
1488+// draw and the right edge appears broken. Keep printable text, expand
1489+// tabs to spaces, split carriage-return progress rows, and drop escape
1490+// / control sequences.
1491+func normalizeBashOutputLines(lines []string) []string {
1492+ var out []string
1493+ for _, line := range lines {
1494+ for _, part := range strings.Split(strings.ReplaceAll(line, "\r", "\n"), "\n") {
1495+ out = append(out, normalizeBashOutputLine(part))
1496+ }
1497+ }
1498+ if len(out) == 0 {
1499+ return []string{""}
1500+ }
1501+ return out
1502+}
1503+
1504+func normalizeBashOutputLine(s string) string {
1505+ var b strings.Builder
1506+ col := 0
1507+ for i := 0; i < len(s); {
1508+ c := s[i]
1509+ if c == 0x1b { // ESC: strip CSI/OSC/DCS and simple escapes.
1510+ i = skipEscapeSequence(s, i)
1511+ continue
1512+ }
1513+ r, size := utf8.DecodeRuneInString(s[i:])
1514+ if r == utf8.RuneError && size == 1 {
1515+ i++
1516+ continue
1517+ }
1518+ switch r {
1519+ case '\t':
1520+ spaces := 8 - (col % 8)
1521+ if spaces == 0 {
1522+ spaces = 8
1523+ }
1524+ b.WriteString(strings.Repeat(" ", spaces))
1525+ col += spaces
1526+ case '\b':
1527+ // Backspace-overstrike output is common in spinners/progress bars.
1528+ // Dropping it is safer than moving the TUI cursor backwards.
1529+ case '\n', '\r':
1530+ // Already split by normalizeBashOutputLines.
1531+ default:
1532+ if r < 0x20 || r == 0x7f {
1533+ // Drop non-printing controls.
1534+ break
1535+ }
1536+ b.WriteRune(r)
1537+ col += runewidth.RuneWidth(r)
1538+ }
1539+ i += size
1540+ }
1541+ return b.String()
1542+}
1543+
1544+func skipEscapeSequence(s string, i int) int {
1545+ if i >= len(s) || s[i] != 0x1b {
1546+ return i + 1
1547+ }
1548+ if i+1 >= len(s) {
1549+ return len(s)
1550+ }
1551+ switch s[i+1] {
1552+ case '[': // CSI: ESC [ ... final byte 0x40-0x7e
1553+ j := i + 2
1554+ for j < len(s) {
1555+ c := s[j]
1556+ j++
1557+ if c >= 0x40 && c <= 0x7e {
1558+ break
1559+ }
1560+ }
1561+ return j
1562+ case ']': // OSC: ESC ] ... BEL or ST
1563+ return skipStringEscape(s, i+2)
1564+ case 'P', '_', '^', 'X': // DCS/APC/PM/SOS: ESC P ... ST, etc.
1565+ return skipStringEscape(s, i+2)
1566+ default:
1567+ // Two-byte escape (cursor save/restore, charset select, etc.).
1568+ return i + 2
1569+ }
1570+}
1571+
1572+func skipStringEscape(s string, i int) int {
1573+ for i < len(s) {
1574+ if s[i] == 0x07 { // BEL
1575+ return i + 1
1576+ }
1577+ if s[i] == 0x1b && i+1 < len(s) && s[i+1] == '\\' { // ST
1578+ return i + 2
1579+ }
1580+ i++
1581+ }
1582+ return len(s)
1583+}
1584+
1480 // looksLikeUnifiedDiff reports whether text is a context diff as1585 // looksLikeUnifiedDiff reports whether text is a context diff as
1481 // emitted by the edit tool: rows start with '+', '-', ' ', or1586 // emitted by the edit tool: rows start with '+', '-', ' ', or
1482 // literal "..." (context-break marker). The presence of at least1587 // literal "..." (context-break marker). The presence of at least

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