Detailed changes
@@ -879,6 +879,22 @@
"created_at": "2025-11-22T05:23:17Z",
"repoId": 987670088,
"pullRequestNo": 1496
+ },
+ {
+ "name": "masroor-ahmad",
+ "id": 75073229,
+ "comment_id": 3592527587,
+ "created_at": "2025-11-30T12:55:09Z",
+ "repoId": 987670088,
+ "pullRequestNo": 1532
+ },
+ {
+ "name": "thezbm",
+ "id": 24851999,
+ "comment_id": 3595049411,
+ "created_at": "2025-12-01T07:39:02Z",
+ "repoId": 987670088,
+ "pullRequestNo": 1534
}
]
}
@@ -24,7 +24,7 @@ require (
github.com/charmbracelet/glamour/v2 v2.0.0-20251106195642-800eb8175930
github.com/charmbracelet/log/v2 v2.0.0-20251106192421-eb64aaa963a0
github.com/charmbracelet/ultraviolet v0.0.0-20251116181749-377898bcce38
- github.com/charmbracelet/x/ansi v0.11.1
+ github.com/charmbracelet/x/ansi v0.11.2
github.com/charmbracelet/x/exp/charmtone v0.0.0-20250708181618-a60a724ba6c3
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f
github.com/charmbracelet/x/exp/ordered v0.1.0
@@ -94,7 +94,7 @@ require (
github.com/charmbracelet/x/json v0.2.0 // indirect
github.com/charmbracelet/x/termios v0.1.1 // indirect
github.com/charmbracelet/x/windows v0.2.2 // indirect
- github.com/clipperhouse/displaywidth v0.5.0 // indirect
+ github.com/clipperhouse/displaywidth v0.6.0 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.3.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
@@ -98,8 +98,8 @@ github.com/charmbracelet/log/v2 v2.0.0-20251106192421-eb64aaa963a0 h1:lxHzxsHd4P
github.com/charmbracelet/log/v2 v2.0.0-20251106192421-eb64aaa963a0/go.mod h1:Q7oMtlboDPnnrYiJDXNwdWmJblOmuOnycPKczlVju6I=
github.com/charmbracelet/ultraviolet v0.0.0-20251116181749-377898bcce38 h1:7Rs87fbKJoIIxsQS8YKJYGYa0tlsDwwb0twQjV1KB+g=
github.com/charmbracelet/ultraviolet v0.0.0-20251116181749-377898bcce38/go.mod h1:6lfcr3MNP+kZR25sF1nQwJFuQnNYBlFy3PGX5rvslXc=
-github.com/charmbracelet/x/ansi v0.11.1 h1:iXAC8SyMQDJgtcz9Jnw+HU8WMEctHzoTAETIeA3JXMk=
-github.com/charmbracelet/x/ansi v0.11.1/go.mod h1:M49wjzpIujwPceJ+t5w3qh2i87+HRtHohgb5iTyepL0=
+github.com/charmbracelet/x/ansi v0.11.2 h1:XAG3FSjiVtFvgEgGrNBkCNNYrsucAt8c6bfxHyROLLs=
+github.com/charmbracelet/x/ansi v0.11.2/go.mod h1:9tY2bzX5SiJCU0iWyskjBeI2BRQfvPqI+J760Mjf+Rg=
github.com/charmbracelet/x/exp/charmtone v0.0.0-20250708181618-a60a724ba6c3 h1:1xwHZg6eMZ9Wv5TE1UGub6ARubyOd1Lo5kPUI/6VL50=
github.com/charmbracelet/x/exp/charmtone v0.0.0-20250708181618-a60a724ba6c3/go.mod h1:T9jr8CzFpjhFVHjNjKwbAD7KwBNyFnj2pntAO7F2zw0=
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
@@ -118,8 +118,8 @@ github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
-github.com/clipperhouse/displaywidth v0.5.0 h1:AIG5vQaSL2EKqzt0M9JMnvNxOCRTKUc4vUnLWGgP89I=
-github.com/clipperhouse/displaywidth v0.5.0/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o=
+github.com/clipperhouse/displaywidth v0.6.0 h1:k32vueaksef9WIKCNcoqRNyKbyvkvkysNYnAWz2fN4s=
+github.com/clipperhouse/displaywidth v0.6.0/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o=
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4=
@@ -131,7 +131,20 @@ func (c *coordinator) Run(ctx context.Context, sessionID string, prompt string,
mergedOptions, temp, topP, topK, freqPenalty, presPenalty := mergeCallOptions(model, providerCfg)
- return c.currentAgent.Run(ctx, SessionAgentCall{
+ if providerCfg.OAuthToken != nil && providerCfg.OAuthToken.IsExpired() {
+ slog.Info("Detected expired OAuth token, attempting refresh", "provider", providerCfg.ID)
+ if refreshErr := c.cfg.RefreshOAuthToken(ctx, providerCfg.ID); refreshErr != nil {
+ slog.Error("Failed to refresh OAuth token", "provider", providerCfg.ID, "error", refreshErr)
+ return nil, refreshErr
+ }
+
+ // Rebuild models with refreshed token
+ if updateErr := c.UpdateModels(ctx); updateErr != nil {
+ slog.Error("Failed to update models after token refresh", "error", updateErr)
+ return nil, updateErr
+ }
+ }
+ result, err := c.currentAgent.Run(ctx, SessionAgentCall{
SessionID: sessionID,
Prompt: prompt,
Attachments: attachments,
@@ -143,6 +156,7 @@ func (c *coordinator) Run(ctx context.Context, sessionID string, prompt string,
FrequencyPenalty: freqPenalty,
PresencePenalty: presPenalty,
})
+ return result, err
}
func getProviderOptions(model Model, providerCfg config.ProviderConfig) fantasy.ProviderOptions {
@@ -16,6 +16,7 @@ import (
"github.com/charmbracelet/crush/internal/csync"
"github.com/charmbracelet/crush/internal/env"
"github.com/charmbracelet/crush/internal/oauth"
+ "github.com/charmbracelet/crush/internal/oauth/claude"
"github.com/invopop/jsonschema"
"github.com/tidwall/sjson"
)
@@ -72,7 +73,7 @@ type SelectedModel struct {
Think bool `json:"think,omitempty" jsonschema:"description=Enable thinking mode for Anthropic models that support reasoning"`
// Overrides the default model configuration.
- MaxTokens int64 `json:"max_tokens,omitempty" jsonschema:"description=Maximum number of tokens for model responses,minimum=1,maximum=200000,example=4096"`
+ MaxTokens int64 `json:"max_tokens,omitempty" jsonschema:"description=Maximum number of tokens for model responses,maximum=200000,example=4096"`
Temperature *float64 `json:"temperature,omitempty" jsonschema:"description=Sampling temperature,minimum=0,maximum=1,example=0.7"`
TopP *float64 `json:"top_p,omitempty" jsonschema:"description=Top-p (nucleus) sampling parameter,minimum=0,maximum=1,example=0.9"`
TopK *int64 `json:"top_k,omitempty" jsonschema:"description=Top-k sampling parameter"`
@@ -468,6 +469,43 @@ func (c *Config) SetConfigField(key string, value any) error {
return nil
}
+func (c *Config) RefreshOAuthToken(ctx context.Context, providerID string) error {
+ providerConfig, exists := c.Providers.Get(providerID)
+ if !exists {
+ return fmt.Errorf("provider %s not found", providerID)
+ }
+
+ if providerConfig.OAuthToken == nil {
+ return fmt.Errorf("provider %s does not have an OAuth token", providerID)
+ }
+
+ // Only Anthropic provider uses OAuth for now
+ if providerID != string(catwalk.InferenceProviderAnthropic) {
+ return fmt.Errorf("OAuth refresh not supported for provider %s", providerID)
+ }
+
+ newToken, err := claude.RefreshToken(ctx, providerConfig.OAuthToken.RefreshToken)
+ if err != nil {
+ return fmt.Errorf("failed to refresh OAuth token for provider %s: %w", providerID, err)
+ }
+
+ slog.Info("Successfully refreshed OAuth token in background", "provider", providerID)
+ providerConfig.OAuthToken = newToken
+ providerConfig.APIKey = fmt.Sprintf("Bearer %s", newToken.AccessToken)
+ providerConfig.SetupClaudeCode()
+
+ c.Providers.Set(providerID, providerConfig)
+
+ if err := cmp.Or(
+ c.SetConfigField(fmt.Sprintf("providers.%s.api_key", providerID), newToken.AccessToken),
+ c.SetConfigField(fmt.Sprintf("providers.%s.oauth", providerID), newToken),
+ ); err != nil {
+ return fmt.Errorf("failed to persist refreshed token: %w", err)
+ }
+
+ return nil
+}
+
func (c *Config) SetProviderAPIKey(providerID string, apiKey any) error {
var providerConfig ProviderConfig
var exists bool
@@ -6,7 +6,6 @@ import (
"math/rand"
"net/http"
"os"
- "os/exec"
"path/filepath"
"runtime"
"slices"
@@ -112,11 +111,8 @@ func (m *editorCmp) openEditor(value string) tea.Cmd {
if _, err := tmpfile.WriteString(value); err != nil {
return util.ReportError(err)
}
- c := exec.CommandContext(context.TODO(), editor, tmpfile.Name())
- c.Stdin = os.Stdin
- c.Stdout = os.Stdout
- c.Stderr = os.Stderr
- return tea.ExecProcess(c, func(err error) tea.Msg {
+ cmdStr := editor + " " + tmpfile.Name()
+ return util.ExecShell(context.TODO(), cmdStr, func(err error) tea.Msg {
if err != nil {
return util.ReportError(err)
}
@@ -143,17 +143,15 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
return m, util.CmdHandler(dialogs.CloseDialogMsg{})
case tea.KeyPressMsg:
switch {
- case key.Matches(msg, key.NewBinding(key.WithKeys("c", "C"))):
- if m.showClaudeOAuth2 && m.claudeOAuth2.State == claude.OAuthStateURL {
- return m, tea.Sequence(
- tea.SetClipboard(m.claudeOAuth2.URL),
- func() tea.Msg {
- _ = clipboard.WriteAll(m.claudeOAuth2.URL)
- return nil
- },
- util.ReportInfo("URL copied to clipboard"),
- )
- }
+ case key.Matches(msg, key.NewBinding(key.WithKeys("c", "C"))) && m.showClaudeOAuth2 && m.claudeOAuth2.State == claude.OAuthStateURL:
+ return m, tea.Sequence(
+ tea.SetClipboard(m.claudeOAuth2.URL),
+ func() tea.Msg {
+ _ = clipboard.WriteAll(m.claudeOAuth2.URL)
+ return nil
+ },
+ util.ReportInfo("URL copied to clipboard"),
+ )
case key.Matches(msg, m.keyMap.Choose) && m.showClaudeAuthMethodChooser:
m.claudeAuthMethodChooser.ToggleChoice()
return m, nil
@@ -0,0 +1,26 @@
+package util
+
+import (
+ "context"
+ "errors"
+ "os/exec"
+
+ tea "charm.land/bubbletea/v2"
+ "mvdan.cc/sh/v3/shell"
+)
+
+// ExecShell parses a shell command string and executes it with exec.Command.
+// Uses shell.Fields for proper handling of shell syntax like quotes and
+// arguments while preserving TTY handling for terminal editors.
+func ExecShell(ctx context.Context, cmdStr string, callback tea.ExecCallback) tea.Cmd {
+ fields, err := shell.Fields(cmdStr, nil)
+ if err != nil {
+ return ReportError(err)
+ }
+ if len(fields) == 0 {
+ return ReportError(errors.New("empty command"))
+ }
+
+ cmd := exec.CommandContext(ctx, fields[0], fields[1:]...)
+ return tea.ExecProcess(cmd, callback)
+}
@@ -555,7 +555,6 @@
"max_tokens": {
"type": "integer",
"maximum": 200000,
- "minimum": 1,
"description": "Maximum number of tokens for model responses",
"examples": [
4096