Merge branch 'main' into server-client-2

Ayman Bagabas created

Change summary

.github/cla-signatures.json            |  16 +
.github/dependabot.yml                 |   1 
.github/workflows/security.yml         |  12 
Taskfile.yaml                          |   9 
go.mod                                 |  58 ++--
go.sum                                 | 132 ++++++------
internal/agent/agent.go                |   3 
internal/agent/coordinator.go          |   2 
internal/agent/hyper/provider.json     |   0 
internal/agent/tools/mcp-tools.go      |  48 ++-
internal/agent/tools/mcp/init.go       | 122 +++++++---
internal/agent/tools/mcp/tools.go      |  76 ++++++
internal/agent/tools/mcp/tools_test.go | 125 +++++++++++
internal/backend/config.go             |  49 ++++
internal/client/config.go              |  26 ++
internal/cmd/root.go                   |  25 ++
internal/cmd/run.go                    |   5 
internal/cmd/session.go                |   2 
internal/config/docker_mcp.go          | 134 ++++++++++++
internal/config/docker_mcp_test.go     | 193 ++++++++++++++++++
internal/server/config.go              |  36 +++
internal/server/server.go              |   4 
internal/ui/AGENTS.md                  |   1 
internal/ui/chat/docker_mcp.go         | 296 ++++++++++++++++++++++++++++
internal/ui/chat/tools.go              |   4 
internal/ui/dialog/actions.go          |   4 
internal/ui/dialog/commands.go         |  42 +++
internal/ui/model/mcp.go               |   8 
internal/ui/model/ui.go                |  59 +++++
internal/ui/styles/styles.go           |   8 
internal/workspace/client_workspace.go |   8 
internal/workspace/workspace.go        |   2 
32 files changed, 1,335 insertions(+), 175 deletions(-)

Detailed changes

.github/cla-signatures.json 🔗

@@ -1351,6 +1351,22 @@
       "created_at": "2026-03-15T02:30:55Z",
       "repoId": 987670088,
       "pullRequestNo": 2410
+    },
+    {
+      "name": "nghiant03",
+      "id": 102637959,
+      "comment_id": 4077213520,
+      "created_at": "2026-03-17T18:41:50Z",
+      "repoId": 987670088,
+      "pullRequestNo": 2421
+    },
+    {
+      "name": "whatnick",
+      "id": 491396,
+      "comment_id": 4102155868,
+      "created_at": "2026-03-21T03:49:12Z",
+      "repoId": 987670088,
+      "pullRequestNo": 2449
     }
   ]
 }

.github/dependabot.yml 🔗

@@ -24,6 +24,7 @@ updates:
       - dependency-name: github.com/charmbracelet/lipgloss/v2
         versions:
           - v2.0.0-beta1
+      - dependency-name: mvdan.cc/sh/moreinterp
 
   - package-ecosystem: "github-actions"
     directory: "/"

.github/workflows/security.yml 🔗

@@ -30,11 +30,11 @@ jobs:
       - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
         with:
           persist-credentials: false
-      - uses: github/codeql-action/init@b1bff81932f5cdfc8695c7752dcee935dcd061c8 # v4.33.0
+      - uses: github/codeql-action/init@38697555549f1db7851b81482ff19f1fa5c4fedc # v4.34.1
         with:
           languages: ${{ matrix.language }}
-      - uses: github/codeql-action/autobuild@b1bff81932f5cdfc8695c7752dcee935dcd061c8 # v4.33.0
-      - uses: github/codeql-action/analyze@b1bff81932f5cdfc8695c7752dcee935dcd061c8 # v4.33.0
+      - uses: github/codeql-action/autobuild@38697555549f1db7851b81482ff19f1fa5c4fedc # v4.34.1
+      - uses: github/codeql-action/analyze@38697555549f1db7851b81482ff19f1fa5c4fedc # v4.34.1
 
   grype:
     runs-on: ubuntu-latest
@@ -46,13 +46,13 @@ jobs:
       - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
         with:
           persist-credentials: false
-      - uses: anchore/scan-action@7037fa011853d5a11690026fb85feee79f4c946c # v7.3.2
+      - uses: anchore/scan-action@e1165082ffb1fe366ebaf02d8526e7c4989ea9d2 # v7.4.0
         id: scan
         with:
           path: "."
           fail-build: true
           severity-cutoff: critical
-      - uses: github/codeql-action/upload-sarif@b1bff81932f5cdfc8695c7752dcee935dcd061c8 # v4.33.0
+      - uses: github/codeql-action/upload-sarif@38697555549f1db7851b81482ff19f1fa5c4fedc # v4.34.1
         with:
           sarif_file: ${{ steps.scan.outputs.sarif }}
 
@@ -73,7 +73,7 @@ jobs:
       - name: Run govulncheck
         run: |
           govulncheck -C . -format sarif ./... > results.sarif
-      - uses: github/codeql-action/upload-sarif@b1bff81932f5cdfc8695c7752dcee935dcd061c8 # v4.33.0
+      - uses: github/codeql-action/upload-sarif@38697555549f1db7851b81482ff19f1fa5c4fedc # v4.34.1
         with:
           sarif_file: results.sarif
 

Taskfile.yaml 🔗

@@ -180,9 +180,14 @@ tasks:
 
   deps:
     desc: Update Fantasy and Catwalk
