Detailed changes
@@ -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
}
]
}
@@ -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: "/"
@@ -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
@@ -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:
@@ -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
)
@@ -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=
@@ -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 {
@@ -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"
)
@@ -1 +1 @@
@@ -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)
@@ -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 {
@@ -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
+}
@@ -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)
+ })
+ }
+}
@@ -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)
@@ -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 {
@@ -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(
@@ -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: `
@@ -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.",
}
@@ -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
+}
@@ -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())
+}
@@ -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
@@ -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,
@@ -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.
@@ -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), ¶ms); 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+"_")
+}
@@ -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)
@@ -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.
@@ -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 {
@@ -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
@@ -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{
@@ -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)
@@ -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) {
@@ -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)