on main help sign in
Merge branch 'main' into server-client-2
commita35a2bdc2c02aac6cd877cc1883ff27cc3f83e35 authorAyman Bagabas <ayman.bagabas@gmail.com> dateSun 22 Mar 2026, 11:17 (7 months ago) parentbc383d9c4f0f1d
32 files changed+1336−176
.github/cla-signatures.json+16 .github/dependabot.yml+1 .github/workflows/security.yml+6−6 Taskfile.yaml+7−2 go.mod+29−29 go.sum+66−66 internal/agent/agent.go+3 internal/agent/coordinator.go+1−1 internal/agent/hyper/provider.json+1−1 internal/agent/tools/mcp-tools.go+31−17 internal/agent/tools/mcp/init.go+81−41 internal/agent/tools/mcp/tools.go+73−3 internal/agent/tools/mcp/tools_test.go+125 internal/backend/config.go+49 internal/client/config.go+26 internal/cmd/root.go+23−2 internal/cmd/run.go+3−2 internal/cmd/session.go+1−1 internal/config/docker_mcp.go+134 internal/config/docker_mcp_test.go+193 internal/server/config.go+36 internal/server/server.go+3−1 internal/ui/AGENTS.md+1 internal/ui/chat/docker_mcp.go+296 internal/ui/chat/tools.go+3−1 internal/ui/dialog/actions.go+4 internal/ui/dialog/commands.go+42 internal/ui/model/mcp.go+7−1 internal/ui/model/ui.go+57−2 internal/ui/styles/styles.go+8 internal/workspace/client_workspace.go+8 internal/workspace/workspace.go+2
.github/cla-signatures.json+16
@@ -1351,6 +1351,22 @@
13511351 "created_at": "2026-03-15T02:30:55Z",
13521352 "repoId": 987670088,
13531353 "pullRequestNo": 2410
1354+ },
1355+ {
1356+ "name": "nghiant03",
1357+ "id": 102637959,
1358+ "comment_id": 4077213520,
1359+ "created_at": "2026-03-17T18:41:50Z",
1360+ "repoId": 987670088,
1361+ "pullRequestNo": 2421
1362+ },
1363+ {
1364+ "name": "whatnick",
1365+ "id": 491396,
1366+ "comment_id": 4102155868,
1367+ "created_at": "2026-03-21T03:49:12Z",
1368+ "repoId": 987670088,
1369+ "pullRequestNo": 2449
13541370 }
13551371 ]
13561372}
.github/dependabot.yml+1
@@ -24,6 +24,7 @@ updates:
2424 - dependency-name: github.com/charmbracelet/lipgloss/v2
2525 versions:
2626 - v2.0.0-beta1
27+ - dependency-name: mvdan.cc/sh/moreinterp
2728
2829 - package-ecosystem: "github-actions"
2930 directory: "/"
.github/workflows/security.yml+6−6
@@ -30,11 +30,11 @@ jobs:
3030 - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
3131 with:
3232 persist-credentials: false
33− - uses: github/codeql-action/init@b1bff81932f5cdfc8695c7752dcee935dcd061c8 # v4.33.0
33+ - uses: github/codeql-action/init@38697555549f1db7851b81482ff19f1fa5c4fedc # v4.34.1
3434 with:
3535 languages: ${{ matrix.language }}
36− - uses: github/codeql-action/autobuild@b1bff81932f5cdfc8695c7752dcee935dcd061c8 # v4.33.0
37− - uses: github/codeql-action/analyze@b1bff81932f5cdfc8695c7752dcee935dcd061c8 # v4.33.0
36+ - uses: github/codeql-action/autobuild@38697555549f1db7851b81482ff19f1fa5c4fedc # v4.34.1
37+ - uses: github/codeql-action/analyze@38697555549f1db7851b81482ff19f1fa5c4fedc # v4.34.1
3838
3939 grype:
4040 runs-on: ubuntu-latest
@@ -46,13 +46,13 @@ jobs:
4646 - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
4747 with:
4848 persist-credentials: false
49− - uses: anchore/scan-action@7037fa011853d5a11690026fb85feee79f4c946c # v7.3.2
49+ - uses: anchore/scan-action@e1165082ffb1fe366ebaf02d8526e7c4989ea9d2 # v7.4.0
5050 id: scan
5151 with:
5252 path: "."
5353 fail-build: true
5454 severity-cutoff: critical
55− - uses: github/codeql-action/upload-sarif@b1bff81932f5cdfc8695c7752dcee935dcd061c8 # v4.33.0
55+ - uses: github/codeql-action/upload-sarif@38697555549f1db7851b81482ff19f1fa5c4fedc # v4.34.1
5656 with:
5757 sarif_file: ${{ steps.scan.outputs.sarif }}
5858
@@ -73,7 +73,7 @@ jobs:
7373 - name: Run govulncheck
7474 run: |
7575 govulncheck -C . -format sarif ./... > results.sarif
76− - uses: github/codeql-action/upload-sarif@b1bff81932f5cdfc8695c7752dcee935dcd061c8 # v4.33.0
76+ - uses: github/codeql-action/upload-sarif@38697555549f1db7851b81482ff19f1fa5c4fedc # v4.34.1
7777 with:
7878 sarif_file: results.sarif
7979
Taskfile.yaml+7−2
@@ -180,9 +180,14 @@ tasks:
180180
181181 deps:
182182 desc: Update Fantasy and Catwalk
183+ env:
184+ # The Go proxy takes a bit of time to see the latest release. Setting
185+ # these bypass it to ensure we can update to a release from a minute ago.
186+ GOPROXY: direct
187+ GONOSUMDB: charm.land/*
183188 cmds:
184− - go get charm.land/fantasy
185− - go get charm.land/catwalk
189+ - go get charm.land/fantasy@latest
190+ - go get charm.land/catwalk@latest
186191 - go mod tidy
187192
188193 swag:
go.mod+29−29
@@ -5,9 +5,9 @@ go 1.26.1
55require (
66 charm.land/bubbles/v2 v2.0.0
77 charm.land/bubbletea/v2 v2.0.2
8− charm.land/catwalk v0.30.3
8+ charm.land/catwalk v0.31.0
99 charm.land/fang/v2 v2.0.1
10− charm.land/fantasy v0.12.3
10+ charm.land/fantasy v0.16.0
1111 charm.land/glamour/v2 v2.0.0
1212 charm.land/lipgloss/v2 v2.0.2
1313 charm.land/log/v2 v2.0.0
@@ -23,6 +23,7 @@ require (
2323 github.com/bmatcuk/doublestar/v4 v4.10.0
2424 github.com/charlievieth/fastwalk v1.0.14
2525 github.com/charmbracelet/colorprofile v0.4.3
26+ github.com/charmbracelet/openai-go v0.0.0-20260319145158-d0740cc34266
2627 github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8
2728 github.com/charmbracelet/x/ansi v0.11.6
2829 github.com/charmbracelet/x/editor v0.2.0
@@ -50,7 +51,6 @@ require (
5051 github.com/modelcontextprotocol/go-sdk v1.4.1
5152 github.com/ncruces/go-sqlite3 v0.32.0
5253 github.com/nxadm/tail v1.4.11
53− github.com/openai/openai-go/v3 v3.28.0
5454 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c
5555 github.com/posthog/posthog-go v1.11.1
5656 github.com/pressly/goose/v3 v3.27.0
@@ -72,7 +72,7 @@ require (
7272 golang.org/x/text v0.35.0
7373 gopkg.in/natefinch/lumberjack.v2 v2.2.1
7474 gopkg.in/yaml.v3 v3.0.1
75− modernc.org/sqlite v1.46.1
75+ modernc.org/sqlite v1.47.0
7676 mvdan.cc/sh/moreinterp v0.0.0-20250902163504-3cf4fd5717a5
7777 mvdan.cc/sh/v3 v3.13.0
7878)
@@ -87,20 +87,20 @@ require (
8787 github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 // indirect
8888 github.com/KyleBanks/depth v1.2.1 // indirect
8989 github.com/andybalholm/cascadia v1.3.3 // indirect
90− github.com/aws/aws-sdk-go-v2 v1.41.3 // indirect
91− github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.5 // indirect
92− github.com/aws/aws-sdk-go-v2/config v1.32.11 // indirect
93− github.com/aws/aws-sdk-go-v2/credentials v1.19.11 // indirect
94− github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.19 // indirect
95− github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.19 // indirect
96− github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.19 // indirect
97− github.com/aws/aws-sdk-go-v2/internal/ini v1.8.5 // indirect
98− github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.6 // indirect
99− github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.19 // indirect
100− github.com/aws/aws-sdk-go-v2/service/signin v1.0.7 // indirect
101− github.com/aws/aws-sdk-go-v2/service/sso v1.30.12 // indirect
102− github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.16 // indirect
103− github.com/aws/aws-sdk-go-v2/service/sts v1.41.8 // indirect
90+ github.com/aws/aws-sdk-go-v2 v1.41.4 // indirect
91+ github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.6 // indirect
92+ github.com/aws/aws-sdk-go-v2/config v1.32.12 // indirect
93+ github.com/aws/aws-sdk-go-v2/credentials v1.19.12 // indirect
94+ github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20 // indirect
95+ github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20 // indirect
96+ github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20 // indirect
97+ github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 // indirect
98+ github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 // indirect
99+ github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20 // indirect
100+ github.com/aws/aws-sdk-go-v2/service/signin v1.0.8 // indirect
101+ github.com/aws/aws-sdk-go-v2/service/sso v1.30.13 // indirect
102+ github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.17 // indirect
103+ github.com/aws/aws-sdk-go-v2/service/sts v1.41.9 // indirect
104104 github.com/aws/smithy-go v1.24.2 // indirect
105105 github.com/aymerick/douceur v0.2.0 // indirect
106106 github.com/bahlo/generic-list-go v0.2.0 // indirect
@@ -135,7 +135,7 @@ require (
135135 github.com/google/go-cmp v0.7.0 // indirect
136136 github.com/google/jsonschema-go v0.4.2 // indirect
137137 github.com/google/s2a-go v0.1.9 // indirect
138− github.com/googleapis/enterprise-certificate-proxy v0.3.12 // indirect
138+ github.com/googleapis/enterprise-certificate-proxy v0.3.14 // indirect
139139 github.com/googleapis/gax-go/v2 v2.17.0 // indirect
140140 github.com/gorilla/css v1.0.1 // indirect
141141 github.com/gorilla/websocket v1.5.3 // indirect
@@ -186,11 +186,11 @@ require (
186186 github.com/yuin/goldmark v1.7.8 // indirect
187187 github.com/yuin/goldmark-emoji v1.0.5 // indirect
188188 go.opentelemetry.io/auto/sdk v1.2.1 // indirect
189− go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.65.0 // indirect
190− go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 // indirect
191− go.opentelemetry.io/otel v1.40.0 // indirect
192− go.opentelemetry.io/otel/metric v1.40.0 // indirect
193− go.opentelemetry.io/otel/trace v1.40.0 // indirect
189+ go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 // indirect
190+ go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect
191+ go.opentelemetry.io/otel v1.42.0 // indirect
192+ go.opentelemetry.io/otel/metric v1.42.0 // indirect
193+ go.opentelemetry.io/otel/trace v1.42.0 // indirect
194194 go.uber.org/multierr v1.11.0 // indirect
195195 go.yaml.in/yaml/v4 v4.0.0-rc.3 // indirect
196196 golang.org/x/crypto v0.49.0 // indirect
@@ -199,18 +199,18 @@ require (
199199 golang.org/x/mod v0.33.0 // indirect
200200 golang.org/x/oauth2 v0.36.0 // indirect
201201 golang.org/x/term v0.41.0 // indirect
202− golang.org/x/time v0.14.0 // indirect
202+ golang.org/x/time v0.15.0 // indirect
203203 golang.org/x/tools v0.42.0 // indirect
204− google.golang.org/api v0.269.0 // indirect
205− google.golang.org/genai v1.49.0 // indirect
204+ google.golang.org/api v0.270.0 // indirect
205+ google.golang.org/genai v1.50.0 // indirect
206206 google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect
207− google.golang.org/grpc v1.79.1 // indirect
207+ google.golang.org/grpc v1.79.2 // indirect
208208 google.golang.org/protobuf v1.36.11 // indirect
209209 gopkg.in/dnaeon/go-vcr.v4 v4.0.6-0.20251110073552-01de4eb40290 // indirect
210210 gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect
211211 gopkg.in/warnings.v0 v0.1.2 // indirect
212212 gopkg.in/yaml.v2 v2.4.0 // indirect
213− modernc.org/libc v1.68.0 // indirect
213+ modernc.org/libc v1.70.0 // indirect
214214 modernc.org/mathutil v1.7.1 // indirect
215215 modernc.org/memory v1.11.0 // indirect
216216)
go.sum+66−66
@@ -2,12 +2,12 @@ charm.land/bubbles/v2 v2.0.0 h1:tE3eK/pHjmtrDiRdoC9uGNLgpopOd8fjhEe31B/ai5s=
22charm.land/bubbles/v2 v2.0.0/go.mod h1:rCHoleP2XhU8um45NTuOWBPNVHxnkXKTiZqcclL/qOI=
33charm.land/bubbletea/v2 v2.0.2 h1:4CRtRnuZOdFDTWSff9r8QFt/9+z6Emubz3aDMnf/dx0=
44charm.land/bubbletea/v2 v2.0.2/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ=
5−charm.land/catwalk v0.30.3 h1:eCRwVoi1znrNGYiPZoBIbWt8+Q4kDhT3zziqnPO3s2Y=
6−charm.land/catwalk v0.30.3/go.mod h1:+fqw/6YGNtvapvPy9vhwA/fAMxVjD2K8hVIKYov8Vhg=
5+charm.land/catwalk v0.31.0 h1:ci2LRf5Gy5BgbbQDN7cXEXOeNA2lP1sqVuXdUrAph3w=
6+charm.land/catwalk v0.31.0/go.mod h1:+fqw/6YGNtvapvPy9vhwA/fAMxVjD2K8hVIKYov8Vhg=
77charm.land/fang/v2 v2.0.1 h1:zQCM8JQJ1JnQX/66B5jlCYBUxL2as5JXQZ2KJ6EL0mY=
88charm.land/fang/v2 v2.0.1/go.mod h1:S1GmkpcvK+OB5w9caywUnJcsMew45Ot8FXqoz8ALrII=
9−charm.land/fantasy v0.12.3 h1:gvqRWD7vWmpNN0VcQ+rSku5QdTlLegqrlJDVCDdAh58=
10−charm.land/fantasy v0.12.3/go.mod h1:noWyUtEgUrfsRXqmGbz7NQlZpf6KFwNwjA+jzdS3No8=
9+charm.land/fantasy v0.16.0 h1:vE/6sR9nPcSD8qXJXX6wR8NXjtWlBVAzwQmTh5pHVrs=
10+charm.land/fantasy v0.16.0/go.mod h1:VZjpXVh7IgeiIzGQybEnKzd68ofDsRj94+kzH1ZCAfQ=
1111charm.land/glamour/v2 v2.0.0 h1:IDBoqLEy7Hdpb9VOXN+khLP/XSxtJy1VsHuW/yF87+U=
1212charm.land/glamour/v2 v2.0.0/go.mod h1:kjq9WB0s8vuUYZNYey2jp4Lgd9f4cKdzAw88FZtpj/w=
1313charm.land/lipgloss/v2 v2.0.2 h1:xFolbF8JdpNkM2cEPTfXEcW1p6NRzOWTSamRfYEw8cs=
@@ -56,34 +56,34 @@ github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kk
5656github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
5757github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
5858github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
59−github.com/aws/aws-sdk-go-v2 v1.41.3 h1:4kQ/fa22KjDt13QCy1+bYADvdgcxpfH18f0zP542kZA=
60−github.com/aws/aws-sdk-go-v2 v1.41.3/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
61−github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.5 h1:zWFmPmgw4sveAYi1mRqG+E/g0461cJ5M4bJ8/nc6d3Q=
62−github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.5/go.mod h1:nVUlMLVV8ycXSb7mSkcNu9e3v/1TJq2RTlrPwhYWr5c=
63−github.com/aws/aws-sdk-go-v2/config v1.32.11 h1:ftxI5sgz8jZkckuUHXfC/wMUc8u3fG1vQS0plr2F2Zs=
64−github.com/aws/aws-sdk-go-v2/config v1.32.11/go.mod h1:twF11+6ps9aNRKEDimksp923o44w/Thk9+8YIlzWMmo=
65−github.com/aws/aws-sdk-go-v2/credentials v1.19.11 h1:NdV8cwCcAXrCWyxArt58BrvZJ9pZ9Fhf9w6Uh5W3Uyc=
66−github.com/aws/aws-sdk-go-v2/credentials v1.19.11/go.mod h1:30yY2zqkMPdrvxBqzI9xQCM+WrlrZKSOpSJEsylVU+8=
67−github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.19 h1:INUvJxmhdEbVulJYHI061k4TVuS3jzzthNvjqvVvTKM=
68−github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.19/go.mod h1:FpZN2QISLdEBWkayloda+sZjVJL+e9Gl0k1SyTgcswU=
69−github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.19 h1:/sECfyq2JTifMI2JPyZ4bdRN77zJmr6SrS1eL3augIA=
70−github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.19/go.mod h1:dMf8A5oAqr9/oxOfLkC/c2LU/uMcALP0Rgn2BD5LWn0=
71−github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.19 h1:AWeJMk33GTBf6J20XJe6qZoRSJo0WfUhsMdUKhoODXE=
72−github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.19/go.mod h1:+GWrYoaAsV7/4pNHpwh1kiNLXkKaSoppxQq9lbH8Ejw=
73−github.com/aws/aws-sdk-go-v2/internal/ini v1.8.5 h1:clHU5fm//kWS1C2HgtgWxfQbFbx4b6rx+5jzhgX9HrI=
74−github.com/aws/aws-sdk-go-v2/internal/ini v1.8.5/go.mod h1:O3h0IK87yXci+kg6flUKzJnWeziQUKciKrLjcatSNcY=
75−github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.6 h1:XAq62tBTJP/85lFD5oqOOe7YYgWxY9LvWq8plyDvDVg=
76−github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.6/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI=
77−github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.19 h1:X1Tow7suZk9UCJHE1Iw9GMZJJl0dAnKXXP1NaSDHwmw=
78−github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.19/go.mod h1:/rARO8psX+4sfjUQXp5LLifjUt8DuATZ31WptNJTyQA=
79−github.com/aws/aws-sdk-go-v2/service/signin v1.0.7 h1:Y2cAXlClHsXkkOvWZFXATr34b0hxxloeQu/pAZz2row=
80−github.com/aws/aws-sdk-go-v2/service/signin v1.0.7/go.mod h1:idzZ7gmDeqeNrSPkdbtMp9qWMgcBwykA7P7Rzh5DXVU=
81−github.com/aws/aws-sdk-go-v2/service/sso v1.30.12 h1:iSsvB9EtQ09YrsmIc44Heqlx5ByGErqhPK1ZQLppias=
82−github.com/aws/aws-sdk-go-v2/service/sso v1.30.12/go.mod h1:fEWYKTRGoZNl8tZ77i61/ccwOMJdGxwOhWCkp6TXAr0=
83−github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.16 h1:EnUdUqRP1CNzt2DkV67tJx6XDN4xlfBFm+bzeNOQVb0=
84−github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.16/go.mod h1:Jic/xv0Rq/pFNCh3WwpH4BEqdbSAl+IyHro8LbibHD8=
85−github.com/aws/aws-sdk-go-v2/service/sts v1.41.8 h1:XQTQTF75vnug2TXS8m7CVJfC2nniYPZnO1D4Np761Oo=
86−github.com/aws/aws-sdk-go-v2/service/sts v1.41.8/go.mod h1:Xgx+PR1NUOjNmQY+tRMnouRp83JRM8pRMw/vCaVhPkI=
59+github.com/aws/aws-sdk-go-v2 v1.41.4 h1:10f50G7WyU02T56ox1wWXq+zTX9I1zxG46HYuG1hH/k=
60+github.com/aws/aws-sdk-go-v2 v1.41.4/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
61+github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.6 h1:N4lRUXZpZ1KVEUn6hxtco/1d2lgYhNn1fHkkl8WhlyQ=
62+github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.6/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI=
63+github.com/aws/aws-sdk-go-v2/config v1.32.12 h1:O3csC7HUGn2895eNrLytOJQdoL2xyJy0iYXhoZ1OmP0=
64+github.com/aws/aws-sdk-go-v2/config v1.32.12/go.mod h1:96zTvoOFR4FURjI+/5wY1vc1ABceROO4lWgWJuxgy0g=
65+github.com/aws/aws-sdk-go-v2/credentials v1.19.12 h1:oqtA6v+y5fZg//tcTWahyN9PEn5eDU/Wpvc2+kJ4aY8=
66+github.com/aws/aws-sdk-go-v2/credentials v1.19.12/go.mod h1:U3R1RtSHx6NB0DvEQFGyf/0sbrpJrluENHdPy1j/3TE=
67+github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20 h1:zOgq3uezl5nznfoK3ODuqbhVg1JzAGDUhXOsU0IDCAo=
68+github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20/go.mod h1:z/MVwUARehy6GAg/yQ1GO2IMl0k++cu1ohP9zo887wE=
69+github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20 h1:CNXO7mvgThFGqOFgbNAP2nol2qAWBOGfqR/7tQlvLmc=
70+github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20/go.mod h1:oydPDJKcfMhgfcgBUZaG+toBbwy8yPWubJXBVERtI4o=
71+github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20 h1:tN6W/hg+pkM+tf9XDkWUbDEjGLb+raoBMFsTodcoYKw=
72+github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20/go.mod h1:YJ898MhD067hSHA6xYCx5ts/jEd8BSOLtQDL3iZsvbc=
73+github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 h1:qYQ4pzQ2Oz6WpQ8T3HvGHnZydA72MnLuFK9tJwmrbHw=
74+github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6/go.mod h1:O3h0IK87yXci+kg6flUKzJnWeziQUKciKrLjcatSNcY=
75+github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 h1:5EniKhLZe4xzL7a+fU3C2tfUN4nWIqlLesfrjkuPFTY=
76+github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI=
77+github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20 h1:2HvVAIq+YqgGotK6EkMf+KIEqTISmTYh5zLpYyeTo1Y=
78+github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20/go.mod h1:V4X406Y666khGa8ghKmphma/7C0DAtEQYhkq9z4vpbk=
79+github.com/aws/aws-sdk-go-v2/service/signin v1.0.8 h1:0GFOLzEbOyZABS3PhYfBIx2rNBACYcKty+XGkTgw1ow=
80+github.com/aws/aws-sdk-go-v2/service/signin v1.0.8/go.mod h1:LXypKvk85AROkKhOG6/YEcHFPoX+prKTowKnVdcaIxE=
81+github.com/aws/aws-sdk-go-v2/service/sso v1.30.13 h1:kiIDLZ005EcKomYYITtfsjn7dtOwHDOFy7IbPXKek2o=
82+github.com/aws/aws-sdk-go-v2/service/sso v1.30.13/go.mod h1:2h/xGEowcW/g38g06g3KpRWDlT+OTfxxI0o1KqayAB8=
83+github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.17 h1:jzKAXIlhZhJbnYwHbvUQZEB8KfgAEuG0dc08Bkda7NU=
84+github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.17/go.mod h1:Al9fFsXjv4KfbzQHGe6V4NZSZQXecFcvaIF4e70FoRA=
85+github.com/aws/aws-sdk-go-v2/service/sts v1.41.9 h1:Cng+OOwCHmFljXIxpEVXAGMnBia8MSU6Ch5i9PgBkcU=
86+github.com/aws/aws-sdk-go-v2/service/sts v1.41.9/go.mod h1:LrlIndBDdjA/EeXeyNBle+gyCwTlizzW5ycgWnvIxkk=
8787github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng=
8888github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
8989github.com/aymanbagabas/go-nativeclipboard v0.1.3 h1:FmAWHPTwneAixu7uGDn3cL42xPlUCdNp2J8egMn3P1k=
@@ -106,6 +106,8 @@ github.com/charmbracelet/anthropic-sdk-go v0.0.0-20260223140439-63879b0b8dab h1:
106106github.com/charmbracelet/anthropic-sdk-go v0.0.0-20260223140439-63879b0b8dab/go.mod h1:hqlYqR7uPKOKfnNeicUbZp0Ps0GeYFlKYtwh5HGDCx8=
107107github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
108108github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
109+github.com/charmbracelet/openai-go v0.0.0-20260319145158-d0740cc34266 h1:BW/sZtyd1JyYy0h5adMm3tzpNyL857LWjuTRET6OhpY=
110+github.com/charmbracelet/openai-go v0.0.0-20260319145158-d0740cc34266/go.mod h1:1DahUaExbUZx/jD+FNT2PKP4L9rLE5+ZBRuI8mZjd/E=
109111github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 h1:eyFRbAmexyt43hVfeyBofiGSEmJ7krjLOYt/9CF5NKA=
110112github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8/go.mod h1:SQpCTRNBtzJkwku5ye4S3HEuthAlGy2n9VXZnWkEW98=
111113github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
@@ -229,8 +231,8 @@ github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
229231github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
230232github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
231233github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
232−github.com/googleapis/enterprise-certificate-proxy v0.3.12 h1:Fg+zsqzYEs1ZnvmcztTYxhgCBsx3eEhEwQ1W/lHq/sQ=
233−github.com/googleapis/enterprise-certificate-proxy v0.3.12/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg=
234+github.com/googleapis/enterprise-certificate-proxy v0.3.14 h1:yh8ncqsbUY4shRD5dA6RlzjJaT4hi3kII+zYw8wmLb8=
235+github.com/googleapis/enterprise-certificate-proxy v0.3.14/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg=
234236github.com/googleapis/gax-go/v2 v2.17.0 h1:RksgfBpxqff0EZkDWYuz9q/uWsTVz+kf43LsZ1J6SMc=
235237github.com/googleapis/gax-go/v2 v2.17.0/go.mod h1:mzaqghpQp4JDh3HvADwrat+6M3MOIDp5YKHhb9PAgDY=
236238github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
@@ -319,8 +321,6 @@ github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S
319321github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
320322github.com/nxadm/tail v1.4.11 h1:8feyoE3OzPrcshW5/MJ4sGESc5cqmGkGCWlco4l0bqY=
321323github.com/nxadm/tail v1.4.11/go.mod h1:OTaG3NK980DZzxbRq6lEuzgU+mug70nY11sMd4JXXHc=
322−github.com/openai/openai-go/v3 v3.28.0 h1:2+FfrCVMdGXSQrBv1tLWtokm+BU7+3hJ/8rAHPQ63KM=
323−github.com/openai/openai-go/v3 v3.28.0/go.mod h1:cdufnVK14cWcT9qA1rRtrXx4FTRsgbDPW7Ia7SS5cZo=
324324github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
325325github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
326326github.com/pierrec/lz4/v4 v4.1.25 h1:kocOqRffaIbU5djlIBr7Wh+cx82C0vtFb0fOurZHqD0=
@@ -425,20 +425,20 @@ github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
425425github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
426426go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
427427go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
428−go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.65.0 h1:XmiuHzgJt067+a6kwyAzkhXooYVv3/TOw9cM2VfJgUM=
429−go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.65.0/go.mod h1:KDgtbWKTQs4bM+VPUr6WlL9m/WXcmkCcBlIzqxPGzmI=
430−go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 h1:7iP2uCb7sGddAr30RRS6xjKy7AZ2JtTOPA3oolgVSw8=
431−go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0/go.mod h1:c7hN3ddxs/z6q9xwvfLPk+UHlWRQyaeR1LdgfL/66l0=
432−go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms=
433−go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g=
434−go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g=
435−go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc=
436−go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8=
437−go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE=
438−go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw=
439−go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg=
440−go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw=
441−go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA=
428+go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 h1:yI1/OhfEPy7J9eoa6Sj051C7n5dvpj0QX8g4sRchg04=
429+go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0/go.mod h1:NoUCKYWK+3ecatC4HjkRktREheMeEtrXoQxrqYFeHSc=
430+go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o=
431+go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg=
432+go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho=
433+go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc=
434+go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4=
435+go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI=
436+go.opentelemetry.io/otel/sdk v1.42.0 h1:LyC8+jqk6UJwdrI/8VydAq/hvkFKNHZVIWuslJXYsDo=
437+go.opentelemetry.io/otel/sdk v1.42.0/go.mod h1:rGHCAxd9DAph0joO4W6OPwxjNTYWghRWmkHuGbayMts=
438+go.opentelemetry.io/otel/sdk/metric v1.42.0 h1:D/1QR46Clz6ajyZ3G8SgNlTJKBdGp84q9RKCAZ3YGuA=
439+go.opentelemetry.io/otel/sdk/metric v1.42.0/go.mod h1:Ua6AAlDKdZ7tdvaQKfSmnFTdHx37+J4ba8MwVCYM5hc=
440+go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY=
441+go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc=
442442go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
443443go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
444444go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
@@ -533,8 +533,8 @@ golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
533533golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
534534golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
535535golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
536−golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
537−golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
536+golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
537+golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
538538golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
539539golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
540540golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
@@ -546,14 +546,14 @@ golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0
546546golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
547547gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
548548gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
549−google.golang.org/api v0.269.0 h1:qDrTOxKUQ/P0MveH6a7vZ+DNHxJQjtGm/uvdbdGXCQg=
550−google.golang.org/api v0.269.0/go.mod h1:N8Wpcu23Tlccl0zSHEkcAZQKDLdquxK+l9r2LkwAauE=
551−google.golang.org/genai v1.49.0 h1:Se+QJaH2GYK1aaR1o5S38mlU2GD5FnVvP76nfkV7LH0=
552−google.golang.org/genai v1.49.0/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk=
549+google.golang.org/api v0.270.0 h1:4rJZbIuWSTohczG9mG2ukSDdt9qKx4sSSHIydTN26L4=
550+google.golang.org/api v0.270.0/go.mod h1:5+H3/8DlXpQWrSz4RjGGwz5HfJAQSEI8Bc6JqQNH77U=
551+google.golang.org/genai v1.50.0 h1:yHKV/vjoeN9PJ3iF0ur4cBZco4N3Kl7j09rMq7XSoWk=
552+google.golang.org/genai v1.50.0/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk=
553553google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ=
554554google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
555−google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
556−google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
555+google.golang.org/grpc v1.79.2 h1:fRMD94s2tITpyJGtBBn7MkMseNpOZU8ZxgC3MMBaXRU=
556+google.golang.org/grpc v1.79.2/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
557557google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
558558google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
559559gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@@ -579,18 +579,18 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
579579gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
580580modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
581581modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
582−modernc.org/ccgo/v4 v4.30.2 h1:4yPaaq9dXYXZ2V8s1UgrC3KIj580l2N4ClrLwnbv2so=
583−modernc.org/ccgo/v4 v4.30.2/go.mod h1:yZMnhWEdW0qw3EtCndG1+ldRrVGS+bIwyWmAWzS0XEw=
584−modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
585−modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
582+modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw=
583+modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0=
584+modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
585+modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
586586modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
587587modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
588588modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
589589modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
590590modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
591591modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
592−modernc.org/libc v1.68.0 h1:PJ5ikFOV5pwpW+VqCK1hKJuEWsonkIJhhIXyuF/91pQ=
593−modernc.org/libc v1.68.0/go.mod h1:NnKCYeoYgsEqnY3PgvNgAeaJnso968ygU8Z0DxjoEc0=
592+modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw=
593+modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo=
594594modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
595595modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
596596modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
@@ -599,8 +599,8 @@ modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
599599modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
600600modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
601601modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
602−modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU=
603−modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
602+modernc.org/sqlite v1.47.0 h1:R1XyaNpoW4Et9yly+I2EeX7pBza/w+pmYee/0HJDyKk=
603+modernc.org/sqlite v1.47.0/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=
604604modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
605605modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
606606modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
internal/agent/agent.go+3
@@ -266,6 +266,9 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy
266266 prepared.Messages[i].ProviderOptions = nil
267267 }
268268
269+ // Use latest tools (updated by SetTools when MCP tools change).
270+ prepared.Tools = a.tools.Copy()
271+
269272 queuedCalls, _ := a.messageQueue.Get(call.SessionID)
270273 a.messageQueue.Del(call.SessionID)
271274 for _, queued := range queuedCalls {
internal/agent/coordinator.go+1−1
@@ -41,7 +41,7 @@ import (
4141 "charm.land/fantasy/providers/openaicompat"
4242 "charm.land/fantasy/providers/openrouter"
4343 "charm.land/fantasy/providers/vercel"
44− openaisdk "github.com/openai/openai-go/v3/option"
44+ openaisdk "github.com/charmbracelet/openai-go/option"
4545 "github.com/qjebbs/go-jsons"
4646)
4747
internal/agent/hyper/provider.json+1−1
@@ -1 +1 @@
1−{"name":"Charm Hyper","id":"hyper","api_endpoint":"https://hyper.charm.land/api/v1/fantasy","type":"hyper","default_large_model_id":"claude-opus-4-5","default_small_model_id":"claude-haiku-4-5","models":[{"id":"claude-haiku-4-5","name":"Claude Haiku 4.5","cost_per_1m_in":1,"cost_per_1m_out":5,"cost_per_1m_in_cached":1.25,"cost_per_1m_out_cached":0.09999999999999999,"context_window":200000,"default_max_tokens":32000,"can_reason":true,"supports_attachments":true,"options":{}},{"id":"claude-opus-4-5","name":"Claude Opus 4.5","cost_per_1m_in":5,"cost_per_1m_out":25,"cost_per_1m_in_cached":6.25,"cost_per_1m_out_cached":0.5,"context_window":200000,"default_max_tokens":50000,"can_reason":true,"supports_attachments":true,"options":{}},{"id":"claude-opus-4-6","name":"Claude Opus 4.6","cost_per_1m_in":5,"cost_per_1m_out":25,"cost_per_1m_in_cached":6.25,"cost_per_1m_out_cached":0.5,"context_window":200000,"default_max_tokens":126000,"can_reason":true,"reasoning_levels":["low","medium","high","max"],"supports_attachments":true,"options":{}},{"id":"claude-sonnet-4-5","name":"Claude Sonnet 4.5","cost_per_1m_in":3,"cost_per_1m_out":15,"cost_per_1m_in_cached":3.75,"cost_per_1m_out_cached":0.3,"context_window":200000,"default_max_tokens":50000,"can_reason":true,"supports_attachments":true,"options":{}},{"id":"gemini-3-flash","name":"Gemini 3 Flash","cost_per_1m_in":0.5,"cost_per_1m_out":3,"cost_per_1m_in_cached":0.049999999999999996,"cost_per_1m_out_cached":0,"context_window":1000000,"default_max_tokens":8000,"can_reason":true,"reasoning_levels":["low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"gemini-3-pro-preview","name":"Gemini 3 Pro","cost_per_1m_in":2,"cost_per_1m_out":12,"cost_per_1m_in_cached":0.19999999999999998,"cost_per_1m_out_cached":0,"context_window":1000000,"default_max_tokens":8000,"can_reason":true,"reasoning_levels":["low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"gemini-3.1-pro-preview","name":"Gemini 3.1 Pro","cost_per_1m_in":2,"cost_per_1m_out":12,"cost_per_1m_in_cached":0.19999999999999998,"cost_per_1m_out_cached":0,"context_window":1000000,"default_max_tokens":8000,"can_reason":true,"reasoning_levels":["low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"glm-4.7","name":"GLM-4.7","cost_per_1m_in":0.43,"cost_per_1m_out":1.75,"cost_per_1m_in_cached":0.08,"cost_per_1m_out_cached":0,"context_window":202752,"default_max_tokens":8000,"can_reason":true,"reasoning_levels":["low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":false,"options":{}},{"id":"glm-4.7-flashx","name":"GLM-4.7 Flash","cost_per_1m_in":0.06,"cost_per_1m_out":0.39999999999999997,"cost_per_1m_in_cached":0.01,"cost_per_1m_out_cached":0,"context_window":200000,"default_max_tokens":8000,"can_reason":true,"reasoning_levels":["low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":false,"options":{}},{"id":"glm-5","name":"GLM-5","cost_per_1m_in":1,"cost_per_1m_out":3.1999999999999997,"cost_per_1m_in_cached":0.19999999999999998,"cost_per_1m_out_cached":0,"context_window":202800,"default_max_tokens":8000,"can_reason":true,"reasoning_levels":["low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":false,"options":{}},{"id":"gpt-5.1-codex","name":"GPT-5.1 Codex","cost_per_1m_in":1.25,"cost_per_1m_out":10,"cost_per_1m_in_cached":0.125,"cost_per_1m_out_cached":0.125,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["minimal","low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"gpt-5.1-codex-max","name":"GPT-5.1 Codex Max","cost_per_1m_in":1.25,"cost_per_1m_out":10,"cost_per_1m_in_cached":0.125,"cost_per_1m_out_cached":0.125,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["minimal","low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"gpt-5.1-codex-mini","name":"GPT-5.1 Codex Mini","cost_per_1m_in":0.25,"cost_per_1m_out":2,"cost_per_1m_in_cached":0.025,"cost_per_1m_out_cached":0.025,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"gpt-5.2","name":"GPT-5.2","cost_per_1m_in":1.75,"cost_per_1m_out":14,"cost_per_1m_in_cached":0.175,"cost_per_1m_out_cached":0.175,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["minimal","low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"gpt-5.2-codex","name":"GPT-5.2 Codex","cost_per_1m_in":1.75,"cost_per_1m_out":14,"cost_per_1m_in_cached":0.175,"cost_per_1m_out_cached":0.175,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["minimal","low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"gpt-5.3-codex","name":"GPT-5.3 Codex","cost_per_1m_in":1.75,"cost_per_1m_out":14,"cost_per_1m_in_cached":0.175,"cost_per_1m_out_cached":0.175,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["minimal","low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"grok-4.1-fast-non-reasoning","name":"Grok 4.1 Fast Non Reasoning","cost_per_1m_in":0.2,"cost_per_1m_out":0.5,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":0.05,"context_window":2000000,"default_max_tokens":200000,"can_reason":false,"supports_attachments":true,"options":{}},{"id":"grok-4.1-fast-reasoning","name":"Grok 4.1 Fast Reasoning","cost_per_1m_in":0.2,"cost_per_1m_out":0.5,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":0.05,"context_window":2000000,"default_max_tokens":200000,"can_reason":true,"supports_attachments":true,"options":{}},{"id":"grok-code-fast-1","name":"Grok Code Fast","cost_per_1m_in":0.2,"cost_per_1m_out":1.5,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":0.02,"context_window":256000,"default_max_tokens":20000,"can_reason":true,"supports_attachments":false,"options":{}},{"id":"kimi-k2-0905","name":"Kimi K2","cost_per_1m_in":0.548,"cost_per_1m_out":2.192,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":0,"context_window":262144,"default_max_tokens":26214,"can_reason":false,"supports_attachments":false,"options":{}},{"id":"kimi-k2.5","name":"Kimi K2.5","cost_per_1m_in":0.5,"cost_per_1m_out":2.8,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":0,"context_window":256000,"default_max_tokens":8000,"can_reason":true,"reasoning_levels":["low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}}]}
1+{"name":"Charm Hyper","id":"hyper","api_endpoint":"https://hyper.charm.land/api/v1/fantasy","type":"hyper","default_large_model_id":"claude-opus-4-5","default_small_model_id":"claude-haiku-4-5","models":[{"id":"claude-haiku-4-5","name":"Claude Haiku 4.5","cost_per_1m_in":1,"cost_per_1m_out":5,"cost_per_1m_in_cached":1.25,"cost_per_1m_out_cached":0.1,"context_window":200000,"default_max_tokens":64000,"can_reason":true,"supports_attachments":true,"options":{}},{"id":"claude-opus-4-5","name":"Claude Opus 4.5","cost_per_1m_in":5,"cost_per_1m_out":25,"cost_per_1m_in_cached":6.25,"cost_per_1m_out_cached":0.5,"context_window":200000,"default_max_tokens":64000,"can_reason":true,"supports_attachments":true,"options":{}},{"id":"claude-opus-4-6","name":"Claude Opus 4.6","cost_per_1m_in":5,"cost_per_1m_out":25,"cost_per_1m_in_cached":6.25,"cost_per_1m_out_cached":0.5,"context_window":1000000,"default_max_tokens":126000,"can_reason":true,"reasoning_levels":["low","medium","high","max"],"supports_attachments":true,"options":{}},{"id":"claude-sonnet-4-5","name":"Claude Sonnet 4.5","cost_per_1m_in":3,"cost_per_1m_out":15,"cost_per_1m_in_cached":3.75,"cost_per_1m_out_cached":0.3,"context_window":200000,"default_max_tokens":64000,"can_reason":true,"supports_attachments":true,"options":{}},{"id":"claude-sonnet-4-6","name":"Claude Sonnet 4.6","cost_per_1m_in":3,"cost_per_1m_out":15,"cost_per_1m_in_cached":3.75,"cost_per_1m_out_cached":0.3,"context_window":1000000,"default_max_tokens":64000,"can_reason":true,"reasoning_levels":["low","medium","high","max"],"supports_attachments":true,"options":{}},{"id":"gemini-3-flash","name":"Gemini 3 Flash","cost_per_1m_in":0.5,"cost_per_1m_out":3,"cost_per_1m_in_cached":0.05,"cost_per_1m_out_cached":0,"context_window":1000000,"default_max_tokens":8000,"can_reason":true,"reasoning_levels":["low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"gemini-3-pro-preview","name":"Gemini 3 Pro","cost_per_1m_in":2,"cost_per_1m_out":12,"cost_per_1m_in_cached":0.2,"cost_per_1m_out_cached":0,"context_window":1000000,"default_max_tokens":8000,"can_reason":true,"reasoning_levels":["low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"gemini-3.1-pro-preview","name":"Gemini 3.1 Pro","cost_per_1m_in":2,"cost_per_1m_out":12,"cost_per_1m_in_cached":0.2,"cost_per_1m_out_cached":0,"context_window":1000000,"default_max_tokens":8000,"can_reason":true,"reasoning_levels":["low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"glm-4.7","name":"GLM-4.7","cost_per_1m_in":0.6,"cost_per_1m_out":2.2,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":0,"context_window":200000,"default_max_tokens":8000,"can_reason":true,"reasoning_levels":["low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":false,"options":{}},{"id":"glm-4.7-flashx","name":"GLM-4.7 Flash","cost_per_1m_in":0.06,"cost_per_1m_out":0.4,"cost_per_1m_in_cached":0.01,"cost_per_1m_out_cached":0,"context_window":200000,"default_max_tokens":8000,"can_reason":true,"reasoning_levels":["low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":false,"options":{}},{"id":"glm-5","name":"GLM-5","cost_per_1m_in":1,"cost_per_1m_out":3.2,"cost_per_1m_in_cached":0.2,"cost_per_1m_out_cached":0,"context_window":202800,"default_max_tokens":8000,"can_reason":true,"reasoning_levels":["low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":false,"options":{}},{"id":"gpt-5.1-codex","name":"GPT-5.1 Codex","cost_per_1m_in":1.25,"cost_per_1m_out":10,"cost_per_1m_in_cached":0.125,"cost_per_1m_out_cached":0.125,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["minimal","low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"gpt-5.1-codex-max","name":"GPT-5.1 Codex Max","cost_per_1m_in":1.25,"cost_per_1m_out":10,"cost_per_1m_in_cached":0.125,"cost_per_1m_out_cached":0.125,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["minimal","low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"gpt-5.1-codex-mini","name":"GPT-5.1 Codex Mini","cost_per_1m_in":0.25,"cost_per_1m_out":2,"cost_per_1m_in_cached":0.025,"cost_per_1m_out_cached":0.025,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"gpt-5.2","name":"GPT-5.2","cost_per_1m_in":1.75,"cost_per_1m_out":14,"cost_per_1m_in_cached":0.175,"cost_per_1m_out_cached":0.175,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["minimal","low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"gpt-5.2-codex","name":"GPT-5.2 Codex","cost_per_1m_in":1.75,"cost_per_1m_out":14,"cost_per_1m_in_cached":0.175,"cost_per_1m_out_cached":0.175,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["minimal","low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"gpt-5.3-codex","name":"GPT-5.3 Codex","cost_per_1m_in":1.75,"cost_per_1m_out":14,"cost_per_1m_in_cached":0.175,"cost_per_1m_out_cached":0.175,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["minimal","low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"gpt-5.4","name":"GPT-5.4","cost_per_1m_in":2.5,"cost_per_1m_out":15,"cost_per_1m_in_cached":0.25,"cost_per_1m_out_cached":0,"context_window":1050000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["low","medium","high","xhigh"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"gpt-5.4-pro","name":"GPT-5.4 Pro","cost_per_1m_in":30,"cost_per_1m_out":180,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":0,"context_window":1050000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["medium","high","xhigh"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"grok-4.1-fast-non-reasoning","name":"Grok 4.1 Fast Non Reasoning","cost_per_1m_in":0.2,"cost_per_1m_out":0.5,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":0.05,"context_window":2000000,"default_max_tokens":200000,"can_reason":false,"supports_attachments":true,"options":{}},{"id":"grok-4.1-fast-reasoning","name":"Grok 4.1 Fast Reasoning","cost_per_1m_in":0.2,"cost_per_1m_out":0.5,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":0.05,"context_window":2000000,"default_max_tokens":200000,"can_reason":true,"supports_attachments":true,"options":{}},{"id":"grok-code-fast-1","name":"Grok Code Fast","cost_per_1m_in":0.2,"cost_per_1m_out":1.5,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":0.02,"context_window":256000,"default_max_tokens":20000,"can_reason":true,"supports_attachments":false,"options":{}},{"id":"kimi-k2-0905","name":"Kimi K2","cost_per_1m_in":0.6,"cost_per_1m_out":2.5,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":0,"context_window":256000,"default_max_tokens":8000,"can_reason":false,"supports_attachments":false,"options":{}},{"id":"kimi-k2.5","name":"Kimi K2.5","cost_per_1m_in":0.6,"cost_per_1m_out":3,"cost_per_1m_in_cached":0.1,"cost_per_1m_out_cached":0,"context_window":262114,"default_max_tokens":8000,"can_reason":true,"reasoning_levels":["low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}}]}
internal/agent/tools/mcp-tools.go+31−17
@@ -3,6 +3,7 @@ package tools
33import (
44 "context"
55 "fmt"
6+ "slices"
67
78 "charm.land/fantasy"
89 "github.com/charmbracelet/crush/internal/agent/tools/mcp"
@@ -10,6 +11,15 @@ import (
1011 "github.com/charmbracelet/crush/internal/permission"
1112)
1213
14+// whitelistDockerTools contains Docker MCP tools that don't require permission.
15+var whitelistDockerTools = []string{
16+ "mcp_docker_mcp-find",
17+ "mcp_docker_mcp-add",
18+ "mcp_docker_mcp-remove",
19+ "mcp_docker_mcp-config-set",
20+ "mcp_docker_code-mode",
21+}
22+
1323// GetMCPTools gets all the currently available MCP tools.
1424func GetMCPTools(permissions permission.Service, cfg *config.ConfigStore, wd string) []*Tool {
1525 var result []*Tool
@@ -91,23 +101,27 @@ func (m *Tool) Run(ctx context.Context, params fantasy.ToolCall) (fantasy.ToolRe
91101 if sessionID == "" {
92102 return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for creating a new file")
93103 }
94− permissionDescription := fmt.Sprintf("execute %s with the following parameters:", m.Info().Name)
95− p, err := m.permissions.Request(ctx,
96− permission.CreatePermissionRequest{
97− SessionID: sessionID,
98− ToolCallID: params.ID,
99− Path: m.workingDir,
100− ToolName: m.Info().Name,
101− Action: "execute",
102− Description: permissionDescription,
103− Params: params.Input,
104− },
105− )
106− if err != nil {
107− return fantasy.ToolResponse{}, err
108− }
109− if !p {
110− return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
104+
105+ // Skip permission for whitelisted Docker MCP tools.
106+ if !slices.Contains(whitelistDockerTools, params.Name) {
107+ permissionDescription := fmt.Sprintf("execute %s with the following parameters:", m.Info().Name)
108+ p, err := m.permissions.Request(ctx,
109+ permission.CreatePermissionRequest{
110+ SessionID: sessionID,
111+ ToolCallID: params.ID,
112+ Path: m.workingDir,
113+ ToolName: m.Info().Name,
114+ Action: "execute",
115+ Description: permissionDescription,
116+ Params: params.Input,
117+ },
118+ )
119+ if err != nil {
120+ return fantasy.ToolResponse{}, err
121+ }
122+ if !p {
123+ return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
124+ }
111125 }
112126
113127 result, err := mcp.RunTool(ctx, m.cfg, m.mcpName, m.tool.Name, params.Input)
internal/agent/tools/mcp/init.go+81−41
@@ -175,8 +175,6 @@ func Initialize(ctx context.Context, permissions permission.Service, cfg *config
175175 }
176176
177177 // Set initial starting state
178− updateState(name, StateStarting, nil, nil, Counts{})
179−
180178 wg.Add(1)
181179 go func(name string, m config.MCPConfig) {
182180 defer func() {
@@ -196,46 +194,9 @@ func Initialize(ctx context.Context, permissions permission.Service, cfg *config
196194 }
197195 }()
198196
199− // createSession handles its own timeout internally.
200− session, err := createSession(ctx, name, m, cfg.Resolver())
201− if err != nil {
202− return
203− }
204−
205− tools, err := getTools(ctx, session)
206− if err != nil {
207− slog.Error("Error listing tools", "error", err)
208− updateState(name, StateError, err, nil, Counts{})
209− session.Close()
210− return
211− }
212−
213− prompts, err := getPrompts(ctx, session)
214− if err != nil {
215− slog.Error("Error listing prompts", "error", err)
216− updateState(name, StateError, err, nil, Counts{})
217− session.Close()
218− return
219− }
220−
221− resources, err := getResources(ctx, session)
222− if err != nil {
223− slog.Error("Error listing resources", "error", err)
224− updateState(name, StateError, err, nil, Counts{})
225− session.Close()
226− return
197+ if err := initClient(ctx, cfg, name, m, cfg.Resolver()); err != nil {
198+ slog.Debug("failed to initialize mcp client", "name", name, "error", err)
227199 }
228−
229− toolCount := updateTools(cfg, name, tools)
230− updatePrompts(name, prompts)
231− resourceCount := updateResources(name, resources)
232− sessions.Set(name, session)
233−
234− updateState(name, StateConnected, nil, session, Counts{
235− Tools: toolCount,
236− Prompts: len(prompts),
237− Resources: resourceCount,
238− })
239200 }(name, m)
240201 }
241202 wg.Wait()
@@ -253,6 +214,85 @@ func WaitForInit(ctx context.Context) error {
253214 }
254215}
255216
217+// InitializeSingle initializes a single MCP client by name.
218+func InitializeSingle(ctx context.Context, name string, cfg *config.ConfigStore) error {
219+ m, exists := cfg.Config().MCP[name]
220+ if !exists {
221+ return fmt.Errorf("mcp '%s' not found in configuration", name)
222+ }
223+
224+ if m.Disabled {
225+ updateState(name, StateDisabled, nil, nil, Counts{})
226+ slog.Debug("skipping disabled mcp", "name", name)
227+ return nil
228+ }
229+
230+ return initClient(ctx, cfg, name, m, cfg.Resolver())
231+}
232+
233+// initClient initializes a single MCP client with the given configuration.
234+func initClient(ctx context.Context, cfg *config.ConfigStore, name string, m config.MCPConfig, resolver config.VariableResolver) error {
235+ // Set initial starting state.
236+ updateState(name, StateStarting, nil, nil, Counts{})
237+
238+ // createSession handles its own timeout internally.
239+ session, err := createSession(ctx, name, m, resolver)
240+ if err != nil {
241+ return err
242+ }
243+
244+ tools, err := getTools(ctx, session)
245+ if err != nil {
246+ slog.Error("Error listing tools", "error", err)
247+ updateState(name, StateError, err, nil, Counts{})
248+ session.Close()
249+ return err
250+ }
251+
252+ prompts, err := getPrompts(ctx, session)
253+ if err != nil {
254+ slog.Error("Error listing prompts", "error", err)
255+ updateState(name, StateError, err, nil, Counts{})
256+ session.Close()
257+ return err
258+ }
259+
260+ toolCount := updateTools(cfg, name, tools)
261+ updatePrompts(name, prompts)
262+ sessions.Set(name, session)
263+
264+ updateState(name, StateConnected, nil, session, Counts{
265+ Tools: toolCount,
266+ Prompts: len(prompts),
267+ })
268+
269+ return nil
270+}
271+
272+// DisableSingle disables and closes a single MCP client by name.
273+func DisableSingle(cfg *config.ConfigStore, name string) error {
274+ session, ok := sessions.Get(name)
275+ if ok {
276+ if err := session.Close(); err != nil &&
277+ !errors.Is(err, io.EOF) &&
278+ !errors.Is(err, context.Canceled) &&
279+ err.Error() != "signal: killed" {
280+ slog.Warn("error closing mcp session", "name", name, "error", err)
281+ }
282+ sessions.Del(name)
283+ }
284+
285+ // Clear tools and prompts for this MCP.
286+ updateTools(cfg, name, nil)
287+ updatePrompts(name, nil)
288+
289+ // Update state to disabled.
290+ updateState(name, StateDisabled, nil, nil, Counts{})
291+
292+ slog.Info("Disabled mcp client", "name", name)
293+ return nil
294+}
295+
256296func getOrRenewClient(ctx context.Context, cfg *config.ConfigStore, name string) (*ClientSession, error) {
257297 sess, ok := sessions.Get(name)
258298 if !ok {
internal/agent/tools/mcp/tools.go+73−3
@@ -2,6 +2,7 @@ package mcp
22
33import (
44 "context"
5+ "encoding/base64"
56 "encoding/json"
67 "fmt"
78 "iter"
@@ -81,12 +82,13 @@ func RunTool(ctx context.Context, cfg *config.ConfigStore, name, toolName string
8182
8283 textContent := strings.Join(textParts, "\n")
8384
84− // MCP SDK returns Data as already base64-encoded, so we use it directly.
85+ // We need to make sure the data is base64
86+ // when using something like docker + playwright the data was not returned correctly.
8587 if imageData != nil {
8688 return ToolResult{
8789 Type: "image",
8890 Content: textContent,
89− Data: imageData,
91+ Data: ensureBase64(imageData),
9092 MediaType: imageMimeType,
9193 }, nil
9294 }
@@ -95,7 +97,7 @@ func RunTool(ctx context.Context, cfg *config.ConfigStore, name, toolName string
9597 return ToolResult{
9698 Type: "media",
9799 Content: textContent,
98− Data: audioData,
100+ Data: ensureBase64(audioData),
99101 MediaType: audioMimeType,
100102 }, nil
101103 }
@@ -164,3 +166,71 @@ func filterDisabledTools(cfg *config.ConfigStore, mcpName string, tools []*Tool)
164166 }
165167 return filtered
166168}
169+
170+// ensureBase64 normalizes valid base64 input and guarantees padded
171+// base64.StdEncoding output; otherwise it encodes raw binary data.
172+func ensureBase64(data []byte) []byte {
173+ if len(data) == 0 {
174+ return data
175+ }
176+
177+ normalized := normalizeBase64Input(data)
178+ if decoded, ok := decodeBase64(normalized); ok {
179+ encoded := make([]byte, base64.StdEncoding.EncodedLen(len(decoded)))
180+ base64.StdEncoding.Encode(encoded, decoded)
181+ return encoded
182+ }
183+
184+ encoded := make([]byte, base64.StdEncoding.EncodedLen(len(data)))
185+ base64.StdEncoding.Encode(encoded, data)
186+ return encoded
187+}
188+
189+func normalizeBase64Input(data []byte) []byte {
190+ normalized := strings.Join(strings.Fields(string(data)), "")
191+ return []byte(normalized)
192+}
193+
194+func decodeBase64(data []byte) ([]byte, bool) {
195+ if len(data) == 0 {
196+ return data, true
197+ }
198+
199+ for _, b := range data {
200+ if b > 127 {
201+ return nil, false
202+ }
203+ }
204+
205+ s := string(data)
206+ decoded, err := base64.StdEncoding.DecodeString(s)
207+ if err == nil {
208+ return decoded, true
209+ }
210+ decoded, err = base64.RawStdEncoding.DecodeString(s)
211+ if err == nil {
212+ return decoded, true
213+ }
214+ return nil, false
215+}
216+
217+// isValidBase64 checks if the data appears to be valid base64-encoded content.
218+func isValidBase64(data []byte) bool {
219+ if len(data) == 0 {
220+ return true
221+ }
222+
223+ // Base64 strings should only contain ASCII characters.
224+ for _, b := range data {
225+ if b > 127 {
226+ return false
227+ }
228+ }
229+
230+ s := string(data)
231+ if _, err := base64.StdEncoding.DecodeString(s); err == nil {
232+ return true
233+ }
234+ _, err := base64.RawStdEncoding.DecodeString(s)
235+ return err == nil
236+}
internal/agent/tools/mcp/tools_test.gonew file+125
@@ -0,0 +1,125 @@
1+package mcp
2+
3+import (
4+ "encoding/base64"
5+ "testing"
6+
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestEnsureBase64(t *testing.T) {
11+ t.Parallel()
12+
13+ tests := []struct {
14+ name string
15+ input []byte
16+ wantData []byte // expected output
17+ }{
18+ {
19+ name: "already base64 encoded",
20+ input: []byte("SGVsbG8gV29ybGQh"), // "Hello World!" in base64
21+ wantData: []byte("SGVsbG8gV29ybGQh"),
22+ },
23+ {
24+ name: "raw binary data (PNG header)",
25+ input: []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A},
26+ wantData: []byte(base64.StdEncoding.EncodeToString([]byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A})),
27+ },
28+ {
29+ name: "raw binary with high bytes",
30+ input: []byte{0xFF, 0xD8, 0xFF, 0xE0}, // JPEG header
31+ wantData: []byte(base64.StdEncoding.EncodeToString([]byte{0xFF, 0xD8, 0xFF, 0xE0})),
32+ },
33+ {
34+ name: "empty data",
35+ input: []byte{},
36+ wantData: []byte{},
37+ },
38+ {
39+ name: "base64 with padding",
40+ input: []byte("YQ=="), // "a" in base64
41+ wantData: []byte("YQ=="),
42+ },
43+ {
44+ name: "base64 without padding",
45+ input: []byte("YQ"),
46+ wantData: []byte("YQ=="),
47+ },
48+ {
49+ name: "base64 with whitespace",
50+ input: []byte("U0dWc2JHOGdWMjl5YkdRaA==\n"),
51+ wantData: []byte("U0dWc2JHOGdWMjl5YkdRaA=="),
52+ },
53+ }
54+
55+ for _, tt := range tests {
56+ t.Run(tt.name, func(t *testing.T) {
57+ t.Parallel()
58+ result := ensureBase64(tt.input)
59+ require.Equal(t, tt.wantData, result)
60+
61+ // Verify the result is valid base64 that can be decoded.
62+ if len(result) > 0 {
63+ _, err := base64.StdEncoding.DecodeString(string(result))
64+ if err != nil {
65+ _, err = base64.RawStdEncoding.DecodeString(string(result))
66+ }
67+ require.NoError(t, err, "result should be valid base64")
68+ }
69+ })
70+ }
71+}
72+
73+func TestIsValidBase64(t *testing.T) {
74+ t.Parallel()
75+
76+ tests := []struct {
77+ name string
78+ input []byte
79+ want bool
80+ }{
81+ {
82+ name: "valid base64",
83+ input: []byte("SGVsbG8gV29ybGQh"),
84+ want: true,
85+ },
86+ {
87+ name: "valid base64 with padding",
88+ input: []byte("YQ=="),
89+ want: true,
90+ },
91+ {
92+ name: "raw binary with high bytes",
93+ input: []byte{0xFF, 0xD8, 0xFF},
94+ want: false,
95+ },
96+ {
97+ name: "empty",
98+ input: []byte{},
99+ want: true,
100+ },
101+ {
102+ name: "valid raw base64 without padding",
103+ input: []byte("YQ"),
104+ want: true,
105+ },
106+ {
107+ name: "valid base64 with whitespace",
108+ input: normalizeBase64Input([]byte("U0dWc2JHOGdWMjl5YkdRaA==\n")),
109+ want: true,
110+ },
111+ {
112+ name: "invalid base64 characters",
113+ input: []byte("SGVsbG8!@#$"),
114+ want: false,
115+ },
116+ }
117+
118+ for _, tt := range tests {
119+ t.Run(tt.name, func(t *testing.T) {
120+ t.Parallel()
121+ got := isValidBase64(tt.input)
122+ require.Equal(t, tt.want, got)
123+ })
124+ }
125+}
internal/backend/config.go+49
@@ -2,6 +2,8 @@ package backend
22
33import (
44 "context"
5+ "errors"
6+ "fmt"
57
68 "github.com/charmbracelet/crush/internal/agent"
79 mcptools "github.com/charmbracelet/crush/internal/agent/tools/mcp"
@@ -114,6 +116,53 @@ func (b *Backend) InitializePrompt(workspaceID string) (string, error) {
114116 return agent.InitializePrompt(ws.Cfg)
115117}
116118
119+// EnableDockerMCP validates Docker MCP availability, stages the
120+// configuration, starts the MCP client, and persists the config.
121+func (b *Backend) EnableDockerMCP(ctx context.Context, workspaceID string) error {
122+ ws, err := b.GetWorkspace(workspaceID)
123+ if err != nil {
124+ return err
125+ }
126+
127+ mcpConfig, err := ws.Cfg.PrepareDockerMCPConfig()
128+ if err != nil {
129+ return err
130+ }
131+
132+ if err := mcptools.InitializeSingle(ctx, config.DockerMCPName, ws.Cfg); err != nil {
133+ disableErr := mcptools.DisableSingle(ws.Cfg, config.DockerMCPName)
134+ delete(ws.Cfg.Config().MCP, config.DockerMCPName)
135+ return fmt.Errorf("failed to start docker MCP: %w", errors.Join(err, disableErr))
136+ }
137+
138+ if err := ws.Cfg.PersistDockerMCPConfig(mcpConfig); err != nil {
139+ disableErr := mcptools.DisableSingle(ws.Cfg, config.DockerMCPName)
140+ delete(ws.Cfg.Config().MCP, config.DockerMCPName)
141+ return fmt.Errorf("docker MCP started but failed to persist configuration: %w", errors.Join(err, disableErr))
142+ }
143+
144+ return nil
145+}
146+
147+// DisableDockerMCP closes the Docker MCP client, removes the
148+// configuration, and persists the change.
149+func (b *Backend) DisableDockerMCP(workspaceID string) error {
150+ ws, err := b.GetWorkspace(workspaceID)
151+ if err != nil {
152+ return err
153+ }
154+
155+ if err := mcptools.DisableSingle(ws.Cfg, config.DockerMCPName); err != nil {
156+ return fmt.Errorf("failed to disable docker MCP: %w", err)
157+ }
158+
159+ if err := ws.Cfg.DisableDockerMCP(); err != nil {
160+ return err
161+ }
162+
163+ return nil
164+}
165+
117166// RefreshMCPTools refreshes the tools for a named MCP server.
118167func (b *Backend) RefreshMCPTools(ctx context.Context, workspaceID, name string) error {
119168 ws, err := b.GetWorkspace(workspaceID)
internal/client/config.go+26
@@ -193,6 +193,32 @@ type MCPResourceContents struct {
193193 Blob []byte `json:"blob,omitempty"`
194194}
195195
196+// EnableDockerMCP enables the Docker MCP server on the workspace.
197+func (c *Client) EnableDockerMCP(ctx context.Context, id string) error {
198+ rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/mcp/docker/enable", id), nil, nil, nil)
199+ if err != nil {
200+ return fmt.Errorf("failed to enable docker MCP: %w", err)
201+ }
202+ defer rsp.Body.Close()
203+ if rsp.StatusCode != http.StatusOK {
204+ return fmt.Errorf("failed to enable docker MCP: status code %d", rsp.StatusCode)
205+ }
206+ return nil
207+}
208+
209+// DisableDockerMCP disables the Docker MCP server on the workspace.
210+func (c *Client) DisableDockerMCP(ctx context.Context, id string) error {
211+ rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/mcp/docker/disable", id), nil, nil, nil)
212+ if err != nil {
213+ return fmt.Errorf("failed to disable docker MCP: %w", err)
214+ }
215+ defer rsp.Body.Close()
216+ if rsp.StatusCode != http.StatusOK {
217+ return fmt.Errorf("failed to disable docker MCP: status code %d", rsp.StatusCode)
218+ }
219+ return nil
220+}
221+
196222// RefreshMCPTools refreshes tools for a named MCP server.
197223func (c *Client) RefreshMCPTools(ctx context.Context, id, name string) error {
198224 rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/mcp/refresh-tools", id), nil, jsonBody(struct {
internal/cmd/root.go+23−2
@@ -43,9 +43,12 @@ func init() {
4343 rootCmd.PersistentFlags().StringP("cwd", "c", "", "Current working directory")
4444 rootCmd.PersistentFlags().StringP("data-dir", "D", "", "Custom crush data directory")
4545 rootCmd.PersistentFlags().BoolP("debug", "d", false, "Debug")
46− rootCmd.PersistentFlags().BoolP("yolo", "y", false, "Automatically accept all permissions (dangerous mode)")
4746 rootCmd.PersistentFlags().StringVarP(&clientHost, "host", "H", server.DefaultHost(), "Connect to a specific crush server host (for advanced users)")
4847 rootCmd.Flags().BoolP("help", "h", false, "Help")
48+ rootCmd.Flags().BoolP("yolo", "y", false, "Automatically accept all permissions (dangerous mode)")
49+ rootCmd.Flags().StringP("session", "s", "", "Continue a previous session by ID")
50+ rootCmd.Flags().BoolP("continue", "C", false, "Continue the most recent session")
51+ rootCmd.MarkFlagsMutuallyExclusive("session", "continue")
4952
5053 rootCmd.AddCommand(
5154 runCmd,
@@ -82,14 +85,32 @@ crush --yolo
8285
8386# Run with custom data directory
8487crush --data-dir /path/to/custom/.crush
88+
89+# Continue a previous session
90+crush --session {session-id}
91+
92+# Continue the most recent session
93+crush --continue
8594 `,
8695 RunE: func(cmd *cobra.Command, args []string) error {
96+ sessionID, _ := cmd.Flags().GetString("session")
97+ continueLast, _ := cmd.Flags().GetBool("continue")
98+
8799 c, ws, cleanup, err := connectToServer(cmd)
88100 if err != nil {
89101 return err
90102 }
91103 defer cleanup()
92104
105+ // Resolve session ID if provided.
106+ if sessionID != "" {
107+ sess, err := resolveSessionByID(cmd.Context(), c, ws.ID, sessionID)
108+ if err != nil {
109+ return err
110+ }
111+ sessionID = sess.ID
112+ }
113+
93114 event.AppInitialized()
94115
95116 clientWs := workspace.NewClientWorkspace(c, *ws)
@@ -101,7 +122,7 @@ crush --data-dir /path/to/custom/.crush
101122 }
102123
103124 com := common.DefaultCommon(clientWs)
104− model := ui.New(com)
125+ model := ui.New(com, sessionID, continueLast)
105126
106127 var env uv.Environ = os.Environ()
107128 program := tea.NewProgram(
internal/cmd/run.go+3−2
@@ -27,8 +27,9 @@ import (
2727)
2828
2929var runCmd = &cobra.Command{
30− Use: "run [prompt...]",
31− Short: "Run a single non-interactive prompt",
30+ Aliases: []string{"r"},
31+ Use: "run [prompt...]",
32+ Short: "Run a single non-interactive prompt",
3233 Long: `Run a single prompt in non-interactive mode and exit.
3334The prompt can be provided as arguments or piped from stdin.`,
3435 Example: `
internal/cmd/session.go+1−1
@@ -30,7 +30,7 @@ import (
3030
3131var sessionCmd = &cobra.Command{
3232 Use: "session",
33− Aliases: []string{"sessions"},
33+ Aliases: []string{"sessions", "s"},
3434 Short: "Manage sessions",
3535 Long: "Manage Crush sessions. Agents can use --json for machine-readable output.",
3636}
internal/config/docker_mcp.gonew file+134
@@ -0,0 +1,134 @@
1+package config
2+
3+import (
4+ "context"
5+ "fmt"
6+ "os/exec"
7+ "sync"
8+ "time"
9+)
10+
11+var dockerMCPVersionRunner = func(ctx context.Context) error {
12+ cmd := exec.CommandContext(ctx, "docker", "mcp", "version")
13+ return cmd.Run()
14+}
15+
16+const dockerMCPAvailabilityTTL = 10 * time.Second
17+
18+var dockerMCPAvailabilityCache struct {
19+ mu sync.Mutex
20+ available bool
21+ checkedAt time.Time
22+ known bool
23+}
24+
25+// DockerMCPName is the name of the Docker MCP configuration.
26+const DockerMCPName = "docker"
27+
28+// IsDockerMCPAvailable checks if Docker MCP is available by running
29+// 'docker mcp version'.
30+func IsDockerMCPAvailable() bool {
31+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
32+ defer cancel()
33+
34+ err := dockerMCPVersionRunner(ctx)
35+ return err == nil
36+}
37+
38+// DockerMCPAvailabilityCached returns the cached Docker MCP availability and
39+// whether the cached value is still fresh.
40+func DockerMCPAvailabilityCached() (available bool, known bool) {
41+ dockerMCPAvailabilityCache.mu.Lock()
42+ defer dockerMCPAvailabilityCache.mu.Unlock()
43+
44+ if !dockerMCPAvailabilityCache.known {
45+ return false, false
46+ }
47+ if time.Since(dockerMCPAvailabilityCache.checkedAt) > dockerMCPAvailabilityTTL {
48+ return dockerMCPAvailabilityCache.available, false
49+ }
50+ return dockerMCPAvailabilityCache.available, true
51+}
52+
53+// RefreshDockerMCPAvailability refreshes and caches Docker MCP availability.
54+func RefreshDockerMCPAvailability() bool {
55+ available := IsDockerMCPAvailable()
56+ dockerMCPAvailabilityCache.mu.Lock()
57+ dockerMCPAvailabilityCache.available = available
58+ dockerMCPAvailabilityCache.checkedAt = time.Now()
59+ dockerMCPAvailabilityCache.known = true
60+ dockerMCPAvailabilityCache.mu.Unlock()
61+ return available
62+}
63+
64+// IsDockerMCPEnabled checks if Docker MCP is already configured.
65+func (c *Config) IsDockerMCPEnabled() bool {
66+ if c.MCP == nil {
67+ return false
68+ }
69+ _, exists := c.MCP[DockerMCPName]
70+ return exists
71+}
72+
73+// DockerMCPConfig returns the default Docker MCP stdio configuration.
74+func DockerMCPConfig() MCPConfig {
75+ return MCPConfig{
76+ Type: MCPStdio,
77+ Command: "docker",
78+ Args: []string{"mcp", "gateway", "run"},
79+ Disabled: false,
80+ }
81+}
82+
83+// PrepareDockerMCPConfig validates Docker MCP availability and stages the
84+// Docker MCP configuration in memory.
85+func (s *ConfigStore) PrepareDockerMCPConfig() (MCPConfig, error) {
86+ if !IsDockerMCPAvailable() {
87+ return MCPConfig{}, fmt.Errorf("docker mcp is not available, please ensure docker is installed and 'docker mcp version' succeeds")
88+ }
89+
90+ mcpConfig := DockerMCPConfig()
91+ if s.config.MCP == nil {
92+ s.config.MCP = make(map[string]MCPConfig)
93+ }
94+ s.config.MCP[DockerMCPName] = mcpConfig
95+ return mcpConfig, nil
96+}
97+
98+// PersistDockerMCPConfig persists a previously prepared Docker MCP
99+// configuration to the global config file.
100+func (s *ConfigStore) PersistDockerMCPConfig(mcpConfig MCPConfig) error {
101+ if err := s.SetConfigField(ScopeGlobal, "mcp."+DockerMCPName, mcpConfig); err != nil {
102+ return fmt.Errorf("failed to persist docker mcp configuration: %w", err)
103+ }
104+ return nil
105+}
106+
107+// EnableDockerMCP adds Docker MCP configuration and persists it.
108+func (s *ConfigStore) EnableDockerMCP() error {
109+ mcpConfig, err := s.PrepareDockerMCPConfig()
110+ if err != nil {
111+ return err
112+ }
113+ if err := s.PersistDockerMCPConfig(mcpConfig); err != nil {
114+ return err
115+ }
116+ return nil
117+}
118+
119+// DisableDockerMCP removes Docker MCP configuration and persists the change.
120+func (s *ConfigStore) DisableDockerMCP() error {
121+ if s.config.MCP == nil {
122+ return nil
123+ }
124+
125+ // Remove from in-memory config.
126+ delete(s.config.MCP, DockerMCPName)
127+
128+ // Persist the updated MCP map to the config file.
129+ if err := s.SetConfigField(ScopeGlobal, "mcp", s.config.MCP); err != nil {
130+ return fmt.Errorf("failed to persist docker mcp removal: %w", err)
131+ }
132+
133+ return nil
134+}
internal/config/docker_mcp_test.gonew file+193
@@ -0,0 +1,193 @@
1+package config
2+
3+import (
4+ "context"
5+ "errors"
6+ "os"
7+ "path/filepath"
8+ "testing"
9+
10+ "github.com/charmbracelet/crush/internal/env"
11+ "github.com/stretchr/testify/require"
12+)
13+
14+var errDockerUnavailable = errors.New("docker unavailable")
15+
16+func setDockerMCPVersionRunner(t *testing.T, runner func(context.Context) error) {
17+ t.Helper()
18+ orig := dockerMCPVersionRunner
19+ dockerMCPVersionRunner = runner
20+ t.Cleanup(func() {
21+ dockerMCPVersionRunner = orig
22+ })
23+}
24+
25+func TestIsDockerMCPEnabled(t *testing.T) {
26+ t.Parallel()
27+
28+ t.Run("returns false when MCP is nil", func(t *testing.T) {
29+ t.Parallel()
30+ cfg := &Config{
31+ MCP: nil,
32+ }
33+ require.False(t, cfg.IsDockerMCPEnabled())
34+ })
35+
36+ t.Run("returns false when docker mcp not configured", func(t *testing.T) {
37+ t.Parallel()
38+ cfg := &Config{
39+ MCP: make(map[string]MCPConfig),
40+ }
41+ require.False(t, cfg.IsDockerMCPEnabled())
42+ })
43+
44+ t.Run("returns true when docker mcp is configured", func(t *testing.T) {
45+ t.Parallel()
46+ cfg := &Config{
47+ MCP: map[string]MCPConfig{
48+ DockerMCPName: {
49+ Type: MCPStdio,
50+ Command: "docker",
51+ },
52+ },
53+ }
54+ require.True(t, cfg.IsDockerMCPEnabled())
55+ })
56+}
57+
58+func TestEnableDockerMCP(t *testing.T) {
59+ t.Run("adds docker mcp to config", func(t *testing.T) {
60+ setDockerMCPVersionRunner(t, func(context.Context) error { return nil })
61+
62+ // Create a temporary directory for config.
63+ tmpDir := t.TempDir()
64+ configPath := filepath.Join(tmpDir, "crush.json")
65+
66+ cfg := &Config{
67+ MCP: make(map[string]MCPConfig),
68+ }
69+ store := &ConfigStore{
70+ config: cfg,
71+ globalDataPath: configPath,
72+ resolver: NewShellVariableResolver(env.New()),
73+ }
74+
75+ err := store.EnableDockerMCP()
76+ require.NoError(t, err)
77+
78+ // Check in-memory config.
79+ require.True(t, cfg.IsDockerMCPEnabled())
80+ mcpConfig, exists := cfg.MCP[DockerMCPName]
81+ require.True(t, exists)
82+ require.Equal(t, MCPStdio, mcpConfig.Type)
83+ require.Equal(t, "docker", mcpConfig.Command)
84+ require.Equal(t, []string{"mcp", "gateway", "run"}, mcpConfig.Args)
85+ require.False(t, mcpConfig.Disabled)
86+
87+ // Check persisted config.
88+ data, err := os.ReadFile(configPath)
89+ require.NoError(t, err)
90+ require.Contains(t, string(data), "docker")
91+ require.Contains(t, string(data), "gateway")
92+ })
93+
94+ t.Run("fails when docker mcp not available", func(t *testing.T) {
95+ setDockerMCPVersionRunner(t, func(context.Context) error { return errDockerUnavailable })
96+
97+ // Create a temporary directory for config.
98+ tmpDir := t.TempDir()
99+ configPath := filepath.Join(tmpDir, "crush.json")
100+
101+ cfg := &Config{
102+ MCP: make(map[string]MCPConfig),
103+ }
104+ store := &ConfigStore{
105+ config: cfg,
106+ globalDataPath: configPath,
107+ resolver: NewShellVariableResolver(env.New()),
108+ }
109+
110+ err := store.EnableDockerMCP()
111+ require.Error(t, err)
112+ require.Contains(t, err.Error(), "docker mcp is not available")
113+ })
114+}
115+
116+func TestDisableDockerMCP(t *testing.T) {
117+ t.Parallel()
118+
119+ t.Run("removes docker mcp from config", func(t *testing.T) {
120+ t.Parallel()
121+
122+ // Create a temporary directory for config.
123+ tmpDir := t.TempDir()
124+ configPath := filepath.Join(tmpDir, "crush.json")
125+
126+ cfg := &Config{
127+ MCP: map[string]MCPConfig{
128+ DockerMCPName: {
129+ Type: MCPStdio,
130+ Command: "docker",
131+ Args: []string{"mcp", "gateway", "run"},
132+ Disabled: false,
133+ },
134+ },
135+ }
136+ store := &ConfigStore{
137+ config: cfg,
138+ globalDataPath: configPath,
139+ resolver: NewShellVariableResolver(env.New()),
140+ }
141+
142+ // Verify it's enabled first.
143+ require.True(t, cfg.IsDockerMCPEnabled())
144+
145+ err := store.DisableDockerMCP()
146+ require.NoError(t, err)
147+
148+ // Check in-memory config.
149+ require.False(t, cfg.IsDockerMCPEnabled())
150+ _, exists := cfg.MCP[DockerMCPName]
151+ require.False(t, exists)
152+ })
153+
154+ t.Run("does nothing when MCP is nil", func(t *testing.T) {
155+ t.Parallel()
156+
157+ cfg := &Config{
158+ MCP: nil,
159+ }
160+ store := &ConfigStore{
161+ config: cfg,
162+ globalDataPath: filepath.Join(t.TempDir(), "crush.json"),
163+ resolver: NewShellVariableResolver(env.New()),
164+ }
165+
166+ err := store.DisableDockerMCP()
167+ require.NoError(t, err)
168+ })
169+}
170+
171+func TestEnableDockerMCPWithRealDockerWhenAvailable(t *testing.T) {
172+ t.Parallel()
173+
174+ if !IsDockerMCPAvailable() {
175+ t.Skip("docker mcp not available on this machine")
176+ }
177+
178+ tmpDir := t.TempDir()
179+ configPath := filepath.Join(tmpDir, "crush.json")
180+
181+ cfg := &Config{
182+ MCP: make(map[string]MCPConfig),
183+ }
184+ store := &ConfigStore{
185+ config: cfg,
186+ globalDataPath: configPath,
187+ resolver: NewShellVariableResolver(env.New()),
188+ }
189+
190+ err := store.EnableDockerMCP()
191+ require.NoError(t, err)
192+ require.True(t, cfg.IsDockerMCPEnabled())
193+}
internal/server/config.go+36
@@ -259,6 +259,42 @@ func (c *controllerV1) handleGetWorkspaceProjectInitPrompt(w http.ResponseWriter
259259 jsonEncode(w, proto.ProjectInitPromptResponse{Prompt: prompt})
260260}
261261
262+// handlePostWorkspaceMCPEnableDocker enables the Docker MCP server.
263+//
264+// @Summary Enable Docker MCP
265+// @Tags mcp
266+// @Param id path string true "Workspace ID"
267+// @Success 200
268+// @Failure 404 {object} proto.Error
269+// @Failure 500 {object} proto.Error
270+// @Router /workspaces/{id}/mcp/docker/enable [post]
271+func (c *controllerV1) handlePostWorkspaceMCPEnableDocker(w http.ResponseWriter, r *http.Request) {
272+ id := r.PathValue("id")
273+ if err := c.backend.EnableDockerMCP(r.Context(), id); err != nil {
274+ c.handleError(w, r, err)
275+ return
276+ }
277+ w.WriteHeader(http.StatusOK)
278+}
279+
280+// handlePostWorkspaceMCPDisableDocker disables the Docker MCP server.
281+//
282+// @Summary Disable Docker MCP
283+// @Tags mcp
284+// @Param id path string true "Workspace ID"
285+// @Success 200
286+// @Failure 404 {object} proto.Error
287+// @Failure 500 {object} proto.Error
288+// @Router /workspaces/{id}/mcp/docker/disable [post]
289+func (c *controllerV1) handlePostWorkspaceMCPDisableDocker(w http.ResponseWriter, r *http.Request) {
290+ id := r.PathValue("id")
291+ if err := c.backend.DisableDockerMCP(id); err != nil {
292+ c.handleError(w, r, err)
293+ return
294+ }
295+ w.WriteHeader(http.StatusOK)
296+}
297+
262298// handlePostWorkspaceMCPRefreshTools refreshes tools for a named MCP server.
263299//
264300// @Summary Refresh MCP tools
internal/server/server.go+3−1
@@ -11,9 +11,9 @@ import (
1111 "runtime"
1212 "strings"
1313
14− _ "github.com/charmbracelet/crush/internal/swagger"
1514 "github.com/charmbracelet/crush/internal/backend"
1615 "github.com/charmbracelet/crush/internal/config"
16+ _ "github.com/charmbracelet/crush/internal/swagger"
1717 httpswagger "github.com/swaggo/http-swagger/v2"
1818)
1919
@@ -163,6 +163,8 @@ func NewServer(cfg *config.ConfigStore, network, address string) *Server {
163163 mux.HandleFunc("GET /v1/workspaces/{id}/mcp/states", c.handleGetWorkspaceMCPStates)
164164 mux.HandleFunc("POST /v1/workspaces/{id}/mcp/refresh-prompts", c.handlePostWorkspaceMCPRefreshPrompts)
165165 mux.HandleFunc("POST /v1/workspaces/{id}/mcp/refresh-resources", c.handlePostWorkspaceMCPRefreshResources)
166+ mux.HandleFunc("POST /v1/workspaces/{id}/mcp/docker/enable", c.handlePostWorkspaceMCPEnableDocker)
167+ mux.HandleFunc("POST /v1/workspaces/{id}/mcp/docker/disable", c.handlePostWorkspaceMCPDisableDocker)
166168 mux.Handle("/v1/docs/", httpswagger.WrapHandler)
167169 s.h = &http.Server{
168170 Protocols: &p,
internal/ui/AGENTS.md+1
@@ -191,6 +191,7 @@ through all components that need access to app state or styles.
191191- Always account for padding/borders in width calculations.
192192- Use `tea.Batch()` when returning multiple commands.
193193- Pass `*common.Common` to components that need styles or app access.
194+- When writing tea.Cmd's prefer creating methods in the model instead of writing inline functions.
194195- The `list.List` only renders visible items (lazy). No render cache exists
195196 at the list level — items should cache internally if rendering is
196197 expensive.
internal/ui/chat/docker_mcp.gonew file+296
@@ -0,0 +1,296 @@
1+package chat
2+
3+import (
4+ "encoding/json"
5+ "fmt"
6+ "sort"
7+ "strings"
8+
9+ "charm.land/lipgloss/v2"
10+ "charm.land/lipgloss/v2/table"
11+ "github.com/charmbracelet/crush/internal/config"
12+ "github.com/charmbracelet/crush/internal/message"
13+ "github.com/charmbracelet/crush/internal/stringext"
14+ "github.com/charmbracelet/crush/internal/ui/styles"
15+)
16+
17+// DockerMCPToolMessageItem is a message item that represents a Docker MCP tool call.
18+type DockerMCPToolMessageItem struct {
19+ *baseToolMessageItem
20+}
21+
22+var _ ToolMessageItem = (*DockerMCPToolMessageItem)(nil)
23+
24+// NewDockerMCPToolMessageItem creates a new [DockerMCPToolMessageItem].
25+func NewDockerMCPToolMessageItem(
26+ sty *styles.Styles,
27+ toolCall message.ToolCall,
28+ result *message.ToolResult,
29+ canceled bool,
30+) ToolMessageItem {
31+ return newBaseToolMessageItem(sty, toolCall, result, &DockerMCPToolRenderContext{}, canceled)
32+}
33+
34+// DockerMCPToolRenderContext renders Docker MCP tool messages.
35+type DockerMCPToolRenderContext struct{}
36+
37+// RenderTool implements the [ToolRenderer] interface.
38+func (d *DockerMCPToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
39+ cappedWidth := cappedMessageWidth(width)
40+
41+ var params map[string]any
42+ if err := json.Unmarshal([]byte(opts.ToolCall.Input), &params); err != nil {
43+ params = make(map[string]any)
44+ }
45+
46+ tool := strings.TrimPrefix(opts.ToolCall.Name, "mcp_"+config.DockerMCPName+"_")
47+
48+ mainParam := opts.ToolCall.Input
49+ extraArgs := map[string]string{}
50+ switch tool {
51+ case "mcp-find":
52+ if query, ok := params["query"]; ok {
53+ if qStr, ok := query.(string); ok {
54+ mainParam = qStr
55+ }
56+ }
57+ for k, v := range params {
58+ if k == "query" {
59+ continue
60+ }
61+ data, _ := json.Marshal(v)
62+ extraArgs[k] = string(data)
63+ }
64+ case "mcp-add":
65+ if name, ok := params["name"]; ok {
66+ if nStr, ok := name.(string); ok {
67+ mainParam = nStr
68+ }
69+ }
70+ for k, v := range params {
71+ if k == "name" {
72+ continue
73+ }
74+ data, _ := json.Marshal(v)
75+ extraArgs[k] = string(data)
76+ }
77+ case "mcp-remove":
78+ if name, ok := params["name"]; ok {
79+ if nStr, ok := name.(string); ok {
80+ mainParam = nStr
81+ }
82+ }
83+ for k, v := range params {
84+ if k == "name" {
85+ continue
86+ }
87+ data, _ := json.Marshal(v)
88+ extraArgs[k] = string(data)
89+ }
90+ case "mcp-exec":
91+ if name, ok := params["name"]; ok {
92+ if nStr, ok := name.(string); ok {
93+ mainParam = nStr
94+ }
95+ }
96+ case "mcp-config-set":
97+ if server, ok := params["server"]; ok {
98+ if sStr, ok := server.(string); ok {
99+ mainParam = sStr
100+ }
101+ }
102+ }
103+
104+ var toolParams []string
105+ toolParams = append(toolParams, mainParam)
106+ keys := make([]string, 0, len(extraArgs))
107+ for k := range extraArgs {
108+ keys = append(keys, k)
109+ }
110+ sort.Strings(keys)
111+ for _, k := range keys {
112+ toolParams = append(toolParams, k, extraArgs[k])
113+ }
114+
115+ if opts.IsPending() {
116+ return pendingTool(sty, d.formatToolName(sty, tool), opts.Anim, false)
117+ }
118+
119+ header := d.makeHeader(sty, tool, cappedWidth, opts, toolParams...)
120+ if opts.Compact {
121+ return header
122+ }
123+
124+ if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok {
125+ return joinToolParts(header, earlyState)
126+ }
127+
128+ if tool == "mcp-find" {
129+ return joinToolParts(header, d.renderMCPServers(sty, opts, cappedWidth))
130+ }
131+
132+ if !opts.HasResult() {
133+ return header
134+ }
135+
136+ bodyWidth := cappedWidth - toolBodyLeftPaddingTotal
137+ var parts []string
138+
139+ // Handle text content.
140+ if opts.Result.Content != "" {
141+ var body string
142+ var result json.RawMessage
143+ if err := json.Unmarshal([]byte(opts.Result.Content), &result); err == nil {
144+ prettyResult, err := json.MarshalIndent(result, "", " ")
145+ if err == nil {
146+ body = sty.Tool.Body.Render(toolOutputCodeContent(sty, "result.json", string(prettyResult), 0, bodyWidth, opts.ExpandedContent))
147+ } else {
148+ body = sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.ExpandedContent))
149+ }
150+ } else if looksLikeMarkdown(opts.Result.Content) {
151+ body = sty.Tool.Body.Render(toolOutputCodeContent(sty, "result.md", opts.Result.Content, 0, bodyWidth, opts.ExpandedContent))
152+ } else {
153+ body = sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.ExpandedContent))
154+ }
155+ parts = append(parts, body)
156+ }
157+
158+ // Handle image content.
159+ if opts.Result.Data != "" && strings.HasPrefix(opts.Result.MIMEType, "image/") {
160+ parts = append(parts, "", toolOutputImageContent(sty, opts.Result.Data, opts.Result.MIMEType))
161+ }
162+
163+ if len(parts) == 0 {
164+ return header
165+ }
166+
167+ return joinToolParts(header, strings.Join(parts, "\n"))
168+}
169+
170+// FindMCPResponse represents the response from mcp-find.
171+type FindMCPResponse struct {
172+ Servers []struct {
173+ Name string `json:"name"`
174+ Description string `json:"description"`
175+ } `json:"servers"`
176+}
177+
178+func (d *DockerMCPToolRenderContext) renderMCPServers(sty *styles.Styles, opts *ToolRenderOpts, width int) string {
179+ if !opts.HasResult() || opts.Result.Content == "" {
180+ return ""
181+ }
182+
183+ var result FindMCPResponse
184+ if err := json.Unmarshal([]byte(opts.Result.Content), &result); err != nil {
185+ return toolOutputPlainContent(sty, opts.Result.Content, width-toolBodyLeftPaddingTotal, opts.ExpandedContent)
186+ }
187+
188+ if len(result.Servers) == 0 {
189+ return sty.Subtle.Render("No MCP servers found.")
190+ }
191+
192+ bodyWidth := min(120, width) - toolBodyLeftPaddingTotal
193+ rows := [][]string{}
194+ moreServers := ""
195+ for i, server := range result.Servers {
196+ if i > 9 {
197+ moreServers = sty.Subtle.Render(fmt.Sprintf("... and %d more", len(result.Servers)-10))
198+ break
199+ }
200+ rows = append(rows, []string{sty.Base.Render(server.Name), sty.Subtle.Render(server.Description)})
201+ }
202+ serverTable := table.New().
203+ Wrap(false).
204+ BorderTop(false).
205+ BorderBottom(false).
206+ BorderRight(false).
207+ BorderLeft(false).
208+ BorderColumn(false).
209+ BorderRow(false).
210+ StyleFunc(func(row, col int) lipgloss.Style {
211+ if row == table.HeaderRow {
212+ return lipgloss.NewStyle()
213+ }
214+ switch col {
215+ case 0:
216+ return lipgloss.NewStyle().PaddingRight(1)
217+ }
218+ return lipgloss.NewStyle()
219+ }).Rows(rows...).Width(bodyWidth)
220+ if moreServers != "" {
221+ return sty.Tool.Body.Render(serverTable.Render() + "\n" + moreServers)
222+ }
223+ return sty.Tool.Body.Render(serverTable.Render())
224+}
225+
226+func (d *DockerMCPToolRenderContext) makeHeader(sty *styles.Styles, tool string, width int, opts *ToolRenderOpts, params ...string) string {
227+ if opts.Compact {
228+ return d.makeCompactHeader(sty, tool, width, params...)
229+ }
230+
231+ icon := toolIcon(sty, opts.Status)
232+ if opts.IsPending() {
233+ icon = sty.Tool.IconPending.Render()
234+ }
235+ prefix := fmt.Sprintf("%s %s ", icon, d.formatToolName(sty, tool))
236+ return prefix + toolParamList(sty, params, width-lipgloss.Width(prefix))
237+}
238+
239+func (d *DockerMCPToolRenderContext) formatToolName(sty *styles.Styles, tool string) string {
240+ mainTool := "Docker MCP"
241+ action := tool
242+ actionStyle := sty.Tool.MCPToolName
243+ switch tool {
244+ case "mcp-exec":
245+ action = "Exec"
246+ case "mcp-config-set":
247+ action = "Config Set"
248+ case "mcp-find":
249+ action = "Find"
250+ case "mcp-add":
251+ action = "Add"
252+ actionStyle = sty.Tool.DockerMCPActionAdd
253+ case "mcp-remove":
254+ action = "Remove"
255+ actionStyle = sty.Tool.DockerMCPActionDel
256+ case "code-mode":
257+ action = "Code Mode"
258+ default:
259+ action = strings.ReplaceAll(tool, "-", " ")
260+ action = strings.ReplaceAll(action, "_", " ")
261+ action = stringext.Capitalize(action)
262+ }
263+
264+ toolNameStyled := sty.Tool.MCPName.Render(mainTool)
265+ arrow := sty.Tool.MCPArrow.String()
266+ return fmt.Sprintf("%s %s %s", toolNameStyled, arrow, actionStyle.Render(action))
267+}
268+
269+func (d *DockerMCPToolRenderContext) makeCompactHeader(sty *styles.Styles, tool string, width int, params ...string) string {
270+ action := tool
271+ switch tool {
272+ case "mcp-exec":
273+ action = "exec"
274+ case "mcp-config-set":
275+ action = "config-set"
276+ case "mcp-find":
277+ action = "find"
278+ case "mcp-add":
279+ action = "add"
280+ case "mcp-remove":
281+ action = "remove"
282+ case "code-mode":
283+ action = "code-mode"
284+ default:
285+ action = strings.ReplaceAll(tool, "-", " ")
286+ action = strings.ReplaceAll(action, "_", " ")
287+ }
288+
289+ name := fmt.Sprintf("Docker MCP: %s", action)
290+ return toolHeader(sty, ToolStatusSuccess, name, width, true, params...)
291+}
292+
293+// IsDockerMCPTool returns true if the tool name is a Docker MCP tool.
294+func IsDockerMCPTool(name string) bool {
295+ return strings.HasPrefix(name, "mcp_"+config.DockerMCPName+"_")
296+}
internal/ui/chat/tools.go+3−1
@@ -255,7 +255,9 @@ func NewToolMessageItem(
255255 case tools.LSPRestartToolName:
256256 item = NewLSPRestartToolMessageItem(sty, toolCall, result, canceled)
257257 default:
258− if strings.HasPrefix(toolCall.Name, "mcp_") {
258+ if IsDockerMCPTool(toolCall.Name) {
259+ item = NewDockerMCPToolMessageItem(sty, toolCall, result, canceled)
260+ } else if strings.HasPrefix(toolCall.Name, "mcp_") {
259261 item = NewMCPToolMessageItem(sty, toolCall, result, canceled)
260262 } else {
261263 item = NewGenericToolMessageItem(sty, toolCall, result, canceled)
internal/ui/dialog/actions.go+4
@@ -81,6 +81,10 @@ type (
8181 Arguments []commands.Argument
8282 Args map[string]string // Actual argument values
8383 }
84+ // ActionEnableDockerMCP is a message to enable Docker MCP.
85+ ActionEnableDockerMCP struct{}
86+ // ActionDisableDockerMCP is a message to disable Docker MCP.
87+ ActionDisableDockerMCP struct{}
8488)
8589
8690// Messages for API key input dialog.
internal/ui/dialog/commands.go+42
@@ -37,6 +37,10 @@ const (
3737)
3838
3939// Commands represents a dialog that shows available commands.
40+type dockerMCPAvailabilityCheckedMsg struct {
41+ available bool
42+}
43+
4044type Commands struct {
4145 com *common.Common
4246 keyMap struct {
@@ -66,6 +70,9 @@ type Commands struct {
6670
6771 customCommands []commands.CustomCommand
6872 mcpPrompts []commands.MCPPrompt
73+
74+ dockerMCPAvailable *bool
75+ dockerMCPCheckInFlight bool
6976}
7077
7178var _ Dialog = (*Commands)(nil)
@@ -126,6 +133,10 @@ func NewCommands(com *common.Common, sessionID string, hasSession, hasTodos, has
126133 closeKey.SetHelp("esc", "cancel")
127134 c.keyMap.Close = closeKey
128135
136+ if available, known := config.DockerMCPAvailabilityCached(); known {
137+ c.dockerMCPAvailable = &available
138+ }
139+
129140 // Set initial commands
130141 c.setCommandItems(c.selected)
131142
@@ -145,6 +156,13 @@ func (c *Commands) ID() string {
145156// HandleMsg implements [Dialog].
146157func (c *Commands) HandleMsg(msg tea.Msg) Action {
147158 switch msg := msg.(type) {
159+ case dockerMCPAvailabilityCheckedMsg:
160+ c.dockerMCPAvailable = &msg.available
161+ c.dockerMCPCheckInFlight = false
162+ if c.selected == SystemCommands {
163+ c.setCommandItems(c.selected)
164+ }
165+ return nil
148166 case spinner.TickMsg:
149167 if c.loading {
150168 var cmd tea.Cmd
@@ -207,6 +225,20 @@ func (c *Commands) HandleMsg(msg tea.Msg) Action {
207225 return nil
208226}
209227
228+func checkDockerMCPAvailabilityCmd() tea.Cmd {
229+ return func() tea.Msg {
230+ return dockerMCPAvailabilityCheckedMsg{available: config.RefreshDockerMCPAvailability()}
231+ }
232+}
233+
234+func (c *Commands) InitialCmd() tea.Cmd {
235+ if c.dockerMCPAvailable != nil || c.dockerMCPCheckInFlight {
236+ return nil
237+ }
238+ c.dockerMCPCheckInFlight = true
239+ return checkDockerMCPAvailabilityCmd()
240+}
241+
210242// Cursor returns the cursor position relative to the dialog.
211243func (c *Commands) Cursor() *tea.Cursor {
212244 return InputCursor(c.com.Styles, c.input.Cursor())
@@ -446,6 +478,16 @@ func (c *Commands) defaultCommands() []*CommandItem {
446478 commands = append(commands, NewCommandItem(c.com.Styles, "open_external_editor", "Open External Editor", "ctrl+o", ActionExternalEditor{}))
447479 }
448480
481+ // Add Docker MCP command if available and not already enabled.
482+ if !cfg.IsDockerMCPEnabled() && c.dockerMCPAvailable != nil && *c.dockerMCPAvailable {
483+ commands = append(commands, NewCommandItem(c.com.Styles, "enable_docker_mcp", "Enable Docker MCP Catalog", "", ActionEnableDockerMCP{}))
484+ }
485+
486+ // Add disable Docker MCP command if it's currently enabled
487+ if cfg.IsDockerMCPEnabled() {
488+ commands = append(commands, NewCommandItem(c.com.Styles, "disable_docker_mcp", "Disable Docker MCP Catalog", "", ActionDisableDockerMCP{}))
489+ }
490+
449491 if c.hasTodos || c.hasQueue {
450492 var label string
451493 switch {
internal/ui/model/mcp.go+7−1
@@ -6,6 +6,7 @@ import (
66
77 "charm.land/lipgloss/v2"
88 "github.com/charmbracelet/crush/internal/agent/tools/mcp"
9+ "github.com/charmbracelet/crush/internal/config"
910 "github.com/charmbracelet/crush/internal/ui/common"
1011 "github.com/charmbracelet/crush/internal/ui/styles"
1112)
@@ -59,7 +60,12 @@ func mcpList(t *styles.Styles, mcps []mcp.ClientInfo, width, maxItems int) strin
5960
6061 for _, m := range mcps {
6162 var icon string
62− title := t.ResourceName.Render(m.Name)
63+ title := m.Name
64+ // Show "Docker MCP" instead of the config name for Docker MCP.
65+ if m.Name == config.DockerMCPName {
66+ title = "Docker MCP"
67+ }
68+ title = t.ResourceName.Render(title)
6369 var description string
6470 var extraContent string
6571
internal/ui/model/ui.go+57−2
@@ -139,6 +139,11 @@ type UI struct {
139139 // keeps track of read files while we don't have a session id
140140 sessionFileReads []string
141141
142+ // initialSessionID is set when loading a specific session on startup.
143+ initialSessionID string
144+ // continueLastSession is set to continue the most recent session on startup.
145+ continueLastSession bool
146+
142147 lastUserMessageTime int64
143148
144149 // The width and height of the terminal in cells.
@@ -242,7 +247,7 @@ type UI struct {
242247}
243248
244249// New creates a new instance of the [UI] model.
245−func New(com *common.Common) *UI {
250+func New(com *common.Common, initialSessionID string, continueLast bool) *UI {
246251 // Editor components
247252 ta := textarea.New()
248253 ta.SetStyles(com.Styles.TextArea)
@@ -298,6 +303,8 @@ func New(com *common.Common) *UI {
298303 mcpStates: make(map[string]mcp.ClientInfo),
299304 notifyBackend: notification.NoopBackend{},
300305 notifyWindowFocused: true,
306+ initialSessionID: initialSessionID,
307+ continueLastSession: continueLast,
301308 }
302309
303310 status := NewStatus(com, ui)
@@ -346,9 +353,34 @@ func (m *UI) Init() tea.Cmd {
346353 cmds = append(cmds, m.loadCustomCommands())
347354 // load prompt history async
348355 cmds = append(cmds, m.loadPromptHistory())
356+ // load initial session if specified
357+ if cmd := m.loadInitialSession(); cmd != nil {
358+ cmds = append(cmds, cmd)
359+ }
349360 return tea.Batch(cmds...)
350361}
351362
363+// loadInitialSession loads the initial session if one was specified on startup.
364+func (m *UI) loadInitialSession() tea.Cmd {
365+ switch {
366+ case m.state != uiLanding:
367+ // Only load if we're in landing state (i.e., fully configured)
368+ return nil
369+ case m.initialSessionID != "":
370+ return m.loadSession(m.initialSessionID)
371+ case m.continueLastSession:
372+ return func() tea.Msg {
373+ sessions, err := m.com.Workspace.ListSessions(context.Background())
374+ if err != nil || len(sessions) == 0 {
375+ return nil
376+ }
377+ return m.loadSession(sessions[0].ID)()
378+ }
379+ default:
380+ return nil
381+ }
382+}
383+
352384// sendNotification returns a command that sends a notification if allowed by policy.
353385func (m *UI) sendNotification(n notification.Notification) tea.Cmd {
354386 if !m.shouldSendNotification() {
@@ -1354,6 +1386,12 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
13541386 m.dialog.CloseDialog(dialog.CommandsID)
13551387 case dialog.ActionQuit:
13561388 cmds = append(cmds, tea.Quit)
1389+ case dialog.ActionEnableDockerMCP:
1390+ m.dialog.CloseDialog(dialog.CommandsID)
1391+ cmds = append(cmds, m.enableDockerMCP)
1392+ case dialog.ActionDisableDockerMCP:
1393+ m.dialog.CloseDialog(dialog.CommandsID)
1394+ cmds = append(cmds, m.disableDockerMCP)
13571395 case dialog.ActionInitializeProject:
13581396 if m.isAgentBusy() {
13591397 cmds = append(cmds, util.ReportWarn("Agent is busy, please wait before summarizing session..."))
@@ -2979,7 +3017,7 @@ func (m *UI) openCommandsDialog() tea.Cmd {
29793017
29803018 m.dialog.OpenDialog(commands)
29813019
2982− return nil
3020+ return commands.InitialCmd()
29833021}
29843022
29853023// openReasoningDialog opens the reasoning effort dialog.
@@ -3421,6 +3459,23 @@ func (m *UI) copyChatHighlight() tea.Cmd {
34213459 )
34223460}
34233461
3462+func (m *UI) enableDockerMCP() tea.Msg {
3463+ ctx := context.Background()
3464+ if err := m.com.Workspace.EnableDockerMCP(ctx); err != nil {
3465+ return util.ReportError(err)()
3466+ }
3467+
3468+ return util.NewInfoMsg("Docker MCP enabled and started successfully")
3469+}
3470+
3471+func (m *UI) disableDockerMCP() tea.Msg {
3472+ if err := m.com.Workspace.DisableDockerMCP(); err != nil {
3473+ return util.ReportError(err)()
3474+ }
3475+
3476+ return util.NewInfoMsg("Docker MCP disabled successfully")
3477+}
3478+
34243479// renderLogo renders the Crush logo with the given styles and dimensions.
34253480func renderLogo(t *styles.Styles, compact bool, width int) string {
34263481 return logo.Render(t, version.Version, compact, logo.Opts{
internal/ui/styles/styles.go+8
@@ -328,6 +328,10 @@ type Styles struct {
328328 ResourceName lipgloss.Style
329329 ResourceSize lipgloss.Style
330330 MediaType lipgloss.Style
331+
332+ // Docker MCP tools
333+ DockerMCPActionAdd lipgloss.Style // Docker MCP add action (green)
334+ DockerMCPActionDel lipgloss.Style // Docker MCP remove action (red)
331335 }
332336
333337 // Dialog styles
@@ -1182,6 +1186,10 @@ func DefaultStyles() Styles {
11821186 s.Tool.MediaType = base
11831187 s.Tool.ResourceSize = base.Foreground(fgMuted)
11841188
1189+ // Docker MCP styles
1190+ s.Tool.DockerMCPActionAdd = base.Foreground(greenLight)
1191+ s.Tool.DockerMCPActionDel = base.Foreground(red)
1192+
11851193 // Buttons
11861194 s.ButtonFocus = lipgloss.NewStyle().Foreground(white).Background(secondary)
11871195 s.ButtonBlur = s.Base.Background(bgSubtle)
internal/workspace/client_workspace.go+8
@@ -509,6 +509,14 @@ func (w *ClientWorkspace) GetMCPPrompt(clientID, promptID string, args map[strin
509509 return w.client.GetMCPPrompt(context.Background(), w.workspaceID(), clientID, promptID, args)
510510}
511511
512+func (w *ClientWorkspace) EnableDockerMCP(ctx context.Context) error {
513+ return w.client.EnableDockerMCP(ctx, w.workspaceID())
514+}
515+
516+func (w *ClientWorkspace) DisableDockerMCP() error {
517+ return w.client.DisableDockerMCP(context.Background(), w.workspaceID())
518+}
519+
512520// -- Lifecycle --
513521
514522func (w *ClientWorkspace) Subscribe(program *tea.Program) {
internal/workspace/workspace.go+2
@@ -135,6 +135,8 @@ type Workspace interface {
135135 RefreshMCPTools(ctx context.Context, name string)
136136 ReadMCPResource(ctx context.Context, name, uri string) ([]MCPResourceContents, error)
137137 GetMCPPrompt(clientID, promptID string, args map[string]string) (string, error)
138+ EnableDockerMCP(ctx context.Context) error
139+ DisableDockerMCP() error
138140
139141 // Events
140142 Subscribe(program *tea.Program)