From 4dd2b17f1116ed0481444bff894f63e80676e973 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Tue, 23 Sep 2025 14:00:26 -0300 Subject: [PATCH 01/22] fix(lsp): improve error messages Signed-off-by: Carlos Alexandro Becker --- internal/lsp/client.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/lsp/client.go b/internal/lsp/client.go index aedf2476918fd5394c4a876bf7cd5ec177348905..226d6c6f3896e29dcbc75c04bee23a34bdc85952 100644 --- a/internal/lsp/client.go +++ b/internal/lsp/client.go @@ -77,7 +77,7 @@ func New(ctx context.Context, name string, config config.LSPConfig) (*Client, er // Create the powernap client powernapClient, err := powernap.NewClient(clientConfig) if err != nil { - return nil, fmt.Errorf("failed to create powernap client: %w", err) + return nil, fmt.Errorf("failed to create lsp client: %w", err) } client := &Client{ @@ -98,7 +98,7 @@ func New(ctx context.Context, name string, config config.LSPConfig) (*Client, er // Initialize initializes the LSP client and returns the server capabilities. func (c *Client) Initialize(ctx context.Context, workspaceDir string) (*protocol.InitializeResult, error) { if err := c.client.Initialize(ctx, false); err != nil { - return nil, fmt.Errorf("failed to initialize powernap client: %w", err) + return nil, fmt.Errorf("failed to initialize the lsp client: %w", err) } // Convert powernap capabilities to protocol capabilities From ab96589e7ed71411582ae2b970ddadd1bb9d3a83 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Tue, 23 Sep 2025 16:11:38 -0300 Subject: [PATCH 02/22] fix: disable providers (#1087) * fix: disable providers if we remove then from the list, they'll still show up because they won't get merged with catwalk providers later on. closes #1037 Signed-off-by: Carlos Alexandro Becker --- internal/config/load.go | 5 ----- internal/config/load_test.go | 8 ++++---- 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/internal/config/load.go b/internal/config/load.go index 16cb531ddfa9d452ed55dd82914c6d77f7650f0d..ad2b75b75df8c6f8d7a5cd2e62df1a831157b9e1 100644 --- a/internal/config/load.go +++ b/internal/config/load.go @@ -126,11 +126,6 @@ func (c *Config) configureProviders(env env.Env, resolver VariableResolver, know config, configExists := c.Providers.Get(string(p.ID)) // if the user configured a known provider we need to allow it to override a couple of parameters if configExists { - if config.Disable { - slog.Debug("Skipping provider due to disable flag", "provider", p.ID) - c.Providers.Del(string(p.ID)) - continue - } if config.BaseURL != "" { p.APIEndpoint = config.BaseURL } diff --git a/internal/config/load_test.go b/internal/config/load_test.go index 90276c96ad113f453ed699c8deeb30b4f5fef9d5..756f849db426e226c197879740f0dc47d3048dd9 100644 --- a/internal/config/load_test.go +++ b/internal/config/load_test.go @@ -543,10 +543,10 @@ func TestConfig_configureProvidersWithDisabledProvider(t *testing.T) { err := cfg.configureProviders(env, resolver, knownProviders) require.NoError(t, err) - // Provider should be removed from config when disabled - require.Equal(t, cfg.Providers.Len(), 0) - _, exists := cfg.Providers.Get("openai") - require.False(t, exists) + require.Equal(t, cfg.Providers.Len(), 1) + prov, exists := cfg.Providers.Get("openai") + require.True(t, exists) + require.True(t, prov.Disable) } func TestConfig_configureProvidersCustomProviderValidation(t *testing.T) { From 3b6a37597433a631bcbed50c11e102b6dfeffa96 Mon Sep 17 00:00:00 2001 From: Max Justus Spransy Date: Wed, 27 Aug 2025 12:26:19 -0700 Subject: [PATCH 03/22] feat: add alt/option+esc binding to current esc key behavior This mimics the behavior of Claude Code and allows folks who use Crush from within a terminal emulator that captures the escape key (like Nvim's built in terminal emulator) to use it. --- internal/tui/components/chat/editor/editor.go | 2 +- internal/tui/components/chat/editor/keys.go | 2 +- internal/tui/components/chat/messages/messages.go | 2 +- internal/tui/components/chat/splash/keys.go | 2 +- internal/tui/components/completions/keys.go | 2 +- internal/tui/components/dialogs/commands/keys.go | 2 +- internal/tui/components/dialogs/compact/keys.go | 2 +- internal/tui/components/dialogs/filepicker/keys.go | 2 +- internal/tui/components/dialogs/keys.go | 2 +- internal/tui/components/dialogs/models/keys.go | 2 +- internal/tui/components/dialogs/quit/keys.go | 2 +- internal/tui/components/dialogs/sessions/keys.go | 2 +- internal/tui/page/chat/chat.go | 14 +++++++------- internal/tui/page/chat/keys.go | 2 +- 14 files changed, 20 insertions(+), 20 deletions(-) diff --git a/internal/tui/components/chat/editor/editor.go b/internal/tui/components/chat/editor/editor.go index 04fb5ed1976c7cf7ba4af372dd16ecef48ceb82f..86390611f6115fc14def1e8a7713b252b0d6a59d 100644 --- a/internal/tui/components/chat/editor/editor.go +++ b/internal/tui/components/chat/editor/editor.go @@ -75,7 +75,7 @@ var DeleteKeyMaps = DeleteAttachmentKeyMaps{ key.WithHelp("ctrl+r+{i}", "delete attachment at index i"), ), Escape: key.NewBinding( - key.WithKeys("esc"), + key.WithKeys("esc", "alt+esc"), key.WithHelp("esc", "cancel delete mode"), ), DeleteAllAttachments: key.NewBinding( diff --git a/internal/tui/components/chat/editor/keys.go b/internal/tui/components/chat/editor/keys.go index 9d2274753b4667031bb43a76f54fce18c1decf51..8bc8b2354dfb72120d9e6173256635e903d012fd 100644 --- a/internal/tui/components/chat/editor/keys.go +++ b/internal/tui/components/chat/editor/keys.go @@ -61,7 +61,7 @@ var AttachmentsKeyMaps = DeleteAttachmentKeyMaps{ key.WithHelp("ctrl+r+{i}", "delete attachment at index i"), ), Escape: key.NewBinding( - key.WithKeys("esc"), + key.WithKeys("esc", "alt+esc"), key.WithHelp("esc", "cancel delete mode"), ), DeleteAllAttachments: key.NewBinding( diff --git a/internal/tui/components/chat/messages/messages.go b/internal/tui/components/chat/messages/messages.go index 5cc15d0303fb152f299aef9a2cdc596b9ffb57d4..296b02478a7d0738fef2f60ae6b2211d44424a2f 100644 --- a/internal/tui/components/chat/messages/messages.go +++ b/internal/tui/components/chat/messages/messages.go @@ -29,7 +29,7 @@ import ( var CopyKey = key.NewBinding(key.WithKeys("c", "y", "C", "Y"), key.WithHelp("c/y", "copy")) // ClearSelectionKey is the key binding for clearing the current selection in the chat interface. -var ClearSelectionKey = key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "clear selection")) +var ClearSelectionKey = key.NewBinding(key.WithKeys("esc", "alt+esc"), key.WithHelp("esc", "clear selection")) // MessageCmp defines the interface for message components in the chat interface. // It combines standard UI model interfaces with message-specific functionality. diff --git a/internal/tui/components/chat/splash/keys.go b/internal/tui/components/chat/splash/keys.go index 675c608a94af4aa72b701376f3983506166ac7d7..d36c8d8e7ee2231ef8bc27eb053a5745a0bd3885 100644 --- a/internal/tui/components/chat/splash/keys.go +++ b/internal/tui/components/chat/splash/keys.go @@ -46,7 +46,7 @@ func DefaultKeyMap() KeyMap { key.WithHelp("←/→", "switch"), ), Back: key.NewBinding( - key.WithKeys("esc"), + key.WithKeys("esc", "alt+esc"), key.WithHelp("esc", "back"), ), } diff --git a/internal/tui/components/completions/keys.go b/internal/tui/components/completions/keys.go index 82372358028aec2b1384f1b4b6bff90be4a05eb8..dec1059f8cde34b7a65faad279ebe551a2108a3a 100644 --- a/internal/tui/components/completions/keys.go +++ b/internal/tui/components/completions/keys.go @@ -28,7 +28,7 @@ func DefaultKeyMap() KeyMap { key.WithHelp("enter", "select"), ), Cancel: key.NewBinding( - key.WithKeys("esc"), + key.WithKeys("esc", "alt+esc"), key.WithHelp("esc", "cancel"), ), DownInsert: key.NewBinding( diff --git a/internal/tui/components/dialogs/commands/keys.go b/internal/tui/components/dialogs/commands/keys.go index 9685216817c02cdfaab682f94e0f89aa64af365f..7b79a29c28a024154a3b4d8c763969585409fd00 100644 --- a/internal/tui/components/dialogs/commands/keys.go +++ b/internal/tui/components/dialogs/commands/keys.go @@ -31,7 +31,7 @@ func DefaultCommandsDialogKeyMap() CommandsDialogKeyMap { key.WithHelp("tab", "switch selection"), ), Close: key.NewBinding( - key.WithKeys("esc"), + key.WithKeys("esc", "alt+esc"), key.WithHelp("esc", "cancel"), ), } diff --git a/internal/tui/components/dialogs/compact/keys.go b/internal/tui/components/dialogs/compact/keys.go index c3dd98e13035085b7d46e7a2e94450b25a7f0d59..cec1486491e342c28f148a50d37f1129944c002e 100644 --- a/internal/tui/components/dialogs/compact/keys.go +++ b/internal/tui/components/dialogs/compact/keys.go @@ -33,7 +33,7 @@ func DefaultKeyMap() KeyMap { key.WithHelp("n", "no"), ), Close: key.NewBinding( - key.WithKeys("esc"), + key.WithKeys("esc", "alt+esc"), key.WithHelp("esc", "cancel"), ), } diff --git a/internal/tui/components/dialogs/filepicker/keys.go b/internal/tui/components/dialogs/filepicker/keys.go index 9f3b706e3cf677b66cbc3136a7b98a466470d949..72e32f2ab9dd07d8b7165aee74744e8be5fd78e8 100644 --- a/internal/tui/components/dialogs/filepicker/keys.go +++ b/internal/tui/components/dialogs/filepicker/keys.go @@ -38,7 +38,7 @@ func DefaultKeyMap() KeyMap { ), Close: key.NewBinding( - key.WithKeys("esc"), + key.WithKeys("esc", "alt+esc"), key.WithHelp("esc", "close/exit"), ), } diff --git a/internal/tui/components/dialogs/keys.go b/internal/tui/components/dialogs/keys.go index c382b7e09e15de04efb5b2520bc490ef9d57b985..264ce3d42f6a99f441f961128f109e6baebf4c1b 100644 --- a/internal/tui/components/dialogs/keys.go +++ b/internal/tui/components/dialogs/keys.go @@ -12,7 +12,7 @@ type KeyMap struct { func DefaultKeyMap() KeyMap { return KeyMap{ Close: key.NewBinding( - key.WithKeys("esc"), + key.WithKeys("esc", "alt+esc"), ), } } diff --git a/internal/tui/components/dialogs/models/keys.go b/internal/tui/components/dialogs/models/keys.go index df546863d87d3a68777e51938f58eee28a5c6473..ef4a6228b839c43a3862e251999dadf81dd6403f 100644 --- a/internal/tui/components/dialogs/models/keys.go +++ b/internal/tui/components/dialogs/models/keys.go @@ -34,7 +34,7 @@ func DefaultKeyMap() KeyMap { key.WithHelp("tab", "toggle type"), ), Close: key.NewBinding( - key.WithKeys("esc"), + key.WithKeys("esc", "alt+esc"), key.WithHelp("esc", "cancel"), ), } diff --git a/internal/tui/components/dialogs/quit/keys.go b/internal/tui/components/dialogs/quit/keys.go index 3268749b20c703ae1faf7640e253ce557f051c65..2e8dbc199264eb9221544319f81ef859d71e58b5 100644 --- a/internal/tui/components/dialogs/quit/keys.go +++ b/internal/tui/components/dialogs/quit/keys.go @@ -37,7 +37,7 @@ func DefaultKeymap() KeyMap { key.WithHelp("tab", "switch options"), ), Close: key.NewBinding( - key.WithKeys("esc"), + key.WithKeys("esc", "alt+esc"), key.WithHelp("esc", "cancel"), ), } diff --git a/internal/tui/components/dialogs/sessions/keys.go b/internal/tui/components/dialogs/sessions/keys.go index a3ca4b31f0c04c491fa7990f7e69ac546f608a7d..bc7ec1ba9f83915caee9189504abf0b07bd4a24b 100644 --- a/internal/tui/components/dialogs/sessions/keys.go +++ b/internal/tui/components/dialogs/sessions/keys.go @@ -26,7 +26,7 @@ func DefaultKeyMap() KeyMap { key.WithHelp("↑", "previous item"), ), Close: key.NewBinding( - key.WithKeys("esc"), + key.WithKeys("esc", "alt+esc"), key.WithHelp("esc", "cancel"), ), } diff --git a/internal/tui/page/chat/chat.go b/internal/tui/page/chat/chat.go index 88523388e31824a65d7e9922b89a1886a5fbcc0d..2918925068cb2f012bead47bbf44260c6255288c 100644 --- a/internal/tui/page/chat/chat.go +++ b/internal/tui/page/chat/chat.go @@ -766,7 +766,7 @@ func (p *chatPage) Bindings() []key.Binding { cancelBinding := p.keyMap.Cancel if p.isCanceling { cancelBinding = key.NewBinding( - key.WithKeys("esc"), + key.WithKeys("esc", "alt+esc"), key.WithHelp("esc", "press again to cancel"), ) } @@ -835,7 +835,7 @@ func (p *chatPage) Help() help.KeyMap { shortList = append(shortList, // Go back key.NewBinding( - key.WithKeys("esc"), + key.WithKeys("esc", "alt+esc"), key.WithHelp("esc", "back"), ), ) @@ -870,7 +870,7 @@ func (p *chatPage) Help() help.KeyMap { key.WithHelp("tab/enter", "complete"), ), key.NewBinding( - key.WithKeys("esc"), + key.WithKeys("esc", "alt+esc"), key.WithHelp("esc", "cancel"), ), key.NewBinding( @@ -885,18 +885,18 @@ func (p *chatPage) Help() help.KeyMap { } if p.app.CoderAgent != nil && p.app.CoderAgent.IsBusy() { cancelBinding := key.NewBinding( - key.WithKeys("esc"), + key.WithKeys("esc", "alt+esc"), key.WithHelp("esc", "cancel"), ) if p.isCanceling { cancelBinding = key.NewBinding( - key.WithKeys("esc"), + key.WithKeys("esc", "alt+esc"), key.WithHelp("esc", "press again to cancel"), ) } if p.app.CoderAgent != nil && p.app.CoderAgent.QueuedPrompts(p.session.ID) > 0 { cancelBinding = key.NewBinding( - key.WithKeys("esc"), + key.WithKeys("esc", "alt+esc"), key.WithHelp("esc", "clear queue"), ) } @@ -1042,7 +1042,7 @@ func (p *chatPage) Help() help.KeyMap { key.WithHelp("ctrl+r+r", "delete all attachments"), ), key.NewBinding( - key.WithKeys("esc"), + key.WithKeys("esc", "alt+esc"), key.WithHelp("esc", "cancel delete mode"), ), }) diff --git a/internal/tui/page/chat/keys.go b/internal/tui/page/chat/keys.go index ef896aaab10fe36ee8ce88d3f70a3f03e3c61d3e..679a97c69522c0e831e59bddc7b0c1ddcc55fbb9 100644 --- a/internal/tui/page/chat/keys.go +++ b/internal/tui/page/chat/keys.go @@ -23,7 +23,7 @@ func DefaultKeyMap() KeyMap { key.WithHelp("ctrl+f", "add attachment"), ), Cancel: key.NewBinding( - key.WithKeys("esc"), + key.WithKeys("esc", "alt+esc"), key.WithHelp("esc", "cancel"), ), Tab: key.NewBinding( From 6b161d29e9832fe1abafea231d7947e03d76f574 Mon Sep 17 00:00:00 2001 From: kslamph <15257433+kslamph@users.noreply.github.com> Date: Wed, 24 Sep 2025 21:12:37 +0800 Subject: [PATCH 04/22] feat(config): allow custom providers of type gemini (#585) This change extends the provider configuration to allow users to define custom providers with `type: "gemini"`. This enables connecting to any Gemini-compatible API by specifying its `base_url` and `api_key` within the `providers` section of `crush.json`. It supports complex setups, such as using a local proxy or a model-balancing service. --- internal/config/load.go | 2 +- internal/llm/provider/gemini.go | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/internal/config/load.go b/internal/config/load.go index ad2b75b75df8c6f8d7a5cd2e62df1a831157b9e1..e39074f78bdb8df0ddc98bfbc7322541175b71d6 100644 --- a/internal/config/load.go +++ b/internal/config/load.go @@ -270,7 +270,7 @@ func (c *Config) configureProviders(env env.Env, resolver VariableResolver, know c.Providers.Del(id) continue } - if providerConfig.Type != catwalk.TypeOpenAI && providerConfig.Type != catwalk.TypeAnthropic { + if providerConfig.Type != catwalk.TypeOpenAI && providerConfig.Type != catwalk.TypeAnthropic && providerConfig.Type != catwalk.TypeGemini { slog.Warn("Skipping custom provider because the provider type is not supported", "provider", id, "type", providerConfig.Type) c.Providers.Del(id) continue diff --git a/internal/llm/provider/gemini.go b/internal/llm/provider/gemini.go index 256e21bf7d59216a41be4603c1475dc9e24bdeea..c1db9561e7db5fd3ae8da1ae1c9ea143f5ea20ec 100644 --- a/internal/llm/provider/gemini.go +++ b/internal/llm/provider/gemini.go @@ -43,6 +43,9 @@ func createGeminiClient(opts providerClientOptions) (*genai.Client, error) { cc := &genai.ClientConfig{ APIKey: opts.apiKey, Backend: genai.BackendGeminiAPI, + HTTPOptions: genai.HTTPOptions{ + BaseURL: opts.baseURL, + }, } if config.Get().Options.Debug { cc.HTTPClient = log.NewHTTPClient() From 9654218e61ff58326e4524f34086822e6d7c2864 Mon Sep 17 00:00:00 2001 From: Amolith Date: Sun, 21 Sep 2025 16:17:50 -0600 Subject: [PATCH 05/22] feat(permissions): pretty-print MCP JSON Add pretty-printed JSON parameters to MCP tool permission dialogs so users can read more of the content. Co-Authored-By: Crush --- internal/llm/agent/mcp-tools.go | 2 +- .../dialogs/permissions/permissions.go | 30 +++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/internal/llm/agent/mcp-tools.go b/internal/llm/agent/mcp-tools.go index 1043ca3b9820e72096a0aafe7cdb7868c8d29720..4a4435dccbdb48ea6d2d64bf7af9f257e8e3730b 100644 --- a/internal/llm/agent/mcp-tools.go +++ b/internal/llm/agent/mcp-tools.go @@ -176,7 +176,7 @@ func (b *McpTool) Run(ctx context.Context, params tools.ToolCall) (tools.ToolRes if sessionID == "" || messageID == "" { return tools.ToolResponse{}, fmt.Errorf("session ID and message ID are required for creating a new file") } - permissionDescription := fmt.Sprintf("execute %s with the following parameters: %s", b.Info().Name, params.Input) + permissionDescription := fmt.Sprintf("execute %s with the following parameters:", b.Info().Name) p := b.permissions.Request( permission.CreatePermissionRequest{ SessionID: sessionID, diff --git a/internal/tui/components/dialogs/permissions/permissions.go b/internal/tui/components/dialogs/permissions/permissions.go index 2633c0a2f1a50f78adf010214680c157f302073b..9e0a6b05d7385c354f8faba3110b1c0951f9a97d 100644 --- a/internal/tui/components/dialogs/permissions/permissions.go +++ b/internal/tui/components/dialogs/permissions/permissions.go @@ -1,6 +1,7 @@ package permissions import ( + "encoding/json" "fmt" "strings" @@ -614,6 +615,35 @@ func (p *permissionDialogCmp) generateDefaultContent() string { content := p.permission.Description + // Add pretty-printed JSON parameters for MCP tools + if p.permission.Params != nil { + var paramStr string + + // Ensure params is a string + if str, ok := p.permission.Params.(string); ok { + paramStr = str + } else { + paramStr = fmt.Sprintf("%v", p.permission.Params) + } + + // Try to parse as JSON for pretty printing + var parsed any + if err := json.Unmarshal([]byte(paramStr), &parsed); err == nil { + if b, err := json.MarshalIndent(parsed, "", " "); err == nil { + if content != "" { + content += "\n\n" + } + content += string(b) + } + } else { + // Not JSON, show as-is + if content != "" { + content += "\n\n" + } + content += paramStr + } + } + content = strings.TrimSpace(content) content = "\n" + content + "\n" lines := strings.Split(content, "\n") From ef6a32453abdf3b943ef9f56800a7ec9c0135c46 Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Tue, 23 Sep 2025 11:23:57 -0300 Subject: [PATCH 06/22] chore: add metrics and error tracking --- README.md | 34 +++++- go.mod | 3 + go.sum | 6 + internal/cmd/root.go | 25 ++++ internal/config/config.go | 1 + internal/event/all.go | 59 +++++++++ internal/event/event.go | 114 ++++++++++++++++++ internal/event/logger.go | 27 +++++ internal/llm/agent/agent.go | 40 +++--- internal/llm/agent/errors.go | 15 +++ internal/llm/agent/event.go | 53 ++++++++ internal/log/log.go | 3 + internal/session/session.go | 3 + .../components/dialogs/sessions/sessions.go | 2 + internal/tui/tui.go | 4 + main.go | 2 + schema.json | 5 + 17 files changed, 379 insertions(+), 17 deletions(-) create mode 100644 internal/event/all.go create mode 100644 internal/event/event.go create mode 100644 internal/event/logger.go create mode 100644 internal/llm/agent/errors.go create mode 100644 internal/llm/agent/event.go diff --git a/README.md b/README.md index a2b07093c1e45162018614411ba7a4300f9ef680..7f28c5c049cdb6c45bc83ec59f94f4310c13b7c5 100644 --- a/README.md +++ b/README.md @@ -545,7 +545,7 @@ config: } ``` -## Disabling Provider Auto-Updates +## Provider Auto-Updates By default, Crush automatically checks for the latest and greatest list of providers and models from [Catwalk](https://github.com/charmbracelet/catwalk), @@ -553,6 +553,8 @@ the open source Crush provider database. This means that when new providers and models are available, or when model metadata changes, Crush automatically updates your local configuration. +### Disabling automatic provider updates + For those with restricted internet access, or those who prefer to work in air-gapped environments, this might not be want you want, and this feature can be disabled. @@ -597,6 +599,36 @@ crush update-providers embedded crush update-providers --help ``` +## Metrics + +Crush records pseudonymous usage metrics (tied to a device-specific hash), +which maintainers rely on to inform development and support priorities. The +metrics include solely usage metadata; prompts and responses are NEVER +collected. + +Details on exactly what’s collected are in the source code ([here](https://github.com/charmbracelet/crush/tree/main/internal/event) +and [here](https://github.com/charmbracelet/crush/blob/main/internal/llm/agent/event.go)). + +You can opt out of metrics collection at any time by setting the environment +variable by setting the following in your environment: + +```bash +export CRUSH_DISABLE_METRICS=1 +``` + +Or by setting the following in your config: + +```json +{ + "options": { + "disable_metrics": true + } +} +``` + +Crush also respects the [`DO_NOT_TRACK`](https://consoledonottrack.com) +convention which can be enabled via `export DO_NOT_TRACK=1`. + ## A Note on Claude Max and GitHub Copilot Crush only supports model providers through official, compliant APIs. We do not diff --git a/go.mod b/go.mod index b59f4d649ad9c635d5dc8d583e993208b06547a6..ea62993931c7532b55127f54b35bab0be2eb23a7 100644 --- a/go.mod +++ b/go.mod @@ -81,6 +81,7 @@ require ( github.com/charmbracelet/x/termios v0.1.1 // indirect github.com/charmbracelet/x/windows v0.2.2 // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/denisbrodbeck/machineid v1.0.1 github.com/disintegration/gift v1.1.2 // indirect github.com/dlclark/regexp2 v1.11.5 // indirect github.com/dustin/go-humanize v1.0.1 // indirect @@ -95,6 +96,7 @@ require ( github.com/googleapis/gax-go/v2 v2.14.1 // indirect github.com/gorilla/css v1.0.1 // indirect github.com/gorilla/websocket v1.5.3 // indirect + github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/klauspost/compress v1.18.0 // indirect github.com/klauspost/cpuid/v2 v2.0.9 // indirect @@ -114,6 +116,7 @@ require ( github.com/ncruces/julianday v1.0.0 // indirect github.com/pierrec/lz4/v4 v4.1.22 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/posthog/posthog-go v1.6.10 github.com/rivo/uniseg v0.4.7 github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect github.com/sethvargo/go-retry v0.3.0 // indirect diff --git a/go.sum b/go.sum index a921201c472e338f3c068503d9404d68a7bcba12..fedfeb243142a92fe79d7d6b474ca921dbc952bd 100644 --- a/go.sum +++ b/go.sum @@ -118,6 +118,8 @@ github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfv github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/denisbrodbeck/machineid v1.0.1 h1:geKr9qtkB876mXguW2X6TU4ZynleN6ezuMSRhl4D7AQ= +github.com/denisbrodbeck/machineid v1.0.1/go.mod h1:dJUwb7PTidGDeYyUBmXZ2GphQBbjJCrnectwCyxcUSI= github.com/disintegration/gift v1.1.2 h1:9ZyHJr+kPamiH10FX3Pynt1AxFUob812bU9Wt4GMzhs= github.com/disintegration/gift v1.1.2/go.mod h1:Jh2i7f7Q2BM7Ezno3PhfezbR1xpUg9dUg3/RlKGr4HI= github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec h1:YrB6aVr9touOt75I9O1SiancmR2GMg45U9UYf0gtgWg= @@ -162,6 +164,8 @@ github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= @@ -233,6 +237,8 @@ github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjL github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/posthog/posthog-go v1.6.10 h1:OA6bkiUg89rI7f5cSXbcrH5+wLinyS6hHplnD92Pu/M= +github.com/posthog/posthog-go v1.6.10/go.mod h1:LcC1Nu4AgvV22EndTtrMXTy+7RGVC0MhChSw7Qk5XkY= github.com/pressly/goose/v3 v3.25.0 h1:6WeYhMWGRCzpyd89SpODFnCBCKz41KrVbRT58nVjGng= github.com/pressly/goose/v3 v3.25.0/go.mod h1:4hC1KrritdCxtuFsqgs1R4AU5bWtTAf+cnWvfhf2DNY= github.com/qjebbs/go-jsons v1.0.0-alpha.4 h1:Qsb4ohRUHQODIUAsJKdKJ/SIDbsO7oGOzsfy+h1yQZs= diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 3ecb23e5acd68c1666cf9798b17bcc408b9290e1..ea9c218b67c65815b6bcc2c8b1cb17fd02390b39 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -7,11 +7,13 @@ import ( "log/slog" "os" "path/filepath" + "strconv" tea "github.com/charmbracelet/bubbletea/v2" "github.com/charmbracelet/crush/internal/app" "github.com/charmbracelet/crush/internal/config" "github.com/charmbracelet/crush/internal/db" + "github.com/charmbracelet/crush/internal/event" "github.com/charmbracelet/crush/internal/tui" "github.com/charmbracelet/crush/internal/version" "github.com/charmbracelet/fang" @@ -66,6 +68,8 @@ crush -y } defer app.Shutdown() + event.AppInitialized() + // Set up the TUI. program := tea.NewProgram( tui.New(app), @@ -78,11 +82,15 @@ crush -y go app.Subscribe(program) if _, err := program.Run(); err != nil { + event.Error(err) slog.Error("TUI run error", "error", err) return fmt.Errorf("TUI error: %v", err) } return nil }, + PostRun: func(cmd *cobra.Command, args []string) { + event.AppExited() + }, } func Execute() { @@ -135,9 +143,26 @@ func setupApp(cmd *cobra.Command) (*app.App, error) { return nil, err } + if shouldEnableMetrics() { + event.Init() + } + return appInstance, nil } +func shouldEnableMetrics() bool { + if v, _ := strconv.ParseBool(os.Getenv("CRUSH_DISABLE_METRICS")); v { + return false + } + if v, _ := strconv.ParseBool(os.Getenv("DO_NOT_TRACK")); v { + return false + } + if config.Get().Options.DisableMetrics { + return false + } + return true +} + func MaybePrependStdin(prompt string) (string, error) { if term.IsTerminal(os.Stdin.Fd()) { return prompt, nil diff --git a/internal/config/config.go b/internal/config/config.go index 8e4b8e5437e31af351b14b7330ab1bf4326b4863..3578850d228b78503e67e630a9b688c575403b9c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -153,6 +153,7 @@ type Options struct { DisabledTools []string `json:"disabled_tools" jsonschema:"description=Tools to disable"` DisableProviderAutoUpdate bool `json:"disable_provider_auto_update,omitempty" jsonschema:"description=Disable providers auto-update,default=false"` Attribution *Attribution `json:"attribution,omitempty" jsonschema:"description=Attribution settings for generated content"` + DisableMetrics bool `json:"disable_metrics,omitempty" jsonschema:"description=Disable sending metrics,default=false"` } type MCPs map[string]MCPConfig diff --git a/internal/event/all.go b/internal/event/all.go new file mode 100644 index 0000000000000000000000000000000000000000..8caf98e62ff3f39b291e341959ebc943361eec05 --- /dev/null +++ b/internal/event/all.go @@ -0,0 +1,59 @@ +package event + +import ( + "time" +) + +var appStartTime time.Time + +func AppInitialized() { + appStartTime = time.Now() + send("app initialized") +} + +func AppExited() { + duration := time.Since(appStartTime).Truncate(time.Second) + send( + "app exited", + "app duration pretty", duration.String(), + "app duration in seconds", int64(duration.Seconds()), + ) + Flush() +} + +func SessionCreated() { + send("session created") +} + +func SessionDeleted() { + send("session deleted") +} + +func SessionSwitched() { + send("session switched") +} + +func FilePickerOpened() { + send("filepicker opened") +} + +func PromptSent(props ...any) { + send( + "prompt sent", + props..., + ) +} + +func PromptResponded(props ...any) { + send( + "prompt responded", + props..., + ) +} + +func TokensUsed(props ...any) { + send( + "tokens used", + props..., + ) +} diff --git a/internal/event/event.go b/internal/event/event.go new file mode 100644 index 0000000000000000000000000000000000000000..89a02411eefbbdefc94e784abbca7e3cd638027d --- /dev/null +++ b/internal/event/event.go @@ -0,0 +1,114 @@ +package event + +import ( + "fmt" + "log/slog" + "os" + "reflect" + "runtime" + + "github.com/charmbracelet/crush/internal/version" + "github.com/denisbrodbeck/machineid" + "github.com/posthog/posthog-go" +) + +const ( + endpoint = "https://data.charm.land" + key = "phc_4zt4VgDWLqbYnJYEwLRxFoaTL2noNrQij0C6E8k3I0V" +) + +var ( + client posthog.Client + + baseProps = posthog.NewProperties(). + Set("GOOS", runtime.GOOS). + Set("GOARCH", runtime.GOARCH). + Set("TERM", os.Getenv("TERM")). + Set("SHELL", os.Getenv("SHELL")). + Set("Version", version.Version). + Set("GoVersion", runtime.Version()) +) + +func Init() { + c, err := posthog.NewWithConfig(key, posthog.Config{ + Endpoint: endpoint, + Logger: logger{}, + }) + if err != nil { + slog.Error("Failed to initialize PostHog client", "error", err) + } + client = c +} + +// send logs an event to PostHog with the given event name and properties. +func send(event string, props ...any) { + if client == nil { + return + } + err := client.Enqueue(posthog.Capture{ + DistinctId: distinctId(), + Event: event, + Properties: pairsToProps(props...).Merge(baseProps), + }) + if err != nil { + slog.Error("Failed to enqueue PostHog event", "event", event, "props", props, "error", err) + return + } +} + +// Error logs an error event to PostHog with the error type and message. +func Error(err any, props ...any) { + if client == nil { + return + } + // The PostHog Go client does not yet support sending exceptions. + // We're mimicking the behavior by sending the minimal info required + // for PostHog to recognize this as an exception event. + props = append( + []any{ + "$exception_list", + []map[string]string{ + {"type": reflect.TypeOf(err).String(), "value": fmt.Sprintf("%v", err)}, + }, + }, + props..., + ) + send("$exception", props...) +} + +func Flush() { + if client == nil { + return + } + if err := client.Close(); err != nil { + slog.Error("Failed to flush PostHog events", "error", err) + } +} + +func pairsToProps(props ...any) posthog.Properties { + p := posthog.NewProperties() + + if !isEven(len(props)) { + slog.Error("Event properties must be provided as key-value pairs", "props", props) + return p + } + + for i := 0; i < len(props); i += 2 { + key := props[i].(string) + value := props[i+1] + p = p.Set(key, value) + } + return p +} + +func isEven(n int) bool { + return n%2 == 0 +} + +func distinctId() string { + id, err := machineid.ProtectedID("charm") + if err != nil { + return "crush-cli" + } + return id +} diff --git a/internal/event/logger.go b/internal/event/logger.go new file mode 100644 index 0000000000000000000000000000000000000000..7648ae2c2cca91ed20535c0d65a677cd4db84500 --- /dev/null +++ b/internal/event/logger.go @@ -0,0 +1,27 @@ +package event + +import ( + "log/slog" + + "github.com/posthog/posthog-go" +) + +var _ posthog.Logger = logger{} + +type logger struct{} + +func (logger) Debugf(format string, args ...any) { + slog.Debug(format, args...) +} + +func (logger) Logf(format string, args ...any) { + slog.Info(format, args...) +} + +func (logger) Warnf(format string, args ...any) { + slog.Warn(format, args...) +} + +func (logger) Errorf(format string, args ...any) { + slog.Error(format, args...) +} diff --git a/internal/llm/agent/agent.go b/internal/llm/agent/agent.go index ec48fc2956ac5ed3baa031ba2ed4b2f905b65ae0..74b1cb74659238de917c823872698f1b2ed31332 100644 --- a/internal/llm/agent/agent.go +++ b/internal/llm/agent/agent.go @@ -12,6 +12,7 @@ import ( "github.com/charmbracelet/catwalk/pkg/catwalk" "github.com/charmbracelet/crush/internal/config" "github.com/charmbracelet/crush/internal/csync" + "github.com/charmbracelet/crush/internal/event" "github.com/charmbracelet/crush/internal/history" "github.com/charmbracelet/crush/internal/llm/prompt" "github.com/charmbracelet/crush/internal/llm/provider" @@ -25,12 +26,6 @@ import ( "github.com/charmbracelet/crush/internal/shell" ) -// Common errors -var ( - ErrRequestCancelled = errors.New("request canceled by user") - ErrSessionBusy = errors.New("session is currently processing another request") -) - type AgentEventType string const ( @@ -66,10 +61,11 @@ type Service interface { type agent struct { *pubsub.Broker[AgentEvent] - agentCfg config.Agent - sessions session.Service - messages message.Service - mcpTools []McpTool + agentCfg config.Agent + sessions session.Service + messages message.Service + permissions permission.Service + mcpTools []McpTool tools *csync.LazySlice[tools.BaseTool] // We need this to be able to update it when model changes @@ -237,6 +233,7 @@ func NewAgent( activeRequests: csync.NewMap[string, context.CancelFunc](), tools: csync.NewLazySlice(toolFn), promptQueue: csync.NewMap[string, []string](), + permissions: permissions, }, nil } @@ -365,8 +362,9 @@ func (a *agent) Run(ctx context.Context, sessionID string, content string, attac } genCtx, cancel := context.WithCancel(ctx) - a.activeRequests.Set(sessionID, cancel) + startTime := time.Now() + go func() { slog.Debug("Request started", "sessionID", sessionID) defer log.RecoverPanic("agent.Run", func() { @@ -377,16 +375,24 @@ func (a *agent) Run(ctx context.Context, sessionID string, content string, attac attachmentParts = append(attachmentParts, message.BinaryContent{Path: attachment.FilePath, MIMEType: attachment.MimeType, Data: attachment.Content}) } result := a.processGeneration(genCtx, sessionID, content, attachmentParts) - if result.Error != nil && !errors.Is(result.Error, ErrRequestCancelled) && !errors.Is(result.Error, context.Canceled) { - slog.Error(result.Error.Error()) + if result.Error != nil { + if isCancelledErr(result.Error) { + slog.Error("Request canceled", "sessionID", sessionID) + } else { + slog.Error("Request errored", "sessionID", sessionID, "error", result.Error.Error()) + event.Error(result.Error) + } + } else { + slog.Debug("Request completed", "sessionID", sessionID) } - slog.Debug("Request completed", "sessionID", sessionID) + a.eventPromptResponded(sessionID, time.Since(startTime).Truncate(time.Second)) a.activeRequests.Del(sessionID) cancel() a.Publish(pubsub.CreatedEvent, result) events <- result close(events) }() + a.eventPromptSent(sessionID) return events, nil } @@ -726,13 +732,13 @@ func (a *agent) processEvent(ctx context.Context, sessionID string, assistantMsg if err := a.messages.Update(ctx, *assistantMsg); err != nil { return fmt.Errorf("failed to update message: %w", err) } - return a.TrackUsage(ctx, sessionID, a.Model(), event.Response.Usage) + return a.trackUsage(ctx, sessionID, a.Model(), event.Response.Usage) } return nil } -func (a *agent) TrackUsage(ctx context.Context, sessionID string, model catwalk.Model, usage provider.TokenUsage) error { +func (a *agent) trackUsage(ctx context.Context, sessionID string, model catwalk.Model, usage provider.TokenUsage) error { sess, err := a.sessions.Get(ctx, sessionID) if err != nil { return fmt.Errorf("failed to get session: %w", err) @@ -743,6 +749,8 @@ func (a *agent) TrackUsage(ctx context.Context, sessionID string, model catwalk. model.CostPer1MIn/1e6*float64(usage.InputTokens) + model.CostPer1MOut/1e6*float64(usage.OutputTokens) + a.eventTokensUsed(sessionID, usage, cost) + sess.Cost += cost sess.CompletionTokens = usage.OutputTokens + usage.CacheReadTokens sess.PromptTokens = usage.InputTokens + usage.CacheCreationTokens diff --git a/internal/llm/agent/errors.go b/internal/llm/agent/errors.go new file mode 100644 index 0000000000000000000000000000000000000000..0e2f983d64b42b93ad3a51f32ce0335b0374a613 --- /dev/null +++ b/internal/llm/agent/errors.go @@ -0,0 +1,15 @@ +package agent + +import ( + "context" + "errors" +) + +var ( + ErrRequestCancelled = errors.New("request canceled by user") + ErrSessionBusy = errors.New("session is currently processing another request") +) + +func isCancelledErr(err error) bool { + return errors.Is(err, context.Canceled) || errors.Is(err, ErrRequestCancelled) +} diff --git a/internal/llm/agent/event.go b/internal/llm/agent/event.go new file mode 100644 index 0000000000000000000000000000000000000000..8642d9990dc31689292abe9f2b39e685462f158e --- /dev/null +++ b/internal/llm/agent/event.go @@ -0,0 +1,53 @@ +package agent + +import ( + "time" + + "github.com/charmbracelet/crush/internal/config" + "github.com/charmbracelet/crush/internal/event" + "github.com/charmbracelet/crush/internal/llm/provider" +) + +func (a *agent) eventPromptSent(sessionID string) { + event.PromptSent( + a.eventCommon(sessionID)..., + ) +} + +func (a *agent) eventPromptResponded(sessionID string, duration time.Duration) { + event.PromptResponded( + append( + a.eventCommon(sessionID), + "prompt duration pretty", duration.String(), + "prompt duration in seconds", int64(duration.Seconds()), + )..., + ) +} + +func (a *agent) eventTokensUsed(sessionID string, usage provider.TokenUsage, cost float64) { + event.TokensUsed( + append( + a.eventCommon(sessionID), + "input tokens", usage.InputTokens, + "output tokens", usage.OutputTokens, + "cache read tokens", usage.CacheReadTokens, + "cache creation tokens", usage.CacheCreationTokens, + "total tokens", usage.InputTokens+usage.OutputTokens+usage.CacheReadTokens+usage.CacheCreationTokens, + "cost", cost, + )..., + ) +} + +func (a *agent) eventCommon(sessionID string) []any { + cfg := config.Get() + currentModel := cfg.Models[cfg.Agents["coder"].Model] + + return []any{ + "session id", sessionID, + "provider", currentModel.Provider, + "model", currentModel.Model, + "reasoning effort", currentModel.ReasoningEffort, + "thinking mode", currentModel.Think, + "yolo mode", a.permissions.SkipRequests(), + } +} diff --git a/internal/log/log.go b/internal/log/log.go index bf99fe60fa9a5015029af171adfd6b3f9bf5596b..9463c3bd97956da3ab895b8600f79d1c05790844 100644 --- a/internal/log/log.go +++ b/internal/log/log.go @@ -9,6 +9,7 @@ import ( "sync/atomic" "time" + "github.com/charmbracelet/crush/internal/event" "gopkg.in/natefinch/lumberjack.v2" ) @@ -48,6 +49,8 @@ func Initialized() bool { func RecoverPanic(name string, cleanup func()) { if r := recover(); r != nil { + event.Error(r, "panic", true, "name", name) + // Create a timestamped panic log file timestamp := time.Now().Format("20060102-150405") filename := fmt.Sprintf("crush-panic-%s-%s.log", name, timestamp) diff --git a/internal/session/session.go b/internal/session/session.go index d988dac3414fa7dd00d13b375e1309f8d6c515dd..f83f66ffa4d1cfb75c6a0d41f09caebcb1c64cf3 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -5,6 +5,7 @@ import ( "database/sql" "github.com/charmbracelet/crush/internal/db" + "github.com/charmbracelet/crush/internal/event" "github.com/charmbracelet/crush/internal/pubsub" "github.com/google/uuid" ) @@ -48,6 +49,7 @@ func (s *service) Create(ctx context.Context, title string) (Session, error) { } session := s.fromDBItem(dbSession) s.Publish(pubsub.CreatedEvent, session) + event.SessionCreated() return session, nil } @@ -89,6 +91,7 @@ func (s *service) Delete(ctx context.Context, id string) error { return err } s.Publish(pubsub.DeletedEvent, session) + event.SessionDeleted() return nil } diff --git a/internal/tui/components/dialogs/sessions/sessions.go b/internal/tui/components/dialogs/sessions/sessions.go index 4e5cbdef7fdb42f4c667de7ac5bdd5066e7be4df..037eb5ebb727a24b8ab9bfda2e2c72943120e819 100644 --- a/internal/tui/components/dialogs/sessions/sessions.go +++ b/internal/tui/components/dialogs/sessions/sessions.go @@ -4,6 +4,7 @@ import ( "github.com/charmbracelet/bubbles/v2/help" "github.com/charmbracelet/bubbles/v2/key" tea "github.com/charmbracelet/bubbletea/v2" + "github.com/charmbracelet/crush/internal/event" "github.com/charmbracelet/crush/internal/session" "github.com/charmbracelet/crush/internal/tui/components/chat" "github.com/charmbracelet/crush/internal/tui/components/core" @@ -99,6 +100,7 @@ func (s *sessionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { selectedItem := s.sessionsList.SelectedItem() if selectedItem != nil { selected := *selectedItem + event.SessionSwitched() return s, tea.Sequence( util.CmdHandler(dialogs.CloseDialogMsg{}), util.CmdHandler( diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 0986aca31dcd779ca6fe611e1d71eff8ad6908e9..2c935810b833af01c582866ec38d5f7b277bc203 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -10,6 +10,7 @@ import ( tea "github.com/charmbracelet/bubbletea/v2" "github.com/charmbracelet/crush/internal/app" "github.com/charmbracelet/crush/internal/config" + "github.com/charmbracelet/crush/internal/event" "github.com/charmbracelet/crush/internal/llm/agent" "github.com/charmbracelet/crush/internal/permission" "github.com/charmbracelet/crush/internal/pubsub" @@ -196,6 +197,7 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if a.app.CoderAgent.IsBusy() { return a, util.ReportWarn("Agent is busy, please wait...") } + config.Get().UpdatePreferredModel(msg.ModelType, msg.Model) // Update the agent with the new model/provider configuration @@ -211,6 +213,8 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // File Picker case commands.OpenFilePickerMsg: + event.FilePickerOpened() + if a.dialog.ActiveDialogID() == filepicker.FilePickerID { // If the commands dialog is already open, close it return a, util.CmdHandler(dialogs.CloseDialogMsg{}) diff --git a/main.go b/main.go index 072e3b35d2a2f408d8ed6a09423712b324df8b96..49dbcd7d3c045ae1510d7ca2055fa480c6fadadf 100644 --- a/main.go +++ b/main.go @@ -10,11 +10,13 @@ import ( _ "github.com/joho/godotenv/autoload" // automatically load .env files "github.com/charmbracelet/crush/internal/cmd" + "github.com/charmbracelet/crush/internal/event" "github.com/charmbracelet/crush/internal/log" ) func main() { defer log.RecoverPanic("main", func() { + event.Flush() slog.Error("Application terminated due to unhandled panic") }) diff --git a/schema.json b/schema.json index f0cb2053e188d918e4c49168080026de5f0bffe5..deb65846fe30ca689779e36745b9a429082c452b 100644 --- a/schema.json +++ b/schema.json @@ -320,6 +320,11 @@ "attribution": { "$ref": "#/$defs/Attribution", "description": "Attribution settings for generated content" + }, + "disable_metrics": { + "type": "boolean", + "description": "Disable sending metrics", + "default": false } }, "additionalProperties": false, From 2cc3fe25d95f22bdf336955fca2ec1299465e895 Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Wed, 24 Sep 2025 13:49:42 -0300 Subject: [PATCH 08/22] fix: strip path from `$SHELL` (#1119) This ensure we'll collect `zsh` instead of `/bin/zsh` or `/opt/homebrew/bin/zsh`, for example. --- internal/event/event.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/event/event.go b/internal/event/event.go index 89a02411eefbbdefc94e784abbca7e3cd638027d..42272c7035638fee7167b5c3510c7975cb9c9394 100644 --- a/internal/event/event.go +++ b/internal/event/event.go @@ -4,6 +4,7 @@ import ( "fmt" "log/slog" "os" + "path/filepath" "reflect" "runtime" @@ -24,7 +25,7 @@ var ( Set("GOOS", runtime.GOOS). Set("GOARCH", runtime.GOARCH). Set("TERM", os.Getenv("TERM")). - Set("SHELL", os.Getenv("SHELL")). + Set("SHELL", filepath.Base(os.Getenv("SHELL"))). Set("Version", version.Version). Set("GoVersion", runtime.Version()) ) From 09d8e75b7be61fb501ecd28682b5a56717db4465 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Wed, 24 Sep 2025 14:35:12 -0300 Subject: [PATCH 09/22] fix(mcp/lsp): expand variable in commands (#1116) closes #806 Signed-off-by: Carlos Alexandro Becker Co-authored-by: taigr --- internal/app/lsp.go | 2 +- internal/llm/agent/mcp-tools.go | 21 +++++++++++++-------- internal/lsp/client.go | 9 +++++++-- internal/lsp/client_test.go | 9 ++++++--- 4 files changed, 27 insertions(+), 14 deletions(-) diff --git a/internal/app/lsp.go b/internal/app/lsp.go index 057e9ce39363f3fd68c8c980ce22e3e8b0e78154..f4c26af2f4ed369a94c7078600ce9639874dc643 100644 --- a/internal/app/lsp.go +++ b/internal/app/lsp.go @@ -36,7 +36,7 @@ func (app *App) createAndStartLSPClient(ctx context.Context, name string, config updateLSPState(name, lsp.StateStarting, nil, nil, 0) // Create LSP client. - lspClient, err := lsp.New(ctx, name, config) + lspClient, err := lsp.New(ctx, name, config, app.config.Resolver()) if err != nil { slog.Error("Failed to create LSP client for", name, err) updateLSPState(name, lsp.StateError, err, nil, 0) diff --git a/internal/llm/agent/mcp-tools.go b/internal/llm/agent/mcp-tools.go index 4a4435dccbdb48ea6d2d64bf7af9f257e8e3730b..d670a5797548cd52bbfd23c8cd16fea96b021e8a 100644 --- a/internal/llm/agent/mcp-tools.go +++ b/internal/llm/agent/mcp-tools.go @@ -149,7 +149,8 @@ func getOrRenewClient(ctx context.Context, name string) (*client.Client, error) return nil, fmt.Errorf("mcp '%s' not available", name) } - m := config.Get().MCP[name] + cfg := config.Get() + m := cfg.MCP[name] state, _ := mcpStates.Get(name) timeout := mcpTimeout(m) @@ -161,7 +162,7 @@ func getOrRenewClient(ctx context.Context, name string) (*client.Client, error) } updateMCPState(name, MCPStateError, maybeTimeoutErr(err, timeout), nil, state.ToolCount) - c, err = createAndInitializeClient(ctx, name, m) + c, err = createAndInitializeClient(ctx, name, m, cfg.Resolver()) if err != nil { return nil, err } @@ -313,7 +314,7 @@ func doGetMCPTools(ctx context.Context, permissions permission.Service, cfg *con ctx, cancel := context.WithTimeout(ctx, mcpTimeout(m)) defer cancel() - c, err := createAndInitializeClient(ctx, name, m) + c, err := createAndInitializeClient(ctx, name, m, cfg.Resolver()) if err != nil { return } @@ -328,8 +329,8 @@ func doGetMCPTools(ctx context.Context, permissions permission.Service, cfg *con return slices.Collect(result.Seq()) } -func createAndInitializeClient(ctx context.Context, name string, m config.MCPConfig) (*client.Client, error) { - c, err := createMcpClient(name, m) +func createAndInitializeClient(ctx context.Context, name string, m config.MCPConfig, resolver config.VariableResolver) (*client.Client, error) { + c, err := createMcpClient(name, m, resolver) if err != nil { updateMCPState(name, MCPStateError, err, nil, 0) slog.Error("error creating mcp client", "error", err, "name", name) @@ -367,14 +368,18 @@ func maybeTimeoutErr(err error, timeout time.Duration) error { return err } -func createMcpClient(name string, m config.MCPConfig) (*client.Client, error) { +func createMcpClient(name string, m config.MCPConfig, resolver config.VariableResolver) (*client.Client, error) { switch m.Type { case config.MCPStdio: - if strings.TrimSpace(m.Command) == "" { + command, err := resolver.ResolveValue(m.Command) + if err != nil { + return nil, fmt.Errorf("invalid mcp command: %w", err) + } + if strings.TrimSpace(command) == "" { return nil, fmt.Errorf("mcp stdio config requires a non-empty 'command' field") } return client.NewStdioMCPClientWithOptions( - home.Long(m.Command), + home.Long(command), m.ResolvedEnv(), m.Args, transport.WithCommandLogger(mcpLogger{name: name}), diff --git a/internal/lsp/client.go b/internal/lsp/client.go index 226d6c6f3896e29dcbc75c04bee23a34bdc85952..259f6ba8c4876dcbeb441d48839685012c48ac32 100644 --- a/internal/lsp/client.go +++ b/internal/lsp/client.go @@ -45,7 +45,7 @@ type Client struct { } // New creates a new LSP client using the powernap implementation. -func New(ctx context.Context, name string, config config.LSPConfig) (*Client, error) { +func New(ctx context.Context, name string, config config.LSPConfig, resolver config.VariableResolver) (*Client, error) { // Convert working directory to file URI workDir, err := os.Getwd() if err != nil { @@ -54,9 +54,14 @@ func New(ctx context.Context, name string, config config.LSPConfig) (*Client, er rootURI := string(protocol.URIFromPath(workDir)) + command, err := resolver.ResolveValue(config.Command) + if err != nil { + return nil, fmt.Errorf("invalid lsp command: %w", err) + } + // Create powernap client config clientConfig := powernap.ClientConfig{ - Command: home.Long(config.Command), + Command: home.Long(command), Args: config.Args, RootURI: rootURI, Environment: func() map[string]string { diff --git a/internal/lsp/client_test.go b/internal/lsp/client_test.go index 99ef0ca3143e5b8689ba3b63fd5c172456a46c24..7cc9f2f4ba230a4c6896e7ccef367a450c1c55c7 100644 --- a/internal/lsp/client_test.go +++ b/internal/lsp/client_test.go @@ -5,14 +5,15 @@ import ( "testing" "github.com/charmbracelet/crush/internal/config" + "github.com/charmbracelet/crush/internal/env" ) -func TestPowernapClient(t *testing.T) { +func TestClient(t *testing.T) { ctx := context.Background() // Create a simple config for testing cfg := config.LSPConfig{ - Command: "echo", // Use echo as a dummy command that won't fail + Command: "$THE_CMD", // Use echo as a dummy command that won't fail Args: []string{"hello"}, FileTypes: []string{"go"}, Env: map[string]string{}, @@ -20,7 +21,9 @@ func TestPowernapClient(t *testing.T) { // Test creating a powernap client - this will likely fail with echo // but we can still test the basic structure - client, err := New(ctx, "test", cfg) + client, err := New(ctx, "test", cfg, config.NewEnvironmentVariableResolver(env.NewFromMap(map[string]string{ + "THE_CMD": "echo", + }))) if err != nil { // Expected to fail with echo command, skip the rest t.Skipf("Powernap client creation failed as expected with dummy command: %v", err) From b22fd0884367fbd59286c9bd158c4fc615f20f66 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Wed, 24 Sep 2025 17:12:45 -0300 Subject: [PATCH 10/22] fix(fsext): panic on fastwalk (#1122) this seems to fix a panic I've been getting some times. bubbletea eats part of the trace, so I'm not sure it's a race, but judging by the code, and the fact that fastwalk creates multiple goroutines, it makes sense. i've been using this for the past few hours and haven't encountered the error since. Signed-off-by: Carlos Alexandro Becker --- internal/fsext/ls.go | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/internal/fsext/ls.go b/internal/fsext/ls.go index 2c46416f28a2777ddc9092883686c8a3461a9f7d..2027f734c4156572b134c012b2e3c143c364bd29 100644 --- a/internal/fsext/ls.go +++ b/internal/fsext/ls.go @@ -4,6 +4,7 @@ import ( "log/slog" "os" "path/filepath" + "slices" "strings" "sync" @@ -200,7 +201,7 @@ func (dl *directoryLister) getIgnore(path string) ignore.IgnoreParser { // ListDirectory lists files and directories in the specified path, func ListDirectory(initialPath string, ignorePatterns []string, limit int) ([]string, bool, error) { - var results []string + results := csync.NewSlice[string]() truncated := false dl := NewDirectoryLister(initialPath) @@ -227,19 +228,19 @@ func ListDirectory(initialPath string, ignorePatterns []string, limit int) ([]st if d.IsDir() { path = path + string(filepath.Separator) } - results = append(results, path) + results.Append(path) } - if limit > 0 && len(results) >= limit { + if limit > 0 && results.Len() >= limit { truncated = true return filepath.SkipAll } return nil }) - if err != nil && len(results) == 0 { + if err != nil && results.Len() == 0 { return nil, truncated, err } - return results, truncated, nil + return slices.Collect(results.Seq()), truncated, nil } From 5633f242b26e043db12004959d867406c3b71e8d Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Wed, 24 Sep 2025 14:25:33 -0300 Subject: [PATCH 11/22] fix(provider): do not retry auth errors If auth failed... its unlikely it'll work next time Signed-off-by: Carlos Alexandro Becker --- internal/llm/provider/anthropic.go | 7 +------ internal/llm/provider/gemini.go | 10 +--------- internal/llm/provider/openai.go | 7 +------ internal/llm/provider/provider.go | 2 +- 4 files changed, 4 insertions(+), 22 deletions(-) diff --git a/internal/llm/provider/anthropic.go b/internal/llm/provider/anthropic.go index a5355b09e235d791d178a445ba98095974acbef4..d07b657e4ca2861bdbf8f82401a9283c82263e57 100644 --- a/internal/llm/provider/anthropic.go +++ b/internal/llm/provider/anthropic.go @@ -493,12 +493,7 @@ func (a *anthropicClient) shouldRetry(attempts int, err error) (bool, int64, err } if apiErr.StatusCode == 401 { - a.providerOptions.apiKey, err = config.Get().Resolve(a.providerOptions.config.APIKey) - if err != nil { - return false, 0, fmt.Errorf("failed to resolve API key: %w", err) - } - a.client = createAnthropicClient(a.providerOptions, a.tp) - return true, 0, nil + return false, 0, err } // Handle context limit exceeded error (400 Bad Request) diff --git a/internal/llm/provider/gemini.go b/internal/llm/provider/gemini.go index c1db9561e7db5fd3ae8da1ae1c9ea143f5ea20ec..54835596d171ded734b245c27fc3a628ddc8c36a 100644 --- a/internal/llm/provider/gemini.go +++ b/internal/llm/provider/gemini.go @@ -436,15 +436,7 @@ func (g *geminiClient) shouldRetry(attempts int, err error) (bool, int64, error) // Check for token expiration (401 Unauthorized) if contains(errMsg, "unauthorized", "invalid api key", "api key expired") { - g.providerOptions.apiKey, err = config.Get().Resolve(g.providerOptions.config.APIKey) - if err != nil { - return false, 0, fmt.Errorf("failed to resolve API key: %w", err) - } - g.client, err = createGeminiClient(g.providerOptions) - if err != nil { - return false, 0, fmt.Errorf("failed to create Gemini client after API key refresh: %w", err) - } - return true, 0, nil + return false, 0, err } // Check for common rate limit error messages diff --git a/internal/llm/provider/openai.go b/internal/llm/provider/openai.go index 8df3989abbacbb7e46c59a0c750df8a7879789c1..587f01384a151a940dcbcdcecb71eb5ba27a554b 100644 --- a/internal/llm/provider/openai.go +++ b/internal/llm/provider/openai.go @@ -514,12 +514,7 @@ func (o *openaiClient) shouldRetry(attempts int, err error) (bool, int64, error) if errors.As(err, &apiErr) { // Check for token expiration (401 Unauthorized) if apiErr.StatusCode == 401 { - o.providerOptions.apiKey, err = config.Get().Resolve(o.providerOptions.config.APIKey) - if err != nil { - return false, 0, fmt.Errorf("failed to resolve API key: %w", err) - } - o.client = createOpenAIClient(o.providerOptions) - return true, 0, nil + return false, 0, err } if apiErr.StatusCode != 429 && apiErr.StatusCode != 500 { diff --git a/internal/llm/provider/provider.go b/internal/llm/provider/provider.go index 3705645517cd10803ede285f8d2935f43575b746..0dada9d8b1e353801fde43b1d9ebb1fc6eaa0a1e 100644 --- a/internal/llm/provider/provider.go +++ b/internal/llm/provider/provider.go @@ -13,7 +13,7 @@ import ( type EventType string -const maxRetries = 8 +const maxRetries = 3 const ( EventContentStart EventType = "content_start" From 1be21ca52a7372f2725bd07397bfa5e31f53204c Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Wed, 24 Sep 2025 14:28:01 -0300 Subject: [PATCH 12/22] refactor: use http.Status... consts Signed-off-by: Carlos Alexandro Becker --- internal/llm/provider/anthropic.go | 5 +++-- internal/llm/provider/openai.go | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/internal/llm/provider/anthropic.go b/internal/llm/provider/anthropic.go index d07b657e4ca2861bdbf8f82401a9283c82263e57..cfe8f6210fce8ad75eb930374a618e32ac1e2a03 100644 --- a/internal/llm/provider/anthropic.go +++ b/internal/llm/provider/anthropic.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "log/slog" + "net/http" "regexp" "strconv" "strings" @@ -492,12 +493,12 @@ func (a *anthropicClient) shouldRetry(attempts int, err error) (bool, int64, err return false, 0, fmt.Errorf("maximum retry attempts reached for rate limit: %d retries", maxRetries) } - if apiErr.StatusCode == 401 { + if apiErr.StatusCode == http.StatusUnauthorized { return false, 0, err } // Handle context limit exceeded error (400 Bad Request) - if apiErr.StatusCode == 400 { + if apiErr.StatusCode == http.StatusBadRequest { if adjusted, ok := a.handleContextLimitError(apiErr); ok { a.adjustedMaxTokens = adjusted slog.Debug("Adjusted max_tokens due to context limit", "new_max_tokens", adjusted) diff --git a/internal/llm/provider/openai.go b/internal/llm/provider/openai.go index 587f01384a151a940dcbcdcecb71eb5ba27a554b..d2563cfec5104145e8571d40540e9d7acd886a1d 100644 --- a/internal/llm/provider/openai.go +++ b/internal/llm/provider/openai.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "log/slog" + "net/http" "strings" "time" @@ -513,11 +514,11 @@ func (o *openaiClient) shouldRetry(attempts int, err error) (bool, int64, error) retryAfterValues := []string{} if errors.As(err, &apiErr) { // Check for token expiration (401 Unauthorized) - if apiErr.StatusCode == 401 { + if apiErr.StatusCode == http.StatusUnauthorized { return false, 0, err } - if apiErr.StatusCode != 429 && apiErr.StatusCode != 500 { + if apiErr.StatusCode != http.StatusTooManyRequests && apiErr.StatusCode != http.StatusInternalServerError { return false, 0, err } From 9365343e4f1c16e695029026ff7a97ba6320d73f Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Wed, 24 Sep 2025 14:29:08 -0300 Subject: [PATCH 13/22] refactor: use http.Status... consts Signed-off-by: Carlos Alexandro Becker --- internal/llm/provider/anthropic.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/llm/provider/anthropic.go b/internal/llm/provider/anthropic.go index cfe8f6210fce8ad75eb930374a618e32ac1e2a03..11c131c6cbc0919e9847c1820740b81cccf7f781 100644 --- a/internal/llm/provider/anthropic.go +++ b/internal/llm/provider/anthropic.go @@ -507,7 +507,7 @@ func (a *anthropicClient) shouldRetry(attempts int, err error) (bool, int64, err } isOverloaded := strings.Contains(apiErr.Error(), "overloaded") || strings.Contains(apiErr.Error(), "rate limit exceeded") - if apiErr.StatusCode != 429 && apiErr.StatusCode != 529 && !isOverloaded { + if apiErr.StatusCode != http.StatusTooManyRequests && apiErr.StatusCode != 529 && !isOverloaded { return false, 0, err } From 685c81bd7c1d5d601c4c6d65608caf08e106e51f Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Wed, 24 Sep 2025 14:30:43 -0300 Subject: [PATCH 14/22] chore: comment Signed-off-by: Carlos Alexandro Becker --- internal/llm/provider/anthropic.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/llm/provider/anthropic.go b/internal/llm/provider/anthropic.go index 11c131c6cbc0919e9847c1820740b81cccf7f781..636257565caf91fc4f7f81af20a6e742aadbd3ee 100644 --- a/internal/llm/provider/anthropic.go +++ b/internal/llm/provider/anthropic.go @@ -507,6 +507,7 @@ func (a *anthropicClient) shouldRetry(attempts int, err error) (bool, int64, err } isOverloaded := strings.Contains(apiErr.Error(), "overloaded") || strings.Contains(apiErr.Error(), "rate limit exceeded") + // 529 (unofficial): The service is overloaded if apiErr.StatusCode != http.StatusTooManyRequests && apiErr.StatusCode != 529 && !isOverloaded { return false, 0, err } From fa822909be01eda4de16064eb188160a9e9db77b Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Wed, 24 Sep 2025 14:38:56 -0300 Subject: [PATCH 15/22] fix: improve retry Signed-off-by: Carlos Alexandro Becker --- internal/llm/provider/anthropic.go | 13 ++++++++++++- internal/llm/provider/gemini.go | 16 +++++++++++++++- internal/llm/provider/openai.go | 13 ++++++++++++- 3 files changed, 39 insertions(+), 3 deletions(-) diff --git a/internal/llm/provider/anthropic.go b/internal/llm/provider/anthropic.go index 636257565caf91fc4f7f81af20a6e742aadbd3ee..9648bfd282b5b467399cb715389c5c5969fba121 100644 --- a/internal/llm/provider/anthropic.go +++ b/internal/llm/provider/anthropic.go @@ -509,7 +509,18 @@ func (a *anthropicClient) shouldRetry(attempts int, err error) (bool, int64, err isOverloaded := strings.Contains(apiErr.Error(), "overloaded") || strings.Contains(apiErr.Error(), "rate limit exceeded") // 529 (unofficial): The service is overloaded if apiErr.StatusCode != http.StatusTooManyRequests && apiErr.StatusCode != 529 && !isOverloaded { - return false, 0, err + prev := a.providerOptions.apiKey + // in case the key comes from a script, we try to re-evaluate it. + a.providerOptions.apiKey, err = config.Get().Resolve(a.providerOptions.config.APIKey) + if err != nil { + return false, 0, fmt.Errorf("failed to resolve API key: %w", err) + } + // if it didn't change, do not retry. + if prev == a.providerOptions.apiKey { + return false, 0, err + } + a.client = createAnthropicClient(a.providerOptions, a.tp) + return true, 0, nil } retryMs := 0 diff --git a/internal/llm/provider/gemini.go b/internal/llm/provider/gemini.go index 54835596d171ded734b245c27fc3a628ddc8c36a..3987deb7ebcc6330c9d3bcb4a52aeeb292eab43f 100644 --- a/internal/llm/provider/gemini.go +++ b/internal/llm/provider/gemini.go @@ -436,7 +436,21 @@ func (g *geminiClient) shouldRetry(attempts int, err error) (bool, int64, error) // Check for token expiration (401 Unauthorized) if contains(errMsg, "unauthorized", "invalid api key", "api key expired") { - return false, 0, err + prev := g.providerOptions.apiKey + // in case the key comes from a script, we try to re-evaluate it. + g.providerOptions.apiKey, err = config.Get().Resolve(g.providerOptions.config.APIKey) + if err != nil { + return false, 0, fmt.Errorf("failed to resolve API key: %w", err) + } + // if it didn't change, do not retry. + if prev == g.providerOptions.apiKey { + return false, 0, err + } + g.client, err = createGeminiClient(g.providerOptions) + if err != nil { + return false, 0, fmt.Errorf("failed to create Gemini client after API key refresh: %w", err) + } + return true, 0, nil } // Check for common rate limit error messages diff --git a/internal/llm/provider/openai.go b/internal/llm/provider/openai.go index d2563cfec5104145e8571d40540e9d7acd886a1d..8ec366caff4156fbf4baae76fc24ce5c30d4a91d 100644 --- a/internal/llm/provider/openai.go +++ b/internal/llm/provider/openai.go @@ -515,7 +515,18 @@ func (o *openaiClient) shouldRetry(attempts int, err error) (bool, int64, error) if errors.As(err, &apiErr) { // Check for token expiration (401 Unauthorized) if apiErr.StatusCode == http.StatusUnauthorized { - return false, 0, err + prev := o.providerOptions.apiKey + // in case the key comes from a script, we try to re-evaluate it. + o.providerOptions.apiKey, err = config.Get().Resolve(o.providerOptions.config.APIKey) + if err != nil { + return false, 0, fmt.Errorf("failed to resolve API key: %w", err) + } + // if it didn't change, do not retry. + if prev == o.providerOptions.apiKey { + return false, 0, err + } + o.client = createOpenAIClient(o.providerOptions) + return true, 0, nil } if apiErr.StatusCode != http.StatusTooManyRequests && apiErr.StatusCode != http.StatusInternalServerError { From 63eda4deeb95abdd683edb31c187de22d5ac3dc5 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Wed, 24 Sep 2025 14:39:58 -0300 Subject: [PATCH 16/22] fix: improve retry Signed-off-by: Carlos Alexandro Becker --- internal/llm/provider/anthropic.go | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/internal/llm/provider/anthropic.go b/internal/llm/provider/anthropic.go index 9648bfd282b5b467399cb715389c5c5969fba121..981ff4590fd7db92288ff11b3d8f607e594cb0fd 100644 --- a/internal/llm/provider/anthropic.go +++ b/internal/llm/provider/anthropic.go @@ -494,7 +494,18 @@ func (a *anthropicClient) shouldRetry(attempts int, err error) (bool, int64, err } if apiErr.StatusCode == http.StatusUnauthorized { - return false, 0, err + prev := a.providerOptions.apiKey + // in case the key comes from a script, we try to re-evaluate it. + a.providerOptions.apiKey, err = config.Get().Resolve(a.providerOptions.config.APIKey) + if err != nil { + return false, 0, fmt.Errorf("failed to resolve API key: %w", err) + } + // if it didn't change, do not retry. + if prev == a.providerOptions.apiKey { + return false, 0, err + } + a.client = createAnthropicClient(a.providerOptions, a.tp) + return true, 0, nil } // Handle context limit exceeded error (400 Bad Request) @@ -509,18 +520,7 @@ func (a *anthropicClient) shouldRetry(attempts int, err error) (bool, int64, err isOverloaded := strings.Contains(apiErr.Error(), "overloaded") || strings.Contains(apiErr.Error(), "rate limit exceeded") // 529 (unofficial): The service is overloaded if apiErr.StatusCode != http.StatusTooManyRequests && apiErr.StatusCode != 529 && !isOverloaded { - prev := a.providerOptions.apiKey - // in case the key comes from a script, we try to re-evaluate it. - a.providerOptions.apiKey, err = config.Get().Resolve(a.providerOptions.config.APIKey) - if err != nil { - return false, 0, fmt.Errorf("failed to resolve API key: %w", err) - } - // if it didn't change, do not retry. - if prev == a.providerOptions.apiKey { - return false, 0, err - } - a.client = createAnthropicClient(a.providerOptions, a.tp) - return true, 0, nil + return false, 0, err } retryMs := 0 From 51cee9e947104ece95de6553bd06ce4f1d3cc866 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Wed, 24 Sep 2025 11:57:14 -0300 Subject: [PATCH 17/22] chore: add task release you can use it as: ```sh task release -- -m 'some short description' ``` and it'll: - figure out the next tag using svu - check if you're on main - check branch is clean - drop the nightly tag (it's recreated, so if you have an old one, `git push --tags` will complain about it) - `git tag --sign ` + any args you pass to release - `git push --tags` Signed-off-by: Carlos Alexandro Becker --- Taskfile.yaml | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/Taskfile.yaml b/Taskfile.yaml index 443531fa2435d5557536a4d2e6d88014ea4a5677..7f821f584704393dffc750795e0c48ecdf5ea8ab 100644 --- a/Taskfile.yaml +++ b/Taskfile.yaml @@ -84,3 +84,20 @@ tasks: - echo "Generated schema.json" generates: - schema.json + + release: + desc: Create and push a new tag following semver + vars: + NEXT: + sh: go run github.com/caarlos0/svu@latest next + prompt: "This will release {{.NEXT}}. Continue?" + preconditions: + - sh: '[ $(git symbolic-ref --short HEAD) = "main" ]' + msg: Not on main branch + - sh: "[ $(git status --porcelain=2 | wc -l) = 0 ]" + msg: "Git is dirty" + cmds: + - git tag -d nightly + - git tag --sign {{.NEXT}} {{.CLI_ARGS}} + - echo "pushing {{.NEXT}}..." + - git push origin --tags From 5f7c46dd1418f8ce7c7f11aeed7e278a1592f799 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Wed, 24 Sep 2025 12:53:14 -0300 Subject: [PATCH 18/22] chore: fix version Signed-off-by: Carlos Alexandro Becker --- Taskfile.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Taskfile.yaml b/Taskfile.yaml index 7f821f584704393dffc750795e0c48ecdf5ea8ab..3388022a61eb8bbaa3410c06e57e4914247a5dbf 100644 --- a/Taskfile.yaml +++ b/Taskfile.yaml @@ -89,7 +89,7 @@ tasks: desc: Create and push a new tag following semver vars: NEXT: - sh: go run github.com/caarlos0/svu@latest next + sh: go run github.com/caarlos0/svu/v3@latest next prompt: "This will release {{.NEXT}}. Continue?" preconditions: - sh: '[ $(git symbolic-ref --short HEAD) = "main" ]' From 4203e52994fc9b8942cbca3bad0a01aa16af9065 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Wed, 24 Sep 2025 13:30:40 -0300 Subject: [PATCH 19/22] Update Taskfile.yaml Co-authored-by: Andrey Nering --- Taskfile.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Taskfile.yaml b/Taskfile.yaml index 3388022a61eb8bbaa3410c06e57e4914247a5dbf..80d6bd86d1070e2f4e900660a7cab060ebdfbcea 100644 --- a/Taskfile.yaml +++ b/Taskfile.yaml @@ -89,7 +89,7 @@ tasks: desc: Create and push a new tag following semver vars: NEXT: - sh: go run github.com/caarlos0/svu/v3@latest next + sh: go run github.com/caarlos0/svu/v3@latest next --always prompt: "This will release {{.NEXT}}. Continue?" preconditions: - sh: '[ $(git symbolic-ref --short HEAD) = "main" ]' From a6a4fa7e419fc9c9ba0ed3d530120d25b0e355b2 Mon Sep 17 00:00:00 2001 From: tauraamui Date: Wed, 24 Sep 2025 12:02:43 +0100 Subject: [PATCH 20/22] chore: add name for helper tool name resolver --- internal/config/config.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/config/config.go b/internal/config/config.go index 3578850d228b78503e67e630a9b688c575403b9c..fc5d62ef1c361c4e4aae29a2683ed92c8e76fd9d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -428,6 +428,7 @@ func (c *Config) SetProviderAPIKey(providerID, apiKey string) error { func allToolNames() []string { return []string{ + "agent", "bash", "download", "edit", From 925e5faf85622f5a1ea5aeb6758f194ec8673a15 Mon Sep 17 00:00:00 2001 From: tauraamui Date: Wed, 24 Sep 2025 12:03:19 +0100 Subject: [PATCH 21/22] feat: if agent has been disabled do not set the agent fn --- internal/llm/agent/agent.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/llm/agent/agent.go b/internal/llm/agent/agent.go index 74b1cb74659238de917c823872698f1b2ed31332..44efba31835aa4d68a79538fd637f1eff43cbb3e 100644 --- a/internal/llm/agent/agent.go +++ b/internal/llm/agent/agent.go @@ -100,7 +100,7 @@ func NewAgent( cfg := config.Get() var agentToolFn func() (tools.BaseTool, error) - if agentCfg.ID == "coder" { + if agentCfg.ID == "coder" && slices.Contains(agentCfg.AllowedTools, AgentToolName) { agentToolFn = func() (tools.BaseTool, error) { taskAgentCfg := config.Get().Agents["task"] if taskAgentCfg.ID == "" { From 32dac11c423726a485731d8914b083ed8cc3dcc8 Mon Sep 17 00:00:00 2001 From: tauraamui Date: Wed, 24 Sep 2025 12:03:34 +0100 Subject: [PATCH 22/22] test: ensure agent tool name is on list of tool names --- internal/config/load_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/config/load_test.go b/internal/config/load_test.go index 756f849db426e226c197879740f0dc47d3048dd9..406fe07d523c8b0d5d7f038f8d94cc74a0b58f89 100644 --- a/internal/config/load_test.go +++ b/internal/config/load_test.go @@ -485,7 +485,7 @@ func TestConfig_setupAgentsWithDisabledTools(t *testing.T) { cfg.SetupAgents() coderAgent, ok := cfg.Agents["coder"] require.True(t, ok) - assert.Equal(t, []string{"bash", "multiedit", "fetch", "glob", "ls", "sourcegraph", "view", "write"}, coderAgent.AllowedTools) + assert.Equal(t, []string{"agent", "bash", "multiedit", "fetch", "glob", "ls", "sourcegraph", "view", "write"}, coderAgent.AllowedTools) taskAgent, ok := cfg.Agents["task"] require.True(t, ok) @@ -508,7 +508,7 @@ func TestConfig_setupAgentsWithEveryReadOnlyToolDisabled(t *testing.T) { cfg.SetupAgents() coderAgent, ok := cfg.Agents["coder"] require.True(t, ok) - assert.Equal(t, []string{"bash", "download", "edit", "multiedit", "fetch", "write"}, coderAgent.AllowedTools) + assert.Equal(t, []string{"agent", "bash", "download", "edit", "multiedit", "fetch", "write"}, coderAgent.AllowedTools) taskAgent, ok := cfg.Agents["task"] require.True(t, ok)