From 23e0fd441709aa040dda95d00de19ff2770ab037 Mon Sep 17 00:00:00 2001 From: Evan Date: Wed, 15 Oct 2025 15:58:56 -0400 Subject: [PATCH 01/21] fix(mcp): add type assertion guards (#1239) Signed-off-by: Evan Wies --- internal/llm/agent/mcp-tools.go | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/internal/llm/agent/mcp-tools.go b/internal/llm/agent/mcp-tools.go index 67f0b39ccfb6eb8aad3abd337e7545a59766d872..038cd43f4469953779799b70850355ef5dcda45f 100644 --- a/internal/llm/agent/mcp-tools.go +++ b/internal/llm/agent/mcp-tools.go @@ -98,9 +98,26 @@ func (b *McpTool) Name() string { } func (b *McpTool) Info() tools.ToolInfo { - input := b.tool.InputSchema.(map[string]any) - required, _ := input["required"].([]string) - parameters, _ := input["properties"].(map[string]any) + var parameters map[string]any + var required []string + + if input, ok := b.tool.InputSchema.(map[string]any); ok { + if props, ok := input["properties"].(map[string]any); ok { + parameters = props + } + if req, ok := input["required"].([]any); ok { + // Convert []any -> []string when elements are strings + for _, v := range req { + if s, ok := v.(string); ok { + required = append(required, s) + } + } + } else if reqStr, ok := input["required"].([]string); ok { + // Handle case where it's already []string + required = reqStr + } + } + return tools.ToolInfo{ Name: fmt.Sprintf("mcp_%s_%s", b.mcpName, b.tool.Name), Description: b.tool.Description, From 05457d52a5467a1168676b8bdff802bac9cddd54 Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Wed, 15 Oct 2025 16:00:50 -0400 Subject: [PATCH 02/21] refactor: use clamp from /x/exp/ordered (#1236) --- go.mod | 2 ++ go.sum | 2 ++ internal/tui/components/chat/messages/messages.go | 5 +++-- internal/tui/exp/list/list.go | 5 +++-- internal/tui/util/util.go | 7 ------- 5 files changed, 10 insertions(+), 11 deletions(-) diff --git a/go.mod b/go.mod index 5f32e148b92ac8e6c456157465061c759d267dd9..15ad5f22d82ad649decda39907f0911650a8b5f5 100644 --- a/go.mod +++ b/go.mod @@ -46,6 +46,8 @@ require ( mvdan.cc/sh/v3 v3.12.1-0.20250902163504-3cf4fd5717a5 ) +require github.com/charmbracelet/x/exp/ordered v0.1.0 + require ( cloud.google.com/go v0.116.0 // indirect cloud.google.com/go/auth v0.13.0 // indirect diff --git a/go.sum b/go.sum index 2d53e85a40001ea9241e4c7ee728baa734a889d9..d1c0349a66d5e8c0e9bf6968d849cb0cbf6d26c5 100644 --- a/go.sum +++ b/go.sum @@ -102,6 +102,8 @@ github.com/charmbracelet/x/exp/charmtone v0.0.0-20250708181618-a60a724ba6c3 h1:1 github.com/charmbracelet/x/exp/charmtone v0.0.0-20250708181618-a60a724ba6c3/go.mod h1:T9jr8CzFpjhFVHjNjKwbAD7KwBNyFnj2pntAO7F2zw0= github.com/charmbracelet/x/exp/golden v0.0.0-20250207160936-21c02780d27a h1:FsHEJ52OC4VuTzU8t+n5frMjLvpYWEznSr/u8tnkCYw= github.com/charmbracelet/x/exp/golden v0.0.0-20250207160936-21c02780d27a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sBf+2agPBgnOFE= +github.com/charmbracelet/x/exp/ordered v0.1.0/go.mod h1:5UHwmG+is5THxMyCJHNPCn2/ecI07aKNrW+LcResjJ8= github.com/charmbracelet/x/exp/slice v0.0.0-20250829135019-44e44e21330d h1:H2oh4WlSsXy8qwLd7I3eAvPd/X3S40aM9l+h47WF1eA= github.com/charmbracelet/x/exp/slice v0.0.0-20250829135019-44e44e21330d/go.mod h1:vI5nDVMWi6veaYH+0Fmvpbe/+cv/iJfMntdh+N0+Tms= github.com/charmbracelet/x/powernap v0.0.0-20250919153222-1038f7e6fef4 h1:ZhDGU688EHQXslD9KphRpXwK0pKP03egUoZAATUDlV0= diff --git a/internal/tui/components/chat/messages/messages.go b/internal/tui/components/chat/messages/messages.go index 296b02478a7d0738fef2f60ae6b2211d44424a2f..d931ba7e179255d6639db78ebea5e82b57af1504 100644 --- a/internal/tui/components/chat/messages/messages.go +++ b/internal/tui/components/chat/messages/messages.go @@ -12,6 +12,7 @@ import ( "github.com/charmbracelet/catwalk/pkg/catwalk" "github.com/charmbracelet/lipgloss/v2" "github.com/charmbracelet/x/ansi" + "github.com/charmbracelet/x/exp/ordered" "github.com/google/uuid" "github.com/atotto/clipboard" @@ -271,7 +272,7 @@ func (m *messageCmp) renderThinkingContent() string { } } fullContent := content.String() - height := util.Clamp(lipgloss.Height(fullContent), 1, 10) + height := ordered.Clamp(lipgloss.Height(fullContent), 1, 10) m.thinkingViewport.SetHeight(height) m.thinkingViewport.SetWidth(m.textWidth()) m.thinkingViewport.SetContent(fullContent) @@ -344,7 +345,7 @@ func (m *messageCmp) GetSize() (int, int) { // SetSize updates the width of the message component for text wrapping func (m *messageCmp) SetSize(width int, height int) tea.Cmd { - m.width = util.Clamp(width, 1, 120) + m.width = ordered.Clamp(width, 1, 120) m.thinkingViewport.SetWidth(m.width - 4) return nil } diff --git a/internal/tui/exp/list/list.go b/internal/tui/exp/list/list.go index fd789f90b89b016abb9b9fb5c79227da7ef30fd9..e18b88348959c59190f1741698f76c33f04571db 100644 --- a/internal/tui/exp/list/list.go +++ b/internal/tui/exp/list/list.go @@ -15,6 +15,7 @@ import ( "github.com/charmbracelet/lipgloss/v2" uv "github.com/charmbracelet/ultraviolet" "github.com/charmbracelet/x/ansi" + "github.com/charmbracelet/x/exp/ordered" "github.com/rivo/uniseg" ) @@ -1283,14 +1284,14 @@ func (l *list[T]) UpdateItem(id string, item T) tea.Cmd { newItem, ok := l.renderedItems.Get(item.ID()) if ok { newLines := newItem.height - oldItem.height - l.offset = util.Clamp(l.offset+newLines, 0, lipgloss.Height(l.rendered)-1) + l.offset = ordered.Clamp(l.offset+newLines, 0, lipgloss.Height(l.rendered)-1) } } } else if hasOldItem && l.offset > oldItem.start { newItem, ok := l.renderedItems.Get(item.ID()) if ok { newLines := newItem.height - oldItem.height - l.offset = util.Clamp(l.offset+newLines, 0, lipgloss.Height(l.rendered)-1) + l.offset = ordered.Clamp(l.offset+newLines, 0, lipgloss.Height(l.rendered)-1) } } } diff --git a/internal/tui/util/util.go b/internal/tui/util/util.go index 1f4ea30c49c8fb0517a5068d3b7f05970638743a..eb19ad89544b281af2e836f667ac63aaa6414e01 100644 --- a/internal/tui/util/util.go +++ b/internal/tui/util/util.go @@ -60,10 +60,3 @@ type ( } ClearStatusMsg struct{} ) - -func Clamp(v, low, high int) int { - if high < low { - low, high = high, low - } - return min(high, max(low, v)) -} From 1932bcebd41811e8605dc626158d6ec2e3e9118b Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Wed, 15 Oct 2025 17:01:29 -0300 Subject: [PATCH 03/21] chore: go mod tidy Signed-off-by: Carlos Alexandro Becker --- go.mod | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/go.mod b/go.mod index 15ad5f22d82ad649decda39907f0911650a8b5f5..e0b92a9380af54233306de80c826a5191878298a 100644 --- a/go.mod +++ b/go.mod @@ -22,6 +22,7 @@ require ( github.com/charmbracelet/x/ansi v0.10.2 github.com/charmbracelet/x/exp/charmtone v0.0.0-20250708181618-a60a724ba6c3 github.com/charmbracelet/x/exp/golden v0.0.0-20250207160936-21c02780d27a + github.com/charmbracelet/x/exp/ordered v0.1.0 github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec github.com/google/uuid v1.6.0 github.com/invopop/jsonschema v0.13.0 @@ -46,8 +47,6 @@ require ( mvdan.cc/sh/v3 v3.12.1-0.20250902163504-3cf4fd5717a5 ) -require github.com/charmbracelet/x/exp/ordered v0.1.0 - require ( cloud.google.com/go v0.116.0 // indirect cloud.google.com/go/auth v0.13.0 // indirect From 4b1001cf3391473faf43233a10cc4240ec147eb9 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Wed, 15 Oct 2025 17:13:14 -0300 Subject: [PATCH 04/21] fix(tui): paste on arguments input (#1240) Signed-off-by: Carlos Alexandro Becker --- internal/tui/components/dialogs/commands/arguments.go | 2 -- internal/tui/components/dialogs/commands/keys.go | 7 ------- 2 files changed, 9 deletions(-) diff --git a/internal/tui/components/dialogs/commands/arguments.go b/internal/tui/components/dialogs/commands/arguments.go index b1a274319719b9f550179b35aa98fd8310e0bb7b..72677bc934864970c2cbded87b31853ad702a6ed 100644 --- a/internal/tui/components/dialogs/commands/arguments.go +++ b/internal/tui/components/dialogs/commands/arguments.go @@ -128,8 +128,6 @@ func (c *commandArgumentsDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { c.inputs[c.focusIndex].Blur() c.focusIndex = (c.focusIndex - 1 + len(c.inputs)) % len(c.inputs) c.inputs[c.focusIndex].Focus() - case key.Matches(msg, c.keys.Paste): - return c, textinput.Paste case key.Matches(msg, c.keys.Close): return c, util.CmdHandler(dialogs.CloseDialogMsg{}) default: diff --git a/internal/tui/components/dialogs/commands/keys.go b/internal/tui/components/dialogs/commands/keys.go index b704f227fe6f183a430bd25d3af62f4ef50b3365..65d4af84c22c87117bf5a08427027da5ee0e244f 100644 --- a/internal/tui/components/dialogs/commands/keys.go +++ b/internal/tui/components/dialogs/commands/keys.go @@ -76,7 +76,6 @@ type ArgumentsDialogKeyMap struct { Confirm key.Binding Next key.Binding Previous key.Binding - Paste key.Binding Close key.Binding } @@ -95,10 +94,6 @@ func DefaultArgumentsDialogKeyMap() ArgumentsDialogKeyMap { key.WithKeys("shift+tab", "up"), key.WithHelp("shift+tab/↑", "previous"), ), - Paste: key.NewBinding( - key.WithKeys("ctrl+v"), - key.WithHelp("ctrl+v", "paste"), - ), Close: key.NewBinding( key.WithKeys("esc", "alt+esc"), key.WithHelp("esc", "cancel"), @@ -112,7 +107,6 @@ func (k ArgumentsDialogKeyMap) KeyBindings() []key.Binding { k.Confirm, k.Next, k.Previous, - k.Paste, k.Close, } } @@ -134,7 +128,6 @@ func (k ArgumentsDialogKeyMap) ShortHelp() []key.Binding { k.Confirm, k.Next, k.Previous, - k.Paste, k.Close, } } From ce72a48378780d5fc76da7333353348aa05502fe Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Thu, 16 Oct 2025 08:58:56 -0300 Subject: [PATCH 05/21] fix(mcp): append to os.Environ() (#1242) Signed-off-by: Carlos Alexandro Becker --- internal/llm/agent/mcp-tools.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/llm/agent/mcp-tools.go b/internal/llm/agent/mcp-tools.go index 038cd43f4469953779799b70850355ef5dcda45f..6209efa29fb7d9a3a488ca730f6f8175e3b08a60 100644 --- a/internal/llm/agent/mcp-tools.go +++ b/internal/llm/agent/mcp-tools.go @@ -10,6 +10,7 @@ import ( "log/slog" "maps" "net/http" + "os" "os/exec" "strings" "sync" @@ -415,7 +416,7 @@ func createMCPTransport(ctx context.Context, m config.MCPConfig, resolver config return nil, fmt.Errorf("mcp stdio config requires a non-empty 'command' field") } cmd := exec.CommandContext(ctx, home.Long(command), m.Args...) - cmd.Env = m.ResolvedEnv() + cmd.Env = append(os.Environ(), m.ResolvedEnv()...) return &mcp.CommandTransport{ Command: cmd, }, nil From 2aa53614d2970fba5674d439a0fec0e531fd6d93 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Thu, 16 Oct 2025 10:11:52 -0300 Subject: [PATCH 06/21] test: add tests for the dirs cmd (#1243) Signed-off-by: Carlos Alexandro Becker --- internal/cmd/dirs_test.go | 46 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 internal/cmd/dirs_test.go diff --git a/internal/cmd/dirs_test.go b/internal/cmd/dirs_test.go new file mode 100644 index 0000000000000000000000000000000000000000..2d68f45481a61b4ee9cf9ddc31b8d86d8a69a51f --- /dev/null +++ b/internal/cmd/dirs_test.go @@ -0,0 +1,46 @@ +package cmd + +import ( + "bytes" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +func init() { + os.Setenv("XDG_CONFIG_HOME", "/tmp/fakeconfig") + os.Setenv("XDG_DATA_HOME", "/tmp/fakedata") +} + +func TestDirs(t *testing.T) { + var b bytes.Buffer + dirsCmd.SetOut(&b) + dirsCmd.SetErr(&b) + dirsCmd.SetIn(bytes.NewReader(nil)) + dirsCmd.Run(dirsCmd, nil) + expected := filepath.FromSlash("/tmp/fakeconfig/crush") + "\n" + + filepath.FromSlash("/tmp/fakedata/crush") + "\n" + require.Equal(t, expected, b.String()) +} + +func TestConfigDir(t *testing.T) { + var b bytes.Buffer + configDirCmd.SetOut(&b) + configDirCmd.SetErr(&b) + configDirCmd.SetIn(bytes.NewReader(nil)) + configDirCmd.Run(configDirCmd, nil) + expected := filepath.FromSlash("/tmp/fakeconfig/crush") + "\n" + require.Equal(t, expected, b.String()) +} + +func TestDataDir(t *testing.T) { + var b bytes.Buffer + dataDirCmd.SetOut(&b) + dataDirCmd.SetErr(&b) + dataDirCmd.SetIn(bytes.NewReader(nil)) + dataDirCmd.Run(dataDirCmd, nil) + expected := filepath.FromSlash("/tmp/fakedata/crush") + "\n" + require.Equal(t, expected, b.String()) +} From 6166fc6fc9a57b9b69a447560563d61abe7e8d46 Mon Sep 17 00:00:00 2001 From: Charm <124303983+charmcli@users.noreply.github.com> Date: Thu, 16 Oct 2025 14:30:16 -0300 Subject: [PATCH 07/21] chore(legal): @BrunoKrugel 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 61a8b03447ae07a5dc775ca59a5eef7aacfe9c2b..12b86fb4c2936e871a1a022a150385b3744b23cf 100644 --- a/.github/cla-signatures.json +++ b/.github/cla-signatures.json @@ -711,6 +711,14 @@ "created_at": "2025-10-13T05:56:20Z", "repoId": 987670088, "pullRequestNo": 1223 + }, + { + "name": "BrunoKrugel", + "id": 30608179, + "comment_id": 3411978929, + "created_at": "2025-10-16T17:30:07Z", + "repoId": 987670088, + "pullRequestNo": 1245 } ] } \ No newline at end of file From b896a2584775d3b4f4179a2774350fee4b6313cf Mon Sep 17 00:00:00 2001 From: Bruno Krugel Date: Thu, 16 Oct 2025 14:34:50 -0300 Subject: [PATCH 08/21] fix(mcp): avoid nil errors for tool parameters (#1245) --- CRUSH.md | 2 +- internal/llm/agent/mcp-tools.go | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CRUSH.md b/CRUSH.md index 102ad43ca5758beee6515ab9da4054ddc92b9a9f..dee2e7ba62baeb2af691828ed67dddf3446d4525 100644 --- a/CRUSH.md +++ b/CRUSH.md @@ -54,7 +54,7 @@ func TestYourFunction(t *testing.T) { ## Formatting - ALWAYS format any Go code you write. - - First, try `goftumpt -w .`. + - First, try `gofumpt -w .`. - If `gofumpt` is not available, use `goimports`. - If `goimports` is not available, use `gofmt`. - You can also use `task fmt` to run `gofumpt -w .` on the entire project, diff --git a/internal/llm/agent/mcp-tools.go b/internal/llm/agent/mcp-tools.go index 6209efa29fb7d9a3a488ca730f6f8175e3b08a60..041cff490a59f1de51505e833cc7ee7866aa7644 100644 --- a/internal/llm/agent/mcp-tools.go +++ b/internal/llm/agent/mcp-tools.go @@ -99,8 +99,8 @@ func (b *McpTool) Name() string { } func (b *McpTool) Info() tools.ToolInfo { - var parameters map[string]any - var required []string + parameters := make(map[string]any) + required := make([]string, 0) if input, ok := b.tool.InputSchema.(map[string]any); ok { if props, ok := input["properties"].(map[string]any); ok { From 3a9954297f6d5c20e53d4a64335d5bccb9323792 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Thu, 16 Oct 2025 14:36:58 -0300 Subject: [PATCH 09/21] fix(mcp): improve STDIO error handling (#1244) Signed-off-by: Carlos Alexandro Becker --- internal/llm/agent/mcp-tools.go | 34 +++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/internal/llm/agent/mcp-tools.go b/internal/llm/agent/mcp-tools.go index 041cff490a59f1de51505e833cc7ee7866aa7644..d2ff6454e9a7135ee9404ef665495772b90ba86c 100644 --- a/internal/llm/agent/mcp-tools.go +++ b/internal/llm/agent/mcp-tools.go @@ -387,6 +387,7 @@ func createMCPSession(ctx context.Context, name string, m config.MCPConfig, reso session, err := client.Connect(mcpCtx, transport, nil) if err != nil { + err = maybeStdioErr(err, transport) updateMCPState(name, MCPStateError, maybeTimeoutErr(err, timeout), nil, 0) slog.Error("error starting mcp client", "error", err, "name", name) cancel() @@ -398,6 +399,27 @@ func createMCPSession(ctx context.Context, name string, m config.MCPConfig, reso return session, nil } +// maybeStdioErr if a stdio mcp prints an error in non-json format, it'll fail +// to parse, and the cli will then close it, causing the EOF error. +// so, if we got an EOF err, and the transport is STDIO, we try to exec it +// again with a timeout and collect the output so we can add details to the +// error. +// this happens particularly when starting things with npx, e.g. if node can't +// be found or some other error like that. +func maybeStdioErr(err error, transport mcp.Transport) error { + if !errors.Is(err, io.EOF) { + return err + } + ct, ok := transport.(*mcp.CommandTransport) + if !ok { + return err + } + if err2 := stdioMCPCheck(ct.Command); err2 != nil { + err = errors.Join(err, err2) + } + return err +} + func maybeTimeoutErr(err error, timeout time.Duration) error { if errors.Is(err, context.Canceled) { return fmt.Errorf("timed out after %s", timeout) @@ -465,3 +487,15 @@ func (rt headerRoundTripper) RoundTrip(req *http.Request) (*http.Response, error func mcpTimeout(m config.MCPConfig) time.Duration { return time.Duration(cmp.Or(m.Timeout, 15)) * time.Second } + +func stdioMCPCheck(old *exec.Cmd) error { + ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) + defer cancel() + cmd := exec.CommandContext(ctx, old.Path, old.Args...) + cmd.Env = old.Env + out, err := cmd.CombinedOutput() + if err == nil || errors.Is(ctx.Err(), context.DeadlineExceeded) { + return nil + } + return fmt.Errorf("%w: %s", err, string(out)) +} From 015632a146db8ce0d04c989b4394f67490ad0c21 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Thu, 16 Oct 2025 19:36:29 -0300 Subject: [PATCH 11/21] fix(mcp): make sure to cancel context on error (#1246) Signed-off-by: Carlos Alexandro Becker --- Taskfile.yaml | 2 +- internal/llm/agent/mcp-tools.go | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/Taskfile.yaml b/Taskfile.yaml index 92b162dfbb847356e09eb17ea5996e6093a305b2..65a7e2d42fe8dcb307bced7c1fa9a0326b35ccc1 100644 --- a/Taskfile.yaml +++ b/Taskfile.yaml @@ -99,7 +99,7 @@ tasks: cmds: - task: fetch-tags - git commit --allow-empty -m "{{.NEXT}}" - - git tag --annotate -m "{{.NEXT}}" {{.NEXT}} {{.CLI_ARGS}} + - git tag --annotate --sign -m "{{.NEXT}}" {{.NEXT}} {{.CLI_ARGS}} - echo "Pushing {{.NEXT}}..." - git push origin main --follow-tags diff --git a/internal/llm/agent/mcp-tools.go b/internal/llm/agent/mcp-tools.go index d2ff6454e9a7135ee9404ef665495772b90ba86c..6838c54ab4dc8cface0eb311e0fb933a5c18aae6 100644 --- a/internal/llm/agent/mcp-tools.go +++ b/internal/llm/agent/mcp-tools.go @@ -365,6 +365,8 @@ func createMCPSession(ctx context.Context, name string, m config.MCPConfig, reso if err != nil { updateMCPState(name, MCPStateError, err, nil, 0) slog.Error("error creating mcp client", "error", err, "name", name) + cancel() + cancelTimer.Stop() return nil, err } @@ -391,6 +393,7 @@ func createMCPSession(ctx context.Context, name string, m config.MCPConfig, reso updateMCPState(name, MCPStateError, maybeTimeoutErr(err, timeout), nil, 0) slog.Error("error starting mcp client", "error", err, "name", name) cancel() + cancelTimer.Stop() return nil, err } From a64a4def3ea855ac2c84cd0c12d165fe5098b1a5 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Fri, 17 Oct 2025 09:56:58 -0300 Subject: [PATCH 12/21] feat(lsp): find references tool (#1233) Signed-off-by: Carlos Alexandro Becker --- go.mod | 2 +- go.sum | 4 +- internal/llm/agent/agent.go | 2 +- internal/llm/tools/diagnostics.go | 2 +- internal/llm/tools/grep.go | 108 +++++++------- internal/llm/tools/grep_test.go | 29 ++++ internal/llm/tools/references.go | 214 +++++++++++++++++++++++++++ internal/llm/tools/references.md | 36 +++++ internal/llm/tools/rg.go | 2 +- internal/llm/tools/testdata/grep.txt | 3 + internal/lsp/client.go | 10 ++ 11 files changed, 348 insertions(+), 64 deletions(-) create mode 100644 internal/llm/tools/references.go create mode 100644 internal/llm/tools/references.md create mode 100644 internal/llm/tools/testdata/grep.txt diff --git a/go.mod b/go.mod index e0b92a9380af54233306de80c826a5191878298a..c0bc32fe29ac100f98c589edf7697f104aa854a5 100644 --- a/go.mod +++ b/go.mod @@ -77,7 +77,7 @@ require ( github.com/charmbracelet/ultraviolet v0.0.0-20250915111650-81d4262876ef github.com/charmbracelet/x/cellbuf v0.0.14-0.20250811133356-e0c5dbe5ea4a // indirect github.com/charmbracelet/x/exp/slice v0.0.0-20250829135019-44e44e21330d - github.com/charmbracelet/x/powernap v0.0.0-20250919153222-1038f7e6fef4 + github.com/charmbracelet/x/powernap v0.0.0-20251015113943-25f979b54ad4 github.com/charmbracelet/x/term v0.2.1 github.com/charmbracelet/x/termios v0.1.1 // indirect github.com/charmbracelet/x/windows v0.2.2 // indirect diff --git a/go.sum b/go.sum index d1c0349a66d5e8c0e9bf6968d849cb0cbf6d26c5..0fa4e9f695cf5d60a60be753aaee9a0b2e14c192 100644 --- a/go.sum +++ b/go.sum @@ -106,8 +106,8 @@ github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sB github.com/charmbracelet/x/exp/ordered v0.1.0/go.mod h1:5UHwmG+is5THxMyCJHNPCn2/ecI07aKNrW+LcResjJ8= github.com/charmbracelet/x/exp/slice v0.0.0-20250829135019-44e44e21330d h1:H2oh4WlSsXy8qwLd7I3eAvPd/X3S40aM9l+h47WF1eA= github.com/charmbracelet/x/exp/slice v0.0.0-20250829135019-44e44e21330d/go.mod h1:vI5nDVMWi6veaYH+0Fmvpbe/+cv/iJfMntdh+N0+Tms= -github.com/charmbracelet/x/powernap v0.0.0-20250919153222-1038f7e6fef4 h1:ZhDGU688EHQXslD9KphRpXwK0pKP03egUoZAATUDlV0= -github.com/charmbracelet/x/powernap v0.0.0-20250919153222-1038f7e6fef4/go.mod h1:cmdl5zlP5mR8TF2Y68UKc7hdGUDiSJ2+4hk0h04Hsx4= +github.com/charmbracelet/x/powernap v0.0.0-20251015113943-25f979b54ad4 h1:i/XilBPYK4L1Yo/mc9FPx0SyJzIsN0y4sj1MWq9Sscc= +github.com/charmbracelet/x/powernap v0.0.0-20251015113943-25f979b54ad4/go.mod h1:cmdl5zlP5mR8TF2Y68UKc7hdGUDiSJ2+4hk0h04Hsx4= github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= diff --git a/internal/llm/agent/agent.go b/internal/llm/agent/agent.go index e338eef782912bdfea48ca72ebfd33c4cd981f62..b2b222db1a481b1eb4c7e945467bd5c74506d5ab 100644 --- a/internal/llm/agent/agent.go +++ b/internal/llm/agent/agent.go @@ -525,7 +525,7 @@ func (a *agent) getAllTools() ([]tools.BaseTool, error) { if a.agentCfg.ID == "coder" { allTools = slices.AppendSeq(allTools, a.mcpTools.Seq()) if a.lspClients.Len() > 0 { - allTools = append(allTools, tools.NewDiagnosticsTool(a.lspClients)) + allTools = append(allTools, tools.NewDiagnosticsTool(a.lspClients), tools.NewReferencesTool(a.lspClients)) } } if a.agentToolFn != nil { diff --git a/internal/llm/tools/diagnostics.go b/internal/llm/tools/diagnostics.go index 8e0c332cef76e40d5e24e74ed3260b95aab8b04b..c2625e9495963f1de467656b2d74e71e0b3c78fa 100644 --- a/internal/llm/tools/diagnostics.go +++ b/internal/llm/tools/diagnostics.go @@ -23,7 +23,7 @@ type diagnosticsTool struct { lspClients *csync.Map[string, *lsp.Client] } -const DiagnosticsToolName = "diagnostics" +const DiagnosticsToolName = "lsp_diagnostics" //go:embed diagnostics.md var diagnosticsDescription []byte diff --git a/internal/llm/tools/grep.go b/internal/llm/tools/grep.go index 237d4e18dab0bc518b9d4b6e2c73ef5035d2b348..ed844b6c10081deab6a314f380da72e0893102ca 100644 --- a/internal/llm/tools/grep.go +++ b/internal/llm/tools/grep.go @@ -2,6 +2,7 @@ package tools import ( "bufio" + "bytes" "context" _ "embed" "encoding/json" @@ -13,7 +14,6 @@ import ( "path/filepath" "regexp" "sort" - "strconv" "strings" "sync" "time" @@ -82,6 +82,7 @@ type grepMatch struct { path string modTime time.Time lineNum int + charNum int lineText string } @@ -189,7 +190,11 @@ func (g *grepTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) fmt.Fprintf(&output, "%s:\n", match.path) } if match.lineNum > 0 { - fmt.Fprintf(&output, " Line %d: %s\n", match.lineNum, match.lineText) + if match.charNum > 0 { + fmt.Fprintf(&output, " Line %d, Char %d: %s\n", match.lineNum, match.charNum, match.lineText) + } else { + fmt.Fprintf(&output, " Line %d: %s\n", match.lineNum, match.lineText) + } } else { fmt.Fprintf(&output, " %s\n", match.path) } @@ -252,66 +257,51 @@ func searchWithRipgrep(ctx context.Context, pattern, path, include string) ([]gr return nil, err } - lines := strings.Split(strings.TrimSpace(string(output)), "\n") - matches := make([]grepMatch, 0, len(lines)) - - for _, line := range lines { - if line == "" { + var matches []grepMatch + for line := range bytes.SplitSeq(bytes.TrimSpace(output), []byte{'\n'}) { + if len(line) == 0 { continue } - - // Parse ripgrep output using null separation - filePath, lineNumStr, lineText, ok := parseRipgrepLine(line) - if !ok { + var match ripgrepMatch + if err := json.Unmarshal(line, &match); err != nil { continue } - - lineNum, err := strconv.Atoi(lineNumStr) - if err != nil { + if match.Type != "match" { continue } - - fileInfo, err := os.Stat(filePath) - if err != nil { - continue // Skip files we can't access + for _, m := range match.Data.Submatches { + fi, err := os.Stat(match.Data.Path.Text) + if err != nil { + continue // Skip files we can't access + } + matches = append(matches, grepMatch{ + path: match.Data.Path.Text, + modTime: fi.ModTime(), + lineNum: match.Data.LineNumber, + charNum: m.Start + 1, // ensure 1-based + lineText: strings.TrimSpace(match.Data.Lines.Text), + }) + // only get the first match of each line + break } - - matches = append(matches, grepMatch{ - path: filePath, - modTime: fileInfo.ModTime(), - lineNum: lineNum, - lineText: lineText, - }) } - return matches, nil } -// parseRipgrepLine parses ripgrep output with null separation to handle Windows paths -func parseRipgrepLine(line string) (filePath, lineNum, lineText string, ok bool) { - // Split on null byte first to separate filename from rest - parts := strings.SplitN(line, "\x00", 2) - if len(parts) != 2 { - return "", "", "", false - } - - filePath = parts[0] - remainder := parts[1] - - // Now split the remainder on first colon: "linenum:content" - colonIndex := strings.Index(remainder, ":") - if colonIndex == -1 { - return "", "", "", false - } - - lineNumStr := remainder[:colonIndex] - lineText = remainder[colonIndex+1:] - - if _, err := strconv.Atoi(lineNumStr); err != nil { - return "", "", "", false - } - - return filePath, lineNumStr, lineText, true +type ripgrepMatch struct { + Type string `json:"type"` + Data struct { + Path struct { + Text string `json:"text"` + } `json:"path"` + Lines struct { + Text string `json:"text"` + } `json:"lines"` + LineNumber int `json:"line_number"` + Submatches []struct { + Start int `json:"start"` + } `json:"submatches"` + } `json:"data"` } func searchFilesWithRegex(pattern, rootPath, include string) ([]grepMatch, error) { @@ -363,7 +353,7 @@ func searchFilesWithRegex(pattern, rootPath, include string) ([]grepMatch, error return nil } - match, lineNum, lineText, err := fileContainsPattern(path, regex) + match, lineNum, charNum, lineText, err := fileContainsPattern(path, regex) if err != nil { return nil // Skip files we can't read } @@ -373,6 +363,7 @@ func searchFilesWithRegex(pattern, rootPath, include string) ([]grepMatch, error path: path, modTime: info.ModTime(), lineNum: lineNum, + charNum: charNum, lineText: lineText, }) @@ -390,15 +381,15 @@ func searchFilesWithRegex(pattern, rootPath, include string) ([]grepMatch, error return matches, nil } -func fileContainsPattern(filePath string, pattern *regexp.Regexp) (bool, int, string, error) { +func fileContainsPattern(filePath string, pattern *regexp.Regexp) (bool, int, int, string, error) { // Only search text files. if !isTextFile(filePath) { - return false, 0, "", nil + return false, 0, 0, "", nil } file, err := os.Open(filePath) if err != nil { - return false, 0, "", err + return false, 0, 0, "", err } defer file.Close() @@ -407,12 +398,13 @@ func fileContainsPattern(filePath string, pattern *regexp.Regexp) (bool, int, st for scanner.Scan() { lineNum++ line := scanner.Text() - if pattern.MatchString(line) { - return true, lineNum, line, nil + if loc := pattern.FindStringIndex(line); loc != nil { + charNum := loc[0] + 1 + return true, lineNum, charNum, line, nil } } - return false, 0, "", scanner.Err() + return false, 0, 0, "", scanner.Err() } // isTextFile checks if a file is a text file by examining its MIME type. diff --git a/internal/llm/tools/grep_test.go b/internal/llm/tools/grep_test.go index 435b3045b93a8e1297ff2aaeff9ee8977b974b56..753ee05942b78578fd2e9170384cac3fd5d9496e 100644 --- a/internal/llm/tools/grep_test.go +++ b/internal/llm/tools/grep_test.go @@ -390,3 +390,32 @@ func TestIsTextFile(t *testing.T) { }) } } + +func TestColumnMatch(t *testing.T) { + t.Parallel() + + // Test both implementations + for name, fn := range map[string]func(pattern, path, include string) ([]grepMatch, error){ + "regex": searchFilesWithRegex, + "rg": func(pattern, path, include string) ([]grepMatch, error) { + return searchWithRipgrep(t.Context(), pattern, path, include) + }, + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + + if name == "rg" && getRg() == "" { + t.Skip("rg is not in $PATH") + } + + matches, err := fn("THIS", "./testdata/", "") + require.NoError(t, err) + require.Len(t, matches, 1) + match := matches[0] + require.Equal(t, 2, match.lineNum) + require.Equal(t, 14, match.charNum) + require.Equal(t, "I wanna grep THIS particular word", match.lineText) + require.Equal(t, "testdata/grep.txt", filepath.ToSlash(filepath.Clean(match.path))) + }) + } +} diff --git a/internal/llm/tools/references.go b/internal/llm/tools/references.go new file mode 100644 index 0000000000000000000000000000000000000000..a1bc393cd5d28755f5f0b694c1b2df40bee1a39e --- /dev/null +++ b/internal/llm/tools/references.go @@ -0,0 +1,214 @@ +package tools + +import ( + "cmp" + "context" + _ "embed" + "encoding/json" + "errors" + "fmt" + "log/slog" + "maps" + "path/filepath" + "regexp" + "slices" + "sort" + "strings" + + "github.com/charmbracelet/crush/internal/csync" + "github.com/charmbracelet/crush/internal/lsp" + "github.com/charmbracelet/x/powernap/pkg/lsp/protocol" +) + +type ReferencesParams struct { + Symbol string `json:"symbol"` + Path string `json:"path"` +} + +type referencesTool struct { + lspClients *csync.Map[string, *lsp.Client] +} + +const ReferencesToolName = "lsp_references" + +//go:embed references.md +var referencesDescription []byte + +func NewReferencesTool(lspClients *csync.Map[string, *lsp.Client]) BaseTool { + return &referencesTool{ + lspClients, + } +} + +func (r *referencesTool) Name() string { + return ReferencesToolName +} + +func (r *referencesTool) Info() ToolInfo { + return ToolInfo{ + Name: ReferencesToolName, + Description: string(referencesDescription), + Parameters: map[string]any{ + "symbol": map[string]any{ + "type": "string", + "description": "The symbol name to search for (e.g., function name, variable name, type name).", + }, + "path": map[string]any{ + "type": "string", + "description": "The directory to search in. Should be the entire project most of the time. Defaults to the current working directory.", + }, + }, + Required: []string{"symbol"}, + } +} + +func (r *referencesTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) { + var params ReferencesParams + if err := json.Unmarshal([]byte(call.Input), ¶ms); err != nil { + return NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil + } + + if params.Symbol == "" { + return NewTextErrorResponse("symbol is required"), nil + } + + if r.lspClients.Len() == 0 { + return NewTextErrorResponse("no LSP clients available"), nil + } + + workingDir := cmp.Or(params.Path, ".") + + matches, _, err := searchFiles(ctx, regexp.QuoteMeta(params.Symbol), workingDir, "", 100) + if err != nil { + return NewTextErrorResponse(fmt.Sprintf("failed to search for symbol: %s", err)), nil + } + + if len(matches) == 0 { + return NewTextResponse(fmt.Sprintf("Symbol '%s' not found", params.Symbol)), nil + } + + var allLocations []protocol.Location + var allErrs error + for _, match := range matches { + locations, err := r.find(ctx, params.Symbol, match) + if err != nil { + if strings.Contains(err.Error(), "no identifier found") { + // grep probably matched a comment, string value, or something else that's irrelevant + continue + } + slog.Error("Failed to find references", "error", err, "symbol", params.Symbol, "path", match.path, "line", match.lineNum, "char", match.charNum) + allErrs = errors.Join(allErrs, err) + continue + } + allLocations = append(allLocations, locations...) + // XXX: should we break here or look for all results? + } + + if len(allLocations) > 0 { + output := formatReferences(cleanupLocations(allLocations)) + return NewTextResponse(output), nil + } + + if allErrs != nil { + return NewTextErrorResponse(allErrs.Error()), nil + } + return NewTextResponse(fmt.Sprintf("No references found for symbol '%s'", params.Symbol)), nil +} + +func (r *referencesTool) find(ctx context.Context, symbol string, match grepMatch) ([]protocol.Location, error) { + absPath, err := filepath.Abs(match.path) + if err != nil { + return nil, fmt.Errorf("failed to get absolute path: %s", err) + } + + var client *lsp.Client + for c := range r.lspClients.Seq() { + if c.HandlesFile(absPath) { + client = c + break + } + } + + if client == nil { + slog.Warn("No LSP clients to handle", "path", match.path) + return nil, nil + } + + return client.FindReferences( + ctx, + absPath, + match.lineNum, + match.charNum+getSymbolOffset(symbol), + true, + ) +} + +// getSymbolOffset returns the character offset to the actual symbol name +// in a qualified symbol (e.g., "Bar" in "foo.Bar" or "method" in "Class::method"). +func getSymbolOffset(symbol string) int { + // Check for :: separator (Rust, C++, Ruby modules/classes, PHP static). + if idx := strings.LastIndex(symbol, "::"); idx != -1 { + return idx + 2 + } + // Check for . separator (Go, Python, JavaScript, Java, C#, Ruby methods). + if idx := strings.LastIndex(symbol, "."); idx != -1 { + return idx + 1 + } + // Check for \ separator (PHP namespaces). + if idx := strings.LastIndex(symbol, "\\"); idx != -1 { + return idx + 1 + } + return 0 +} + +func cleanupLocations(locations []protocol.Location) []protocol.Location { + slices.SortFunc(locations, func(a, b protocol.Location) int { + if a.URI != b.URI { + return strings.Compare(string(a.URI), string(b.URI)) + } + if a.Range.Start.Line != b.Range.Start.Line { + return cmp.Compare(a.Range.Start.Line, b.Range.Start.Line) + } + return cmp.Compare(a.Range.Start.Character, b.Range.Start.Character) + }) + return slices.CompactFunc(locations, func(a, b protocol.Location) bool { + return a.URI == b.URI && + a.Range.Start.Line == b.Range.Start.Line && + a.Range.Start.Character == b.Range.Start.Character + }) +} + +func groupByFilename(locations []protocol.Location) map[string][]protocol.Location { + files := make(map[string][]protocol.Location) + for _, loc := range locations { + path, err := loc.URI.Path() + if err != nil { + slog.Error("Failed to convert location URI to path", "uri", loc.URI, "error", err) + continue + } + files[path] = append(files[path], loc) + } + return files +} + +func formatReferences(locations []protocol.Location) string { + fileRefs := groupByFilename(locations) + files := slices.Collect(maps.Keys(fileRefs)) + sort.Strings(files) + + var output strings.Builder + output.WriteString(fmt.Sprintf("Found %d reference(s) in %d file(s):\n\n", len(locations), len(files))) + + for _, file := range files { + refs := fileRefs[file] + output.WriteString(fmt.Sprintf("%s (%d reference(s)):\n", file, len(refs))) + for _, ref := range refs { + line := ref.Range.Start.Line + 1 + char := ref.Range.Start.Character + 1 + output.WriteString(fmt.Sprintf(" Line %d, Column %d\n", line, char)) + } + output.WriteString("\n") + } + + return output.String() +} diff --git a/internal/llm/tools/references.md b/internal/llm/tools/references.md new file mode 100644 index 0000000000000000000000000000000000000000..951ce71a68b9d62060649cda999107ab9243f42a --- /dev/null +++ b/internal/llm/tools/references.md @@ -0,0 +1,36 @@ +Find all references to/usage of a symbol by name using the Language Server Protocol (LSP). + +WHEN TO USE THIS TOOL: + +- **ALWAYS USE THIS FIRST** when searching for where a function, method, variable, type, or constant is used +- **DO NOT use grep/glob for symbol searches** - this tool is semantic-aware and much more accurate +- Use when you need to find all usages of a specific symbol (function, variable, type, class, method, etc.) +- More accurate than grep because it understands code semantics and scope +- Finds only actual references, not string matches in comments or unrelated code +- Helpful for understanding where a symbol is used throughout the codebase +- Useful for refactoring or analyzing code dependencies +- Good for finding all call sites of a function, method, type, package, constant, variable, etc. + +HOW TO USE: + +- Provide the symbol name (e.g., "MyFunction", "myVariable", "MyType") +- Optionally specify a path to narrow the search to a specific directory +- The tool will automatically find the symbol and locate all references + +FEATURES: + +- Returns all references grouped by file +- Shows line and column numbers for each reference +- Supports multiple programming languages through LSP +- Automatically finds the symbol without needing exact position + +LIMITATIONS: + +- May not find references in files that haven't been opened or indexed +- Results depend on the LSP server's capabilities + +TIPS: + +- **Use this tool instead of grep when looking for symbol references** - it's more accurate and semantic-aware +- Simply provide the symbol name and let the tool find it for you +- This tool understands code structure, so it won't match unrelated strings or comments diff --git a/internal/llm/tools/rg.go b/internal/llm/tools/rg.go index 8809b57c8db30b4ac1ed6c070df5a7218c59e233..76dbb5daf2234669ac3d90552cbbc5af5cc003d0 100644 --- a/internal/llm/tools/rg.go +++ b/internal/llm/tools/rg.go @@ -43,7 +43,7 @@ func getRgSearchCmd(ctx context.Context, pattern, path, include string) *exec.Cm return nil } // Use -n to show line numbers, -0 for null separation to handle Windows paths - args := []string{"-H", "-n", "-0", pattern} + args := []string{"--json", "-H", "-n", "-0", pattern} if include != "" { args = append(args, "--glob", include) } diff --git a/internal/llm/tools/testdata/grep.txt b/internal/llm/tools/testdata/grep.txt new file mode 100644 index 0000000000000000000000000000000000000000..edac9ec894634e3b924fb9a0928a272ac4f29e7e --- /dev/null +++ b/internal/llm/tools/testdata/grep.txt @@ -0,0 +1,3 @@ +test file for grep +I wanna grep THIS particular word +and nothing else diff --git a/internal/lsp/client.go b/internal/lsp/client.go index ff9a3ac9b5249663c151fb2df04a4acb168e4de4..afbe95cc2deb1c37b64c9e9b68fb705a4a0a59f9 100644 --- a/internal/lsp/client.go +++ b/internal/lsp/client.go @@ -445,6 +445,16 @@ func (c *Client) WaitForDiagnostics(ctx context.Context, d time.Duration) { } } +// FindReferences finds all references to the symbol at the given position. +func (c *Client) FindReferences(ctx context.Context, filepath string, line, character int, includeDeclaration bool) ([]protocol.Location, error) { + if err := c.OpenFileOnDemand(ctx, filepath); err != nil { + return nil, err + } + // NOTE: line and character should be 0-based. + // See: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#position + return c.client.FindReferences(ctx, filepath, line-1, character-1, includeDeclaration) +} + // HasRootMarkers checks if any of the specified root marker patterns exist in the given directory. // Uses glob patterns to match files, allowing for more flexible matching. func HasRootMarkers(dir string, rootMarkers []string) bool { From 29f9dbbb368ee074bc507a96e4da8c7d49f1d354 Mon Sep 17 00:00:00 2001 From: Charm <124303983+charmcli@users.noreply.github.com> Date: Sat, 18 Oct 2025 16:24:11 -0300 Subject: [PATCH 13/21] chore(legal): @dpolishuk 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 12b86fb4c2936e871a1a022a150385b3744b23cf..e71c57185db1ec3540a5082a7d9ec7daf33a3379 100644 --- a/.github/cla-signatures.json +++ b/.github/cla-signatures.json @@ -719,6 +719,14 @@ "created_at": "2025-10-16T17:30:07Z", "repoId": 987670088, "pullRequestNo": 1245 + }, + { + "name": "dpolishuk", + "id": 466424, + "comment_id": 3418756045, + "created_at": "2025-10-18T19:24:00Z", + "repoId": 987670088, + "pullRequestNo": 1254 } ] } \ No newline at end of file From 7fd91ba6d1556bd334e83c6e82f76c4c5dc9e9b3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 20 Oct 2025 10:19:31 +0000 Subject: [PATCH 14/21] chore(deps): bump the all group with 3 updates (#1265) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 6 +++--- go.sum | 12 ++++++------ 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/go.mod b/go.mod index c0bc32fe29ac100f98c589edf7697f104aa854a5..b73666f15b444b4ab3b671e868090b7f02c75701 100644 --- a/go.mod +++ b/go.mod @@ -7,14 +7,14 @@ require ( github.com/MakeNowJust/heredoc v1.0.0 github.com/PuerkitoBio/goquery v1.10.3 github.com/alecthomas/chroma/v2 v2.20.0 - github.com/anthropics/anthropic-sdk-go v1.13.0 + github.com/anthropics/anthropic-sdk-go v1.14.0 github.com/atotto/clipboard v0.1.4 github.com/aymanbagabas/go-udiff v0.3.1 github.com/bmatcuk/doublestar/v4 v4.9.1 github.com/charlievieth/fastwalk v1.0.14 github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250820203609-601216f68ee2 github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20251011205917-3b687ffc1619 - github.com/charmbracelet/catwalk v0.6.4 + github.com/charmbracelet/catwalk v0.7.0 github.com/charmbracelet/fang v0.4.3 github.com/charmbracelet/glamour/v2 v2.0.0-20250811143442-a27abb32f018 github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3.0.20250917201909-41ff0bf215ea @@ -153,7 +153,7 @@ require ( golang.org/x/text v0.30.0 golang.org/x/time v0.8.0 // indirect google.golang.org/api v0.211.0 // indirect - google.golang.org/genai v1.30.0 + google.golang.org/genai v1.31.0 google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463 // indirect google.golang.org/grpc v1.71.0 // indirect google.golang.org/protobuf v1.36.8 // indirect diff --git a/go.sum b/go.sum index 0fa4e9f695cf5d60a60be753aaee9a0b2e14c192..fc96feb71bd75fe1b0f07b7ac739d3550ed1be4e 100644 --- a/go.sum +++ b/go.sum @@ -30,8 +30,8 @@ github.com/alecthomas/repr v0.5.1/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW5 github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU= github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM= github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA= -github.com/anthropics/anthropic-sdk-go v1.13.0 h1:Bhbe8sRoDPtipttg8bQYrMCKe2b79+q6rFW1vOKEUKI= -github.com/anthropics/anthropic-sdk-go v1.13.0/go.mod h1:WTz31rIUHUHqai2UslPpw5CwXrQP3geYBioRV4WOLvE= +github.com/anthropics/anthropic-sdk-go v1.14.0 h1:EzNQvnZlaDHe2UPkoUySDz3ixRgNbwKdH8KtFpv7pi4= +github.com/anthropics/anthropic-sdk-go v1.14.0/go.mod h1:WTz31rIUHUHqai2UslPpw5CwXrQP3geYBioRV4WOLvE= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aws/aws-sdk-go-v2 v1.30.3 h1:jUeBtG0Ih+ZIFH0F4UkmL9w3cSpaMv9tYYDbzILP8dY= @@ -80,8 +80,8 @@ github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250820203609-601216f68ee2 github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250820203609-601216f68ee2/go.mod h1:6HamsBKWqEC/FVHuQMHgQL+knPyvHH55HwJDHl/adMw= github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20251011205917-3b687ffc1619 h1:hjOhtqsxa+LVuCAkzhfA43wtusOaUPyQdSTg/wbRscw= github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20251011205917-3b687ffc1619/go.mod h1:5IzIGXU1n0foRc8bRAherC8ZuQCQURPlwx3ANLq1138= -github.com/charmbracelet/catwalk v0.6.4 h1:zFHtuP94mSDE48nST3DS3a37wfsQqNcVnsFkS3v6N6E= -github.com/charmbracelet/catwalk v0.6.4/go.mod h1:ReU4SdrLfe63jkEjWMdX2wlZMV3k9r11oQAmzN0m+KY= +github.com/charmbracelet/catwalk v0.7.0 h1:qhLv56aeel5Q+2G/YFh9k5FhTqsozsn4HYViuAQ/Rio= +github.com/charmbracelet/catwalk v0.7.0/go.mod h1:ReU4SdrLfe63jkEjWMdX2wlZMV3k9r11oQAmzN0m+KY= github.com/charmbracelet/colorprofile v0.3.2 h1:9J27WdztfJQVAQKX2WOlSSRB+5gaKqqITmrvb1uTIiI= github.com/charmbracelet/colorprofile v0.3.2/go.mod h1:mTD5XzNeWHj8oqHb+S1bssQb7vIHbepiebQ2kPKVKbI= github.com/charmbracelet/fang v0.4.3 h1:qXeMxnL4H6mSKBUhDefHu8NfikFbP/MBNTfqTrXvzmY= @@ -427,8 +427,8 @@ golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/api v0.211.0 h1:IUpLjq09jxBSV1lACO33CGY3jsRcbctfGzhj+ZSE/Bg= google.golang.org/api v0.211.0/go.mod h1:XOloB4MXFH4UTlQSGuNUxw0UT74qdENK8d6JNsXKLi0= -google.golang.org/genai v1.30.0 h1:7021aneIvl24nEBLbtQFEWleHsMbjzpcQvkT4WcJ1dc= -google.golang.org/genai v1.30.0/go.mod h1:7pAilaICJlQBonjKKJNhftDFv3SREhZcTe9F6nRcjbg= +google.golang.org/genai v1.31.0 h1:R7xDt/Dosz11vcXbZ4IgisGnzUGGau2PZOIOAnXsYjw= +google.golang.org/genai v1.31.0/go.mod h1:7pAilaICJlQBonjKKJNhftDFv3SREhZcTe9F6nRcjbg= google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463 h1:e0AIkUUhxyBKh6ssZNrAMeqhA7RKUj42346d1y02i2g= google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= google.golang.org/grpc v1.71.0 h1:kF77BGdPTQ4/JZWMlb9VpJ5pa25aqvVqogsxNHHdeBg= From 5e3155381308f5dbb39ebdc3804d9c8c6d6e41d2 Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Mon, 20 Oct 2025 16:26:09 -0300 Subject: [PATCH 15/21] chore(deps): pin `anthropic-sdk-go` to our branch with fixes --- go.mod | 4 ++++ go.sum | 4 ++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/go.mod b/go.mod index b73666f15b444b4ab3b671e868090b7f02c75701..344e0835b91133c506216098c6aaac94b171b758 100644 --- a/go.mod +++ b/go.mod @@ -161,3 +161,7 @@ require ( gopkg.in/yaml.v3 v3.0.1 // indirect mvdan.cc/sh/moreinterp v0.0.0-20250902163504-3cf4fd5717a5 ) + +// NOTE(@andreynering): Temporarily pinning branch with fixes: +// https://github.com/charmbracelet/anthropic-sdk-go/commits/fantasy/ +replace github.com/anthropics/anthropic-sdk-go => github.com/charmbracelet/anthropic-sdk-go v0.0.0-20251020200929-fdb68318b7af diff --git a/go.sum b/go.sum index fc96feb71bd75fe1b0f07b7ac739d3550ed1be4e..9411b6803932f87185b9e8122daf034c0d9d6bbc 100644 --- a/go.sum +++ b/go.sum @@ -30,8 +30,6 @@ github.com/alecthomas/repr v0.5.1/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW5 github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU= github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM= github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA= -github.com/anthropics/anthropic-sdk-go v1.14.0 h1:EzNQvnZlaDHe2UPkoUySDz3ixRgNbwKdH8KtFpv7pi4= -github.com/anthropics/anthropic-sdk-go v1.14.0/go.mod h1:WTz31rIUHUHqai2UslPpw5CwXrQP3geYBioRV4WOLvE= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aws/aws-sdk-go-v2 v1.30.3 h1:jUeBtG0Ih+ZIFH0F4UkmL9w3cSpaMv9tYYDbzILP8dY= @@ -76,6 +74,8 @@ github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMU github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= github.com/charlievieth/fastwalk v1.0.14 h1:3Eh5uaFGwHZd8EGwTjJnSpBkfwfsak9h6ICgnWlhAyg= github.com/charlievieth/fastwalk v1.0.14/go.mod h1:diVcUreiU1aQ4/Wu3NbxxH4/KYdKpLDojrQ1Bb2KgNY= +github.com/charmbracelet/anthropic-sdk-go v0.0.0-20251020200929-fdb68318b7af h1:iPwFVe5v46OfhqxKXSJ4J0YWf8XzthTnWyrim2yGFnU= +github.com/charmbracelet/anthropic-sdk-go v0.0.0-20251020200929-fdb68318b7af/go.mod h1:WTz31rIUHUHqai2UslPpw5CwXrQP3geYBioRV4WOLvE= github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250820203609-601216f68ee2 h1:973OHYuq2Jx9deyuPwe/6lsuQrDCatOsjP8uCd02URE= github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250820203609-601216f68ee2/go.mod h1:6HamsBKWqEC/FVHuQMHgQL+knPyvHH55HwJDHl/adMw= github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20251011205917-3b687ffc1619 h1:hjOhtqsxa+LVuCAkzhfA43wtusOaUPyQdSTg/wbRscw= From 2708121abecf192a1c7b5e2d38ba3cdd51d3de08 Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Mon, 20 Oct 2025 16:27:38 -0300 Subject: [PATCH 16/21] feat(bedrock): add support for `AWS_BEARER_TOKEN_BEDROCK` for bedrock This adds an alternative authorization method vs. `aws configure`. --- internal/config/load.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/internal/config/load.go b/internal/config/load.go index c63a9663613bdfdea6a9c9ccef9f53d375e35c74..a219b7d1c848b3eea76809fca96e8e4049838365 100644 --- a/internal/config/load.go +++ b/internal/config/load.go @@ -589,6 +589,10 @@ func hasVertexCredentials(env env.Env) bool { } func hasAWSCredentials(env env.Env) bool { + if env.Get("AWS_BEARER_TOKEN_BEDROCK") != "" { + return true + } + if env.Get("AWS_ACCESS_KEY_ID") != "" && env.Get("AWS_SECRET_ACCESS_KEY") != "" { return true } From f478c4c009aa01f42c41e614ccb5463248c7825e Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Mon, 20 Oct 2025 16:29:11 -0300 Subject: [PATCH 17/21] docs: update aws bedrock docs on readme * Removed duplicated `AWS_REGION` in table. * Add documentation for `AWS_BEARER_TOKEN_BEDROCK`. --- README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 7f28c5c049cdb6c45bc83ec59f94f4310c13b7c5..6ed20ff1acbf2384d9daaaa184b2feffa939ceaf 100644 --- a/README.md +++ b/README.md @@ -189,8 +189,8 @@ That said, you can also set environment variables for preferred providers. | `AWS_ACCESS_KEY_ID` | AWS Bedrock (Claude) | | `AWS_SECRET_ACCESS_KEY` | AWS Bedrock (Claude) | | `AWS_REGION` | AWS Bedrock (Claude) | -| `AWS_PROFILE` | Custom AWS Profile | -| `AWS_REGION` | AWS Region | +| `AWS_PROFILE` | AWS Bedrock (Custom Profile) | +| `AWS_BEARER_TOKEN_BEDROCK` | AWS Bedrock | | `AZURE_OPENAI_API_ENDPOINT` | Azure OpenAI models | | `AZURE_OPENAI_API_KEY` | Azure OpenAI models (optional when using Entra ID) | | `AZURE_OPENAI_API_VERSION` | Azure OpenAI models | @@ -479,6 +479,7 @@ Crush currently supports running Anthropic models through Bedrock, with caching - A Bedrock provider will appear once you have AWS configured, i.e. `aws configure` - Crush also expects the `AWS_REGION` or `AWS_DEFAULT_REGION` to be set - To use a specific AWS profile set `AWS_PROFILE` in your environment, i.e. `AWS_PROFILE=myprofile crush` +- Alternatively to `aws configure`, you can also just set `AWS_BEARER_TOKEN_BEDROCK` ### Vertex AI Platform From 4519e198cc50fc8950198a19b8e6f39d68e33930 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Mon, 20 Oct 2025 21:27:09 -0300 Subject: [PATCH 18/21] fix(mcp): improve cache hits when using MCPs (#1271) --- internal/llm/agent/agent.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/internal/llm/agent/agent.go b/internal/llm/agent/agent.go index b2b222db1a481b1eb4c7e945467bd5c74506d5ab..6825da22ac13dd107731abb1a506b49bec8a5271 100644 --- a/internal/llm/agent/agent.go +++ b/internal/llm/agent/agent.go @@ -535,6 +535,10 @@ func (a *agent) getAllTools() ([]tools.BaseTool, error) { } allTools = append(allTools, agentTool) } + + slices.SortFunc(allTools, func(a, b tools.BaseTool) int { + return strings.Compare(a.Name(), b.Name()) + }) return allTools, nil } From beb3bc0d8a751bdcf3a0782e118222ddc62e96bd Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Tue, 21 Oct 2025 04:45:56 -0300 Subject: [PATCH 19/21] fix(tui): remove ctrl+d deny keybind (#1269) Signed-off-by: Carlos Alexandro Becker --- internal/tui/components/dialogs/permissions/keys.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/tui/components/dialogs/permissions/keys.go b/internal/tui/components/dialogs/permissions/keys.go index 4b7660ceb2310595fc0ad7d1ce51dade83169035..fc1810fc582dc4c25cada280b00b3f9515e43008 100644 --- a/internal/tui/components/dialogs/permissions/keys.go +++ b/internal/tui/components/dialogs/permissions/keys.go @@ -42,7 +42,7 @@ func DefaultKeyMap() KeyMap { key.WithHelp("s", "allow session"), ), Deny: key.NewBinding( - key.WithKeys("d", "D", "ctrl+d", "esc"), + key.WithKeys("d", "D", "esc"), key.WithHelp("d", "deny"), ), Select: key.NewBinding( From 6dbb5b9b3596f53cb7386a043e3244333bdf31d6 Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Tue, 21 Oct 2025 14:06:20 -0300 Subject: [PATCH 21/21] chore(deps): use our anthropic's fork directly instead of `replace` (#1277) --- go.mod | 6 +----- go.sum | 4 ++-- internal/llm/provider/anthropic.go | 8 ++++---- 3 files changed, 7 insertions(+), 11 deletions(-) diff --git a/go.mod b/go.mod index 344e0835b91133c506216098c6aaac94b171b758..7c1cf2b1b5033a3e4fb404fa0a09c83f77db8367 100644 --- a/go.mod +++ b/go.mod @@ -7,11 +7,11 @@ require ( github.com/MakeNowJust/heredoc v1.0.0 github.com/PuerkitoBio/goquery v1.10.3 github.com/alecthomas/chroma/v2 v2.20.0 - github.com/anthropics/anthropic-sdk-go v1.14.0 github.com/atotto/clipboard v0.1.4 github.com/aymanbagabas/go-udiff v0.3.1 github.com/bmatcuk/doublestar/v4 v4.9.1 github.com/charlievieth/fastwalk v1.0.14 + github.com/charmbracelet/anthropic-sdk-go v0.0.0-20251021163913-d29170d047bf github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250820203609-601216f68ee2 github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20251011205917-3b687ffc1619 github.com/charmbracelet/catwalk v0.7.0 @@ -161,7 +161,3 @@ require ( gopkg.in/yaml.v3 v3.0.1 // indirect mvdan.cc/sh/moreinterp v0.0.0-20250902163504-3cf4fd5717a5 ) - -// NOTE(@andreynering): Temporarily pinning branch with fixes: -// https://github.com/charmbracelet/anthropic-sdk-go/commits/fantasy/ -replace github.com/anthropics/anthropic-sdk-go => github.com/charmbracelet/anthropic-sdk-go v0.0.0-20251020200929-fdb68318b7af diff --git a/go.sum b/go.sum index 9411b6803932f87185b9e8122daf034c0d9d6bbc..bbb776c23ee5a38737e960af5f17764ea977aaab 100644 --- a/go.sum +++ b/go.sum @@ -74,8 +74,8 @@ github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMU github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= github.com/charlievieth/fastwalk v1.0.14 h1:3Eh5uaFGwHZd8EGwTjJnSpBkfwfsak9h6ICgnWlhAyg= github.com/charlievieth/fastwalk v1.0.14/go.mod h1:diVcUreiU1aQ4/Wu3NbxxH4/KYdKpLDojrQ1Bb2KgNY= -github.com/charmbracelet/anthropic-sdk-go v0.0.0-20251020200929-fdb68318b7af h1:iPwFVe5v46OfhqxKXSJ4J0YWf8XzthTnWyrim2yGFnU= -github.com/charmbracelet/anthropic-sdk-go v0.0.0-20251020200929-fdb68318b7af/go.mod h1:WTz31rIUHUHqai2UslPpw5CwXrQP3geYBioRV4WOLvE= +github.com/charmbracelet/anthropic-sdk-go v0.0.0-20251021163913-d29170d047bf h1:toCE1GpniOr8JPJII2GH1AffivFVOzq8Rs2S0FUrkNU= +github.com/charmbracelet/anthropic-sdk-go v0.0.0-20251021163913-d29170d047bf/go.mod h1:8TIYxZxsuCqqeJ0lga/b91tBwrbjoHDC66Sq5t8N2R4= github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250820203609-601216f68ee2 h1:973OHYuq2Jx9deyuPwe/6lsuQrDCatOsjP8uCd02URE= github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250820203609-601216f68ee2/go.mod h1:6HamsBKWqEC/FVHuQMHgQL+knPyvHH55HwJDHl/adMw= github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20251011205917-3b687ffc1619 h1:hjOhtqsxa+LVuCAkzhfA43wtusOaUPyQdSTg/wbRscw= diff --git a/internal/llm/provider/anthropic.go b/internal/llm/provider/anthropic.go index 981ff4590fd7db92288ff11b3d8f607e594cb0fd..1f1965fde3fd04dad759f368ce2e232543f86c8e 100644 --- a/internal/llm/provider/anthropic.go +++ b/internal/llm/provider/anthropic.go @@ -13,10 +13,10 @@ import ( "strings" "time" - "github.com/anthropics/anthropic-sdk-go" - "github.com/anthropics/anthropic-sdk-go/bedrock" - "github.com/anthropics/anthropic-sdk-go/option" - "github.com/anthropics/anthropic-sdk-go/vertex" + "github.com/charmbracelet/anthropic-sdk-go" + "github.com/charmbracelet/anthropic-sdk-go/bedrock" + "github.com/charmbracelet/anthropic-sdk-go/option" + "github.com/charmbracelet/anthropic-sdk-go/vertex" "github.com/charmbracelet/catwalk/pkg/catwalk" "github.com/charmbracelet/crush/internal/config" "github.com/charmbracelet/crush/internal/llm/tools"