on main help sign in
wip: initial setup
477f846kujtimiihoxha
Taskfile.yaml+1−1
@@ -21,7 +21,7 @@ tasks:
21 env:
21 env:
22 GOEXPERIMENT: null
22 GOEXPERIMENT: null
23
23
24− lint-fix:
24+ lint:fix:
25 desc: Run base linters and fix issues
25 desc: Run base linters and fix issues
26 cmds:
26 cmds:
27 - golangci-lint run --path-mode=abs --config=".golangci.yml" --timeout=5m --fix
27 - golangci-lint run --path-mode=abs --config=".golangci.yml" --timeout=5m --fix
go.mod+37−28
@@ -7,7 +7,6 @@ require (
7 github.com/MakeNowJust/heredoc v1.0.0
7 github.com/MakeNowJust/heredoc v1.0.0
8 github.com/PuerkitoBio/goquery v1.10.3
8 github.com/PuerkitoBio/goquery v1.10.3
9 github.com/alecthomas/chroma/v2 v2.20.0
9 github.com/alecthomas/chroma/v2 v2.20.0
10− github.com/anthropics/anthropic-sdk-go v1.12.0
11 github.com/atotto/clipboard v0.1.4
10 github.com/atotto/clipboard v0.1.4
12 github.com/aymanbagabas/go-udiff v0.3.1
11 github.com/aymanbagabas/go-udiff v0.3.1
13 github.com/bmatcuk/doublestar/v4 v4.9.1
12 github.com/bmatcuk/doublestar/v4 v4.9.1
@@ -42,18 +41,18 @@ require (
42 github.com/stretchr/testify v1.11.1
41 github.com/stretchr/testify v1.11.1
43 github.com/tidwall/sjson v1.2.5
42 github.com/tidwall/sjson v1.2.5
44 github.com/zeebo/xxh3 v1.0.2
43 github.com/zeebo/xxh3 v1.0.2
44+ google.golang.org/genai v1.26.0
45 gopkg.in/natefinch/lumberjack.v2 v2.2.1
45 gopkg.in/natefinch/lumberjack.v2 v2.2.1
46 mvdan.cc/sh/v3 v3.12.1-0.20250902163504-3cf4fd5717a5
46 mvdan.cc/sh/v3 v3.12.1-0.20250902163504-3cf4fd5717a5
47)
47)
48
48
49require (
49require (
50 cloud.google.com/go v0.116.0 // indirect
50 cloud.google.com/go v0.116.0 // indirect
51− cloud.google.com/go/auth v0.13.0 // indirect
51+ cloud.google.com/go/auth v0.9.3 // indirect
52− cloud.google.com/go/auth/oauth2adapt v0.2.6 // indirect
52+ cloud.google.com/go/auth/oauth2adapt v0.2.4 // indirect
53− cloud.google.com/go/compute/metadata v0.6.0 // indirect
53+ cloud.google.com/go/compute/metadata v0.5.0 // indirect
54 github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0 // indirect
54 github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0 // indirect
55 github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 // indirect
55 github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 // indirect
56− github.com/andybalholm/cascadia v1.3.3 // indirect
57 github.com/aws/aws-sdk-go-v2 v1.30.3 // indirect
56 github.com/aws/aws-sdk-go-v2 v1.30.3 // indirect
58 github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.3 // indirect
57 github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.3 // indirect
59 github.com/aws/aws-sdk-go-v2/config v1.27.27 // indirect
58 github.com/aws/aws-sdk-go-v2/config v1.27.27 // indirect
@@ -68,6 +67,38 @@ require (
68 github.com/aws/aws-sdk-go-v2/service/ssooidc v1.26.4 // indirect
67 github.com/aws/aws-sdk-go-v2/service/ssooidc v1.26.4 // indirect
69 github.com/aws/aws-sdk-go-v2/service/sts v1.30.3 // indirect
68 github.com/aws/aws-sdk-go-v2/service/sts v1.30.3 // indirect
70 github.com/aws/smithy-go v1.20.3 // indirect
69 github.com/aws/smithy-go v1.20.3 // indirect
70+ github.com/felixge/httpsnoop v1.0.4 // indirect
71+ github.com/go-logr/logr v1.4.3 // indirect
72+ github.com/go-logr/stdr v1.2.2 // indirect
73+ github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
74+ github.com/google/go-cmp v0.7.0 // indirect
75+ github.com/google/s2a-go v0.1.8 // indirect
76+ github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect
77+ github.com/gorilla/websocket v1.5.3 // indirect
78+ go.opencensus.io v0.24.0 // indirect
79+ go.opentelemetry.io/auto/sdk v1.1.0 // indirect
80+ go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0 // indirect
81+ go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 // indirect
82+ go.opentelemetry.io/otel v1.37.0 // indirect
83+ go.opentelemetry.io/otel/metric v1.37.0 // indirect
84+ go.opentelemetry.io/otel/trace v1.37.0 // indirect
85+ golang.org/x/crypto v0.41.0 // indirect
86+ golang.org/x/oauth2 v0.30.0 // indirect
87+ golang.org/x/time v0.6.0 // indirect
88+ google.golang.org/api v0.197.0 // indirect
89+ google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 // indirect
90+ google.golang.org/grpc v1.66.2 // indirect
91+ google.golang.org/protobuf v1.36.8 // indirect
92+)
93+
94+require (
95+ github.com/anthropics/anthropic-sdk-go v1.12.0
96+ github.com/charmbracelet/fantasy v0.0.0-20250930080945-d8adb55feb9a
97+ github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
98+)
99+
100+require (
101+ github.com/andybalholm/cascadia v1.3.3 // indirect
71 github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
102 github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
72 github.com/aymerick/douceur v0.2.0 // indirect
103 github.com/aymerick/douceur v0.2.0 // indirect
73 github.com/bahlo/generic-list-go v0.2.0 // indirect
104 github.com/bahlo/generic-list-go v0.2.0 // indirect
@@ -75,7 +106,7 @@ require (
75 github.com/charmbracelet/colorprofile v0.3.2 // indirect
106 github.com/charmbracelet/colorprofile v0.3.2 // indirect
76 github.com/charmbracelet/ultraviolet v0.0.0-20250915111650-81d4262876ef
107 github.com/charmbracelet/ultraviolet v0.0.0-20250915111650-81d4262876ef
77 github.com/charmbracelet/x/cellbuf v0.0.14-0.20250811133356-e0c5dbe5ea4a // indirect
108 github.com/charmbracelet/x/cellbuf v0.0.14-0.20250811133356-e0c5dbe5ea4a // indirect
78− github.com/charmbracelet/x/exp/slice v0.0.0-20250829135019-44e44e21330d
109+ github.com/charmbracelet/x/exp/slice v0.0.0-20250904123553-b4e2667e5ad5
79 github.com/charmbracelet/x/powernap v0.0.0-20250919153222-1038f7e6fef4
110 github.com/charmbracelet/x/powernap v0.0.0-20250919153222-1038f7e6fef4
80 github.com/charmbracelet/x/term v0.2.1
111 github.com/charmbracelet/x/term v0.2.1
81 github.com/charmbracelet/x/termios v0.1.1 // indirect
112 github.com/charmbracelet/x/termios v0.1.1 // indirect
@@ -85,17 +116,9 @@ require (
85 github.com/disintegration/gift v1.1.2 // indirect
116 github.com/disintegration/gift v1.1.2 // indirect
86 github.com/dlclark/regexp2 v1.11.5 // indirect
117 github.com/dlclark/regexp2 v1.11.5 // indirect
87 github.com/dustin/go-humanize v1.0.1 // indirect
118 github.com/dustin/go-humanize v1.0.1 // indirect
88− github.com/felixge/httpsnoop v1.0.4 // indirect
89 github.com/fsnotify/fsnotify v1.9.0 // indirect
119 github.com/fsnotify/fsnotify v1.9.0 // indirect
90 github.com/go-logfmt/logfmt v0.6.0 // indirect
120 github.com/go-logfmt/logfmt v0.6.0 // indirect
91− github.com/go-logr/logr v1.4.3 // indirect
92− github.com/go-logr/stdr v1.2.2 // indirect
93− github.com/google/go-cmp v0.7.0 // indirect
94− github.com/google/s2a-go v0.1.8 // indirect
95− github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect
96− github.com/googleapis/gax-go/v2 v2.14.1 // indirect
97 github.com/gorilla/css v1.0.1 // indirect
121 github.com/gorilla/css v1.0.1 // indirect
98− github.com/gorilla/websocket v1.5.3 // indirect
99 github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
122 github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
100 github.com/inconshreveable/mousetrap v1.1.0 // indirect
123 github.com/inconshreveable/mousetrap v1.1.0 // indirect
101 github.com/klauspost/compress v1.18.0 // indirect
124 github.com/klauspost/compress v1.18.0 // indirect
@@ -134,28 +157,14 @@ require (
134 github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
157 github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
135 github.com/yuin/goldmark v1.7.8 // indirect
158 github.com/yuin/goldmark v1.7.8 // indirect
136 github.com/yuin/goldmark-emoji v1.0.5 // indirect
159 github.com/yuin/goldmark-emoji v1.0.5 // indirect
137− go.opentelemetry.io/auto/sdk v1.1.0 // indirect
138− go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0 // indirect
139− go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 // indirect
140− go.opentelemetry.io/otel v1.37.0 // indirect
141− go.opentelemetry.io/otel/metric v1.37.0 // indirect
142− go.opentelemetry.io/otel/trace v1.37.0 // indirect
143 go.uber.org/multierr v1.11.0 // indirect
160 go.uber.org/multierr v1.11.0 // indirect
144− golang.org/x/crypto v0.41.0 // indirect
145 golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
161 golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
146 golang.org/x/image v0.26.0 // indirect
162 golang.org/x/image v0.26.0 // indirect
147 golang.org/x/net v0.43.0 // indirect
163 golang.org/x/net v0.43.0 // indirect
148− golang.org/x/oauth2 v0.30.0 // indirect
149 golang.org/x/sync v0.17.0 // indirect
164 golang.org/x/sync v0.17.0 // indirect
150 golang.org/x/sys v0.36.0 // indirect
165 golang.org/x/sys v0.36.0 // indirect
151 golang.org/x/term v0.34.0 // indirect
166 golang.org/x/term v0.34.0 // indirect
152 golang.org/x/text v0.29.0
167 golang.org/x/text v0.29.0
153− golang.org/x/time v0.8.0 // indirect
154− google.golang.org/api v0.211.0 // indirect
155− google.golang.org/genai v1.26.0
156− google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463 // indirect
157− google.golang.org/grpc v1.71.0 // indirect
158− google.golang.org/protobuf v1.36.8 // indirect
159 gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect
168 gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect
160 gopkg.in/yaml.v3 v3.0.1 // indirect
169 gopkg.in/yaml.v3 v3.0.1 // indirect
161 mvdan.cc/sh/moreinterp v0.0.0-20250902163504-3cf4fd5717a5
170 mvdan.cc/sh/moreinterp v0.0.0-20250902163504-3cf4fd5717a5
go.sum+101−22
@@ -1,11 +1,12 @@
1+cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
1cloud.google.com/go v0.116.0 h1:B3fRrSDkLRt5qSHWe40ERJvhvnQwdZiHu0bJOpldweE=
2cloud.google.com/go v0.116.0 h1:B3fRrSDkLRt5qSHWe40ERJvhvnQwdZiHu0bJOpldweE=
2cloud.google.com/go v0.116.0/go.mod h1:cEPSRWPzZEswwdr9BxE6ChEn01dWlTaF05LiC2Xs70U=
3cloud.google.com/go v0.116.0/go.mod h1:cEPSRWPzZEswwdr9BxE6ChEn01dWlTaF05LiC2Xs70U=
3−cloud.google.com/go/auth v0.13.0 h1:8Fu8TZy167JkW8Tj3q7dIkr2v4cndv41ouecJx0PAHs=
4+cloud.google.com/go/auth v0.9.3 h1:VOEUIAADkkLtyfr3BLa3R8Ed/j6w1jTBmARx+wb5w5U=
4−cloud.google.com/go/auth v0.13.0/go.mod h1:COOjD9gwfKNKz+IIduatIhYJQIc0mG3H102r/EMxX6Q=
5+cloud.google.com/go/auth v0.9.3/go.mod h1:7z6VY+7h3KUdRov5F1i8NDP5ZzWKYmEPO842BgCsmTk=
5−cloud.google.com/go/auth/oauth2adapt v0.2.6 h1:V6a6XDu2lTwPZWOawrAa9HUK+DB2zfJyTuciBG5hFkU=
6+cloud.google.com/go/auth/oauth2adapt v0.2.4 h1:0GWE/FUsXhf6C+jAkWgYm7X9tK8cuEIfy19DBn6B6bY=
6−cloud.google.com/go/auth/oauth2adapt v0.2.6/go.mod h1:AlmsELtlEBnaNTL7jCj8VQFLy6mbZv0s4Q7NGBeQ5E8=
7+cloud.google.com/go/auth/oauth2adapt v0.2.4/go.mod h1:jC/jOpwFP6JBxhB3P5Rr0a9HLMC/Pe3eaL4NmdvqPtc=
7−cloud.google.com/go/compute/metadata v0.6.0 h1:A6hENjEsCDtC1k8byVsgwvVcioamEHvZ4j01OwKxG9I=
8+cloud.google.com/go/compute/metadata v0.5.0 h1:Zr0eK8JbFv6+Wi4ilXAR8FJ3wyNdpxHKJNPos6LTZOY=
8−cloud.google.com/go/compute/metadata v0.6.0/go.mod h1:FjyFAW1MW0C203CEOMDTu3Dk1FlqW3Rga40jzHL4hfg=
9+cloud.google.com/go/compute/metadata v0.5.0/go.mod h1:aHnloV2TPI38yx4s9+wAZhHykWvVCfu7hQbF+9CWoiY=
9github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0 h1:g0EZJwz7xkXQiZAI5xi9f3WWFYBlX1CPTrR+NDToRkQ=
10github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0 h1:g0EZJwz7xkXQiZAI5xi9f3WWFYBlX1CPTrR+NDToRkQ=
10github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0/go.mod h1:XCW7KnZet0Opnr7HccfUw1PLc4CjHqpcaxW8DHklNkQ=
11github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0/go.mod h1:XCW7KnZet0Opnr7HccfUw1PLc4CjHqpcaxW8DHklNkQ=
11github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.7.0 h1:tfLQ34V6F7tVSwoTf/4lH5sE0o6eCJuNDTmH09nDpbc=
12github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.7.0 h1:tfLQ34V6F7tVSwoTf/4lH5sE0o6eCJuNDTmH09nDpbc=
@@ -14,6 +15,7 @@ github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 h1:ywEEhmNahHBihViHepv3xP
14github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0/go.mod h1:iZDifYGJTIgIIkYRNWPENUnqx6bJ2xnSDFI2tjwZNuY=
15github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0/go.mod h1:iZDifYGJTIgIIkYRNWPENUnqx6bJ2xnSDFI2tjwZNuY=
15github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2 h1:XHOnouVk1mxXfQidrMEnLlPk9UMeRtyBTnEFtxkV0kU=
16github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2 h1:XHOnouVk1mxXfQidrMEnLlPk9UMeRtyBTnEFtxkV0kU=
16github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
17github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
18+github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
17github.com/JohannesKaufmann/html-to-markdown v1.6.0 h1:04VXMiE50YYfCfLboJCLcgqF5x+rHJnb1ssNmqpLH/k=
19github.com/JohannesKaufmann/html-to-markdown v1.6.0 h1:04VXMiE50YYfCfLboJCLcgqF5x+rHJnb1ssNmqpLH/k=
18github.com/JohannesKaufmann/html-to-markdown v1.6.0/go.mod h1:NUI78lGg/a7vpEJTz/0uOcYMaibytE4BUOQS8k78yPQ=
20github.com/JohannesKaufmann/html-to-markdown v1.6.0/go.mod h1:NUI78lGg/a7vpEJTz/0uOcYMaibytE4BUOQS8k78yPQ=
19github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
21github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
@@ -74,6 +76,7 @@ github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/
74github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
76github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
75github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
77github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
76github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
78github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
79+github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
77github.com/charlievieth/fastwalk v1.0.14 h1:3Eh5uaFGwHZd8EGwTjJnSpBkfwfsak9h6ICgnWlhAyg=
80github.com/charlievieth/fastwalk v1.0.14 h1:3Eh5uaFGwHZd8EGwTjJnSpBkfwfsak9h6ICgnWlhAyg=
78github.com/charlievieth/fastwalk v1.0.14/go.mod h1:diVcUreiU1aQ4/Wu3NbxxH4/KYdKpLDojrQ1Bb2KgNY=
81github.com/charlievieth/fastwalk v1.0.14/go.mod h1:diVcUreiU1aQ4/Wu3NbxxH4/KYdKpLDojrQ1Bb2KgNY=
79github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250820203609-601216f68ee2 h1:973OHYuq2Jx9deyuPwe/6lsuQrDCatOsjP8uCd02URE=
82github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250820203609-601216f68ee2 h1:973OHYuq2Jx9deyuPwe/6lsuQrDCatOsjP8uCd02URE=
@@ -86,6 +89,8 @@ github.com/charmbracelet/colorprofile v0.3.2 h1:9J27WdztfJQVAQKX2WOlSSRB+5gaKqqI
86github.com/charmbracelet/colorprofile v0.3.2/go.mod h1:mTD5XzNeWHj8oqHb+S1bssQb7vIHbepiebQ2kPKVKbI=
89github.com/charmbracelet/colorprofile v0.3.2/go.mod h1:mTD5XzNeWHj8oqHb+S1bssQb7vIHbepiebQ2kPKVKbI=
87github.com/charmbracelet/fang v0.4.2 h1:nWr7Tb82/TTNNGMGG35aTZ1X68loAOQmpb0qxkKXjas=
90github.com/charmbracelet/fang v0.4.2 h1:nWr7Tb82/TTNNGMGG35aTZ1X68loAOQmpb0qxkKXjas=
88github.com/charmbracelet/fang v0.4.2/go.mod h1:wHJKQYO5ReYsxx+yZl+skDtrlKO/4LLEQ6EXsdHhRhg=
91github.com/charmbracelet/fang v0.4.2/go.mod h1:wHJKQYO5ReYsxx+yZl+skDtrlKO/4LLEQ6EXsdHhRhg=
92+github.com/charmbracelet/fantasy v0.0.0-20250930080945-d8adb55feb9a h1:pD/ATiDbIRm8i5cKf8k1NR/0HhmSdAejY2kbIpmBiis=
93+github.com/charmbracelet/fantasy v0.0.0-20250930080945-d8adb55feb9a/go.mod h1:RZotHpq44tKZDe6Vf0kk1iDqnUgH7Scx+K/7uJ9Qwnw=
89github.com/charmbracelet/glamour/v2 v2.0.0-20250811143442-a27abb32f018 h1:PU4Zvpagsk5sgaDxn5W4sxHuLp9QRMBZB3bFSk40A4w=
94github.com/charmbracelet/glamour/v2 v2.0.0-20250811143442-a27abb32f018 h1:PU4Zvpagsk5sgaDxn5W4sxHuLp9QRMBZB3bFSk40A4w=
90github.com/charmbracelet/glamour/v2 v2.0.0-20250811143442-a27abb32f018/go.mod h1:Z/GLmp9fzaqX4ze3nXG7StgWez5uBM5XtlLHK8V/qSk=
95github.com/charmbracelet/glamour/v2 v2.0.0-20250811143442-a27abb32f018/go.mod h1:Z/GLmp9fzaqX4ze3nXG7StgWez5uBM5XtlLHK8V/qSk=
91github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3.0.20250917201909-41ff0bf215ea h1:g1HfUgSMvye8mgecMD1mPscpt+pzJoDEiSA+p2QXzdQ=
96github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3.0.20250917201909-41ff0bf215ea h1:g1HfUgSMvye8mgecMD1mPscpt+pzJoDEiSA+p2QXzdQ=
@@ -102,8 +107,8 @@ github.com/charmbracelet/x/exp/charmtone v0.0.0-20250708181618-a60a724ba6c3 h1:1
102github.com/charmbracelet/x/exp/charmtone v0.0.0-20250708181618-a60a724ba6c3/go.mod h1:T9jr8CzFpjhFVHjNjKwbAD7KwBNyFnj2pntAO7F2zw0=
107github.com/charmbracelet/x/exp/charmtone v0.0.0-20250708181618-a60a724ba6c3/go.mod h1:T9jr8CzFpjhFVHjNjKwbAD7KwBNyFnj2pntAO7F2zw0=
103github.com/charmbracelet/x/exp/golden v0.0.0-20250207160936-21c02780d27a h1:FsHEJ52OC4VuTzU8t+n5frMjLvpYWEznSr/u8tnkCYw=
108github.com/charmbracelet/x/exp/golden v0.0.0-20250207160936-21c02780d27a h1:FsHEJ52OC4VuTzU8t+n5frMjLvpYWEznSr/u8tnkCYw=
104github.com/charmbracelet/x/exp/golden v0.0.0-20250207160936-21c02780d27a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
109github.com/charmbracelet/x/exp/golden v0.0.0-20250207160936-21c02780d27a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
105−github.com/charmbracelet/x/exp/slice v0.0.0-20250829135019-44e44e21330d h1:H2oh4WlSsXy8qwLd7I3eAvPd/X3S40aM9l+h47WF1eA=
110+github.com/charmbracelet/x/exp/slice v0.0.0-20250904123553-b4e2667e5ad5 h1:DTSZxdV9qQagD4iGcAt9RgaRBZtJl01bfKgdLzUzUPI=
106−github.com/charmbracelet/x/exp/slice v0.0.0-20250829135019-44e44e21330d/go.mod h1:vI5nDVMWi6veaYH+0Fmvpbe/+cv/iJfMntdh+N0+Tms=
111+github.com/charmbracelet/x/exp/slice v0.0.0-20250904123553-b4e2667e5ad5/go.mod h1:vI5nDVMWi6veaYH+0Fmvpbe/+cv/iJfMntdh+N0+Tms=
107github.com/charmbracelet/x/powernap v0.0.0-20250919153222-1038f7e6fef4 h1:ZhDGU688EHQXslD9KphRpXwK0pKP03egUoZAATUDlV0=
112github.com/charmbracelet/x/powernap v0.0.0-20250919153222-1038f7e6fef4 h1:ZhDGU688EHQXslD9KphRpXwK0pKP03egUoZAATUDlV0=
108github.com/charmbracelet/x/powernap v0.0.0-20250919153222-1038f7e6fef4/go.mod h1:cmdl5zlP5mR8TF2Y68UKc7hdGUDiSJ2+4hk0h04Hsx4=
113github.com/charmbracelet/x/powernap v0.0.0-20250919153222-1038f7e6fef4/go.mod h1:cmdl5zlP5mR8TF2Y68UKc7hdGUDiSJ2+4hk0h04Hsx4=
109github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
114github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
@@ -112,6 +117,8 @@ github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8
112github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
117github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
113github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
118github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
114github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
119github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
120+github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
121+github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
115github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
122github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
116github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
123github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
117github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
124github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
@@ -128,6 +135,10 @@ github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZ
128github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
135github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
129github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
136github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
130github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
137github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
138+github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
139+github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
140+github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
141+github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
131github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
142github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
132github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
143github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
133github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
144github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
@@ -144,21 +155,42 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
144github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
155github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
145github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI=
156github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI=
146github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow=
157github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow=
158+github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
159+github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
147github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
160github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
148github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
161github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
162+github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
163+github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
164+github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
165+github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
166+github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
167+github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
168+github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
169+github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
170+github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
171+github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
172+github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
173+github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
174+github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
175+github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
149github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
176github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
150github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
177github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
178+github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
179+github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
180+github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
181+github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
182+github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
183+github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
151github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
184github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
152github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
185github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
153github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
186github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
154github.com/google/s2a-go v0.1.8 h1:zZDs9gcbt9ZPLV0ndSyQk6Kacx2g/X+SKYovpnz3SMM=
187github.com/google/s2a-go v0.1.8 h1:zZDs9gcbt9ZPLV0ndSyQk6Kacx2g/X+SKYovpnz3SMM=
155github.com/google/s2a-go v0.1.8/go.mod h1:6iNWHTpQ+nfNRN5E00MSdfDwVesa8hhS32PhPO8deJA=
188github.com/google/s2a-go v0.1.8/go.mod h1:6iNWHTpQ+nfNRN5E00MSdfDwVesa8hhS32PhPO8deJA=
189+github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
156github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
190github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
157github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
191github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
158github.com/googleapis/enterprise-certificate-proxy v0.3.4 h1:XYIDZApgAnrN1c855gTgghdIA6Stxb52D5RnLI1SLyw=
192github.com/googleapis/enterprise-certificate-proxy v0.3.4 h1:XYIDZApgAnrN1c855gTgghdIA6Stxb52D5RnLI1SLyw=
159github.com/googleapis/enterprise-certificate-proxy v0.3.4/go.mod h1:YKe7cfqYXjKGpGvmSg28/fFvhNzinZQm8DGnaburhGA=
193github.com/googleapis/enterprise-certificate-proxy v0.3.4/go.mod h1:YKe7cfqYXjKGpGvmSg28/fFvhNzinZQm8DGnaburhGA=
160−github.com/googleapis/gax-go/v2 v2.14.1 h1:hb0FFeiPaQskmvakKu5EbCbpntQn48jyHuvrkurSS/Q=
161−github.com/googleapis/gax-go/v2 v2.14.1/go.mod h1:Hb/NubMaVM88SrNkvl8X/o8XWwDJEPqouaLeN2IUxoA=
162github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
194github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
163github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
195github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
164github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
196github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
@@ -241,6 +273,7 @@ github.com/posthog/posthog-go v1.6.10 h1:OA6bkiUg89rI7f5cSXbcrH5+wLinyS6hHplnD92
241github.com/posthog/posthog-go v1.6.10/go.mod h1:LcC1Nu4AgvV22EndTtrMXTy+7RGVC0MhChSw7Qk5XkY=
273github.com/posthog/posthog-go v1.6.10/go.mod h1:LcC1Nu4AgvV22EndTtrMXTy+7RGVC0MhChSw7Qk5XkY=
242github.com/pressly/goose/v3 v3.25.0 h1:6WeYhMWGRCzpyd89SpODFnCBCKz41KrVbRT58nVjGng=
274github.com/pressly/goose/v3 v3.25.0 h1:6WeYhMWGRCzpyd89SpODFnCBCKz41KrVbRT58nVjGng=
243github.com/pressly/goose/v3 v3.25.0/go.mod h1:4hC1KrritdCxtuFsqgs1R4AU5bWtTAf+cnWvfhf2DNY=
275github.com/pressly/goose/v3 v3.25.0/go.mod h1:4hC1KrritdCxtuFsqgs1R4AU5bWtTAf+cnWvfhf2DNY=
276+github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
244github.com/qjebbs/go-jsons v1.0.0-alpha.4 h1:Qsb4ohRUHQODIUAsJKdKJ/SIDbsO7oGOzsfy+h1yQZs=
277github.com/qjebbs/go-jsons v1.0.0-alpha.4 h1:Qsb4ohRUHQODIUAsJKdKJ/SIDbsO7oGOzsfy+h1yQZs=
245github.com/qjebbs/go-jsons v1.0.0-alpha.4/go.mod h1:wNJrtinHyC3YSf6giEh4FJN8+yZV7nXBjvmfjhBIcw4=
278github.com/qjebbs/go-jsons v1.0.0-alpha.4/go.mod h1:wNJrtinHyC3YSf6giEh4FJN8+yZV7nXBjvmfjhBIcw4=
246github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
279github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
@@ -276,9 +309,14 @@ github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS
276github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ=
309github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ=
277github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef/go.mod h1:nXTWP6+gD5+LUJ8krVhhoeHjvHTutPxMYl5SvkcnJNE=
310github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef/go.mod h1:nXTWP6+gD5+LUJ8krVhhoeHjvHTutPxMYl5SvkcnJNE=
278github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
311github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
312+github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
313+github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
279github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
314github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
280github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
315github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
281github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
316github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
317+github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
318+github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
319+github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
282github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
320github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
283github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
321github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
284github.com/tetratelabs/wazero v1.9.0 h1:IcZ56OuxrtaEz8UYNRHBrUa9bYeX9oVY93KspZZBf/I=
322github.com/tetratelabs/wazero v1.9.0 h1:IcZ56OuxrtaEz8UYNRHBrUa9bYeX9oVY93KspZZBf/I=
@@ -313,6 +351,8 @@ github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
313github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
351github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
314github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
352github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
315github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
353github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
354+go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
355+go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
316go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
356go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
317go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
357go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
318go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0 h1:r6I7RJCN86bpD/FQwedZ0vSixDpwuWREjW9oRMsmqDc=
358go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0 h1:r6I7RJCN86bpD/FQwedZ0vSixDpwuWREjW9oRMsmqDc=
@@ -323,15 +363,12 @@ go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
323go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
363go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
324go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
364go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
325go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
365go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
326−go.opentelemetry.io/otel/sdk v1.34.0 h1:95zS4k/2GOy069d321O8jWgYsW3MzVV+KuSPKp7Wr1A=
327−go.opentelemetry.io/otel/sdk v1.34.0/go.mod h1:0e/pNiaMAqaykJGKbi+tSjWfNNHMTxoC9qANsCzbyxU=
328−go.opentelemetry.io/otel/sdk/metric v1.34.0 h1:5CeK9ujjbFVL5c1PhLuStg1wxA7vQv7ce1EK0Gyvahk=
329−go.opentelemetry.io/otel/sdk/metric v1.34.0/go.mod h1:jQ/r8Ze28zRKoNRdkjCZxfs6YvBTG1+YIqyFVFYec5w=
330go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
366go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
331go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
367go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
332go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
368go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
333go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
369go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
334golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
370golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
371+golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
335golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
372golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
336golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
373golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
337golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
374golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
@@ -340,16 +377,26 @@ golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v
340golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
377golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
341golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
378golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
342golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
379golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
380+golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
343golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
381golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
344golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
382golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
345golang.org/x/image v0.26.0 h1:4XjIFEZWQmCZi6Wv8BoxsDhRU3RVnLX04dToTDAEPlY=
383golang.org/x/image v0.26.0 h1:4XjIFEZWQmCZi6Wv8BoxsDhRU3RVnLX04dToTDAEPlY=
346golang.org/x/image v0.26.0/go.mod h1:lcxbMFAovzpnJxzXS3nyL83K27tmqtKzIJpctK8YO5c=
384golang.org/x/image v0.26.0/go.mod h1:lcxbMFAovzpnJxzXS3nyL83K27tmqtKzIJpctK8YO5c=
385+golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
386+golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
387+golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
347golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
388golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
348golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
389golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
349golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
390golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
350golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
391golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
351golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
392golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
393+golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
394+golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
395+golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
396+golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
397+golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
352golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
398golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
399+golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
353golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
400golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
354golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
401golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
355golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
402golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
@@ -362,8 +409,11 @@ golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
362golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
409golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
363golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
410golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
364golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
411golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
412+golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
365golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
413golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
366golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
414golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
415+golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
416+golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
367golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
417golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
368golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
418golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
369golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
419golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -373,7 +423,10 @@ golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
373golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
423golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
374golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
424golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
375golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
425golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
426+golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
376golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
427golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
428+golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
429+golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
377golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
430golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
378golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
431golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
379golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
432golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -414,23 +467,47 @@ golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
414golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
467golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
415golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
468golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
416golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
469golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
417−golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg=
470+golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U=
418−golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
471+golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
419golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
472golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
473+golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
474+golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
475+golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
476+golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
420golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
477golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
421golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
478golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
422golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
479golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
423golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
480golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
424golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
481golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
425golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
482golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
426−google.golang.org/api v0.211.0 h1:IUpLjq09jxBSV1lACO33CGY3jsRcbctfGzhj+ZSE/Bg=
483+golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
427−google.golang.org/api v0.211.0/go.mod h1:XOloB4MXFH4UTlQSGuNUxw0UT74qdENK8d6JNsXKLi0=
484+google.golang.org/api v0.197.0 h1:x6CwqQLsFiA5JKAiGyGBjc2bNtHtLddhJCE2IKuhhcQ=
485+google.golang.org/api v0.197.0/go.mod h1:AuOuo20GoQ331nq7DquGHlU6d+2wN2fZ8O0ta60nRNw=
486+google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
487+google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
428google.golang.org/genai v1.26.0 h1:r4HGL54kFv/WCRMTAbZg05Ct+vXfhAbTRlXhFyBkEQo=
488google.golang.org/genai v1.26.0 h1:r4HGL54kFv/WCRMTAbZg05Ct+vXfhAbTRlXhFyBkEQo=
429google.golang.org/genai v1.26.0/go.mod h1:OClfdf+r5aaD+sCd4aUSkPzJItmg2wD/WON9lQnRPaY=
489google.golang.org/genai v1.26.0/go.mod h1:OClfdf+r5aaD+sCd4aUSkPzJItmg2wD/WON9lQnRPaY=
430−google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463 h1:e0AIkUUhxyBKh6ssZNrAMeqhA7RKUj42346d1y02i2g=
490+google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
491+google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
492+google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
493+google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 h1:pPJltXNxVzT4pK9yD8vR9X75DaWYYmLGMsEvBfFQZzQ=
431−google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
494+google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU=
432−google.golang.org/grpc v1.71.0 h1:kF77BGdPTQ4/JZWMlb9VpJ5pa25aqvVqogsxNHHdeBg=
495+google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
433−google.golang.org/grpc v1.71.0/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec=
496+google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
497+google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
498+google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
499+google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
500+google.golang.org/grpc v1.66.2 h1:3QdXkuq3Bkh7w+ywLdLvM56cmGvQHUMZpiCzt6Rqaoo=
501+google.golang.org/grpc v1.66.2/go.mod h1:s3/l6xSSCURdVfAnL+TqCNMyTDAGN6+lZeVxnZR128Y=
502+google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
503+google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
504+google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
505+google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
506+google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
507+google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
508+google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
509+google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
510+google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
434google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
511google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
435google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
512google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
436gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
513gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@@ -447,6 +524,8 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
447gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
524gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
448gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
525gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
449gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
526gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
527+honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
528+honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
450modernc.org/libc v1.66.3 h1:cfCbjTUcdsKyyZZfEUKfoHcP3S0Wkvz3jgSzByEWVCQ=
529modernc.org/libc v1.66.3 h1:cfCbjTUcdsKyyZZfEUKfoHcP3S0Wkvz3jgSzByEWVCQ=
451modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8=
530modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8=
452modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
531modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
internal/agent/agent.gonew file+620
@@ -0,0 +1,620 @@
1+package agent
2+
3+import (
4+ "context"
5+ _ "embed"
6+ "errors"
7+ "fmt"
8+ "log/slog"
9+ "strings"
10+ "time"
11+
12+ "github.com/charmbracelet/catwalk/pkg/catwalk"
13+ "github.com/charmbracelet/crush/internal/csync"
14+ "github.com/charmbracelet/crush/internal/llm/tools"
15+ "github.com/charmbracelet/crush/internal/message"
16+ "github.com/charmbracelet/crush/internal/permission"
17+ "github.com/charmbracelet/crush/internal/session"
18+ "github.com/charmbracelet/fantasy/ai"
19+ "github.com/charmbracelet/fantasy/anthropic"
20+)
21+
22+//go:embed templates/title.md
23+var titlePrompt []byte
24+
25+//go:embed templates/summary.md
26+var summaryPrompt []byte
27+
28+type SessionAgentCall struct {
29+ SessionID string
30+ Prompt string
31+ ProviderOptions ai.ProviderOptions
32+ Attachments []message.Attachment
33+ MaxOutputTokens int64
34+ Temperature *float64
35+ TopP *float64
36+ TopK *int64
37+ FrequencyPenalty *float64
38+ PresencePenalty *float64
39+}
40+
41+type SessionAgent interface {
42+ Run(context.Context, SessionAgentCall) (*ai.AgentResult, error)
43+ SetModels(large Model, small Model)
44+ SetTools(tools []ai.AgentTool)
45+ Cancel(sessionID string)
46+ CancelAll()
47+ IsSessionBusy(sessionID string) bool
48+ IsBusy() bool
49+ QueuedPrompts(sessionID string) int
50+ ClearQueue(sessionID string)
51+ Summarize(context.Context, string) error
52+}
53+
54+type Model struct {
55+ model ai.LanguageModel
56+ config catwalk.Model
57+}
58+
59+type sessionAgent struct {
60+ largeModel Model
61+ smallModel Model
62+ systemPrompt string
63+ tools []ai.AgentTool
64+ maxOutputTokens int64
65+ sessions session.Service
66+ messages message.Service
67+
68+ messageQueue *csync.Map[string, []SessionAgentCall]
69+ activeRequests *csync.Map[string, context.CancelFunc]
70+}
71+
72+type SessionAgentOption func(*sessionAgent)
73+
74+func NewSessionAgent() SessionAgent {
75+ return &sessionAgent{}
76+}
77+
78+func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*ai.AgentResult, error) {
79+ if call.Prompt == "" {
80+ return nil, ErrEmptyPrompt
81+ }
82+ if call.SessionID == "" {
83+ return nil, ErrSessionMissing
84+ }
85+
86+ // Queue the message if busy
87+ if a.IsSessionBusy(call.SessionID) {
88+ existing, ok := a.messageQueue.Get(call.SessionID)
89+ if !ok {
90+ existing = []SessionAgentCall{}
91+ }
92+ existing = append(existing, call)
93+ a.messageQueue.Set(call.SessionID, existing)
94+ return nil, nil
95+ }
96+
97+ if len(a.tools) > 0 {
98+ // add anthropic caching to the last tool
99+ a.tools[len(a.tools)-1].SetProviderOptions(a.getCacheControlOptions())
100+ }
101+
102+ agent := ai.NewAgent(
103+ a.largeModel.model,
104+ ai.WithSystemPrompt(a.systemPrompt),
105+ ai.WithTools(a.tools...),
106+ ai.WithMaxOutputTokens(a.maxOutputTokens),
107+ )
108+
109+ currentSession, err := a.sessions.Get(ctx, call.SessionID)
110+ if err != nil {
111+ return nil, fmt.Errorf("failed to get session: %w", err)
112+ }
113+
114+ msgs, err := a.getSessionMessages(ctx, currentSession)
115+ if err != nil {
116+ return nil, fmt.Errorf("failed to get session messages: %w", err)
117+ }
118+
119+ // Generate title if first message
120+ if len(msgs) == 0 {
121+ go a.generateTitle(ctx, currentSession, call.Prompt)
122+ }
123+
124+ // Add the user message to the session
125+ _, err = a.createUserMessage(ctx, call)
126+ if err != nil {
127+ return nil, err
128+ }
129+
130+ // add the session to the context
131+ ctx = context.WithValue(ctx, tools.SessionIDContextKey, call.SessionID)
132+
133+ genCtx, cancel := context.WithCancel(ctx)
134+ a.activeRequests.Set(call.SessionID, cancel)
135+
136+ defer cancel()
137+ defer a.activeRequests.Del(call.SessionID)
138+
139+ history, files := a.preparePrompt(msgs, call.Attachments...)
140+
141+ var currentAssistant *message.Message
142+ result, err := agent.Stream(genCtx, ai.AgentStreamCall{
143+ Prompt: call.Prompt,
144+ Files: files,
145+ Messages: history,
146+ ProviderOptions: call.ProviderOptions,
147+ MaxOutputTokens: &call.MaxOutputTokens,
148+ TopP: call.TopP,
149+ Temperature: call.Temperature,
150+ PresencePenalty: call.PresencePenalty,
151+ TopK: call.TopK,
152+ FrequencyPenalty: call.FrequencyPenalty,
153+ // Before each step create the new assistant message
154+ PrepareStep: func(options ai.PrepareStepFunctionOptions) (prepared ai.PrepareStepResult, err error) {
155+ var assistantMsg message.Message
156+ assistantMsg, err = a.messages.Create(genCtx, call.SessionID, message.CreateMessageParams{
157+ Role: message.Assistant,
158+ Parts: []message.ContentPart{},
159+ Model: a.largeModel.model.Model(),
160+ Provider: a.largeModel.model.Provider(),
161+ })
162+ if err != nil {
163+ return prepared, err
164+ }
165+
166+ currentAssistant = &assistantMsg
167+
168+ prepared.Messages = options.Messages
169+
170+ queuedCalls, _ := a.messageQueue.Get(call.SessionID)
171+ a.messageQueue.Del(call.SessionID)
172+ for _, queued := range queuedCalls {
173+ userMessage, createErr := a.createUserMessage(genCtx, queued)
174+ if createErr != nil {
175+ return prepared, createErr
176+ }
177+ prepared.Messages = append(prepared.Messages, userMessage.ToAIMessage()...)
178+ }
179+
180+ lastSystemRoleInx := 0
181+ systemMessageUpdated := false
182+ for i, msg := range prepared.Messages {
183+ // only add cache control to the last message
184+ if msg.Role == ai.MessageRoleSystem {
185+ lastSystemRoleInx = i
186+ } else if !systemMessageUpdated {
187+ prepared.Messages[lastSystemRoleInx].ProviderOptions = a.getCacheControlOptions()
188+ systemMessageUpdated = true
189+ }
190+ // than add cache control to the last 2 messages
191+ if i > len(msgs)-3 {
192+ prepared.Messages[i].ProviderOptions = a.getCacheControlOptions()
193+ }
194+ }
195+ return prepared, err
196+ },
197+ OnReasoningDelta: func(id string, text string) error {
198+ currentAssistant.AppendReasoningContent(text)
199+ return a.messages.Update(genCtx, *currentAssistant)
200+ },
201+ OnReasoningEnd: func(id string, reasoning ai.ReasoningContent) error {
202+ // handle anthropic signature
203+ if anthropicData, ok := reasoning.ProviderMetadata[anthropic.Name]; ok {
204+ if reasoning, ok := anthropicData.(*anthropic.ReasoningOptionMetadata); ok {
205+ currentAssistant.AppendReasoningSignature(reasoning.Signature)
206+ }
207+ }
208+ currentAssistant.FinishThinking()
209+ return a.messages.Update(genCtx, *currentAssistant)
210+ },
211+ OnTextDelta: func(id string, text string) error {
212+ currentAssistant.AppendContent(text)
213+ return a.messages.Update(genCtx, *currentAssistant)
214+ },
215+ OnToolInputStart: func(id string, toolName string) error {
216+ toolCall := message.ToolCall{
217+ ID: id,
218+ Name: toolName,
219+ ProviderExecuted: false,
220+ Finished: false,
221+ }
222+ currentAssistant.AddToolCall(toolCall)
223+ return a.messages.Update(genCtx, *currentAssistant)
224+ },
225+ OnRetry: func(err *ai.APICallError, delay time.Duration) {
226+ // TODO: implement
227+ },
228+ OnToolResult: func(result ai.ToolResultContent) error {
229+ var resultContent string
230+ isError := false
231+ switch result.Result.GetType() {
232+ case ai.ToolResultContentTypeText:
233+ r, ok := ai.AsToolResultOutputType[ai.ToolResultOutputContentText](result.Result)
234+ if ok {
235+ resultContent = r.Text
236+ }
237+ case ai.ToolResultContentTypeError:
238+ r, ok := ai.AsToolResultOutputType[ai.ToolResultOutputContentError](result.Result)
239+ if ok {
240+ isError = true
241+ resultContent = r.Error.Error()
242+ }
243+ case ai.ToolResultContentTypeMedia:
244+ // TODO: handle this message type
245+ }
246+ toolResult := message.ToolResult{
247+ ToolCallID: result.ToolCallID,
248+ Name: result.ToolName,
249+ Content: resultContent,
250+ IsError: isError,
251+ Metadata: result.ClientMetadata,
252+ }
253+ a.messages.Create(context.Background(), currentAssistant.SessionID, message.CreateMessageParams{
254+ Role: message.Tool,
255+ Parts: []message.ContentPart{
256+ toolResult,
257+ },
258+ })
259+ return a.messages.Update(genCtx, *currentAssistant)
260+ },
261+ OnStepFinish: func(stepResult ai.StepResult) error {
262+ finishReason := message.FinishReasonUnknown
263+ switch stepResult.FinishReason {
264+ case ai.FinishReasonLength:
265+ finishReason = message.FinishReasonMaxTokens
266+ case ai.FinishReasonStop:
267+ finishReason = message.FinishReasonEndTurn
268+ case ai.FinishReasonToolCalls:
269+ finishReason = message.FinishReasonToolUse
270+ }
271+ currentAssistant.AddFinish(finishReason, "", "")
272+ a.updateSessionUsage(a.largeModel, &currentSession, stepResult.Usage)
273+ return a.messages.Update(genCtx, *currentAssistant)
274+ },
275+ })
276+ if err != nil {
277+ isCancelErr := errors.Is(err, context.Canceled)
278+ isPermissionErr := errors.Is(err, permission.ErrorPermissionDenied)
279+ if currentAssistant == nil {
280+ return result, err
281+ }
282+ toolCalls := currentAssistant.ToolCalls()
283+ toolResults := currentAssistant.ToolResults()
284+ for _, tc := range toolCalls {
285+ if !tc.Finished {
286+ tc.Finished = true
287+ tc.Input = "{}"
288+ }
289+ currentAssistant.AddToolCall(tc)
290+ found := false
291+ for _, tr := range toolResults {
292+ if tr.ToolCallID == tc.ID {
293+ found = true
294+ break
295+ }
296+ }
297+ if !found {
298+ content := "There was an error while executing the tool"
299+ if isCancelErr {
300+ content = "Tool execution canceled by user"
301+ } else if isPermissionErr {
302+ content = "Permission denied"
303+ }
304+ currentAssistant.AddToolResult(message.ToolResult{
305+ ToolCallID: tc.ID,
306+ Name: tc.Name,
307+ Content: content,
308+ IsError: true,
309+ })
310+ }
311+ }
312+ if isCancelErr {
313+ currentAssistant.AddFinish(message.FinishReasonCanceled, "Request cancelled", "")
314+ } else if isPermissionErr {
315+ currentAssistant.AddFinish(message.FinishReasonPermissionDenied, "Permission denied", "")
316+ } else {
317+ currentAssistant.AddFinish(message.FinishReasonError, "API Error", err.Error())
318+ }
319+ // INFO: we use the parent context here because the genCtx might have been cancelled
320+ updateErr := a.messages.Update(ctx, *currentAssistant)
321+ if updateErr != nil {
322+ return nil, updateErr
323+ }
324+ }
325+ if err != nil {
326+ return nil, err
327+ }
328+
329+ queuedMessages, ok := a.messageQueue.Get(call.SessionID)
330+ if !ok || len(queuedMessages) == 0 {
331+ return result, err
332+ }
333+ // there are queued messages restart the loop
334+ firstQueuedMessage := queuedMessages[0]
335+ a.messageQueue.Set(call.SessionID, queuedMessages[1:])
336+ return a.Run(genCtx, firstQueuedMessage)
337+}
338+
339+func (a *sessionAgent) Summarize(ctx context.Context, sessionID string) error {
340+ if a.IsSessionBusy(sessionID) {
341+ return ErrSessionBusy
342+ }
343+
344+ currentSession, err := a.sessions.Get(ctx, sessionID)
345+ if err != nil {
346+ return fmt.Errorf("failed to get session: %w", err)
347+ }
348+ msgs, err := a.getSessionMessages(ctx, currentSession)
349+ if err != nil {
350+ return err
351+ }
352+ if len(msgs) == 0 {
353+ // nothing to summarize
354+ return nil
355+ }
356+
357+ aiMsgs, _ := a.preparePrompt(msgs)
358+
359+ genCtx, cancel := context.WithCancel(ctx)
360+ a.activeRequests.Set(sessionID, cancel)
361+ defer a.activeRequests.Del(sessionID)
362+ defer cancel()
363+
364+ agent := ai.NewAgent(a.largeModel.model,
365+ ai.WithSystemPrompt(string(summaryPrompt)),
366+ )
367+ summaryMessage, err := a.messages.Create(ctx, sessionID, message.CreateMessageParams{
368+ Role: message.Assistant,
369+ Model: a.largeModel.model.Model(),
370+ Provider: a.largeModel.model.Provider(),
371+ })
372+ if err != nil {
373+ return err
374+ }
375+
376+ resp, err := agent.Stream(ctx, ai.AgentStreamCall{
377+ Prompt: "Provide a detailed summary of our conversation above.",
378+ Messages: aiMsgs,
379+ OnReasoningDelta: func(id string, text string) error {
380+ summaryMessage.AppendReasoningContent(text)
381+ return a.messages.Update(ctx, summaryMessage)
382+ },
383+ OnReasoningEnd: func(id string, reasoning ai.ReasoningContent) error {
384+ // handle anthropic signature
385+ if anthropicData, ok := reasoning.ProviderMetadata["anthropic"]; ok {
386+ if signature, ok := anthropicData.(*anthropic.ReasoningOptionMetadata); ok && signature.Signature != "" {
387+ summaryMessage.AppendReasoningSignature(signature.Signature)
388+ }
389+ }
390+ summaryMessage.FinishThinking()
391+ return a.messages.Update(ctx, summaryMessage)
392+ },
393+ OnTextDelta: func(id, text string) error {
394+ summaryMessage.AppendContent(text)
395+ return a.messages.Update(ctx, summaryMessage)
396+ },
397+ })
398+ if err != nil {
399+ return err
400+ }
401+
402+ summaryMessage.AddFinish(message.FinishReasonEndTurn, "", "")
403+ err = a.messages.Update(genCtx, summaryMessage)
404+ if err != nil {
405+ return err
406+ }
407+
408+ a.updateSessionUsage(a.largeModel, &currentSession, resp.TotalUsage)
409+
410+ // just in case get just the last usage
411+ usage := resp.Response.Usage
412+ currentSession.SummaryMessageID = summaryMessage.ID
413+ currentSession.CompletionTokens = usage.OutputTokens
414+ currentSession.PromptTokens = 0
415+ _, err = a.sessions.Save(genCtx, currentSession)
416+ return err
417+}
418+
419+func (a *sessionAgent) getCacheControlOptions() ai.ProviderOptions {
420+ return ai.ProviderOptions{
421+ anthropic.Name: &anthropic.ProviderCacheControlOptions{
422+ CacheControl: anthropic.CacheControl{Type: "ephemeral"},
423+ },
424+ }
425+}
426+
427+func (a *sessionAgent) createUserMessage(ctx context.Context, call SessionAgentCall) (message.Message, error) {
428+ var attachmentParts []message.ContentPart
429+ for _, attachment := range call.Attachments {
430+ attachmentParts = append(attachmentParts, message.BinaryContent{Path: attachment.FilePath, MIMEType: attachment.MimeType, Data: attachment.Content})
431+ }
432+ parts := []message.ContentPart{message.TextContent{Text: call.Prompt}}
433+ parts = append(parts, attachmentParts...)
434+ msg, err := a.messages.Create(ctx, call.SessionID, message.CreateMessageParams{
435+ Role: message.User,
436+ Parts: parts,
437+ })
438+ if err != nil {
439+ return message.Message{}, fmt.Errorf("failed to create user message: %w", err)
440+ }
441+ return msg, nil
442+}
443+
444+func (a *sessionAgent) preparePrompt(msgs []message.Message, attachments ...message.Attachment) ([]ai.Message, []ai.FilePart) {
445+ var history []ai.Message
446+ for _, m := range msgs {
447+ if len(m.Parts) == 0 {
448+ continue
449+ }
450+ // Assistant message without content or tool calls (cancelled before it returned anything)
451+ if m.Role == message.Assistant && len(m.ToolCalls()) == 0 && m.Content().Text == "" && m.ReasoningContent().String() == "" {
452+ continue
453+ }
454+ history = append(history, m.ToAIMessage()...)
455+ }
456+
457+ var files []ai.FilePart
458+ for _, attachment := range attachments {
459+ files = append(files, ai.FilePart{
460+ Filename: attachment.FileName,
461+ Data: attachment.Content,
462+ MediaType: attachment.MimeType,
463+ })
464+ }
465+
466+ return history, files
467+}
468+
469+func (a *sessionAgent) getSessionMessages(ctx context.Context, session session.Session) ([]message.Message, error) {
470+ msgs, err := a.messages.List(ctx, session.ID)
471+ if err != nil {
472+ return nil, fmt.Errorf("failed to list messages: %w", err)
473+ }
474+
475+ if session.SummaryMessageID != "" {
476+ summaryMsgInex := -1
477+ for i, msg := range msgs {
478+ if msg.ID == session.SummaryMessageID {
479+ summaryMsgInex = i
480+ break
481+ }
482+ }
483+ if summaryMsgInex != -1 {
484+ msgs = msgs[summaryMsgInex:]
485+ msgs[0].Role = message.User
486+ }
487+ }
488+ return msgs, nil
489+}
490+
491+func (a *sessionAgent) generateTitle(ctx context.Context, session session.Session, prompt string) {
492+ if prompt == "" {
493+ return
494+ }
495+
496+ agent := ai.NewAgent(a.smallModel.model,
497+ ai.WithSystemPrompt(string(titlePrompt)),
498+ ai.WithMaxOutputTokens(40),
499+ )
500+
501+ resp, err := agent.Stream(ctx, ai.AgentStreamCall{
502+ Prompt: fmt.Sprintf("Generate a concise title for the following content:\n\n%s", prompt),
503+ })
504+ if err != nil {
505+ slog.Error("error generating title", "err", err)
506+ return
507+ }
508+
509+ title := resp.Response.Content.Text()
510+
511+ title = strings.ReplaceAll(title, "\n", " ")
512+
513+ // remove thinking tags if present
514+ if idx := strings.Index(title, "</think>"); idx > 0 {
515+ title = title[idx+len("</think>"):]
516+ }
517+
518+ title = strings.TrimSpace(title)
519+ if title == "" {
520+ slog.Warn("failed to generate title", "warn", "empty title")
521+ return
522+ }
523+
524+ session.Title = title
525+ a.updateSessionUsage(a.smallModel, &session, resp.TotalUsage)
526+ _, saveErr := a.sessions.Save(ctx, session)
527+ if saveErr != nil {
528+ slog.Error("failed to save session title & usage", "error", saveErr)
529+ return
530+ }
531+}
532+
533+func (a *sessionAgent) updateSessionUsage(model Model, session *session.Session, usage ai.Usage) {
534+ modelConfig := model.config
535+ cost := modelConfig.CostPer1MInCached/1e6*float64(usage.CacheCreationTokens) +
536+ modelConfig.CostPer1MOutCached/1e6*float64(usage.CacheReadTokens) +
537+ modelConfig.CostPer1MIn/1e6*float64(usage.InputTokens) +
538+ modelConfig.CostPer1MOut/1e6*float64(usage.OutputTokens)
539+ session.Cost += cost
540+ session.CompletionTokens = usage.OutputTokens + usage.CacheReadTokens
541+ session.PromptTokens = usage.InputTokens + usage.CacheCreationTokens
542+}
543+
544+func (a *sessionAgent) Cancel(sessionID string) {
545+ // Cancel regular requests
546+ if cancel, ok := a.activeRequests.Take(sessionID); ok && cancel != nil {
547+ slog.Info("Request cancellation initiated", "session_id", sessionID)
548+ cancel()
549+ }
550+
551+ // Also check for summarize requests
552+ if cancel, ok := a.activeRequests.Take(sessionID + "-summarize"); ok && cancel != nil {
553+ slog.Info("Summarize cancellation initiated", "session_id", sessionID)
554+ cancel()
555+ }
556+
557+ if a.QueuedPrompts(sessionID) > 0 {
558+ slog.Info("Clearing queued prompts", "session_id", sessionID)
559+ a.messageQueue.Del(sessionID)
560+ }
561+}
562+
563+func (a *sessionAgent) ClearQueue(sessionID string) {
564+ if a.QueuedPrompts(sessionID) > 0 {
565+ slog.Info("Clearing queued prompts", "session_id", sessionID)
566+ a.messageQueue.Del(sessionID)
567+ }
568+}
569+
570+func (a *sessionAgent) CancelAll() {
571+ if !a.IsBusy() {
572+ return
573+ }
574+ for key := range a.activeRequests.Seq2() {
575+ a.Cancel(key) // key is sessionID
576+ }
577+
578+ timeout := time.After(5 * time.Second)
579+ for a.IsBusy() {
580+ select {
581+ case <-timeout:
582+ return
583+ default:
584+ time.Sleep(200 * time.Millisecond)
585+ }
586+ }
587+}
588+
589+func (a *sessionAgent) IsBusy() bool {
590+ var busy bool
591+ for cancelFunc := range a.activeRequests.Seq() {
592+ if cancelFunc != nil {
593+ busy = true
594+ break
595+ }
596+ }
597+ return busy
598+}
599+
600+func (a *sessionAgent) IsSessionBusy(sessionID string) bool {
601+ _, busy := a.activeRequests.Get(sessionID)
602+ return busy
603+}
604+
605+func (a *sessionAgent) QueuedPrompts(sessionID string) int {
606+ l, ok := a.messageQueue.Get(sessionID)
607+ if !ok {
608+ return 0
609+ }
610+ return len(l)
611+}
612+
613+func (a *sessionAgent) SetModels(large Model, small Model) {
614+ a.largeModel = large
615+ a.smallModel = small
616+}
617+
618+func (a *sessionAgent) SetTools(tools []ai.AgentTool) {
619+ a.tools = tools
620+}
internal/agent/coordinator.gonew file+20
@@ -0,0 +1,20 @@
1+package agent
2+
3+import (
4+ "context"
5+
6+ "github.com/charmbracelet/crush/internal/config"
7+ "github.com/charmbracelet/crush/internal/message"
8+ "github.com/charmbracelet/fantasy/ai"
9+)
10+
11+type Coordinator interface {
12+ // INFO: (kujtim) this is not used yet we will use this when we have multiple agents
13+ SetMainAgent(string)
14+ Run(ctx context.Context, sessionID, prompt string, attachments ...message.Attachment) (*ai.AgentResult, error)
15+}
16+
17+type coordinator struct {
18+ cfg *config.Config
19+ currentAgent SessionAgent
20+}
internal/agent/errors.gonew file+17
@@ -0,0 +1,17 @@
1+package agent
2+
3+import (
4+ "context"
5+ "errors"
6+)
7+
8+var (
9+ ErrRequestCancelled = errors.New("request canceled by user")
10+ ErrSessionBusy = errors.New("session is currently processing another request")
11+ ErrEmptyPrompt = errors.New("prompt is empty")
12+ ErrSessionMissing = errors.New("session id is missing")
13+)
14+
15+func isCancelledErr(err error) bool {
16+ return errors.Is(err, context.Canceled) || errors.Is(err, ErrRequestCancelled)
17+}
internal/agent/prompt/prompt.gonew file+140
@@ -0,0 +1,140 @@
1+package prompt
2+
3+import (
4+ "fmt"
5+ "os"
6+ "path/filepath"
7+ "runtime"
8+ "strings"
9+ "text/template"
10+ "time"
11+
12+ "github.com/charmbracelet/crush/internal/config"
13+ "github.com/charmbracelet/crush/internal/home"
14+)
15+
16+// Prompt represents a template-based prompt generator.
17+type Prompt struct {
18+ name string
19+ template string
20+}
21+
22+type PromptDat struct {
23+ Provider string
24+ Model string
25+ Config config.Config
26+ WorkingDir string
27+ IsGitRepo bool
28+ Platform string
29+ Date string
30+}
31+
32+type ContextFile struct {
33+ Path string
34+ Content string
35+}
36+
37+func NewPrompt(name, promptTemplate string) (*Prompt, error) {
38+ return &Prompt{
39+ name: name,
40+ template: promptTemplate,
41+ }, nil
42+}
43+
44+func (p *Prompt) Build(provider, model string, cfg config.Config) (string, error) {
45+ t, err := template.New(p.name).Funcs(p.funcMap(cfg)).Parse(p.template)
46+ if err != nil {
47+ return "", fmt.Errorf("parsing template: %w", err)
48+ }
49+ var sb strings.Builder
50+ if err := t.Execute(&sb, promptData(provider, model, cfg)); err != nil {
51+ return "", fmt.Errorf("executing template: %w", err)
52+ }
53+
54+ return sb.String(), nil
55+}
56+
57+func (p *Prompt) funcMap(cfg config.Config) template.FuncMap {
58+ return template.FuncMap{
59+ "contextFiles": func(path string) []ContextFile {
60+ path = expandPath(path, cfg)
61+ return processContextPath(path, cfg)
62+ },
63+ }
64+}
65+
66+func processFile(filePath string) *ContextFile {
67+ content, err := os.ReadFile(filePath)
68+ if err != nil {
69+ return nil
70+ }
71+ return &ContextFile{
72+ Path: filePath,
73+ Content: string(content),
74+ }
75+}
76+
77+func processContextPath(p string, cfg config.Config) []ContextFile {
78+ var contexts []ContextFile
79+ fullPath := p
80+ if !filepath.IsAbs(p) {
81+ fullPath = filepath.Join(cfg.WorkingDir(), p)
82+ }
83+ info, err := os.Stat(fullPath)
84+ if err != nil {
85+ return contexts
86+ }
87+ if info.IsDir() {
88+ filepath.WalkDir(fullPath, func(path string, d os.DirEntry, err error) error {
89+ if err != nil {
90+ return err
91+ }
92+ if !d.IsDir() {
93+ if result := processFile(path); result != nil {
94+ contexts = append(contexts, *result)
95+ }
96+ }
97+ return nil
98+ })
99+ } else {
100+ result := processFile(fullPath)
101+ if result != nil {
102+ contexts = append(contexts, *result)
103+ }
104+ }
105+ return contexts
106+}
107+
108+// expandPath expands ~ and environment variables in file paths
109+func expandPath(path string, cfg config.Config) string {
110+ path = home.Long(path)
111+ // Handle environment variable expansion using the same pattern as config
112+ if strings.HasPrefix(path, "$") {
113+ if expanded, err := cfg.Resolver().ResolveValue(path); err == nil {
114+ path = expanded
115+ }
116+ }
117+
118+ return path
119+}
120+
121+func promptData(provider, model string, cfg config.Config) PromptDat {
122+ return PromptDat{
123+ Provider: provider,
124+ Model: model,
125+ Config: cfg,
126+ WorkingDir: cfg.WorkingDir(),
127+ IsGitRepo: isGitRepo(cfg.WorkingDir()),
128+ Platform: runtime.GOOS,
129+ Date: time.Now().Format("1/2/2006"),
130+ }
131+}
132+
133+func isGitRepo(dir string) bool {
134+ _, err := os.Stat(filepath.Join(dir, ".git"))
135+ return err == nil
136+}
137+
138+func (p *Prompt) Name() string {
139+ return p.name
140+}
internal/agent/templates/coder.gotmplnew file+133
@@ -0,0 +1,133 @@
1+You are Crush, a powerful AI Assistant that runs in the CLI.
2+Use the instructions below and the tools available to you to assist the user.
3+
4+<memory_instructions>
5+If the current working directory contains a file called CRUSH.md, it will be automatically added to your context.
6+
7+This file serves multiple purposes:
8+
9+- Storing frequently used bash commands (build, test, lint, etc.) so you can use them without searching each time
10+- Recording the user's code style preferences (naming conventions, preferred libraries, etc.)
11+- Maintaining useful information about the codebase structure and organization
12+
13+When you discover important information that could be useful for the future update/add the info to the CRUSH.md.
14+
15+Memory might be added to you during a task if there are nested memory files that relate to the work you are doing.
16+</memory_instructions>
17+<communication_style>
18+- Be concise and direct
19+- Keep responses under 4 lines unless details requested
20+- Answer without preamble/postamble ("Here is...", "The answer is...")
21+- One-word answers preferred when possible
22+- Never use emojis in your responses
23+- You MUST answer concisely with fewer than 4 lines of text (not including tool use or code generation), unless user asks for detail
24+- User markdown formatting for responses when appropriate
25+
26+<example>
27+user: 2 + 2
28+assistant: 4
29+</example>
30+
31+<example>
32+user: what is 2+2?
33+assistant: 4
34+</example>
35+
36+<example>
37+user: is 11 a prime number?
38+assistant: true
39+</example>
40+
41+<example>
42+user: what command should I run to list files in the current directory?
43+assistant: ls
44+</example>
45+
46+<example>
47+user: what command should I run to watch files in the current directory?
48+assistant: [use the ls tool to list the files in the current directory, then read docs/commands in the relevant file to find out how to watch files]
49+npm run dev
50+</example>
51+
52+<example>
53+user: How many golf balls fit inside a jetta?
54+assistant: 150000
55+</example>
56+
57+<example>
58+user: what files are in the directory src/?
59+assistant: [runs ls and sees foo.c, bar.c, baz.c]
60+user: which file contains the implementation of foo?
61+assistant: src/foo.c
62+</example>
63+
64+<example>
65+user: write tests for new feature
66+assistant: [uses grep and glob search tools to find where similar tests are defined, uses concurrent read file tool use blocks in one tool call to read relevant files at the same time, uses edit file tool to write new tests]
67+</example>
68+</communication_style>
69+
70+<proactiveness>
71+You are allowed to be proactive, but only when the user asks you to do something. You should strive to strike a balance between:
72+
73+- Doing the right thing when asked, including taking actions and follow-up actions
74+- Not surprising the user with actions you take without asking
75+ - For example, if the user asks you how to approach something, you should do your best to answer their question first, and not immediately jump into taking actions.
76+- Do not add additional code explanation summary unless requested by the user. After working on a file, just stop, rather than providing an explanation of what you did.
77+</proactiveness>
78+
79+<following_conversations>
80+When making changes to files, first understand the file's code conventions. Mimic code style, use existing libraries and utilities, and follow existing patterns.
81+
82+- NEVER assume that a given library is available, even if it is well known. Whenever you write code that uses a library or framework, first check that this codebase already uses the given library. For example, you might look at neighboring files, or check the package.json (or cargo.toml, and so on depending on the language).
83+- When you create a new component, first look at existing components to see how they're written; then consider framework choice, naming conventions, typing, and other conventions.
84+- When you edit a piece of code, first look at the code's surrounding context (especially its imports) to understand the code's choice of frameworks and libraries. Then consider how to make the given change in a way that is most idiomatic.
85+- Always follow security best practices. Never introduce code that exposes or logs secrets and keys. Never commit secrets or keys to the repository.
86+</following_conversations>
87+
88+<code_style>
89+- Follow existing code style and patterns
90+- Do not add any comments to code you write unless asked to do so
91+</code_style>
92+
93+<doing_tasks>
94+The user will primarily request you perform software engineering tasks. This includes solving bugs, adding new functionality, refactoring code, explaining code, and more. For these tasks the following steps are recommended:
95+
96+- Use the available search tools to understand the codebase and the user's query.
97+- Plan out the implementation
98+- Implement the solution using all tools available to you
99+- Verify the solution if possible with tests. NEVER assume specific test framework or test script. Check the README or search codebase to determine the testing approach.
100+- When you have completed a task, you MUST run the lint and typecheck commands (eg. npm run lint, npm run typecheck, ruff, etc.) if they were provided to you to ensure your code is correct. If you are unable to find the correct command, ask the user for the command to run and if they supply it, proactively suggest writing it to CRUSH.md so that you will know to run it next time.
101+
102+NEVER commit changes unless the user explicitly asks you to. It is VERY IMPORTANT to only commit when explicitly asked.
103+</doing_tasks>
104+
105+<tool_use>
106+- When doing file search, prefer to use the Agent tool in order to reduce context usage.
107+- All tools are executed in parallel when multiple tool calls are sent in a single message. Only send multiple tool calls when they are safe to run in parallel (no dependencies between them).
108+- The user does not see the full output of the tool responses, so if you need the output of the tool for the response make sure to summarize it for the user.
109+</tool_use>
110+
111+<env>
112+Working directory: {{.WorkingDir}}
113+Is directory a git repo: {{if .IsGitRepo}} yes {{else}} no {{end}}
114+Platform: {{.Platform}}
115+Today's date: {{.Date}}
116+</env>{{if gt (len .Config.LSP) 0}}
117+<lsp>
118+Tools that support it will also include useful diagnostics such as linting and typechecking.
119+- These diagnostics will be automatically enabled when you run the tool, and will be displayed in the output at the bottom within the <file_diagnostics></file_diagnostics> and <project_diagnostics></project_diagnostics> tags.
120+- Take necessary actions to fix the issues.
121+- You should ignore diagnostics of files that you did not change or are not related or caused by your changes unless the user explicitly asks you to fix them.
122+</lsp>
123+{{end}}{{if gt (len .Config.Options.ContextPaths) 0}}
124+<memory>
125+{{range .Config.Options.ContextPaths}}
126+{{range contextFiles .}}
127+<file path="{{.Path}}">
128+{{.Content}}
129+</file>
130+{{end}}
131+{{end}}
132+</memory>
133+{{end}}
internal/agent/templates/initialize.mdnew file+9
@@ -0,0 +1,9 @@
1+`Please analyze this codebase and create a **CRUSH.md** file containing:
2+
3+- Build/lint/test commands - especially for running a single test
4+- Code style guidelines including imports, formatting, types, naming conventions, error handling, etc.
5+
6+The file you create will be given to agentic coding agents (such as yourself) that operate in this repository. Make it about 20-30 lines long.
7+If there's already a **CRUSH.md**, improve it.
8+
9+If there are Cursor rules (in `.cursor/rules/` or `.cursorrules`) or Copilot rules (in `.github/copilot-instructions.md`), make sure to include them.
internal/agent/templates/summary.mdnew file+11
@@ -0,0 +1,11 @@
1+You are a helpful AI assistant tasked with summarizing conversations.
2+
3+When asked to summarize, provide a detailed but concise summary of the conversation.
4+Focus on information that would be helpful for continuing the conversation, including:
5+
6+- What was done
7+- What is currently being worked on
8+- Which files are being modified
9+- What needs to be done next
10+
11+Your summary should be comprehensive enough to provide context but concise enough to be quickly understood.
internal/agent/templates/task.gotmplnew file+15
@@ -0,0 +1,15 @@
1+You are an agent for Crush. Given the user's prompt, you should use the tools available to you to answer the user's question.
2+
3+<rules>
4+1. You should be concise, direct, and to the point, since your responses will be displayed on a command line interface. Answer the user's question directly, without elaboration, explanation, or details. One word answers are best. Avoid introductions, conclusions, and explanations. You MUST avoid text before/after your response, such as "The answer is <answer>.", "Here is the content of the file..." or "Based on the information provided, the answer is..." or "Here is what I will do next...".
5+2. When relevant, share file names and code snippets relevant to the query
6+3. Any file paths you return in your final response MUST be absolute. DO NOT use relative paths.
7+</rules>
8+
9+<env>
10+Working directory: {{.WorkingDir}}
11+Is directory a git repo: {{if .IsGitRepo}} yes {{else}} no {{end}}
12+Platform: {{.Platform}}
13+Today's date: {{.Date}}
14+</env>
15+
internal/agent/templates/title.mdnew file+8
@@ -0,0 +1,8 @@
1+you will generate a short title based on the first message a user begins a conversation with
2+
3+- ensure it is not more than 50 characters long
4+- the title should be a summary of the user's message
5+- it should be one line long
6+- do not use quotes or colons
7+- the entire text you return will be used as the title
8+- never return anything that is more than one sentence (one line) long
internal/agent/tools/bash.gonew file+315
@@ -0,0 +1,315 @@
1+package tools
2+
3+import (
4+ "bytes"
5+ "context"
6+ _ "embed"
7+ "fmt"
8+ "html/template"
9+ "strings"
10+ "time"
11+
12+ "github.com/charmbracelet/crush/internal/config"
13+ "github.com/charmbracelet/crush/internal/permission"
14+ "github.com/charmbracelet/crush/internal/shell"
15+ "github.com/charmbracelet/fantasy/ai"
16+)
17+
18+type BashParams struct {
19+ Command string `json:"command" description:"The command to execute"`
20+ Description string `json:"description,omitempty" description:"A brief description of what the command does"`
21+ Timeout int `json:"timeout,omitempty" description:"Optional timeout in milliseconds (max 600000)"`
22+}
23+
24+type BashPermissionsParams struct {
25+ Command string `json:"command"`
26+ Description string `json:"description"`
27+ Timeout int `json:"timeout"`
28+}
29+
30+type BashResponseMetadata struct {
31+ StartTime int64 `json:"start_time"`
32+ EndTime int64 `json:"end_time"`
33+ Output string `json:"output"`
34+ Description string `json:"description"`
35+ WorkingDirectory string `json:"working_directory"`
36+}
37+
38+const (
39+ BashToolName = "bash"
40+
41+ DefaultTimeout = 1 * 60 * 1000 // 1 minutes in milliseconds
42+ MaxTimeout = 10 * 60 * 1000 // 10 minutes in milliseconds
43+ MaxOutputLength = 30000
44+ BashNoOutput = "no output"
45+)
46+
47+//go:embed bash.gotmpl
48+var bashDescriptionTmpl []byte
49+
50+var bashDescriptionTpl = template.Must(
51+ template.New("bashDescription").
52+ Parse(string(bashDescriptionTmpl)),
53+)
54+
55+type bashDescriptionData struct {
56+ BannedCommands string
57+ MaxOutputLength int
58+ Attribution config.Attribution
59+}
60+
61+var bannedCommands = []string{
62+ // Network/Download tools
63+ "alias",
64+ "aria2c",
65+ "axel",
66+ "chrome",
67+ "curl",
68+ "curlie",
69+ "firefox",
70+ "http-prompt",
71+ "httpie",
72+ "links",
73+ "lynx",
74+ "nc",
75+ "safari",
76+ "scp",
77+ "ssh",
78+ "telnet",
79+ "w3m",
80+ "wget",
81+ "xh",
82+
83+ // System administration
84+ "doas",
85+ "su",
86+ "sudo",
87+
88+ // Package managers
89+ "apk",
90+ "apt",
91+ "apt-cache",
92+ "apt-get",
93+ "dnf",
94+ "dpkg",
95+ "emerge",
96+ "home-manager",
97+ "makepkg",
98+ "opkg",
99+ "pacman",
100+ "paru",
101+ "pkg",
102+ "pkg_add",
103+ "pkg_delete",
104+ "portage",
105+ "rpm",
106+ "yay",
107+ "yum",
108+ "zypper",
109+
110+ // System modification
111+ "at",
112+ "batch",
113+ "chkconfig",
114+ "crontab",
115+ "fdisk",
116+ "mkfs",
117+ "mount",
118+ "parted",
119+ "service",
120+ "systemctl",
121+ "umount",
122+
123+ // Network configuration
124+ "firewall-cmd",
125+ "ifconfig",
126+ "ip",
127+ "iptables",
128+ "netstat",
129+ "pfctl",
130+ "route",
131+ "ufw",
132+}
133+
134+func bashDescription(attribution *config.Attribution) string {
135+ bannedCommandsStr := strings.Join(bannedCommands, ", ")
136+ var out bytes.Buffer
137+ if err := bashDescriptionTpl.Execute(&out, bashDescriptionData{
138+ BannedCommands: bannedCommandsStr,
139+ MaxOutputLength: MaxOutputLength,
140+ Attribution: *attribution,
141+ }); err != nil {
142+ // this should never happen.
143+ panic("failed to execute bash description template: " + err.Error())
144+ }
145+ return out.String()
146+}
147+
148+func blockFuncs() []shell.BlockFunc {
149+ return []shell.BlockFunc{
150+ shell.CommandsBlocker(bannedCommands),
151+
152+ // System package managers
153+ shell.ArgumentsBlocker("apk", []string{"add"}, nil),
154+ shell.ArgumentsBlocker("apt", []string{"install"}, nil),
155+ shell.ArgumentsBlocker("apt-get", []string{"install"}, nil),
156+ shell.ArgumentsBlocker("dnf", []string{"install"}, nil),
157+ shell.ArgumentsBlocker("pacman", nil, []string{"-S"}),
158+ shell.ArgumentsBlocker("pkg", []string{"install"}, nil),
159+ shell.ArgumentsBlocker("yum", []string{"install"}, nil),
160+ shell.ArgumentsBlocker("zypper", []string{"install"}, nil),
161+
162+ // Language-specific package managers
163+ shell.ArgumentsBlocker("brew", []string{"install"}, nil),
164+ shell.ArgumentsBlocker("cargo", []string{"install"}, nil),
165+ shell.ArgumentsBlocker("gem", []string{"install"}, nil),
166+ shell.ArgumentsBlocker("go", []string{"install"}, nil),
167+ shell.ArgumentsBlocker("npm", []string{"install"}, []string{"--global"}),
168+ shell.ArgumentsBlocker("npm", []string{"install"}, []string{"-g"}),
169+ shell.ArgumentsBlocker("pip", []string{"install"}, []string{"--user"}),
170+ shell.ArgumentsBlocker("pip3", []string{"install"}, []string{"--user"}),
171+ shell.ArgumentsBlocker("pnpm", []string{"add"}, []string{"--global"}),
172+ shell.ArgumentsBlocker("pnpm", []string{"add"}, []string{"-g"}),
173+ shell.ArgumentsBlocker("yarn", []string{"global", "add"}, nil),
174+
175+ // `go test -exec` can run arbitrary commands
176+ shell.ArgumentsBlocker("go", []string{"test"}, []string{"-exec"}),
177+ }
178+}
179+
180+func NewBashTool(permissions permission.Service, workingDir string, attribution *config.Attribution) ai.AgentTool {
181+ // Set up command blocking on the persistent shell
182+ persistentShell := shell.GetPersistentShell(workingDir)
183+ persistentShell.SetBlockFuncs(blockFuncs())
184+ return ai.NewAgentTool(
185+ BashToolName,
186+ string(bashDescription(attribution)),
187+ func(ctx context.Context, params BashParams, call ai.ToolCall) (ai.ToolResponse, error) {
188+ if params.Timeout > MaxTimeout {
189+ params.Timeout = MaxTimeout
190+ } else if params.Timeout <= 0 {
191+ params.Timeout = DefaultTimeout
192+ }
193+
194+ if params.Command == "" {
195+ return ai.NewTextErrorResponse("missing command"), nil
196+ }
197+
198+ isSafeReadOnly := false
199+ cmdLower := strings.ToLower(params.Command)
200+
201+ for _, safe := range safeCommands {
202+ if strings.HasPrefix(cmdLower, safe) {
203+ if len(cmdLower) == len(safe) || cmdLower[len(safe)] == ' ' || cmdLower[len(safe)] == '-' {
204+ isSafeReadOnly = true
205+ break
206+ }
207+ }
208+ }
209+
210+ sessionID := GetSessionFromContext(ctx)
211+ if sessionID == "" {
212+ return ai.ToolResponse{}, fmt.Errorf("session ID is required for executing shell command")
213+ }
214+ if !isSafeReadOnly {
215+ shell := shell.GetPersistentShell(workingDir)
216+ p := permissions.Request(
217+ permission.CreatePermissionRequest{
218+ SessionID: sessionID,
219+ Path: shell.GetWorkingDir(),
220+ ToolCallID: call.ID,
221+ ToolName: BashToolName,
222+ Action: "execute",
223+ Description: fmt.Sprintf("Execute command: %s", params.Command),
224+ Params: BashPermissionsParams{
225+ Command: params.Command,
226+ Description: params.Description,
227+ },
228+ },
229+ )
230+ if !p {
231+ return ai.ToolResponse{}, permission.ErrorPermissionDenied
232+ }
233+ }
234+ startTime := time.Now()
235+ if params.Timeout > 0 {
236+ var cancel context.CancelFunc
237+ ctx, cancel = context.WithTimeout(ctx, time.Duration(params.Timeout)*time.Millisecond)
238+ defer cancel()
239+ }
240+
241+ persistentShell := shell.GetPersistentShell(workingDir)
242+ stdout, stderr, err := persistentShell.Exec(ctx, params.Command)
243+
244+ // Get the current working directory after command execution
245+ currentWorkingDir := persistentShell.GetWorkingDir()
246+ interrupted := shell.IsInterrupt(err)
247+ exitCode := shell.ExitCode(err)
248+ if exitCode == 0 && !interrupted && err != nil {
249+ return ai.ToolResponse{}, fmt.Errorf("error executing command: %w", err)
250+ }
251+
252+ stdout = truncateOutput(stdout)
253+ stderr = truncateOutput(stderr)
254+
255+ errorMessage := stderr
256+ if errorMessage == "" && err != nil {
257+ errorMessage = err.Error()
258+ }
259+
260+ if interrupted {
261+ if errorMessage != "" {
262+ errorMessage += "\n"
263+ }
264+ errorMessage += "Command was aborted before completion"
265+ } else if exitCode != 0 {
266+ if errorMessage != "" {
267+ errorMessage += "\n"
268+ }
269+ errorMessage += fmt.Sprintf("Exit code %d", exitCode)
270+ }
271+
272+ hasBothOutputs := stdout != "" && stderr != ""
273+
274+ if hasBothOutputs {
275+ stdout += "\n"
276+ }
277+
278+ if errorMessage != "" {
279+ stdout += "\n" + errorMessage
280+ }
281+
282+ metadata := BashResponseMetadata{
283+ StartTime: startTime.UnixMilli(),
284+ EndTime: time.Now().UnixMilli(),
285+ Output: stdout,
286+ Description: params.Description,
287+ WorkingDirectory: currentWorkingDir,
288+ }
289+ if stdout == "" {
290+ return ai.WithResponseMetadata(ai.NewTextResponse(BashNoOutput), metadata), nil
291+ }
292+ stdout += fmt.Sprintf("\n\n<cwd>%s</cwd>", currentWorkingDir)
293+ return ai.WithResponseMetadata(ai.NewTextResponse(stdout), metadata), nil
294+ })
295+}
296+
297+func truncateOutput(content string) string {
298+ if len(content) <= MaxOutputLength {
299+ return content
300+ }
301+
302+ halfLength := MaxOutputLength / 2
303+ start := content[:halfLength]
304+ end := content[len(content)-halfLength:]
305+
306+ truncatedLinesCount := countLines(content[halfLength : len(content)-halfLength])
307+ return fmt.Sprintf("%s\n\n... [%d lines truncated] ...\n\n%s", start, truncatedLinesCount, end)
308+}
309+
310+func countLines(s string) int {
311+ if s == "" {
312+ return 0
313+ }
314+ return len(strings.Split(s, "\n"))
315+}
internal/agent/tools/bash.gotmplnew file+116
@@ -0,0 +1,116 @@
1+Executes bash commands in persistent shell session with timeout and security measures.
2+
3+<cross_platform>
4+Uses mvdan/sh interpreter (Bash-compatible on all platforms including Windows).
5+Use forward slashes for paths: "ls C:/foo/bar" not "ls C:\foo\bar".
6+Common shell builtins and core utils available on Windows.
7+</cross_platform>
8+
9+<execution_steps>
10+1. Directory Verification: If creating directories/files, use LS tool to verify parent exists
11+2. Security Check: Banned commands ({{ .BannedCommands }}) return error - explain to user. Safe read-only commands execute without prompts
12+3. Command Execution: Execute with proper quoting, capture output
13+4. Output Processing: Truncate if exceeds {{ .MaxOutputLength }} characters
14+5. Return Result: Include errors, metadata with <cwd></cwd> tags
15+</execution_steps>
16+
17+<usage_notes>
18+- Command required, timeout optional (max 600000ms/10min, default 30min if unspecified)
19+- IMPORTANT: Use Grep/Glob/Agent tools instead of 'find'/'grep'. Use View/LS tools instead of 'cat'/'head'/'tail'/'ls'
20+- Chain with ';' or '&&', avoid newlines except in quoted strings
21+- Shell state persists (env vars, virtual envs, cwd, etc.)
22+- Prefer absolute paths over 'cd' (use 'cd' only if user explicitly requests)
23+</usage_notes>
24+
25+<git_commits>
26+When user asks to create git commit:
27+
28+1. Single message with three tool_use blocks (IMPORTANT for speed):
29+ - git status (untracked files)
30+ - git diff (staged/unstaged changes)
31+ - git log (recent commit message style)
32+
33+2. Add relevant untracked files to staging. Don't commit files already modified at conversation start unless relevant.
34+
35+3. Analyze staged changes in <commit_analysis> tags:
36+ - List changed/added files, summarize nature (feature/enhancement/bug fix/refactoring/test/docs)
37+ - Brainstorm purpose/motivation, assess project impact, check for sensitive info
38+ - Don't use tools beyond git context
39+ - Draft concise (1-2 sentences) message focusing on "why" not "what"
40+ - Use clear language, accurate reflection ("add"=new feature, "update"=enhancement, "fix"=bug fix)
41+ - Avoid generic messages, review draft
42+
43+4. Create commit with Crush signature using HEREDOC:
44+ git commit -m "$(cat <<'EOF'
45+ Commit message here.
46+{{ if .Attribution.GeneratedWith}}
47+ 💘 Generated with Crush
48+{{ end }}
49+{{ if .Attribution.CoAuthoredBy}}
50+ Co-Authored-By: Crush <crush@charm.land>
51+{{ end }}
52+ EOF
53+ )"
54+
55+5. If pre-commit hook fails, retry ONCE. If fails again, hook preventing commit. If succeeds but files modified, MUST amend.
56+
57+6. Run git status to verify.
58+
59+Notes: Use "git commit -am" when possible, don't stage unrelated files, NEVER update config, don't push, no -i flags, no empty commits, return empty response.
60+</git_commits>
61+
62+<pull_requests>
63+Use gh command for ALL GitHub tasks. When user asks to create PR:
64+
65+1. Single message with multiple tool_use blocks (VERY IMPORTANT for speed):
66+ - git status (untracked files)
67+ - git diff (staged/unstaged changes)
68+ - Check if branch tracks remote and is up to date
69+ - git log and 'git diff main...HEAD' (full commit history from main divergence)
70+
71+2. Create new branch if needed
72+3. Commit changes if needed
73+4. Push to remote with -u flag if needed
74+
75+5. Analyze changes in <pr_analysis> tags:
76+ - List commits since diverging from main
77+ - Summarize nature of changes
78+ - Brainstorm purpose/motivation
79+ - Assess project impact
80+ - Don't use tools beyond git context
81+ - Check for sensitive information
82+ - Draft concise (1-2 bullet points) PR summary focusing on "why"
83+ - Ensure summary reflects ALL changes since main divergence
84+ - Clear, concise language
85+ - Accurate reflection of changes and purpose
86+ - Avoid generic summaries
87+ - Review draft
88+
89+6. Create PR with gh pr create using HEREDOC:
90+ gh pr create --title "title" --body "$(cat <<'EOF'
91+
92+ ## Summary
93+
94+ <1-3 bullet points>
95+
96+ ## Test plan
97+
98+ [Checklist of TODOs...]
99+
100+{{ if .Attribution.GeneratedWith}}
101+ 💘 Generated with Crush
102+{{ end }}
103+
104+ EOF
105+ )"
106+
107+Important:
108+
109+- Return empty response - user sees gh output
110+- Never update git config
111+ </pull_requests>
112+
113+<examples>
114+Good: pytest /foo/bar/tests
115+Bad: cd /foo/bar && pytest tests
116+</examples>
internal/agent/tools/diagnostics.gonew file+185
@@ -0,0 +1,185 @@
1+package tools
2+
3+import (
4+ "context"
5+ _ "embed"
6+ "fmt"
7+ "log/slog"
8+ "sort"
9+ "strings"
10+ "time"
11+
12+ "github.com/charmbracelet/crush/internal/csync"
13+ "github.com/charmbracelet/crush/internal/lsp"
14+ "github.com/charmbracelet/fantasy/ai"
15+ "github.com/charmbracelet/x/powernap/pkg/lsp/protocol"
16+)
17+
18+type DiagnosticsParams struct {
19+ FilePath string `json:"file_path,omitempty" description:"The path to the file to get diagnostics for (leave w empty for project diagnostics)"`
20+}
21+
22+const DiagnosticsToolName = "diagnostics"
23+
24+//go:embed diagnostics.md
25+var diagnosticsDescription []byte
26+
27+func NewDiagnosticsTool(lspClients *csync.Map[string, *lsp.Client]) ai.AgentTool {
28+ return ai.NewAgentTool(
29+ DiagnosticsToolName,
30+ string(diagnosticsDescription),
31+ func(ctx context.Context, params DiagnosticsParams, call ai.ToolCall) (ai.ToolResponse, error) {
32+ if lspClients.Len() == 0 {
33+ return ai.NewTextErrorResponse("no LSP clients available"), nil
34+ }
35+ notifyLSPs(ctx, lspClients, params.FilePath)
36+ output := getDiagnostics(params.FilePath, lspClients)
37+ return ai.NewTextResponse(output), nil
38+ })
39+}
40+
41+func notifyLSPs(ctx context.Context, lsps *csync.Map[string, *lsp.Client], filepath string) {
42+ if filepath == "" {
43+ return
44+ }
45+ for client := range lsps.Seq() {
46+ if !client.HandlesFile(filepath) {
47+ continue
48+ }
49+ _ = client.OpenFileOnDemand(ctx, filepath)
50+ _ = client.NotifyChange(ctx, filepath)
51+ client.WaitForDiagnostics(ctx, 5*time.Second)
52+ }
53+}
54+
55+func getDiagnostics(filePath string, lsps *csync.Map[string, *lsp.Client]) string {
56+ fileDiagnostics := []string{}
57+ projectDiagnostics := []string{}
58+
59+ for lspName, client := range lsps.Seq2() {
60+ for location, diags := range client.GetDiagnostics() {
61+ path, err := location.Path()
62+ if err != nil {
63+ slog.Error("Failed to convert diagnostic location URI to path", "uri", location, "error", err)
64+ continue
65+ }
66+ isCurrentFile := path == filePath
67+ for _, diag := range diags {
68+ formattedDiag := formatDiagnostic(path, diag, lspName)
69+ if isCurrentFile {
70+ fileDiagnostics = append(fileDiagnostics, formattedDiag)
71+ } else {
72+ projectDiagnostics = append(projectDiagnostics, formattedDiag)
73+ }
74+ }
75+ }
76+ }
77+
78+ sortDiagnostics(fileDiagnostics)
79+ sortDiagnostics(projectDiagnostics)
80+
81+ var output strings.Builder
82+ writeDiagnostics(&output, "file_diagnostics", fileDiagnostics)
83+ writeDiagnostics(&output, "project_diagnostics", projectDiagnostics)
84+
85+ if len(fileDiagnostics) > 0 || len(projectDiagnostics) > 0 {
86+ fileErrors := countSeverity(fileDiagnostics, "Error")
87+ fileWarnings := countSeverity(fileDiagnostics, "Warn")
88+ projectErrors := countSeverity(projectDiagnostics, "Error")
89+ projectWarnings := countSeverity(projectDiagnostics, "Warn")
90+ output.WriteString("\n<diagnostic_summary>\n")
91+ fmt.Fprintf(&output, "Current file: %d errors, %d warnings\n", fileErrors, fileWarnings)
92+ fmt.Fprintf(&output, "Project: %d errors, %d warnings\n", projectErrors, projectWarnings)
93+ output.WriteString("</diagnostic_summary>\n")
94+ }
95+
96+ out := output.String()
97+ slog.Info("Diagnostics", "output", fmt.Sprintf("%q", out))
98+ return out
99+}
100+
101+func writeDiagnostics(output *strings.Builder, tag string, in []string) {
102+ if len(in) == 0 {
103+ return
104+ }
105+ output.WriteString("\n<" + tag + ">\n")
106+ if len(in) > 10 {
107+ output.WriteString(strings.Join(in[:10], "\n"))
108+ fmt.Fprintf(output, "\n... and %d more diagnostics", len(in)-10)
109+ } else {
110+ output.WriteString(strings.Join(in, "\n"))
111+ }
112+ output.WriteString("\n</" + tag + ">\n")
113+}
114+
115+func sortDiagnostics(in []string) []string {
116+ sort.Slice(in, func(i, j int) bool {
117+ iIsError := strings.HasPrefix(in[i], "Error")
118+ jIsError := strings.HasPrefix(in[j], "Error")
119+ if iIsError != jIsError {
120+ return iIsError // Errors come first
121+ }
122+ return in[i] < in[j] // Then alphabetically
123+ })
124+ return in
125+}
126+
127+func formatDiagnostic(pth string, diagnostic protocol.Diagnostic, source string) string {
128+ severity := "Info"
129+ switch diagnostic.Severity {
130+ case protocol.SeverityError:
131+ severity = "Error"
132+ case protocol.SeverityWarning:
133+ severity = "Warn"
134+ case protocol.SeverityHint:
135+ severity = "Hint"
136+ }
137+
138+ location := fmt.Sprintf("%s:%d:%d", pth, diagnostic.Range.Start.Line+1, diagnostic.Range.Start.Character+1)
139+
140+ sourceInfo := ""
141+ if diagnostic.Source != "" {
142+ sourceInfo = diagnostic.Source
143+ } else if source != "" {
144+ sourceInfo = source
145+ }
146+
147+ codeInfo := ""
148+ if diagnostic.Code != nil {
149+ codeInfo = fmt.Sprintf("[%v]", diagnostic.Code)
150+ }
151+
152+ tagsInfo := ""
153+ if len(diagnostic.Tags) > 0 {
154+ tags := []string{}
155+ for _, tag := range diagnostic.Tags {
156+ switch tag {
157+ case protocol.Unnecessary:
158+ tags = append(tags, "unnecessary")
159+ case protocol.Deprecated:
160+ tags = append(tags, "deprecated")
161+ }
162+ }
163+ if len(tags) > 0 {
164+ tagsInfo = fmt.Sprintf(" (%s)", strings.Join(tags, ", "))
165+ }
166+ }
167+
168+ return fmt.Sprintf("%s: %s [%s]%s%s %s",
169+ severity,
170+ location,
171+ sourceInfo,
172+ codeInfo,
173+ tagsInfo,
174+ diagnostic.Message)
175+}
176+
177+func countSeverity(diagnostics []string, severity string) int {
178+ count := 0
179+ for _, diag := range diagnostics {
180+ if strings.HasPrefix(diag, severity) {
181+ count++
182+ }
183+ }
184+ return count
185+}
internal/agent/tools/diagnostics.mdnew file+24
@@ -0,0 +1,24 @@
1+Get diagnostics for file and/or project.
2+
3+<usage>
4+- Provide file path to get diagnostics for that file
5+- Leave path empty to get diagnostics for entire project
6+- Results displayed in structured format with severity levels
7+</usage>
8+
9+<features>
10+- Displays errors, warnings, and hints
11+- Groups diagnostics by severity
12+- Provides detailed information about each diagnostic
13+</features>
14+
15+<limitations>
16+- Results limited to diagnostics provided by LSP clients
17+- May not cover all possible code issues
18+- Does not provide suggestions for fixing issues
19+</limitations>
20+
21+<tips>
22+- Use with other tools for comprehensive code review
23+- Combine with LSP client for real-time diagnostics
24+</tips>
internal/agent/tools/download.gonew file+157
@@ -0,0 +1,157 @@
1+package tools
2+
3+import (
4+ "context"
5+ _ "embed"
6+ "fmt"
7+ "io"
8+ "net/http"
9+ "os"
10+ "path/filepath"
11+ "strings"
12+ "time"
13+
14+ "github.com/charmbracelet/crush/internal/permission"
15+ "github.com/charmbracelet/fantasy/ai"
16+)
17+
18+type DownloadParams struct {
19+ URL string `json:"url" description:"The URL to download from"`
20+ FilePath string `json:"file_path" description:"The local file path where the downloaded content should be saved"`
21+ Timeout int `json:"timeout,omitempty" description:"Optional timeout in seconds (max 600)"`
22+}
23+
24+type DownloadPermissionsParams struct {
25+ URL string `json:"url"`
26+ FilePath string `json:"file_path"`
27+ Timeout int `json:"timeout,omitempty"`
28+}
29+
30+const DownloadToolName = "download"
31+
32+//go:embed download.md
33+var downloadDescription []byte
34+
35+func NewDownloadTool(permissions permission.Service, workingDir string) ai.AgentTool {
36+ client := &http.Client{
37+ Timeout: 5 * time.Minute, // Default 5 minute timeout for downloads
38+ Transport: &http.Transport{
39+ MaxIdleConns: 100,
40+ MaxIdleConnsPerHost: 10,
41+ IdleConnTimeout: 90 * time.Second,
42+ },
43+ }
44+ return ai.NewAgentTool(
45+ DownloadToolName,
46+ string(downloadDescription),
47+ func(ctx context.Context, params DownloadParams, call ai.ToolCall) (ai.ToolResponse, error) {
48+ if params.URL == "" {
49+ return ai.NewTextErrorResponse("URL parameter is required"), nil
50+ }
51+
52+ if params.FilePath == "" {
53+ return ai.NewTextErrorResponse("file_path parameter is required"), nil
54+ }
55+
56+ if !strings.HasPrefix(params.URL, "http://") && !strings.HasPrefix(params.URL, "https://") {
57+ return ai.NewTextErrorResponse("URL must start with http:// or https://"), nil
58+ }
59+
60+ // Convert relative path to absolute path
61+ var filePath string
62+ if filepath.IsAbs(params.FilePath) {
63+ filePath = params.FilePath
64+ } else {
65+ filePath = filepath.Join(workingDir, params.FilePath)
66+ }
67+
68+ sessionID := GetSessionFromContext(ctx)
69+ if sessionID == "" {
70+ return ai.ToolResponse{}, fmt.Errorf("session ID is required for downloading files")
71+ }
72+
73+ p := permissions.Request(
74+ permission.CreatePermissionRequest{
75+ SessionID: sessionID,
76+ Path: filePath,
77+ ToolName: DownloadToolName,
78+ Action: "download",
79+ Description: fmt.Sprintf("Download file from URL: %s to %s", params.URL, filePath),
80+ Params: DownloadPermissionsParams(params),
81+ },
82+ )
83+
84+ if !p {
85+ return ai.ToolResponse{}, permission.ErrorPermissionDenied
86+ }
87+
88+ // Handle timeout with context
89+ requestCtx := ctx
90+ if params.Timeout > 0 {
91+ maxTimeout := 600 // 10 minutes
92+ if params.Timeout > maxTimeout {
93+ params.Timeout = maxTimeout
94+ }
95+ var cancel context.CancelFunc
96+ requestCtx, cancel = context.WithTimeout(ctx, time.Duration(params.Timeout)*time.Second)
97+ defer cancel()
98+ }
99+
100+ req, err := http.NewRequestWithContext(requestCtx, "GET", params.URL, nil)
101+ if err != nil {
102+ return ai.ToolResponse{}, fmt.Errorf("failed to create request: %w", err)
103+ }
104+
105+ req.Header.Set("User-Agent", "crush/1.0")
106+
107+ resp, err := client.Do(req)
108+ if err != nil {
109+ return ai.ToolResponse{}, fmt.Errorf("failed to download from URL: %w", err)
110+ }
111+ defer resp.Body.Close()
112+
113+ if resp.StatusCode != http.StatusOK {
114+ return ai.NewTextErrorResponse(fmt.Sprintf("Request failed with status code: %d", resp.StatusCode)), nil
115+ }
116+
117+ // Check content length if available
118+ maxSize := int64(100 * 1024 * 1024) // 100MB
119+ if resp.ContentLength > maxSize {
120+ return ai.NewTextErrorResponse(fmt.Sprintf("File too large: %d bytes (max %d bytes)", resp.ContentLength, maxSize)), nil
121+ }
122+
123+ // Create parent directories if they don't exist
124+ if err := os.MkdirAll(filepath.Dir(filePath), 0o755); err != nil {
125+ return ai.ToolResponse{}, fmt.Errorf("failed to create parent directories: %w", err)
126+ }
127+
128+ // Create the output file
129+ outFile, err := os.Create(filePath)
130+ if err != nil {
131+ return ai.ToolResponse{}, fmt.Errorf("failed to create output file: %w", err)
132+ }
133+ defer outFile.Close()
134+
135+ // Copy data with size limit
136+ limitedReader := io.LimitReader(resp.Body, maxSize)
137+ bytesWritten, err := io.Copy(outFile, limitedReader)
138+ if err != nil {
139+ return ai.ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
140+ }
141+
142+ // Check if we hit the size limit
143+ if bytesWritten == maxSize {
144+ // Clean up the file since it might be incomplete
145+ os.Remove(filePath)
146+ return ai.NewTextErrorResponse(fmt.Sprintf("File too large: exceeded %d bytes limit", maxSize)), nil
147+ }
148+
149+ contentType := resp.Header.Get("Content-Type")
150+ responseMsg := fmt.Sprintf("Successfully downloaded %d bytes to %s", bytesWritten, filePath)
151+ if contentType != "" {
152+ responseMsg += fmt.Sprintf(" (Content-Type: %s)", contentType)
153+ }
154+
155+ return ai.NewTextResponse(responseMsg), nil
156+ })
157+}
internal/agent/tools/download.mdnew file+28
@@ -0,0 +1,28 @@
1+Downloads binary data from URL and saves to local file.
2+
3+<usage>
4+- Provide URL to download from
5+- Specify local file path where content should be saved
6+- Optional timeout for request
7+</usage>
8+
9+<features>
10+- Downloads any file type (binary or text)
11+- Auto-creates parent directories if missing
12+- Handles large files efficiently with streaming
13+- Sets reasonable timeouts to prevent hanging
14+- Validates input parameters before requests
15+</features>
16+
17+<limitations>
18+- Max file size: 100MB
19+- Only supports HTTP and HTTPS protocols
20+- Cannot handle authentication or cookies
21+- Some websites may block automated requests
22+- Will overwrite existing files without warning
23+</limitations>
24+
25+<tips>
26+- Use absolute paths or paths relative to working directory
27+- Set appropriate timeouts for large files or slow connections
28+</tips>
internal/agent/tools/edit.gonew file+449
@@ -0,0 +1,449 @@
1+package tools
2+
3+import (
4+ "context"
5+ _ "embed"
6+ "fmt"
7+ "log/slog"
8+ "os"
9+ "path/filepath"
10+ "strings"
11+ "time"
12+
13+ "github.com/charmbracelet/crush/internal/csync"
14+ "github.com/charmbracelet/crush/internal/diff"
15+ "github.com/charmbracelet/crush/internal/fsext"
16+ "github.com/charmbracelet/crush/internal/history"
17+ "github.com/charmbracelet/fantasy/ai"
18+
19+ "github.com/charmbracelet/crush/internal/lsp"
20+ "github.com/charmbracelet/crush/internal/permission"
21+)
22+
23+type EditParams struct {
24+ FilePath string `json:"file_path" description:"The absolute path to the file to modify"`
25+ OldString string `json:"old_string" description:"The text to replace"`
26+ NewString string `json:"new_string" description:"The text to replace it with"`
27+ ReplaceAll bool `json:"replace_all,omitempty" description:"Replace all occurrences of old_string (default false)"`
28+}
29+
30+type EditPermissionsParams struct {
31+ FilePath string `json:"file_path"`
32+ OldContent string `json:"old_content,omitempty"`
33+ NewContent string `json:"new_content,omitempty"`
34+}
35+
36+type EditResponseMetadata struct {
37+ Additions int `json:"additions"`
38+ Removals int `json:"removals"`
39+ OldContent string `json:"old_content,omitempty"`
40+ NewContent string `json:"new_content,omitempty"`
41+}
42+
43+const EditToolName = "edit"
44+
45+//go:embed edit.md
46+var editDescription []byte
47+
48+type editContext struct {
49+ ctx context.Context
50+ permissions permission.Service
51+ files history.Service
52+ workingDir string
53+}
54+
55+func NewEditTool(lspClients *csync.Map[string, *lsp.Client], permissions permission.Service, files history.Service, workingDir string) ai.AgentTool {
56+ return ai.NewAgentTool(
57+ EditToolName,
58+ string(editDescription),
59+ func(ctx context.Context, params EditParams, call ai.ToolCall) (ai.ToolResponse, error) {
60+ if params.FilePath == "" {
61+ return ai.NewTextErrorResponse("file_path is required"), nil
62+ }
63+
64+ if !filepath.IsAbs(params.FilePath) {
65+ params.FilePath = filepath.Join(workingDir, params.FilePath)
66+ }
67+
68+ var response ai.ToolResponse
69+ var err error
70+
71+ editCtx := editContext{ctx, permissions, files, workingDir}
72+
73+ if params.OldString == "" {
74+ response, err = createNewFile(editCtx, params.FilePath, params.NewString, call)
75+ if err != nil {
76+ return response, err
77+ }
78+ }
79+
80+ if params.NewString == "" {
81+ response, err = deleteContent(editCtx, params.FilePath, params.OldString, params.ReplaceAll, call)
82+ if err != nil {
83+ return response, err
84+ }
85+ }
86+
87+ response, err = replaceContent(editCtx, params.FilePath, params.OldString, params.NewString, params.ReplaceAll, call)
88+ if err != nil {
89+ return response, err
90+ }
91+ if response.IsError {
92+ // Return early if there was an error during content replacement
93+ // This prevents unnecessary LSP diagnostics processing
94+ return response, nil
95+ }
96+
97+ notifyLSPs(ctx, lspClients, params.FilePath)
98+
99+ text := fmt.Sprintf("<result>\n%s\n</result>\n", response.Content)
100+ text += getDiagnostics(params.FilePath, lspClients)
101+ response.Content = text
102+ return response, nil
103+ })
104+}
105+
106+func createNewFile(edit editContext, filePath, content string, call ai.ToolCall) (ai.ToolResponse, error) {
107+ fileInfo, err := os.Stat(filePath)
108+ if err == nil {
109+ if fileInfo.IsDir() {
110+ return ai.NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", filePath)), nil
111+ }
112+ return ai.NewTextErrorResponse(fmt.Sprintf("file already exists: %s", filePath)), nil
113+ } else if !os.IsNotExist(err) {
114+ return ai.ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
115+ }
116+
117+ dir := filepath.Dir(filePath)
118+ if err = os.MkdirAll(dir, 0o755); err != nil {
119+ return ai.ToolResponse{}, fmt.Errorf("failed to create parent directories: %w", err)
120+ }
121+
122+ sessionID := GetSessionFromContext(edit.ctx)
123+ if sessionID == "" {
124+ return ai.ToolResponse{}, fmt.Errorf("session ID is required for creating a new file")
125+ }
126+
127+ _, additions, removals := diff.GenerateDiff(
128+ "",
129+ content,
130+ strings.TrimPrefix(filePath, edit.workingDir),
131+ )
132+ p := edit.permissions.Request(
133+ permission.CreatePermissionRequest{
134+ SessionID: sessionID,
135+ Path: fsext.PathOrPrefix(filePath, edit.workingDir),
136+ ToolCallID: call.ID,
137+ ToolName: EditToolName,
138+ Action: "write",
139+ Description: fmt.Sprintf("Create file %s", filePath),
140+ Params: EditPermissionsParams{
141+ FilePath: filePath,
142+ OldContent: "",
143+ NewContent: content,
144+ },
145+ },
146+ )
147+ if !p {
148+ return ai.ToolResponse{}, permission.ErrorPermissionDenied
149+ }
150+
151+ err = os.WriteFile(filePath, []byte(content), 0o644)
152+ if err != nil {
153+ return ai.ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
154+ }
155+
156+ // File can't be in the history so we create a new file history
157+ _, err = edit.files.Create(edit.ctx, sessionID, filePath, "")
158+ if err != nil {
159+ // Log error but don't fail the operation
160+ return ai.ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
161+ }
162+
163+ // Add the new content to the file history
164+ _, err = edit.files.CreateVersion(edit.ctx, sessionID, filePath, content)
165+ if err != nil {
166+ // Log error but don't fail the operation
167+ slog.Debug("Error creating file history version", "error", err)
168+ }
169+
170+ recordFileWrite(filePath)
171+ recordFileRead(filePath)
172+
173+ return ai.WithResponseMetadata(
174+ ai.NewTextResponse("File created: "+filePath),
175+ EditResponseMetadata{
176+ OldContent: "",
177+ NewContent: content,
178+ Additions: additions,
179+ Removals: removals,
180+ },
181+ ), nil
182+}
183+
184+func deleteContent(edit editContext, filePath, oldString string, replaceAll bool, call ai.ToolCall) (ai.ToolResponse, error) {
185+ fileInfo, err := os.Stat(filePath)
186+ if err != nil {
187+ if os.IsNotExist(err) {
188+ return ai.NewTextErrorResponse(fmt.Sprintf("file not found: %s", filePath)), nil
189+ }
190+ return ai.ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
191+ }
192+
193+ if fileInfo.IsDir() {
194+ return ai.NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", filePath)), nil
195+ }
196+
197+ if getLastReadTime(filePath).IsZero() {
198+ return ai.NewTextErrorResponse("you must read the file before editing it. Use the View tool first"), nil
199+ }
200+
201+ modTime := fileInfo.ModTime()
202+ lastRead := getLastReadTime(filePath)
203+ if modTime.After(lastRead) {
204+ return ai.NewTextErrorResponse(
205+ fmt.Sprintf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
206+ filePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339),
207+ )), nil
208+ }
209+
210+ content, err := os.ReadFile(filePath)
211+ if err != nil {
212+ return ai.ToolResponse{}, fmt.Errorf("failed to read file: %w", err)
213+ }
214+
215+ oldContent, isCrlf := fsext.ToUnixLineEndings(string(content))
216+
217+ var newContent string
218+ var deletionCount int
219+
220+ if replaceAll {
221+ newContent = strings.ReplaceAll(oldContent, oldString, "")
222+ deletionCount = strings.Count(oldContent, oldString)
223+ if deletionCount == 0 {
224+ return ai.NewTextErrorResponse("old_string not found in file. Make sure it matches exactly, including whitespace and line breaks"), nil
225+ }
226+ } else {
227+ index := strings.Index(oldContent, oldString)
228+ if index == -1 {
229+ return ai.NewTextErrorResponse("old_string not found in file. Make sure it matches exactly, including whitespace and line breaks"), nil
230+ }
231+
232+ lastIndex := strings.LastIndex(oldContent, oldString)
233+ if index != lastIndex {
234+ return ai.NewTextErrorResponse("old_string appears multiple times in the file. Please provide more context to ensure a unique match, or set replace_all to true"), nil
235+ }
236+
237+ newContent = oldContent[:index] + oldContent[index+len(oldString):]
238+ deletionCount = 1
239+ }
240+
241+ sessionID := GetSessionFromContext(edit.ctx)
242+
243+ if sessionID == "" {
244+ return ai.ToolResponse{}, fmt.Errorf("session ID is required for creating a new file")
245+ }
246+
247+ _, additions, removals := diff.GenerateDiff(
248+ oldContent,
249+ newContent,
250+ strings.TrimPrefix(filePath, edit.workingDir),
251+ )
252+
253+ p := edit.permissions.Request(
254+ permission.CreatePermissionRequest{
255+ SessionID: sessionID,
256+ Path: fsext.PathOrPrefix(filePath, edit.workingDir),
257+ ToolCallID: call.ID,
258+ ToolName: EditToolName,
259+ Action: "write",
260+ Description: fmt.Sprintf("Delete content from file %s", filePath),
261+ Params: EditPermissionsParams{
262+ FilePath: filePath,
263+ OldContent: oldContent,
264+ NewContent: newContent,
265+ },
266+ },
267+ )
268+ if !p {
269+ return ai.ToolResponse{}, permission.ErrorPermissionDenied
270+ }
271+
272+ if isCrlf {
273+ newContent, _ = fsext.ToWindowsLineEndings(newContent)
274+ }
275+
276+ err = os.WriteFile(filePath, []byte(newContent), 0o644)
277+ if err != nil {
278+ return ai.ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
279+ }
280+
281+ // Check if file exists in history
282+ file, err := edit.files.GetByPathAndSession(edit.ctx, filePath, sessionID)
283+ if err != nil {
284+ _, err = edit.files.Create(edit.ctx, sessionID, filePath, oldContent)
285+ if err != nil {
286+ // Log error but don't fail the operation
287+ return ai.ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
288+ }
289+ }
290+ if file.Content != oldContent {
291+ // User Manually changed the content store an intermediate version
292+ _, err = edit.files.CreateVersion(edit.ctx, sessionID, filePath, oldContent)
293+ if err != nil {
294+ slog.Debug("Error creating file history version", "error", err)
295+ }
296+ }
297+ // Store the new version
298+ _, err = edit.files.CreateVersion(edit.ctx, sessionID, filePath, "")
299+ if err != nil {
300+ slog.Debug("Error creating file history version", "error", err)
301+ }
302+
303+ recordFileWrite(filePath)
304+ recordFileRead(filePath)
305+
306+ return ai.WithResponseMetadata(
307+ ai.NewTextResponse("Content deleted from file: "+filePath),
308+ EditResponseMetadata{
309+ OldContent: oldContent,
310+ NewContent: newContent,
311+ Additions: additions,
312+ Removals: removals,
313+ },
314+ ), nil
315+}
316+
317+func replaceContent(edit editContext, filePath, oldString, newString string, replaceAll bool, call ai.ToolCall) (ai.ToolResponse, error) {
318+ fileInfo, err := os.Stat(filePath)
319+ if err != nil {
320+ if os.IsNotExist(err) {
321+ return ai.NewTextErrorResponse(fmt.Sprintf("file not found: %s", filePath)), nil
322+ }
323+ return ai.ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
324+ }
325+
326+ if fileInfo.IsDir() {
327+ return ai.NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", filePath)), nil
328+ }
329+
330+ if getLastReadTime(filePath).IsZero() {
331+ return ai.NewTextErrorResponse("you must read the file before editing it. Use the View tool first"), nil
332+ }
333+
334+ modTime := fileInfo.ModTime()
335+ lastRead := getLastReadTime(filePath)
336+ if modTime.After(lastRead) {
337+ return ai.NewTextErrorResponse(
338+ fmt.Sprintf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
339+ filePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339),
340+ )), nil
341+ }
342+
343+ content, err := os.ReadFile(filePath)
344+ if err != nil {
345+ return ai.ToolResponse{}, fmt.Errorf("failed to read file: %w", err)
346+ }
347+
348+ oldContent, isCrlf := fsext.ToUnixLineEndings(string(content))
349+
350+ var newContent string
351+ var replacementCount int
352+
353+ if replaceAll {
354+ newContent = strings.ReplaceAll(oldContent, oldString, newString)
355+ replacementCount = strings.Count(oldContent, oldString)
356+ if replacementCount == 0 {
357+ return ai.NewTextErrorResponse("old_string not found in file. Make sure it matches exactly, including whitespace and line breaks"), nil
358+ }
359+ } else {
360+ index := strings.Index(oldContent, oldString)
361+ if index == -1 {
362+ return ai.NewTextErrorResponse("old_string not found in file. Make sure it matches exactly, including whitespace and line breaks"), nil
363+ }
364+
365+ lastIndex := strings.LastIndex(oldContent, oldString)
366+ if index != lastIndex {
367+ return ai.NewTextErrorResponse("old_string appears multiple times in the file. Please provide more context to ensure a unique match, or set replace_all to true"), nil
368+ }
369+
370+ newContent = oldContent[:index] + newString + oldContent[index+len(oldString):]
371+ replacementCount = 1
372+ }
373+
374+ if oldContent == newContent {
375+ return ai.NewTextErrorResponse("new content is the same as old content. No changes made."), nil
376+ }
377+ sessionID := GetSessionFromContext(edit.ctx)
378+
379+ if sessionID == "" {
380+ return ai.ToolResponse{}, fmt.Errorf("session ID is required for creating a new file")
381+ }
382+ _, additions, removals := diff.GenerateDiff(
383+ oldContent,
384+ newContent,
385+ strings.TrimPrefix(filePath, edit.workingDir),
386+ )
387+
388+ p := edit.permissions.Request(
389+ permission.CreatePermissionRequest{
390+ SessionID: sessionID,
391+ Path: fsext.PathOrPrefix(filePath, edit.workingDir),
392+ ToolCallID: call.ID,
393+ ToolName: EditToolName,
394+ Action: "write",
395+ Description: fmt.Sprintf("Replace content in file %s", filePath),
396+ Params: EditPermissionsParams{
397+ FilePath: filePath,
398+ OldContent: oldContent,
399+ NewContent: newContent,
400+ },
401+ },
402+ )
403+ if !p {
404+ return ai.ToolResponse{}, permission.ErrorPermissionDenied
405+ }
406+
407+ if isCrlf {
408+ newContent, _ = fsext.ToWindowsLineEndings(newContent)
409+ }
410+
411+ err = os.WriteFile(filePath, []byte(newContent), 0o644)
412+ if err != nil {
413+ return ai.ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
414+ }
415+
416+ // Check if file exists in history
417+ file, err := edit.files.GetByPathAndSession(edit.ctx, filePath, sessionID)
418+ if err != nil {
419+ _, err = edit.files.Create(edit.ctx, sessionID, filePath, oldContent)
420+ if err != nil {
421+ // Log error but don't fail the operation
422+ return ai.ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
423+ }
424+ }
425+ if file.Content != oldContent {
426+ // User Manually changed the content store an intermediate version
427+ _, err = edit.files.CreateVersion(edit.ctx, sessionID, filePath, oldContent)
428+ if err != nil {
429+ slog.Debug("Error creating file history version", "error", err)
430+ }
431+ }
432+ // Store the new version
433+ _, err = edit.files.CreateVersion(edit.ctx, sessionID, filePath, newContent)
434+ if err != nil {
435+ slog.Debug("Error creating file history version", "error", err)
436+ }
437+
438+ recordFileWrite(filePath)
439+ recordFileRead(filePath)
440+
441+ return ai.WithResponseMetadata(
442+ ai.NewTextResponse("Content replaced in file: "+filePath),
443+ EditResponseMetadata{
444+ OldContent: oldContent,
445+ NewContent: newContent,
446+ Additions: additions,
447+ Removals: removals,
448+ }), nil
449+}
internal/agent/tools/edit.mdnew file+60
@@ -0,0 +1,60 @@
1+Edits files by replacing text, creating new files, or deleting content. For moving/renaming use Bash 'mv'. For large edits use Write tool.
2+
3+<prerequisites>
4+1. Use View tool to understand file contents and context
5+2. For new files: Use LS tool to verify parent directory exists
6+</prerequisites>
7+
8+<parameters>
9+1. file_path: Absolute path to file (required)
10+2. old_string: Text to replace (must match exactly including whitespace/indentation)
11+3. new_string: Replacement text
12+4. replace_all: Replace all occurrences (default false)
13+</parameters>
14+
15+<special_cases>
16+
17+- Create file: provide file_path + new_string, leave old_string empty
18+- Delete content: provide file_path + old_string, leave new_string empty
19+ </special_cases>
20+
21+<critical_requirements>
22+UNIQUENESS (when replace_all=false): old_string MUST uniquely identify target instance
23+
24+- Include 3-5 lines context BEFORE and AFTER change point
25+- Include exact whitespace, indentation, surrounding code
26+
27+SINGLE INSTANCE: Tool changes ONE instance when replace_all=false
28+
29+- For multiple instances: set replace_all=true OR make separate calls with unique context
30+- Plan calls carefully to avoid conflicts
31+
32+VERIFICATION: Before using
33+
34+- Check how many instances of target text exist
35+- Gather sufficient context for unique identification
36+- Plan separate calls or use replace_all
37+ </critical_requirements>
38+
39+<warnings>
40+Tool fails if:
41+- old_string matches multiple locations and replace_all=false
42+- old_string doesn't match exactly (including whitespace)
43+- Insufficient context causes wrong instance change
44+</warnings>
45+
46+<best_practices>
47+
48+- Ensure edits result in correct, idiomatic code
49+- Don't leave code in broken state
50+- Use absolute file paths (starting with /)
51+- Use forward slashes (/) for cross-platform compatibility
52+- Multiple edits to same file: send all in single message with multiple tool calls
53+ </best_practices>
54+
55+<windows_notes>
56+
57+- Forward slashes work throughout (C:/path/file)
58+- File permissions handled automatically
59+- Line endings converted automatically (\n ↔ \r\n)
60+ </windows_notes>
internal/agent/tools/fetch.gonew file+203
@@ -0,0 +1,203 @@
1+package tools
2+
3+import (
4+ "context"
5+ _ "embed"
6+ "fmt"
7+ "io"
8+ "net/http"
9+ "strings"
10+ "time"
11+ "unicode/utf8"
12+
13+ md "github.com/JohannesKaufmann/html-to-markdown"
14+ "github.com/PuerkitoBio/goquery"
15+ "github.com/charmbracelet/crush/internal/permission"
16+ "github.com/charmbracelet/fantasy/ai"
17+)
18+
19+type FetchParams struct {
20+ URL string `json:"url" description:"The URL to fetch content from"`
21+ Format string `json:"format" description:"The format to return the content in (text, markdown, or html)"`
22+ Timeout int `json:"timeout,omitempty" description:"Optional timeout in seconds (max 120)"`
23+}
24+
25+type FetchPermissionsParams struct {
26+ URL string `json:"url"`
27+ Format string `json:"format"`
28+ Timeout int `json:"timeout,omitempty"`
29+}
30+
31+type fetchTool struct {
32+ client *http.Client
33+ permissions permission.Service
34+ workingDir string
35+}
36+
37+const FetchToolName = "fetch"
38+
39+//go:embed fetch.md
40+var fetchDescription []byte
41+
42+func NewFetchTool(permissions permission.Service, workingDir string) ai.AgentTool {
43+ client := &http.Client{
44+ Timeout: 30 * time.Second,
45+ Transport: &http.Transport{
46+ MaxIdleConns: 100,
47+ MaxIdleConnsPerHost: 10,
48+ IdleConnTimeout: 90 * time.Second,
49+ },
50+ }
51+
52+ return ai.NewAgentTool(
53+ FetchToolName,
54+ string(fetchDescription),
55+ func(ctx context.Context, params FetchParams, call ai.ToolCall) (ai.ToolResponse, error) {
56+ if params.URL == "" {
57+ return ai.NewTextErrorResponse("URL parameter is required"), nil
58+ }
59+
60+ format := strings.ToLower(params.Format)
61+ if format != "text" && format != "markdown" && format != "html" {
62+ return ai.NewTextErrorResponse("Format must be one of: text, markdown, html"), nil
63+ }
64+
65+ if !strings.HasPrefix(params.URL, "http://") && !strings.HasPrefix(params.URL, "https://") {
66+ return ai.NewTextErrorResponse("URL must start with http:// or https://"), nil
67+ }
68+
69+ sessionID := GetSessionFromContext(ctx)
70+ if sessionID == "" {
71+ return ai.ToolResponse{}, fmt.Errorf("session ID is required for creating a new file")
72+ }
73+
74+ p := permissions.Request(
75+ permission.CreatePermissionRequest{
76+ SessionID: sessionID,
77+ Path: workingDir,
78+ ToolCallID: call.ID,
79+ ToolName: FetchToolName,
80+ Action: "fetch",
81+ Description: fmt.Sprintf("Fetch content from URL: %s", params.URL),
82+ Params: FetchPermissionsParams(params),
83+ },
84+ )
85+
86+ if !p {
87+ return ai.ToolResponse{}, permission.ErrorPermissionDenied
88+ }
89+
90+ // Handle timeout with context
91+ requestCtx := ctx
92+ if params.Timeout > 0 {
93+ maxTimeout := 120 // 2 minutes
94+ if params.Timeout > maxTimeout {
95+ params.Timeout = maxTimeout
96+ }
97+ var cancel context.CancelFunc
98+ requestCtx, cancel = context.WithTimeout(ctx, time.Duration(params.Timeout)*time.Second)
99+ defer cancel()
100+ }
101+
102+ req, err := http.NewRequestWithContext(requestCtx, "GET", params.URL, nil)
103+ if err != nil {
104+ return ai.ToolResponse{}, fmt.Errorf("failed to create request: %w", err)
105+ }
106+
107+ req.Header.Set("User-Agent", "crush/1.0")
108+
109+ resp, err := client.Do(req)
110+ if err != nil {
111+ return ai.ToolResponse{}, fmt.Errorf("failed to fetch URL: %w", err)
112+ }
113+ defer resp.Body.Close()
114+
115+ if resp.StatusCode != http.StatusOK {
116+ return ai.NewTextErrorResponse(fmt.Sprintf("Request failed with status code: %d", resp.StatusCode)), nil
117+ }
118+
119+ maxSize := int64(5 * 1024 * 1024) // 5MB
120+ body, err := io.ReadAll(io.LimitReader(resp.Body, maxSize))
121+ if err != nil {
122+ return ai.NewTextErrorResponse("Failed to read response body: " + err.Error()), nil
123+ }
124+
125+ content := string(body)
126+
127+ isValidUt8 := utf8.ValidString(content)
128+ if !isValidUt8 {
129+ return ai.NewTextErrorResponse("Response content is not valid UTF-8"), nil
130+ }
131+ contentType := resp.Header.Get("Content-Type")
132+
133+ switch format {
134+ case "text":
135+ if strings.Contains(contentType, "text/html") {
136+ text, err := extractTextFromHTML(content)
137+ if err != nil {
138+ return ai.NewTextErrorResponse("Failed to extract text from HTML: " + err.Error()), nil
139+ }
140+ content = text
141+ }
142+
143+ case "markdown":
144+ if strings.Contains(contentType, "text/html") {
145+ markdown, err := convertHTMLToMarkdown(content)
146+ if err != nil {
147+ return ai.NewTextErrorResponse("Failed to convert HTML to Markdown: " + err.Error()), nil
148+ }
149+ content = markdown
150+ }
151+
152+ content = "```\n" + content + "\n```"
153+
154+ case "html":
155+ // return only the body of the HTML document
156+ if strings.Contains(contentType, "text/html") {
157+ doc, err := goquery.NewDocumentFromReader(strings.NewReader(content))
158+ if err != nil {
159+ return ai.NewTextErrorResponse("Failed to parse HTML: " + err.Error()), nil
160+ }
161+ body, err := doc.Find("body").Html()
162+ if err != nil {
163+ return ai.NewTextErrorResponse("Failed to extract body from HTML: " + err.Error()), nil
164+ }
165+ if body == "" {
166+ return ai.NewTextErrorResponse("No body content found in HTML"), nil
167+ }
168+ content = "<html>\n<body>\n" + body + "\n</body>\n</html>"
169+ }
170+ }
171+ // calculate byte size of content
172+ contentSize := int64(len(content))
173+ if contentSize > MaxReadSize {
174+ content = content[:MaxReadSize]
175+ content += fmt.Sprintf("\n\n[Content truncated to %d bytes]", MaxReadSize)
176+ }
177+
178+ return ai.NewTextResponse(content), nil
179+ })
180+}
181+
182+func extractTextFromHTML(html string) (string, error) {
183+ doc, err := goquery.NewDocumentFromReader(strings.NewReader(html))
184+ if err != nil {
185+ return "", err
186+ }
187+
188+ text := doc.Find("body").Text()
189+ text = strings.Join(strings.Fields(text), " ")
190+
191+ return text, nil
192+}
193+
194+func convertHTMLToMarkdown(html string) (string, error) {
195+ converter := md.NewConverter("", true, nil)
196+
197+ markdown, err := converter.ConvertString(html)
198+ if err != nil {
199+ return "", err
200+ }
201+
202+ return markdown, nil
203+}
internal/agent/tools/fetch.mdnew file+28
@@ -0,0 +1,28 @@
1+Fetches content from URL and returns it in specified format.
2+
3+<usage>
4+- Provide URL to fetch content from
5+- Specify desired output format (text, markdown, or html)
6+- Optional timeout for request
7+</usage>
8+
9+<features>
10+- Supports three output formats: text, markdown, html
11+- Auto-handles HTTP redirects
12+- Sets reasonable timeouts to prevent hanging
13+- Validates input parameters before requests
14+</features>
15+
16+<limitations>
17+- Max response size: 5MB
18+- Only supports HTTP and HTTPS protocols
19+- Cannot handle authentication or cookies
20+- Some websites may block automated requests
21+</limitations>
22+
23+<tips>
24+- Use text format for plain text content or simple API responses
25+- Use markdown format for content that should be rendered with formatting
26+- Use html format when you need raw HTML structure
27+- Set appropriate timeouts for potentially slow websites
28+</tips>
internal/agent/tools/file.gonew file+53
@@ -0,0 +1,53 @@
1+package tools
2+
3+import (
4+ "sync"
5+ "time"
6+)
7+
8+// File record to track when files were read/written
9+type fileRecord struct {
10+ path string
11+ readTime time.Time
12+ writeTime time.Time
13+}
14+
15+var (
16+ fileRecords = make(map[string]fileRecord)
17+ fileRecordMutex sync.RWMutex
18+)
19+
20+func recordFileRead(path string) {
21+ fileRecordMutex.Lock()
22+ defer fileRecordMutex.Unlock()
23+
24+ record, exists := fileRecords[path]
25+ if !exists {
26+ record = fileRecord{path: path}
27+ }
28+ record.readTime = time.Now()
29+ fileRecords[path] = record
30+}
31+
32+func getLastReadTime(path string) time.Time {
33+ fileRecordMutex.RLock()
34+ defer fileRecordMutex.RUnlock()
35+
36+ record, exists := fileRecords[path]
37+ if !exists {
38+ return time.Time{}
39+ }
40+ return record.readTime
41+}
42+
43+func recordFileWrite(path string) {
44+ fileRecordMutex.Lock()
45+ defer fileRecordMutex.Unlock()
46+
47+ record, exists := fileRecords[path]
48+ if !exists {
49+ record = fileRecord{path: path}
50+ }
51+ record.writeTime = time.Now()
52+ fileRecords[path] = record
53+}
internal/agent/tools/glob.gonew file+118
@@ -0,0 +1,118 @@
1+package tools
2+
3+import (
4+ "bytes"
5+ "context"
6+ _ "embed"
7+ "fmt"
8+ "log/slog"
9+ "os/exec"
10+ "path/filepath"
11+ "sort"
12+ "strings"
13+
14+ "github.com/charmbracelet/crush/internal/fsext"
15+ "github.com/charmbracelet/fantasy/ai"
16+)
17+
18+const GlobToolName = "glob"
19+
20+//go:embed glob.md
21+var globDescription []byte
22+
23+type GlobParams struct {
24+ Pattern string `json:"pattern" description:"The glob pattern to match files against"`
25+ Path string `json:"path" description:"The directory to search in. Defaults to the current working directory."`
26+}
27+
28+type GlobResponseMetadata struct {
29+ NumberOfFiles int `json:"number_of_files"`
30+ Truncated bool `json:"truncated"`
31+}
32+
33+func NewGlobTool(workingDir string) ai.AgentTool {
34+ return ai.NewAgentTool(
35+ GlobToolName,
36+ string(globDescription),
37+ func(ctx context.Context, params GlobParams, call ai.ToolCall) (ai.ToolResponse, error) {
38+ if params.Pattern == "" {
39+ return ai.NewTextErrorResponse("pattern is required"), nil
40+ }
41+
42+ searchPath := params.Path
43+ if searchPath == "" {
44+ searchPath = workingDir
45+ }
46+
47+ files, truncated, err := globFiles(ctx, params.Pattern, searchPath, 100)
48+ if err != nil {
49+ return ai.ToolResponse{}, fmt.Errorf("error finding files: %w", err)
50+ }
51+
52+ var output string
53+ if len(files) == 0 {
54+ output = "No files found"
55+ } else {
56+ output = strings.Join(files, "\n")
57+ if truncated {
58+ output += "\n\n(Results are truncated. Consider using a more specific path or pattern.)"
59+ }
60+ }
61+
62+ return ai.WithResponseMetadata(
63+ ai.NewTextResponse(output),
64+ GlobResponseMetadata{
65+ NumberOfFiles: len(files),
66+ Truncated: truncated,
67+ },
68+ ), nil
69+ })
70+}
71+
72+func globFiles(ctx context.Context, pattern, searchPath string, limit int) ([]string, bool, error) {
73+ cmdRg := getRgCmd(ctx, pattern)
74+ if cmdRg != nil {
75+ cmdRg.Dir = searchPath
76+ matches, err := runRipgrep(cmdRg, searchPath, limit)
77+ if err == nil {
78+ return matches, len(matches) >= limit && limit > 0, nil
79+ }
80+ slog.Warn("Ripgrep execution failed, falling back to doublestar", "error", err)
81+ }
82+
83+ return fsext.GlobWithDoubleStar(pattern, searchPath, limit)
84+}
85+
86+func runRipgrep(cmd *exec.Cmd, searchRoot string, limit int) ([]string, error) {
87+ out, err := cmd.CombinedOutput()
88+ if err != nil {
89+ if ee, ok := err.(*exec.ExitError); ok && ee.ExitCode() == 1 {
90+ return nil, nil
91+ }
92+ return nil, fmt.Errorf("ripgrep: %w\n%s", err, out)
93+ }
94+
95+ var matches []string
96+ for p := range bytes.SplitSeq(out, []byte{0}) {
97+ if len(p) == 0 {
98+ continue
99+ }
100+ absPath := string(p)
101+ if !filepath.IsAbs(absPath) {
102+ absPath = filepath.Join(searchRoot, absPath)
103+ }
104+ if fsext.SkipHidden(absPath) {
105+ continue
106+ }
107+ matches = append(matches, absPath)
108+ }
109+
110+ sort.SliceStable(matches, func(i, j int) bool {
111+ return len(matches[i]) < len(matches[j])
112+ })
113+
114+ if limit > 0 && len(matches) > limit {
115+ matches = matches[:limit]
116+ }
117+ return matches, nil
118+}
internal/agent/tools/glob.mdnew file+42
@@ -0,0 +1,42 @@
1+Fast file pattern matching tool that finds files by name/pattern, returning paths sorted by modification time (newest first).
2+
3+<usage>
4+- Provide glob pattern to match against file paths
5+- Optional starting directory (defaults to current working directory)
6+- Results sorted with most recently modified files first
7+</usage>
8+
9+<pattern_syntax>
10+
11+- '\*' matches any sequence of non-separator characters
12+- '\*\*' matches any sequence including separators
13+- '?' matches any single non-separator character
14+- '[...]' matches any character in brackets
15+- '[!...]' matches any character not in brackets
16+ </pattern_syntax>
17+
18+<examples>
19+- '*.js' - JavaScript files in current directory
20+- '**/*.js' - JavaScript files in any subdirectory
21+- 'src/**/*.{ts,tsx}' - TypeScript files in src directory
22+- '*.{html,css,js}' - HTML, CSS, and JS files
23+</examples>
24+
25+<limitations>
26+- Results limited to 100 files (newest first)
27+- Does not search file contents (use Grep for that)
28+- Hidden files (starting with '.') skipped
29+</limitations>
30+
31+<cross_platform>
32+
33+- Path separators handled automatically (/ and \ work)
34+- Uses ripgrep (rg) if available, otherwise Go implementation
35+- Patterns should use forward slashes (/) for compatibility
36+ </cross_platform>
37+
38+<tips>
39+- Combine with Grep: find files with Glob, search contents with Grep
40+- For iterative exploration requiring multiple searches, consider Agent tool
41+- Check if results truncated and refine pattern if needed
42+</tips>
internal/agent/tools/grep.gonew file+430
@@ -0,0 +1,430 @@
1+package tools
2+
3+import (
4+ "bufio"
5+ "context"
6+ _ "embed"
7+ "fmt"
8+ "io"
9+ "os"
10+ "os/exec"
11+ "path/filepath"
12+ "regexp"
13+ "sort"
14+ "strconv"
15+ "strings"
16+ "sync"
17+ "time"
18+
19+ "github.com/charmbracelet/crush/internal/fsext"
20+ "github.com/charmbracelet/fantasy/ai"
21+)
22+
23+// regexCache provides thread-safe caching of compiled regex patterns
24+type regexCache struct {
25+ cache map[string]*regexp.Regexp
26+ mu sync.RWMutex
27+}
28+
29+// newRegexCache creates a new regex cache
30+func newRegexCache() *regexCache {
31+ return &regexCache{
32+ cache: make(map[string]*regexp.Regexp),
33+ }
34+}
35+
36+// get retrieves a compiled regex from cache or compiles and caches it
37+func (rc *regexCache) get(pattern string) (*regexp.Regexp, error) {
38+ // Try to get from cache first (read lock)
39+ rc.mu.RLock()
40+ if regex, exists := rc.cache[pattern]; exists {
41+ rc.mu.RUnlock()
42+ return regex, nil
43+ }
44+ rc.mu.RUnlock()
45+
46+ // Compile the regex (write lock)
47+ rc.mu.Lock()
48+ defer rc.mu.Unlock()
49+
50+ // Double-check in case another goroutine compiled it while we waited
51+ if regex, exists := rc.cache[pattern]; exists {
52+ return regex, nil
53+ }
54+
55+ // Compile and cache the regex
56+ regex, err := regexp.Compile(pattern)
57+ if err != nil {
58+ return nil, err
59+ }
60+
61+ rc.cache[pattern] = regex
62+ return regex, nil
63+}
64+
65+// Global regex cache instances
66+var (
67+ searchRegexCache = newRegexCache()
68+ globRegexCache = newRegexCache()
69+ // Pre-compiled regex for glob conversion (used frequently)
70+ globBraceRegex = regexp.MustCompile(`\{([^}]+)\}`)
71+)
72+
73+type GrepParams struct {
74+ Pattern string `json:"pattern" description:"The regex pattern to search for in file contents"`
75+ Path string `json:"path" description:"The directory to search in. Defaults to the current working directory."`
76+ Include string `json:"include" description:"File pattern to include in the search (e.g. \"*.js\", \"*.{ts,tsx}\")"`
77+ LiteralText bool `json:"literal_text" description:"If true, the pattern will be treated as literal text with special regex characters escaped. Default is false."`
78+}
79+
80+type grepMatch struct {
81+ path string
82+ modTime time.Time
83+ lineNum int
84+ lineText string
85+}
86+
87+type GrepResponseMetadata struct {
88+ NumberOfMatches int `json:"number_of_matches"`
89+ Truncated bool `json:"truncated"`
90+}
91+
92+const GrepToolName = "grep"
93+
94+//go:embed grep.md
95+var grepDescription []byte
96+
97+func NewGrepTool(workingDir string) ai.AgentTool {
98+ return ai.NewAgentTool(
99+ GrepToolName,
100+ string(grepDescription),
101+ func(ctx context.Context, params GrepParams, call ai.ToolCall) (ai.ToolResponse, error) {
102+ if params.Pattern == "" {
103+ return ai.NewTextErrorResponse("pattern is required"), nil
104+ }
105+
106+ // If literal_text is true, escape the pattern
107+ searchPattern := params.Pattern
108+ if params.LiteralText {
109+ searchPattern = escapeRegexPattern(params.Pattern)
110+ }
111+
112+ searchPath := params.Path
113+ if searchPath == "" {
114+ searchPath = workingDir
115+ }
116+
117+ matches, truncated, err := searchFiles(ctx, searchPattern, searchPath, params.Include, 100)
118+ if err != nil {
119+ return ai.ToolResponse{}, fmt.Errorf("error searching files: %w", err)
120+ }
121+
122+ var output strings.Builder
123+ if len(matches) == 0 {
124+ output.WriteString("No files found")
125+ } else {
126+ fmt.Fprintf(&output, "Found %d matches\n", len(matches))
127+
128+ currentFile := ""
129+ for _, match := range matches {
130+ if currentFile != match.path {
131+ if currentFile != "" {
132+ output.WriteString("\n")
133+ }
134+ currentFile = match.path
135+ fmt.Fprintf(&output, "%s:\n", match.path)
136+ }
137+ if match.lineNum > 0 {
138+ fmt.Fprintf(&output, " Line %d: %s\n", match.lineNum, match.lineText)
139+ } else {
140+ fmt.Fprintf(&output, " %s\n", match.path)
141+ }
142+ }
143+
144+ if truncated {
145+ output.WriteString("\n(Results are truncated. Consider using a more specific path or pattern.)")
146+ }
147+ }
148+
149+ return ai.WithResponseMetadata(
150+ ai.NewTextResponse(output.String()),
151+ GrepResponseMetadata{
152+ NumberOfMatches: len(matches),
153+ Truncated: truncated,
154+ },
155+ ), nil
156+ })
157+}
158+
159+// escapeRegexPattern escapes special regex characters so they're treated as literal characters
160+func escapeRegexPattern(pattern string) string {
161+ specialChars := []string{"\\", ".", "+", "*", "?", "(", ")", "[", "]", "{", "}", "^", "$", "|"}
162+ escaped := pattern
163+
164+ for _, char := range specialChars {
165+ escaped = strings.ReplaceAll(escaped, char, "\\"+char)
166+ }
167+
168+ return escaped
169+}
170+
171+func searchFiles(ctx context.Context, pattern, rootPath, include string, limit int) ([]grepMatch, bool, error) {
172+ matches, err := searchWithRipgrep(ctx, pattern, rootPath, include)
173+ if err != nil {
174+ matches, err = searchFilesWithRegex(pattern, rootPath, include)
175+ if err != nil {
176+ return nil, false, err
177+ }
178+ }
179+
180+ sort.Slice(matches, func(i, j int) bool {
181+ return matches[i].modTime.After(matches[j].modTime)
182+ })
183+
184+ truncated := len(matches) > limit
185+ if truncated {
186+ matches = matches[:limit]
187+ }
188+
189+ return matches, truncated, nil
190+}
191+
192+func searchWithRipgrep(ctx context.Context, pattern, path, include string) ([]grepMatch, error) {
193+ cmd := getRgSearchCmd(ctx, pattern, path, include)
194+ if cmd == nil {
195+ return nil, fmt.Errorf("ripgrep not found in $PATH")
196+ }
197+
198+ // Only add ignore files if they exist
199+ for _, ignoreFile := range []string{".gitignore", ".crushignore"} {
200+ ignorePath := filepath.Join(path, ignoreFile)
201+ if _, err := os.Stat(ignorePath); err == nil {
202+ cmd.Args = append(cmd.Args, "--ignore-file", ignorePath)
203+ }
204+ }
205+
206+ output, err := cmd.Output()
207+ if err != nil {
208+ if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 {
209+ return []grepMatch{}, nil
210+ }
211+ return nil, err
212+ }
213+
214+ lines := strings.Split(strings.TrimSpace(string(output)), "\n")
215+ matches := make([]grepMatch, 0, len(lines))
216+
217+ for _, line := range lines {
218+ if line == "" {
219+ continue
220+ }
221+
222+ // Parse ripgrep output using null separation
223+ filePath, lineNumStr, lineText, ok := parseRipgrepLine(line)
224+ if !ok {
225+ continue
226+ }
227+
228+ lineNum, err := strconv.Atoi(lineNumStr)
229+ if err != nil {
230+ continue
231+ }
232+
233+ fileInfo, err := os.Stat(filePath)
234+ if err != nil {
235+ continue // Skip files we can't access
236+ }
237+
238+ matches = append(matches, grepMatch{
239+ path: filePath,
240+ modTime: fileInfo.ModTime(),
241+ lineNum: lineNum,
242+ lineText: lineText,
243+ })
244+ }
245+
246+ return matches, nil
247+}
248+
249+// parseRipgrepLine parses ripgrep output with null separation to handle Windows paths
250+func parseRipgrepLine(line string) (filePath, lineNum, lineText string, ok bool) {
251+ // Split on null byte first to separate filename from rest
252+ parts := strings.SplitN(line, "\x00", 2)
253+ if len(parts) != 2 {
254+ return "", "", "", false
255+ }
256+
257+ filePath = parts[0]
258+ remainder := parts[1]
259+
260+ // Now split the remainder on first colon: "linenum:content"
261+ colonIndex := strings.Index(remainder, ":")
262+ if colonIndex == -1 {
263+ return "", "", "", false
264+ }
265+
266+ lineNumStr := remainder[:colonIndex]
267+ lineText = remainder[colonIndex+1:]
268+
269+ if _, err := strconv.Atoi(lineNumStr); err != nil {
270+ return "", "", "", false
271+ }
272+
273+ return filePath, lineNumStr, lineText, true
274+}
275+
276+func searchFilesWithRegex(pattern, rootPath, include string) ([]grepMatch, error) {
277+ matches := []grepMatch{}
278+
279+ // Use cached regex compilation
280+ regex, err := searchRegexCache.get(pattern)
281+ if err != nil {
282+ return nil, fmt.Errorf("invalid regex pattern: %w", err)
283+ }
284+
285+ var includePattern *regexp.Regexp
286+ if include != "" {
287+ regexPattern := globToRegex(include)
288+ includePattern, err = globRegexCache.get(regexPattern)
289+ if err != nil {
290+ return nil, fmt.Errorf("invalid include pattern: %w", err)
291+ }
292+ }
293+
294+ // Create walker with gitignore and crushignore support
295+ walker := fsext.NewFastGlobWalker(rootPath)
296+
297+ err = filepath.Walk(rootPath, func(path string, info os.FileInfo, err error) error {
298+ if err != nil {
299+ return nil // Skip errors
300+ }
301+
302+ if info.IsDir() {
303+ // Check if directory should be skipped
304+ if walker.ShouldSkip(path) {
305+ return filepath.SkipDir
306+ }
307+ return nil // Continue into directory
308+ }
309+
310+ // Use walker's shouldSkip method for files
311+ if walker.ShouldSkip(path) {
312+ return nil
313+ }
314+
315+ // Skip hidden files (starting with a dot) to match ripgrep's default behavior
316+ base := filepath.Base(path)
317+ if base != "." && strings.HasPrefix(base, ".") {
318+ return nil
319+ }
320+
321+ if includePattern != nil && !includePattern.MatchString(path) {
322+ return nil
323+ }
324+
325+ match, lineNum, lineText, err := fileContainsPattern(path, regex)
326+ if err != nil {
327+ return nil // Skip files we can't read
328+ }
329+
330+ if match {
331+ matches = append(matches, grepMatch{
332+ path: path,
333+ modTime: info.ModTime(),
334+ lineNum: lineNum,
335+ lineText: lineText,
336+ })
337+
338+ if len(matches) >= 200 {
339+ return filepath.SkipAll
340+ }
341+ }
342+
343+ return nil
344+ })
345+ if err != nil {
346+ return nil, err
347+ }
348+
349+ return matches, nil
350+}
351+
352+func fileContainsPattern(filePath string, pattern *regexp.Regexp) (bool, int, string, error) {
353+ // Quick binary file detection
354+ if isBinaryFile(filePath) {
355+ return false, 0, "", nil
356+ }
357+
358+ file, err := os.Open(filePath)
359+ if err != nil {
360+ return false, 0, "", err
361+ }
362+ defer file.Close()
363+
364+ scanner := bufio.NewScanner(file)
365+ lineNum := 0
366+ for scanner.Scan() {
367+ lineNum++
368+ line := scanner.Text()
369+ if pattern.MatchString(line) {
370+ return true, lineNum, line, nil
371+ }
372+ }
373+
374+ return false, 0, "", scanner.Err()
375+}
376+
377+var binaryExts = map[string]struct{}{
378+ ".exe": {}, ".dll": {}, ".so": {}, ".dylib": {},
379+ ".bin": {}, ".obj": {}, ".o": {}, ".a": {},
380+ ".zip": {}, ".tar": {}, ".gz": {}, ".bz2": {},
381+ ".jpg": {}, ".jpeg": {}, ".png": {}, ".gif": {},
382+ ".pdf": {}, ".doc": {}, ".docx": {}, ".xls": {},
383+ ".mp3": {}, ".mp4": {}, ".avi": {}, ".mov": {},
384+}
385+
386+// isBinaryFile performs a quick check to determine if a file is binary
387+func isBinaryFile(filePath string) bool {
388+ // Check file extension first (fastest)
389+ ext := strings.ToLower(filepath.Ext(filePath))
390+ if _, isBinary := binaryExts[ext]; isBinary {
391+ return true
392+ }
393+
394+ // Quick content check for files without clear extensions
395+ file, err := os.Open(filePath)
396+ if err != nil {
397+ return false // If we can't open it, let the caller handle the error
398+ }
399+ defer file.Close()
400+
401+ // Read first 512 bytes to check for null bytes
402+ buffer := make([]byte, 512)
403+ n, err := file.Read(buffer)
404+ if err != nil && err != io.EOF {
405+ return false
406+ }
407+
408+ // Check for null bytes (common in binary files)
409+ for i := range n {
410+ if buffer[i] == 0 {
411+ return true
412+ }
413+ }
414+
415+ return false
416+}
417+
418+func globToRegex(glob string) string {
419+ regexPattern := strings.ReplaceAll(glob, ".", "\\.")
420+ regexPattern = strings.ReplaceAll(regexPattern, "*", ".*")
421+ regexPattern = strings.ReplaceAll(regexPattern, "?", ".")
422+
423+ // Use pre-compiled regex instead of compiling each time
424+ regexPattern = globBraceRegex.ReplaceAllStringFunc(regexPattern, func(match string) string {
425+ inner := match[1 : len(match)-1]
426+ return "(" + strings.ReplaceAll(inner, ",", "|") + ")"
427+ })
428+
429+ return regexPattern
430+}
internal/agent/tools/grep.mdnew file+52
@@ -0,0 +1,52 @@
1+Fast content search tool that finds files containing specific text/patterns, returning matching paths sorted by modification time (newest first).
2+
3+<usage>
4+- Provide regex pattern to search within file contents
5+- Set literal_text=true for exact text with special characters (recommended for non-regex users)
6+- Optional starting directory (defaults to current working directory)
7+- Optional include pattern to filter which files to search
8+- Results sorted with most recently modified files first
9+</usage>
10+
11+<regex_syntax>
12+When literal_text=false (supports standard regex):
13+
14+- 'function' searches for literal text "function"
15+- 'log\..\*Error' finds text starting with "log." and ending with "Error"
16+- 'import\s+.\*\s+from' finds import statements in JavaScript/TypeScript
17+ </regex_syntax>
18+
19+<include_patterns>
20+
21+- '\*.js' - Only search JavaScript files
22+- '\*.{ts,tsx}' - Only search TypeScript files
23+- '\*.go' - Only search Go files
24+ </include_patterns>
25+
26+<limitations>
27+- Results limited to 100 files (newest first)
28+- Performance depends on number of files searched
29+- Very large binary files may be skipped
30+- Hidden files (starting with '.') skipped
31+</limitations>
32+
33+<ignore_support>
34+
35+- Respects .gitignore patterns to skip ignored files/directories
36+- Respects .crushignore patterns for additional ignore rules
37+- Both ignore files auto-detected in search root directory
38+ </ignore_support>
39+
40+<cross_platform>
41+
42+- Uses ripgrep (rg) if available for better performance
43+- Falls back to Go implementation if ripgrep unavailable
44+- File paths normalized automatically for compatibility
45+ </cross_platform>
46+
47+<tips>
48+- For faster searches: use Glob to find relevant files first, then Grep
49+- For iterative exploration requiring multiple searches, consider Agent tool
50+- Check if results truncated and refine search pattern if needed
51+- Use literal_text=true for exact text with special characters (dots, parentheses, etc.)
52+</tips>
internal/agent/tools/grep_test.gonew file+200
@@ -0,0 +1,200 @@
1+package tools
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "regexp"
7+ "testing"
8+
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func TestRegexCache(t *testing.T) {
13+ cache := newRegexCache()
14+
15+ // Test basic caching
16+ pattern := "test.*pattern"
17+ regex1, err := cache.get(pattern)
18+ if err != nil {
19+ t.Fatalf("Failed to compile regex: %v", err)
20+ }
21+
22+ regex2, err := cache.get(pattern)
23+ if err != nil {
24+ t.Fatalf("Failed to get cached regex: %v", err)
25+ }
26+
27+ // Should be the same instance (cached)
28+ if regex1 != regex2 {
29+ t.Error("Expected cached regex to be the same instance")
30+ }
31+
32+ // Test that it actually works
33+ if !regex1.MatchString("test123pattern") {
34+ t.Error("Regex should match test string")
35+ }
36+}
37+
38+func TestGlobToRegexCaching(t *testing.T) {
39+ // Test that globToRegex uses pre-compiled regex
40+ pattern1 := globToRegex("*.{js,ts}")
41+
42+ // Should not panic and should work correctly
43+ regex1, err := regexp.Compile(pattern1)
44+ if err != nil {
45+ t.Fatalf("Failed to compile glob regex: %v", err)
46+ }
47+
48+ if !regex1.MatchString("test.js") {
49+ t.Error("Glob regex should match .js files")
50+ }
51+ if !regex1.MatchString("test.ts") {
52+ t.Error("Glob regex should match .ts files")
53+ }
54+ if regex1.MatchString("test.go") {
55+ t.Error("Glob regex should not match .go files")
56+ }
57+}
58+
59+func TestGrepWithIgnoreFiles(t *testing.T) {
60+ t.Parallel()
61+ tempDir := t.TempDir()
62+
63+ // Create test files
64+ testFiles := map[string]string{
65+ "file1.txt": "hello world",
66+ "file2.txt": "hello world",
67+ "ignored/file3.txt": "hello world",
68+ "node_modules/lib.js": "hello world",
69+ "secret.key": "hello world",
70+ }
71+
72+ for path, content := range testFiles {
73+ fullPath := filepath.Join(tempDir, path)
74+ require.NoError(t, os.MkdirAll(filepath.Dir(fullPath), 0o755))
75+ require.NoError(t, os.WriteFile(fullPath, []byte(content), 0o644))
76+ }
77+
78+ // Create .gitignore file
79+ gitignoreContent := "ignored/\n*.key\n"
80+ require.NoError(t, os.WriteFile(filepath.Join(tempDir, ".gitignore"), []byte(gitignoreContent), 0o644))
81+
82+ // Create .crushignore file
83+ crushignoreContent := "node_modules/\n"
84+ require.NoError(t, os.WriteFile(filepath.Join(tempDir, ".crushignore"), []byte(crushignoreContent), 0o644))
85+
86+ // Test both implementations
87+ for name, fn := range map[string]func(pattern, path, include string) ([]grepMatch, error){
88+ "regex": searchFilesWithRegex,
89+ "rg": func(pattern, path, include string) ([]grepMatch, error) {
90+ return searchWithRipgrep(t.Context(), pattern, path, include)
91+ },
92+ } {
93+ t.Run(name, func(t *testing.T) {
94+ t.Parallel()
95+
96+ if name == "rg" && getRg() == "" {
97+ t.Skip("rg is not in $PATH")
98+ }
99+
100+ matches, err := fn("hello world", tempDir, "")
101+ require.NoError(t, err)
102+
103+ // Convert matches to a set of file paths for easier testing
104+ foundFiles := make(map[string]bool)
105+ for _, match := range matches {
106+ foundFiles[filepath.Base(match.path)] = true
107+ }
108+
109+ // Should find file1.txt and file2.txt
110+ require.True(t, foundFiles["file1.txt"], "Should find file1.txt")
111+ require.True(t, foundFiles["file2.txt"], "Should find file2.txt")
112+
113+ // Should NOT find ignored files
114+ require.False(t, foundFiles["file3.txt"], "Should not find file3.txt (ignored by .gitignore)")
115+ require.False(t, foundFiles["lib.js"], "Should not find lib.js (ignored by .crushignore)")
116+ require.False(t, foundFiles["secret.key"], "Should not find secret.key (ignored by .gitignore)")
117+
118+ // Should find exactly 2 matches
119+ require.Equal(t, 2, len(matches), "Should find exactly 2 matches")
120+ })
121+ }
122+}
123+
124+func TestSearchImplementations(t *testing.T) {
125+ t.Parallel()
126+ tempDir := t.TempDir()
127+
128+ for path, content := range map[string]string{
129+ "file1.go": "package main\nfunc main() {\n\tfmt.Println(\"hello world\")\n}",
130+ "file2.js": "console.log('hello world');",
131+ "file3.txt": "hello world from text file",
132+ "binary.exe": "\x00\x01\x02\x03",
133+ "empty.txt": "",
134+ "subdir/nested.go": "package nested\n// hello world comment",
135+ ".hidden.txt": "hello world in hidden file",
136+ "file4.txt": "hello world from a banana",
137+ "file5.txt": "hello world from a grape",
138+ } {
139+ fullPath := filepath.Join(tempDir, path)
140+ require.NoError(t, os.MkdirAll(filepath.Dir(fullPath), 0o755))
141+ require.NoError(t, os.WriteFile(fullPath, []byte(content), 0o644))
142+ }
143+
144+ require.NoError(t, os.WriteFile(filepath.Join(tempDir, ".gitignore"), []byte("file4.txt\n"), 0o644))
145+ require.NoError(t, os.WriteFile(filepath.Join(tempDir, ".crushignore"), []byte("file5.txt\n"), 0o644))
146+
147+ for name, fn := range map[string]func(pattern, path, include string) ([]grepMatch, error){
148+ "regex": searchFilesWithRegex,
149+ "rg": func(pattern, path, include string) ([]grepMatch, error) {
150+ return searchWithRipgrep(t.Context(), pattern, path, include)
151+ },
152+ } {
153+ t.Run(name, func(t *testing.T) {
154+ t.Parallel()
155+
156+ if name == "rg" && getRg() == "" {
157+ t.Skip("rg is not in $PATH")
158+ }
159+
160+ matches, err := fn("hello world", tempDir, "")
161+ require.NoError(t, err)
162+
163+ require.Equal(t, len(matches), 4)
164+ for _, match := range matches {
165+ require.NotEmpty(t, match.path)
166+ require.NotZero(t, match.lineNum)
167+ require.NotEmpty(t, match.lineText)
168+ require.NotZero(t, match.modTime)
169+ require.NotContains(t, match.path, ".hidden.txt")
170+ require.NotContains(t, match.path, "file4.txt")
171+ require.NotContains(t, match.path, "file5.txt")
172+ require.NotContains(t, match.path, "binary.exe")
173+ }
174+ })
175+ }
176+}
177+
178+// Benchmark to show performance improvement
179+func BenchmarkRegexCacheVsCompile(b *testing.B) {
180+ cache := newRegexCache()
181+ pattern := "test.*pattern.*[0-9]+"
182+
183+ b.Run("WithCache", func(b *testing.B) {
184+ for b.Loop() {
185+ _, err := cache.get(pattern)
186+ if err != nil {
187+ b.Fatal(err)
188+ }
189+ }
190+ })
191+
192+ b.Run("WithoutCache", func(b *testing.B) {
193+ for b.Loop() {
194+ _, err := regexp.Compile(pattern)
195+ if err != nil {
196+ b.Fatal(err)
197+ }
198+ }
199+ })
200+}
internal/agent/tools/ls.gonew file+239
@@ -0,0 +1,239 @@
1+package tools
2+
3+import (
4+ "context"
5+ _ "embed"
6+ "fmt"
7+ "os"
8+ "path/filepath"
9+ "strings"
10+
11+ "github.com/charmbracelet/crush/internal/fsext"
12+ "github.com/charmbracelet/crush/internal/permission"
13+ "github.com/charmbracelet/fantasy/ai"
14+)
15+
16+type LSParams struct {
17+ Path string `json:"path" description:"The path to the directory to list (defaults to current working directory)"`
18+ Ignore []string `json:"ignore,omitempty" description:"List of glob patterns to ignore"`
19+}
20+
21+type LSPermissionsParams struct {
22+ Path string `json:"path"`
23+ Ignore []string `json:"ignore"`
24+}
25+
26+type TreeNode struct {
27+ Name string `json:"name"`
28+ Path string `json:"path"`
29+ Type string `json:"type"` // "file" or "directory"
30+ Children []*TreeNode `json:"children,omitempty"`
31+}
32+
33+type LSResponseMetadata struct {
34+ NumberOfFiles int `json:"number_of_files"`
35+ Truncated bool `json:"truncated"`
36+}
37+
38+const (
39+ LSToolName = "ls"
40+ MaxLSFiles = 1000
41+)
42+
43+//go:embed ls.md
44+var lsDescription []byte
45+
46+func NewLsTool(permissions permission.Service, workingDir string) ai.AgentTool {
47+ return ai.NewAgentTool(
48+ LSToolName,
49+ string(lsDescription),
50+ func(ctx context.Context, params LSParams, call ai.ToolCall) (ai.ToolResponse, error) {
51+ searchPath := params.Path
52+ if searchPath == "" {
53+ searchPath = workingDir
54+ }
55+
56+ var err error
57+ searchPath, err = fsext.Expand(searchPath)
58+ if err != nil {
59+ return ai.ToolResponse{}, fmt.Errorf("error expanding path: %w", err)
60+ }
61+
62+ if !filepath.IsAbs(searchPath) {
63+ searchPath = filepath.Join(workingDir, searchPath)
64+ }
65+
66+ // Check if directory is outside working directory and request permission if needed
67+ absWorkingDir, err := filepath.Abs(workingDir)
68+ if err != nil {
69+ return ai.ToolResponse{}, fmt.Errorf("error resolving working directory: %w", err)
70+ }
71+
72+ absSearchPath, err := filepath.Abs(searchPath)
73+ if err != nil {
74+ return ai.ToolResponse{}, fmt.Errorf("error resolving search path: %w", err)
75+ }
76+
77+ relPath, err := filepath.Rel(absWorkingDir, absSearchPath)
78+ if err != nil || strings.HasPrefix(relPath, "..") {
79+ // Directory is outside working directory, request permission
80+ sessionID := GetSessionFromContext(ctx)
81+ if sessionID == "" {
82+ return ai.ToolResponse{}, fmt.Errorf("session ID is required for accessing directories outside working directory")
83+ }
84+
85+ granted := permissions.Request(
86+ permission.CreatePermissionRequest{
87+ SessionID: sessionID,
88+ Path: absSearchPath,
89+ ToolCallID: call.ID,
90+ ToolName: LSToolName,
91+ Action: "list",
92+ Description: fmt.Sprintf("List directory outside working directory: %s", absSearchPath),
93+ Params: LSPermissionsParams(params),
94+ },
95+ )
96+
97+ if !granted {
98+ return ai.ToolResponse{}, permission.ErrorPermissionDenied
99+ }
100+ }
101+
102+ output, err := ListDirectoryTree(searchPath, params.Ignore)
103+ if err != nil {
104+ return ai.ToolResponse{}, err
105+ }
106+
107+ // Get file count for metadata
108+ files, truncated, err := fsext.ListDirectory(searchPath, params.Ignore, MaxLSFiles)
109+ if err != nil {
110+ return ai.ToolResponse{}, fmt.Errorf("error listing directory for metadata: %w", err)
111+ }
112+
113+ return ai.WithResponseMetadata(
114+ ai.NewTextResponse(output),
115+ LSResponseMetadata{
116+ NumberOfFiles: len(files),
117+ Truncated: truncated,
118+ },
119+ ), nil
120+ })
121+}
122+
123+func ListDirectoryTree(searchPath string, ignore []string) (string, error) {
124+ if _, err := os.Stat(searchPath); os.IsNotExist(err) {
125+ return "", fmt.Errorf("path does not exist: %s", searchPath)
126+ }
127+
128+ files, truncated, err := fsext.ListDirectory(searchPath, ignore, MaxLSFiles)
129+ if err != nil {
130+ return "", fmt.Errorf("error listing directory: %w", err)
131+ }
132+
133+ tree := createFileTree(files, searchPath)
134+ output := printTree(tree, searchPath)
135+
136+ if truncated {
137+ output = fmt.Sprintf("There are more than %d files in the directory. Use a more specific path or use the Glob tool to find specific files. The first %d files and directories are included below:\n\n%s", MaxLSFiles, MaxLSFiles, output)
138+ }
139+
140+ return output, nil
141+}
142+
143+func createFileTree(sortedPaths []string, rootPath string) []*TreeNode {
144+ root := []*TreeNode{}
145+ pathMap := make(map[string]*TreeNode)
146+
147+ for _, path := range sortedPaths {
148+ relativePath := strings.TrimPrefix(path, rootPath)
149+ parts := strings.Split(relativePath, string(filepath.Separator))
150+ currentPath := ""
151+ var parentPath string
152+
153+ var cleanParts []string
154+ for _, part := range parts {
155+ if part != "" {
156+ cleanParts = append(cleanParts, part)
157+ }
158+ }
159+ parts = cleanParts
160+
161+ if len(parts) == 0 {
162+ continue
163+ }
164+
165+ for i, part := range parts {
166+ if currentPath == "" {
167+ currentPath = part
168+ } else {
169+ currentPath = filepath.Join(currentPath, part)
170+ }
171+
172+ if _, exists := pathMap[currentPath]; exists {
173+ parentPath = currentPath
174+ continue
175+ }
176+
177+ isLastPart := i == len(parts)-1
178+ isDir := !isLastPart || strings.HasSuffix(relativePath, string(filepath.Separator))
179+ nodeType := "file"
180+ if isDir {
181+ nodeType = "directory"
182+ }
183+ newNode := &TreeNode{
184+ Name: part,
185+ Path: currentPath,
186+ Type: nodeType,
187+ Children: []*TreeNode{},
188+ }
189+
190+ pathMap[currentPath] = newNode
191+
192+ if i > 0 && parentPath != "" {
193+ if parent, ok := pathMap[parentPath]; ok {
194+ parent.Children = append(parent.Children, newNode)
195+ }
196+ } else {
197+ root = append(root, newNode)
198+ }
199+
200+ parentPath = currentPath
201+ }
202+ }
203+
204+ return root
205+}
206+
207+func printTree(tree []*TreeNode, rootPath string) string {
208+ var result strings.Builder
209+
210+ result.WriteString("- ")
211+ result.WriteString(rootPath)
212+ if rootPath[len(rootPath)-1] != '/' {
213+ result.WriteByte(filepath.Separator)
214+ }
215+ result.WriteByte('\n')
216+
217+ for _, node := range tree {
218+ printNode(&result, node, 1)
219+ }
220+
221+ return result.String()
222+}
223+
224+func printNode(builder *strings.Builder, node *TreeNode, level int) {
225+ indent := strings.Repeat(" ", level)
226+
227+ nodeName := node.Name
228+ if node.Type == "directory" {
229+ nodeName = nodeName + string(filepath.Separator)
230+ }
231+
232+ fmt.Fprintf(builder, "%s- %s\n", indent, nodeName)
233+
234+ if node.Type == "directory" && len(node.Children) > 0 {
235+ for _, child := range node.Children {
236+ printNode(builder, child, level+1)
237+ }
238+ }
239+}
internal/agent/tools/ls.mdnew file+35
@@ -0,0 +1,35 @@
1+Shows files and subdirectories in tree structure for exploring project organization.
2+
3+<usage>
4+- Provide path to list (defaults to current working directory)
5+- Optional glob patterns to ignore
6+- Results displayed in tree structure
7+</usage>
8+
9+<features>
10+- Hierarchical view of files and directories
11+- Auto-skips hidden files/directories (starting with '.')
12+- Skips common system directories like __pycache__
13+- Can filter files matching specific patterns
14+</features>
15+
16+<limitations>
17+- Results limited to 1000 files
18+- Large directories truncated
19+- No file sizes or permissions shown
20+- Cannot recursively list all directories in large projects
21+</limitations>
22+
23+<cross_platform>
24+
25+- Hidden file detection uses Unix convention (files starting with '.')
26+- Windows hidden files (with hidden attribute) not auto-skipped
27+- Common Windows directories (System32, Program Files) not in default ignore
28+- Path separators handled automatically (/ and \ work)
29+ </cross_platform>
30+
31+<tips>
32+- Use Glob for finding files by name patterns instead of browsing
33+- Use Grep for searching file contents
34+- Combine with other tools for effective exploration
35+</tips>
internal/agent/tools/mcp-tools.gonew file+432
@@ -0,0 +1,432 @@
1+package tools
2+
3+import (
4+ "cmp"
5+ "context"
6+ "encoding/json"
7+ "errors"
8+ "fmt"
9+ "log/slog"
10+ "maps"
11+ "slices"
12+ "strings"
13+ "sync"
14+ "time"
15+
16+ "github.com/charmbracelet/crush/internal/config"
17+ "github.com/charmbracelet/crush/internal/csync"
18+ "github.com/charmbracelet/crush/internal/home"
19+ "github.com/charmbracelet/crush/internal/permission"
20+ "github.com/charmbracelet/crush/internal/pubsub"
21+ "github.com/charmbracelet/crush/internal/version"
22+ "github.com/charmbracelet/fantasy/ai"
23+ "github.com/mark3labs/mcp-go/client"
24+ "github.com/mark3labs/mcp-go/client/transport"
25+ "github.com/mark3labs/mcp-go/mcp"
26+)
27+
28+// MCPState represents the current state of an MCP client
29+type MCPState int
30+
31+const (
32+ MCPStateDisabled MCPState = iota
33+ MCPStateStarting
34+ MCPStateConnected
35+ MCPStateError
36+)
37+
38+func (s MCPState) String() string {
39+ switch s {
40+ case MCPStateDisabled:
41+ return "disabled"
42+ case MCPStateStarting:
43+ return "starting"
44+ case MCPStateConnected:
45+ return "connected"
46+ case MCPStateError:
47+ return "error"
48+ default:
49+ return "unknown"
50+ }
51+}
52+
53+// MCPEventType represents the type of MCP event
54+type MCPEventType string
55+
56+const (
57+ MCPEventStateChanged MCPEventType = "state_changed"
58+)
59+
60+// MCPEvent represents an event in the MCP system
61+type MCPEvent struct {
62+ Type MCPEventType
63+ Name string
64+ State MCPState
65+ Error error
66+ ToolCount int
67+}
68+
69+// MCPClientInfo holds information about an MCP client's state
70+type MCPClientInfo struct {
71+ Name string
72+ State MCPState
73+ Error error
74+ Client *client.Client
75+ ToolCount int
76+ ConnectedAt time.Time
77+}
78+
79+var (
80+ mcpToolsOnce sync.Once
81+ mcpTools []ai.AgentTool
82+ mcpClients = csync.NewMap[string, *client.Client]()
83+ mcpStates = csync.NewMap[string, MCPClientInfo]()
84+ mcpBroker = pubsub.NewBroker[MCPEvent]()
85+)
86+
87+type McpTool struct {
88+ mcpName string
89+ tool mcp.Tool
90+ permissions permission.Service
91+ workingDir string
92+ providerOptions ai.ProviderOptions
93+}
94+
95+func (m *McpTool) SetProviderOptions(opts ai.ProviderOptions) {
96+ m.providerOptions = opts
97+}
98+
99+func (m *McpTool) ProviderOptions() ai.ProviderOptions {
100+ return m.providerOptions
101+}
102+
103+func (b *McpTool) Name() string {
104+ return fmt.Sprintf("mcp_%s_%s", b.mcpName, b.tool.Name)
105+}
106+
107+func (b *McpTool) Info() ai.ToolInfo {
108+ required := b.tool.InputSchema.Required
109+ if required == nil {
110+ required = make([]string, 0)
111+ }
112+ parameters := b.tool.InputSchema.Properties
113+ if parameters == nil {
114+ parameters = make(map[string]any)
115+ }
116+ return ai.ToolInfo{
117+ Name: fmt.Sprintf("mcp_%s_%s", b.mcpName, b.tool.Name),
118+ Description: b.tool.Description,
119+ Parameters: parameters,
120+ Required: required,
121+ }
122+}
123+
124+func runTool(ctx context.Context, name, toolName string, input string) (ai.ToolResponse, error) {
125+ var args map[string]any
126+ if err := json.Unmarshal([]byte(input), &args); err != nil {
127+ return ai.NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil
128+ }
129+
130+ c, err := getOrRenewClient(ctx, name)
131+ if err != nil {
132+ return ai.NewTextErrorResponse(err.Error()), nil
133+ }
134+ result, err := c.CallTool(ctx, mcp.CallToolRequest{
135+ Params: mcp.CallToolParams{
136+ Name: toolName,
137+ Arguments: args,
138+ },
139+ })
140+ if err != nil {
141+ return ai.NewTextErrorResponse(err.Error()), nil
142+ }
143+
144+ output := make([]string, 0, len(result.Content))
145+ for _, v := range result.Content {
146+ if v, ok := v.(mcp.TextContent); ok {
147+ output = append(output, v.Text)
148+ } else {
149+ output = append(output, fmt.Sprintf("%v", v))
150+ }
151+ }
152+ return ai.NewTextResponse(strings.Join(output, "\n")), nil
153+}
154+
155+func getOrRenewClient(ctx context.Context, name string) (*client.Client, error) {
156+ c, ok := mcpClients.Get(name)
157+ if !ok {
158+ return nil, fmt.Errorf("mcp '%s' not available", name)
159+ }
160+
161+ cfg := config.Get()
162+ m := cfg.MCP[name]
163+ state, _ := mcpStates.Get(name)
164+
165+ timeout := mcpTimeout(m)
166+ pingCtx, cancel := context.WithTimeout(ctx, timeout)
167+ defer cancel()
168+ err := c.Ping(pingCtx)
169+ if err == nil {
170+ return c, nil
171+ }
172+ updateMCPState(name, MCPStateError, maybeTimeoutErr(err, timeout), nil, state.ToolCount)
173+
174+ c, err = createAndInitializeClient(ctx, name, m, cfg.Resolver())
175+ if err != nil {
176+ return nil, err
177+ }
178+
179+ updateMCPState(name, MCPStateConnected, nil, c, state.ToolCount)
180+ mcpClients.Set(name, c)
181+ return c, nil
182+}
183+
184+func (b *McpTool) Run(ctx context.Context, params ai.ToolCall) (ai.ToolResponse, error) {
185+ sessionID := GetSessionFromContext(ctx)
186+ if sessionID == "" {
187+ return ai.ToolResponse{}, fmt.Errorf("session ID is required for creating a new file")
188+ }
189+ permissionDescription := fmt.Sprintf("execute %s with the following parameters:", b.Info().Name)
190+ p := b.permissions.Request(
191+ permission.CreatePermissionRequest{
192+ SessionID: sessionID,
193+ ToolCallID: params.ID,
194+ Path: b.workingDir,
195+ ToolName: b.Info().Name,
196+ Action: "execute",
197+ Description: permissionDescription,
198+ Params: params.Input,
199+ },
200+ )
201+ if !p {
202+ return ai.ToolResponse{}, permission.ErrorPermissionDenied
203+ }
204+
205+ return runTool(ctx, b.mcpName, b.tool.Name, params.Input)
206+}
207+
208+func getTools(ctx context.Context, name string, permissions permission.Service, c *client.Client, workingDir string) []ai.AgentTool {
209+ result, err := c.ListTools(ctx, mcp.ListToolsRequest{})
210+ if err != nil {
211+ slog.Error("error listing tools", "error", err)
212+ updateMCPState(name, MCPStateError, err, nil, 0)
213+ c.Close()
214+ mcpClients.Del(name)
215+ return nil
216+ }
217+ mcpTools := make([]ai.AgentTool, 0, len(result.Tools))
218+ for _, tool := range result.Tools {
219+ mcpTools = append(mcpTools, &McpTool{
220+ mcpName: name,
221+ tool: tool,
222+ permissions: permissions,
223+ workingDir: workingDir,
224+ })
225+ }
226+ return mcpTools
227+}
228+
229+// SubscribeMCPEvents returns a channel for MCP events
230+func SubscribeMCPEvents(ctx context.Context) <-chan pubsub.Event[MCPEvent] {
231+ return mcpBroker.Subscribe(ctx)
232+}
233+
234+// GetMCPStates returns the current state of all MCP clients
235+func GetMCPStates() map[string]MCPClientInfo {
236+ return maps.Collect(mcpStates.Seq2())
237+}
238+
239+// GetMCPState returns the state of a specific MCP client
240+func GetMCPState(name string) (MCPClientInfo, bool) {
241+ return mcpStates.Get(name)
242+}
243+
244+// updateMCPState updates the state of an MCP client and publishes an event
245+func updateMCPState(name string, state MCPState, err error, client *client.Client, toolCount int) {
246+ info := MCPClientInfo{
247+ Name: name,
248+ State: state,
249+ Error: err,
250+ Client: client,
251+ ToolCount: toolCount,
252+ }
253+ if state == MCPStateConnected {
254+ info.ConnectedAt = time.Now()
255+ }
256+ mcpStates.Set(name, info)
257+
258+ // Publish state change event
259+ mcpBroker.Publish(pubsub.UpdatedEvent, MCPEvent{
260+ Type: MCPEventStateChanged,
261+ Name: name,
262+ State: state,
263+ Error: err,
264+ ToolCount: toolCount,
265+ })
266+}
267+
268+// CloseMCPClients closes all MCP clients. This should be called during application shutdown.
269+func CloseMCPClients() error {
270+ var errs []error
271+ for name, c := range mcpClients.Seq2() {
272+ if err := c.Close(); err != nil {
273+ errs = append(errs, fmt.Errorf("close mcp: %s: %w", name, err))
274+ }
275+ }
276+ mcpBroker.Shutdown()
277+ return errors.Join(errs...)
278+}
279+
280+var mcpInitRequest = mcp.InitializeRequest{
281+ Params: mcp.InitializeParams{
282+ ProtocolVersion: mcp.LATEST_PROTOCOL_VERSION,
283+ ClientInfo: mcp.Implementation{
284+ Name: "Crush",
285+ Version: version.Version,
286+ },
287+ },
288+}
289+
290+func GetMCPTools(ctx context.Context, permissions permission.Service, cfg *config.Config) []ai.AgentTool {
291+ var wg sync.WaitGroup
292+ result := csync.NewSlice[ai.AgentTool]()
293+
294+ // Initialize states for all configured MCPs
295+ for name, m := range cfg.MCP {
296+ if m.Disabled {
297+ updateMCPState(name, MCPStateDisabled, nil, nil, 0)
298+ slog.Debug("skipping disabled mcp", "name", name)
299+ continue
300+ }
301+
302+ // Set initial starting state
303+ updateMCPState(name, MCPStateStarting, nil, nil, 0)
304+
305+ wg.Add(1)
306+ go func(name string, m config.MCPConfig) {
307+ defer func() {
308+ wg.Done()
309+ if r := recover(); r != nil {
310+ var err error
311+ switch v := r.(type) {
312+ case error:
313+ err = v
314+ case string:
315+ err = fmt.Errorf("panic: %s", v)
316+ default:
317+ err = fmt.Errorf("panic: %v", v)
318+ }
319+ updateMCPState(name, MCPStateError, err, nil, 0)
320+ slog.Error("panic in mcp client initialization", "error", err, "name", name)
321+ }
322+ }()
323+
324+ ctx, cancel := context.WithTimeout(ctx, mcpTimeout(m))
325+ defer cancel()
326+ c, err := createAndInitializeClient(ctx, name, m, cfg.Resolver())
327+ if err != nil {
328+ return
329+ }
330+ mcpClients.Set(name, c)
331+
332+ tools := getTools(ctx, name, permissions, c, cfg.WorkingDir())
333+ updateMCPState(name, MCPStateConnected, nil, c, len(tools))
334+ result.Append(tools...)
335+ }(name, m)
336+ }
337+ wg.Wait()
338+ return slices.Collect(result.Seq())
339+}
340+
341+func createAndInitializeClient(ctx context.Context, name string, m config.MCPConfig, resolver config.VariableResolver) (*client.Client, error) {
342+ c, err := createMcpClient(name, m, resolver)
343+ if err != nil {
344+ updateMCPState(name, MCPStateError, err, nil, 0)
345+ slog.Error("error creating mcp client", "error", err, "name", name)
346+ return nil, err
347+ }
348+
349+ timeout := mcpTimeout(m)
350+ initCtx, cancel := context.WithTimeout(ctx, timeout)
351+ defer cancel()
352+
353+ // Only call Start() for non-stdio clients, as stdio clients auto-start
354+ if m.Type != config.MCPStdio {
355+ if err := c.Start(initCtx); err != nil {
356+ updateMCPState(name, MCPStateError, maybeTimeoutErr(err, timeout), nil, 0)
357+ slog.Error("error starting mcp client", "error", err, "name", name)
358+ _ = c.Close()
359+ return nil, err
360+ }
361+ }
362+ if _, err := c.Initialize(initCtx, mcpInitRequest); err != nil {
363+ updateMCPState(name, MCPStateError, maybeTimeoutErr(err, timeout), nil, 0)
364+ slog.Error("error initializing mcp client", "error", err, "name", name)
365+ _ = c.Close()
366+ return nil, err
367+ }
368+
369+ slog.Info("Initialized mcp client", "name", name)
370+ return c, nil
371+}
372+
373+func maybeTimeoutErr(err error, timeout time.Duration) error {
374+ if errors.Is(err, context.DeadlineExceeded) {
375+ return fmt.Errorf("timed out after %s", timeout)
376+ }
377+ return err
378+}
379+
380+func createMcpClient(name string, m config.MCPConfig, resolver config.VariableResolver) (*client.Client, error) {
381+ switch m.Type {
382+ case config.MCPStdio:
383+ command, err := resolver.ResolveValue(m.Command)
384+ if err != nil {
385+ return nil, fmt.Errorf("invalid mcp command: %w", err)
386+ }
387+ if strings.TrimSpace(command) == "" {
388+ return nil, fmt.Errorf("mcp stdio config requires a non-empty 'command' field")
389+ }
390+ return client.NewStdioMCPClientWithOptions(
391+ home.Long(command),
392+ m.ResolvedEnv(),
393+ m.Args,
394+ transport.WithCommandLogger(mcpLogger{name: name}),
395+ )
396+ case config.MCPHttp:
397+ if strings.TrimSpace(m.URL) == "" {
398+ return nil, fmt.Errorf("mcp http config requires a non-empty 'url' field")
399+ }
400+ return client.NewStreamableHttpClient(
401+ m.URL,
402+ transport.WithHTTPHeaders(m.ResolvedHeaders()),
403+ transport.WithHTTPLogger(mcpLogger{name: name}),
404+ )
405+ case config.MCPSse:
406+ if strings.TrimSpace(m.URL) == "" {
407+ return nil, fmt.Errorf("mcp sse config requires a non-empty 'url' field")
408+ }
409+ return client.NewSSEMCPClient(
410+ m.URL,
411+ client.WithHeaders(m.ResolvedHeaders()),
412+ transport.WithSSELogger(mcpLogger{name: name}),
413+ )
414+ default:
415+ return nil, fmt.Errorf("unsupported mcp type: %s", m.Type)
416+ }
417+}
418+
419+// for MCP's clients.
420+type mcpLogger struct{ name string }
421+
422+func (l mcpLogger) Errorf(format string, v ...any) {
423+ slog.Error(fmt.Sprintf(format, v...), "name", l.name)
424+}
425+
426+func (l mcpLogger) Infof(format string, v ...any) {
427+ slog.Info(fmt.Sprintf(format, v...), "name", l.name)
428+}
429+
430+func mcpTimeout(m config.MCPConfig) time.Duration {
431+ return time.Duration(cmp.Or(m.Timeout, 15)) * time.Second
432+}
internal/agent/tools/multiedit.gonew file+366
@@ -0,0 +1,366 @@
1+package tools
2+
3+import (
4+ "context"
5+ _ "embed"
6+ "fmt"
7+ "log/slog"
8+ "os"
9+ "path/filepath"
10+ "strings"
11+ "time"
12+
13+ "github.com/charmbracelet/crush/internal/csync"
14+ "github.com/charmbracelet/crush/internal/diff"
15+ "github.com/charmbracelet/crush/internal/fsext"
16+ "github.com/charmbracelet/crush/internal/history"
17+ "github.com/charmbracelet/crush/internal/lsp"
18+ "github.com/charmbracelet/crush/internal/permission"
19+ "github.com/charmbracelet/fantasy/ai"
20+)
21+
22+type MultiEditOperation struct {
23+ OldString string `json:"old_string" description:"The text to replace"`
24+ NewString string `json:"new_string" description:"The text to replace it with"`
25+ ReplaceAll bool `json:"replace_all,omitempty" description:"Replace all occurrences of old_string (default false)."`
26+}
27+
28+type MultiEditParams struct {
29+ FilePath string `json:"file_path" description:"The absolute path to the file to modify"`
30+ Edits []MultiEditOperation `json:"edits" description:"Array of edit operations to perform sequentially on the file"`
31+}
32+
33+type MultiEditPermissionsParams struct {
34+ FilePath string `json:"file_path"`
35+ OldContent string `json:"old_content,omitempty"`
36+ NewContent string `json:"new_content,omitempty"`
37+}
38+
39+type MultiEditResponseMetadata struct {
40+ Additions int `json:"additions"`
41+ Removals int `json:"removals"`
42+ OldContent string `json:"old_content,omitempty"`
43+ NewContent string `json:"new_content,omitempty"`
44+ EditsApplied int `json:"edits_applied"`
45+}
46+
47+const MultiEditToolName = "multiedit"
48+
49+//go:embed multiedit.md
50+var multieditDescription []byte
51+
52+func NewMultiEditTool(lspClients *csync.Map[string, *lsp.Client], permissions permission.Service, files history.Service, workingDir string) ai.AgentTool {
53+ return ai.NewAgentTool(
54+ MultiEditToolName,
55+ string(multieditDescription),
56+ func(ctx context.Context, params MultiEditParams, call ai.ToolCall) (ai.ToolResponse, error) {
57+ if params.FilePath == "" {
58+ return ai.NewTextErrorResponse("file_path is required"), nil
59+ }
60+
61+ if len(params.Edits) == 0 {
62+ return ai.NewTextErrorResponse("at least one edit operation is required"), nil
63+ }
64+
65+ if !filepath.IsAbs(params.FilePath) {
66+ params.FilePath = filepath.Join(workingDir, params.FilePath)
67+ }
68+
69+ // Validate all edits before applying any
70+ if err := validateEdits(params.Edits); err != nil {
71+ return ai.NewTextErrorResponse(err.Error()), nil
72+ }
73+
74+ var response ai.ToolResponse
75+ var err error
76+
77+ editCtx := editContext{ctx, permissions, files, workingDir}
78+ // Handle file creation case (first edit has empty old_string)
79+ if len(params.Edits) > 0 && params.Edits[0].OldString == "" {
80+ response, err = processMultiEditWithCreation(editCtx, params, call)
81+ } else {
82+ response, err = processMultiEditExistingFile(editCtx, params, call)
83+ }
84+
85+ if err != nil {
86+ return response, err
87+ }
88+
89+ if response.IsError {
90+ return response, nil
91+ }
92+
93+ // Notify LSP clients about the change
94+ notifyLSPs(ctx, lspClients, params.FilePath)
95+
96+ // Wait for LSP diagnostics and add them to the response
97+ text := fmt.Sprintf("<result>\n%s\n</result>\n", response.Content)
98+ text += getDiagnostics(params.FilePath, lspClients)
99+ response.Content = text
100+ return response, nil
101+ })
102+}
103+
104+func validateEdits(edits []MultiEditOperation) error {
105+ for i, edit := range edits {
106+ if edit.OldString == edit.NewString {
107+ return fmt.Errorf("edit %d: old_string and new_string are identical", i+1)
108+ }
109+ // Only the first edit can have empty old_string (for file creation)
110+ if i > 0 && edit.OldString == "" {
111+ return fmt.Errorf("edit %d: only the first edit can have empty old_string (for file creation)", i+1)
112+ }
113+ }
114+ return nil
115+}
116+
117+func processMultiEditWithCreation(edit editContext, params MultiEditParams, call ai.ToolCall) (ai.ToolResponse, error) {
118+ // First edit creates the file
119+ firstEdit := params.Edits[0]
120+ if firstEdit.OldString != "" {
121+ return ai.NewTextErrorResponse("first edit must have empty old_string for file creation"), nil
122+ }
123+
124+ // Check if file already exists
125+ if _, err := os.Stat(params.FilePath); err == nil {
126+ return ai.NewTextErrorResponse(fmt.Sprintf("file already exists: %s", params.FilePath)), nil
127+ } else if !os.IsNotExist(err) {
128+ return ai.ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
129+ }
130+
131+ // Create parent directories
132+ dir := filepath.Dir(params.FilePath)
133+ if err := os.MkdirAll(dir, 0o755); err != nil {
134+ return ai.ToolResponse{}, fmt.Errorf("failed to create parent directories: %w", err)
135+ }
136+
137+ // Start with the content from the first edit
138+ currentContent := firstEdit.NewString
139+
140+ // Apply remaining edits to the content
141+ for i := 1; i < len(params.Edits); i++ {
142+ edit := params.Edits[i]
143+ newContent, err := applyEditToContent(currentContent, edit)
144+ if err != nil {
145+ return ai.NewTextErrorResponse(fmt.Sprintf("edit %d failed: %s", i+1, err.Error())), nil
146+ }
147+ currentContent = newContent
148+ }
149+
150+ // Get session and message IDs
151+ sessionID := GetSessionFromContext(edit.ctx)
152+ if sessionID == "" {
153+ return ai.ToolResponse{}, fmt.Errorf("session ID is required for creating a new file")
154+ }
155+
156+ // Check permissions
157+ _, additions, removals := diff.GenerateDiff("", currentContent, strings.TrimPrefix(params.FilePath, edit.workingDir))
158+
159+ p := edit.permissions.Request(permission.CreatePermissionRequest{
160+ SessionID: sessionID,
161+ Path: fsext.PathOrPrefix(params.FilePath, edit.workingDir),
162+ ToolCallID: call.ID,
163+ ToolName: MultiEditToolName,
164+ Action: "write",
165+ Description: fmt.Sprintf("Create file %s with %d edits", params.FilePath, len(params.Edits)),
166+ Params: MultiEditPermissionsParams{
167+ FilePath: params.FilePath,
168+ OldContent: "",
169+ NewContent: currentContent,
170+ },
171+ })
172+ if !p {
173+ return ai.ToolResponse{}, permission.ErrorPermissionDenied
174+ }
175+
176+ // Write the file
177+ err := os.WriteFile(params.FilePath, []byte(currentContent), 0o644)
178+ if err != nil {
179+ return ai.ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
180+ }
181+
182+ // Update file history
183+ _, err = edit.files.Create(edit.ctx, sessionID, params.FilePath, "")
184+ if err != nil {
185+ return ai.ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
186+ }
187+
188+ _, err = edit.files.CreateVersion(edit.ctx, sessionID, params.FilePath, currentContent)
189+ if err != nil {
190+ slog.Debug("Error creating file history version", "error", err)
191+ }
192+
193+ recordFileWrite(params.FilePath)
194+ recordFileRead(params.FilePath)
195+
196+ return ai.WithResponseMetadata(
197+ ai.NewTextResponse(fmt.Sprintf("File created with %d edits: %s", len(params.Edits), params.FilePath)),
198+ MultiEditResponseMetadata{
199+ OldContent: "",
200+ NewContent: currentContent,
201+ Additions: additions,
202+ Removals: removals,
203+ EditsApplied: len(params.Edits),
204+ },
205+ ), nil
206+}
207+
208+func processMultiEditExistingFile(edit editContext, params MultiEditParams, call ai.ToolCall) (ai.ToolResponse, error) {
209+ // Validate file exists and is readable
210+ fileInfo, err := os.Stat(params.FilePath)
211+ if err != nil {
212+ if os.IsNotExist(err) {
213+ return ai.NewTextErrorResponse(fmt.Sprintf("file not found: %s", params.FilePath)), nil
214+ }
215+ return ai.ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
216+ }
217+
218+ if fileInfo.IsDir() {
219+ return ai.NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", params.FilePath)), nil
220+ }
221+
222+ // Check if file was read before editing
223+ if getLastReadTime(params.FilePath).IsZero() {
224+ return ai.NewTextErrorResponse("you must read the file before editing it. Use the View tool first"), nil
225+ }
226+
227+ // Check if file was modified since last read
228+ modTime := fileInfo.ModTime()
229+ lastRead := getLastReadTime(params.FilePath)
230+ if modTime.After(lastRead) {
231+ return ai.NewTextErrorResponse(
232+ fmt.Sprintf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
233+ params.FilePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339),
234+ )), nil
235+ }
236+
237+ // Read current file content
238+ content, err := os.ReadFile(params.FilePath)
239+ if err != nil {
240+ return ai.ToolResponse{}, fmt.Errorf("failed to read file: %w", err)
241+ }
242+
243+ oldContent, isCrlf := fsext.ToUnixLineEndings(string(content))
244+ currentContent := oldContent
245+
246+ // Apply all edits sequentially
247+ for i, edit := range params.Edits {
248+ newContent, err := applyEditToContent(currentContent, edit)
249+ if err != nil {
250+ return ai.NewTextErrorResponse(fmt.Sprintf("edit %d failed: %s", i+1, err.Error())), nil
251+ }
252+ currentContent = newContent
253+ }
254+
255+ // Check if content actually changed
256+ if oldContent == currentContent {
257+ return ai.NewTextErrorResponse("no changes made - all edits resulted in identical content"), nil
258+ }
259+
260+ // Get session and message IDs
261+ sessionID := GetSessionFromContext(edit.ctx)
262+ if sessionID == "" {
263+ return ai.ToolResponse{}, fmt.Errorf("session ID is required for editing file")
264+ }
265+
266+ // Generate diff and check permissions
267+ _, additions, removals := diff.GenerateDiff(oldContent, currentContent, strings.TrimPrefix(params.FilePath, edit.workingDir))
268+ p := edit.permissions.Request(permission.CreatePermissionRequest{
269+ SessionID: sessionID,
270+ Path: fsext.PathOrPrefix(params.FilePath, edit.workingDir),
271+ ToolCallID: call.ID,
272+ ToolName: MultiEditToolName,
273+ Action: "write",
274+ Description: fmt.Sprintf("Apply %d edits to file %s", len(params.Edits), params.FilePath),
275+ Params: MultiEditPermissionsParams{
276+ FilePath: params.FilePath,
277+ OldContent: oldContent,
278+ NewContent: currentContent,
279+ },
280+ })
281+ if !p {
282+ return ai.ToolResponse{}, permission.ErrorPermissionDenied
283+ }
284+
285+ if isCrlf {
286+ currentContent, _ = fsext.ToWindowsLineEndings(currentContent)
287+ }
288+
289+ // Write the updated content
290+ err = os.WriteFile(params.FilePath, []byte(currentContent), 0o644)
291+ if err != nil {
292+ return ai.ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
293+ }
294+
295+ // Update file history
296+ file, err := edit.files.GetByPathAndSession(edit.ctx, params.FilePath, sessionID)
297+ if err != nil {
298+ _, err = edit.files.Create(edit.ctx, sessionID, params.FilePath, oldContent)
299+ if err != nil {
300+ return ai.ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
301+ }
302+ }
303+ if file.Content != oldContent {
304+ // User manually changed the content, store an intermediate version
305+ _, err = edit.files.CreateVersion(edit.ctx, sessionID, params.FilePath, oldContent)
306+ if err != nil {
307+ slog.Debug("Error creating file history version", "error", err)
308+ }
309+ }
310+
311+ // Store the new version
312+ _, err = edit.files.CreateVersion(edit.ctx, sessionID, params.FilePath, currentContent)
313+ if err != nil {
314+ slog.Debug("Error creating file history version", "error", err)
315+ }
316+
317+ recordFileWrite(params.FilePath)
318+ recordFileRead(params.FilePath)
319+
320+ return ai.WithResponseMetadata(
321+ ai.NewTextResponse(fmt.Sprintf("Applied %d edits to file: %s", len(params.Edits), params.FilePath)),
322+ MultiEditResponseMetadata{
323+ OldContent: oldContent,
324+ NewContent: currentContent,
325+ Additions: additions,
326+ Removals: removals,
327+ EditsApplied: len(params.Edits),
328+ },
329+ ), nil
330+}
331+
332+func applyEditToContent(content string, edit MultiEditOperation) (string, error) {
333+ if edit.OldString == "" && edit.NewString == "" {
334+ return content, nil
335+ }
336+
337+ if edit.OldString == "" {
338+ return "", fmt.Errorf("old_string cannot be empty for content replacement")
339+ }
340+
341+ var newContent string
342+ var replacementCount int
343+
344+ if edit.ReplaceAll {
345+ newContent = strings.ReplaceAll(content, edit.OldString, edit.NewString)
346+ replacementCount = strings.Count(content, edit.OldString)
347+ if replacementCount == 0 {
348+ return "", fmt.Errorf("old_string not found in content. Make sure it matches exactly, including whitespace and line breaks")
349+ }
350+ } else {
351+ index := strings.Index(content, edit.OldString)
352+ if index == -1 {
353+ return "", fmt.Errorf("old_string not found in content. Make sure it matches exactly, including whitespace and line breaks")
354+ }
355+
356+ lastIndex := strings.LastIndex(content, edit.OldString)
357+ if index != lastIndex {
358+ return "", fmt.Errorf("old_string appears multiple times in the content. Please provide more context to ensure a unique match, or set replace_all to true")
359+ }
360+
361+ newContent = content[:index] + edit.NewString + content[index+len(edit.OldString):]
362+ replacementCount = 1
363+ }
364+
365+ return newContent, nil
366+}
internal/agent/tools/multiedit.mdnew file+50
@@ -0,0 +1,50 @@
1+Makes multiple edits to a single file in one operation. Built on Edit tool for efficient multiple find-and-replace operations. Prefer over Edit tool for multiple edits to same file.
2+
3+<prerequisites>
4+1. Use View tool to understand file contents and context
5+2. Verify directory path is correct
6+</prerequisites>
7+
8+<parameters>
9+1. file_path: Absolute path to file (required)
10+2. edits: Array of edit operations, each containing:
11+ - old_string: Text to replace (must match exactly including whitespace/indentation)
12+ - new_string: Replacement text
13+ - replace_all: Replace all occurrences (optional, defaults to false)
14+</parameters>
15+
16+<operation>
17+- Edits applied sequentially in provided order
18+- Each edit operates on result of previous edit
19+- All edits must be valid for operation to succeed - if any fails, none applied
20+- Ideal for several changes to different parts of same file
21+</operation>
22+
23+<critical_requirements>
24+
25+1. All edits follow same requirements as single Edit tool
26+2. Edits are atomic - either all succeed or none applied
27+3. Plan edits carefully to avoid conflicts between sequential operations
28+ </critical_requirements>
29+
30+<warnings>
31+- Tool fails if old_string doesn't match file contents exactly (including whitespace)
32+- Tool fails if old_string and new_string are identical
33+- Earlier edits may affect text that later edits try to find - plan sequence carefully
34+</warnings>
35+
36+<best_practices>
37+
38+- Ensure all edits result in correct, idiomatic code
39+- Don't leave code in broken state
40+- Use absolute file paths (starting with /)
41+- Use replace_all for renaming variables across file
42+- Avoid adding emojis unless user explicitly requests
43+ </best_practices>
44+
45+<new_file_creation>
46+
47+- Provide new file path (including directory if needed)
48+- First edit: empty old_string, new file contents as new_string
49+- Subsequent edits: normal edit operations on created content
50+ </new_file_creation>
internal/agent/tools/rg.gonew file+53
@@ -0,0 +1,53 @@
1+package tools
2+
3+import (
4+ "context"
5+ "log/slog"
6+ "os/exec"
7+ "path/filepath"
8+ "strings"
9+ "sync"
10+
11+ "github.com/charmbracelet/crush/internal/log"
12+)
13+
14+var getRg = sync.OnceValue(func() string {
15+ path, err := exec.LookPath("rg")
16+ if err != nil {
17+ if log.Initialized() {
18+ slog.Warn("Ripgrep (rg) not found in $PATH. Some grep features might be limited or slower.")
19+ }
20+ return ""
21+ }
22+ return path
23+})
24+
25+func getRgCmd(ctx context.Context, globPattern string) *exec.Cmd {
26+ name := getRg()
27+ if name == "" {
28+ return nil
29+ }
30+ args := []string{"--files", "-L", "--null"}
31+ if globPattern != "" {
32+ if !filepath.IsAbs(globPattern) && !strings.HasPrefix(globPattern, "/") {
33+ globPattern = "/" + globPattern
34+ }
35+ args = append(args, "--glob", globPattern)
36+ }
37+ return exec.CommandContext(ctx, name, args...)
38+}
39+
40+func getRgSearchCmd(ctx context.Context, pattern, path, include string) *exec.Cmd {
41+ name := getRg()
42+ if name == "" {
43+ return nil
44+ }
45+ // Use -n to show line numbers, -0 for null separation to handle Windows paths
46+ args := []string{"-H", "-n", "-0", pattern}
47+ if include != "" {
48+ args = append(args, "--glob", include)
49+ }
50+ args = append(args, path)
51+
52+ return exec.CommandContext(ctx, name, args...)
53+}
internal/agent/tools/safe.gonew file+70
@@ -0,0 +1,70 @@
1+package tools
2+
3+import "runtime"
4+
5+var safeCommands = []string{
6+ // Bash builtins and core utils
7+ "cal",
8+ "date",
9+ "df",
10+ "du",
11+ "echo",
12+ "env",
13+ "free",
14+ "groups",
15+ "hostname",
16+ "id",
17+ "kill",
18+ "killall",
19+ "ls",
20+ "nice",
21+ "nohup",
22+ "printenv",
23+ "ps",
24+ "pwd",
25+ "set",
26+ "time",
27+ "timeout",
28+ "top",
29+ "type",
30+ "uname",
31+ "unset",
32+ "uptime",
33+ "whatis",
34+ "whereis",
35+ "which",
36+ "whoami",
37+
38+ // Git
39+ "git blame",
40+ "git branch",
41+ "git config --get",
42+ "git config --list",
43+ "git describe",
44+ "git diff",
45+ "git grep",
46+ "git log",
47+ "git ls-files",
48+ "git ls-remote",
49+ "git remote",
50+ "git rev-parse",
51+ "git shortlog",
52+ "git show",
53+ "git status",
54+ "git tag",
55+}
56+
57+func init() {
58+ if runtime.GOOS == "windows" {
59+ safeCommands = append(
60+ safeCommands,
61+ // Windows-specific commands
62+ "ipconfig",
63+ "nslookup",
64+ "ping",
65+ "systeminfo",
66+ "tasklist",
67+ "where",
68+ )
69+ }
70+}
internal/agent/tools/sourcegraph.gonew file+265
@@ -0,0 +1,265 @@
1+package tools
2+
3+import (
4+ "bytes"
5+ "context"
6+ _ "embed"
7+ "encoding/json"
8+ "fmt"
9+ "io"
10+ "net/http"
11+ "strings"
12+ "time"
13+
14+ "github.com/charmbracelet/fantasy/ai"
15+)
16+
17+type SourcegraphParams struct {
18+ Query string `json:"query" description:"The Sourcegraph search query"`
19+ Count int `json:"count,omitempty" description:"Optional number of results to return (default: 10, max: 20)"`
20+ ContextWindow int `json:"context_window,omitempty" description:"The context around the match to return (default: 10 lines)"`
21+ Timeout int `json:"timeout,omitempty" description:"Optional timeout in seconds (max 120)"`
22+}
23+
24+type SourcegraphResponseMetadata struct {
25+ NumberOfMatches int `json:"number_of_matches"`
26+ Truncated bool `json:"truncated"`
27+}
28+
29+const SourcegraphToolName = "sourcegraph"
30+
31+//go:embed sourcegraph.md
32+var sourcegraphDescription []byte
33+
34+func NewSourcegraphTool() ai.AgentTool {
35+ client := &http.Client{
36+ Timeout: 30 * time.Second,
37+ Transport: &http.Transport{
38+ MaxIdleConns: 100,
39+ MaxIdleConnsPerHost: 10,
40+ IdleConnTimeout: 90 * time.Second,
41+ },
42+ }
43+ return ai.NewAgentTool(
44+ SourcegraphToolName,
45+ string(sourcegraphDescription),
46+ func(ctx context.Context, params SourcegraphParams, call ai.ToolCall) (ai.ToolResponse, error) {
47+ if params.Query == "" {
48+ return ai.NewTextErrorResponse("Query parameter is required"), nil
49+ }
50+
51+ if params.Count <= 0 {
52+ params.Count = 10
53+ } else if params.Count > 20 {
54+ params.Count = 20 // Limit to 20 results
55+ }
56+
57+ if params.ContextWindow <= 0 {
58+ params.ContextWindow = 10 // Default context window
59+ }
60+
61+ // Handle timeout with context
62+ requestCtx := ctx
63+ if params.Timeout > 0 {
64+ maxTimeout := 120 // 2 minutes
65+ if params.Timeout > maxTimeout {
66+ params.Timeout = maxTimeout
67+ }
68+ var cancel context.CancelFunc
69+ requestCtx, cancel = context.WithTimeout(ctx, time.Duration(params.Timeout)*time.Second)
70+ defer cancel()
71+ }
72+
73+ type graphqlRequest struct {
74+ Query string `json:"query"`
75+ Variables struct {
76+ Query string `json:"query"`
77+ } `json:"variables"`
78+ }
79+
80+ request := graphqlRequest{
81+ Query: "query Search($query: String!) { search(query: $query, version: V2, patternType: keyword ) { results { matchCount, limitHit, resultCount, approximateResultCount, missing { name }, timedout { name }, indexUnavailable, results { __typename, ... on FileMatch { repository { name }, file { path, url, content }, lineMatches { preview, lineNumber, offsetAndLengths } } } } } }",
82+ }
83+ request.Variables.Query = params.Query
84+
85+ graphqlQueryBytes, err := json.Marshal(request)
86+ if err != nil {
87+ return ai.ToolResponse{}, fmt.Errorf("failed to marshal GraphQL request: %w", err)
88+ }
89+ graphqlQuery := string(graphqlQueryBytes)
90+
91+ req, err := http.NewRequestWithContext(
92+ requestCtx,
93+ "POST",
94+ "https://sourcegraph.com/.api/graphql",
95+ bytes.NewBuffer([]byte(graphqlQuery)),
96+ )
97+ if err != nil {
98+ return ai.ToolResponse{}, fmt.Errorf("failed to create request: %w", err)
99+ }
100+
101+ req.Header.Set("Content-Type", "application/json")
102+ req.Header.Set("User-Agent", "crush/1.0")
103+
104+ resp, err := client.Do(req)
105+ if err != nil {
106+ return ai.ToolResponse{}, fmt.Errorf("failed to fetch URL: %w", err)
107+ }
108+ defer resp.Body.Close()
109+
110+ if resp.StatusCode != http.StatusOK {
111+ body, _ := io.ReadAll(resp.Body)
112+ if len(body) > 0 {
113+ return ai.NewTextErrorResponse(fmt.Sprintf("Request failed with status code: %d, response: %s", resp.StatusCode, string(body))), nil
114+ }
115+
116+ return ai.NewTextErrorResponse(fmt.Sprintf("Request failed with status code: %d", resp.StatusCode)), nil
117+ }
118+ body, err := io.ReadAll(resp.Body)
119+ if err != nil {
120+ return ai.ToolResponse{}, fmt.Errorf("failed to read response body: %w", err)
121+ }
122+
123+ var result map[string]any
124+ if err = json.Unmarshal(body, &result); err != nil {
125+ return ai.ToolResponse{}, fmt.Errorf("failed to unmarshal response: %w", err)
126+ }
127+
128+ formattedResults, err := formatSourcegraphResults(result, params.ContextWindow)
129+ if err != nil {
130+ return ai.NewTextErrorResponse("Failed to format results: " + err.Error()), nil
131+ }
132+
133+ return ai.NewTextResponse(formattedResults), nil
134+ })
135+}
136+
137+func formatSourcegraphResults(result map[string]any, contextWindow int) (string, error) {
138+ var buffer strings.Builder
139+
140+ if errors, ok := result["errors"].([]any); ok && len(errors) > 0 {
141+ buffer.WriteString("## Sourcegraph API Error\n\n")
142+ for _, err := range errors {
143+ if errMap, ok := err.(map[string]any); ok {
144+ if message, ok := errMap["message"].(string); ok {
145+ buffer.WriteString(fmt.Sprintf("- %s\n", message))
146+ }
147+ }
148+ }
149+ return buffer.String(), nil
150+ }
151+
152+ data, ok := result["data"].(map[string]any)
153+ if !ok {
154+ return "", fmt.Errorf("invalid response format: missing data field")
155+ }
156+
157+ search, ok := data["search"].(map[string]any)
158+ if !ok {
159+ return "", fmt.Errorf("invalid response format: missing search field")
160+ }
161+
162+ searchResults, ok := search["results"].(map[string]any)
163+ if !ok {
164+ return "", fmt.Errorf("invalid response format: missing results field")
165+ }
166+
167+ matchCount, _ := searchResults["matchCount"].(float64)
168+ resultCount, _ := searchResults["resultCount"].(float64)
169+ limitHit, _ := searchResults["limitHit"].(bool)
170+
171+ buffer.WriteString("# Sourcegraph Search Results\n\n")
172+ buffer.WriteString(fmt.Sprintf("Found %d matches across %d results\n", int(matchCount), int(resultCount)))
173+
174+ if limitHit {
175+ buffer.WriteString("(Result limit reached, try a more specific query)\n")
176+ }
177+
178+ buffer.WriteString("\n")
179+
180+ results, ok := searchResults["results"].([]any)
181+ if !ok || len(results) == 0 {
182+ buffer.WriteString("No results found. Try a different query.\n")
183+ return buffer.String(), nil
184+ }
185+
186+ maxResults := 10
187+ if len(results) > maxResults {
188+ results = results[:maxResults]
189+ }
190+
191+ for i, res := range results {
192+ fileMatch, ok := res.(map[string]any)
193+ if !ok {
194+ continue
195+ }
196+
197+ typeName, _ := fileMatch["__typename"].(string)
198+ if typeName != "FileMatch" {
199+ continue
200+ }
201+
202+ repo, _ := fileMatch["repository"].(map[string]any)
203+ file, _ := fileMatch["file"].(map[string]any)
204+ lineMatches, _ := fileMatch["lineMatches"].([]any)
205+
206+ if repo == nil || file == nil {
207+ continue
208+ }
209+
210+ repoName, _ := repo["name"].(string)
211+ filePath, _ := file["path"].(string)
212+ fileURL, _ := file["url"].(string)
213+ fileContent, _ := file["content"].(string)
214+
215+ buffer.WriteString(fmt.Sprintf("## Result %d: %s/%s\n\n", i+1, repoName, filePath))
216+
217+ if fileURL != "" {
218+ buffer.WriteString(fmt.Sprintf("URL: %s\n\n", fileURL))
219+ }
220+
221+ if len(lineMatches) > 0 {
222+ for _, lm := range lineMatches {
223+ lineMatch, ok := lm.(map[string]any)
224+ if !ok {
225+ continue
226+ }
227+
228+ lineNumber, _ := lineMatch["lineNumber"].(float64)
229+ preview, _ := lineMatch["preview"].(string)
230+
231+ if fileContent != "" {
232+ lines := strings.Split(fileContent, "\n")
233+
234+ buffer.WriteString("```\n")
235+
236+ startLine := max(1, int(lineNumber)-contextWindow)
237+
238+ for j := startLine - 1; j < int(lineNumber)-1 && j < len(lines); j++ {
239+ if j >= 0 {
240+ buffer.WriteString(fmt.Sprintf("%d| %s\n", j+1, lines[j]))
241+ }
242+ }
243+
244+ buffer.WriteString(fmt.Sprintf("%d| %s\n", int(lineNumber), preview))
245+
246+ endLine := int(lineNumber) + contextWindow
247+
248+ for j := int(lineNumber); j < endLine && j < len(lines); j++ {
249+ if j < len(lines) {
250+ buffer.WriteString(fmt.Sprintf("%d| %s\n", j+1, lines[j]))
251+ }
252+ }
253+
254+ buffer.WriteString("```\n\n")
255+ } else {
256+ buffer.WriteString("```\n")
257+ buffer.WriteString(fmt.Sprintf("%d| %s\n", int(lineNumber), preview))
258+ buffer.WriteString("```\n\n")
259+ }
260+ }
261+ }
262+ }
263+
264+ return buffer.String(), nil
265+}
internal/agent/tools/sourcegraph.mdnew file+57
@@ -0,0 +1,57 @@
1+Search code across public repositories using Sourcegraph's GraphQL API.
2+
3+<usage>
4+- Provide search query using Sourcegraph syntax
5+- Optional result count (default: 10, max: 20)
6+- Optional timeout for request
7+</usage>
8+
9+<basic_syntax>
10+
11+- "fmt.Println" - exact matches
12+- "file:.go fmt.Println" - limit to Go files
13+- "repo:^github\.com/golang/go$ fmt.Println" - specific repos
14+- "lang:go fmt.Println" - limit to Go code
15+- "fmt.Println AND log.Fatal" - combined terms
16+- "fmt\.(Print|Printf|Println)" - regex patterns
17+- "\"exact phrase\"" - exact phrase matching
18+- "-file:test" or "-repo:forks" - exclude matches
19+ </basic_syntax>
20+
21+<key_filters>
22+Repository: repo:name, repo:^exact$, repo:org/repo@branch, -repo:exclude, fork:yes, archived:yes, visibility:public
23+File: file:\.js$, file:internal/, -file:test, file:has.content(text)
24+Content: content:"exact", -content:"unwanted", case:yes
25+Type: type:symbol, type:file, type:path, type:diff, type:commit
26+Time: after:"1 month ago", before:"2023-01-01", author:name, message:"fix"
27+Result: select:repo, select:file, select:content, count:100, timeout:30s
28+</key_filters>
29+
30+<examples>
31+- "file:.go context.WithTimeout" - Go code using context.WithTimeout
32+- "lang:typescript useState type:symbol" - TypeScript React useState hooks
33+- "repo:^github\.com/kubernetes/kubernetes$ pod list type:file" - Kubernetes pod files
34+- "file:Dockerfile (alpine OR ubuntu) -content:alpine:latest" - Dockerfiles with base images
35+</examples>
36+
37+<boolean_operators>
38+
39+- "term1 AND term2" - both terms
40+- "term1 OR term2" - either term
41+- "term1 NOT term2" - term1 but not term2
42+- "term1 and (term2 or term3)" - grouping with parentheses
43+ </boolean_operators>
44+
45+<limitations>
46+- Only searches public repositories
47+- Rate limits may apply
48+- Complex queries take longer
49+- Max 20 results per query
50+</limitations>
51+
52+<tips>
53+- Use specific file extensions to narrow results
54+- Add repo: filters for targeted searches
55+- Use type:symbol for function/method definitions
56+- Use type:file to find relevant files
57+</tips>
internal/agent/tools/tools.gonew file+26
@@ -0,0 +1,26 @@
1+package tools
2+
3+import (
4+ "context"
5+)
6+
7+type (
8+ sessionIDContextKey string
9+ messageIDContextKey string
10+)
11+
12+const (
13+ SessionIDContextKey sessionIDContextKey = "session_id"
14+)
15+
16+func GetSessionFromContext(ctx context.Context) string {
17+ sessionID := ctx.Value(SessionIDContextKey)
18+ if sessionID == nil {
19+ return ""
20+ }
21+ s, ok := sessionID.(string)
22+ if !ok {
23+ return ""
24+ }
25+ return s
26+}
internal/agent/tools/view.gonew file+308
@@ -0,0 +1,308 @@
1+package tools
2+
3+import (
4+ "bufio"
5+ "context"
6+ _ "embed"
7+ "fmt"
8+ "io"
9+ "os"
10+ "path/filepath"
11+ "strings"
12+ "unicode/utf8"
13+
14+ "github.com/charmbracelet/crush/internal/csync"
15+ "github.com/charmbracelet/crush/internal/lsp"
16+ "github.com/charmbracelet/crush/internal/permission"
17+ "github.com/charmbracelet/fantasy/ai"
18+)
19+
20+//go:embed view.md
21+var viewDescription []byte
22+
23+type ViewParams struct {
24+ FilePath string `json:"file_path" description:"The path to the file to read"`
25+ Offset int `json:"offset" description:"The line number to start reading from (0-based)"`
26+ Limit int `json:"limit" description:"The number of lines to read (defaults to 2000)"`
27+}
28+
29+type ViewPermissionsParams struct {
30+ FilePath string `json:"file_path"`
31+ Offset int `json:"offset"`
32+ Limit int `json:"limit"`
33+}
34+
35+type viewTool struct {
36+ lspClients *csync.Map[string, *lsp.Client]
37+ workingDir string
38+ permissions permission.Service
39+}
40+
41+type ViewResponseMetadata struct {
42+ FilePath string `json:"file_path"`
43+ Content string `json:"content"`
44+}
45+
46+const (
47+ ViewToolName = "view"
48+ MaxReadSize = 250 * 1024
49+ DefaultReadLimit = 2000
50+ MaxLineLength = 2000
51+)
52+
53+func NewViewTool(lspClients *csync.Map[string, *lsp.Client], permissions permission.Service, workingDir string) ai.AgentTool {
54+ return ai.NewAgentTool(
55+ ViewToolName,
56+ string(viewDescription),
57+ func(ctx context.Context, params ViewParams, call ai.ToolCall) (ai.ToolResponse, error) {
58+ if params.FilePath == "" {
59+ return ai.NewTextErrorResponse("file_path is required"), nil
60+ }
61+
62+ // Handle relative paths
63+ filePath := params.FilePath
64+ if !filepath.IsAbs(filePath) {
65+ filePath = filepath.Join(workingDir, filePath)
66+ }
67+
68+ // Check if file is outside working directory and request permission if needed
69+ absWorkingDir, err := filepath.Abs(workingDir)
70+ if err != nil {
71+ return ai.ToolResponse{}, fmt.Errorf("error resolving working directory: %w", err)
72+ }
73+
74+ absFilePath, err := filepath.Abs(filePath)
75+ if err != nil {
76+ return ai.ToolResponse{}, fmt.Errorf("error resolving file path: %w", err)
77+ }
78+
79+ relPath, err := filepath.Rel(absWorkingDir, absFilePath)
80+ if err != nil || strings.HasPrefix(relPath, "..") {
81+ // File is outside working directory, request permission
82+ sessionID := GetSessionFromContext(ctx)
83+ if sessionID == "" {
84+ return ai.ToolResponse{}, fmt.Errorf("session ID is required for accessing files outside working directory")
85+ }
86+
87+ granted := permissions.Request(
88+ permission.CreatePermissionRequest{
89+ SessionID: sessionID,
90+ Path: absFilePath,
91+ ToolCallID: call.ID,
92+ ToolName: ViewToolName,
93+ Action: "read",
94+ Description: fmt.Sprintf("Read file outside working directory: %s", absFilePath),
95+ Params: ViewPermissionsParams(params),
96+ },
97+ )
98+
99+ if !granted {
100+ return ai.ToolResponse{}, permission.ErrorPermissionDenied
101+ }
102+ }
103+
104+ // Check if file exists
105+ fileInfo, err := os.Stat(filePath)
106+ if err != nil {
107+ if os.IsNotExist(err) {
108+ // Try to offer suggestions for similarly named files
109+ dir := filepath.Dir(filePath)
110+ base := filepath.Base(filePath)
111+
112+ dirEntries, dirErr := os.ReadDir(dir)
113+ if dirErr == nil {
114+ var suggestions []string
115+ for _, entry := range dirEntries {
116+ if strings.Contains(strings.ToLower(entry.Name()), strings.ToLower(base)) ||
117+ strings.Contains(strings.ToLower(base), strings.ToLower(entry.Name())) {
118+ suggestions = append(suggestions, filepath.Join(dir, entry.Name()))
119+ if len(suggestions) >= 3 {
120+ break
121+ }
122+ }
123+ }
124+
125+ if len(suggestions) > 0 {
126+ return ai.NewTextErrorResponse(fmt.Sprintf("File not found: %s\n\nDid you mean one of these?\n%s",
127+ filePath, strings.Join(suggestions, "\n"))), nil
128+ }
129+ }
130+
131+ return ai.NewTextErrorResponse(fmt.Sprintf("File not found: %s", filePath)), nil
132+ }
133+ return ai.ToolResponse{}, fmt.Errorf("error accessing file: %w", err)
134+ }
135+
136+ // Check if it's a directory
137+ if fileInfo.IsDir() {
138+ return ai.NewTextErrorResponse(fmt.Sprintf("Path is a directory, not a file: %s", filePath)), nil
139+ }
140+
141+ // Check file size
142+ if fileInfo.Size() > MaxReadSize {
143+ return ai.NewTextErrorResponse(fmt.Sprintf("File is too large (%d bytes). Maximum size is %d bytes",
144+ fileInfo.Size(), MaxReadSize)), nil
145+ }
146+
147+ // Set default limit if not provided
148+ if params.Limit <= 0 {
149+ params.Limit = DefaultReadLimit
150+ }
151+
152+ // Check if it's an image file
153+ isImage, imageType := isImageFile(filePath)
154+ // TODO: handle images
155+ if isImage {
156+ return ai.NewTextErrorResponse(fmt.Sprintf("This is an image file of type: %s\n", imageType)), nil
157+ }
158+
159+ // Read the file content
160+ content, lineCount, err := readTextFile(filePath, params.Offset, params.Limit)
161+ isValidUt8 := utf8.ValidString(content)
162+ if !isValidUt8 {
163+ return ai.NewTextErrorResponse("File content is not valid UTF-8"), nil
164+ }
165+ if err != nil {
166+ return ai.ToolResponse{}, fmt.Errorf("error reading file: %w", err)
167+ }
168+
169+ notifyLSPs(ctx, lspClients, filePath)
170+ output := "<file>\n"
171+ // Format the output with line numbers
172+ output += addLineNumbers(content, params.Offset+1)
173+
174+ // Add a note if the content was truncated
175+ if lineCount > params.Offset+len(strings.Split(content, "\n")) {
176+ output += fmt.Sprintf("\n\n(File has more lines. Use 'offset' parameter to read beyond line %d)",
177+ params.Offset+len(strings.Split(content, "\n")))
178+ }
179+ output += "\n</file>\n"
180+ output += getDiagnostics(filePath, lspClients)
181+ recordFileRead(filePath)
182+ return ai.WithResponseMetadata(
183+ ai.NewTextResponse(output),
184+ ViewResponseMetadata{
185+ FilePath: filePath,
186+ Content: content,
187+ },
188+ ), nil
189+ })
190+}
191+
192+func addLineNumbers(content string, startLine int) string {
193+ if content == "" {
194+ return ""
195+ }
196+
197+ lines := strings.Split(content, "\n")
198+
199+ var result []string
200+ for i, line := range lines {
201+ line = strings.TrimSuffix(line, "\r")
202+
203+ lineNum := i + startLine
204+ numStr := fmt.Sprintf("%d", lineNum)
205+
206+ if len(numStr) >= 6 {
207+ result = append(result, fmt.Sprintf("%s|%s", numStr, line))
208+ } else {
209+ paddedNum := fmt.Sprintf("%6s", numStr)
210+ result = append(result, fmt.Sprintf("%s|%s", paddedNum, line))
211+ }
212+ }
213+
214+ return strings.Join(result, "\n")
215+}
216+
217+func readTextFile(filePath string, offset, limit int) (string, int, error) {
218+ file, err := os.Open(filePath)
219+ if err != nil {
220+ return "", 0, err
221+ }
222+ defer file.Close()
223+
224+ lineCount := 0
225+
226+ scanner := NewLineScanner(file)
227+ if offset > 0 {
228+ for lineCount < offset && scanner.Scan() {
229+ lineCount++
230+ }
231+ if err = scanner.Err(); err != nil {
232+ return "", 0, err
233+ }
234+ }
235+
236+ if offset == 0 {
237+ _, err = file.Seek(0, io.SeekStart)
238+ if err != nil {
239+ return "", 0, err
240+ }
241+ }
242+
243+ // Pre-allocate slice with expected capacity
244+ lines := make([]string, 0, limit)
245+ lineCount = offset
246+
247+ for scanner.Scan() && len(lines) < limit {
248+ lineCount++
249+ lineText := scanner.Text()
250+ if len(lineText) > MaxLineLength {
251+ lineText = lineText[:MaxLineLength] + "..."
252+ }
253+ lines = append(lines, lineText)
254+ }
255+
256+ // Continue scanning to get total line count
257+ for scanner.Scan() {
258+ lineCount++
259+ }
260+
261+ if err := scanner.Err(); err != nil {
262+ return "", 0, err
263+ }
264+
265+ return strings.Join(lines, "\n"), lineCount, nil
266+}
267+
268+func isImageFile(filePath string) (bool, string) {
269+ ext := strings.ToLower(filepath.Ext(filePath))
270+ switch ext {
271+ case ".jpg", ".jpeg":
272+ return true, "JPEG"
273+ case ".png":
274+ return true, "PNG"
275+ case ".gif":
276+ return true, "GIF"
277+ case ".bmp":
278+ return true, "BMP"
279+ case ".svg":
280+ return true, "SVG"
281+ case ".webp":
282+ return true, "WebP"
283+ default:
284+ return false, ""
285+ }
286+}
287+
288+type LineScanner struct {
289+ scanner *bufio.Scanner
290+}
291+
292+func NewLineScanner(r io.Reader) *LineScanner {
293+ return &LineScanner{
294+ scanner: bufio.NewScanner(r),
295+ }
296+}
297+
298+func (s *LineScanner) Scan() bool {
299+ return s.scanner.Scan()
300+}
301+
302+func (s *LineScanner) Text() string {
303+ return s.scanner.Text()
304+}
305+
306+func (s *LineScanner) Err() error {
307+ return s.scanner.Err()
308+}
internal/agent/tools/view.mdnew file+36
@@ -0,0 +1,36 @@
1+Reads and displays file contents with line numbers for examining code, logs, or text data.
2+
3+<usage>
4+- Provide file path to read
5+- Optional offset: start reading from specific line (0-based)
6+- Optional limit: control lines read (default 2000)
7+- Don't use for directories (use LS tool instead)
8+</usage>
9+
10+<features>
11+- Displays contents with line numbers
12+- Can read from any file position using offset
13+- Handles large files by limiting lines read
14+- Auto-truncates very long lines for display
15+- Suggests similar filenames when file not found
16+</features>
17+
18+<limitations>
19+- Max file size: 250KB
20+- Default limit: 2000 lines
21+- Lines >2000 chars truncated
22+- Cannot display binary files/images (identifies them)
23+</limitations>
24+
25+<cross_platform>
26+
27+- Handles Windows (CRLF) and Unix (LF) line endings
28+- Works with forward slashes (/) and backslashes (\)
29+- Auto-detects text encoding for common formats
30+ </cross_platform>
31+
32+<tips>
33+- Use with Glob to find files first
34+- For code exploration: Grep to find relevant files, then View to examine
35+- For large files: use offset parameter for specific sections
36+</tips>
internal/agent/tools/write.gonew file+177
@@ -0,0 +1,177 @@
1+package tools
2+
3+import (
4+ "context"
5+ _ "embed"
6+ "fmt"
7+ "log/slog"
8+ "os"
9+ "path/filepath"
10+ "strings"
11+ "time"
12+
13+ "github.com/charmbracelet/crush/internal/csync"
14+ "github.com/charmbracelet/crush/internal/diff"
15+ "github.com/charmbracelet/crush/internal/fsext"
16+ "github.com/charmbracelet/crush/internal/history"
17+ "github.com/charmbracelet/fantasy/ai"
18+
19+ "github.com/charmbracelet/crush/internal/lsp"
20+ "github.com/charmbracelet/crush/internal/permission"
21+)
22+
23+//go:embed write.md
24+var writeDescription []byte
25+
26+type WriteParams struct {
27+ FilePath string `json:"file_path" description:"The path to the file to write"`
28+ Content string `json:"content" description:"The content to write to the file"`
29+}
30+
31+type WritePermissionsParams struct {
32+ FilePath string `json:"file_path"`
33+ OldContent string `json:"old_content,omitempty"`
34+ NewContent string `json:"new_content,omitempty"`
35+}
36+
37+type writeTool struct {
38+ lspClients *csync.Map[string, *lsp.Client]
39+ permissions permission.Service
40+ files history.Service
41+ workingDir string
42+}
43+
44+type WriteResponseMetadata struct {
45+ Diff string `json:"diff"`
46+ Additions int `json:"additions"`
47+ Removals int `json:"removals"`
48+}
49+
50+const WriteToolName = "write"
51+
52+func NewWriteTool(lspClients *csync.Map[string, *lsp.Client], permissions permission.Service, files history.Service, workingDir string) ai.AgentTool {
53+ return ai.NewAgentTool(
54+ WriteToolName,
55+ string(writeDescription),
56+ func(ctx context.Context, params WriteParams, call ai.ToolCall) (ai.ToolResponse, error) {
57+ if params.FilePath == "" {
58+ return ai.NewTextErrorResponse("file_path is required"), nil
59+ }
60+
61+ if params.Content == "" {
62+ return ai.NewTextErrorResponse("content is required"), nil
63+ }
64+
65+ filePath := params.FilePath
66+ if !filepath.IsAbs(filePath) {
67+ filePath = filepath.Join(workingDir, filePath)
68+ }
69+
70+ fileInfo, err := os.Stat(filePath)
71+ if err == nil {
72+ if fileInfo.IsDir() {
73+ return ai.NewTextErrorResponse(fmt.Sprintf("Path is a directory, not a file: %s", filePath)), nil
74+ }
75+
76+ modTime := fileInfo.ModTime()
77+ lastRead := getLastReadTime(filePath)
78+ if modTime.After(lastRead) {
79+ return ai.NewTextErrorResponse(fmt.Sprintf("File %s has been modified since it was last read.\nLast modification: %s\nLast read: %s\n\nPlease read the file again before modifying it.",
80+ filePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339))), nil
81+ }
82+
83+ oldContent, readErr := os.ReadFile(filePath)
84+ if readErr == nil && string(oldContent) == params.Content {
85+ return ai.NewTextErrorResponse(fmt.Sprintf("File %s already contains the exact content. No changes made.", filePath)), nil
86+ }
87+ } else if !os.IsNotExist(err) {
88+ return ai.ToolResponse{}, fmt.Errorf("error checking file: %w", err)
89+ }
90+
91+ dir := filepath.Dir(filePath)
92+ if err = os.MkdirAll(dir, 0o755); err != nil {
93+ return ai.ToolResponse{}, fmt.Errorf("error creating directory: %w", err)
94+ }
95+
96+ oldContent := ""
97+ if fileInfo != nil && !fileInfo.IsDir() {
98+ oldBytes, readErr := os.ReadFile(filePath)
99+ if readErr == nil {
100+ oldContent = string(oldBytes)
101+ }
102+ }
103+
104+ sessionID := GetSessionFromContext(ctx)
105+ if sessionID == "" {
106+ return ai.ToolResponse{}, fmt.Errorf("session_id is required")
107+ }
108+
109+ diff, additions, removals := diff.GenerateDiff(
110+ oldContent,
111+ params.Content,
112+ strings.TrimPrefix(filePath, workingDir),
113+ )
114+
115+ p := permissions.Request(
116+ permission.CreatePermissionRequest{
117+ SessionID: sessionID,
118+ Path: fsext.PathOrPrefix(filePath, workingDir),
119+ ToolCallID: call.ID,
120+ ToolName: WriteToolName,
121+ Action: "write",
122+ Description: fmt.Sprintf("Create file %s", filePath),
123+ Params: WritePermissionsParams{
124+ FilePath: filePath,
125+ OldContent: oldContent,
126+ NewContent: params.Content,
127+ },
128+ },
129+ )
130+ if !p {
131+ return ai.ToolResponse{}, permission.ErrorPermissionDenied
132+ }
133+
134+ err = os.WriteFile(filePath, []byte(params.Content), 0o644)
135+ if err != nil {
136+ return ai.ToolResponse{}, fmt.Errorf("error writing file: %w", err)
137+ }
138+
139+ // Check if file exists in history
140+ file, err := files.GetByPathAndSession(ctx, filePath, sessionID)
141+ if err != nil {
142+ _, err = files.Create(ctx, sessionID, filePath, oldContent)
143+ if err != nil {
144+ // Log error but don't fail the operation
145+ return ai.ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
146+ }
147+ }
148+ if file.Content != oldContent {
149+ // User Manually changed the content store an intermediate version
150+ _, err = files.CreateVersion(ctx, sessionID, filePath, oldContent)
151+ if err != nil {
152+ slog.Debug("Error creating file history version", "error", err)
153+ }
154+ }
155+ // Store the new version
156+ _, err = files.CreateVersion(ctx, sessionID, filePath, params.Content)
157+ if err != nil {
158+ slog.Debug("Error creating file history version", "error", err)
159+ }
160+
161+ recordFileWrite(filePath)
162+ recordFileRead(filePath)
163+
164+ notifyLSPs(ctx, lspClients, params.FilePath)
165+
166+ result := fmt.Sprintf("File successfully written: %s", filePath)
167+ result = fmt.Sprintf("<result>\n%s\n</result>", result)
168+ result += getDiagnostics(filePath, lspClients)
169+ return ai.WithResponseMetadata(ai.NewTextResponse(result),
170+ WriteResponseMetadata{
171+ Diff: diff,
172+ Additions: additions,
173+ Removals: removals,
174+ },
175+ ), nil
176+ })
177+}
internal/agent/tools/write.mdnew file+31
@@ -0,0 +1,31 @@
1+Creates or updates files in filesystem for saving/modifying text content.
2+
3+<usage>
4+- Provide file path to write
5+- Include content to write to file
6+- Tool creates necessary parent directories automatically
7+</usage>
8+
9+<features>
10+- Creates new files or overwrites existing ones
11+- Auto-creates parent directories if missing
12+- Checks if file modified since last read for safety
13+- Avoids unnecessary writes when content unchanged
14+</features>
15+
16+<limitations>
17+- Read file before writing to avoid conflicts
18+- Cannot append (rewrites entire file)
19+</limitations>
20+
21+<cross_platform>
22+
23+- Use forward slashes (/) for compatibility
24+ </cross_platform>
25+
26+<tips>
27+- Use View tool first to examine existing files before modifying
28+- Use LS tool to verify location when creating new files
29+- Combine with Glob/Grep to find and modify multiple files
30+- Include descriptive comments when changing existing code
31+</tips>
internal/app/app.go+1
@@ -31,6 +31,7 @@ type App struct {
31 History history.Service
31 History history.Service
32 Permissions permission.Service
32 Permissions permission.Service
33
33
34+ // TODO: (kujtim) remove this when fully implemented
34 CoderAgent agent.Service
35 CoderAgent agent.Service
35
36
36 LSPClients *csync.Map[string, *lsp.Client]
37 LSPClients *csync.Map[string, *lsp.Client]
internal/config/config.go+16−3
@@ -57,13 +57,23 @@ type SelectedModel struct {
57 Provider string `json:"provider" jsonschema:"required,description=The model provider ID that matches a key in the providers config,example=openai"`
57 Provider string `json:"provider" jsonschema:"required,description=The model provider ID that matches a key in the providers config,example=openai"`
58
58
59 // Only used by models that use the openai provider and need this set.
59 // Only used by models that use the openai provider and need this set.
60+ // Deprecated: use provider_options instead.
60 ReasoningEffort string `json:"reasoning_effort,omitempty" jsonschema:"description=Reasoning effort level for OpenAI models that support it,enum=low,enum=medium,enum=high"`
61 ReasoningEffort string `json:"reasoning_effort,omitempty" jsonschema:"description=Reasoning effort level for OpenAI models that support it,enum=low,enum=medium,enum=high"`
61
62
62− // Overrides the default model configuration.
63− MaxTokens int64 `json:"max_tokens,omitempty" jsonschema:"description=Maximum number of tokens for model responses,minimum=1,maximum=200000,example=4096"`
64−
65 // Used by anthropic models that can reason to indicate if the model should think.
63 // Used by anthropic models that can reason to indicate if the model should think.
64+ // Deprecated: use provider_options instead.
66 Think bool `json:"think,omitempty" jsonschema:"description=Enable thinking mode for Anthropic models that support reasoning"`
65 Think bool `json:"think,omitempty" jsonschema:"description=Enable thinking mode for Anthropic models that support reasoning"`
66+
67+ // Overrides the default model configuration.
68+ MaxTokens int64 `json:"max_tokens,omitempty" jsonschema:"description=Maximum number of tokens for model responses,minimum=1,maximum=200000,example=4096"`
69+ Temperature *float64 `json:"temperature,omitempty" jsonschema:"description=Sampling temperature,minimum=0,maximum=1,example=0.7"`
70+ TopP *float64 `json:"top_p,omitempty" jsonschema:"description=Top-p (nucleus) sampling parameter,minimum=0,maximum=1,example=0.9"`
71+ TopK *int64 `json:"top_k,omitempty" jsonschema:"description=Top-k sampling parameter"`
72+ FrequencyPenalty *float64 `json:"frequency_penalty,omitempty" jsonschema:"description=Frequency penalty to reduce repetition"`
73+ PresencePenalty *float64 `json:"presence_penalty,omitempty" jsonschema:"description=Presence penalty to increase topic diversity"`
74+
75+ // Override provider specific options.
76+ ProviderOptions map[string]any `json:"provider_options,omitempty" jsonschema:"description=Additional provider-specific options for the model"`
67}
77}
68
78
69type ProviderConfig struct {
79type ProviderConfig struct {
@@ -93,6 +103,9 @@ type ProviderConfig struct {
93
103
94 // The provider models
104 // The provider models
95 Models []catwalk.Model `json:"models,omitempty" jsonschema:"description=List of models available from this provider"`
105 Models []catwalk.Model `json:"models,omitempty" jsonschema:"description=List of models available from this provider"`
106+
107+ // Override provider specific options.
108+ ProviderOptions map[string]any `json:"provider_options,omitempty" jsonschema:"description=Additional provider-specific options for the model"`
96}
109}
97
110
98type MCPType string
111type MCPType string
internal/message/content.go+88−5
@@ -2,10 +2,13 @@ package message
2
2
3import (
3import (
4 "encoding/base64"
4 "encoding/base64"
5+ "errors"
5 "slices"
6 "slices"
6 "time"
7 "time"
7
8
8 "github.com/charmbracelet/catwalk/pkg/catwalk"
9 "github.com/charmbracelet/catwalk/pkg/catwalk"
10+ "github.com/charmbracelet/fantasy/ai"
11+ "github.com/charmbracelet/fantasy/anthropic"
9)
12)
10
13
11type MessageRole string
14type MessageRole string
@@ -85,11 +88,12 @@ func (bc BinaryContent) String(p catwalk.InferenceProvider) string {
85func (BinaryContent) isPart() {}
88func (BinaryContent) isPart() {}
86
89
87type ToolCall struct {
90type ToolCall struct {
88− ID string `json:"id"`
91+ ID string `json:"id"`
89− Name string `json:"name"`
92+ Name string `json:"name"`
90− Input string `json:"input"`
93+ Input string `json:"input"`
94+ ProviderExecuted bool `json:"provider_executed"`
91− Type string `json:"type"`
95+ Type string `json:"type"`
92− Finished bool `json:"finished"`
96+ Finished bool `json:"finished"`
93}
97}
94
98
95func (ToolCall) isPart() {}
99func (ToolCall) isPart() {}
@@ -98,6 +102,8 @@ type ToolResult struct {
98 ToolCallID string `json:"tool_call_id"`
102 ToolCallID string `json:"tool_call_id"`
99 Name string `json:"name"`
103 Name string `json:"name"`
100 Content string `json:"content"`
104 Content string `json:"content"`
105+ Data string `json:"data"`
106+ MIMEType string `json:"mime_type"`
101 Metadata string `json:"metadata"`
107 Metadata string `json:"metadata"`
102 IsError bool `json:"is_error"`
108 IsError bool `json:"is_error"`
103}
109}
@@ -384,3 +390,80 @@ func (m *Message) AddImageURL(url, detail string) {
384func (m *Message) AddBinary(mimeType string, data []byte) {
390func (m *Message) AddBinary(mimeType string, data []byte) {
385 m.Parts = append(m.Parts, BinaryContent{MIMEType: mimeType, Data: data})
391 m.Parts = append(m.Parts, BinaryContent{MIMEType: mimeType, Data: data})
386}
392}
393+
394+func (m *Message) ToAIMessage() []ai.Message {
395+ var messages []ai.Message
396+ switch m.Role {
397+ case User:
398+ var parts []ai.MessagePart
399+ if m.Content().Text != "" {
400+ parts = append(parts, ai.TextPart{Text: m.Content().Text})
401+ }
402+ for _, content := range m.BinaryContent() {
403+ parts = append(parts, ai.FilePart{
404+ Filename: content.Path,
405+ Data: content.Data,
406+ MediaType: content.MIMEType,
407+ })
408+ }
409+ messages = append(messages, ai.Message{
410+ Role: ai.MessageRoleUser,
411+ Content: parts,
412+ })
413+ case Assistant:
414+ var parts []ai.MessagePart
415+ if m.Content().Text != "" {
416+ parts = append(parts, ai.TextPart{Text: m.Content().Text})
417+ }
418+ reasoning := m.ReasoningContent()
419+ if reasoning.Thinking != "" {
420+ reasoningPart := ai.ReasoningPart{Text: reasoning.Thinking, ProviderOptions: ai.ProviderOptions{}}
421+ if reasoning.Signature != "" {
422+ reasoningPart.ProviderOptions["anthropic"] = &anthropic.ReasoningOptionMetadata{
423+ Signature: reasoning.Signature,
424+ }
425+ }
426+ parts = append(parts, reasoningPart)
427+ }
428+ for _, call := range m.ToolCalls() {
429+ parts = append(parts, ai.ToolCallPart{
430+ ToolCallID: call.ID,
431+ ToolName: call.Name,
432+ Input: call.Input,
433+ ProviderExecuted: call.ProviderExecuted,
434+ })
435+ }
436+ messages = append(messages, ai.Message{
437+ Role: ai.MessageRoleAssistant,
438+ Content: parts,
439+ })
440+ case Tool:
441+ var parts []ai.MessagePart
442+ for _, result := range m.ToolResults() {
443+ var content ai.ToolResultOutputContent
444+ if result.IsError {
445+ content = ai.ToolResultOutputContentError{
446+ Error: errors.New(result.Content),
447+ }
448+ } else if result.Data != "" {
449+ content = ai.ToolResultOutputContentMedia{
450+ Data: result.Data,
451+ MediaType: result.MIMEType,
452+ }
453+ } else {
454+ content = ai.ToolResultOutputContentText{
455+ Text: result.Content,
456+ }
457+ }
458+ parts = append(parts, ai.ToolResultPart{
459+ ToolCallID: result.ToolCallID,
460+ Output: content,
461+ })
462+ }
463+ messages = append(messages, ai.Message{
464+ Role: ai.MessageRoleTool,
465+ Content: parts,
466+ })
467+ }
468+ return messages
469+}