+    env:
+      # The Go proxy takes a bit of time to see the latest release. Setting
+      # these bypass it to ensure we can update to a release from a minute ago.
+      GOPROXY: direct
+      GONOSUMDB: charm.land/*
     cmds:
-      - go get charm.land/fantasy
-      - go get charm.land/catwalk
+      - go get charm.land/fantasy@latest
+      - go get charm.land/catwalk@latest
       - go mod tidy
 
   swag:

go.mod 🔗

@@ -5,9 +5,9 @@ go 1.26.1
 require (
 	charm.land/bubbles/v2 v2.0.0
 	charm.land/bubbletea/v2 v2.0.2
-	charm.land/catwalk v0.30.3
+	charm.land/catwalk v0.31.0
 	charm.land/fang/v2 v2.0.1
-	charm.land/fantasy v0.12.3
+	charm.land/fantasy v0.16.0
 	charm.land/glamour/v2 v2.0.0
 	charm.land/lipgloss/v2 v2.0.2
 	charm.land/log/v2 v2.0.0
@@ -23,6 +23,7 @@ require (
 	github.com/bmatcuk/doublestar/v4 v4.10.0
 	github.com/charlievieth/fastwalk v1.0.14
 	github.com/charmbracelet/colorprofile v0.4.3
+	github.com/charmbracelet/openai-go v0.0.0-20260319145158-d0740cc34266
 	github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8
 	github.com/charmbracelet/x/ansi v0.11.6
 	github.com/charmbracelet/x/editor v0.2.0
@@ -50,7 +51,6 @@ require (
 	github.com/modelcontextprotocol/go-sdk v1.4.1
 	github.com/ncruces/go-sqlite3 v0.32.0
 	github.com/nxadm/tail v1.4.11
-	github.com/openai/openai-go/v3 v3.28.0
 	github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c
 	github.com/posthog/posthog-go v1.11.1
 	github.com/pressly/goose/v3 v3.27.0
@@ -72,7 +72,7 @@ require (
 	golang.org/x/text v0.35.0
 	gopkg.in/natefinch/lumberjack.v2 v2.2.1
 	gopkg.in/yaml.v3 v3.0.1
-	modernc.org/sqlite v1.46.1
+	modernc.org/sqlite v1.47.0
 	mvdan.cc/sh/moreinterp v0.0.0-20250902163504-3cf4fd5717a5
 	mvdan.cc/sh/v3 v3.13.0
 )
@@ -87,20 +87,20 @@ require (
 	github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 // indirect
 	github.com/KyleBanks/depth v1.2.1 // indirect
 	github.com/andybalholm/cascadia v1.3.3 // indirect
-	github.com/aws/aws-sdk-go-v2 v1.41.3 // indirect
-	github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.5 // indirect
-	github.com/aws/aws-sdk-go-v2/config v1.32.11 // indirect
-	github.com/aws/aws-sdk-go-v2/credentials v1.19.11 // indirect
-	github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.19 // indirect
-	github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.19 // indirect
-	github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.19 // indirect
-	github.com/aws/aws-sdk-go-v2/internal/ini v1.8.5 // indirect
-	github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.6 // indirect
-	github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.19 // indirect
-	github.com/aws/aws-sdk-go-v2/service/signin v1.0.7 // indirect
-	github.com/aws/aws-sdk-go-v2/service/sso v1.30.12 // indirect
-	github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.16 // indirect
-	github.com/aws/aws-sdk-go-v2/service/sts v1.41.8 // indirect
+	github.com/aws/aws-sdk-go-v2 v1.41.4 // indirect
+	github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.6 // indirect
+	github.com/aws/aws-sdk-go-v2/config v1.32.12 // indirect
+	github.com/aws/aws-sdk-go-v2/credentials v1.19.12 // indirect
+	github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20 // indirect
+	github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20 // indirect
+	github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20 // indirect
+	github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 // indirect
+	github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 // indirect
+	github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20 // indirect
+	github.com/aws/aws-sdk-go-v2/service/signin v1.0.8 // indirect
+	github.com/aws/aws-sdk-go-v2/service/sso v1.30.13 // indirect
+	github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.17 // indirect
+	github.com/aws/aws-sdk-go-v2/service/sts v1.41.9 // indirect
 	github.com/aws/smithy-go v1.24.2 // indirect
 	github.com/aymerick/douceur v0.2.0 // indirect
 	github.com/bahlo/generic-list-go v0.2.0 // indirect
@@ -135,7 +135,7 @@ require (
 	github.com/google/go-cmp v0.7.0 // indirect
 	github.com/google/jsonschema-go v0.4.2 // indirect
 	github.com/google/s2a-go v0.1.9 // indirect
-	github.com/googleapis/enterprise-certificate-proxy v0.3.12 // indirect
+	github.com/googleapis/enterprise-certificate-proxy v0.3.14 // indirect
 	github.com/googleapis/gax-go/v2 v2.17.0 // indirect
 	github.com/gorilla/css v1.0.1 // indirect
 	github.com/gorilla/websocket v1.5.3 // indirect
@@ -186,11 +186,11 @@ require (
 	github.com/yuin/goldmark v1.7.8 // indirect
 	github.com/yuin/goldmark-emoji v1.0.5 // indirect
 	go.opentelemetry.io/auto/sdk v1.2.1 // indirect
-	go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.65.0 // indirect
-	go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 // indirect
-	go.opentelemetry.io/otel v1.40.0 // indirect
-	go.opentelemetry.io/otel/metric v1.40.0 // indirect
-	go.opentelemetry.io/otel/trace v1.40.0 // indirect
+	go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 // indirect
+	go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect
+	go.opentelemetry.io/otel v1.42.0 // indirect
+	go.opentelemetry.io/otel/metric v1.42.0 // indirect
+	go.opentelemetry.io/otel/trace v1.42.0 // indirect
 	go.uber.org/multierr v1.11.0 // indirect
 	go.yaml.in/yaml/v4 v4.0.0-rc.3 // indirect
 	golang.org/x/crypto v0.49.0 // indirect
@@ -199,18 +199,18 @@ require (
 	golang.org/x/mod v0.33.0 // indirect
 	golang.org/x/oauth2 v0.36.0 // indirect
 	golang.org/x/term v0.41.0 // indirect
-	golang.org/x/time v0.14.0 // indirect
+	golang.org/x/time v0.15.0 // indirect
 	golang.org/x/tools v0.42.0 // indirect
-	google.golang.org/api v0.269.0 // indirect
-	google.golang.org/genai v1.49.0 // indirect
+	google.golang.org/api v0.270.0 // indirect
+	google.golang.org/genai v1.50.0 // indirect
 	google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect
-	google.golang.org/grpc v1.79.1 // indirect
+	google.golang.org/grpc v1.79.2 // indirect
 	google.golang.org/protobuf v1.36.11 // indirect
 	gopkg.in/dnaeon/go-vcr.v4 v4.0.6-0.20251110073552-01de4eb40290 // indirect
 	gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect
 	gopkg.in/warnings.v0 v0.1.2 // indirect
 	gopkg.in/yaml.v2 v2.4.0 // indirect
-	modernc.org/libc v1.68.0 // indirect
+	modernc.org/libc v1.70.0 // indirect
 	modernc.org/mathutil v1.7.1 // indirect
 	modernc.org/memory v1.11.0 // indirect
 )

go.sum 🔗

@@ -2,12 +2,12 @@ charm.land/bubbles/v2 v2.0.0 h1:tE3eK/pHjmtrDiRdoC9uGNLgpopOd8fjhEe31B/ai5s=
 charm.land/bubbles/v2 v2.0.0/go.mod h1:rCHoleP2XhU8um45NTuOWBPNVHxnkXKTiZqcclL/qOI=
 charm.land/bubbletea/v2 v2.0.2 h1:4CRtRnuZOdFDTWSff9r8QFt/9+z6Emubz3aDMnf/dx0=
 charm.land/bubbletea/v2 v2.0.2/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ=
-charm.land/catwalk v0.30.3 h1:eCRwVoi1znrNGYiPZoBIbWt8+Q4kDhT3zziqnPO3s2Y=
-charm.land/catwalk v0.30.3/go.mod h1:+fqw/6YGNtvapvPy9vhwA/fAMxVjD2K8hVIKYov8Vhg=
+charm.land/catwalk v0.31.0 h1:ci2LRf5Gy5BgbbQDN7cXEXOeNA2lP1sqVuXdUrAph3w=
+charm.land/catwalk v0.31.0/go.mod h1:+fqw/6YGNtvapvPy9vhwA/fAMxVjD2K8hVIKYov8Vhg=
 charm.land/fang/v2 v2.0.1 h1:zQCM8JQJ1JnQX/66B5jlCYBUxL2as5JXQZ2KJ6EL0mY=
 charm.land/fang/v2 v2.0.1/go.mod h1:S1GmkpcvK+OB5w9caywUnJcsMew45Ot8FXqoz8ALrII=
-charm.land/fantasy v0.12.3 h1:gvqRWD7vWmpNN0VcQ+rSku5QdTlLegqrlJDVCDdAh58=
-charm.land/fantasy v0.12.3/go.mod h1:noWyUtEgUrfsRXqmGbz7NQlZpf6KFwNwjA+jzdS3No8=
+charm.land/fantasy v0.16.0 h1:vE/6sR9nPcSD8qXJXX6wR8NXjtWlBVAzwQmTh5pHVrs=
+charm.land/fantasy v0.16.0/go.mod h1:VZjpXVh7IgeiIzGQybEnKzd68ofDsRj94+kzH1ZCAfQ=
 charm.land/glamour/v2 v2.0.0 h1:IDBoqLEy7Hdpb9VOXN+khLP/XSxtJy1VsHuW/yF87+U=
 charm.land/glamour/v2 v2.0.0/go.mod h1:kjq9WB0s8vuUYZNYey2jp4Lgd9f4cKdzAw88FZtpj/w=
 charm.land/lipgloss/v2 v2.0.2 h1:xFolbF8JdpNkM2cEPTfXEcW1p6NRzOWTSamRfYEw8cs=
@@ -56,34 +56,34 @@ github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kk
 github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
 github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
 github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
-github.com/aws/aws-sdk-go-v2 v1.41.3 h1:4kQ/fa22KjDt13QCy1+bYADvdgcxpfH18f0zP542kZA=
-github.com/aws/aws-sdk-go-v2 v1.41.3/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
-github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.5 h1:zWFmPmgw4sveAYi1mRqG+E/g0461cJ5M4bJ8/nc6d3Q=
-github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.5/go.mod h1:nVUlMLVV8ycXSb7mSkcNu9e3v/1TJq2RTlrPwhYWr5c=
-github.com/aws/aws-sdk-go-v2/config v1.32.11 h1:ftxI5sgz8jZkckuUHXfC/wMUc8u3fG1vQS0plr2F2Zs=
-github.com/aws/aws-sdk-go-v2/config v1.32.11/go.mod h1:twF11+6ps9aNRKEDimksp923o44w/Thk9+8YIlzWMmo=
-github.com/aws/aws-sdk-go-v2/credentials v1.19.11 h1:NdV8cwCcAXrCWyxArt58BrvZJ9pZ9Fhf9w6Uh5W3Uyc=
-github.com/aws/aws-sdk-go-v2/credentials v1.19.11/go.mod h1:30yY2zqkMPdrvxBqzI9xQCM+WrlrZKSOpSJEsylVU+8=
-github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.19 h1:INUvJxmhdEbVulJYHI061k4TVuS3jzzthNvjqvVvTKM=
-github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.19/go.mod h1:FpZN2QISLdEBWkayloda+sZjVJL+e9Gl0k1SyTgcswU=
-github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.19 h1:/sECfyq2JTifMI2JPyZ4bdRN77zJmr6SrS1eL3augIA=
-github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.19/go.mod h1:dMf8A5oAqr9/oxOfLkC/c2LU/uMcALP0Rgn2BD5LWn0=
-github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.19 h1:AWeJMk33GTBf6J20XJe6qZoRSJo0WfUhsMdUKhoODXE=
-github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.19/go.mod h1:+GWrYoaAsV7/4pNHpwh1kiNLXkKaSoppxQq9lbH8Ejw=
-github.com/aws/aws-sdk-go-v2/internal/ini v1.8.5 h1:clHU5fm//kWS1C2HgtgWxfQbFbx4b6rx+5jzhgX9HrI=
-github.com/aws/aws-sdk-go-v2/internal/ini v1.8.5/go.mod h1:O3h0IK87yXci+kg6flUKzJnWeziQUKciKrLjcatSNcY=
-github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.6 h1:XAq62tBTJP/85lFD5oqOOe7YYgWxY9LvWq8plyDvDVg=
-github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.6/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI=
-github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.19 h1:X1Tow7suZk9UCJHE1Iw9GMZJJl0dAnKXXP1NaSDHwmw=
-github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.19/go.mod h1:/rARO8psX+4sfjUQXp5LLifjUt8DuATZ31WptNJTyQA=
-github.com/aws/aws-sdk-go-v2/service/signin v1.0.7 h1:Y2cAXlClHsXkkOvWZFXATr34b0hxxloeQu/pAZz2row=
-github.com/aws/aws-sdk-go-v2/service/signin v1.0.7/go.mod h1:idzZ7gmDeqeNrSPkdbtMp9qWMgcBwykA7P7Rzh5DXVU=
-github.com/aws/aws-sdk-go-v2/service/sso v1.30.12 h1:iSsvB9EtQ09YrsmIc44Heqlx5ByGErqhPK1ZQLppias=
-github.com/aws/aws-sdk-go-v2/service/sso v1.30.12/go.mod h1:fEWYKTRGoZNl8tZ77i61/ccwOMJdGxwOhWCkp6TXAr0=
-github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.16 h1:EnUdUqRP1CNzt2DkV67tJx6XDN4xlfBFm+bzeNOQVb0=
-github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.16/go.mod h1:Jic/xv0Rq/pFNCh3WwpH4BEqdbSAl+IyHro8LbibHD8=
-github.com/aws/aws-sdk-go-v2/service/sts v1.41.8 h1:XQTQTF75vnug2TXS8m7CVJfC2nniYPZnO1D4Np761Oo=
-github.com/aws/aws-sdk-go-v2/service/sts v1.41.8/go.mod h1:Xgx+PR1NUOjNmQY+tRMnouRp83JRM8pRMw/vCaVhPkI=
+github.com/aws/aws-sdk-go-v2 v1.41.4 h1:10f50G7WyU02T56ox1wWXq+zTX9I1zxG46HYuG1hH/k=
+github.com/aws/aws-sdk-go-v2 v1.41.4/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
+github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.6 h1:N4lRUXZpZ1KVEUn6hxtco/1d2lgYhNn1fHkkl8WhlyQ=
+github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.6/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI=
+github.com/aws/aws-sdk-go-v2/config v1.32.12 h1:O3csC7HUGn2895eNrLytOJQdoL2xyJy0iYXhoZ1OmP0=
+github.com/aws/aws-sdk-go-v2/config v1.32.12/go.mod h1:96zTvoOFR4FURjI+/5wY1vc1ABceROO4lWgWJuxgy0g=
+github.com/aws/aws-sdk-go-v2/credentials v1.19.12 h1:oqtA6v+y5fZg//tcTWahyN9PEn5eDU/Wpvc2+kJ4aY8=
+github.com/aws/aws-sdk-go-v2/credentials v1.19.12/go.mod h1:U3R1RtSHx6NB0DvEQFGyf/0sbrpJrluENHdPy1j/3TE=
+github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20 h1:zOgq3uezl5nznfoK3ODuqbhVg1JzAGDUhXOsU0IDCAo=
+github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20/go.mod h1:z/MVwUARehy6GAg/yQ1GO2IMl0k++cu1ohP9zo887wE=
+github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20 h1:CNXO7mvgThFGqOFgbNAP2nol2qAWBOGfqR/7tQlvLmc=
+github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20/go.mod h1:oydPDJKcfMhgfcgBUZaG+toBbwy8yPWubJXBVERtI4o=
+github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20 h1:tN6W/hg+pkM+tf9XDkWUbDEjGLb+raoBMFsTodcoYKw=
+github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20/go.mod h1:YJ898MhD067hSHA6xYCx5ts/jEd8BSOLtQDL3iZsvbc=
+github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 h1:qYQ4pzQ2Oz6WpQ8T3HvGHnZydA72MnLuFK9tJwmrbHw=
+github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6/go.mod h1:O3h0IK87yXci+kg6flUKzJnWeziQUKciKrLjcatSNcY=
+github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 h1:5EniKhLZe4xzL7a+fU3C2tfUN4nWIqlLesfrjkuPFTY=
+github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI=
+github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20 h1:2HvVAIq+YqgGotK6EkMf+KIEqTISmTYh5zLpYyeTo1Y=
+github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20/go.mod h1:V4X406Y666khGa8ghKmphma/7C0DAtEQYhkq9z4vpbk=
+github.com/aws/aws-sdk-go-v2/service/signin v1.0.8 h1:0GFOLzEbOyZABS3PhYfBIx2rNBACYcKty+XGkTgw1ow=
+github.com/aws/aws-sdk-go-v2/service/signin v1.0.8/go.mod h1:LXypKvk85AROkKhOG6/YEcHFPoX+prKTowKnVdcaIxE=
+github.com/aws/aws-sdk-go-v2/service/sso v1.30.13 h1:kiIDLZ005EcKomYYITtfsjn7dtOwHDOFy7IbPXKek2o=
+github.com/aws/aws-sdk-go-v2/service/sso v1.30.13/go.mod h1:2h/xGEowcW/g38g06g3KpRWDlT+OTfxxI0o1KqayAB8=
+github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.17 h1:jzKAXIlhZhJbnYwHbvUQZEB8KfgAEuG0dc08Bkda7NU=
+github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.17/go.mod h1:Al9fFsXjv4KfbzQHGe6V4NZSZQXecFcvaIF4e70FoRA=
+github.com/aws/aws-sdk-go-v2/service/sts v1.41.9 h1:Cng+OOwCHmFljXIxpEVXAGMnBia8MSU6Ch5i9PgBkcU=
+github.com/aws/aws-sdk-go-v2/service/sts v1.41.9/go.mod h1:LrlIndBDdjA/EeXeyNBle+gyCwTlizzW5ycgWnvIxkk=
 github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng=
 github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
 github.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:
 github.com/charmbracelet/anthropic-sdk-go v0.0.0-20260223140439-63879b0b8dab/go.mod h1:hqlYqR7uPKOKfnNeicUbZp0Ps0GeYFlKYtwh5HGDCx8=
 github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
 github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
+github.com/charmbracelet/openai-go v0.0.0-20260319145158-d0740cc34266 h1:BW/sZtyd1JyYy0h5adMm3tzpNyL857LWjuTRET6OhpY=
+github.com/charmbracelet/openai-go v0.0.0-20260319145158-d0740cc34266/go.mod h1:1DahUaExbUZx/jD+FNT2PKP4L9rLE5+ZBRuI8mZjd/E=
 github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 h1:eyFRbAmexyt43hVfeyBofiGSEmJ7krjLOYt/9CF5NKA=
 github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8/go.mod h1:SQpCTRNBtzJkwku5ye4S3HEuthAlGy2n9VXZnWkEW98=
 github.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=
 github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
 github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
 github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
-github.com/googleapis/enterprise-certificate-proxy v0.3.12 h1:Fg+zsqzYEs1ZnvmcztTYxhgCBsx3eEhEwQ1W/lHq/sQ=
-github.com/googleapis/enterprise-certificate-proxy v0.3.12/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg=
+github.com/googleapis/enterprise-certificate-proxy v0.3.14 h1:yh8ncqsbUY4shRD5dA6RlzjJaT4hi3kII+zYw8wmLb8=
+github.com/googleapis/enterprise-certificate-proxy v0.3.14/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg=
 github.com/googleapis/gax-go/v2 v2.17.0 h1:RksgfBpxqff0EZkDWYuz9q/uWsTVz+kf43LsZ1J6SMc=
 github.com/googleapis/gax-go/v2 v2.17.0/go.mod h1:mzaqghpQp4JDh3HvADwrat+6M3MOIDp5YKHhb9PAgDY=
 github.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
 github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
 github.com/nxadm/tail v1.4.11 h1:8feyoE3OzPrcshW5/MJ4sGESc5cqmGkGCWlco4l0bqY=
 github.com/nxadm/tail v1.4.11/go.mod h1:OTaG3NK980DZzxbRq6lEuzgU+mug70nY11sMd4JXXHc=
-github.com/openai/openai-go/v3 v3.28.0 h1:2+FfrCVMdGXSQrBv1tLWtokm+BU7+3hJ/8rAHPQ63KM=
-github.com/openai/openai-go/v3 v3.28.0/go.mod h1:cdufnVK14cWcT9qA1rRtrXx4FTRsgbDPW7Ia7SS5cZo=
 github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
 github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
 github.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=
 github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
 go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
 go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
-go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.65.0 h1:XmiuHzgJt067+a6kwyAzkhXooYVv3/TOw9cM2VfJgUM=
-go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.65.0/go.mod h1:KDgtbWKTQs4bM+VPUr6WlL9m/WXcmkCcBlIzqxPGzmI=
-go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 h1:7iP2uCb7sGddAr30RRS6xjKy7AZ2JtTOPA3oolgVSw8=
-go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0/go.mod h1:c7hN3ddxs/z6q9xwvfLPk+UHlWRQyaeR1LdgfL/66l0=
-go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms=
-go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g=
-go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g=
-go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc=
-go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8=
-go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE=
-go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw=
-go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg=
-go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw=
-go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA=
+go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 h1:yI1/OhfEPy7J9eoa6Sj051C7n5dvpj0QX8g4sRchg04=
+go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0/go.mod h1:NoUCKYWK+3ecatC4HjkRktREheMeEtrXoQxrqYFeHSc=
+go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o=
+go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg=
+go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho=
+go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc=
+go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4=
+go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI=
+go.opentelemetry.io/otel/sdk v1.42.0 h1:LyC8+jqk6UJwdrI/8VydAq/hvkFKNHZVIWuslJXYsDo=
+go.opentelemetry.io/otel/sdk v1.42.0/go.mod h1:rGHCAxd9DAph0joO4W6OPwxjNTYWghRWmkHuGbayMts=
+go.opentelemetry.io/otel/sdk/metric v1.42.0 h1:D/1QR46Clz6ajyZ3G8SgNlTJKBdGp84q9RKCAZ3YGuA=
+go.opentelemetry.io/otel/sdk/metric v1.42.0/go.mod h1:Ua6AAlDKdZ7tdvaQKfSmnFTdHx37+J4ba8MwVCYM5hc=
+go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY=
+go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc=
 go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
 go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
 go.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=
 golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
 golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
 golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
-golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
-golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
+golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
+golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
 golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
 golang.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
 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
 gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
-google.golang.org/api v0.269.0 h1:qDrTOxKUQ/P0MveH6a7vZ+DNHxJQjtGm/uvdbdGXCQg=
-google.golang.org/api v0.269.0/go.mod h1:N8Wpcu23Tlccl0zSHEkcAZQKDLdquxK+l9r2LkwAauE=
-google.golang.org/genai v1.49.0 h1:Se+QJaH2GYK1aaR1o5S38mlU2GD5FnVvP76nfkV7LH0=
-google.golang.org/genai v1.49.0/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk=
+google.golang.org/api v0.270.0 h1:4rJZbIuWSTohczG9mG2ukSDdt9qKx4sSSHIydTN26L4=
+google.golang.org/api v0.270.0/go.mod h1:5+H3/8DlXpQWrSz4RjGGwz5HfJAQSEI8Bc6JqQNH77U=
+google.golang.org/genai v1.50.0 h1:yHKV/vjoeN9PJ3iF0ur4cBZco4N3Kl7j09rMq7XSoWk=
+google.golang.org/genai v1.50.0/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk=
 google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ=
 google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
-google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
-google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
+google.golang.org/grpc v1.79.2 h1:fRMD94s2tITpyJGtBBn7MkMseNpOZU8ZxgC3MMBaXRU=
+google.golang.org/grpc v1.79.2/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
 google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
 google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
 gopkg.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=
 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
 modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
-modernc.org/ccgo/v4 v4.30.2 h1:4yPaaq9dXYXZ2V8s1UgrC3KIj580l2N4ClrLwnbv2so=
-modernc.org/ccgo/v4 v4.30.2/go.mod h1:yZMnhWEdW0qw3EtCndG1+ldRrVGS+bIwyWmAWzS0XEw=
-modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
-modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
+modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw=
+modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0=
+modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
+modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
 modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
 modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
 modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
 modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
 modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
 modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
-modernc.org/libc v1.68.0 h1:PJ5ikFOV5pwpW+VqCK1hKJuEWsonkIJhhIXyuF/91pQ=
-modernc.org/libc v1.68.0/go.mod h1:NnKCYeoYgsEqnY3PgvNgAeaJnso968ygU8Z0DxjoEc0=
+modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw=
+modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo=
 modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
 modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
 modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
@@ -599,8 +599,8 @@ modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
 modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
 modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
 modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
-modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU=
-modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
+modernc.org/sqlite v1.47.0 h1:R1XyaNpoW4Et9yly+I2EeX7pBza/w+pmYee/0HJDyKk=
+modernc.org/sqlite v1.47.0/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=
 modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
 modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
 modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=

internal/agent/agent.go 🔗

@@ -266,6 +266,9 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy
 				prepared.Messages[i].ProviderOptions = nil
 			}
 
+			// Use latest tools (updated by SetTools when MCP tools change).
+			prepared.Tools = a.tools.Copy()
+
 			queuedCalls, _ := a.messageQueue.Get(call.SessionID)
 			a.messageQueue.Del(call.SessionID)
 			for _, queued := range queuedCalls {

internal/agent/coordinator.go 🔗

@@ -41,7 +41,7 @@ import (
 	"charm.land/fantasy/providers/openaicompat"
 	"charm.land/fantasy/providers/openrouter"
 	"charm.land/fantasy/providers/vercel"
-	openaisdk "github.com/openai/openai-go/v3/option"
+	openaisdk "github.com/charmbracelet/openai-go/option"
 	"github.com/qjebbs/go-jsons"
 )
 

internal/agent/tools/mcp-tools.go 🔗

@@ -3,6 +3,7 @@ package tools
 import (
 	"context"
 	"fmt"
+	"slices"
 
 	"charm.land/fantasy"
 	"github.com/charmbracelet/crush/internal/agent/tools/mcp"
@@ -10,6 +11,15 @@ import (
 	"github.com/charmbracelet/crush/internal/permission"
 )
 
+// whitelistDockerTools contains Docker MCP tools that don't require permission.
+var whitelistDockerTools = []string{
+	"mcp_docker_mcp-find",
+	"mcp_docker_mcp-add",
+	"mcp_docker_mcp-remove",
+	"mcp_docker_mcp-config-set",
+	"mcp_docker_code-mode",
+}
+
 // GetMCPTools gets all the currently available MCP tools.
 func GetMCPTools(permissions permission.Service, cfg *config.ConfigStore, wd string) []*Tool {
 	var result []*Tool
@@ -91,23 +101,27 @@ func (m *Tool) Run(ctx context.Context, params fantasy.ToolCall) (fantasy.ToolRe
 	if sessionID == "" {
 		return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for creating a new file")
 	}
-	permissionDescription := fmt.Sprintf("execute %s with the following parameters:", m.Info().Name)
-	p, err := m.permissions.Request(ctx,
-		permission.CreatePermissionRequest{
-			SessionID:   sessionID,
-			ToolCallID:  params.ID,
-			Path:        m.workingDir,
-			ToolName:    m.Info().Name,
-			Action:      "execute",
-			Description: permissionDescription,
-			Params:      params.Input,
-		},
-	)
-	if err != nil {
-		return fantasy.ToolResponse{}, err
-	}
-	if !p {
-		return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
+
+	// Skip permission for whitelisted Docker MCP tools.
+	if !slices.Contains(whitelistDockerTools, params.Name) {
+		permissionDescription := fmt.Sprintf("execute %s with the following parameters:", m.Info().Name)
+		p, err := m.permissions.Request(ctx,
+			permission.CreatePermissionRequest{
+				SessionID:   sessionID,
+				ToolCallID:  params.ID,
+				Path:        m.workingDir,
+				ToolName:    m.Info().Name,
+				Action:      "execute",
+				Description: permissionDescription,
+				Params:      params.Input,
+			},
+		)
+		if err != nil {
+			return fantasy.ToolResponse{}, err
+		}
+		if !p {
+			return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
+		}
 	}
 
 	result, err := mcp.RunTool(ctx, m.cfg, m.mcpName, m.tool.Name, params.Input)

internal/agent/tools/mcp/init.go 🔗

@@ -175,8 +175,6 @@ func Initialize(ctx context.Context, permissions permission.Service, cfg *config
 		}
 
 		// Set initial starting state
-		updateState(name, StateStarting, nil, nil, Counts{})
-
 		wg.Add(1)
 		go func(name string, m config.MCPConfig) {
 			defer func() {
@@ -196,46 +194,9 @@ func Initialize(ctx context.Context, permissions permission.Service, cfg *config
 				}
 			}()
 
-			// createSession handles its own timeout internally.
-			session, err := createSession(ctx, name, m, cfg.Resolver())
-			if err != nil {
-				return
-			}
-
-			tools, err := getTools(ctx, session)
-			if err != nil {
-				slog.Error("Error listing tools", "error", err)
-				updateState(name, StateError, err, nil, Counts{})
-				session.Close()
-				return
-			}
-
-			prompts, err := getPrompts(ctx, session)
-			if err != nil {
-				slog.Error("Error listing prompts", "error", err)
-				updateState(name, StateError, err, nil, Counts{})
-				session.Close()
-				return
-			}
-
-			resources, err := getResources(ctx, session)
-			if err != nil {
-				slog.Error("Error listing resources", "error", err)
-				updateState(name, StateError, err, nil, Counts{})
-				session.Close()
-				return
+			if err := initClient(ctx, cfg, name, m, cfg.Resolver()); err != nil {
+				slog.Debug("failed to initialize mcp client", "name", name, "error", err)
 			}
-
-			toolCount := updateTools(cfg, name, tools)
-			updatePrompts(name, prompts)
-			resourceCount := updateResources(name, resources)
-			sessions.Set(name, session)
-
-			updateState(name, StateConnected, nil, session, Counts{
-				Tools:     toolCount,
-				Prompts:   len(prompts),
-				Resources: resourceCount,
-			})
 		}(name, m)
 	}
 	wg.Wait()
@@ -253,6 +214,85 @@ func WaitForInit(ctx context.Context) error {
 	}
 }
 
+// InitializeSingle initializes a single MCP client by name.
+func InitializeSingle(ctx context.Context, name string, cfg *config.ConfigStore) error {
+	m, exists := cfg.Config().MCP[name]
+	if !exists {
+		return fmt.Errorf("mcp '%s' not found in configuration", name)
+	}
+
+	if m.Disabled {
+		updateState(name, StateDisabled, nil, nil, Counts{})
+		slog.Debug("skipping disabled mcp", "name", name)
+		return nil
+	}
+
+	return initClient(ctx, cfg, name, m, cfg.Resolver())
+}
+
+// initClient initializes a single MCP client with the given configuration.
+func initClient(ctx context.Context, cfg *config.ConfigStore, name string, m config.MCPConfig, resolver config.VariableResolver) error {
+	// Set initial starting state.
+	updateState(name, StateStarting, nil, nil, Counts{})
+
+	// createSession handles its own timeout internally.
+	session, err := createSession(ctx, name, m, resolver)
+	if err != nil {
+		return err
+	}
+
+	tools, err := getTools(ctx, session)
+	if err != nil {
+		slog.Error("Error listing tools", "error", err)
+		updateState(name, StateError, err, nil, Counts{})
+		session.Close()
+		return err
+	}
+
+	prompts, err := getPrompts(ctx, session)
+	if err != nil {
+		slog.Error("Error listing prompts", "error", err)
+		updateState(name, StateError, err, nil, Counts{})
+		session.Close()
+		return err
+	}
+
+	toolCount := updateTools(cfg, name, tools)
+	updatePrompts(name, prompts)
+	sessions.Set(name, session)
+
+	updateState(name, StateConnected, nil, session, Counts{
+		Tools:   toolCount,
+		Prompts: len(prompts),
+	})
+
+	return nil
+}
+
+// DisableSingle disables and closes a single MCP client by name.
+func DisableSingle(cfg *config.ConfigStore, name string) error {
+	session, ok := sessions.Get(name)
+	if ok {
+		if err := session.Close(); err != nil &&
+			!errors.Is(err, io.EOF) &&
+			!errors.Is(err, context.Canceled) &&
+			err.Error() != "signal: killed" {
+			slog.Warn("error closing mcp session", "name", name, "error", err)
+		}
+		sessions.Del(name)
+	}
+
+	// Clear tools and prompts for this MCP.
+	updateTools(cfg, name, nil)
+	updatePrompts(name, nil)
+
+	// Update state to disabled.
+	updateState(name, StateDisabled, nil, nil, Counts{})
+
+	slog.Info("Disabled mcp client", "name", name)
+	return nil
+}
+
 func getOrRenewClient(ctx context.Context, cfg *config.ConfigStore, name string) (*ClientSession, error) {
 	sess, ok := sessions.Get(name)
 	if !ok {

internal/agent/tools/mcp/tools.go 🔗

@@ -2,6 +2,7 @@ package mcp
 
 import (
 	"context"
+	"encoding/base64"
 	"encoding/json"
 	"fmt"
 	"iter"
@@ -81,12 +82,13 @@ func RunTool(ctx context.Context, cfg *config.ConfigStore, name, toolName string
 
 	textContent := strings.Join(textParts, "\n")
 
-	// MCP SDK returns Data as already base64-encoded, so we use it directly.
+	// We need to make sure the data is base64
+	// when using something like docker + playwright the data was not returned correctly.
 	if imageData != nil {
 		return ToolResult{
 			Type:      "image",
 			Content:   textContent,
-			Data:      imageData,
+			Data:      ensureBase64(imageData),
 			MediaType: imageMimeType,
 		}, nil
 	}
@@ -95,7 +97,7 @@ func RunTool(ctx context.Context, cfg *config.ConfigStore, name, toolName string
 		return ToolResult{
 			Type:      "media",
 			Content:   textContent,
-			Data:      audioData,
+			Data:      ensureBase64(audioData),
 			MediaType: audioMimeType,
 		}, nil
 	}
@@ -164,3 +166,71 @@ func filterDisabledTools(cfg *config.ConfigStore, mcpName string, tools []*Tool)
 	}
 	return filtered
 }
+
+// ensureBase64 normalizes valid base64 input and guarantees padded
+// base64.StdEncoding output; otherwise it encodes raw binary data.
+func ensureBase64(data []byte) []byte {
+	if len(data) == 0 {
+		return data
+	}
+
+	normalized := normalizeBase64Input(data)
+	if decoded, ok := decodeBase64(normalized); ok {
+		encoded := make([]byte, base64.StdEncoding.EncodedLen(len(decoded)))
+		base64.StdEncoding.Encode(encoded, decoded)
+		return encoded
+	}
+
+	encoded := make([]byte, base64.StdEncoding.EncodedLen(len(data)))
+	base64.StdEncoding.Encode(encoded, data)
+	return encoded
+}
+
+func normalizeBase64Input(data []byte) []byte {
+	normalized := strings.Join(strings.Fields(string(data)), "")
+	return []byte(normalized)
+}
+
+func decodeBase64(data []byte) ([]byte, bool) {
+	if len(data) == 0 {
+		return data, true
+	}
+
+	for _, b := range data {
+		if b > 127 {
+			return nil, false
+		}
+	}
+
+	s := string(data)
+	decoded, err := base64.StdEncoding.DecodeString(s)
+	if err == nil {
+		return decoded, true
+	}
+	decoded, err = base64.RawStdEncoding.DecodeString(s)
+	if err == nil {
+		return decoded, true
+	}
+	return nil, false
+}
+
+// isValidBase64 checks if the data appears to be valid base64-encoded content.
+func isValidBase64(data []byte) bool {
+	if len(data) == 0 {
+		return true
+	}
+
+	// Base64 strings should only contain ASCII characters.
+	for _, b := range data {
+		if b > 127 {
+			return false
+		}
+	}
+
+	s := string(data)
+	if _, err := base64.StdEncoding.DecodeString(s); err == nil {
+		return true
+	}
+	_, err := base64.RawStdEncoding.DecodeString(s)
+	return err == nil
+}

internal/agent/tools/mcp/tools_test.go 🔗

@@ -0,0 +1,125 @@
+package mcp
+
+import (
+	"encoding/base64"
+	"testing"
+
+	"github.com/stretchr/testify/require"
+)
+
+func TestEnsureBase64(t *testing.T) {
+	t.Parallel()
+
+	tests := []struct {
+		name     string
+		input    []byte
+		wantData []byte // expected output
+	}{
+		{
+			name:     "already base64 encoded",
+			input:    []byte("SGVsbG8gV29ybGQh"), // "Hello World!" in base64
+			wantData: []byte("SGVsbG8gV29ybGQh"),
+		},
+		{
+			name:     "raw binary data (PNG header)",
+			input:    []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A},
+			wantData: []byte(base64.StdEncoding.EncodeToString([]byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A})),
+		},
+		{
+			name:     "raw binary with high bytes",
+			input:    []byte{0xFF, 0xD8, 0xFF, 0xE0}, // JPEG header
+			wantData: []byte(base64.StdEncoding.EncodeToString([]byte{0xFF, 0xD8, 0xFF, 0xE0})),
+		},
+		{
+			name:     "empty data",
+			input:    []byte{},
+			wantData: []byte{},
+		},
+		{
+			name:     "base64 with padding",
+			input:    []byte("YQ=="), // "a" in base64
+			wantData: []byte("YQ=="),
+		},
+		{
+			name:     "base64 without padding",
+			input:    []byte("YQ"),
+			wantData: []byte("YQ=="),
+		},
+		{
+			name:     "base64 with whitespace",
+			input:    []byte("U0dWc2JHOGdWMjl5YkdRaA==\n"),
+			wantData: []byte("U0dWc2JHOGdWMjl5YkdRaA=="),
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			t.Parallel()
+			result := ensureBase64(tt.input)
+			require.Equal(t, tt.wantData, result)
+
+			// Verify the result is valid base64 that can be decoded.
+			if len(result) > 0 {
+				_, err := base64.StdEncoding.DecodeString(string(result))
+				if err != nil {
+					_, err = base64.RawStdEncoding.DecodeString(string(result))
+				}
+				require.NoError(t, err, "result should be valid base64")
+			}
+		})
+	}
+}
+
+func TestIsValidBase64(t *testing.T) {
+	t.Parallel()
+
+	tests := []struct {
+		name  string
+		input []byte
+		want  bool
+	}{
+		{
+			name:  "valid base64",
+			input: []byte("SGVsbG8gV29ybGQh"),
+			want:  true,
+		},
+		{
+			name:  "valid base64 with padding",
+			input: []byte("YQ=="),
+			want:  true,
+		},
+		{
+			name:  "raw binary with high bytes",
+			input: []byte{0xFF, 0xD8, 0xFF},
+			want:  false,
+		},
+		{
+			name:  "empty",
+			input: []byte{},
+			want:  true,
+		},
+		{
+			name:  "valid raw base64 without padding",
+			input: []byte("YQ"),
+			want:  true,
+		},
+		{
+			name:  "valid base64 with whitespace",
+			input: normalizeBase64Input([]byte("U0dWc2JHOGdWMjl5YkdRaA==\n")),
+			want:  true,
+		},
+		{
+			name:  "invalid base64 characters",
+			input: []byte("SGVsbG8!@#$"),
+			want:  false,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			t.Parallel()
+			got := isValidBase64(tt.input)
+			require.Equal(t, tt.want, got)
+		})
+	}
+}

internal/backend/config.go 🔗

@@ -2,6 +2,8 @@ package backend
 
 import (
 	"context"
+	"errors"
+	"fmt"
 
 	"github.com/charmbracelet/crush/internal/agent"
 	mcptools "github.com/charmbracelet/crush/internal/agent/tools/mcp"
@@ -114,6 +116,53 @@ func (b *Backend) InitializePrompt(workspaceID string) (string, error) {
 	return agent.InitializePrompt(ws.Cfg)
 }
 
+// EnableDockerMCP validates Docker MCP availability, stages the
+// configuration, starts the MCP client, and persists the config.
+func (b *Backend) EnableDockerMCP(ctx context.Context, workspaceID string) error {
+	ws, err := b.GetWorkspace(workspaceID)
+	if err != nil {
+		return err
+	}
+
+	mcpConfig, err := ws.Cfg.PrepareDockerMCPConfig()
+	if err != nil {
+		return err
+	}
+
+	if err := mcptools.InitializeSingle(ctx, config.DockerMCPName, ws.Cfg); err != nil {
+		disableErr := mcptools.DisableSingle(ws.Cfg, config.DockerMCPName)
+		delete(ws.Cfg.Config().MCP, config.DockerMCPName)
+		return fmt.Errorf("failed to start docker MCP: %w", errors.Join(err, disableErr))
+	}
+
+	if err := ws.Cfg.PersistDockerMCPConfig(mcpConfig); err != nil {
+		disableErr := mcptools.DisableSingle(ws.Cfg, config.DockerMCPName)
+		delete(ws.Cfg.Config().MCP, config.DockerMCPName)
+		return fmt.Errorf("docker MCP started but failed to persist configuration: %w", errors.Join(err, disableErr))
+	}
+
+	return nil
+}
+
+// DisableDockerMCP closes the Docker MCP client, removes the
+// configuration, and persists the change.
+func (b *Backend) DisableDockerMCP(workspaceID string) error {
+	ws, err := b.GetWorkspace(workspaceID)
+	if err != nil {
+		return err
+	}
+
+	if err := mcptools.DisableSingle(ws.Cfg, config.DockerMCPName); err != nil {
+		return fmt.Errorf("failed to disable docker MCP: %w", err)
+	}
+
+	if err := ws.Cfg.DisableDockerMCP(); err != nil {
+		return err
+	}
+
+	return nil
+}
+
 // RefreshMCPTools refreshes the tools for a named MCP server.
 func (b *Backend) RefreshMCPTools(ctx context.Context, workspaceID, name string) error {
 	ws, err := b.GetWorkspace(workspaceID)

internal/client/config.go 🔗

@@ -193,6 +193,32 @@ type MCPResourceContents struct {
 	Blob     []byte `json:"blob,omitempty"`
 }
 
+// EnableDockerMCP enables the Docker MCP server on the workspace.
+func (c *Client) EnableDockerMCP(ctx context.Context, id string) error {
+	rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/mcp/docker/enable", id), nil, nil, nil)
+	if err != nil {
+		return fmt.Errorf("failed to enable docker MCP: %w", err)
+	}
+	defer rsp.Body.Close()
+	if rsp.StatusCode != http.StatusOK {
+		return fmt.Errorf("failed to enable docker MCP: status code %d", rsp.StatusCode)
+	}
+	return nil
+}
+
+// DisableDockerMCP disables the Docker MCP server on the workspace.
+func (c *Client) DisableDockerMCP(ctx context.Context, id string) error {
+	rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/mcp/docker/disable", id), nil, nil, nil)
+	if err != nil {
+		return fmt.Errorf("failed to disable docker MCP: %w", err)
+	}
+	defer rsp.Body.Close()
+	if rsp.StatusCode != http.StatusOK {
+		return fmt.Errorf("failed to disable docker MCP: status code %d", rsp.StatusCode)
+	}
+	return nil
+}
+
 // RefreshMCPTools refreshes tools for a named MCP server.
 func (c *Client) RefreshMCPTools(ctx context.Context, id, name string) error {
 	rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/mcp/refresh-tools", id), nil, jsonBody(struct {

internal/cmd/root.go 🔗

@@ -43,9 +43,12 @@ func init() {
 	rootCmd.PersistentFlags().StringP("cwd", "c", "", "Current working directory")
 	rootCmd.PersistentFlags().StringP("data-dir", "D", "", "Custom crush data directory")
 	rootCmd.PersistentFlags().BoolP("debug", "d", false, "Debug")
-	rootCmd.PersistentFlags().BoolP("yolo", "y", false, "Automatically accept all permissions (dangerous mode)")
 	rootCmd.PersistentFlags().StringVarP(&clientHost, "host", "H", server.DefaultHost(), "Connect to a specific crush server host (for advanced users)")
 	rootCmd.Flags().BoolP("help", "h", false, "Help")
+	rootCmd.Flags().BoolP("yolo", "y", false, "Automatically accept all permissions (dangerous mode)")
+	rootCmd.Flags().StringP("session", "s", "", "Continue a previous session by ID")
+	rootCmd.Flags().BoolP("continue", "C", false, "Continue the most recent session")
+	rootCmd.MarkFlagsMutuallyExclusive("session", "continue")
 
 	rootCmd.AddCommand(
 		runCmd,
@@ -82,14 +85,32 @@ crush --yolo
 
 # Run with custom data directory
 crush --data-dir /path/to/custom/.crush
+
+# Continue a previous session
+crush --session {session-id}
+
+# Continue the most recent session
+crush --continue
   `,
 	RunE: func(cmd *cobra.Command, args []string) error {
+		sessionID, _ := cmd.Flags().GetString("session")
+		continueLast, _ := cmd.Flags().GetBool("continue")
+
 		c, ws, cleanup, err := connectToServer(cmd)
 		if err != nil {
 			return err
 		}
 		defer cleanup()
 
+		// Resolve session ID if provided.
+		if sessionID != "" {
+			sess, err := resolveSessionByID(cmd.Context(), c, ws.ID, sessionID)
+			if err != nil {
+				return err
+			}
+			sessionID = sess.ID
+		}
+
 		event.AppInitialized()
 
 		clientWs := workspace.NewClientWorkspace(c, *ws)
@@ -101,7 +122,7 @@ crush --data-dir /path/to/custom/.crush
 		}
 
 		com := common.DefaultCommon(clientWs)
-		model := ui.New(com)
+		model := ui.New(com, sessionID, continueLast)
 
 		var env uv.Environ = os.Environ()
 		program := tea.NewProgram(

internal/cmd/run.go 🔗

@@ -27,8 +27,9 @@ import (
 )
 
 var runCmd = &cobra.Command{
-	Use:   "run [prompt...]",
-	Short: "Run a single non-interactive prompt",
+	Aliases: []string{"r"},
+	Use:     "run [prompt...]",
+	Short:   "Run a single non-interactive prompt",
 	Long: `Run a single prompt in non-interactive mode and exit.
 The prompt can be provided as arguments or piped from stdin.`,
 	Example: `

internal/cmd/session.go 🔗

@@ -30,7 +30,7 @@ import (
 
 var sessionCmd = &cobra.Command{
 	Use:     "session",
-	Aliases: []string{"sessions"},
+	Aliases: []string{"sessions", "s"},
 	Short:   "Manage sessions",
 	Long:    "Manage Crush sessions. Agents can use --json for machine-readable output.",
 }

internal/config/docker_mcp.go 🔗

@@ -0,0 +1,134 @@
+package config
+
+import (
+	"context"
+	"fmt"
+	"os/exec"
+	"sync"
+	"time"
+)
+
+var dockerMCPVersionRunner = func(ctx context.Context) error {
+	cmd := exec.CommandContext(ctx, "docker", "mcp", "version")
+	return cmd.Run()
+}
+
+const dockerMCPAvailabilityTTL = 10 * time.Second
+
+var dockerMCPAvailabilityCache struct {
+	mu        sync.Mutex
+	available bool
+	checkedAt time.Time
+	known     bool
+}
+
+// DockerMCPName is the name of the Docker MCP configuration.
+const DockerMCPName = "docker"
+
+// IsDockerMCPAvailable checks if Docker MCP is available by running
+// 'docker mcp version'.
+func IsDockerMCPAvailable() bool {
+	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+	defer cancel()
+
+	err := dockerMCPVersionRunner(ctx)
+	return err == nil
+}
+
+// DockerMCPAvailabilityCached returns the cached Docker MCP availability and
+// whether the cached value is still fresh.
+func DockerMCPAvailabilityCached() (available bool, known bool) {
+	dockerMCPAvailabilityCache.mu.Lock()
+	defer dockerMCPAvailabilityCache.mu.Unlock()
+
+	if !dockerMCPAvailabilityCache.known {
+		return false, false
+	}
+	if time.Since(dockerMCPAvailabilityCache.checkedAt) > dockerMCPAvailabilityTTL {
+		return dockerMCPAvailabilityCache.available, false
+	}
+	return dockerMCPAvailabilityCache.available, true
+}
+
+// RefreshDockerMCPAvailability refreshes and caches Docker MCP availability.
+func RefreshDockerMCPAvailability() bool {
+	available := IsDockerMCPAvailable()
+	dockerMCPAvailabilityCache.mu.Lock()
+	dockerMCPAvailabilityCache.available = available
+	dockerMCPAvailabilityCache.checkedAt = time.Now()
+	dockerMCPAvailabilityCache.known = true
+	dockerMCPAvailabilityCache.mu.Unlock()
+	return available
+}
+
+// IsDockerMCPEnabled checks if Docker MCP is already configured.
+func (c *Config) IsDockerMCPEnabled() bool {
+	if c.MCP == nil {
+		return false
+	}
+	_, exists := c.MCP[DockerMCPName]
+	return exists
+}
+
+// DockerMCPConfig returns the default Docker MCP stdio configuration.
+func DockerMCPConfig() MCPConfig {
+	return MCPConfig{
+		Type:     MCPStdio,
+		Command:  "docker",
+		Args:     []string{"mcp", "gateway", "run"},
+		Disabled: false,
+	}
+}
+
+// PrepareDockerMCPConfig validates Docker MCP availability and stages the
+// Docker MCP configuration in memory.
+func (s *ConfigStore) PrepareDockerMCPConfig() (MCPConfig, error) {
+	if !IsDockerMCPAvailable() {
+		return MCPConfig{}, fmt.Errorf("docker mcp is not available, please ensure docker is installed and 'docker mcp version' succeeds")
+	}
+
+	mcpConfig := DockerMCPConfig()
+	if s.config.MCP == nil {
+		s.config.MCP = make(map[string]MCPConfig)
+	}
+	s.config.MCP[DockerMCPName] = mcpConfig
+	return mcpConfig, nil
+}
+
+// PersistDockerMCPConfig persists a previously prepared Docker MCP
+// configuration to the global config file.
+func (s *ConfigStore) PersistDockerMCPConfig(mcpConfig MCPConfig) error {
+	if err := s.SetConfigField(ScopeGlobal, "mcp."+DockerMCPName, mcpConfig); err != nil {
+		return fmt.Errorf("failed to persist docker mcp configuration: %w", err)
+	}
+	return nil
+}
+
+// EnableDockerMCP adds Docker MCP configuration and persists it.
+func (s *ConfigStore) EnableDockerMCP() error {
+	mcpConfig, err := s.PrepareDockerMCPConfig()
+	if err != nil {
+		return err
+	}
+	if err := s.PersistDockerMCPConfig(mcpConfig); err != nil {
+		return err
+	}
+	return nil
+}
+
+// DisableDockerMCP removes Docker MCP configuration and persists the change.
+func (s *ConfigStore) DisableDockerMCP() error {
+	if s.config.MCP == nil {
+		return nil
+	}
+
+	// Remove from in-memory config.
+	delete(s.config.MCP, DockerMCPName)
+
+	// Persist the updated MCP map to the config file.
+	if err := s.SetConfigField(ScopeGlobal, "mcp", s.config.MCP); err != nil {
+		return fmt.Errorf("failed to persist docker mcp removal: %w", err)
+	}
+
+	return nil
+}

internal/config/docker_mcp_test.go 🔗

@@ -0,0 +1,193 @@
+package config
+
+import (
+	"context"
+	"errors"
+	"os"
+	"path/filepath"
+	"testing"
+
+	"github.com/charmbracelet/crush/internal/env"
+	"github.com/stretchr/testify/require"
+)
+
+var errDockerUnavailable = errors.New("docker unavailable")
+
+func setDockerMCPVersionRunner(t *testing.T, runner func(context.Context) error) {
+	t.Helper()
+	orig := dockerMCPVersionRunner
+	dockerMCPVersionRunner = runner
+	t.Cleanup(func() {
+		dockerMCPVersionRunner = orig
+	})
+}
+
+func TestIsDockerMCPEnabled(t *testing.T) {
+	t.Parallel()
+
+	t.Run("returns false when MCP is nil", func(t *testing.T) {
+		t.Parallel()
+		cfg := &Config{
+			MCP: nil,
+		}
+		require.False(t, cfg.IsDockerMCPEnabled())
+	})
+
+	t.Run("returns false when docker mcp not configured", func(t *testing.T) {
+		t.Parallel()
+		cfg := &Config{
+			MCP: make(map[string]MCPConfig),
+		}
+		require.False(t, cfg.IsDockerMCPEnabled())
+	})
+
+	t.Run("returns true when docker mcp is configured", func(t *testing.T) {
+		t.Parallel()
+		cfg := &Config{
+			MCP: map[string]MCPConfig{
+				DockerMCPName: {
+					Type:    MCPStdio,
+					Command: "docker",
+				},
+			},
+		}
+		require.True(t, cfg.IsDockerMCPEnabled())
+	})
+}
+
+func TestEnableDockerMCP(t *testing.T) {
+	t.Run("adds docker mcp to config", func(t *testing.T) {
+		setDockerMCPVersionRunner(t, func(context.Context) error { return nil })
+
+		// Create a temporary directory for config.
+		tmpDir := t.TempDir()
+		configPath := filepath.Join(tmpDir, "crush.json")
+
+		cfg := &Config{
+			MCP: make(map[string]MCPConfig),
+		}
+		store := &ConfigStore{
+			config:         cfg,
+			globalDataPath: configPath,
+			resolver:       NewShellVariableResolver(env.New()),
+		}
+
+		err := store.EnableDockerMCP()
+		require.NoError(t, err)
+
+		// Check in-memory config.
+		require.True(t, cfg.IsDockerMCPEnabled())
+		mcpConfig, exists := cfg.MCP[DockerMCPName]
+		require.True(t, exists)
+		require.Equal(t, MCPStdio, mcpConfig.Type)
+		require.Equal(t, "docker", mcpConfig.Command)
+		require.Equal(t, []string{"mcp", "gateway", "run"}, mcpConfig.Args)
+		require.False(t, mcpConfig.Disabled)
+
+		// Check persisted config.
+		data, err := os.ReadFile(configPath)
+		require.NoError(t, err)
+		require.Contains(t, string(data), "docker")
+		require.Contains(t, string(data), "gateway")
+	})
+
+	t.Run("fails when docker mcp not available", func(t *testing.T) {
+		setDockerMCPVersionRunner(t, func(context.Context) error { return errDockerUnavailable })
+
+		// Create a temporary directory for config.
+		tmpDir := t.TempDir()
+		configPath := filepath.Join(tmpDir, "crush.json")
+
+		cfg := &Config{
+			MCP: make(map[string]MCPConfig),
+		}
+		store := &ConfigStore{
+			config:         cfg,
+			globalDataPath: configPath,
+			resolver:       NewShellVariableResolver(env.New()),
+		}
+
+		err := store.EnableDockerMCP()
+		require.Error(t, err)
+		require.Contains(t, err.Error(), "docker mcp is not available")
+	})
+}
+
+func TestDisableDockerMCP(t *testing.T) {
+	t.Parallel()
+
+	t.Run("removes docker mcp from config", func(t *testing.T) {
+		t.Parallel()
+
+		// Create a temporary directory for config.
+		tmpDir := t.TempDir()
+		configPath := filepath.Join(tmpDir, "crush.json")
+
+		cfg := &Config{
+			MCP: map[string]MCPConfig{
+				DockerMCPName: {
+					Type:     MCPStdio,
+					Command:  "docker",
+					Args:     []string{"mcp", "gateway", "run"},
+					Disabled: false,
+				},
+			},
+		}
+		store := &ConfigStore{
+			config:         cfg,
+			globalDataPath: configPath,
+			resolver:       NewShellVariableResolver(env.New()),
+		}
+
+		// Verify it's enabled first.
+		require.True(t, cfg.IsDockerMCPEnabled())
+
+		err := store.DisableDockerMCP()
+		require.NoError(t, err)
+
+		// Check in-memory config.
+		require.False(t, cfg.IsDockerMCPEnabled())
+		_, exists := cfg.MCP[DockerMCPName]
+		require.False(t, exists)
+	})
+
+	t.Run("does nothing when MCP is nil", func(t *testing.T) {
+		t.Parallel()
+
+		cfg := &Config{
+			MCP: nil,
+		}
+		store := &ConfigStore{
+			config:         cfg,
+			globalDataPath: filepath.Join(t.TempDir(), "crush.json"),
+			resolver:       NewShellVariableResolver(env.New()),
+		}
+
+		err := store.DisableDockerMCP()
+		require.NoError(t, err)
+	})
+}
+
+func TestEnableDockerMCPWithRealDockerWhenAvailable(t *testing.T) {
+	t.Parallel()
+
+	if !IsDockerMCPAvailable() {
+		t.Skip("docker mcp not available on this machine")
+	}
+
+	tmpDir := t.TempDir()
+	configPath := filepath.Join(tmpDir, "crush.json")
+
+	cfg := &Config{
+		MCP: make(map[string]MCPConfig),
+	}
+	store := &ConfigStore{
+		config:         cfg,
+		globalDataPath: configPath,
+		resolver:       NewShellVariableResolver(env.New()),
+	}
+
+	err := store.EnableDockerMCP()
+	require.NoError(t, err)
+	require.True(t, cfg.IsDockerMCPEnabled())
+}

internal/server/config.go 🔗

@@ -259,6 +259,42 @@ func (c *controllerV1) handleGetWorkspaceProjectInitPrompt(w http.ResponseWriter
 	jsonEncode(w, proto.ProjectInitPromptResponse{Prompt: prompt})
 }
 
+// handlePostWorkspaceMCPEnableDocker enables the Docker MCP server.
+//
+//	@Summary		Enable Docker MCP
+//	@Tags			mcp
+//	@Param			id	path	string	true	"Workspace ID"
+//	@Success		200
+//	@Failure		404	{object}	proto.Error
+//	@Failure		500	{object}	proto.Error
+//	@Router			/workspaces/{id}/mcp/docker/enable [post]
+func (c *controllerV1) handlePostWorkspaceMCPEnableDocker(w http.ResponseWriter, r *http.Request) {
+	id := r.PathValue("id")
+	if err := c.backend.EnableDockerMCP(r.Context(), id); err != nil {
+		c.handleError(w, r, err)
+		return
+	}
+	w.WriteHeader(http.StatusOK)
+}
+
+// handlePostWorkspaceMCPDisableDocker disables the Docker MCP server.
+//
+//	@Summary		Disable Docker MCP
+//	@Tags			mcp
+//	@Param			id	path	string	true	"Workspace ID"
+//	@Success		200
+//	@Failure		404	{object}	proto.Error
+//	@Failure		500	{object}	proto.Error
+//	@Router			/workspaces/{id}/mcp/docker/disable [post]
+func (c *controllerV1) handlePostWorkspaceMCPDisableDocker(w http.ResponseWriter, r *http.Request) {
+	id := r.PathValue("id")
+	if err := c.backend.DisableDockerMCP(id); err != nil {
+		c.handleError(w, r, err)
+		return
+	}
+	w.WriteHeader(http.StatusOK)
+}
+
 // handlePostWorkspaceMCPRefreshTools refreshes tools for a named MCP server.
 //
 //	@Summary		Refresh MCP tools

internal/server/server.go 🔗

@@ -11,9 +11,9 @@ import (
 	"runtime"
 	"strings"
 
-	_ "github.com/charmbracelet/crush/internal/swagger"
 	"github.com/charmbracelet/crush/internal/backend"
 	"github.com/charmbracelet/crush/internal/config"
+	_ "github.com/charmbracelet/crush/internal/swagger"
 	httpswagger "github.com/swaggo/http-swagger/v2"
 )
 
@@ -163,6 +163,8 @@ func NewServer(cfg *config.ConfigStore, network, address string) *Server {
 	mux.HandleFunc("GET /v1/workspaces/{id}/mcp/states", c.handleGetWorkspaceMCPStates)
 	mux.HandleFunc("POST /v1/workspaces/{id}/mcp/refresh-prompts", c.handlePostWorkspaceMCPRefreshPrompts)
 	mux.HandleFunc("POST /v1/workspaces/{id}/mcp/refresh-resources", c.handlePostWorkspaceMCPRefreshResources)
+	mux.HandleFunc("POST /v1/workspaces/{id}/mcp/docker/enable", c.handlePostWorkspaceMCPEnableDocker)
+	mux.HandleFunc("POST /v1/workspaces/{id}/mcp/docker/disable", c.handlePostWorkspaceMCPDisableDocker)
 	mux.Handle("/v1/docs/", httpswagger.WrapHandler)
 	s.h = &http.Server{
 		Protocols: &p,

internal/ui/AGENTS.md 🔗

@@ -191,6 +191,7 @@ through all components that need access to app state or styles.
 - Always account for padding/borders in width calculations.
 - Use `tea.Batch()` when returning multiple commands.
 - Pass `*common.Common` to components that need styles or app access.
+- When writing tea.Cmd's prefer creating methods in the model instead of writing inline functions.
 - The `list.List` only renders visible items (lazy). No render cache exists
   at the list level — items should cache internally if rendering is
   expensive.

internal/ui/chat/docker_mcp.go 🔗

@@ -0,0 +1,296 @@
+package chat
+
+import (
+	"encoding/json"
+	"fmt"
+	"sort"
+	"strings"
+
+	"charm.land/lipgloss/v2"
+	"charm.land/lipgloss/v2/table"
+	"github.com/charmbracelet/crush/internal/config"
+	"github.com/charmbracelet/crush/internal/message"
+	"github.com/charmbracelet/crush/internal/stringext"
+	"github.com/charmbracelet/crush/internal/ui/styles"
+)
+
+// DockerMCPToolMessageItem is a message item that represents a Docker MCP tool call.
+type DockerMCPToolMessageItem struct {
+	*baseToolMessageItem
+}
+
+var _ ToolMessageItem = (*DockerMCPToolMessageItem)(nil)
+
+// NewDockerMCPToolMessageItem creates a new [DockerMCPToolMessageItem].
+func NewDockerMCPToolMessageItem(
+	sty *styles.Styles,
+	toolCall message.ToolCall,
+	result *message.ToolResult,
+	canceled bool,
+) ToolMessageItem {
+	return newBaseToolMessageItem(sty, toolCall, result, &DockerMCPToolRenderContext{}, canceled)
+}
+
+// DockerMCPToolRenderContext renders Docker MCP tool messages.
+type DockerMCPToolRenderContext struct{}
+
+// RenderTool implements the [ToolRenderer] interface.
+func (d *DockerMCPToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
+	cappedWidth := cappedMessageWidth(width)
+
+	var params map[string]any
+	if err := json.Unmarshal([]byte(opts.ToolCall.Input), &params); err != nil {
+		params = make(map[string]any)
+	}
+
+	tool := strings.TrimPrefix(opts.ToolCall.Name, "mcp_"+config.DockerMCPName+"_")
+
+	mainParam := opts.ToolCall.Input
+	extraArgs := map[string]string{}
+	switch tool {
+	case "mcp-find":
+		if query, ok := params["query"]; ok {
+			if qStr, ok := query.(string); ok {
+				mainParam = qStr
+			}
+		}
+		for k, v := range params {
+			if k == "query" {
+				continue
+			}
+			data, _ := json.Marshal(v)
+			extraArgs[k] = string(data)
+		}
+	case "mcp-add":
+		if name, ok := params["name"]; ok {
+			if nStr, ok := name.(string); ok {
+				mainParam = nStr
+			}
+		}
+		for k, v := range params {
+			if k == "name" {
+				continue
+			}
+			data, _ := json.Marshal(v)
+			extraArgs[k] = string(data)
+		}
+	case "mcp-remove":
+		if name, ok := params["name"]; ok {
+			if nStr, ok := name.(string); ok {
+				mainParam = nStr
+			}
+		}
+		for k, v := range params {
+			if k == "name" {
+				continue
+			}
+			data, _ := json.Marshal(v)
+			extraArgs[k] = string(data)
+		}
+	case "mcp-exec":
+		if name, ok := params["name"]; ok {
+			if nStr, ok := name.(string); ok {
+				mainParam = nStr
+			}
+		}
+	case "mcp-config-set":
+		if server, ok := params["server"]; ok {
+			if sStr, ok := server.(string); ok {
+				mainParam = sStr
+			}
+		}
+	}
+
+	var toolParams []string
+	toolParams = append(toolParams, mainParam)
+	keys := make([]string, 0, len(extraArgs))
+	for k := range extraArgs {
+		keys = append(keys, k)
+	}
+	sort.Strings(keys)
+	for _, k := range keys {
+		toolParams = append(toolParams, k, extraArgs[k])
+	}
+
+	if opts.IsPending() {
+		return pendingTool(sty, d.formatToolName(sty, tool), opts.Anim, false)
+	}
+
+	header := d.makeHeader(sty, tool, cappedWidth, opts, toolParams...)
+	if opts.Compact {
+		return header
+	}
+
+	if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok {
+		return joinToolParts(header, earlyState)
+	}
+
+	if tool == "mcp-find" {
+		return joinToolParts(header, d.renderMCPServers(sty, opts, cappedWidth))
+	}
+
+	if !opts.HasResult() {
+		return header
+	}
+
+	bodyWidth := cappedWidth - toolBodyLeftPaddingTotal
+	var parts []string
+
+	// Handle text content.
+	if opts.Result.Content != "" {
+		var body string
+		var result json.RawMessage
+		if err := json.Unmarshal([]byte(opts.Result.Content), &result); err == nil {
+			prettyResult, err := json.MarshalIndent(result, "", "  ")
+			if err == nil {
+				body = sty.Tool.Body.Render(toolOutputCodeContent(sty, "result.json", string(prettyResult), 0, bodyWidth, opts.ExpandedContent))
+			} else {
+				body = sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.ExpandedContent))
+			}
+		} else if looksLikeMarkdown(opts.Result.Content) {
+			body = sty.Tool.Body.Render(toolOutputCodeContent(sty, "result.md", opts.Result.Content, 0, bodyWidth, opts.ExpandedContent))
+		} else {
+			body = sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.ExpandedContent))
+		}
+		parts = append(parts, body)
+	}
+
+	// Handle image content.
+	if opts.Result.Data != "" && strings.HasPrefix(opts.Result.MIMEType, "image/") {
+		parts = append(parts, "", toolOutputImageContent(sty, opts.Result.Data, opts.Result.MIMEType))
+	}
+
+	if len(parts) == 0 {
+		return header
+	}
+
+	return joinToolParts(header, strings.Join(parts, "\n"))
+}
+
+// FindMCPResponse represents the response from mcp-find.
+type FindMCPResponse struct {
+	Servers []struct {
+		Name        string `json:"name"`
+		Description string `json:"description"`
+	} `json:"servers"`
+}
+
+func (d *DockerMCPToolRenderContext) renderMCPServers(sty *styles.Styles, opts *ToolRenderOpts, width int) string {
+	if !opts.HasResult() || opts.Result.Content == "" {
+		return ""
+	}
+
+	var result FindMCPResponse
+	if err := json.Unmarshal([]byte(opts.Result.Content), &result); err != nil {
+		return toolOutputPlainContent(sty, opts.Result.Content, width-toolBodyLeftPaddingTotal, opts.ExpandedContent)
+	}
+
+	if len(result.Servers) == 0 {
+		return sty.Subtle.Render("No MCP servers found.")
+	}
+
+	bodyWidth := min(120, width) - toolBodyLeftPaddingTotal
+	rows := [][]string{}
+	moreServers := ""
+	for i, server := range result.Servers {
+		if i > 9 {
+			moreServers = sty.Subtle.Render(fmt.Sprintf("... and %d more", len(result.Servers)-10))
+			break
+		}
+		rows = append(rows, []string{sty.Base.Render(server.Name), sty.Subtle.Render(server.Description)})
+	}
+	serverTable := table.New().
+		Wrap(false).
+		BorderTop(false).
+		BorderBottom(false).
+		BorderRight(false).
+		BorderLeft(false).
+		BorderColumn(false).
+		BorderRow(false).
+		StyleFunc(func(row, col int) lipgloss.Style {
+			if row == table.HeaderRow {
+				return lipgloss.NewStyle()
+			}
+			switch col {
+			case 0:
+				return lipgloss.NewStyle().PaddingRight(1)
+			}
+			return lipgloss.NewStyle()
+		}).Rows(rows...).Width(bodyWidth)
+	if moreServers != "" {
+		return sty.Tool.Body.Render(serverTable.Render() + "\n" + moreServers)
+	}
+	return sty.Tool.Body.Render(serverTable.Render())
+}
+
+func (d *DockerMCPToolRenderContext) makeHeader(sty *styles.Styles, tool string, width int, opts *ToolRenderOpts, params ...string) string {
+	if opts.Compact {
+		return d.makeCompactHeader(sty, tool, width, params...)
+	}
+
+	icon := toolIcon(sty, opts.Status)
+	if opts.IsPending() {
+		icon = sty.Tool.IconPending.Render()
+	}
+	prefix := fmt.Sprintf("%s %s ", icon, d.formatToolName(sty, tool))
+	return prefix + toolParamList(sty, params, width-lipgloss.Width(prefix))
+}
+
+func (d *DockerMCPToolRenderContext) formatToolName(sty *styles.Styles, tool string) string {
+	mainTool := "Docker MCP"
+	action := tool
+	actionStyle := sty.Tool.MCPToolName
+	switch tool {
+	case "mcp-exec":
+		action = "Exec"
+	case "mcp-config-set":
+		action = "Config Set"
+	case "mcp-find":
+		action = "Find"
+	case "mcp-add":
+		action = "Add"
+		actionStyle = sty.Tool.DockerMCPActionAdd
+	case "mcp-remove":
+		action = "Remove"
+		actionStyle = sty.Tool.DockerMCPActionDel
+	case "code-mode":
+		action = "Code Mode"
+	default:
+		action = strings.ReplaceAll(tool, "-", " ")
+		action = strings.ReplaceAll(action, "_", " ")
+		action = stringext.Capitalize(action)
+	}
+
+	toolNameStyled := sty.Tool.MCPName.Render(mainTool)
+	arrow := sty.Tool.MCPArrow.String()
+	return fmt.Sprintf("%s %s %s", toolNameStyled, arrow, actionStyle.Render(action))
+}
+
+func (d *DockerMCPToolRenderContext) makeCompactHeader(sty *styles.Styles, tool string, width int, params ...string) string {
+	action := tool
+	switch tool {
+	case "mcp-exec":
+		action = "exec"
+	case "mcp-config-set":
+		action = "config-set"
+	case "mcp-find":
+		action = "find"
+	case "mcp-add":
+		action = "add"
+	case "mcp-remove":
+		action = "remove"
+	case "code-mode":
+		action = "code-mode"
+	default:
+		action = strings.ReplaceAll(tool, "-", " ")
+		action = strings.ReplaceAll(action, "_", " ")
+	}
+
+	name := fmt.Sprintf("Docker MCP: %s", action)
+	return toolHeader(sty, ToolStatusSuccess, name, width, true, params...)
+}
+
+// IsDockerMCPTool returns true if the tool name is a Docker MCP tool.
+func IsDockerMCPTool(name string) bool {
+	return strings.HasPrefix(name, "mcp_"+config.DockerMCPName+"_")
+}

internal/ui/chat/tools.go 🔗

@@ -255,7 +255,9 @@ func NewToolMessageItem(
 	case tools.LSPRestartToolName:
 		item = NewLSPRestartToolMessageItem(sty, toolCall, result, canceled)
 	default:
-		if strings.HasPrefix(toolCall.Name, "mcp_") {
+		if IsDockerMCPTool(toolCall.Name) {
+			item = NewDockerMCPToolMessageItem(sty, toolCall, result, canceled)
+		} else if strings.HasPrefix(toolCall.Name, "mcp_") {
 			item = NewMCPToolMessageItem(sty, toolCall, result, canceled)
 		} else {
 			item = NewGenericToolMessageItem(sty, toolCall, result, canceled)

internal/ui/dialog/actions.go 🔗

@@ -81,6 +81,10 @@ type (
 		Arguments   []commands.Argument
 		Args        map[string]string // Actual argument values
 	}
+	// ActionEnableDockerMCP is a message to enable Docker MCP.
+	ActionEnableDockerMCP struct{}
+	// ActionDisableDockerMCP is a message to disable Docker MCP.
+	ActionDisableDockerMCP struct{}
 )
 
 // Messages for API key input dialog.

internal/ui/dialog/commands.go 🔗

@@ -37,6 +37,10 @@ const (
 )
 
 // Commands represents a dialog that shows available commands.
+type dockerMCPAvailabilityCheckedMsg struct {
+	available bool
+}
+
 type Commands struct {
 	com    *common.Common
 	keyMap struct {
@@ -66,6 +70,9 @@ type Commands struct {
 
 	customCommands []commands.CustomCommand
 	mcpPrompts     []commands.MCPPrompt
+
+	dockerMCPAvailable     *bool
+	dockerMCPCheckInFlight bool
 }
 
 var _ Dialog = (*Commands)(nil)
@@ -126,6 +133,10 @@ func NewCommands(com *common.Common, sessionID string, hasSession, hasTodos, has
 	closeKey.SetHelp("esc", "cancel")
 	c.keyMap.Close = closeKey
 
+	if available, known := config.DockerMCPAvailabilityCached(); known {
+		c.dockerMCPAvailable = &available
+	}
+
 	// Set initial commands
 	c.setCommandItems(c.selected)
 
@@ -145,6 +156,13 @@ func (c *Commands) ID() string {
 // HandleMsg implements [Dialog].
 func (c *Commands) HandleMsg(msg tea.Msg) Action {
 	switch msg := msg.(type) {
+	case dockerMCPAvailabilityCheckedMsg:
+		c.dockerMCPAvailable = &msg.available
+		c.dockerMCPCheckInFlight = false
+		if c.selected == SystemCommands {
+			c.setCommandItems(c.selected)
+		}
+		return nil
 	case spinner.TickMsg:
 		if c.loading {
 			var cmd tea.Cmd
@@ -207,6 +225,20 @@ func (c *Commands) HandleMsg(msg tea.Msg) Action {
 	return nil
 }
 
+func checkDockerMCPAvailabilityCmd() tea.Cmd {
+	return func() tea.Msg {
+		return dockerMCPAvailabilityCheckedMsg{available: config.RefreshDockerMCPAvailability()}
+	}
+}
+
+func (c *Commands) InitialCmd() tea.Cmd {
+	if c.dockerMCPAvailable != nil || c.dockerMCPCheckInFlight {
+		return nil
+	}
+	c.dockerMCPCheckInFlight = true
+	return checkDockerMCPAvailabilityCmd()
+}
+
 // Cursor returns the cursor position relative to the dialog.
 func (c *Commands) Cursor() *tea.Cursor {
 	return InputCursor(c.com.Styles, c.input.Cursor())
@@ -446,6 +478,16 @@ func (c *Commands) defaultCommands() []*CommandItem {
 		commands = append(commands, NewCommandItem(c.com.Styles, "open_external_editor", "Open External Editor", "ctrl+o", ActionExternalEditor{}))
 	}
 
+	// Add Docker MCP command if available and not already enabled.
+	if !cfg.IsDockerMCPEnabled() && c.dockerMCPAvailable != nil && *c.dockerMCPAvailable {
+		commands = append(commands, NewCommandItem(c.com.Styles, "enable_docker_mcp", "Enable Docker MCP Catalog", "", ActionEnableDockerMCP{}))
+	}
+
+	// Add disable Docker MCP command if it's currently enabled
+	if cfg.IsDockerMCPEnabled() {
+		commands = append(commands, NewCommandItem(c.com.Styles, "disable_docker_mcp", "Disable Docker MCP Catalog", "", ActionDisableDockerMCP{}))
+	}
+
 	if c.hasTodos || c.hasQueue {
 		var label string
 		switch {

internal/ui/model/mcp.go 🔗

@@ -6,6 +6,7 @@ import (
 
 	"charm.land/lipgloss/v2"
 	"github.com/charmbracelet/crush/internal/agent/tools/mcp"
+	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/ui/common"
 	"github.com/charmbracelet/crush/internal/ui/styles"
 )
@@ -59,7 +60,12 @@ func mcpList(t *styles.Styles, mcps []mcp.ClientInfo, width, maxItems int) strin
 
 	for _, m := range mcps {
 		var icon string
-		title := t.ResourceName.Render(m.Name)
+		title := m.Name
+		// Show "Docker MCP" instead of the config name for Docker MCP.
+		if m.Name == config.DockerMCPName {
+			title = "Docker MCP"
+		}
+		title = t.ResourceName.Render(title)
 		var description string
 		var extraContent string
 

internal/ui/model/ui.go 🔗

@@ -139,6 +139,11 @@ type UI struct {
 	// keeps track of read files while we don't have a session id
 	sessionFileReads []string
 
+	// initialSessionID is set when loading a specific session on startup.
+	initialSessionID string
+	// continueLastSession is set to continue the most recent session on startup.
+	continueLastSession bool
+
 	lastUserMessageTime int64
 
 	// The width and height of the terminal in cells.
@@ -242,7 +247,7 @@ type UI struct {
 }
 
 // New creates a new instance of the [UI] model.
-func New(com *common.Common) *UI {
+func New(com *common.Common, initialSessionID string, continueLast bool) *UI {
 	// Editor components
 	ta := textarea.New()
 	ta.SetStyles(com.Styles.TextArea)
@@ -298,6 +303,8 @@ func New(com *common.Common) *UI {
 		mcpStates:           make(map[string]mcp.ClientInfo),
 		notifyBackend:       notification.NoopBackend{},
 		notifyWindowFocused: true,
+		initialSessionID:    initialSessionID,
+		continueLastSession: continueLast,
 	}
 
 	status := NewStatus(com, ui)
@@ -346,9 +353,34 @@ func (m *UI) Init() tea.Cmd {
 	cmds = append(cmds, m.loadCustomCommands())
 	// load prompt history async
 	cmds = append(cmds, m.loadPromptHistory())
+	// load initial session if specified
+	if cmd := m.loadInitialSession(); cmd != nil {
+		cmds = append(cmds, cmd)
+	}
 	return tea.Batch(cmds...)
 }
 
+// loadInitialSession loads the initial session if one was specified on startup.
+func (m *UI) loadInitialSession() tea.Cmd {
+	switch {
+	case m.state != uiLanding:
+		// Only load if we're in landing state (i.e., fully configured)
+		return nil
+	case m.initialSessionID != "":
+		return m.loadSession(m.initialSessionID)
+	case m.continueLastSession:
+		return func() tea.Msg {
+			sessions, err := m.com.Workspace.ListSessions(context.Background())
+			if err != nil || len(sessions) == 0 {
+				return nil
+			}
+			return m.loadSession(sessions[0].ID)()
+		}
+	default:
+		return nil
+	}
+}
+
 // sendNotification returns a command that sends a notification if allowed by policy.
 func (m *UI) sendNotification(n notification.Notification) tea.Cmd {
 	if !m.shouldSendNotification() {
@@ -1354,6 +1386,12 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
 		m.dialog.CloseDialog(dialog.CommandsID)
 	case dialog.ActionQuit:
 		cmds = append(cmds, tea.Quit)
+	case dialog.ActionEnableDockerMCP:
+		m.dialog.CloseDialog(dialog.CommandsID)
+		cmds = append(cmds, m.enableDockerMCP)
+	case dialog.ActionDisableDockerMCP:
+		m.dialog.CloseDialog(dialog.CommandsID)
+		cmds = append(cmds, m.disableDockerMCP)
 	case dialog.ActionInitializeProject:
 		if m.isAgentBusy() {
 			cmds = append(cmds, util.ReportWarn("Agent is busy, please wait before summarizing session..."))
@@ -2979,7 +3017,7 @@ func (m *UI) openCommandsDialog() tea.Cmd {
 
 	m.dialog.OpenDialog(commands)
 
-	return nil
+	return commands.InitialCmd()
 }
 
 // openReasoningDialog opens the reasoning effort dialog.
@@ -3421,6 +3459,23 @@ func (m *UI) copyChatHighlight() tea.Cmd {
 	)
 }
 
+func (m *UI) enableDockerMCP() tea.Msg {
+	ctx := context.Background()
+	if err := m.com.Workspace.EnableDockerMCP(ctx); err != nil {
+		return util.ReportError(err)()
+	}
+
+	return util.NewInfoMsg("Docker MCP enabled and started successfully")
+}
+
+func (m *UI) disableDockerMCP() tea.Msg {
+	if err := m.com.Workspace.DisableDockerMCP(); err != nil {
+		return util.ReportError(err)()
+	}
+
+	return util.NewInfoMsg("Docker MCP disabled successfully")
+}
+
 // renderLogo renders the Crush logo with the given styles and dimensions.
 func renderLogo(t *styles.Styles, compact bool, width int) string {
 	return logo.Render(t, version.Version, compact, logo.Opts{

internal/ui/styles/styles.go 🔗

@@ -328,6 +328,10 @@ type Styles struct {
 		ResourceName            lipgloss.Style
 		ResourceSize            lipgloss.Style
 		MediaType               lipgloss.Style
+
+		// Docker MCP tools
+		DockerMCPActionAdd lipgloss.Style // Docker MCP add action (green)
+		DockerMCPActionDel lipgloss.Style // Docker MCP remove action (red)
 	}
 
 	// Dialog styles
@@ -1182,6 +1186,10 @@ func DefaultStyles() Styles {
 	s.Tool.MediaType = base
 	s.Tool.ResourceSize = base.Foreground(fgMuted)
 
+	// Docker MCP styles
+	s.Tool.DockerMCPActionAdd = base.Foreground(greenLight)
+	s.Tool.DockerMCPActionDel = base.Foreground(red)
+
 	// Buttons
 	s.ButtonFocus = lipgloss.NewStyle().Foreground(white).Background(secondary)
 	s.ButtonBlur = s.Base.Background(bgSubtle)

internal/workspace/client_workspace.go 🔗

@@ -509,6 +509,14 @@ func (w *ClientWorkspace) GetMCPPrompt(clientID, promptID string, args map[strin
 	return w.client.GetMCPPrompt(context.Background(), w.workspaceID(), clientID, promptID, args)
 }
 
+func (w *ClientWorkspace) EnableDockerMCP(ctx context.Context) error {
+	return w.client.EnableDockerMCP(ctx, w.workspaceID())
+}
+
+func (w *ClientWorkspace) DisableDockerMCP() error {
+	return w.client.DisableDockerMCP(context.Background(), w.workspaceID())
+}
+
 // -- Lifecycle --
 
 func (w *ClientWorkspace) Subscribe(program *tea.Program) {

internal/workspace/workspace.go 🔗

@@ -135,6 +135,8 @@ type Workspace interface {
 	RefreshMCPTools(ctx context.Context, name string)
 	ReadMCPResource(ctx context.Context, name, uri string) ([]MCPResourceContents, error)
 	GetMCPPrompt(clientID, promptID string, args map[string]string) (string, error)
+	EnableDockerMCP(ctx context.Context) error
+	DisableDockerMCP() error
 
 	// Events
 	Subscribe(program *tea.Program)