diff --git a/.github/cla-signatures.json b/.github/cla-signatures.json index c1f5f7c12ee1d19b064c856d6154d95087674103..89a3128c2e2ad02a26e06468975e20cbd82fa32f 100644 --- a/.github/cla-signatures.json +++ b/.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 } ] } \ No newline at end of file diff --git a/.github/dependabot.yml b/.github/dependabot.yml index cf970b5887bc33fd822ab7fc4fe4540df045a6e1..72f70bfaecfca298f6146051068f16e4cdfd7ce1 100644 --- a/.github/dependabot.yml +++ b/.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: "/" diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index cf4a9b535b0522833cabc9a6906dcdd7c0193124..9e590b9e00fb39d13d20842624d3fd6955a38bae 100644 --- a/.github/workflows/security.yml +++ b/.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 diff --git a/Taskfile.yaml b/Taskfile.yaml index e0ea8757c7a72502881b61ec4fabbfb240dad297..5f3fd18ad94ebed463716e091a19c53efc347935 100644 --- a/Taskfile.yaml +++ b/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: diff --git a/go.mod b/go.mod index a72a1589505f13246850505211ad610d1b673ef0..c602ff48b24ab2e1a3cffbc8b07f66bd3bc27506 100644 --- a/go.mod +++ b/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 ) diff --git a/go.sum b/go.sum index 42ac781d81c3a4946c16aaef972d12c3bf43f079..d72543ef8e9abbd7268adc6f924c04fb554b47c2 100644 --- a/go.sum +++ b/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= diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 2b09be75f4882c44869428008156772c3cc4ad99..11a87f58554729048cd7a0629979a0c6bb3babd2 100644 --- a/internal/agent/agent.go +++ b/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 { diff --git a/internal/agent/coordinator.go b/internal/agent/coordinator.go index 04a49eebe2aeb110cd0cd55421d9b632480e7461..91c3a2c7b71757080084d6f6cd64ea0ab5fde602 100644 --- a/internal/agent/coordinator.go +++ b/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" ) diff --git a/internal/agent/hyper/provider.json b/internal/agent/hyper/provider.json index 05f1a7d15762919adc462fbed9d9b2dd492ea26a..c1f318318ab49177024de26b0409020ae84e6243 100644 --- a/internal/agent/hyper/provider.json +++ b/internal/agent/hyper/provider.json @@ -1 +1 @@ -{"name":"Charm Hyper","id":"hyper","api_endpoint":"https://hyper.charm.land/api/v1/fantasy","type":"hyper","default_large_model_id":"claude-opus-4-5","default_small_model_id":"claude-haiku-4-5","models":[{"id":"claude-haiku-4-5","name":"Claude Haiku 4.5","cost_per_1m_in":1,"cost_per_1m_out":5,"cost_per_1m_in_cached":1.25,"cost_per_1m_out_cached":0.09999999999999999,"context_window":200000,"default_max_tokens":32000,"can_reason":true,"supports_attachments":true,"options":{}},{"id":"claude-opus-4-5","name":"Claude Opus 4.5","cost_per_1m_in":5,"cost_per_1m_out":25,"cost_per_1m_in_cached":6.25,"cost_per_1m_out_cached":0.5,"context_window":200000,"default_max_tokens":50000,"can_reason":true,"supports_attachments":true,"options":{}},{"id":"claude-opus-4-6","name":"Claude Opus 4.6","cost_per_1m_in":5,"cost_per_1m_out":25,"cost_per_1m_in_cached":6.25,"cost_per_1m_out_cached":0.5,"context_window":200000,"default_max_tokens":126000,"can_reason":true,"reasoning_levels":["low","medium","high","max"],"supports_attachments":true,"options":{}},{"id":"claude-sonnet-4-5","name":"Claude Sonnet 4.5","cost_per_1m_in":3,"cost_per_1m_out":15,"cost_per_1m_in_cached":3.75,"cost_per_1m_out_cached":0.3,"context_window":200000,"default_max_tokens":50000,"can_reason":true,"supports_attachments":true,"options":{}},{"id":"gemini-3-flash","name":"Gemini 3 Flash","cost_per_1m_in":0.5,"cost_per_1m_out":3,"cost_per_1m_in_cached":0.049999999999999996,"cost_per_1m_out_cached":0,"context_window":1000000,"default_max_tokens":8000,"can_reason":true,"reasoning_levels":["low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"gemini-3-pro-preview","name":"Gemini 3 Pro","cost_per_1m_in":2,"cost_per_1m_out":12,"cost_per_1m_in_cached":0.19999999999999998,"cost_per_1m_out_cached":0,"context_window":1000000,"default_max_tokens":8000,"can_reason":true,"reasoning_levels":["low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"gemini-3.1-pro-preview","name":"Gemini 3.1 Pro","cost_per_1m_in":2,"cost_per_1m_out":12,"cost_per_1m_in_cached":0.19999999999999998,"cost_per_1m_out_cached":0,"context_window":1000000,"default_max_tokens":8000,"can_reason":true,"reasoning_levels":["low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"glm-4.7","name":"GLM-4.7","cost_per_1m_in":0.43,"cost_per_1m_out":1.75,"cost_per_1m_in_cached":0.08,"cost_per_1m_out_cached":0,"context_window":202752,"default_max_tokens":8000,"can_reason":true,"reasoning_levels":["low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":false,"options":{}},{"id":"glm-4.7-flashx","name":"GLM-4.7 Flash","cost_per_1m_in":0.06,"cost_per_1m_out":0.39999999999999997,"cost_per_1m_in_cached":0.01,"cost_per_1m_out_cached":0,"context_window":200000,"default_max_tokens":8000,"can_reason":true,"reasoning_levels":["low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":false,"options":{}},{"id":"glm-5","name":"GLM-5","cost_per_1m_in":1,"cost_per_1m_out":3.1999999999999997,"cost_per_1m_in_cached":0.19999999999999998,"cost_per_1m_out_cached":0,"context_window":202800,"default_max_tokens":8000,"can_reason":true,"reasoning_levels":["low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":false,"options":{}},{"id":"gpt-5.1-codex","name":"GPT-5.1 Codex","cost_per_1m_in":1.25,"cost_per_1m_out":10,"cost_per_1m_in_cached":0.125,"cost_per_1m_out_cached":0.125,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["minimal","low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"gpt-5.1-codex-max","name":"GPT-5.1 Codex Max","cost_per_1m_in":1.25,"cost_per_1m_out":10,"cost_per_1m_in_cached":0.125,"cost_per_1m_out_cached":0.125,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["minimal","low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"gpt-5.1-codex-mini","name":"GPT-5.1 Codex Mini","cost_per_1m_in":0.25,"cost_per_1m_out":2,"cost_per_1m_in_cached":0.025,"cost_per_1m_out_cached":0.025,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"gpt-5.2","name":"GPT-5.2","cost_per_1m_in":1.75,"cost_per_1m_out":14,"cost_per_1m_in_cached":0.175,"cost_per_1m_out_cached":0.175,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["minimal","low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"gpt-5.2-codex","name":"GPT-5.2 Codex","cost_per_1m_in":1.75,"cost_per_1m_out":14,"cost_per_1m_in_cached":0.175,"cost_per_1m_out_cached":0.175,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["minimal","low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"gpt-5.3-codex","name":"GPT-5.3 Codex","cost_per_1m_in":1.75,"cost_per_1m_out":14,"cost_per_1m_in_cached":0.175,"cost_per_1m_out_cached":0.175,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["minimal","low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"grok-4.1-fast-non-reasoning","name":"Grok 4.1 Fast Non Reasoning","cost_per_1m_in":0.2,"cost_per_1m_out":0.5,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":0.05,"context_window":2000000,"default_max_tokens":200000,"can_reason":false,"supports_attachments":true,"options":{}},{"id":"grok-4.1-fast-reasoning","name":"Grok 4.1 Fast Reasoning","cost_per_1m_in":0.2,"cost_per_1m_out":0.5,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":0.05,"context_window":2000000,"default_max_tokens":200000,"can_reason":true,"supports_attachments":true,"options":{}},{"id":"grok-code-fast-1","name":"Grok Code Fast","cost_per_1m_in":0.2,"cost_per_1m_out":1.5,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":0.02,"context_window":256000,"default_max_tokens":20000,"can_reason":true,"supports_attachments":false,"options":{}},{"id":"kimi-k2-0905","name":"Kimi K2","cost_per_1m_in":0.548,"cost_per_1m_out":2.192,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":0,"context_window":262144,"default_max_tokens":26214,"can_reason":false,"supports_attachments":false,"options":{}},{"id":"kimi-k2.5","name":"Kimi K2.5","cost_per_1m_in":0.5,"cost_per_1m_out":2.8,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":0,"context_window":256000,"default_max_tokens":8000,"can_reason":true,"reasoning_levels":["low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}}]} \ No newline at end of file +{"name":"Charm Hyper","id":"hyper","api_endpoint":"https://hyper.charm.land/api/v1/fantasy","type":"hyper","default_large_model_id":"claude-opus-4-5","default_small_model_id":"claude-haiku-4-5","models":[{"id":"claude-haiku-4-5","name":"Claude Haiku 4.5","cost_per_1m_in":1,"cost_per_1m_out":5,"cost_per_1m_in_cached":1.25,"cost_per_1m_out_cached":0.1,"context_window":200000,"default_max_tokens":64000,"can_reason":true,"supports_attachments":true,"options":{}},{"id":"claude-opus-4-5","name":"Claude Opus 4.5","cost_per_1m_in":5,"cost_per_1m_out":25,"cost_per_1m_in_cached":6.25,"cost_per_1m_out_cached":0.5,"context_window":200000,"default_max_tokens":64000,"can_reason":true,"supports_attachments":true,"options":{}},{"id":"claude-opus-4-6","name":"Claude Opus 4.6","cost_per_1m_in":5,"cost_per_1m_out":25,"cost_per_1m_in_cached":6.25,"cost_per_1m_out_cached":0.5,"context_window":1000000,"default_max_tokens":126000,"can_reason":true,"reasoning_levels":["low","medium","high","max"],"supports_attachments":true,"options":{}},{"id":"claude-sonnet-4-5","name":"Claude Sonnet 4.5","cost_per_1m_in":3,"cost_per_1m_out":15,"cost_per_1m_in_cached":3.75,"cost_per_1m_out_cached":0.3,"context_window":200000,"default_max_tokens":64000,"can_reason":true,"supports_attachments":true,"options":{}},{"id":"claude-sonnet-4-6","name":"Claude Sonnet 4.6","cost_per_1m_in":3,"cost_per_1m_out":15,"cost_per_1m_in_cached":3.75,"cost_per_1m_out_cached":0.3,"context_window":1000000,"default_max_tokens":64000,"can_reason":true,"reasoning_levels":["low","medium","high","max"],"supports_attachments":true,"options":{}},{"id":"gemini-3-flash","name":"Gemini 3 Flash","cost_per_1m_in":0.5,"cost_per_1m_out":3,"cost_per_1m_in_cached":0.05,"cost_per_1m_out_cached":0,"context_window":1000000,"default_max_tokens":8000,"can_reason":true,"reasoning_levels":["low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"gemini-3-pro-preview","name":"Gemini 3 Pro","cost_per_1m_in":2,"cost_per_1m_out":12,"cost_per_1m_in_cached":0.2,"cost_per_1m_out_cached":0,"context_window":1000000,"default_max_tokens":8000,"can_reason":true,"reasoning_levels":["low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"gemini-3.1-pro-preview","name":"Gemini 3.1 Pro","cost_per_1m_in":2,"cost_per_1m_out":12,"cost_per_1m_in_cached":0.2,"cost_per_1m_out_cached":0,"context_window":1000000,"default_max_tokens":8000,"can_reason":true,"reasoning_levels":["low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"glm-4.7","name":"GLM-4.7","cost_per_1m_in":0.6,"cost_per_1m_out":2.2,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":0,"context_window":200000,"default_max_tokens":8000,"can_reason":true,"reasoning_levels":["low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":false,"options":{}},{"id":"glm-4.7-flashx","name":"GLM-4.7 Flash","cost_per_1m_in":0.06,"cost_per_1m_out":0.4,"cost_per_1m_in_cached":0.01,"cost_per_1m_out_cached":0,"context_window":200000,"default_max_tokens":8000,"can_reason":true,"reasoning_levels":["low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":false,"options":{}},{"id":"glm-5","name":"GLM-5","cost_per_1m_in":1,"cost_per_1m_out":3.2,"cost_per_1m_in_cached":0.2,"cost_per_1m_out_cached":0,"context_window":202800,"default_max_tokens":8000,"can_reason":true,"reasoning_levels":["low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":false,"options":{}},{"id":"gpt-5.1-codex","name":"GPT-5.1 Codex","cost_per_1m_in":1.25,"cost_per_1m_out":10,"cost_per_1m_in_cached":0.125,"cost_per_1m_out_cached":0.125,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["minimal","low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"gpt-5.1-codex-max","name":"GPT-5.1 Codex Max","cost_per_1m_in":1.25,"cost_per_1m_out":10,"cost_per_1m_in_cached":0.125,"cost_per_1m_out_cached":0.125,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["minimal","low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"gpt-5.1-codex-mini","name":"GPT-5.1 Codex Mini","cost_per_1m_in":0.25,"cost_per_1m_out":2,"cost_per_1m_in_cached":0.025,"cost_per_1m_out_cached":0.025,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"gpt-5.2","name":"GPT-5.2","cost_per_1m_in":1.75,"cost_per_1m_out":14,"cost_per_1m_in_cached":0.175,"cost_per_1m_out_cached":0.175,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["minimal","low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"gpt-5.2-codex","name":"GPT-5.2 Codex","cost_per_1m_in":1.75,"cost_per_1m_out":14,"cost_per_1m_in_cached":0.175,"cost_per_1m_out_cached":0.175,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["minimal","low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"gpt-5.3-codex","name":"GPT-5.3 Codex","cost_per_1m_in":1.75,"cost_per_1m_out":14,"cost_per_1m_in_cached":0.175,"cost_per_1m_out_cached":0.175,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["minimal","low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"gpt-5.4","name":"GPT-5.4","cost_per_1m_in":2.5,"cost_per_1m_out":15,"cost_per_1m_in_cached":0.25,"cost_per_1m_out_cached":0,"context_window":1050000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["low","medium","high","xhigh"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"gpt-5.4-pro","name":"GPT-5.4 Pro","cost_per_1m_in":30,"cost_per_1m_out":180,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":0,"context_window":1050000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["medium","high","xhigh"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"grok-4.1-fast-non-reasoning","name":"Grok 4.1 Fast Non Reasoning","cost_per_1m_in":0.2,"cost_per_1m_out":0.5,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":0.05,"context_window":2000000,"default_max_tokens":200000,"can_reason":false,"supports_attachments":true,"options":{}},{"id":"grok-4.1-fast-reasoning","name":"Grok 4.1 Fast Reasoning","cost_per_1m_in":0.2,"cost_per_1m_out":0.5,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":0.05,"context_window":2000000,"default_max_tokens":200000,"can_reason":true,"supports_attachments":true,"options":{}},{"id":"grok-code-fast-1","name":"Grok Code Fast","cost_per_1m_in":0.2,"cost_per_1m_out":1.5,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":0.02,"context_window":256000,"default_max_tokens":20000,"can_reason":true,"supports_attachments":false,"options":{}},{"id":"kimi-k2-0905","name":"Kimi K2","cost_per_1m_in":0.6,"cost_per_1m_out":2.5,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":0,"context_window":256000,"default_max_tokens":8000,"can_reason":false,"supports_attachments":false,"options":{}},{"id":"kimi-k2.5","name":"Kimi K2.5","cost_per_1m_in":0.6,"cost_per_1m_out":3,"cost_per_1m_in_cached":0.1,"cost_per_1m_out_cached":0,"context_window":262114,"default_max_tokens":8000,"can_reason":true,"reasoning_levels":["low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}}]} \ No newline at end of file diff --git a/internal/agent/tools/mcp-tools.go b/internal/agent/tools/mcp-tools.go index e1184118552ee62e75f60c6943f59ecca2868563..a7e9dc5d3de24944a91856250e9c6d0920222a00 100644 --- a/internal/agent/tools/mcp-tools.go +++ b/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) diff --git a/internal/agent/tools/mcp/init.go b/internal/agent/tools/mcp/init.go index cba9a51c717b1866b823762f85bfadf90e1a7a10..7ad802bae6963540a616bd4cc1576c7b7d7bb0a4 100644 --- a/internal/agent/tools/mcp/init.go +++ b/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 { diff --git a/internal/agent/tools/mcp/tools.go b/internal/agent/tools/mcp/tools.go index 8d1d2649ba4381e14fa8d99933f1dfb3b42d27ae..05d6b2b75d8fadff2e9af8385817ac135722f1a8 100644 --- a/internal/agent/tools/mcp/tools.go +++ b/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 +} diff --git a/internal/agent/tools/mcp/tools_test.go b/internal/agent/tools/mcp/tools_test.go new file mode 100644 index 0000000000000000000000000000000000000000..aae4428ed6b830549540611761c22f070eeda925 --- /dev/null +++ b/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) + }) + } +} diff --git a/internal/backend/config.go b/internal/backend/config.go index 27fa174fefa51bcd46432b56c07327c8f2c25047..c7e01ff3bd08d3e96edcf875d6198d168fbeb1a5 100644 --- a/internal/backend/config.go +++ b/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) diff --git a/internal/client/config.go b/internal/client/config.go index 7589c4c9684670d84f22ab737b4da5c3c9e8478d..64c45ecd91cfabbe6727695071e7a62e5fe435ba 100644 --- a/internal/client/config.go +++ b/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 { diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 8aa18b0e7983378bdb052020bc98154673f26495..0c300e6b1eeb5f7297d627cf43f4b36a29771375 100644 --- a/internal/cmd/root.go +++ b/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( diff --git a/internal/cmd/run.go b/internal/cmd/run.go index 010baeb3ea5dc933477af1d2eadab101c28132fc..fbe28f27de42f27c993aecfd2b0340df553dbcf2 100644 --- a/internal/cmd/run.go +++ b/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: ` diff --git a/internal/cmd/session.go b/internal/cmd/session.go index f4267f7d57300ac91504adbc0da01ff149b71412..d5fa973516b9b63a926cd4a30e81c3be8b4de43a 100644 --- a/internal/cmd/session.go +++ b/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.", } diff --git a/internal/config/docker_mcp.go b/internal/config/docker_mcp.go new file mode 100644 index 0000000000000000000000000000000000000000..fda70e36f2734164c3bf926532b0b85b25e3ef34 --- /dev/null +++ b/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 +} diff --git a/internal/config/docker_mcp_test.go b/internal/config/docker_mcp_test.go new file mode 100644 index 0000000000000000000000000000000000000000..1ac5c99bd63ab3855034d7b6e56855396fdd3ed5 --- /dev/null +++ b/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()) +} diff --git a/internal/server/config.go b/internal/server/config.go index 05806663f73276573383aa1d3809121c07fd295f..b5277d08e32935a59ee0748809b0868f3dc3b5a9 100644 --- a/internal/server/config.go +++ b/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 diff --git a/internal/server/server.go b/internal/server/server.go index 6bc89c2acc958154a91cfe6ce38eff99136a5480..9ac4dba4c908050a0381b49258941d1b3a931970 100644 --- a/internal/server/server.go +++ b/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, diff --git a/internal/ui/AGENTS.md b/internal/ui/AGENTS.md index f3720d7b8867d60b30d00089b3b567ac70fd61ac..6395187ac9ca500d42456abb46aaafad1ed85ee2 100644 --- a/internal/ui/AGENTS.md +++ b/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. diff --git a/internal/ui/chat/docker_mcp.go b/internal/ui/chat/docker_mcp.go new file mode 100644 index 0000000000000000000000000000000000000000..57cd9da55f83e63279413d9801337236290d5cdc --- /dev/null +++ b/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), ¶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+"_") +} diff --git a/internal/ui/chat/tools.go b/internal/ui/chat/tools.go index 1342cfba2f6cc1c608298e9695578ae351726160..f91ad8ebd8725c2e2c15a4b9968bb51226c4db12 100644 --- a/internal/ui/chat/tools.go +++ b/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) diff --git a/internal/ui/dialog/actions.go b/internal/ui/dialog/actions.go index 09755417a5a12e4bdb7df1e5f932ade18016fb8f..a2de6513c13a9d00febd8ca510472542e687ce4a 100644 --- a/internal/ui/dialog/actions.go +++ b/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. diff --git a/internal/ui/dialog/commands.go b/internal/ui/dialog/commands.go index a5b555f2b345f51d9624ce87a2dcc6aaa3c1f70e..18d6a7599f6d1cfe76830a675aaa6f5d771f9e51 100644 --- a/internal/ui/dialog/commands.go +++ b/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 { diff --git a/internal/ui/model/mcp.go b/internal/ui/model/mcp.go index c5c94268d2985fff3c79590d3f432872439962b2..edcdfa6ea840b897a208875348484e3f9d77d2b4 100644 --- a/internal/ui/model/mcp.go +++ b/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 diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 918f31df2461fd7af255a636ee54a79d46c90afe..04419e1c2231afafe205bae6f60fb31a3920889c 100644 --- a/internal/ui/model/ui.go +++ b/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{ diff --git a/internal/ui/styles/styles.go b/internal/ui/styles/styles.go index bc6d7099f66a1e4c0a9d8e7e1c05c32d6974dcec..12c5c99e0e2b9619777d64b746c053e0bd3e165b 100644 --- a/internal/ui/styles/styles.go +++ b/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) diff --git a/internal/workspace/client_workspace.go b/internal/workspace/client_workspace.go index a3090a740e948b36111c29d1c1f59918df2a0c0b..10df76ee536ccb647516f9dad5e16a097ba201b7 100644 --- a/internal/workspace/client_workspace.go +++ b/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) { diff --git a/internal/workspace/workspace.go b/internal/workspace/workspace.go index eae106d0ff823446712316c1ba275af6ed67c6da..4237ac8ec18234534e616db2d44d1eac52c542f2 100644 --- a/internal/workspace/workspace.go +++ b/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)