From b621f9312f4e7677062e3dec2028091279ba4b2d Mon Sep 17 00:00:00 2001 From: Raphael Amorim Date: Sat, 29 Nov 2025 16:51:50 +0100 Subject: [PATCH 1/9] fix: refresh oauth token in the background Co-authored-by: Kujtim Hoxha --- internal/agent/coordinator.go | 16 ++++++++++++++- internal/config/config.go | 38 +++++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/internal/agent/coordinator.go b/internal/agent/coordinator.go index c01ae619726343e33992b1c0c98066697e0b5f7f..40dc818a55edf0eee005cc6d984622a5253b2151 100644 --- a/internal/agent/coordinator.go +++ b/internal/agent/coordinator.go @@ -130,7 +130,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, @@ -142,6 +155,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 { diff --git a/internal/config/config.go b/internal/config/config.go index 2d1882ba876e3d0ab1ea284dce7edbbe3503013a..fab908413c691eb6cf7a73ea44c31fdfb722d0b9 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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" ) @@ -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 From f508c6b39a06b7865bb52f592d9b66daf48b40e9 Mon Sep 17 00:00:00 2001 From: Charm <124303983+charmcli@users.noreply.github.com> Date: Sun, 30 Nov 2025 09:55:22 -0300 Subject: [PATCH 3/9] chore(legal): @masroor-ahmad has signed the CLA --- .github/cla-signatures.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/cla-signatures.json b/.github/cla-signatures.json index 01ed8f63b35bda38dfb64a92f2a1c5f22b2d0042..968fef42234779ae5046878d815be7fc81ff033e 100644 --- a/.github/cla-signatures.json +++ b/.github/cla-signatures.json @@ -879,6 +879,14 @@ "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 } ] } \ No newline at end of file From 8193ba85e6ac4b13228a17ae6c958a873e1da4c1 Mon Sep 17 00:00:00 2001 From: Charm <124303983+charmcli@users.noreply.github.com> Date: Mon, 1 Dec 2025 04:39:24 -0300 Subject: [PATCH 4/9] chore(legal): @thezbm has signed the CLA --- .github/cla-signatures.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/cla-signatures.json b/.github/cla-signatures.json index 968fef42234779ae5046878d815be7fc81ff033e..029c1bfee1bd4905201889d14be531b791ba3985 100644 --- a/.github/cla-signatures.json +++ b/.github/cla-signatures.json @@ -887,6 +887,14 @@ "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 } ] } \ No newline at end of file From a9aeaff236de182f491fc1f3fc0d43034920a0ab Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 1 Dec 2025 13:22:20 +0000 Subject: [PATCH 5/9] chore(deps): bump github.com/charmbracelet/x/ansi from 0.11.1 to 0.11.2 in the all group (#1535) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 4 ++-- go.sum | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/go.mod b/go.mod index 09f1482ee4d5c305b54fb96c279d0920d50f2d6f..f291838eba7bca3a9c25b061f5974a4d7885b6ce 100644 --- a/go.mod +++ b/go.mod @@ -22,7 +22,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 @@ -92,7 +92,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 diff --git a/go.sum b/go.sum index c575d10b05d87532c234585577b8d0c2c1e0cc90..aaeba2379bc8d66cdbbbed1cfe491372abf9e76c 100644 --- a/go.sum +++ b/go.sum @@ -100,8 +100,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= @@ -120,8 +120,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= From e5639f03895140587312d55d056a5e22c36249a6 Mon Sep 17 00:00:00 2001 From: Beiming Zhang Date: Mon, 1 Dec 2025 07:35:08 -0600 Subject: [PATCH 6/9] fix: fix `c` key not working in model filter (#1534) --- .../tui/components/dialogs/models/models.go | 20 +++++++++---------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/internal/tui/components/dialogs/models/models.go b/internal/tui/components/dialogs/models/models.go index 820c8b0fa3c574521ed773f62032e2e7cb6747d1..ff7243ca7ea344208a1b637a1cf2818c8121638c 100644 --- a/internal/tui/components/dialogs/models/models.go +++ b/internal/tui/components/dialogs/models/models.go @@ -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 From 4f37cff65d79273b67faa83b1b1f78a69b8fddaa Mon Sep 17 00:00:00 2001 From: Amolith Date: Mon, 1 Dec 2025 06:40:42 -0700 Subject: [PATCH 7/9] fix(editor): fix opening `$EDITOR` w/ and w/o args (#1520) --- internal/tui/components/chat/editor/editor.go | 8 ++---- internal/tui/util/shell.go | 26 +++++++++++++++++++ 2 files changed, 28 insertions(+), 6 deletions(-) create mode 100644 internal/tui/util/shell.go diff --git a/internal/tui/components/chat/editor/editor.go b/internal/tui/components/chat/editor/editor.go index de1a98b34595613594e83063cc12add4ba820c84..8ae8ed3d7dd7b5f277b8d0076b97859b5c6aa73f 100644 --- a/internal/tui/components/chat/editor/editor.go +++ b/internal/tui/components/chat/editor/editor.go @@ -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) } diff --git a/internal/tui/util/shell.go b/internal/tui/util/shell.go new file mode 100644 index 0000000000000000000000000000000000000000..43690c8aaacd6a396b02220536d022c674f16111 --- /dev/null +++ b/internal/tui/util/shell.go @@ -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) +} From 2b222e3d951d317389f361f8f12be0fbc77267eb Mon Sep 17 00:00:00 2001 From: masroor-ahmad <75073229+masroor-ahmad@users.noreply.github.com> Date: Mon, 1 Dec 2025 18:16:36 +0100 Subject: [PATCH 9/9] fix: remove `max_tokens` minimum requirement to fix json schema issue (#1532) --- internal/config/config.go | 2 +- schema.json | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index fab908413c691eb6cf7a73ea44c31fdfb722d0b9..464dc14bc8c6d12cdf1db17c681c4faa68a59339 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -73,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"` diff --git a/schema.json b/schema.json index 41809010df84ba774d734d81403a0cebb1579375..47740b9c18c8d2807c74557ffd9e21b5b6658ceb 100644 --- a/schema.json +++ b/schema.json @@ -555,7 +555,6 @@ "max_tokens": { "type": "integer", "maximum": 200000, - "minimum": 1, "description": "Maximum number of tokens for model responses", "examples": [ 4096