on main help sign in
feat: add support for claude code max (#1514)
191a6c8Andrey Nering
README.md−11
@@ -664,17 +664,6 @@ Or by setting the following in your config:
664Crush also respects the [`DO_NOT_TRACK`](https://consoledonottrack.com)
664Crush also respects the [`DO_NOT_TRACK`](https://consoledonottrack.com)
665convention which can be enabled via `export DO_NOT_TRACK=1`.
665convention which can be enabled via `export DO_NOT_TRACK=1`.
666
666
667−## A Note on Claude Max and GitHub Copilot
668−
669−Crush only supports model providers through official, compliant APIs. We do not
670−support or endorse any methods that rely on personal Claude Max and GitHub
671−Copilot accounts or OAuth workarounds, which violate Anthropic and
672−Microsoft’s Terms of Service.
673−
674−We’re committed to building sustainable, trusted integrations with model
675−providers. If you’re a provider interested in working with us,
676−[reach out](mailto:vt100@charm.sh).
677−
678## Contributing
667## Contributing
679
668
680See the [contributing guide](https://github.com/charmbracelet/crush?tab=contributing-ov-file#contributing).
669See the [contributing guide](https://github.com/charmbracelet/crush?tab=contributing-ov-file#contributing).
go.mod+2−1
@@ -6,7 +6,7 @@ require (
6 charm.land/bubbles/v2 v2.0.0-rc.1
6 charm.land/bubbles/v2 v2.0.0-rc.1
7 charm.land/bubbletea/v2 v2.0.0-rc.2.0.20251124184313-5de0f1f67562
7 charm.land/bubbletea/v2 v2.0.0-rc.2.0.20251124184313-5de0f1f67562
8 charm.land/fantasy v0.3.2
8 charm.land/fantasy v0.3.2
9− charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251106193318-19329a3e8410
9+ charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251119143523-0334bb4562ca
10 charm.land/x/vcr v0.1.1
10 charm.land/x/vcr v0.1.1
11 github.com/JohannesKaufmann/html-to-markdown v1.6.0
11 github.com/JohannesKaufmann/html-to-markdown v1.6.0
12 github.com/MakeNowJust/heredoc v1.0.0
12 github.com/MakeNowJust/heredoc v1.0.0
@@ -41,6 +41,7 @@ require (
41 github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646
41 github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646
42 github.com/nxadm/tail v1.4.11
42 github.com/nxadm/tail v1.4.11
43 github.com/openai/openai-go/v2 v2.7.1
43 github.com/openai/openai-go/v2 v2.7.1
44+ github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c
44 github.com/posthog/posthog-go v1.6.12
45 github.com/posthog/posthog-go v1.6.12
45 github.com/pressly/goose/v3 v3.26.0
46 github.com/pressly/goose/v3 v3.26.0
46 github.com/qjebbs/go-jsons v1.0.0-alpha.4
47 github.com/qjebbs/go-jsons v1.0.0-alpha.4
go.sum+3−2
@@ -4,8 +4,8 @@ charm.land/bubbletea/v2 v2.0.0-rc.2.0.20251124184313-5de0f1f67562 h1:61aovinon0n
4charm.land/bubbletea/v2 v2.0.0-rc.2.0.20251124184313-5de0f1f67562/go.mod h1:IXFmnCnMLTWw/KQ9rEatSYqbAPAYi8kA3Yqwa1SFnLk=
4charm.land/bubbletea/v2 v2.0.0-rc.2.0.20251124184313-5de0f1f67562/go.mod h1:IXFmnCnMLTWw/KQ9rEatSYqbAPAYi8kA3Yqwa1SFnLk=
5charm.land/fantasy v0.3.2 h1:yHTsSZ25LcICMRw3xzdz3OkaZtDQch+B5ljJo17HxgU=
5charm.land/fantasy v0.3.2 h1:yHTsSZ25LcICMRw3xzdz3OkaZtDQch+B5ljJo17HxgU=
6charm.land/fantasy v0.3.2/go.mod h1:sV8Ns/JTJHOaYOHPgVRDugMheAyxsW/nmdpVGrycYEk=
6charm.land/fantasy v0.3.2/go.mod h1:sV8Ns/JTJHOaYOHPgVRDugMheAyxsW/nmdpVGrycYEk=
7−charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251106193318-19329a3e8410 h1:D9PbaszZYpB4nj+d6HTWr1onlmlyuGVNfL9gAi8iB3k=
7+charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251119143523-0334bb4562ca h1:6bVc8OFotCS4sS7HKqxTudP7yn8Y0ODR6df2pdlY/+s=
8−charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251106193318-19329a3e8410/go.mod h1:1qZyvvVCenJO2M1ac2mX0yyiIZJoZmDM4DG4s0udJkU=
8+charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251119143523-0334bb4562ca/go.mod h1:XSJjv7DaH4zd1Y27kZis295RkEj9OFR9zh2WffQQsKQ=
9charm.land/x/vcr v0.1.1 h1:PXCFMUG0rPtyk35rhfzYCJEduOzWXCIbrXTFq4OF/9Q=
9charm.land/x/vcr v0.1.1 h1:PXCFMUG0rPtyk35rhfzYCJEduOzWXCIbrXTFq4OF/9Q=
10charm.land/x/vcr v0.1.1/go.mod h1:eByq2gqzWvcct/8XE2XO5KznoWEBiXH56+y2gphbltM=
10charm.land/x/vcr v0.1.1/go.mod h1:eByq2gqzWvcct/8XE2XO5KznoWEBiXH56+y2gphbltM=
11cloud.google.com/go v0.116.0 h1:B3fRrSDkLRt5qSHWe40ERJvhvnQwdZiHu0bJOpldweE=
11cloud.google.com/go v0.116.0 h1:B3fRrSDkLRt5qSHWe40ERJvhvnQwdZiHu0bJOpldweE=
@@ -408,6 +408,7 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc
408golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
408golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
409golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
409golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
410golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
410golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
411+golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
411golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
412golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
412golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
413golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
413golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
414golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
internal/agent/agent.go+19−2
@@ -238,8 +238,8 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy
238 }
238 }
239 }
239 }
240
240
241− if a.systemPromptPrefix != "" {
241+ if promptPrefix := a.promptPrefix(); promptPrefix != "" {
242− prepared.Messages = append([]fantasy.Message{fantasy.NewSystemMessage(a.systemPromptPrefix)}, prepared.Messages...)
242+ prepared.Messages = append([]fantasy.Message{fantasy.NewSystemMessage(promptPrefix)}, prepared.Messages...)
243 }
243 }
244
244
245 var assistantMsg message.Message
245 var assistantMsg message.Message
@@ -789,6 +789,10 @@ func (a *sessionAgent) updateSessionUsage(model Model, session *session.Session,
789 modelConfig.CostPer1MIn/1e6*float64(usage.InputTokens) +
789 modelConfig.CostPer1MIn/1e6*float64(usage.InputTokens) +
790 modelConfig.CostPer1MOut/1e6*float64(usage.OutputTokens)
790 modelConfig.CostPer1MOut/1e6*float64(usage.OutputTokens)
791
791
792+ if a.isClaudeCode() {
793+ cost = 0
794+ }
795+
792 a.eventTokensUsed(session.ID, model, usage, cost)
796 a.eventTokensUsed(session.ID, model, usage, cost)
793
797
794 if overrideCost != nil {
798 if overrideCost != nil {
@@ -882,3 +886,16 @@ func (a *sessionAgent) SetTools(tools []fantasy.AgentTool) {
882func (a *sessionAgent) Model() Model {
886func (a *sessionAgent) Model() Model {
883 return a.largeModel
887 return a.largeModel
884}
888}
889+
890+func (a *sessionAgent) promptPrefix() string {
891+ if a.isClaudeCode() {
892+ return "You are Claude Code, Anthropic's official CLI for Claude."
893+ }
894+ return a.systemPromptPrefix
895+}
896+
897+func (a *sessionAgent) isClaudeCode() bool {
898+ cfg := config.Get()
899+ pc, ok := cfg.Providers.Get(a.largeModel.ModelCfg.Provider)
900+ return ok && pc.ID == string(catwalk.InferenceProviderAnthropic) && pc.OAuthToken != nil
901+}
internal/config/config.go+48−8
@@ -1,6 +1,7 @@
1package config
1package config
2
2
3import (
3import (
4+ "cmp"
4 "context"
5 "context"
5 "fmt"
6 "fmt"
6 "log/slog"
7 "log/slog"
@@ -14,6 +15,7 @@ import (
14 "github.com/charmbracelet/catwalk/pkg/catwalk"
15 "github.com/charmbracelet/catwalk/pkg/catwalk"
15 "github.com/charmbracelet/crush/internal/csync"
16 "github.com/charmbracelet/crush/internal/csync"
16 "github.com/charmbracelet/crush/internal/env"
17 "github.com/charmbracelet/crush/internal/env"
18+ "github.com/charmbracelet/crush/internal/oauth"
17 "github.com/invopop/jsonschema"
19 "github.com/invopop/jsonschema"
18 "github.com/tidwall/sjson"
20 "github.com/tidwall/sjson"
19)
21)
@@ -92,6 +94,8 @@ type ProviderConfig struct {
92 Type catwalk.Type `json:"type,omitempty" jsonschema:"description=Provider type that determines the API format,enum=openai,enum=openai-compat,enum=anthropic,enum=gemini,enum=azure,enum=vertexai,default=openai"`
94 Type catwalk.Type `json:"type,omitempty" jsonschema:"description=Provider type that determines the API format,enum=openai,enum=openai-compat,enum=anthropic,enum=gemini,enum=azure,enum=vertexai,default=openai"`
93 // The provider's API key.
95 // The provider's API key.
94 APIKey string `json:"api_key,omitempty" jsonschema:"description=API key for authentication with the provider,example=$OPENAI_API_KEY"`
96 APIKey string `json:"api_key,omitempty" jsonschema:"description=API key for authentication with the provider,example=$OPENAI_API_KEY"`
97+ // OAuthToken for providers that use OAuth2 authentication.
98+ OAuthToken *oauth.Token `json:"oauth,omitempty" jsonschema:"description=OAuth2 token for authentication with the provider"`
95 // Marks the provider as disabled.
99 // Marks the provider as disabled.
96 Disable bool `json:"disable,omitempty" jsonschema:"description=Whether this provider is disabled,default=false"`
100 Disable bool `json:"disable,omitempty" jsonschema:"description=Whether this provider is disabled,default=false"`
97
101
@@ -112,6 +116,24 @@ type ProviderConfig struct {
112 Models []catwalk.Model `json:"models,omitempty" jsonschema:"description=List of models available from this provider"`
116 Models []catwalk.Model `json:"models,omitempty" jsonschema:"description=List of models available from this provider"`
113}
117}
114
118
119+func (pc *ProviderConfig) SetupClaudeCode() {
120+ if !strings.HasPrefix(pc.APIKey, "Bearer ") {
121+ pc.APIKey = fmt.Sprintf("Bearer %s", pc.APIKey)
122+ }
123+ pc.SystemPromptPrefix = "You are Claude Code, Anthropic's official CLI for Claude."
124+ pc.ExtraHeaders["anthropic-version"] = "2023-06-01"
125+
126+ value := pc.ExtraHeaders["anthropic-beta"]
127+ const want = "oauth-2025-04-20"
128+ if !strings.Contains(value, want) {
129+ if value != "" {
130+ value += ","
131+ }
132+ value += want
133+ }
134+ pc.ExtraHeaders["anthropic-beta"] = value
135+}
136+
115type MCPType string
137type MCPType string
116
138
117const (
139const (
@@ -448,16 +470,34 @@ func (c *Config) SetConfigField(key string, value any) error {
448 return nil
470 return nil
449}
471}
450
472
451−func (c *Config) SetProviderAPIKey(providerID, apiKey string) error {
473+func (c *Config) SetProviderAPIKey(providerID string, apiKey any) error {
452− // First save to the config file
474+ var providerConfig ProviderConfig
475+ var exists bool
476+ var setKeyOrToken func()
477+
478+ switch v := apiKey.(type) {
479+ case string:
453− err := c.SetConfigField("providers."+providerID+".api_key", apiKey)
480+ if err := c.SetConfigField(fmt.Sprintf("providers.%s.api_key", providerID), v); err != nil {
454− if err != nil {
455− return fmt.Errorf("failed to save API key to config file: %w", err)
481+ return fmt.Errorf("failed to save api key to config file: %w", err)
482+ }
483+ setKeyOrToken = func() { providerConfig.APIKey = v }
484+ case *oauth.Token:
485+ if err := cmp.Or(
486+ c.SetConfigField(fmt.Sprintf("providers.%s.api_key", providerID), v.AccessToken),
487+ c.SetConfigField(fmt.Sprintf("providers.%s.oauth", providerID), v),
488+ ); err != nil {
489+ return err
490+ }
491+ setKeyOrToken = func() {
492+ providerConfig.APIKey = v.AccessToken
493+ providerConfig.OAuthToken = v
494+ providerConfig.SetupClaudeCode()
495+ }
456 }
496 }
457
497
458− providerConfig, exists := c.Providers.Get(providerID)
498+ providerConfig, exists = c.Providers.Get(providerID)
459 if exists {
499 if exists {
460− providerConfig.APIKey = apiKey
500+ setKeyOrToken()
461 c.Providers.Set(providerID, providerConfig)
501 c.Providers.Set(providerID, providerConfig)
462 return nil
502 return nil
463 }
503 }
@@ -477,12 +517,12 @@ func (c *Config) SetProviderAPIKey(providerID, apiKey string) error {
477 Name: foundProvider.Name,
517 Name: foundProvider.Name,
478 BaseURL: foundProvider.APIEndpoint,
518 BaseURL: foundProvider.APIEndpoint,
479 Type: foundProvider.Type,
519 Type: foundProvider.Type,
480− APIKey: apiKey,
481 Disable: false,
520 Disable: false,
482 ExtraHeaders: make(map[string]string),
521 ExtraHeaders: make(map[string]string),
483 ExtraParams: make(map[string]string),
522 ExtraParams: make(map[string]string),
484 Models: foundProvider.Models,
523 Models: foundProvider.Models,
485 }
524 }
525+ setKeyOrToken()
486 } else {
526 } else {
487 return fmt.Errorf("provider with ID %s not found in known providers", providerID)
527 return fmt.Errorf("provider with ID %s not found in known providers", providerID)
488 }
528 }
internal/config/load.go+28
@@ -1,6 +1,7 @@
1package config
1package config
2
2
3import (
3import (
4+ "cmp"
4 "context"
5 "context"
5 "encoding/json"
6 "encoding/json"
6 "fmt"
7 "fmt"
@@ -18,9 +19,11 @@ import (
18 "github.com/charmbracelet/catwalk/pkg/catwalk"
19 "github.com/charmbracelet/catwalk/pkg/catwalk"
19 "github.com/charmbracelet/crush/internal/csync"
20 "github.com/charmbracelet/crush/internal/csync"
20 "github.com/charmbracelet/crush/internal/env"
21 "github.com/charmbracelet/crush/internal/env"
22+ "github.com/charmbracelet/crush/internal/event"
21 "github.com/charmbracelet/crush/internal/fsext"
23 "github.com/charmbracelet/crush/internal/fsext"
22 "github.com/charmbracelet/crush/internal/home"
24 "github.com/charmbracelet/crush/internal/home"
23 "github.com/charmbracelet/crush/internal/log"
25 "github.com/charmbracelet/crush/internal/log"
26+ "github.com/charmbracelet/crush/internal/oauth/claude"
24 powernapConfig "github.com/charmbracelet/x/powernap/pkg/config"
27 powernapConfig "github.com/charmbracelet/x/powernap/pkg/config"
25)
28)
26
29
@@ -133,6 +136,7 @@ func (c *Config) configureProviders(env env.Env, resolver VariableResolver, know
133 knownProviderNames := make(map[string]bool)
136 knownProviderNames := make(map[string]bool)
134 restore := PushPopCrushEnv()
137 restore := PushPopCrushEnv()
135 defer restore()
138 defer restore()
139+
136 for _, p := range knownProviders {
140 for _, p := range knownProviders {
137 knownProviderNames[string(p.ID)] = true
141 knownProviderNames[string(p.ID)] = true
138 config, configExists := c.Providers.Get(string(p.ID))
142 config, configExists := c.Providers.Get(string(p.ID))
@@ -185,6 +189,7 @@ func (c *Config) configureProviders(env env.Env, resolver VariableResolver, know
185 Name: p.Name,
189 Name: p.Name,
186 BaseURL: p.APIEndpoint,
190 BaseURL: p.APIEndpoint,
187 APIKey: p.APIKey,
191 APIKey: p.APIKey,
192+ OAuthToken: config.OAuthToken,
188 Type: p.Type,
193 Type: p.Type,
189 Disable: config.Disable,
194 Disable: config.Disable,
190 SystemPromptPrefix: config.SystemPromptPrefix,
195 SystemPromptPrefix: config.SystemPromptPrefix,
@@ -194,6 +199,29 @@ func (c *Config) configureProviders(env env.Env, resolver VariableResolver, know
194 Models: p.Models,
199 Models: p.Models,
195 }
200 }
196
201
202+ if p.ID == catwalk.InferenceProviderAnthropic && config.OAuthToken != nil {
203+ if config.OAuthToken.IsExpired() {
204+ newToken, err := claude.RefreshToken(context.TODO(), config.OAuthToken.RefreshToken)
205+ if err == nil {
206+ slog.Info("Successfully refreshed Anthropic OAuth token")
207+ config.OAuthToken = newToken
208+ prepared.OAuthToken = newToken
209+ if err := cmp.Or(
210+ c.SetConfigField("providers.anthropic.api_key", newToken.AccessToken),
211+ c.SetConfigField("providers.anthropic.oauth", newToken),
212+ ); err != nil {
213+ return err
214+ }
215+ } else {
216+ slog.Error("Failed to refresh Anthropic OAuth token", "error", err)
217+ event.Error(err)
218+ }
219+ } else {
220+ slog.Info("Using existing non-expired Anthropic OAuth token")
221+ }
222+ prepared.SetupClaudeCode()
223+ }
224+
197 switch p.ID {
225 switch p.ID {
198 // Handle specific providers that require additional configuration
226 // Handle specific providers that require additional configuration
199 case catwalk.InferenceProviderVertexAI:
227 case catwalk.InferenceProviderVertexAI:
internal/oauth/claude/challenge.gonew file+28
@@ -0,0 +1,28 @@
1+package claude
2+
3+import (
4+ "crypto/rand"
5+ "crypto/sha256"
6+ "encoding/base64"
7+ "strings"
8+)
9+
10+// GetChallenge generates a PKCE verifier and its corresponding challenge.
11+func GetChallenge() (verifier string, challenge string, err error) {
12+ bytes := make([]byte, 32)
13+ if _, err := rand.Read(bytes); err != nil {
14+ return "", "", err
15+ }
16+ verifier = encodeBase64(bytes)
17+ hash := sha256.Sum256([]byte(verifier))
18+ challenge = encodeBase64(hash[:])
19+ return verifier, challenge, nil
20+}
21+
22+func encodeBase64(input []byte) (encoded string) {
23+ encoded = base64.StdEncoding.EncodeToString(input)
24+ encoded = strings.ReplaceAll(encoded, "=", "")
25+ encoded = strings.ReplaceAll(encoded, "+", "-")
26+ encoded = strings.ReplaceAll(encoded, "/", "_")
27+ return encoded
28+}
internal/oauth/claude/oauth.gonew file+126
@@ -0,0 +1,126 @@
1+package claude
2+
3+import (
4+ "bytes"
5+ "context"
6+ "encoding/json"
7+ "fmt"
8+ "io"
9+ "net/http"
10+ "net/url"
11+ "strings"
12+ "time"
13+
14+ "github.com/charmbracelet/crush/internal/oauth"
15+)
16+
17+const clientId = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
18+
19+// AuthorizeURL returns the Claude Code Max OAuth2 authorization URL.
20+func AuthorizeURL(verifier, challenge string) (string, error) {
21+ u, err := url.Parse("https://claude.ai/oauth/authorize")
22+ if err != nil {
23+ return "", err
24+ }
25+ q := u.Query()
26+ q.Set("response_type", "code")
27+ q.Set("client_id", clientId)
28+ q.Set("redirect_uri", "https://console.anthropic.com/oauth/code/callback")
29+ q.Set("scope", "org:create_api_key user:profile user:inference")
30+ q.Set("code_challenge", challenge)
31+ q.Set("code_challenge_method", "S256")
32+ q.Set("state", verifier)
33+ u.RawQuery = q.Encode()
34+ return u.String(), nil
35+}
36+
37+// ExchangeToken exchanges the authorization code for an OAuth2 token.
38+func ExchangeToken(ctx context.Context, code, verifier string) (*oauth.Token, error) {
39+ code = strings.TrimSpace(code)
40+ parts := strings.SplitN(code, "#", 2)
41+ pure := parts[0]
42+ state := ""
43+ if len(parts) > 1 {
44+ state = parts[1]
45+ }
46+
47+ reqBody := map[string]string{
48+ "code": pure,
49+ "state": state,
50+ "grant_type": "authorization_code",
51+ "client_id": clientId,
52+ "redirect_uri": "https://console.anthropic.com/oauth/code/callback",
53+ "code_verifier": verifier,
54+ }
55+
56+ resp, err := request(ctx, "POST", "https://console.anthropic.com/v1/oauth/token", reqBody)
57+ if err != nil {
58+ return nil, err
59+ }
60+ defer resp.Body.Close()
61+
62+ body, err := io.ReadAll(resp.Body)
63+ if err != nil {
64+ return nil, err
65+ }
66+
67+ if resp.StatusCode != http.StatusOK {
68+ return nil, fmt.Errorf("claude code max: failed to exchange token: status %d body %q", resp.StatusCode, string(body))
69+ }
70+
71+ var token oauth.Token
72+ if err := json.Unmarshal(body, &token); err != nil {
73+ return nil, err
74+ }
75+ token.SetExpiresAt()
76+ return &token, nil
77+}
78+
79+// RefreshToken refreshes the OAuth2 token using the provided refresh token.
80+func RefreshToken(ctx context.Context, refreshToken string) (*oauth.Token, error) {
81+ reqBody := map[string]string{
82+ "grant_type": "refresh_token",
83+ "refresh_token": refreshToken,
84+ "client_id": clientId,
85+ }
86+
87+ resp, err := request(ctx, "POST", "https://console.anthropic.com/v1/oauth/token", reqBody)
88+ if err != nil {
89+ return nil, err
90+ }
91+ defer resp.Body.Close()
92+
93+ body, err := io.ReadAll(resp.Body)
94+ if err != nil {
95+ return nil, err
96+ }
97+
98+ if resp.StatusCode != http.StatusOK {
99+ return nil, fmt.Errorf("claude code max: failed to refresh token: status %d body %q", resp.StatusCode, string(body))
100+ }
101+
102+ var token oauth.Token
103+ if err := json.Unmarshal(body, &token); err != nil {
104+ return nil, err
105+ }
106+ token.SetExpiresAt()
107+ return &token, nil
108+}
109+
110+func request(ctx context.Context, method, url string, body any) (*http.Response, error) {
111+ date, err := json.Marshal(body)
112+ if err != nil {
113+ return nil, err
114+ }
115+
116+ req, err := http.NewRequestWithContext(ctx, method, url, bytes.NewReader(date))
117+ if err != nil {
118+ return nil, err
119+ }
120+
121+ req.Header.Set("Content-Type", "application/json")
122+ req.Header.Set("User-Agent", "anthropic")
123+
124+ client := &http.Client{Timeout: 30 * time.Second}
125+ return client.Do(req)
126+}
internal/oauth/token.gonew file+23
@@ -0,0 +1,23 @@
1+package oauth
2+
3+import (
4+ "time"
5+)
6+
7+// Token represents an OAuth2 token from Claude Code Max.
8+type Token struct {
9+ AccessToken string `json:"access_token"`
10+ RefreshToken string `json:"refresh_token"`
11+ ExpiresIn int `json:"expires_in"`
12+ ExpiresAt int64 `json:"expires_at"`
13+}
14+
15+// SetExpiresAt calculates and sets the ExpiresAt field based on the current time and ExpiresIn.
16+func (t *Token) SetExpiresAt() {
17+ t.ExpiresAt = time.Now().Add(time.Duration(t.ExpiresIn) * time.Second).Unix()
18+}
19+
20+// IsExpired checks if the token is expired or about to expire (within 10% of its lifetime).
21+func (t *Token) IsExpired() bool {
22+ return time.Now().Unix() >= (t.ExpiresAt - int64(t.ExpiresIn)/10)
23+}
internal/tui/components/chat/splash/keys.go+6−1
@@ -12,7 +12,8 @@ type KeyMap struct {
12 No,
12 No,
13 Tab,
13 Tab,
14 LeftRight,
14 LeftRight,
15+ Back,
15− Back key.Binding
16+ Copy key.Binding
16}
17}
17
18
18func DefaultKeyMap() KeyMap {
19func DefaultKeyMap() KeyMap {
@@ -49,5 +50,9 @@ func DefaultKeyMap() KeyMap {
49 key.WithKeys("esc", "alt+esc"),
50 key.WithKeys("esc", "alt+esc"),
50 key.WithHelp("esc", "back"),
51 key.WithHelp("esc", "back"),
51 ),
52 ),
53+ Copy: key.NewBinding(
54+ key.WithKeys("c"),
55+ key.WithHelp("c", "copy url"),
56+ ),
52 }
57 }
53}
58}
internal/tui/components/chat/splash/splash.go+231−17
@@ -9,6 +9,7 @@ import (
9 "charm.land/bubbles/v2/spinner"
9 "charm.land/bubbles/v2/spinner"
10 tea "charm.land/bubbletea/v2"
10 tea "charm.land/bubbletea/v2"
11 "charm.land/lipgloss/v2"
11 "charm.land/lipgloss/v2"
12+ "github.com/atotto/clipboard"
12 "github.com/charmbracelet/catwalk/pkg/catwalk"
13 "github.com/charmbracelet/catwalk/pkg/catwalk"
13 "github.com/charmbracelet/crush/internal/agent"
14 "github.com/charmbracelet/crush/internal/agent"
14 "github.com/charmbracelet/crush/internal/config"
15 "github.com/charmbracelet/crush/internal/config"
@@ -16,6 +17,7 @@ import (
16 "github.com/charmbracelet/crush/internal/tui/components/chat"
17 "github.com/charmbracelet/crush/internal/tui/components/chat"
17 "github.com/charmbracelet/crush/internal/tui/components/core"
18 "github.com/charmbracelet/crush/internal/tui/components/core"
18 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
19 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
20+ "github.com/charmbracelet/crush/internal/tui/components/dialogs/claude"
19 "github.com/charmbracelet/crush/internal/tui/components/dialogs/models"
21 "github.com/charmbracelet/crush/internal/tui/components/dialogs/models"
20 "github.com/charmbracelet/crush/internal/tui/components/logo"
22 "github.com/charmbracelet/crush/internal/tui/components/logo"
21 lspcomponent "github.com/charmbracelet/crush/internal/tui/components/lsp"
23 lspcomponent "github.com/charmbracelet/crush/internal/tui/components/lsp"
@@ -41,6 +43,18 @@ type Splash interface {
41
43
42 // IsAPIKeyValid returns whether the API key is valid
44 // IsAPIKeyValid returns whether the API key is valid
43 IsAPIKeyValid() bool
45 IsAPIKeyValid() bool
46+
47+ // IsShowingClaudeAuthMethodChooser returns whether showing Claude auth method chooser
48+ IsShowingClaudeAuthMethodChooser() bool
49+
50+ // IsShowingClaudeOAuth2 returns whether showing Claude OAuth2 flow
51+ IsShowingClaudeOAuth2() bool
52+
53+ // IsClaudeOAuthURLState returns whether in OAuth URL state
54+ IsClaudeOAuthURLState() bool
55+
56+ // IsClaudeOAuthComplete returns whether Claude OAuth flow is complete
57+ IsClaudeOAuthComplete() bool
44}
58}
45
59
46const (
60const (
@@ -72,6 +86,12 @@ type splashCmp struct {
72 selectedModel *models.ModelOption
86 selectedModel *models.ModelOption
73 isAPIKeyValid bool
87 isAPIKeyValid bool
74 apiKeyValue string
88 apiKeyValue string
89+
90+ // Claude state
91+ claudeAuthMethodChooser *claude.AuthMethodChooser
92+ claudeOAuth2 *claude.OAuth2
93+ showClaudeAuthMethodChooser bool
94+ showClaudeOAuth2 bool
75}
95}
76
96
77func New() Splash {
97func New() Splash {
@@ -97,6 +117,9 @@ func New() Splash {
97 modelList: modelList,
117 modelList: modelList,
98 apiKeyInput: apiKeyInput,
118 apiKeyInput: apiKeyInput,
99 selectedNo: false,
119 selectedNo: false,
120+
121+ claudeAuthMethodChooser: claude.NewAuthMethodChooser(),
122+ claudeOAuth2: claude.NewOAuth2(),
100 }
123 }
101}
124}
102
125
@@ -115,7 +138,12 @@ func (s *splashCmp) GetSize() (int, int) {
115
138
116// Init implements SplashPage.
139// Init implements SplashPage.
117func (s *splashCmp) Init() tea.Cmd {
140func (s *splashCmp) Init() tea.Cmd {
141+ return tea.Batch(
142+ s.modelList.Init(),
118− return tea.Batch(s.modelList.Init(), s.apiKeyInput.Init())
143+ s.apiKeyInput.Init(),
144+ s.claudeAuthMethodChooser.Init(),
145+ s.claudeOAuth2.Init(),
146+ )
119}
147}
120
148
121// SetSize implements SplashPage.
149// SetSize implements SplashPage.
@@ -131,6 +159,7 @@ func (s *splashCmp) SetSize(width int, height int) tea.Cmd {
131 s.listHeight = s.height - lipgloss.Height(s.logoRendered) - (SplashScreenPaddingY * 2) - s.logoGap() - 2
159 s.listHeight = s.height - lipgloss.Height(s.logoRendered) - (SplashScreenPaddingY * 2) - s.logoGap() - 2
132 listWidth := min(60, width)
160 listWidth := min(60, width)
133 s.apiKeyInput.SetWidth(width - 2)
161 s.apiKeyInput.SetWidth(width - 2)
162+ s.claudeAuthMethodChooser.SetWidth(min(width-2, 60))
134 return s.modelList.SetSize(listWidth, s.listHeight)
163 return s.modelList.SetSize(listWidth, s.listHeight)
135}
164}
136
165
@@ -139,6 +168,28 @@ func (s *splashCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
139 switch msg := msg.(type) {
168 switch msg := msg.(type) {
140 case tea.WindowSizeMsg:
169 case tea.WindowSizeMsg:
141 return s, s.SetSize(msg.Width, msg.Height)
170 return s, s.SetSize(msg.Width, msg.Height)
171+ case claude.ValidationCompletedMsg:
172+ var cmds []tea.Cmd
173+ u, cmd := s.claudeOAuth2.Update(msg)
174+ s.claudeOAuth2 = u.(*claude.OAuth2)
175+ cmds = append(cmds, cmd)
176+
177+ if msg.State == claude.OAuthValidationStateValid {
178+ cmds = append(
179+ cmds,
180+ s.saveAPIKeyAndContinue(msg.Token, false),
181+ func() tea.Msg {
182+ time.Sleep(5 * time.Second)
183+ return claude.AuthenticationCompleteMsg{}
184+ },
185+ )
186+ }
187+
188+ return s, tea.Batch(cmds...)
189+ case claude.AuthenticationCompleteMsg:
190+ s.showClaudeAuthMethodChooser = false
191+ s.showClaudeOAuth2 = false
192+ return s, util.CmdHandler(OnboardingCompleteMsg{})
142 case models.APIKeyStateChangeMsg:
193 case models.APIKeyStateChangeMsg:
143 u, cmd := s.apiKeyInput.Update(msg)
194 u, cmd := s.apiKeyInput.Update(msg)
144 s.apiKeyInput = u.(*models.APIKeyInput)
195 s.apiKeyInput = u.(*models.APIKeyInput)
@@ -150,16 +201,48 @@ func (s *splashCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
150 return s, cmd
201 return s, cmd
151 case SubmitAPIKeyMsg:
202 case SubmitAPIKeyMsg:
152 if s.isAPIKeyValid {
203 if s.isAPIKeyValid {
153− return s, s.saveAPIKeyAndContinue(s.apiKeyValue)
204+ return s, s.saveAPIKeyAndContinue(s.apiKeyValue, true)
154 }
205 }
155 case tea.KeyPressMsg:
206 case tea.KeyPressMsg:
156 switch {
207 switch {
208+ case key.Matches(msg, s.keyMap.Copy):
209+ if s.showClaudeOAuth2 && s.claudeOAuth2.State == claude.OAuthStateURL {
210+ return s, tea.Sequence(
211+ tea.SetClipboard(s.claudeOAuth2.URL),
212+ func() tea.Msg {
213+ _ = clipboard.WriteAll(s.claudeOAuth2.URL)
214+ return nil
215+ },
216+ util.ReportInfo("URL copied to clipboard"),
217+ )
218+ } else if s.showClaudeAuthMethodChooser {
219+ u, cmd := s.claudeAuthMethodChooser.Update(msg)
220+ s.claudeAuthMethodChooser = u.(*claude.AuthMethodChooser)
221+ return s, cmd
222+ } else if s.showClaudeOAuth2 {
223+ u, cmd := s.claudeOAuth2.Update(msg)
224+ s.claudeOAuth2 = u.(*claude.OAuth2)
225+ return s, cmd
226+ }
157 case key.Matches(msg, s.keyMap.Back):
227 case key.Matches(msg, s.keyMap.Back):
228+ if s.showClaudeAuthMethodChooser {
229+ s.claudeAuthMethodChooser.SetDefaults()
230+ s.showClaudeAuthMethodChooser = false
231+ return s, nil
232+ }
233+ if s.showClaudeOAuth2 {
234+ s.claudeOAuth2.SetDefaults()
235+ s.showClaudeOAuth2 = false
236+ s.showClaudeAuthMethodChooser = true
237+ return s, nil
238+ }
158 if s.isAPIKeyValid {
239 if s.isAPIKeyValid {
159 return s, nil
240 return s, nil
160 }
241 }
161 if s.needsAPIKey {
242 if s.needsAPIKey {
162− // Go back to model selection
243+ if s.selectedModel.Provider.ID == catwalk.InferenceProviderAnthropic {
244+ s.showClaudeAuthMethodChooser = true
245+ }
163 s.needsAPIKey = false
246 s.needsAPIKey = false
164 s.selectedModel = nil
247 s.selectedModel = nil
165 s.isAPIKeyValid = false
248 s.isAPIKeyValid = false
@@ -168,8 +251,32 @@ func (s *splashCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
168 return s, nil
251 return s, nil
169 }
252 }
170 case key.Matches(msg, s.keyMap.Select):
253 case key.Matches(msg, s.keyMap.Select):
254+ if s.showClaudeAuthMethodChooser {
255+ selectedItem := s.modelList.SelectedModel()
256+ if selectedItem == nil {
257+ return s, nil
258+ }
259+
260+ switch s.claudeAuthMethodChooser.State {
261+ case claude.AuthMethodAPIKey:
262+ s.showClaudeAuthMethodChooser = false
263+ s.needsAPIKey = true
264+ s.selectedModel = selectedItem
265+ s.apiKeyInput.SetProviderName(selectedItem.Provider.Name)
266+ case claude.AuthMethodOAuth2:
267+ s.selectedModel = selectedItem
268+ s.showClaudeAuthMethodChooser = false
269+ s.showClaudeOAuth2 = true
270+ }
271+ return s, nil
272+ }
273+ if s.showClaudeOAuth2 {
274+ m2, cmd2 := s.claudeOAuth2.ValidationConfirm()
275+ s.claudeOAuth2 = m2.(*claude.OAuth2)
276+ return s, cmd2
277+ }
171 if s.isAPIKeyValid {
278 if s.isAPIKeyValid {
172− return s, s.saveAPIKeyAndContinue(s.apiKeyValue)
279+ return s, s.saveAPIKeyAndContinue(s.apiKeyValue, true)
173 }
280 }
174 if s.isOnboarding && !s.needsAPIKey {
281 if s.isOnboarding && !s.needsAPIKey {
175 selectedItem := s.modelList.SelectedModel()
282 selectedItem := s.modelList.SelectedModel()
@@ -181,6 +288,10 @@ func (s *splashCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
181 s.isOnboarding = false
288 s.isOnboarding = false
182 return s, tea.Batch(cmd, util.CmdHandler(OnboardingCompleteMsg{}))
289 return s, tea.Batch(cmd, util.CmdHandler(OnboardingCompleteMsg{}))
183 } else {
290 } else {
291+ if selectedItem.Provider.ID == catwalk.InferenceProviderAnthropic {
292+ s.showClaudeAuthMethodChooser = true
293+ return s, nil
294+ }
184 // Provider not configured, show API key input
295 // Provider not configured, show API key input
185 s.needsAPIKey = true
296 s.needsAPIKey = true
186 s.selectedModel = selectedItem
297 s.selectedModel = selectedItem
@@ -232,6 +343,10 @@ func (s *splashCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
232 return s, s.initializeProject()
343 return s, s.initializeProject()
233 }
344 }
234 case key.Matches(msg, s.keyMap.Tab, s.keyMap.LeftRight):
345 case key.Matches(msg, s.keyMap.Tab, s.keyMap.LeftRight):
346+ if s.showClaudeAuthMethodChooser {
347+ s.claudeAuthMethodChooser.ToggleChoice()
348+ return s, nil
349+ }
235 if s.needsAPIKey {
350 if s.needsAPIKey {
236 u, cmd := s.apiKeyInput.Update(msg)
351 u, cmd := s.apiKeyInput.Update(msg)
237 s.apiKeyInput = u.(*models.APIKeyInput)
352 s.apiKeyInput = u.(*models.APIKeyInput)
@@ -272,7 +387,15 @@ func (s *splashCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
272 return s, s.initializeProject()
387 return s, s.initializeProject()
273 }
388 }
274 default:
389 default:
390+ if s.showClaudeAuthMethodChooser {
391+ u, cmd := s.claudeAuthMethodChooser.Update(msg)
392+ s.claudeAuthMethodChooser = u.(*claude.AuthMethodChooser)
393+ return s, cmd
394+ } else if s.showClaudeOAuth2 {
395+ u, cmd := s.claudeOAuth2.Update(msg)
396+ s.claudeOAuth2 = u.(*claude.OAuth2)
397+ return s, cmd
275− if s.needsAPIKey {
398+ } else if s.needsAPIKey {
276 u, cmd := s.apiKeyInput.Update(msg)
399 u, cmd := s.apiKeyInput.Update(msg)
277 s.apiKeyInput = u.(*models.APIKeyInput)
400 s.apiKeyInput = u.(*models.APIKeyInput)
278 return s, cmd
401 return s, cmd
@@ -283,7 +406,11 @@ func (s *splashCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
283 }
406 }
284 }
407 }
285 case tea.PasteMsg:
408 case tea.PasteMsg:
409+ if s.showClaudeOAuth2 {
410+ u, cmd := s.claudeOAuth2.Update(msg)
411+ s.claudeOAuth2 = u.(*claude.OAuth2)
412+ return s, cmd
286− if s.needsAPIKey {
413+ } else if s.needsAPIKey {
287 u, cmd := s.apiKeyInput.Update(msg)
414 u, cmd := s.apiKeyInput.Update(msg)
288 s.apiKeyInput = u.(*models.APIKeyInput)
415 s.apiKeyInput = u.(*models.APIKeyInput)
289 return s, cmd
416 return s, cmd
@@ -293,14 +420,20 @@ func (s *splashCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
293 return s, cmd
420 return s, cmd
294 }
421 }
295 case spinner.TickMsg:
422 case spinner.TickMsg:
423+ if s.showClaudeOAuth2 {
296− u, cmd := s.apiKeyInput.Update(msg)
424+ u, cmd := s.claudeOAuth2.Update(msg)
425+ s.claudeOAuth2 = u.(*claude.OAuth2)
426+ return s, cmd
427+ } else {
428+ u, cmd := s.apiKeyInput.Update(msg)
297− s.apiKeyInput = u.(*models.APIKeyInput)
429+ s.apiKeyInput = u.(*models.APIKeyInput)
298− return s, cmd
430+ return s, cmd
431+ }
299 }
432 }
300 return s, nil
433 return s, nil
301}
434}
302
435
303−func (s *splashCmp) saveAPIKeyAndContinue(apiKey string) tea.Cmd {
436+func (s *splashCmp) saveAPIKeyAndContinue(apiKey any, close bool) tea.Cmd {
304 if s.selectedModel == nil {
437 if s.selectedModel == nil {
305 return nil
438 return nil
306 }
439 }
@@ -318,7 +451,10 @@ func (s *splashCmp) saveAPIKeyAndContinue(apiKey string) tea.Cmd {
318 s.selectedModel = nil
451 s.selectedModel = nil
319 s.isAPIKeyValid = false
452 s.isAPIKeyValid = false
320
453
454+ if close {
321− return tea.Batch(cmd, util.CmdHandler(OnboardingCompleteMsg{}))
455+ return tea.Batch(cmd, util.CmdHandler(OnboardingCompleteMsg{}))
456+ }
457+ return cmd
322}
458}
323
459
324func (s *splashCmp) initializeProject() tea.Cmd {
460func (s *splashCmp) initializeProject() tea.Cmd {
@@ -426,7 +562,39 @@ func (s *splashCmp) isProviderConfigured(providerID string) bool {
426func (s *splashCmp) View() string {
562func (s *splashCmp) View() string {
427 t := styles.CurrentTheme()
563 t := styles.CurrentTheme()
428 var content string
564 var content string
565+ if s.showClaudeAuthMethodChooser {
566+ remainingHeight := s.height - lipgloss.Height(s.logoRendered) - (SplashScreenPaddingY * 2)
567+ chooserView := s.claudeAuthMethodChooser.View()
568+ authMethodSelector := t.S().Base.AlignVertical(lipgloss.Bottom).Height(remainingHeight).Render(
569+ lipgloss.JoinVertical(
570+ lipgloss.Left,
571+ t.S().Base.PaddingLeft(1).Foreground(t.Primary).Render("Let's Auth Anthropic"),
572+ "",
573+ chooserView,
574+ ),
575+ )
576+ content = lipgloss.JoinVertical(
577+ lipgloss.Left,
578+ s.logoRendered,
579+ authMethodSelector,
580+ )
581+ } else if s.showClaudeOAuth2 {
582+ remainingHeight := s.height - lipgloss.Height(s.logoRendered) - (SplashScreenPaddingY * 2)
583+ oauth2View := s.claudeOAuth2.View()
584+ oauthSelector := t.S().Base.AlignVertical(lipgloss.Bottom).Height(remainingHeight).Render(
585+ lipgloss.JoinVertical(
586+ lipgloss.Left,
587+ t.S().Base.PaddingLeft(1).Foreground(t.Primary).Render("Let's Auth Anthropic"),
588+ "",
589+ oauth2View,
590+ ),
591+ )
592+ content = lipgloss.JoinVertical(
593+ lipgloss.Left,
594+ s.logoRendered,
595+ oauthSelector,
596+ )
429− if s.needsAPIKey {
597+ } else if s.needsAPIKey {
430 remainingHeight := s.height - lipgloss.Height(s.logoRendered) - (SplashScreenPaddingY * 2)
598 remainingHeight := s.height - lipgloss.Height(s.logoRendered) - (SplashScreenPaddingY * 2)
431 apiKeyView := t.S().Base.PaddingLeft(1).Render(s.apiKeyInput.View())
599 apiKeyView := t.S().Base.PaddingLeft(1).Render(s.apiKeyInput.View())
432 apiKeySelector := t.S().Base.AlignVertical(lipgloss.Bottom).Height(remainingHeight).Render(
600 apiKeySelector := t.S().Base.AlignVertical(lipgloss.Bottom).Height(remainingHeight).Render(
@@ -524,6 +692,16 @@ func (s *splashCmp) View() string {
524}
692}
525
693
526func (s *splashCmp) Cursor() *tea.Cursor {
694func (s *splashCmp) Cursor() *tea.Cursor {
695+ if s.showClaudeAuthMethodChooser {
696+ return nil
697+ }
698+ if s.showClaudeOAuth2 {
699+ if cursor := s.claudeOAuth2.CodeInput.Cursor(); cursor != nil {
700+ cursor.Y += 2 // FIXME(@andreynering): Why do we need this?
701+ return s.moveCursor(cursor)
702+ }
703+ return nil
704+ }
527 if s.needsAPIKey {
705 if s.needsAPIKey {
528 cursor := s.apiKeyInput.Cursor()
706 cursor := s.apiKeyInput.Cursor()
529 if cursor != nil {
707 if cursor != nil {
@@ -596,17 +774,23 @@ func (s *splashCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor {
596 }
774 }
597 // Calculate the correct Y offset based on current state
775 // Calculate the correct Y offset based on current state
598 logoHeight := lipgloss.Height(s.logoRendered)
776 logoHeight := lipgloss.Height(s.logoRendered)
599− if s.needsAPIKey {
777+ if s.needsAPIKey || s.showClaudeOAuth2 {
778+ var view string
779+ if s.needsAPIKey {
780+ view = s.apiKeyInput.View()
781+ } else {
782+ view = s.claudeOAuth2.View()
783+ }
600 infoSectionHeight := lipgloss.Height(s.infoSection())
784 infoSectionHeight := lipgloss.Height(s.infoSection())
601 baseOffset := logoHeight + SplashScreenPaddingY + infoSectionHeight
785 baseOffset := logoHeight + SplashScreenPaddingY + infoSectionHeight
602− remainingHeight := s.height - baseOffset - lipgloss.Height(s.apiKeyInput.View()) - SplashScreenPaddingY
786+ remainingHeight := s.height - baseOffset - lipgloss.Height(view) - SplashScreenPaddingY
603 offset := baseOffset + remainingHeight
787 offset := baseOffset + remainingHeight
604 cursor.Y += offset
788 cursor.Y += offset
605− cursor.X = cursor.X + 1
789+ cursor.X += 1
606 } else if s.isOnboarding {
790 } else if s.isOnboarding {
607 offset := logoHeight + SplashScreenPaddingY + s.logoGap() + 2
791 offset := logoHeight + SplashScreenPaddingY + s.logoGap() + 2
608 cursor.Y += offset
792 cursor.Y += offset
609− cursor.X = cursor.X + 1
793+ cursor.X += 1
610 }
794 }
611
795
612 return cursor
796 return cursor
@@ -621,7 +805,21 @@ func (s *splashCmp) logoGap() int {
621
805
622// Bindings implements SplashPage.
806// Bindings implements SplashPage.
623func (s *splashCmp) Bindings() []key.Binding {
807func (s *splashCmp) Bindings() []key.Binding {
808+ if s.showClaudeAuthMethodChooser {
809+ return []key.Binding{
810+ s.keyMap.Select,
811+ s.keyMap.Tab,
812+ s.keyMap.Back,
813+ }
814+ } else if s.showClaudeOAuth2 {
815+ bindings := []key.Binding{
816+ s.keyMap.Select,
817+ }
818+ if s.claudeOAuth2.State == claude.OAuthStateURL {
819+ bindings = append(bindings, s.keyMap.Copy)
820+ }
821+ return bindings
624− if s.needsAPIKey {
822+ } else if s.needsAPIKey {
625 return []key.Binding{
823 return []key.Binding{
626 s.keyMap.Select,
824 s.keyMap.Select,
627 s.keyMap.Back,
825 s.keyMap.Back,
@@ -726,3 +924,19 @@ func (s *splashCmp) IsShowingAPIKey() bool {
726func (s *splashCmp) IsAPIKeyValid() bool {
924func (s *splashCmp) IsAPIKeyValid() bool {
727 return s.isAPIKeyValid
925 return s.isAPIKeyValid
728}
926}
927+
928+func (s *splashCmp) IsShowingClaudeAuthMethodChooser() bool {
929+ return s.showClaudeAuthMethodChooser
930+}
931+
932+func (s *splashCmp) IsShowingClaudeOAuth2() bool {
933+ return s.showClaudeOAuth2
934+}
935+
936+func (s *splashCmp) IsClaudeOAuthURLState() bool {
937+ return s.showClaudeOAuth2 && s.claudeOAuth2.State == claude.OAuthStateURL
938+}
939+
940+func (s *splashCmp) IsClaudeOAuthComplete() bool {
941+ return s.showClaudeOAuth2 && s.claudeOAuth2.State == claude.OAuthStateCode && s.claudeOAuth2.ValidationState == claude.OAuthValidationStateValid
942+}
internal/tui/components/dialogs/claude/method.gonew file+115
@@ -0,0 +1,115 @@
1+package claude
2+
3+import (
4+ tea "charm.land/bubbletea/v2"
5+ "charm.land/lipgloss/v2"
6+ "github.com/charmbracelet/crush/internal/tui/styles"
7+ "github.com/charmbracelet/crush/internal/tui/util"
8+)
9+
10+type AuthMethod int
11+
12+const (
13+ AuthMethodAPIKey AuthMethod = iota
14+ AuthMethodOAuth2
15+)
16+
17+type AuthMethodChooser struct {
18+ State AuthMethod
19+ width int
20+ isOnboarding bool
21+}
22+
23+func NewAuthMethodChooser() *AuthMethodChooser {
24+ return &AuthMethodChooser{
25+ State: AuthMethodOAuth2,
26+ }
27+}
28+
29+func (a *AuthMethodChooser) Init() tea.Cmd {
30+ return nil
31+}
32+
33+func (a *AuthMethodChooser) Update(msg tea.Msg) (util.Model, tea.Cmd) {
34+ return a, nil
35+}
36+
37+func (a *AuthMethodChooser) View() string {
38+ t := styles.CurrentTheme()
39+
40+ white := lipgloss.NewStyle().Foreground(t.White)
41+ primary := lipgloss.NewStyle().Foreground(t.Primary)
42+ success := lipgloss.NewStyle().Foreground(t.Success)
43+
44+ titleStyle := white
45+ if a.isOnboarding {
46+ titleStyle = primary
47+ }
48+
49+ question := lipgloss.
50+ NewStyle().
51+ Margin(0, 1).
52+ Render(titleStyle.Render("How would you like to authenticate with ") + success.Render("Anthropic") + titleStyle.Render("?"))
53+
54+ squareWidth := (a.width - 2) / 2
55+ squareHeight := squareWidth / 3
56+ if isOdd(squareHeight) {
57+ squareHeight++
58+ }
59+
60+ square := lipgloss.NewStyle().
61+ Width(squareWidth).
62+ Height(squareHeight).
63+ Margin(0, 0).
64+ Border(lipgloss.RoundedBorder())
65+
66+ squareText := lipgloss.NewStyle().
67+ Width(squareWidth - 2).
68+ Height(squareHeight).
69+ Align(lipgloss.Center).
70+ AlignVertical(lipgloss.Center)
71+
72+ oauthBorder := t.AuthBorderSelected
73+ oauthText := t.AuthTextSelected
74+ apiKeyBorder := t.AuthBorderUnselected
75+ apiKeyText := t.AuthTextUnselected
76+
77+ if a.State == AuthMethodAPIKey {
78+ oauthBorder, apiKeyBorder = apiKeyBorder, oauthBorder
79+ oauthText, apiKeyText = apiKeyText, oauthText
80+ }
81+
82+ return lipgloss.JoinVertical(
83+ lipgloss.Left,
84+ question,
85+ "",
86+ lipgloss.JoinHorizontal(
87+ lipgloss.Center,
88+ square.MarginLeft(1).
89+ Inherit(oauthBorder).Render(squareText.Inherit(oauthText).Render("Claude Account\nwith Subscription")),
90+ square.MarginRight(1).
91+ Inherit(apiKeyBorder).Render(squareText.Inherit(apiKeyText).Render("API Key")),
92+ ),
93+ )
94+}
95+
96+func (a *AuthMethodChooser) SetDefaults() {
97+ a.State = AuthMethodOAuth2
98+}
99+
100+func (a *AuthMethodChooser) SetWidth(w int) {
101+ a.width = w
102+}
103+
104+func (a *AuthMethodChooser) ToggleChoice() {
105+ switch a.State {
106+ case AuthMethodAPIKey:
107+ a.State = AuthMethodOAuth2
108+ case AuthMethodOAuth2:
109+ a.State = AuthMethodAPIKey
110+ }
111+}
112+
113+func isOdd(n int) bool {
114+ return n%2 != 0
115+}
internal/tui/components/dialogs/claude/oauth.gonew file+267
@@ -0,0 +1,267 @@
1+package claude
2+
3+import (
4+ "context"
5+ "fmt"
6+ "net/url"
7+
8+ "charm.land/bubbles/v2/spinner"
9+ "charm.land/bubbles/v2/textinput"
10+ tea "charm.land/bubbletea/v2"
11+ "charm.land/lipgloss/v2"
12+ "github.com/charmbracelet/crush/internal/oauth"
13+ "github.com/charmbracelet/crush/internal/oauth/claude"
14+ "github.com/charmbracelet/crush/internal/tui/styles"
15+ "github.com/charmbracelet/crush/internal/tui/util"
16+ "github.com/pkg/browser"
17+ "github.com/zeebo/xxh3"
18+)
19+
20+type OAuthState int
21+
22+const (
23+ OAuthStateURL OAuthState = iota
24+ OAuthStateCode
25+)
26+
27+type OAuthValidationState int
28+
29+const (
30+ OAuthValidationStateNone OAuthValidationState = iota
31+ OAuthValidationStateVerifying
32+ OAuthValidationStateValid
33+ OAuthValidationStateError
34+)
35+
36+type ValidationCompletedMsg struct {
37+ State OAuthValidationState
38+ Token *oauth.Token
39+}
40+
41+type AuthenticationCompleteMsg struct{}
42+
43+type OAuth2 struct {
44+ State OAuthState
45+ ValidationState OAuthValidationState
46+ width int
47+ isOnboarding bool
48+
49+ // URL page
50+ err error
51+ verifier string
52+ challenge string
53+ URL string
54+ urlId string
55+ token *oauth.Token
56+
57+ // Code input page
58+ CodeInput textinput.Model
59+ spinner spinner.Model
60+}
61+
62+func NewOAuth2() *OAuth2 {
63+ return &OAuth2{
64+ State: OAuthStateURL,
65+ }
66+}
67+
68+func (o *OAuth2) Init() tea.Cmd {
69+ t := styles.CurrentTheme()
70+
71+ verifier, challenge, err := claude.GetChallenge()
72+ if err != nil {
73+ o.err = err
74+ return nil
75+ }
76+
77+ url, err := claude.AuthorizeURL(verifier, challenge)
78+ if err != nil {
79+ o.err = err
80+ return nil
81+ }
82+
83+ o.verifier = verifier
84+ o.challenge = challenge
85+ o.URL = url
86+
87+ h := xxh3.New()
88+ _, _ = h.WriteString(o.URL)
89+ o.urlId = fmt.Sprintf("id=%x", h.Sum(nil))
90+
91+ o.CodeInput = textinput.New()
92+ o.CodeInput.Placeholder = "Paste or type"
93+ o.CodeInput.SetVirtualCursor(false)
94+ o.CodeInput.Prompt = "> "
95+ o.CodeInput.SetStyles(t.S().TextInput)
96+ o.CodeInput.SetWidth(50)
97+
98+ o.spinner = spinner.New(
99+ spinner.WithSpinner(spinner.Dot),
100+ spinner.WithStyle(t.S().Base.Foreground(t.Green)),
101+ )
102+
103+ return nil
104+}
105+
106+func (o *OAuth2) Update(msg tea.Msg) (util.Model, tea.Cmd) {
107+ var cmds []tea.Cmd
108+
109+ switch msg := msg.(type) {
110+ case ValidationCompletedMsg:
111+ o.ValidationState = msg.State
112+ o.token = msg.Token
113+ switch o.ValidationState {
114+ case OAuthValidationStateError:
115+ o.CodeInput.Focus()
116+ }
117+ o.updatePrompt()
118+ }
119+
120+ if o.ValidationState == OAuthValidationStateVerifying {
121+ var cmd tea.Cmd
122+ o.spinner, cmd = o.spinner.Update(msg)
123+ cmds = append(cmds, cmd)
124+ o.updatePrompt()
125+ }
126+ {
127+ var cmd tea.Cmd
128+ o.CodeInput, cmd = o.CodeInput.Update(msg)
129+ cmds = append(cmds, cmd)
130+ }
131+
132+ return o, tea.Batch(cmds...)
133+}
134+
135+func (o *OAuth2) ValidationConfirm() (util.Model, tea.Cmd) {
136+ var cmds []tea.Cmd
137+
138+ switch {
139+ case o.State == OAuthStateURL:
140+ _ = browser.OpenURL(o.URL)
141+ o.State = OAuthStateCode
142+ cmds = append(cmds, o.CodeInput.Focus())
143+ case o.ValidationState == OAuthValidationStateNone || o.ValidationState == OAuthValidationStateError:
144+ o.CodeInput.Blur()
145+ o.ValidationState = OAuthValidationStateVerifying
146+ cmds = append(cmds, o.spinner.Tick, o.validateCode)
147+ case o.ValidationState == OAuthValidationStateValid:
148+ cmds = append(cmds, func() tea.Msg { return AuthenticationCompleteMsg{} })
149+ }
150+
151+ o.updatePrompt()
152+ return o, tea.Batch(cmds...)
153+}
154+
155+func (o *OAuth2) View() string {
156+ t := styles.CurrentTheme()
157+
158+ whiteStyle := lipgloss.NewStyle().Foreground(t.White)
159+ primaryStyle := lipgloss.NewStyle().Foreground(t.Primary)
160+ successStyle := lipgloss.NewStyle().Foreground(t.Success)
161+ errorStyle := lipgloss.NewStyle().Foreground(t.Error)
162+
163+ titleStyle := whiteStyle
164+ if o.isOnboarding {
165+ titleStyle = primaryStyle
166+ }
167+
168+ switch {
169+ case o.err != nil:
170+ return lipgloss.NewStyle().
171+ Margin(0, 1).
172+ Foreground(t.Error).
173+ Render(o.err.Error())
174+ case o.State == OAuthStateURL:
175+ heading := lipgloss.
176+ NewStyle().
177+ Margin(0, 1).
178+ Render(titleStyle.Render("Press enter key to open the following ") + successStyle.Render("URL") + titleStyle.Render(":"))
179+
180+ return lipgloss.JoinVertical(
181+ lipgloss.Left,
182+ heading,
183+ "",
184+ lipgloss.NewStyle().
185+ Margin(0, 1).
186+ Foreground(t.FgMuted).
187+ Hyperlink(o.URL, o.urlId).
188+ Render(o.displayUrl()),
189+ )
190+ case o.State == OAuthStateCode:
191+ var heading string
192+
193+ switch o.ValidationState {
194+ case OAuthValidationStateNone:
195+ st := lipgloss.NewStyle().Margin(0, 1)
196+ heading = st.Render(titleStyle.Render("Enter the ") + successStyle.Render("code") + titleStyle.Render(" you received."))
197+ case OAuthValidationStateVerifying:
198+ heading = titleStyle.Margin(0, 1).Render("Verifying...")
199+ case OAuthValidationStateValid:
200+ heading = successStyle.Margin(0, 1).Render("Validated.")
201+ case OAuthValidationStateError:
202+ heading = errorStyle.Margin(0, 1).Render("Invalid. Try again?")
203+ }
204+
205+ return lipgloss.JoinVertical(
206+ lipgloss.Left,
207+ heading,
208+ "",
209+ " "+o.CodeInput.View(),
210+ )
211+ default:
212+ panic("claude oauth2: invalid state")
213+ }
214+}
215+
216+func (o *OAuth2) SetDefaults() {
217+ o.State = OAuthStateURL
218+ o.ValidationState = OAuthValidationStateNone
219+ o.CodeInput.SetValue("")
220+ o.err = nil
221+}
222+
223+func (o *OAuth2) SetWidth(w int) {
224+ o.width = w
225+ o.CodeInput.SetWidth(w - 4)
226+}
227+
228+func (o *OAuth2) SetError(err error) {
229+ o.err = err
230+}
231+
232+func (o *OAuth2) validateCode() tea.Msg {
233+ token, err := claude.ExchangeToken(context.Background(), o.CodeInput.Value(), o.verifier)
234+ if err != nil || token == nil {
235+ return ValidationCompletedMsg{State: OAuthValidationStateError}
236+ }
237+ return ValidationCompletedMsg{State: OAuthValidationStateValid, Token: token}
238+}
239+
240+func (o *OAuth2) updatePrompt() {
241+ switch o.ValidationState {
242+ case OAuthValidationStateNone:
243+ o.CodeInput.Prompt = "> "
244+ case OAuthValidationStateVerifying:
245+ o.CodeInput.Prompt = o.spinner.View() + " "
246+ case OAuthValidationStateValid:
247+ o.CodeInput.Prompt = styles.CheckIcon + " "
248+ case OAuthValidationStateError:
249+ o.CodeInput.Prompt = styles.ErrorIcon + " "
250+ }
251+}
252+
253+// Remove query params for display
254+// e.g., "https://claude.ai/oauth/authorize?..." -> "https://claude.ai/oauth/authorize..."
255+func (o *OAuth2) displayUrl() string {
256+ parsed, err := url.Parse(o.URL)
257+ if err != nil {
258+ return o.URL
259+ }
260+
261+ if parsed.RawQuery != "" {
262+ parsed.RawQuery = ""
263+ return parsed.String() + "..."
264+ }
265+
266+ return o.URL
267+}
internal/tui/components/dialogs/models/keys.go+66
@@ -8,11 +8,17 @@ type KeyMap struct {
8 Select,
8 Select,
9 Next,
9 Next,
10 Previous,
10 Previous,
11+ Choose,
11 Tab,
12 Tab,
12 Close key.Binding
13 Close key.Binding
13
14
14 isAPIKeyHelp bool
15 isAPIKeyHelp bool
15 isAPIKeyValid bool
16 isAPIKeyValid bool
17+
18+ isClaudeAuthChoiseHelp bool
19+ isClaudeOAuthHelp bool
20+ isClaudeOAuthURLState bool
21+ isClaudeOAuthHelpComplete bool
16}
22}
17
23
18func DefaultKeyMap() KeyMap {
24func DefaultKeyMap() KeyMap {
@@ -29,6 +35,10 @@ func DefaultKeyMap() KeyMap {
29 key.WithKeys("up", "ctrl+p"),
35 key.WithKeys("up", "ctrl+p"),
30 key.WithHelp("↑", "previous item"),
36 key.WithHelp("↑", "previous item"),
31 ),
37 ),
38+ Choose: key.NewBinding(
39+ key.WithKeys("left", "right", "h", "l"),
40+ key.WithHelp("←→", "choose"),
41+ ),
32 Tab: key.NewBinding(
42 Tab: key.NewBinding(
33 key.WithKeys("tab"),
43 key.WithKeys("tab"),
34 key.WithHelp("tab", "toggle type"),
44 key.WithHelp("tab", "toggle type"),
@@ -64,8 +74,64 @@ func (k KeyMap) FullHelp() [][]key.Binding {
64
74
65// ShortHelp implements help.KeyMap.
75// ShortHelp implements help.KeyMap.
66func (k KeyMap) ShortHelp() []key.Binding {
76func (k KeyMap) ShortHelp() []key.Binding {
77+ if k.isClaudeAuthChoiseHelp {
78+ return []key.Binding{
79+ key.NewBinding(
80+ key.WithKeys("left", "right", "h", "l"),
81+ key.WithHelp("←→", "choose"),
82+ ),
83+ key.NewBinding(
84+ key.WithKeys("enter"),
85+ key.WithHelp("enter", "accept"),
86+ ),
87+ key.NewBinding(
88+ key.WithKeys("esc"),
89+ key.WithHelp("esc", "back"),
90+ ),
91+ }
92+ }
93+ if k.isClaudeOAuthHelp {
94+ if k.isClaudeOAuthHelpComplete {
95+ return []key.Binding{
96+ key.NewBinding(
97+ key.WithKeys("enter"),
98+ key.WithHelp("enter", "close"),
99+ ),
100+ }
101+ }
102+
103+ enterHelp := "submit"
104+ if k.isClaudeOAuthURLState {
105+ enterHelp = "open"
106+ }
107+
108+ bindings := []key.Binding{
109+ key.NewBinding(
110+ key.WithKeys("enter"),
111+ key.WithHelp("enter", enterHelp),
112+ ),
113+ }
114+
115+ if k.isClaudeOAuthURLState {
116+ bindings = append(bindings, key.NewBinding(
117+ key.WithKeys("c"),
118+ key.WithHelp("c", "copy url"),
119+ ))
120+ }
121+
122+ bindings = append(bindings, key.NewBinding(
123+ key.WithKeys("esc"),
124+ key.WithHelp("esc", "back"),
125+ ))
126+
127+ return bindings
128+ }
67 if k.isAPIKeyHelp && !k.isAPIKeyValid {
129 if k.isAPIKeyHelp && !k.isAPIKeyValid {
68 return []key.Binding{
130 return []key.Binding{
131+ key.NewBinding(
132+ key.WithKeys("enter"),
133+ key.WithHelp("enter", "submit"),
134+ ),
69 k.Close,
135 k.Close,
70 }
136 }
71 } else if k.isAPIKeyValid {
137 } else if k.isAPIKeyValid {
internal/tui/components/dialogs/models/models.go+170−29
@@ -9,10 +9,12 @@ import (
9 "charm.land/bubbles/v2/spinner"
9 "charm.land/bubbles/v2/spinner"
10 tea "charm.land/bubbletea/v2"
10 tea "charm.land/bubbletea/v2"
11 "charm.land/lipgloss/v2"
11 "charm.land/lipgloss/v2"
12+ "github.com/atotto/clipboard"
12 "github.com/charmbracelet/catwalk/pkg/catwalk"
13 "github.com/charmbracelet/catwalk/pkg/catwalk"
13 "github.com/charmbracelet/crush/internal/config"
14 "github.com/charmbracelet/crush/internal/config"
14 "github.com/charmbracelet/crush/internal/tui/components/core"
15 "github.com/charmbracelet/crush/internal/tui/components/core"
15 "github.com/charmbracelet/crush/internal/tui/components/dialogs"
16 "github.com/charmbracelet/crush/internal/tui/components/dialogs"
17+ "github.com/charmbracelet/crush/internal/tui/components/dialogs/claude"
16 "github.com/charmbracelet/crush/internal/tui/exp/list"
18 "github.com/charmbracelet/crush/internal/tui/exp/list"
17 "github.com/charmbracelet/crush/internal/tui/styles"
19 "github.com/charmbracelet/crush/internal/tui/styles"
18 "github.com/charmbracelet/crush/internal/tui/util"
20 "github.com/charmbracelet/crush/internal/tui/util"
@@ -67,6 +69,12 @@ type modelDialogCmp struct {
67 selectedModelType config.SelectedModelType
69 selectedModelType config.SelectedModelType
68 isAPIKeyValid bool
70 isAPIKeyValid bool
69 apiKeyValue string
71 apiKeyValue string
72+
73+ // Claude state
74+ claudeAuthMethodChooser *claude.AuthMethodChooser
75+ claudeOAuth2 *claude.OAuth2
76+ showClaudeAuthMethodChooser bool
77+ showClaudeOAuth2 bool
70}
78}
71
79
72func NewModelDialogCmp() ModelDialog {
80func NewModelDialogCmp() ModelDialog {
@@ -91,11 +99,19 @@ func NewModelDialogCmp() ModelDialog {
91 width: defaultWidth,
99 width: defaultWidth,
92 keyMap: DefaultKeyMap(),
100 keyMap: DefaultKeyMap(),
93 help: help,
101 help: help,
102+
103+ claudeAuthMethodChooser: claude.NewAuthMethodChooser(),
104+ claudeOAuth2: claude.NewOAuth2(),
94 }
105 }
95}
106}
96
107
97func (m *modelDialogCmp) Init() tea.Cmd {
108func (m *modelDialogCmp) Init() tea.Cmd {
109+ return tea.Batch(
110+ m.modelList.Init(),
98− return tea.Batch(m.modelList.Init(), m.apiKeyInput.Init())
111+ m.apiKeyInput.Init(),
112+ m.claudeAuthMethodChooser.Init(),
113+ m.claudeOAuth2.Init(),
114+ )
99}
115}
100
116
101func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
117func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
@@ -105,16 +121,84 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
105 m.wHeight = msg.Height
121 m.wHeight = msg.Height
106 m.apiKeyInput.SetWidth(m.width - 2)
122 m.apiKeyInput.SetWidth(m.width - 2)
107 m.help.SetWidth(m.width - 2)
123 m.help.SetWidth(m.width - 2)
124+ m.claudeAuthMethodChooser.SetWidth(m.width - 2)
108 return m, m.modelList.SetSize(m.listWidth(), m.listHeight())
125 return m, m.modelList.SetSize(m.listWidth(), m.listHeight())
109 case APIKeyStateChangeMsg:
126 case APIKeyStateChangeMsg:
110 u, cmd := m.apiKeyInput.Update(msg)
127 u, cmd := m.apiKeyInput.Update(msg)
111 m.apiKeyInput = u.(*APIKeyInput)
128 m.apiKeyInput = u.(*APIKeyInput)
112 return m, cmd
129 return m, cmd
130+ case claude.ValidationCompletedMsg:
131+ var cmds []tea.Cmd
132+ u, cmd := m.claudeOAuth2.Update(msg)
133+ m.claudeOAuth2 = u.(*claude.OAuth2)
134+ cmds = append(cmds, cmd)
135+
136+ if msg.State == claude.OAuthValidationStateValid {
137+ cmds = append(cmds, m.saveAPIKeyAndContinue(msg.Token, false))
138+ m.keyMap.isClaudeOAuthHelpComplete = true
139+ }
140+
141+ return m, tea.Batch(cmds...)
142+ case claude.AuthenticationCompleteMsg:
143+ return m, util.CmdHandler(dialogs.CloseDialogMsg{})
113 case tea.KeyPressMsg:
144 case tea.KeyPressMsg:
114 switch {
145 switch {
146+ case key.Matches(msg, key.NewBinding(key.WithKeys("c", "C"))):
147+ if m.showClaudeOAuth2 && m.claudeOAuth2.State == claude.OAuthStateURL {
148+ return m, tea.Sequence(
149+ tea.SetClipboard(m.claudeOAuth2.URL),
150+ func() tea.Msg {
151+ _ = clipboard.WriteAll(m.claudeOAuth2.URL)
152+ return nil
153+ },
154+ util.ReportInfo("URL copied to clipboard"),
155+ )
156+ }
157+ case key.Matches(msg, m.keyMap.Choose):
158+ if m.showClaudeAuthMethodChooser {
159+ m.claudeAuthMethodChooser.ToggleChoice()
160+ return m, nil
161+ }
115 case key.Matches(msg, m.keyMap.Select):
162 case key.Matches(msg, m.keyMap.Select):
163+ selectedItem := m.modelList.SelectedModel()
164+
165+ modelType := config.SelectedModelTypeLarge
166+ if m.modelList.GetModelType() == SmallModelType {
167+ modelType = config.SelectedModelTypeSmall
168+ }
169+
170+ askForApiKey := func() {
171+ m.keyMap.isClaudeAuthChoiseHelp = false
172+ m.keyMap.isClaudeOAuthHelp = false
173+ m.keyMap.isAPIKeyHelp = true
174+ m.showClaudeAuthMethodChooser = false
175+ m.needsAPIKey = true
176+ m.selectedModel = selectedItem
177+ m.selectedModelType = modelType
178+ m.apiKeyInput.SetProviderName(selectedItem.Provider.Name)
179+ }
180+
181+ if m.showClaudeAuthMethodChooser {
182+ switch m.claudeAuthMethodChooser.State {
183+ case claude.AuthMethodAPIKey:
184+ askForApiKey()
185+ case claude.AuthMethodOAuth2:
186+ m.selectedModel = selectedItem
187+ m.selectedModelType = modelType
188+ m.showClaudeAuthMethodChooser = false
189+ m.showClaudeOAuth2 = true
190+ m.keyMap.isClaudeAuthChoiseHelp = false
191+ m.keyMap.isClaudeOAuthHelp = true
192+ }
193+ return m, nil
194+ }
195+ if m.showClaudeOAuth2 {
196+ m2, cmd2 := m.claudeOAuth2.ValidationConfirm()
197+ m.claudeOAuth2 = m2.(*claude.OAuth2)
198+ return m, cmd2
199+ }
116 if m.isAPIKeyValid {
200 if m.isAPIKeyValid {
117− return m, m.saveAPIKeyAndContinue(m.apiKeyValue)
201+ return m, m.saveAPIKeyAndContinue(m.apiKeyValue, true)
118 }
202 }
119 if m.needsAPIKey {
203 if m.needsAPIKey {
120 // Handle API key submission
204 // Handle API key submission
@@ -154,15 +238,6 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
154 },
238 },
155 )
239 )
156 }
240 }
157− // Normal model selection
158− selectedItem := m.modelList.SelectedModel()
159−
160− var modelType config.SelectedModelType
161− if m.modelList.GetModelType() == LargeModelType {
162− modelType = config.SelectedModelTypeLarge
163− } else {
164− modelType = config.SelectedModelTypeSmall
165− }
166
241
167 // Check if provider is configured
242 // Check if provider is configured
168 if m.isProviderConfigured(string(selectedItem.Provider.ID)) {
243 if m.isProviderConfigured(string(selectedItem.Provider.ID)) {
@@ -179,27 +254,38 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
179 }),
254 }),
180 )
255 )
181 } else {
256 } else {
182− // Provider not configured, show API key input
257+ if selectedItem.Provider.ID == catwalk.InferenceProviderAnthropic {
183− m.needsAPIKey = true
258+ m.showClaudeAuthMethodChooser = true
184− m.selectedModel = selectedItem
259+ m.keyMap.isClaudeAuthChoiseHelp = true
185− m.selectedModelType = modelType
260+ return m, nil
186− m.apiKeyInput.SetProviderName(selectedItem.Provider.Name)
261+ }
262+ askForApiKey()
187 return m, nil
263 return m, nil
188 }
264 }
189 case key.Matches(msg, m.keyMap.Tab):
265 case key.Matches(msg, m.keyMap.Tab):
190− if m.needsAPIKey {
266+ switch {
267+ case m.showClaudeAuthMethodChooser:
268+ m.claudeAuthMethodChooser.ToggleChoice()
269+ return m, nil
270+ case m.needsAPIKey:
191 u, cmd := m.apiKeyInput.Update(msg)
271 u, cmd := m.apiKeyInput.Update(msg)
192 m.apiKeyInput = u.(*APIKeyInput)
272 m.apiKeyInput = u.(*APIKeyInput)
193 return m, cmd
273 return m, cmd
194− }
195− if m.modelList.GetModelType() == LargeModelType {
274+ case m.modelList.GetModelType() == LargeModelType:
196 m.modelList.SetInputPlaceholder(smallModelInputPlaceholder)
275 m.modelList.SetInputPlaceholder(smallModelInputPlaceholder)
197 return m, m.modelList.SetModelType(SmallModelType)
276 return m, m.modelList.SetModelType(SmallModelType)
198− } else {
277+ default:
199 m.modelList.SetInputPlaceholder(largeModelInputPlaceholder)
278 m.modelList.SetInputPlaceholder(largeModelInputPlaceholder)
200 return m, m.modelList.SetModelType(LargeModelType)
279 return m, m.modelList.SetModelType(LargeModelType)
201 }
280 }
202 case key.Matches(msg, m.keyMap.Close):
281 case key.Matches(msg, m.keyMap.Close):
282+ if m.showClaudeAuthMethodChooser {
283+ m.claudeAuthMethodChooser.SetDefaults()
284+ m.showClaudeAuthMethodChooser = false
285+ m.keyMap.isClaudeAuthChoiseHelp = false
286+ m.keyMap.isClaudeOAuthHelp = false
287+ return m, nil
288+ }
203 if m.needsAPIKey {
289 if m.needsAPIKey {
204 if m.isAPIKeyValid {
290 if m.isAPIKeyValid {
205 return m, nil
291 return m, nil
@@ -214,7 +300,15 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
214 }
300 }
215 return m, util.CmdHandler(dialogs.CloseDialogMsg{})
301 return m, util.CmdHandler(dialogs.CloseDialogMsg{})
216 default:
302 default:
303+ if m.showClaudeAuthMethodChooser {
304+ u, cmd := m.claudeAuthMethodChooser.Update(msg)
305+ m.claudeAuthMethodChooser = u.(*claude.AuthMethodChooser)
306+ return m, cmd
307+ } else if m.showClaudeOAuth2 {
308+ u, cmd := m.claudeOAuth2.Update(msg)
309+ m.claudeOAuth2 = u.(*claude.OAuth2)
310+ return m, cmd
217− if m.needsAPIKey {
311+ } else if m.needsAPIKey {
218 u, cmd := m.apiKeyInput.Update(msg)
312 u, cmd := m.apiKeyInput.Update(msg)
219 m.apiKeyInput = u.(*APIKeyInput)
313 m.apiKeyInput = u.(*APIKeyInput)
220 return m, cmd
314 return m, cmd
@@ -225,7 +319,11 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
225 }
319 }
226 }
320 }
227 case tea.PasteMsg:
321 case tea.PasteMsg:
322+ if m.showClaudeOAuth2 {
323+ u, cmd := m.claudeOAuth2.Update(msg)
324+ m.claudeOAuth2 = u.(*claude.OAuth2)
325+ return m, cmd
228− if m.needsAPIKey {
326+ } else if m.needsAPIKey {
229 u, cmd := m.apiKeyInput.Update(msg)
327 u, cmd := m.apiKeyInput.Update(msg)
230 m.apiKeyInput = u.(*APIKeyInput)
328 m.apiKeyInput = u.(*APIKeyInput)
231 return m, cmd
329 return m, cmd
@@ -235,9 +333,15 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
235 return m, cmd
333 return m, cmd
236 }
334 }
237 case spinner.TickMsg:
335 case spinner.TickMsg:
336+ if m.showClaudeOAuth2 {
238− u, cmd := m.apiKeyInput.Update(msg)
337+ u, cmd := m.claudeOAuth2.Update(msg)
338+ m.claudeOAuth2 = u.(*claude.OAuth2)
339+ return m, cmd
340+ } else {
341+ u, cmd := m.apiKeyInput.Update(msg)
239− m.apiKeyInput = u.(*APIKeyInput)
342+ m.apiKeyInput = u.(*APIKeyInput)
240− return m, cmd
343+ return m, cmd
344+ }
241 }
345 }
242 return m, nil
346 return m, nil
243}
347}
@@ -245,7 +349,29 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
245func (m *modelDialogCmp) View() string {
349func (m *modelDialogCmp) View() string {
246 t := styles.CurrentTheme()
350 t := styles.CurrentTheme()
247
351
352+ switch {
353+ case m.showClaudeAuthMethodChooser:
354+ chooserView := m.claudeAuthMethodChooser.View()
355+ content := lipgloss.JoinVertical(
356+ lipgloss.Left,
357+ t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Let's Auth Anthropic", m.width-4)),
358+ chooserView,
359+ "",
360+ t.S().Base.Width(m.width-2).PaddingLeft(1).AlignHorizontal(lipgloss.Left).Render(m.help.View(m.keyMap)),
361+ )
362+ return m.style().Render(content)
363+ case m.showClaudeOAuth2:
364+ m.keyMap.isClaudeOAuthURLState = m.claudeOAuth2.State == claude.OAuthStateURL
365+ oauth2View := m.claudeOAuth2.View()
366+ content := lipgloss.JoinVertical(
367+ lipgloss.Left,
368+ t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Let's Auth Anthropic", m.width-4)),
369+ oauth2View,
370+ "",
371+ t.S().Base.Width(m.width-2).PaddingLeft(1).AlignHorizontal(lipgloss.Left).Render(m.help.View(m.keyMap)),
372+ )
373+ return m.style().Render(content)
248− if m.needsAPIKey {
374+ case m.needsAPIKey:
249 // Show API key input
375 // Show API key input
250 m.keyMap.isAPIKeyHelp = true
376 m.keyMap.isAPIKeyHelp = true
251 m.keyMap.isAPIKeyValid = m.isAPIKeyValid
377 m.keyMap.isAPIKeyValid = m.isAPIKeyValid
@@ -275,6 +401,16 @@ func (m *modelDialogCmp) View() string {
275}
401}
276
402
277func (m *modelDialogCmp) Cursor() *tea.Cursor {
403func (m *modelDialogCmp) Cursor() *tea.Cursor {
404+ if m.showClaudeAuthMethodChooser {
405+ return nil
406+ }
407+ if m.showClaudeOAuth2 {
408+ if cursor := m.claudeOAuth2.CodeInput.Cursor(); cursor != nil {
409+ cursor.Y += 2 // FIXME(@andreynering): Why do we need this?
410+ return m.moveCursor(cursor)
411+ }
412+ return nil
413+ }
278 if m.needsAPIKey {
414 if m.needsAPIKey {
279 cursor := m.apiKeyInput.Cursor()
415 cursor := m.apiKeyInput.Cursor()
280 if cursor != nil {
416 if cursor != nil {
@@ -365,7 +501,7 @@ func (m *modelDialogCmp) getProvider(providerID catwalk.InferenceProvider) (*cat
365 return nil, nil
501 return nil, nil
366}
502}
367
503
368−func (m *modelDialogCmp) saveAPIKeyAndContinue(apiKey string) tea.Cmd {
504+func (m *modelDialogCmp) saveAPIKeyAndContinue(apiKey any, close bool) tea.Cmd {
369 if m.selectedModel == nil {
505 if m.selectedModel == nil {
370 return util.ReportError(fmt.Errorf("no model selected"))
506 return util.ReportError(fmt.Errorf("no model selected"))
371 }
507 }
@@ -378,8 +514,12 @@ func (m *modelDialogCmp) saveAPIKeyAndContinue(apiKey string) tea.Cmd {
378
514
379 // Reset API key state and continue with model selection
515 // Reset API key state and continue with model selection
380 selectedModel := *m.selectedModel
516 selectedModel := *m.selectedModel
381− return tea.Sequence(
517+ var cmds []tea.Cmd
518+ if close {
382− util.CmdHandler(dialogs.CloseDialogMsg{}),
519+ cmds = append(cmds, util.CmdHandler(dialogs.CloseDialogMsg{}))
520+ }
521+ cmds = append(
522+ cmds,
383 util.CmdHandler(ModelSelectedMsg{
523 util.CmdHandler(ModelSelectedMsg{
384 Model: config.SelectedModel{
524 Model: config.SelectedModel{
385 Model: selectedModel.Model.ID,
525 Model: selectedModel.Model.ID,
@@ -390,4 +530,5 @@ func (m *modelDialogCmp) saveAPIKeyAndContinue(apiKey string) tea.Cmd {
390 ModelType: m.selectedModelType,
530 ModelType: m.selectedModelType,
391 }),
531 }),
392 )
532 )
533+ return tea.Sequence(cmds...)
393}
534}
internal/tui/page/chat/chat.go+73
@@ -29,6 +29,7 @@ import (
29 "github.com/charmbracelet/crush/internal/tui/components/core"
29 "github.com/charmbracelet/crush/internal/tui/components/core"
30 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
30 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
31 "github.com/charmbracelet/crush/internal/tui/components/dialogs"
31 "github.com/charmbracelet/crush/internal/tui/components/dialogs"
32+ "github.com/charmbracelet/crush/internal/tui/components/dialogs/claude"
32 "github.com/charmbracelet/crush/internal/tui/components/dialogs/commands"
33 "github.com/charmbracelet/crush/internal/tui/components/dialogs/commands"
33 "github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker"
34 "github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker"
34 "github.com/charmbracelet/crush/internal/tui/components/dialogs/models"
35 "github.com/charmbracelet/crush/internal/tui/components/dialogs/models"
@@ -293,6 +294,13 @@ func (p *chatPage) Update(msg tea.Msg) (util.Model, tea.Cmd) {
293 cmds = append(cmds, cmd)
294 cmds = append(cmds, cmd)
294 return p, tea.Batch(cmds...)
295 return p, tea.Batch(cmds...)
295
296
297+ case claude.ValidationCompletedMsg, claude.AuthenticationCompleteMsg:
298+ if p.focusedPane == PanelTypeSplash {
299+ u, cmd := p.splash.Update(msg)
300+ p.splash = u.(splash.Splash)
301+ cmds = append(cmds, cmd)
302+ }
303+ return p, tea.Batch(cmds...)
296 case models.APIKeyStateChangeMsg:
304 case models.APIKeyStateChangeMsg:
297 if p.focusedPane == PanelTypeSplash {
305 if p.focusedPane == PanelTypeSplash {
298 u, cmd := p.splash.Update(msg)
306 u, cmd := p.splash.Update(msg)
@@ -816,6 +824,71 @@ func (p *chatPage) Help() help.KeyMap {
816 var shortList []key.Binding
824 var shortList []key.Binding
817 var fullList [][]key.Binding
825 var fullList [][]key.Binding
818 switch {
826 switch {
827+ case p.isOnboarding && p.splash.IsShowingClaudeAuthMethodChooser():
828+ shortList = append(shortList,
829+ // Choose auth method
830+ key.NewBinding(
831+ key.WithKeys("left", "right", "tab"),
832+ key.WithHelp("←→/tab", "choose"),
833+ ),
834+ // Accept selection
835+ key.NewBinding(
836+ key.WithKeys("enter"),
837+ key.WithHelp("enter", "accept"),
838+ ),
839+ // Go back
840+ key.NewBinding(
841+ key.WithKeys("esc", "alt+esc"),
842+ key.WithHelp("esc", "back"),
843+ ),
844+ // Quit
845+ key.NewBinding(
846+ key.WithKeys("ctrl+c"),
847+ key.WithHelp("ctrl+c", "quit"),
848+ ),
849+ )
850+ // keep them the same
851+ for _, v := range shortList {
852+ fullList = append(fullList, []key.Binding{v})
853+ }
854+ case p.isOnboarding && p.splash.IsShowingClaudeOAuth2():
855+ if p.splash.IsClaudeOAuthURLState() {
856+ shortList = append(shortList,
857+ key.NewBinding(
858+ key.WithKeys("enter"),
859+ key.WithHelp("enter", "open"),
860+ ),
861+ key.NewBinding(
862+ key.WithKeys("c"),
863+ key.WithHelp("c", "copy url"),
864+ ),
865+ )
866+ } else if p.splash.IsClaudeOAuthComplete() {
867+ shortList = append(shortList,
868+ key.NewBinding(
869+ key.WithKeys("enter"),
870+ key.WithHelp("enter", "continue"),
871+ ),
872+ )
873+ } else {
874+ shortList = append(shortList,
875+ key.NewBinding(
876+ key.WithKeys("enter"),
877+ key.WithHelp("enter", "submit"),
878+ ),
879+ )
880+ }
881+ shortList = append(shortList,
882+ // Quit
883+ key.NewBinding(
884+ key.WithKeys("ctrl+c"),
885+ key.WithHelp("ctrl+c", "quit"),
886+ ),
887+ )
888+ // keep them the same
889+ for _, v := range shortList {
890+ fullList = append(fullList, []key.Binding{v})
891+ }
819 case p.isOnboarding && !p.splash.IsShowingAPIKey():
892 case p.isOnboarding && !p.splash.IsShowingAPIKey():
820 shortList = append(shortList,
893 shortList = append(shortList,
821 // Choose model
894 // Choose model
internal/tui/styles/charmtone.go+7
@@ -67,10 +67,17 @@ func NewCharmtoneTheme() *Theme {
67 t.ItemErrorIcon = t.ItemOfflineIcon.Foreground(charmtone.Coral)
67 t.ItemErrorIcon = t.ItemOfflineIcon.Foreground(charmtone.Coral)
68 t.ItemOnlineIcon = t.ItemOfflineIcon.Foreground(charmtone.Guac)
68 t.ItemOnlineIcon = t.ItemOfflineIcon.Foreground(charmtone.Guac)
69
69
70+ // Editor: Yolo Mode.
70 t.YoloIconFocused = lipgloss.NewStyle().Foreground(charmtone.Oyster).Background(charmtone.Citron).Bold(true).SetString(" ! ")
71 t.YoloIconFocused = lipgloss.NewStyle().Foreground(charmtone.Oyster).Background(charmtone.Citron).Bold(true).SetString(" ! ")
71 t.YoloIconBlurred = t.YoloIconFocused.Foreground(charmtone.Pepper).Background(charmtone.Squid)
72 t.YoloIconBlurred = t.YoloIconFocused.Foreground(charmtone.Pepper).Background(charmtone.Squid)
72 t.YoloDotsFocused = lipgloss.NewStyle().Foreground(charmtone.Zest).SetString(":::")
73 t.YoloDotsFocused = lipgloss.NewStyle().Foreground(charmtone.Zest).SetString(":::")
73 t.YoloDotsBlurred = t.YoloDotsFocused.Foreground(charmtone.Squid)
74 t.YoloDotsBlurred = t.YoloDotsFocused.Foreground(charmtone.Squid)
74
75
76+ // oAuth Chooser.
77+ t.AuthBorderSelected = lipgloss.NewStyle().BorderForeground(charmtone.Guac)
78+ t.AuthTextSelected = lipgloss.NewStyle().Foreground(charmtone.Julep)
79+ t.AuthBorderUnselected = lipgloss.NewStyle().BorderForeground(charmtone.Iron)
80+ t.AuthTextUnselected = lipgloss.NewStyle().Foreground(charmtone.Squid)
81+
75 return t
82 return t
76}
83}
internal/tui/styles/theme.go+7−1
@@ -85,12 +85,18 @@ type Theme struct {
85 ItemErrorIcon lipgloss.Style
85 ItemErrorIcon lipgloss.Style
86 ItemOnlineIcon lipgloss.Style
86 ItemOnlineIcon lipgloss.Style
87
87
88− // Editor: Yolo Mode
88+ // Editor: Yolo Mode.
89 YoloIconFocused lipgloss.Style
89 YoloIconFocused lipgloss.Style
90 YoloIconBlurred lipgloss.Style
90 YoloIconBlurred lipgloss.Style
91 YoloDotsFocused lipgloss.Style
91 YoloDotsFocused lipgloss.Style
92 YoloDotsBlurred lipgloss.Style
92 YoloDotsBlurred lipgloss.Style
93
93
94+ // oAuth Chooser.
95+ AuthBorderSelected lipgloss.Style
96+ AuthTextSelected lipgloss.Style
97+ AuthBorderUnselected lipgloss.Style
98+ AuthTextUnselected lipgloss.Style
99+
94 styles *Styles
100 styles *Styles
95}
101}
96
102