Detailed changes
@@ -196,6 +196,19 @@ tasks:
- go get charm.land/catwalk@latest
- go mod tidy
+ swag:
+ desc: Generate OpenAPI spec from swag annotations
+ cmds:
+ - go run github.com/swaggo/swag/cmd/swag@v1.16.6 init --generalInfo main.go --dir . --output internal/swagger --packageName swagger --parseDependency --parseInternal --parseDepth 5
+ sources:
+ - internal/server/*.go
+ - internal/proto/*.go
+ - main.go
+ generates:
+ - internal/swagger/docs.go
+ - internal/swagger/swagger.json
+ - internal/swagger/swagger.yaml
+
sqlc:
desc: Generate code using SQLC
cmds:
@@ -14,6 +14,7 @@ require (
charm.land/x/vcr v0.1.1
github.com/JohannesKaufmann/html-to-markdown v1.6.0
github.com/MakeNowJust/heredoc v1.0.0
+ github.com/Microsoft/go-winio v0.6.2
github.com/PuerkitoBio/goquery v1.12.0
github.com/alecthomas/chroma/v2 v2.23.1
github.com/atotto/clipboard v0.1.4
@@ -59,12 +60,15 @@ require (
github.com/sourcegraph/jsonrpc2 v0.2.1
github.com/spf13/cobra v1.10.2
github.com/stretchr/testify v1.11.1
+ github.com/swaggo/http-swagger/v2 v2.0.2
+ github.com/swaggo/swag v1.16.6
github.com/tidwall/gjson v1.18.0
github.com/tidwall/sjson v1.2.5
github.com/zeebo/xxh3 v1.1.0
go.uber.org/goleak v1.3.0
golang.org/x/net v0.52.0
golang.org/x/sync v0.20.0
+ golang.org/x/sys v0.42.0
golang.org/x/text v0.35.0
gopkg.in/natefinch/lumberjack.v2 v2.2.1
gopkg.in/yaml.v3 v3.0.1
@@ -81,6 +85,7 @@ require (
git.sr.ht/~jackmordaunt/go-toast v1.1.2 // indirect
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0 // indirect
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.4 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.7 // indirect
@@ -119,6 +124,10 @@ require (
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
+ github.com/go-openapi/jsonpointer v0.19.5 // indirect
+ github.com/go-openapi/jsonreference v0.20.0 // indirect
+ github.com/go-openapi/spec v0.20.6 // indirect
+ github.com/go-openapi/swag v0.19.15 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
@@ -135,6 +144,7 @@ require (
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jackmordaunt/icns/v3 v3.0.1 // indirect
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
+ github.com/josharian/intern v1.0.0 // indirect
github.com/kaptinlin/go-i18n v0.2.12 // indirect
github.com/kaptinlin/jsonpointer v0.4.17 // indirect
github.com/kaptinlin/jsonschema v0.7.6 // indirect
@@ -166,6 +176,7 @@ require (
github.com/sergeymakinen/go-ico v1.0.0-beta.0 // indirect
github.com/sethvargo/go-retry v0.3.0 // indirect
github.com/spf13/pflag v1.0.9 // indirect
+ github.com/swaggo/files/v2 v2.0.0 // indirect
github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
@@ -187,10 +198,11 @@ require (
golang.org/x/crypto v0.49.0 // indirect
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 // indirect
golang.org/x/image v0.38.0 // indirect
+ golang.org/x/mod v0.34.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect
- golang.org/x/sys v0.42.0 // indirect
golang.org/x/term v0.41.0 // indirect
golang.org/x/time v0.15.0 // indirect
+ golang.org/x/tools v0.43.0 // indirect
google.golang.org/api v0.271.0 // indirect
google.golang.org/genai v1.51.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260311181403-84a4fc48630c // indirect
@@ -199,6 +211,7 @@ require (
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.70.0 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
@@ -36,8 +36,12 @@ github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2 h1:XHOnouVk1mx
github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
github.com/JohannesKaufmann/html-to-markdown v1.6.0 h1:04VXMiE50YYfCfLboJCLcgqF5x+rHJnb1ssNmqpLH/k=
github.com/JohannesKaufmann/html-to-markdown v1.6.0/go.mod h1:NUI78lGg/a7vpEJTz/0uOcYMaibytE4BUOQS8k78yPQ=
+github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
+github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
+github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
+github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/PuerkitoBio/goquery v1.9.2/go.mod h1:GHPCaP0ODyyxqcNoFGYlAprUFH81NuRPd0GX3Zu2Mvk=
github.com/PuerkitoBio/goquery v1.12.0 h1:pAcL4g3WRXekcB9AU/y1mbKez2dbY2AajVhtkO8RIBo=
github.com/PuerkitoBio/goquery v1.12.0/go.mod h1:802ej+gV2y7bbIhOIoPY5sT183ZW0YFofScC4q/hIpQ=
@@ -139,6 +143,7 @@ github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJ
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik=
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
+github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s=
@@ -190,6 +195,16 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
+github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
+github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY=
+github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
+github.com/go-openapi/jsonreference v0.20.0 h1:MYlu0sBgChmCfJxxUKZ8g1cPWFOB37YSZqewK7OKeyA=
+github.com/go-openapi/jsonreference v0.20.0/go.mod h1:Ag74Ico3lPc+zR+qjn4XBUmXymS4zJbYVCZmcgkasdo=
+github.com/go-openapi/spec v0.20.6 h1:ich1RQ3WDbfoeTqTAb+5EIxNmpKVJZWBNah9RAT0jIQ=
+github.com/go-openapi/spec v0.20.6/go.mod h1:2OpW+JddWPrpXSCIX8eOx7lZ5iyuWj3RYR6VaaBKcWA=
+github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk=
+github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM=
+github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ=
github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI=
github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow=
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
@@ -243,6 +258,7 @@ github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/jordanella/go-ansi-paintbrush v0.0.0-20240728195301-b7ad996ecf3d h1:on25kP+Sx7sxUMRQiA8gdcToAGet4DK/EIA30mXre+4=
github.com/jordanella/go-ansi-paintbrush v0.0.0-20240728195301-b7ad996ecf3d/go.mod h1:SV0W0APWP9MZ1/gfDQ/NzzTlWdIgYZ/ZbpN4d/UXRYw=
+github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/kaptinlin/go-i18n v0.2.12 h1:ywDsvb4KDFddMC2dpI/rrIzGU2mWUSvHmWUm9BMsdl4=
github.com/kaptinlin/go-i18n v0.2.12/go.mod h1:pVcu9qsW5pOIOoZFJXesRYmLos1vMQrby70JPAoWmJU=
@@ -269,6 +285,9 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
+github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
+github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
+github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
@@ -303,6 +322,7 @@ github.com/ncruces/julianday v1.0.0 h1:fH0OKwa7NWvniGQtxdJRxAgkBMolni2BjDHaWTxqt
github.com/ncruces/julianday v1.0.0/go.mod h1:Dusn2KvZrrovOMJuOt0TNXL6tB7U2E8kvza5fFc9G7g=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
+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/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k=
@@ -365,11 +385,18 @@ github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSS
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
+github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+github.com/swaggo/files/v2 v2.0.0 h1:hmAt8Dkynw7Ssz46F6pn8ok6YmGZqHSVLZ+HQM7i0kw=
+github.com/swaggo/files/v2 v2.0.0/go.mod h1:24kk2Y9NYEJ5lHuCra6iVwkMjIekMCaFq/0JQj66kyM=
+github.com/swaggo/http-swagger/v2 v2.0.2 h1:FKCdLsl+sFCx60KFsyM0rDarwiUSZ8DqbfSyIKC9OBg=
+github.com/swaggo/http-swagger/v2 v2.0.2/go.mod h1:r7/GBkAWIfK6E/OLnE8fXnviHiDeAHmgIyooa4xm3AQ=
+github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI=
+github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg=
github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af h1:6yITBqGTE2lEeTPG04SN9W+iWHCRyHqlVYILiSXziwk=
github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af/go.mod h1:4F09kP5F+am0jAwlQLddpoMDM+iewkxxt6nxUQ5nq5o=
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
@@ -536,7 +563,9 @@ google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhH
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=
+gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/dnaeon/go-vcr.v4 v4.0.6-0.20251110073552-01de4eb40290 h1:g3ah7zaWmw41EtOgBNXpx8zk4HYuH3OMwB+qh1Dt834=
@@ -551,6 +580,7 @@ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
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=
@@ -81,7 +81,7 @@ func New(ctx context.Context, conn *sql.DB, store *config.ConfigStore) (*App, er
messages := message.NewService(q)
files := history.NewService(q, conn)
cfg := store.Config()
- skipPermissionsRequests := cfg.Permissions != nil && cfg.Permissions.SkipRequests
+ skipPermissionsRequests := store.Overrides().SkipPermissionRequests
var allowedTools []string
if cfg.Permissions != nil && cfg.Permissions.AllowedTools != nil {
allowedTools = cfg.Permissions.AllowedTools
@@ -152,6 +152,20 @@ func (app *App) Store() *config.ConfigStore {
return app.config
}
+// Events returns the events channel for the application.
+func (app *App) Events() <-chan tea.Msg {
+ return app.events
+}
+
+// SendEvent pushes a message into the application's events channel.
+// It is non-blocking; the message is dropped if the channel is full.
+func (app *App) SendEvent(msg tea.Msg) {
+ select {
+ case app.events <- msg:
+ default:
+ }
+}
+
// AgentNotifications returns the broker for agent notification events.
func (app *App) AgentNotifications() *pubsub.Broker[notify.Notification] {
return app.agentNotifications
@@ -0,0 +1,144 @@
+package backend
+
+import (
+ "context"
+
+ "github.com/charmbracelet/crush/internal/config"
+ "github.com/charmbracelet/crush/internal/proto"
+)
+
+// SendMessage sends a prompt to the agent coordinator for the given
+// workspace and session.
+func (b *Backend) SendMessage(ctx context.Context, workspaceID string, msg proto.AgentMessage) error {
+ ws, err := b.GetWorkspace(workspaceID)
+ if err != nil {
+ return err
+ }
+
+ if ws.AgentCoordinator == nil {
+ return ErrAgentNotInitialized
+ }
+
+ _, err = ws.AgentCoordinator.Run(ctx, msg.SessionID, msg.Prompt)
+ return err
+}
+
+// GetAgentInfo returns the agent's model and busy status.
+func (b *Backend) GetAgentInfo(workspaceID string) (proto.AgentInfo, error) {
+ ws, err := b.GetWorkspace(workspaceID)
+ if err != nil {
+ return proto.AgentInfo{}, err
+ }
+
+ var agentInfo proto.AgentInfo
+ if ws.AgentCoordinator != nil {
+ m := ws.AgentCoordinator.Model()
+ agentInfo = proto.AgentInfo{
+ Model: m.CatwalkCfg,
+ ModelCfg: m.ModelCfg,
+ IsBusy: ws.AgentCoordinator.IsBusy(),
+ IsReady: true,
+ }
+ }
+ return agentInfo, nil
+}
+
+// InitAgent initializes the coder agent for the workspace.
+func (b *Backend) InitAgent(ctx context.Context, workspaceID string) error {
+ ws, err := b.GetWorkspace(workspaceID)
+ if err != nil {
+ return err
+ }
+
+ return ws.InitCoderAgent(ctx)
+}
+
+// UpdateAgent reloads the agent model configuration.
+func (b *Backend) UpdateAgent(ctx context.Context, workspaceID string) error {
+ ws, err := b.GetWorkspace(workspaceID)
+ if err != nil {
+ return err
+ }
+
+ return ws.UpdateAgentModel(ctx)
+}
+
+// CancelSession cancels an ongoing agent operation for the given
+// session.
+func (b *Backend) CancelSession(workspaceID, sessionID string) error {
+ ws, err := b.GetWorkspace(workspaceID)
+ if err != nil {
+ return err
+ }
+
+ if ws.AgentCoordinator != nil {
+ ws.AgentCoordinator.Cancel(sessionID)
+ }
+ return nil
+}
+
+// SummarizeSession triggers a session summarization.
+func (b *Backend) SummarizeSession(ctx context.Context, workspaceID, sessionID string) error {
+ ws, err := b.GetWorkspace(workspaceID)
+ if err != nil {
+ return err
+ }
+
+ if ws.AgentCoordinator == nil {
+ return ErrAgentNotInitialized
+ }
+
+ return ws.AgentCoordinator.Summarize(ctx, sessionID)
+}
+
+// QueuedPrompts returns the number of queued prompts for the session.
+func (b *Backend) QueuedPrompts(workspaceID, sessionID string) (int, error) {
+ ws, err := b.GetWorkspace(workspaceID)
+ if err != nil {
+ return 0, err
+ }
+
+ if ws.AgentCoordinator == nil {
+ return 0, nil
+ }
+
+ return ws.AgentCoordinator.QueuedPrompts(sessionID), nil
+}
+
+// ClearQueue clears the prompt queue for the session.
+func (b *Backend) ClearQueue(workspaceID, sessionID string) error {
+ ws, err := b.GetWorkspace(workspaceID)
+ if err != nil {
+ return err
+ }
+
+ if ws.AgentCoordinator != nil {
+ ws.AgentCoordinator.ClearQueue(sessionID)
+ }
+ return nil
+}
+
+// QueuedPromptsList returns the list of queued prompt strings for a
+// session.
+func (b *Backend) QueuedPromptsList(workspaceID, sessionID string) ([]string, error) {
+ ws, err := b.GetWorkspace(workspaceID)
+ if err != nil {
+ return nil, err
+ }
+
+ if ws.AgentCoordinator == nil {
+ return nil, nil
+ }
+
+ return ws.AgentCoordinator.QueuedPromptsList(sessionID), nil
+}
+
+// GetDefaultSmallModel returns the default small model for a provider.
+func (b *Backend) GetDefaultSmallModel(workspaceID, providerID string) (config.SelectedModel, error) {
+ ws, err := b.GetWorkspace(workspaceID)
+ if err != nil {
+ return config.SelectedModel{}, err
+ }
+
+ return ws.GetDefaultSmallModel(providerID), nil
+}
@@ -0,0 +1,204 @@
+// Package backend provides transport-agnostic operations for managing
+// workspaces, sessions, agents, permissions, and events. It is consumed
+// by protocol-specific layers such as HTTP (server) and ACP.
+package backend
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "log/slog"
+ "runtime"
+
+ "github.com/charmbracelet/crush/internal/app"
+ "github.com/charmbracelet/crush/internal/config"
+ "github.com/charmbracelet/crush/internal/csync"
+ "github.com/charmbracelet/crush/internal/db"
+ "github.com/charmbracelet/crush/internal/proto"
+ "github.com/charmbracelet/crush/internal/ui/util"
+ "github.com/charmbracelet/crush/internal/version"
+ "github.com/google/uuid"
+)
+
+// Common errors returned by backend operations.
+var (
+ ErrWorkspaceNotFound = errors.New("workspace not found")
+ ErrLSPClientNotFound = errors.New("LSP client not found")
+ ErrAgentNotInitialized = errors.New("agent coordinator not initialized")
+ ErrPathRequired = errors.New("path is required")
+ ErrInvalidPermissionAction = errors.New("invalid permission action")
+ ErrUnknownCommand = errors.New("unknown command")
+)
+
+// ShutdownFunc is called when the backend needs to trigger a server
+// shutdown (e.g. when the last workspace is removed).
+type ShutdownFunc func()
+
+// Backend provides transport-agnostic business logic for the Crush
+// server. It manages workspaces and delegates to [app.App] services.
+type Backend struct {
+ workspaces *csync.Map[string, *Workspace]
+ cfg *config.ConfigStore
+ ctx context.Context
+ shutdownFn ShutdownFunc
+}
+
+// Workspace represents a running [app.App] workspace with its
+// associated resources and state.
+type Workspace struct {
+ *app.App
+ ID string
+ Path string
+ Cfg *config.ConfigStore
+ Env []string
+}
+
+// New creates a new [Backend].
+func New(ctx context.Context, cfg *config.ConfigStore, shutdownFn ShutdownFunc) *Backend {
+ return &Backend{
+ workspaces: csync.NewMap[string, *Workspace](),
+ cfg: cfg,
+ ctx: ctx,
+ shutdownFn: shutdownFn,
+ }
+}
+
+// GetWorkspace retrieves a workspace by ID.
+func (b *Backend) GetWorkspace(id string) (*Workspace, error) {
+ ws, ok := b.workspaces.Get(id)
+ if !ok {
+ return nil, ErrWorkspaceNotFound
+ }
+ return ws, nil
+}
+
+// ListWorkspaces returns all running workspaces.
+func (b *Backend) ListWorkspaces() []proto.Workspace {
+ workspaces := []proto.Workspace{}
+ for _, ws := range b.workspaces.Seq2() {
+ workspaces = append(workspaces, workspaceToProto(ws))
+ }
+ return workspaces
+}
+
+// CreateWorkspace initializes a new workspace from the given
+// parameters. It creates the config, database connection, and
+// [app.App] instance.
+func (b *Backend) CreateWorkspace(args proto.Workspace) (*Workspace, proto.Workspace, error) {
+ if args.Path == "" {
+ return nil, proto.Workspace{}, ErrPathRequired
+ }
+
+ id := uuid.New().String()
+ cfg, err := config.Init(args.Path, args.DataDir, args.Debug)
+ if err != nil {
+ return nil, proto.Workspace{}, fmt.Errorf("failed to initialize config: %w", err)
+ }
+
+ cfg.Overrides().SkipPermissionRequests = args.YOLO
+
+ if err := createDotCrushDir(cfg.Config().Options.DataDirectory); err != nil {
+ return nil, proto.Workspace{}, fmt.Errorf("failed to create data directory: %w", err)
+ }
+
+ conn, err := db.Connect(b.ctx, cfg.Config().Options.DataDirectory)
+ if err != nil {
+ return nil, proto.Workspace{}, fmt.Errorf("failed to connect to database: %w", err)
+ }
+
+ appWorkspace, err := app.New(b.ctx, conn, cfg)
+ if err != nil {
+ return nil, proto.Workspace{}, fmt.Errorf("failed to create app workspace: %w", err)
+ }
+
+ ws := &Workspace{
+ App: appWorkspace,
+ ID: id,
+ Path: args.Path,
+ Cfg: cfg,
+ Env: args.Env,
+ }
+
+ b.workspaces.Set(id, ws)
+
+ if args.Version != "" && args.Version != version.Version {
+ slog.Warn("Client/server version mismatch",
+ "client", args.Version,
+ "server", version.Version,
+ )
+ appWorkspace.SendEvent(util.NewWarnMsg(fmt.Sprintf(
+ "Server version %q differs from client version %q. Consider restarting the server.",
+ version.Version, args.Version,
+ )))
+ }
+
+ result := proto.Workspace{
+ ID: id,
+ Path: args.Path,
+ DataDir: cfg.Config().Options.DataDirectory,
+ Debug: cfg.Config().Options.Debug,
+ YOLO: cfg.Overrides().SkipPermissionRequests,
+ Config: cfg.Config(),
+ Env: args.Env,
+ }
+
+ return ws, result, nil
+}
+
+// DeleteWorkspace shuts down and removes a workspace. If it was the
+// last workspace, the shutdown callback is invoked.
+func (b *Backend) DeleteWorkspace(id string) {
+ ws, ok := b.workspaces.Get(id)
+ if ok {
+ ws.Shutdown()
+ }
+ b.workspaces.Del(id)
+
+ if b.workspaces.Len() == 0 && b.shutdownFn != nil {
+ slog.Info("Last workspace removed, shutting down server...")
+ b.shutdownFn()
+ }
+}
+
+// GetWorkspaceProto returns the proto representation of a workspace.
+func (b *Backend) GetWorkspaceProto(id string) (proto.Workspace, error) {
+ ws, err := b.GetWorkspace(id)
+ if err != nil {
+ return proto.Workspace{}, err
+ }
+ return workspaceToProto(ws), nil
+}
+
+// VersionInfo returns server version information.
+func (b *Backend) VersionInfo() proto.VersionInfo {
+ return proto.VersionInfo{
+ Version: version.Version,
+ Commit: version.Commit,
+ GoVersion: runtime.Version(),
+ Platform: fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH),
+ }
+}
+
+// Config returns the server-level configuration.
+func (b *Backend) Config() *config.ConfigStore {
+ return b.cfg
+}
+
+// Shutdown initiates a graceful server shutdown.
+func (b *Backend) Shutdown() {
+ if b.shutdownFn != nil {
+ b.shutdownFn()
+ }
+}
+
+func workspaceToProto(ws *Workspace) proto.Workspace {
+ cfg := ws.Cfg.Config()
+ return proto.Workspace{
+ ID: ws.ID,
+ Path: ws.Path,
+ YOLO: ws.Cfg.Overrides().SkipPermissionRequests,
+ DataDir: cfg.Options.DataDirectory,
+ Debug: cfg.Options.Debug,
+ Config: cfg,
+ }
+}
@@ -0,0 +1,214 @@
+package backend
+
+import (
+ "context"
+ "errors"
+ "fmt"
+
+ "github.com/charmbracelet/crush/internal/agent"
+ mcptools "github.com/charmbracelet/crush/internal/agent/tools/mcp"
+ "github.com/charmbracelet/crush/internal/commands"
+ "github.com/charmbracelet/crush/internal/config"
+ "github.com/charmbracelet/crush/internal/oauth"
+)
+
+// MCPResourceContents holds the contents of an MCP resource returned
+// by the backend.
+type MCPResourceContents struct {
+ URI string `json:"uri"`
+ MIMEType string `json:"mime_type,omitempty"`
+ Text string `json:"text,omitempty"`
+ Blob []byte `json:"blob,omitempty"`
+}
+
+// SetConfigField sets a key/value pair in the config file for the
+// given scope.
+func (b *Backend) SetConfigField(workspaceID string, scope config.Scope, key string, value any) error {
+ ws, err := b.GetWorkspace(workspaceID)
+ if err != nil {
+ return err
+ }
+ return ws.Cfg.SetConfigField(scope, key, value)
+}
+
+// RemoveConfigField removes a key from the config file for the given
+// scope.
+func (b *Backend) RemoveConfigField(workspaceID string, scope config.Scope, key string) error {
+ ws, err := b.GetWorkspace(workspaceID)
+ if err != nil {
+ return err
+ }
+ return ws.Cfg.RemoveConfigField(scope, key)
+}
+
+// UpdatePreferredModel updates the preferred model for the given type
+// and persists it to the config file at the given scope.
+func (b *Backend) UpdatePreferredModel(workspaceID string, scope config.Scope, modelType config.SelectedModelType, model config.SelectedModel) error {
+ ws, err := b.GetWorkspace(workspaceID)
+ if err != nil {
+ return err
+ }
+ return ws.Cfg.UpdatePreferredModel(scope, modelType, model)
+}
+
+// SetCompactMode sets the compact mode setting and persists it.
+func (b *Backend) SetCompactMode(workspaceID string, scope config.Scope, enabled bool) error {
+ ws, err := b.GetWorkspace(workspaceID)
+ if err != nil {
+ return err
+ }
+ return ws.Cfg.SetCompactMode(scope, enabled)
+}
+
+// SetProviderAPIKey sets the API key for a provider and persists it.
+func (b *Backend) SetProviderAPIKey(workspaceID string, scope config.Scope, providerID string, apiKey any) error {
+ ws, err := b.GetWorkspace(workspaceID)
+ if err != nil {
+ return err
+ }
+ return ws.Cfg.SetProviderAPIKey(scope, providerID, apiKey)
+}
+
+// ImportCopilot attempts to import a GitHub Copilot token from disk.
+func (b *Backend) ImportCopilot(workspaceID string) (*oauth.Token, bool, error) {
+ ws, err := b.GetWorkspace(workspaceID)
+ if err != nil {
+ return nil, false, err
+ }
+ token, ok := ws.Cfg.ImportCopilot()
+ return token, ok, nil
+}
+
+// RefreshOAuthToken refreshes the OAuth token for a provider.
+func (b *Backend) RefreshOAuthToken(ctx context.Context, workspaceID string, scope config.Scope, providerID string) error {
+ ws, err := b.GetWorkspace(workspaceID)
+ if err != nil {
+ return err
+ }
+ return ws.Cfg.RefreshOAuthToken(ctx, scope, providerID)
+}
+
+// ProjectNeedsInitialization checks whether the project in this
+// workspace needs initialization.
+func (b *Backend) ProjectNeedsInitialization(workspaceID string) (bool, error) {
+ ws, err := b.GetWorkspace(workspaceID)
+ if err != nil {
+ return false, err
+ }
+ return config.ProjectNeedsInitialization(ws.Cfg)
+}
+
+// MarkProjectInitialized marks the project as initialized.
+func (b *Backend) MarkProjectInitialized(workspaceID string) error {
+ ws, err := b.GetWorkspace(workspaceID)
+ if err != nil {
+ return err
+ }
+ return config.MarkProjectInitialized(ws.Cfg)
+}
+
+// InitializePrompt builds the initialization prompt for the workspace.
+func (b *Backend) InitializePrompt(workspaceID string) (string, error) {
+ ws, err := b.GetWorkspace(workspaceID)
+ if err != nil {
+ return "", err
+ }
+ 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)
+ if err != nil {
+ return err
+ }
+ mcptools.RefreshTools(ctx, ws.Cfg, name)
+ return nil
+}
+
+// ReadMCPResource reads a resource from a named MCP server.
+func (b *Backend) ReadMCPResource(ctx context.Context, workspaceID, name, uri string) ([]MCPResourceContents, error) {
+ ws, err := b.GetWorkspace(workspaceID)
+ if err != nil {
+ return nil, err
+ }
+ contents, err := mcptools.ReadResource(ctx, ws.Cfg, name, uri)
+ if err != nil {
+ return nil, err
+ }
+ result := make([]MCPResourceContents, len(contents))
+ for i, c := range contents {
+ result[i] = MCPResourceContents{
+ URI: c.URI,
+ MIMEType: c.MIMEType,
+ Text: c.Text,
+ Blob: c.Blob,
+ }
+ }
+ return result, nil
+}
+
+// GetMCPPrompt retrieves a prompt from a named MCP server.
+func (b *Backend) GetMCPPrompt(workspaceID, clientID, promptID string, args map[string]string) (string, error) {
+ ws, err := b.GetWorkspace(workspaceID)
+ if err != nil {
+ return "", err
+ }
+ return commands.GetMCPPrompt(ws.Cfg, clientID, promptID, args)
+}
+
+// GetWorkingDir returns the working directory for a workspace.
+func (b *Backend) GetWorkingDir(workspaceID string) (string, error) {
+ ws, err := b.GetWorkspace(workspaceID)
+ if err != nil {
+ return "", err
+ }
+ return ws.Cfg.WorkingDir(), nil
+}
@@ -0,0 +1,107 @@
+package backend
+
+import (
+ "context"
+
+ tea "charm.land/bubbletea/v2"
+
+ mcptools "github.com/charmbracelet/crush/internal/agent/tools/mcp"
+ "github.com/charmbracelet/crush/internal/app"
+ "github.com/charmbracelet/crush/internal/config"
+)
+
+// SubscribeEvents returns the event channel for a workspace's app.
+func (b *Backend) SubscribeEvents(workspaceID string) (<-chan tea.Msg, error) {
+ ws, err := b.GetWorkspace(workspaceID)
+ if err != nil {
+ return nil, err
+ }
+
+ return ws.Events(), nil
+}
+
+// GetLSPStates returns the state of all LSP clients.
+func (b *Backend) GetLSPStates(workspaceID string) (map[string]app.LSPClientInfo, error) {
+ _, err := b.GetWorkspace(workspaceID)
+ if err != nil {
+ return nil, err
+ }
+
+ return app.GetLSPStates(), nil
+}
+
+// GetLSPDiagnostics returns diagnostics for a specific LSP client in
+// the workspace.
+func (b *Backend) GetLSPDiagnostics(workspaceID, lspName string) (any, error) {
+ ws, err := b.GetWorkspace(workspaceID)
+ if err != nil {
+ return nil, err
+ }
+
+ for name, client := range ws.LSPManager.Clients().Seq2() {
+ if name == lspName {
+ return client.GetDiagnostics(), nil
+ }
+ }
+
+ return nil, ErrLSPClientNotFound
+}
+
+// GetWorkspaceConfig returns the workspace-level configuration.
+func (b *Backend) GetWorkspaceConfig(workspaceID string) (*config.Config, error) {
+ ws, err := b.GetWorkspace(workspaceID)
+ if err != nil {
+ return nil, err
+ }
+
+ return ws.Cfg.Config(), nil
+}
+
+// GetWorkspaceProviders returns the configured providers for a
+// workspace.
+func (b *Backend) GetWorkspaceProviders(workspaceID string) (any, error) {
+ ws, err := b.GetWorkspace(workspaceID)
+ if err != nil {
+ return nil, err
+ }
+
+ providers, _ := config.Providers(ws.Cfg.Config())
+ return providers, nil
+}
+
+// LSPStart starts an LSP server for the given path.
+func (b *Backend) LSPStart(ctx context.Context, workspaceID, path string) error {
+ ws, err := b.GetWorkspace(workspaceID)
+ if err != nil {
+ return err
+ }
+
+ ws.LSPManager.Start(ctx, path)
+ return nil
+}
+
+// LSPStopAll stops all LSP servers for a workspace.
+func (b *Backend) LSPStopAll(ctx context.Context, workspaceID string) error {
+ ws, err := b.GetWorkspace(workspaceID)
+ if err != nil {
+ return err
+ }
+
+ ws.LSPManager.StopAll(ctx)
+ return nil
+}
+
+// MCPGetStates returns the current state of all MCP clients.
+func (b *Backend) MCPGetStates(_ string) map[string]mcptools.ClientInfo {
+ return mcptools.GetStates()
+}
+
+// MCPRefreshPrompts refreshes prompts for a named MCP client.
+func (b *Backend) MCPRefreshPrompts(ctx context.Context, _ string, name string) {
+ mcptools.RefreshPrompts(ctx, name)
+}
+
+// MCPRefreshResources refreshes resources for a named MCP client.
+func (b *Backend) MCPRefreshResources(ctx context.Context, _ string, name string) {
+ mcptools.RefreshResources(ctx, name)
+}
@@ -0,0 +1,37 @@
+package backend
+
+import (
+ "context"
+ "time"
+)
+
+// FileTrackerRecordRead records a file read for a session.
+func (b *Backend) FileTrackerRecordRead(ctx context.Context, workspaceID, sessionID, path string) error {
+ ws, err := b.GetWorkspace(workspaceID)
+ if err != nil {
+ return err
+ }
+
+ ws.FileTracker.RecordRead(ctx, sessionID, path)
+ return nil
+}
+
+// FileTrackerLastReadTime returns the last read time for a file in a session.
+func (b *Backend) FileTrackerLastReadTime(ctx context.Context, workspaceID, sessionID, path string) (time.Time, error) {
+ ws, err := b.GetWorkspace(workspaceID)
+ if err != nil {
+ return time.Time{}, err
+ }
+
+ return ws.FileTracker.LastReadTime(ctx, sessionID, path), nil
+}
+
+// FileTrackerListReadFiles returns the list of read files for a session.
+func (b *Backend) FileTrackerListReadFiles(ctx context.Context, workspaceID, sessionID string) ([]string, error) {
+ ws, err := b.GetWorkspace(workspaceID)
+ if err != nil {
+ return nil, err
+ }
+
+ return ws.FileTracker.ListReadFiles(ctx, sessionID)
+}
@@ -0,0 +1,59 @@
+package backend
+
+import (
+ "github.com/charmbracelet/crush/internal/permission"
+ "github.com/charmbracelet/crush/internal/proto"
+)
+
+// GrantPermission grants, denies, or persistently grants a permission
+// request.
+func (b *Backend) GrantPermission(workspaceID string, req proto.PermissionGrant) error {
+ ws, err := b.GetWorkspace(workspaceID)
+ if err != nil {
+ return err
+ }
+
+ perm := permission.PermissionRequest{
+ ID: req.Permission.ID,
+ SessionID: req.Permission.SessionID,
+ ToolCallID: req.Permission.ToolCallID,
+ ToolName: req.Permission.ToolName,
+ Description: req.Permission.Description,
+ Action: req.Permission.Action,
+ Params: req.Permission.Params,
+ Path: req.Permission.Path,
+ }
+
+ switch req.Action {
+ case proto.PermissionAllow:
+ ws.Permissions.Grant(perm)
+ case proto.PermissionAllowForSession:
+ ws.Permissions.GrantPersistent(perm)
+ case proto.PermissionDeny:
+ ws.Permissions.Deny(perm)
+ default:
+ return ErrInvalidPermissionAction
+ }
+ return nil
+}
+
+// SetPermissionsSkip sets whether permission prompts are skipped.
+func (b *Backend) SetPermissionsSkip(workspaceID string, skip bool) error {
+ ws, err := b.GetWorkspace(workspaceID)
+ if err != nil {
+ return err
+ }
+
+ ws.Permissions.SetSkipRequests(skip)
+ return nil
+}
+
+// GetPermissionsSkip returns whether permission prompts are skipped.
+func (b *Backend) GetPermissionsSkip(workspaceID string) (bool, error) {
+ ws, err := b.GetWorkspace(workspaceID)
+ if err != nil {
+ return false, err
+ }
+
+ return ws.Permissions.SkipRequests(), nil
+}
@@ -0,0 +1,126 @@
+package backend
+
+import (
+ "context"
+
+ "github.com/charmbracelet/crush/internal/message"
+ "github.com/charmbracelet/crush/internal/proto"
+ "github.com/charmbracelet/crush/internal/session"
+)
+
+// CreateSession creates a new session in the given workspace.
+func (b *Backend) CreateSession(ctx context.Context, workspaceID, title string) (session.Session, error) {
+ ws, err := b.GetWorkspace(workspaceID)
+ if err != nil {
+ return session.Session{}, err
+ }
+
+ return ws.Sessions.Create(ctx, title)
+}
+
+// GetSession retrieves a session by workspace and session ID.
+func (b *Backend) GetSession(ctx context.Context, workspaceID, sessionID string) (session.Session, error) {
+ ws, err := b.GetWorkspace(workspaceID)
+ if err != nil {
+ return session.Session{}, err
+ }
+
+ return ws.Sessions.Get(ctx, sessionID)
+}
+
+// ListSessions returns all sessions in the given workspace.
+func (b *Backend) ListSessions(ctx context.Context, workspaceID string) ([]session.Session, error) {
+ ws, err := b.GetWorkspace(workspaceID)
+ if err != nil {
+ return nil, err
+ }
+
+ return ws.Sessions.List(ctx)
+}
+
+// GetAgentSession returns session metadata with the agent's busy
+// status.
+func (b *Backend) GetAgentSession(ctx context.Context, workspaceID, sessionID string) (proto.AgentSession, error) {
+ ws, err := b.GetWorkspace(workspaceID)
+ if err != nil {
+ return proto.AgentSession{}, err
+ }
+
+ se, err := ws.Sessions.Get(ctx, sessionID)
+ if err != nil {
+ return proto.AgentSession{}, err
+ }
+
+ var isSessionBusy bool
+ if ws.AgentCoordinator != nil {
+ isSessionBusy = ws.AgentCoordinator.IsSessionBusy(sessionID)
+ }
+
+ return proto.AgentSession{
+ Session: proto.Session{
+ ID: se.ID,
+ Title: se.Title,
+ },
+ IsBusy: isSessionBusy,
+ }, nil
+}
+
+// ListSessionMessages returns all messages for a session.
+func (b *Backend) ListSessionMessages(ctx context.Context, workspaceID, sessionID string) ([]message.Message, error) {
+ ws, err := b.GetWorkspace(workspaceID)
+ if err != nil {
+ return nil, err
+ }
+
+ return ws.Messages.List(ctx, sessionID)
+}
+
+// ListSessionHistory returns the history items for a session.
+func (b *Backend) ListSessionHistory(ctx context.Context, workspaceID, sessionID string) (any, error) {
+ ws, err := b.GetWorkspace(workspaceID)
+ if err != nil {
+ return nil, err
+ }
+
+ return ws.History.ListBySession(ctx, sessionID)
+}
+
+// SaveSession updates a session in the given workspace.
+func (b *Backend) SaveSession(ctx context.Context, workspaceID string, sess session.Session) (session.Session, error) {
+ ws, err := b.GetWorkspace(workspaceID)
+ if err != nil {
+ return session.Session{}, err
+ }
+
+ return ws.Sessions.Save(ctx, sess)
+}
+
+// DeleteSession deletes a session from the given workspace.
+func (b *Backend) DeleteSession(ctx context.Context, workspaceID, sessionID string) error {
+ ws, err := b.GetWorkspace(workspaceID)
+ if err != nil {
+ return err
+ }
+
+ return ws.Sessions.Delete(ctx, sessionID)
+}
+
+// ListUserMessages returns user-role messages for a session.
+func (b *Backend) ListUserMessages(ctx context.Context, workspaceID, sessionID string) ([]message.Message, error) {
+ ws, err := b.GetWorkspace(workspaceID)
+ if err != nil {
+ return nil, err
+ }
+
+ return ws.Messages.ListUserMessages(ctx, sessionID)
+}
+
+// ListAllUserMessages returns all user-role messages across sessions.
+func (b *Backend) ListAllUserMessages(ctx context.Context, workspaceID string) ([]message.Message, error) {
+ ws, err := b.GetWorkspace(workspaceID)
+ if err != nil {
+ return nil, err
+ }
+
+ return ws.Messages.ListAllUserMessages(ctx)
+}
@@ -0,0 +1,22 @@
+package backend
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+)
+
+func createDotCrushDir(dir string) error {
+ if err := os.MkdirAll(dir, 0o700); err != nil {
+ return fmt.Errorf("failed to create data directory: %q %w", dir, err)
+ }
+
+ gitIgnorePath := filepath.Join(dir, ".gitignore")
+ if _, err := os.Stat(gitIgnorePath); os.IsNotExist(err) {
+ if err := os.WriteFile(gitIgnorePath, []byte("*\n"), 0o644); err != nil {
+ return fmt.Errorf("failed to create .gitignore file: %q %w", gitIgnorePath, err)
+ }
+ }
+
+ return nil
+}
@@ -0,0 +1,196 @@
+package client
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net"
+ "net/http"
+ "net/url"
+ stdpath "path"
+ "path/filepath"
+ "time"
+
+ "github.com/charmbracelet/crush/internal/config"
+ "github.com/charmbracelet/crush/internal/proto"
+ "github.com/charmbracelet/crush/internal/server"
+)
+
+// DummyHost is used to satisfy the http.Client's requirement for a URL.
+const DummyHost = "api.crush.localhost"
+
+// Client represents an RPC client connected to a Crush server.
+type Client struct {
+ h *http.Client
+ path string
+ network string
+ addr string
+}
+
+// DefaultClient creates a new [Client] connected to the default server address.
+func DefaultClient(path string) (*Client, error) {
+ host, err := server.ParseHostURL(server.DefaultHost())
+ if err != nil {
+ return nil, err
+ }
+ return NewClient(path, host.Scheme, host.Host)
+}
+
+// NewClient creates a new [Client] connected to the server at the given
+// network and address.
+func NewClient(path, network, address string) (*Client, error) {
+ c := new(Client)
+ c.path = filepath.Clean(path)
+ c.network = network
+ c.addr = address
+ p := &http.Protocols{}
+ p.SetHTTP1(true)
+ p.SetUnencryptedHTTP2(true)
+ tr := http.DefaultTransport.(*http.Transport).Clone()
+ tr.Protocols = p
+ tr.DialContext = c.dialer
+ if c.network == "npipe" || c.network == "unix" {
+ tr.DisableCompression = true
+ }
+ c.h = &http.Client{
+ Transport: tr,
+ Timeout: 0,
+ }
+ return c, nil
+}
+
+// Path returns the client's workspace filesystem path.
+func (c *Client) Path() string {
+ return c.path
+}
+
+// GetGlobalConfig retrieves the server's configuration.
+func (c *Client) GetGlobalConfig(ctx context.Context) (*config.Config, error) {
+ var cfg config.Config
+ rsp, err := c.get(ctx, "/config", nil, nil)
+ if err != nil {
+ return nil, err
+ }
+ defer rsp.Body.Close()
+ if err := json.NewDecoder(rsp.Body).Decode(&cfg); err != nil {
+ return nil, err
+ }
+ return &cfg, nil
+}
+
+// Health checks the server's health status.
+func (c *Client) Health(ctx context.Context) error {
+ rsp, err := c.get(ctx, "/health", nil, nil)
+ if err != nil {
+ return err
+ }
+ defer rsp.Body.Close()
+ if rsp.StatusCode != http.StatusOK {
+ return fmt.Errorf("server health check failed: %s", rsp.Status)
+ }
+ return nil
+}
+
+// VersionInfo retrieves the server's version information.
+func (c *Client) VersionInfo(ctx context.Context) (*proto.VersionInfo, error) {
+ var vi proto.VersionInfo
+ rsp, err := c.get(ctx, "version", nil, nil)
+ if err != nil {
+ return nil, err
+ }
+ defer rsp.Body.Close()
+ if err := json.NewDecoder(rsp.Body).Decode(&vi); err != nil {
+ return nil, err
+ }
+ return &vi, nil
+}
+
+// ShutdownServer sends a shutdown request to the server.
+func (c *Client) ShutdownServer(ctx context.Context) error {
+ rsp, err := c.post(ctx, "/control", nil, jsonBody(proto.ServerControl{
+ Command: "shutdown",
+ }), nil)
+ if err != nil {
+ return err
+ }
+ defer rsp.Body.Close()
+ if rsp.StatusCode != http.StatusOK {
+ return fmt.Errorf("server shutdown failed: %s", rsp.Status)
+ }
+ return nil
+}
+
+func (c *Client) dialer(ctx context.Context, network, address string) (net.Conn, error) {
+ d := net.Dialer{
+ Timeout: 30 * time.Second,
+ KeepAlive: 30 * time.Second,
+ }
+ switch c.network {
+ case "npipe":
+ ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
+ defer cancel()
+ return dialPipeContext(ctx, c.addr)
+ case "unix":
+ return d.DialContext(ctx, "unix", c.addr)
+ default:
+ return d.DialContext(ctx, network, address)
+ }
+}
+
+func (c *Client) get(ctx context.Context, path string, query url.Values, headers http.Header) (*http.Response, error) {
+ return c.sendReq(ctx, http.MethodGet, path, query, nil, headers)
+}
+
+func (c *Client) post(ctx context.Context, path string, query url.Values, body io.Reader, headers http.Header) (*http.Response, error) {
+ return c.sendReq(ctx, http.MethodPost, path, query, body, headers)
+}
+
+func (c *Client) delete(ctx context.Context, path string, query url.Values, headers http.Header) (*http.Response, error) {
+ return c.sendReq(ctx, http.MethodDelete, path, query, nil, headers)
+}
+
+func (c *Client) put(ctx context.Context, path string, query url.Values, body io.Reader, headers http.Header) (*http.Response, error) {
+ return c.sendReq(ctx, http.MethodPut, path, query, body, headers)
+}
+
+func (c *Client) sendReq(ctx context.Context, method, path string, query url.Values, body io.Reader, headers http.Header) (*http.Response, error) {
+ url := (&url.URL{
+ Path: stdpath.Join("/v1", path),
+ RawQuery: query.Encode(),
+ }).String()
+ req, err := c.buildReq(ctx, method, url, body, headers)
+ if err != nil {
+ return nil, err
+ }
+
+ rsp, err := c.h.Do(req)
+ if err != nil {
+ return nil, err
+ }
+
+ return rsp, nil
+}
+
+func (c *Client) buildReq(ctx context.Context, method, url string, body io.Reader, headers http.Header) (*http.Request, error) {
+ r, err := http.NewRequestWithContext(ctx, method, url, body)
+ if err != nil {
+ return nil, err
+ }
+
+ for k, v := range headers {
+ r.Header[http.CanonicalHeaderKey(k)] = v
+ }
+
+ r.URL.Scheme = "http"
+ r.URL.Host = c.addr
+ if c.network == "npipe" || c.network == "unix" {
+ r.Host = DummyHost
+ }
+
+ if body != nil && r.Header.Get("Content-Type") == "" {
+ r.Header.Set("Content-Type", "text/plain")
+ }
+
+ return r, nil
+}
@@ -0,0 +1,278 @@
+package client
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+
+ "github.com/charmbracelet/crush/internal/config"
+ "github.com/charmbracelet/crush/internal/oauth"
+)
+
+// SetConfigField sets a config key/value pair on the server.
+func (c *Client) SetConfigField(ctx context.Context, id string, scope config.Scope, key string, value any) error {
+ rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/config/set", id), nil, jsonBody(struct {
+ Scope config.Scope `json:"scope"`
+ Key string `json:"key"`
+ Value any `json:"value"`
+ }{Scope: scope, Key: key, Value: value}), http.Header{"Content-Type": []string{"application/json"}})
+ if err != nil {
+ return fmt.Errorf("failed to set config field: %w", err)
+ }
+ defer rsp.Body.Close()
+ if rsp.StatusCode != http.StatusOK {
+ return fmt.Errorf("failed to set config field: status code %d", rsp.StatusCode)
+ }
+ return nil
+}
+
+// RemoveConfigField removes a config key on the server.
+func (c *Client) RemoveConfigField(ctx context.Context, id string, scope config.Scope, key string) error {
+ rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/config/remove", id), nil, jsonBody(struct {
+ Scope config.Scope `json:"scope"`
+ Key string `json:"key"`
+ }{Scope: scope, Key: key}), http.Header{"Content-Type": []string{"application/json"}})
+ if err != nil {
+ return fmt.Errorf("failed to remove config field: %w", err)
+ }
+ defer rsp.Body.Close()
+ if rsp.StatusCode != http.StatusOK {
+ return fmt.Errorf("failed to remove config field: status code %d", rsp.StatusCode)
+ }
+ return nil
+}
+
+// UpdatePreferredModel updates the preferred model on the server.
+func (c *Client) UpdatePreferredModel(ctx context.Context, id string, scope config.Scope, modelType config.SelectedModelType, model config.SelectedModel) error {
+ rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/config/model", id), nil, jsonBody(struct {
+ Scope config.Scope `json:"scope"`
+ ModelType config.SelectedModelType `json:"model_type"`
+ Model config.SelectedModel `json:"model"`
+ }{Scope: scope, ModelType: modelType, Model: model}), http.Header{"Content-Type": []string{"application/json"}})
+ if err != nil {
+ return fmt.Errorf("failed to update preferred model: %w", err)
+ }
+ defer rsp.Body.Close()
+ if rsp.StatusCode != http.StatusOK {
+ return fmt.Errorf("failed to update preferred model: status code %d", rsp.StatusCode)
+ }
+ return nil
+}
+
+// SetCompactMode sets compact mode on the server.
+func (c *Client) SetCompactMode(ctx context.Context, id string, scope config.Scope, enabled bool) error {
+ rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/config/compact", id), nil, jsonBody(struct {
+ Scope config.Scope `json:"scope"`
+ Enabled bool `json:"enabled"`
+ }{Scope: scope, Enabled: enabled}), http.Header{"Content-Type": []string{"application/json"}})
+ if err != nil {
+ return fmt.Errorf("failed to set compact mode: %w", err)
+ }
+ defer rsp.Body.Close()
+ if rsp.StatusCode != http.StatusOK {
+ return fmt.Errorf("failed to set compact mode: status code %d", rsp.StatusCode)
+ }
+ return nil
+}
+
+// SetProviderAPIKey sets a provider API key on the server.
+func (c *Client) SetProviderAPIKey(ctx context.Context, id string, scope config.Scope, providerID string, apiKey any) error {
+ rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/config/provider-key", id), nil, jsonBody(struct {
+ Scope config.Scope `json:"scope"`
+ ProviderID string `json:"provider_id"`
+ APIKey any `json:"api_key"`
+ }{Scope: scope, ProviderID: providerID, APIKey: apiKey}), http.Header{"Content-Type": []string{"application/json"}})
+ if err != nil {
+ return fmt.Errorf("failed to set provider API key: %w", err)
+ }
+ defer rsp.Body.Close()
+ if rsp.StatusCode != http.StatusOK {
+ return fmt.Errorf("failed to set provider API key: status code %d", rsp.StatusCode)
+ }
+ return nil
+}
+
+// ImportCopilot attempts to import a GitHub Copilot token on the
+// server.
+func (c *Client) ImportCopilot(ctx context.Context, id string) (*oauth.Token, bool, error) {
+ rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/config/import-copilot", id), nil, nil, nil)
+ if err != nil {
+ return nil, false, fmt.Errorf("failed to import copilot: %w", err)
+ }
+ defer rsp.Body.Close()
+ if rsp.StatusCode != http.StatusOK {
+ return nil, false, fmt.Errorf("failed to import copilot: status code %d", rsp.StatusCode)
+ }
+ var result struct {
+ Token *oauth.Token `json:"token"`
+ Success bool `json:"success"`
+ }
+ if err := json.NewDecoder(rsp.Body).Decode(&result); err != nil {
+ return nil, false, fmt.Errorf("failed to decode import copilot response: %w", err)
+ }
+ return result.Token, result.Success, nil
+}
+
+// RefreshOAuthToken refreshes an OAuth token for a provider on the
+// server.
+func (c *Client) RefreshOAuthToken(ctx context.Context, id string, scope config.Scope, providerID string) error {
+ rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/config/refresh-oauth", id), nil, jsonBody(struct {
+ Scope config.Scope `json:"scope"`
+ ProviderID string `json:"provider_id"`
+ }{Scope: scope, ProviderID: providerID}), http.Header{"Content-Type": []string{"application/json"}})
+ if err != nil {
+ return fmt.Errorf("failed to refresh OAuth token: %w", err)
+ }
+ defer rsp.Body.Close()
+ if rsp.StatusCode != http.StatusOK {
+ return fmt.Errorf("failed to refresh OAuth token: status code %d", rsp.StatusCode)
+ }
+ return nil
+}
+
+// ProjectNeedsInitialization checks if the project needs
+// initialization.
+func (c *Client) ProjectNeedsInitialization(ctx context.Context, id string) (bool, error) {
+ rsp, err := c.get(ctx, fmt.Sprintf("/workspaces/%s/project/needs-init", id), nil, nil)
+ if err != nil {
+ return false, fmt.Errorf("failed to check project init: %w", err)
+ }
+ defer rsp.Body.Close()
+ if rsp.StatusCode != http.StatusOK {
+ return false, fmt.Errorf("failed to check project init: status code %d", rsp.StatusCode)
+ }
+ var result struct {
+ NeedsInit bool `json:"needs_init"`
+ }
+ if err := json.NewDecoder(rsp.Body).Decode(&result); err != nil {
+ return false, fmt.Errorf("failed to decode project init response: %w", err)
+ }
+ return result.NeedsInit, nil
+}
+
+// MarkProjectInitialized marks the project as initialized on the
+// server.
+func (c *Client) MarkProjectInitialized(ctx context.Context, id string) error {
+ rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/project/init", id), nil, nil, nil)
+ if err != nil {
+ return fmt.Errorf("failed to mark project initialized: %w", err)
+ }
+ defer rsp.Body.Close()
+ if rsp.StatusCode != http.StatusOK {
+ return fmt.Errorf("failed to mark project initialized: status code %d", rsp.StatusCode)
+ }
+ return nil
+}
+
+// GetInitializePrompt retrieves the initialization prompt from the
+// server.
+func (c *Client) GetInitializePrompt(ctx context.Context, id string) (string, error) {
+ rsp, err := c.get(ctx, fmt.Sprintf("/workspaces/%s/project/init-prompt", id), nil, nil)
+ if err != nil {
+ return "", fmt.Errorf("failed to get init prompt: %w", err)
+ }
+ defer rsp.Body.Close()
+ if rsp.StatusCode != http.StatusOK {
+ return "", fmt.Errorf("failed to get init prompt: status code %d", rsp.StatusCode)
+ }
+ var result struct {
+ Prompt string `json:"prompt"`
+ }
+ if err := json.NewDecoder(rsp.Body).Decode(&result); err != nil {
+ return "", fmt.Errorf("failed to decode init prompt response: %w", err)
+ }
+ return result.Prompt, nil
+}
+
+// MCPResourceContents holds the contents of an MCP resource.
+type MCPResourceContents struct {
+ URI string `json:"uri"`
+ MIMEType string `json:"mime_type,omitempty"`
+ Text string `json:"text,omitempty"`
+ 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 {
+ Name string `json:"name"`
+ }{Name: name}), http.Header{"Content-Type": []string{"application/json"}})
+ if err != nil {
+ return fmt.Errorf("failed to refresh MCP tools: %w", err)
+ }
+ defer rsp.Body.Close()
+ if rsp.StatusCode != http.StatusOK {
+ return fmt.Errorf("failed to refresh MCP tools: status code %d", rsp.StatusCode)
+ }
+ return nil
+}
+
+// ReadMCPResource reads a resource from a named MCP server.
+func (c *Client) ReadMCPResource(ctx context.Context, id, name, uri string) ([]MCPResourceContents, error) {
+ rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/mcp/read-resource", id), nil, jsonBody(struct {
+ Name string `json:"name"`
+ URI string `json:"uri"`
+ }{Name: name, URI: uri}), http.Header{"Content-Type": []string{"application/json"}})
+ if err != nil {
+ return nil, fmt.Errorf("failed to read MCP resource: %w", err)
+ }
+ defer rsp.Body.Close()
+ if rsp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("failed to read MCP resource: status code %d", rsp.StatusCode)
+ }
+ var contents []MCPResourceContents
+ if err := json.NewDecoder(rsp.Body).Decode(&contents); err != nil {
+ return nil, fmt.Errorf("failed to decode MCP resource: %w", err)
+ }
+ return contents, nil
+}
+
+// GetMCPPrompt retrieves a prompt from a named MCP server.
+func (c *Client) GetMCPPrompt(ctx context.Context, id, clientID, promptID string, args map[string]string) (string, error) {
+ rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/mcp/get-prompt", id), nil, jsonBody(struct {
+ ClientID string `json:"client_id"`
+ PromptID string `json:"prompt_id"`
+ Args map[string]string `json:"args"`
+ }{ClientID: clientID, PromptID: promptID, Args: args}), http.Header{"Content-Type": []string{"application/json"}})
+ if err != nil {
+ return "", fmt.Errorf("failed to get MCP prompt: %w", err)
+ }
+ defer rsp.Body.Close()
+ if rsp.StatusCode != http.StatusOK {
+ return "", fmt.Errorf("failed to get MCP prompt: status code %d", rsp.StatusCode)
+ }
+ var result struct {
+ Prompt string `json:"prompt"`
+ }
+ if err := json.NewDecoder(rsp.Body).Decode(&result); err != nil {
+ return "", fmt.Errorf("failed to decode MCP prompt response: %w", err)
+ }
+ return result.Prompt, nil
+}
@@ -0,0 +1,14 @@
+//go:build !windows
+// +build !windows
+
+package client
+
+import (
+ "context"
+ "net"
+ "syscall"
+)
+
+func dialPipeContext(context.Context, string) (net.Conn, error) {
+ return nil, syscall.EAFNOSUPPORT
+}
@@ -0,0 +1,15 @@
+//go:build windows
+// +build windows
+
+package client
+
+import (
+ "context"
+ "net"
+
+ "github.com/Microsoft/go-winio"
+)
+
+func dialPipeContext(ctx context.Context, address string) (net.Conn, error) {
+ return winio.DialPipeContext(ctx, address)
+}
@@ -0,0 +1,750 @@
+package client
+
+import (
+ "bufio"
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "log/slog"
+ "net/http"
+ "net/url"
+ "time"
+
+ "github.com/charmbracelet/crush/internal/config"
+ "github.com/charmbracelet/crush/internal/message"
+ "github.com/charmbracelet/crush/internal/proto"
+ "github.com/charmbracelet/crush/internal/pubsub"
+ "github.com/charmbracelet/x/powernap/pkg/lsp/protocol"
+)
+
+// ListWorkspaces retrieves all workspaces from the server.
+func (c *Client) ListWorkspaces(ctx context.Context) ([]proto.Workspace, error) {
+ rsp, err := c.get(ctx, "/workspaces", nil, nil)
+ if err != nil {
+ return nil, fmt.Errorf("failed to list workspaces: %w", err)
+ }
+ defer rsp.Body.Close()
+ if rsp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("failed to list workspaces: status code %d", rsp.StatusCode)
+ }
+ var workspaces []proto.Workspace
+ if err := json.NewDecoder(rsp.Body).Decode(&workspaces); err != nil {
+ return nil, fmt.Errorf("failed to decode workspaces: %w", err)
+ }
+ return workspaces, nil
+}
+
+// CreateWorkspace creates a new workspace on the server.
+func (c *Client) CreateWorkspace(ctx context.Context, ws proto.Workspace) (*proto.Workspace, error) {
+ rsp, err := c.post(ctx, "/workspaces", nil, jsonBody(ws), http.Header{"Content-Type": []string{"application/json"}})
+ if err != nil {
+ return nil, fmt.Errorf("failed to create workspace: %w", err)
+ }
+ defer rsp.Body.Close()
+ if rsp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("failed to create workspace: status code %d", rsp.StatusCode)
+ }
+ var created proto.Workspace
+ if err := json.NewDecoder(rsp.Body).Decode(&created); err != nil {
+ return nil, fmt.Errorf("failed to decode workspace: %w", err)
+ }
+ return &created, nil
+}
+
+// GetWorkspace retrieves a workspace from the server.
+func (c *Client) GetWorkspace(ctx context.Context, id string) (*proto.Workspace, error) {
+ rsp, err := c.get(ctx, fmt.Sprintf("/workspaces/%s", id), nil, nil)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get workspace: %w", err)
+ }
+ defer rsp.Body.Close()
+ if rsp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("failed to get workspace: status code %d", rsp.StatusCode)
+ }
+ var ws proto.Workspace
+ if err := json.NewDecoder(rsp.Body).Decode(&ws); err != nil {
+ return nil, fmt.Errorf("failed to decode workspace: %w", err)
+ }
+ return &ws, nil
+}
+
+// DeleteWorkspace deletes a workspace on the server.
+func (c *Client) DeleteWorkspace(ctx context.Context, id string) error {
+ rsp, err := c.delete(ctx, fmt.Sprintf("/workspaces/%s", id), nil, nil)
+ if err != nil {
+ return fmt.Errorf("failed to delete workspace: %w", err)
+ }
+ defer rsp.Body.Close()
+ if rsp.StatusCode != http.StatusOK {
+ return fmt.Errorf("failed to delete workspace: status code %d", rsp.StatusCode)
+ }
+ return nil
+}
+
+// SubscribeEvents subscribes to server-sent events for a workspace.
+func (c *Client) SubscribeEvents(ctx context.Context, id string) (<-chan any, error) {
+ events := make(chan any, 100)
+ //nolint:bodyclose
+ rsp, err := c.get(ctx, fmt.Sprintf("/workspaces/%s/events", id), nil, http.Header{
+ "Accept": []string{"text/event-stream"},
+ "Cache-Control": []string{"no-cache"},
+ "Connection": []string{"keep-alive"},
+ })
+ if err != nil {
+ return nil, fmt.Errorf("failed to subscribe to events: %w", err)
+ }
+
+ if rsp.StatusCode != http.StatusOK {
+ rsp.Body.Close()
+ return nil, fmt.Errorf("failed to subscribe to events: status code %d", rsp.StatusCode)
+ }
+
+ go func() {
+ defer rsp.Body.Close()
+
+ scr := bufio.NewReader(rsp.Body)
+ for {
+ line, err := scr.ReadBytes('\n')
+ if errors.Is(err, io.EOF) {
+ break
+ }
+ if err != nil {
+ slog.Error("Reading from events stream", "error", err)
+ time.Sleep(time.Second * 2)
+ continue
+ }
+ line = bytes.TrimSpace(line)
+ if len(line) == 0 {
+ continue
+ }
+
+ data, ok := bytes.CutPrefix(line, []byte("data:"))
+ if !ok {
+ slog.Warn("Invalid event format", "line", string(line))
+ continue
+ }
+
+ data = bytes.TrimSpace(data)
+
+ var p pubsub.Payload
+ if err := json.Unmarshal(data, &p); err != nil {
+ slog.Error("Unmarshaling event envelope", "error", err)
+ continue
+ }
+
+ switch p.Type {
+ case pubsub.PayloadTypeLSPEvent:
+ var e pubsub.Event[proto.LSPEvent]
+ _ = json.Unmarshal(p.Payload, &e)
+ sendEvent(ctx, events, e)
+ case pubsub.PayloadTypeMCPEvent:
+ var e pubsub.Event[proto.MCPEvent]
+ _ = json.Unmarshal(p.Payload, &e)
+ sendEvent(ctx, events, e)
+ case pubsub.PayloadTypePermissionRequest:
+ var e pubsub.Event[proto.PermissionRequest]
+ _ = json.Unmarshal(p.Payload, &e)
+ sendEvent(ctx, events, e)
+ case pubsub.PayloadTypePermissionNotification:
+ var e pubsub.Event[proto.PermissionNotification]
+ _ = json.Unmarshal(p.Payload, &e)
+ sendEvent(ctx, events, e)
+ case pubsub.PayloadTypeMessage:
+ var e pubsub.Event[proto.Message]
+ _ = json.Unmarshal(p.Payload, &e)
+ sendEvent(ctx, events, e)
+ case pubsub.PayloadTypeSession:
+ var e pubsub.Event[proto.Session]
+ _ = json.Unmarshal(p.Payload, &e)
+ sendEvent(ctx, events, e)
+ case pubsub.PayloadTypeFile:
+ var e pubsub.Event[proto.File]
+ _ = json.Unmarshal(p.Payload, &e)
+ sendEvent(ctx, events, e)
+ case pubsub.PayloadTypeAgentEvent:
+ var e pubsub.Event[proto.AgentEvent]
+ _ = json.Unmarshal(p.Payload, &e)
+ sendEvent(ctx, events, e)
+ default:
+ slog.Warn("Unknown event type", "type", p.Type)
+ continue
+ }
+ }
+ }()
+
+ return events, nil
+}
+
+func sendEvent(ctx context.Context, evc chan any, ev any) {
+ slog.Info("Event received", "event", fmt.Sprintf("%T %+v", ev, ev))
+ select {
+ case evc <- ev:
+ case <-ctx.Done():
+ close(evc)
+ return
+ }
+}
+
+// GetLSPDiagnostics retrieves LSP diagnostics for a specific LSP client.
+func (c *Client) GetLSPDiagnostics(ctx context.Context, id string, lspName string) (map[protocol.DocumentURI][]protocol.Diagnostic, error) {
+ rsp, err := c.get(ctx, fmt.Sprintf("/workspaces/%s/lsps/%s/diagnostics", id, lspName), nil, nil)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get LSP diagnostics: %w", err)
+ }
+ defer rsp.Body.Close()
+ if rsp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("failed to get LSP diagnostics: status code %d", rsp.StatusCode)
+ }
+ var diagnostics map[protocol.DocumentURI][]protocol.Diagnostic
+ if err := json.NewDecoder(rsp.Body).Decode(&diagnostics); err != nil {
+ return nil, fmt.Errorf("failed to decode LSP diagnostics: %w", err)
+ }
+ return diagnostics, nil
+}
+
+// GetLSPs retrieves the LSP client states for a workspace.
+func (c *Client) GetLSPs(ctx context.Context, id string) (map[string]proto.LSPClientInfo, error) {
+ rsp, err := c.get(ctx, fmt.Sprintf("/workspaces/%s/lsps", id), nil, nil)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get LSPs: %w", err)
+ }
+ defer rsp.Body.Close()
+ if rsp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("failed to get LSPs: status code %d", rsp.StatusCode)
+ }
+ var lsps map[string]proto.LSPClientInfo
+ if err := json.NewDecoder(rsp.Body).Decode(&lsps); err != nil {
+ return nil, fmt.Errorf("failed to decode LSPs: %w", err)
+ }
+ return lsps, nil
+}
+
+// MCPGetStates retrieves the MCP client states for a workspace.
+func (c *Client) MCPGetStates(ctx context.Context, id string) (map[string]proto.MCPClientInfo, error) {
+ rsp, err := c.get(ctx, fmt.Sprintf("/workspaces/%s/mcp/states", id), nil, nil)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get MCP states: %w", err)
+ }
+ defer rsp.Body.Close()
+ if rsp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("failed to get MCP states: status code %d", rsp.StatusCode)
+ }
+ var states map[string]proto.MCPClientInfo
+ if err := json.NewDecoder(rsp.Body).Decode(&states); err != nil {
+ return nil, fmt.Errorf("failed to decode MCP states: %w", err)
+ }
+ return states, nil
+}
+
+// MCPRefreshPrompts refreshes prompts for a named MCP client.
+func (c *Client) MCPRefreshPrompts(ctx context.Context, id, name string) error {
+ rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/mcp/refresh-prompts", id), nil,
+ jsonBody(struct {
+ Name string `json:"name"`
+ }{Name: name}),
+ http.Header{"Content-Type": []string{"application/json"}})
+ if err != nil {
+ return fmt.Errorf("failed to refresh MCP prompts: %w", err)
+ }
+ defer rsp.Body.Close()
+ if rsp.StatusCode != http.StatusOK {
+ return fmt.Errorf("failed to refresh MCP prompts: status code %d", rsp.StatusCode)
+ }
+ return nil
+}
+
+// MCPRefreshResources refreshes resources for a named MCP client.
+func (c *Client) MCPRefreshResources(ctx context.Context, id, name string) error {
+ rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/mcp/refresh-resources", id), nil,
+ jsonBody(struct {
+ Name string `json:"name"`
+ }{Name: name}),
+ http.Header{"Content-Type": []string{"application/json"}})
+ if err != nil {
+ return fmt.Errorf("failed to refresh MCP resources: %w", err)
+ }
+ defer rsp.Body.Close()
+ if rsp.StatusCode != http.StatusOK {
+ return fmt.Errorf("failed to refresh MCP resources: status code %d", rsp.StatusCode)
+ }
+ return nil
+}
+
+// GetAgentSessionQueuedPrompts retrieves the number of queued prompts for a
+// session.
+func (c *Client) GetAgentSessionQueuedPrompts(ctx context.Context, id string, sessionID string) (int, error) {
+ rsp, err := c.get(ctx, fmt.Sprintf("/workspaces/%s/agent/sessions/%s/prompts/queued", id, sessionID), nil, nil)
+ if err != nil {
+ return 0, fmt.Errorf("failed to get session agent queued prompts: %w", err)
+ }
+ defer rsp.Body.Close()
+ if rsp.StatusCode != http.StatusOK {
+ return 0, fmt.Errorf("failed to get session agent queued prompts: status code %d", rsp.StatusCode)
+ }
+ var count int
+ if err := json.NewDecoder(rsp.Body).Decode(&count); err != nil {
+ return 0, fmt.Errorf("failed to decode session agent queued prompts: %w", err)
+ }
+ return count, nil
+}
+
+// ClearAgentSessionQueuedPrompts clears the queued prompts for a session.
+func (c *Client) ClearAgentSessionQueuedPrompts(ctx context.Context, id string, sessionID string) error {
+ rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/agent/sessions/%s/prompts/clear", id, sessionID), nil, nil, nil)
+ if err != nil {
+ return fmt.Errorf("failed to clear session agent queued prompts: %w", err)
+ }
+ defer rsp.Body.Close()
+ if rsp.StatusCode != http.StatusOK {
+ return fmt.Errorf("failed to clear session agent queued prompts: status code %d", rsp.StatusCode)
+ }
+ return nil
+}
+
+// GetAgentInfo retrieves the agent status for a workspace.
+func (c *Client) GetAgentInfo(ctx context.Context, id string) (*proto.AgentInfo, error) {
+ rsp, err := c.get(ctx, fmt.Sprintf("/workspaces/%s/agent", id), nil, nil)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get agent status: %w", err)
+ }
+ defer rsp.Body.Close()
+ if rsp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("failed to get agent status: status code %d", rsp.StatusCode)
+ }
+ var info proto.AgentInfo
+ if err := json.NewDecoder(rsp.Body).Decode(&info); err != nil {
+ return nil, fmt.Errorf("failed to decode agent status: %w", err)
+ }
+ return &info, nil
+}
+
+// UpdateAgent triggers an agent model update on the server.
+func (c *Client) UpdateAgent(ctx context.Context, id string) error {
+ rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/agent/update", id), nil, nil, nil)
+ if err != nil {
+ return fmt.Errorf("failed to update agent: %w", err)
+ }
+ defer rsp.Body.Close()
+ if rsp.StatusCode != http.StatusOK {
+ return fmt.Errorf("failed to update agent: status code %d", rsp.StatusCode)
+ }
+ return nil
+}
+
+// SendMessage sends a message to the agent for a workspace.
+func (c *Client) SendMessage(ctx context.Context, id string, sessionID, prompt string, attachments ...message.Attachment) error {
+ protoAttachments := make([]proto.Attachment, len(attachments))
+ for i, a := range attachments {
+ protoAttachments[i] = proto.Attachment{
+ FilePath: a.FilePath,
+ FileName: a.FileName,
+ MimeType: a.MimeType,
+ Content: a.Content,
+ }
+ }
+ rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/agent", id), nil, jsonBody(proto.AgentMessage{
+ SessionID: sessionID,
+ Prompt: prompt,
+ Attachments: protoAttachments,
+ }), http.Header{"Content-Type": []string{"application/json"}})
+ if err != nil {
+ return fmt.Errorf("failed to send message to agent: %w", err)
+ }
+ defer rsp.Body.Close()
+ if rsp.StatusCode != http.StatusOK {
+ return fmt.Errorf("failed to send message to agent: status code %d", rsp.StatusCode)
+ }
+ return nil
+}
+
+// GetAgentSessionInfo retrieves the agent session info for a workspace.
+func (c *Client) GetAgentSessionInfo(ctx context.Context, id string, sessionID string) (*proto.AgentSession, error) {
+ rsp, err := c.get(ctx, fmt.Sprintf("/workspaces/%s/agent/sessions/%s", id, sessionID), nil, nil)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get session agent info: %w", err)
+ }
+ defer rsp.Body.Close()
+ if rsp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("failed to get session agent info: status code %d", rsp.StatusCode)
+ }
+ var info proto.AgentSession
+ if err := json.NewDecoder(rsp.Body).Decode(&info); err != nil {
+ return nil, fmt.Errorf("failed to decode session agent info: %w", err)
+ }
+ return &info, nil
+}
+
+// AgentSummarizeSession requests a session summarization.
+func (c *Client) AgentSummarizeSession(ctx context.Context, id string, sessionID string) error {
+ rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/agent/sessions/%s/summarize", id, sessionID), nil, nil, nil)
+ if err != nil {
+ return fmt.Errorf("failed to summarize session: %w", err)
+ }
+ defer rsp.Body.Close()
+ if rsp.StatusCode != http.StatusOK {
+ return fmt.Errorf("failed to summarize session: status code %d", rsp.StatusCode)
+ }
+ return nil
+}
+
+// InitiateAgentProcessing triggers agent initialization on the server.
+func (c *Client) InitiateAgentProcessing(ctx context.Context, id string) error {
+ rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/agent/init", id), nil, nil, nil)
+ if err != nil {
+ return fmt.Errorf("failed to initiate session agent processing: %w", err)
+ }
+ defer rsp.Body.Close()
+ if rsp.StatusCode != http.StatusOK {
+ return fmt.Errorf("failed to initiate session agent processing: status code %d", rsp.StatusCode)
+ }
+ return nil
+}
+
+// ListMessages retrieves all messages for a session as proto types.
+func (c *Client) ListMessages(ctx context.Context, id string, sessionID string) ([]proto.Message, error) {
+ rsp, err := c.get(ctx, fmt.Sprintf("/workspaces/%s/sessions/%s/messages", id, sessionID), nil, nil)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get messages: %w", err)
+ }
+ defer rsp.Body.Close()
+ if rsp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("failed to get messages: status code %d", rsp.StatusCode)
+ }
+ var msgs []proto.Message
+ if err := json.NewDecoder(rsp.Body).Decode(&msgs); err != nil && !errors.Is(err, io.EOF) {
+ return nil, fmt.Errorf("failed to decode messages: %w", err)
+ }
+ return msgs, nil
+}
+
+// GetSession retrieves a specific session as a proto type.
+func (c *Client) GetSession(ctx context.Context, id string, sessionID string) (*proto.Session, error) {
+ rsp, err := c.get(ctx, fmt.Sprintf("/workspaces/%s/sessions/%s", id, sessionID), nil, nil)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get session: %w", err)
+ }
+ defer rsp.Body.Close()
+ if rsp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("failed to get session: status code %d", rsp.StatusCode)
+ }
+ var sess proto.Session
+ if err := json.NewDecoder(rsp.Body).Decode(&sess); err != nil {
+ return nil, fmt.Errorf("failed to decode session: %w", err)
+ }
+ return &sess, nil
+}
+
+// ListSessionHistoryFiles retrieves history files for a session as proto types.
+func (c *Client) ListSessionHistoryFiles(ctx context.Context, id string, sessionID string) ([]proto.File, error) {
+ rsp, err := c.get(ctx, fmt.Sprintf("/workspaces/%s/sessions/%s/history", id, sessionID), nil, nil)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get session history files: %w", err)
+ }
+ defer rsp.Body.Close()
+ if rsp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("failed to get session history files: status code %d", rsp.StatusCode)
+ }
+ var files []proto.File
+ if err := json.NewDecoder(rsp.Body).Decode(&files); err != nil {
+ return nil, fmt.Errorf("failed to decode session history files: %w", err)
+ }
+ return files, nil
+}
+
+// CreateSession creates a new session in a workspace as a proto type.
+func (c *Client) CreateSession(ctx context.Context, id string, title string) (*proto.Session, error) {
+ rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/sessions", id), nil, jsonBody(proto.Session{Title: title}), http.Header{"Content-Type": []string{"application/json"}})
+ if err != nil {
+ return nil, fmt.Errorf("failed to create session: %w", err)
+ }
+ defer rsp.Body.Close()
+ if rsp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("failed to create session: status code %d", rsp.StatusCode)
+ }
+ var sess proto.Session
+ if err := json.NewDecoder(rsp.Body).Decode(&sess); err != nil {
+ return nil, fmt.Errorf("failed to decode session: %w", err)
+ }
+ return &sess, nil
+}
+
+// ListSessions lists all sessions in a workspace as proto types.
+func (c *Client) ListSessions(ctx context.Context, id string) ([]proto.Session, error) {
+ rsp, err := c.get(ctx, fmt.Sprintf("/workspaces/%s/sessions", id), nil, nil)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get sessions: %w", err)
+ }
+ defer rsp.Body.Close()
+ if rsp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("failed to get sessions: status code %d", rsp.StatusCode)
+ }
+ var sessions []proto.Session
+ if err := json.NewDecoder(rsp.Body).Decode(&sessions); err != nil {
+ return nil, fmt.Errorf("failed to decode sessions: %w", err)
+ }
+ return sessions, nil
+}
+
+// GrantPermission grants a permission on a workspace.
+func (c *Client) GrantPermission(ctx context.Context, id string, req proto.PermissionGrant) error {
+ rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/permissions/grant", id), nil, jsonBody(req), http.Header{"Content-Type": []string{"application/json"}})
+ if err != nil {
+ return fmt.Errorf("failed to grant permission: %w", err)
+ }
+ defer rsp.Body.Close()
+ if rsp.StatusCode != http.StatusOK {
+ return fmt.Errorf("failed to grant permission: status code %d", rsp.StatusCode)
+ }
+ return nil
+}
+
+// SetPermissionsSkipRequests sets the skip-requests flag for a workspace.
+func (c *Client) SetPermissionsSkipRequests(ctx context.Context, id string, skip bool) error {
+ rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/permissions/skip", id), nil, jsonBody(proto.PermissionSkipRequest{Skip: skip}), http.Header{"Content-Type": []string{"application/json"}})
+ if err != nil {
+ return fmt.Errorf("failed to set permissions skip requests: %w", err)
+ }
+ defer rsp.Body.Close()
+ if rsp.StatusCode != http.StatusOK {
+ return fmt.Errorf("failed to set permissions skip requests: status code %d", rsp.StatusCode)
+ }
+ return nil
+}
+
+// GetPermissionsSkipRequests retrieves the skip-requests flag for a workspace.
+func (c *Client) GetPermissionsSkipRequests(ctx context.Context, id string) (bool, error) {
+ rsp, err := c.get(ctx, fmt.Sprintf("/workspaces/%s/permissions/skip", id), nil, nil)
+ if err != nil {
+ return false, fmt.Errorf("failed to get permissions skip requests: %w", err)
+ }
+ defer rsp.Body.Close()
+ if rsp.StatusCode != http.StatusOK {
+ return false, fmt.Errorf("failed to get permissions skip requests: status code %d", rsp.StatusCode)
+ }
+ var skip proto.PermissionSkipRequest
+ if err := json.NewDecoder(rsp.Body).Decode(&skip); err != nil {
+ return false, fmt.Errorf("failed to decode permissions skip requests: %w", err)
+ }
+ return skip.Skip, nil
+}
+
+// GetConfig retrieves the workspace-specific configuration.
+func (c *Client) GetConfig(ctx context.Context, id string) (*config.Config, error) {
+ rsp, err := c.get(ctx, fmt.Sprintf("/workspaces/%s/config", id), nil, nil)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get config: %w", err)
+ }
+ defer rsp.Body.Close()
+ if rsp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("failed to get config: status code %d", rsp.StatusCode)
+ }
+ var cfg config.Config
+ if err := json.NewDecoder(rsp.Body).Decode(&cfg); err != nil {
+ return nil, fmt.Errorf("failed to decode config: %w", err)
+ }
+ return &cfg, nil
+}
+
+func jsonBody(v any) *bytes.Buffer {
+ b := new(bytes.Buffer)
+ m, _ := json.Marshal(v)
+ b.Write(m)
+ return b
+}
+
+// SaveSession updates a session in a workspace, returning a proto type.
+func (c *Client) SaveSession(ctx context.Context, id string, sess proto.Session) (*proto.Session, error) {
+ rsp, err := c.put(ctx, fmt.Sprintf("/workspaces/%s/sessions/%s", id, sess.ID), nil, jsonBody(sess), http.Header{"Content-Type": []string{"application/json"}})
+ if err != nil {
+ return nil, fmt.Errorf("failed to save session: %w", err)
+ }
+ defer rsp.Body.Close()
+ if rsp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("failed to save session: status code %d", rsp.StatusCode)
+ }
+ var saved proto.Session
+ if err := json.NewDecoder(rsp.Body).Decode(&saved); err != nil {
+ return nil, fmt.Errorf("failed to decode session: %w", err)
+ }
+ return &saved, nil
+}
+
+// DeleteSession deletes a session from a workspace.
+func (c *Client) DeleteSession(ctx context.Context, id string, sessionID string) error {
+ rsp, err := c.delete(ctx, fmt.Sprintf("/workspaces/%s/sessions/%s", id, sessionID), nil, nil)
+ if err != nil {
+ return fmt.Errorf("failed to delete session: %w", err)
+ }
+ defer rsp.Body.Close()
+ if rsp.StatusCode != http.StatusOK {
+ return fmt.Errorf("failed to delete session: status code %d", rsp.StatusCode)
+ }
+ return nil
+}
+
+// ListUserMessages retrieves user-role messages for a session as proto types.
+func (c *Client) ListUserMessages(ctx context.Context, id string, sessionID string) ([]proto.Message, error) {
+ rsp, err := c.get(ctx, fmt.Sprintf("/workspaces/%s/sessions/%s/messages/user", id, sessionID), nil, nil)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get user messages: %w", err)
+ }
+ defer rsp.Body.Close()
+ if rsp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("failed to get user messages: status code %d", rsp.StatusCode)
+ }
+ var msgs []proto.Message
+ if err := json.NewDecoder(rsp.Body).Decode(&msgs); err != nil && !errors.Is(err, io.EOF) {
+ return nil, fmt.Errorf("failed to decode user messages: %w", err)
+ }
+ return msgs, nil
+}
+
+// ListAllUserMessages retrieves all user-role messages across sessions as proto types.
+func (c *Client) ListAllUserMessages(ctx context.Context, id string) ([]proto.Message, error) {
+ rsp, err := c.get(ctx, fmt.Sprintf("/workspaces/%s/messages/user", id), nil, nil)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get all user messages: %w", err)
+ }
+ defer rsp.Body.Close()
+ if rsp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("failed to get all user messages: status code %d", rsp.StatusCode)
+ }
+ var msgs []proto.Message
+ if err := json.NewDecoder(rsp.Body).Decode(&msgs); err != nil && !errors.Is(err, io.EOF) {
+ return nil, fmt.Errorf("failed to decode all user messages: %w", err)
+ }
+ return msgs, nil
+}
+
+// CancelAgentSession cancels an ongoing agent operation for a session.
+func (c *Client) CancelAgentSession(ctx context.Context, id string, sessionID string) error {
+ rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/agent/sessions/%s/cancel", id, sessionID), nil, nil, nil)
+ if err != nil {
+ return fmt.Errorf("failed to cancel agent session: %w", err)
+ }
+ defer rsp.Body.Close()
+ if rsp.StatusCode != http.StatusOK {
+ return fmt.Errorf("failed to cancel agent session: status code %d", rsp.StatusCode)
+ }
+ return nil
+}
+
+// GetAgentSessionQueuedPromptsList retrieves the list of queued prompt
+// strings for a session.
+func (c *Client) GetAgentSessionQueuedPromptsList(ctx context.Context, id string, sessionID string) ([]string, error) {
+ rsp, err := c.get(ctx, fmt.Sprintf("/workspaces/%s/agent/sessions/%s/prompts/list", id, sessionID), nil, nil)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get queued prompts list: %w", err)
+ }
+ defer rsp.Body.Close()
+ if rsp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("failed to get queued prompts list: status code %d", rsp.StatusCode)
+ }
+ var prompts []string
+ if err := json.NewDecoder(rsp.Body).Decode(&prompts); err != nil {
+ return nil, fmt.Errorf("failed to decode queued prompts list: %w", err)
+ }
+ return prompts, nil
+}
+
+// GetDefaultSmallModel retrieves the default small model for a provider.
+func (c *Client) GetDefaultSmallModel(ctx context.Context, id string, providerID string) (*config.SelectedModel, error) {
+ rsp, err := c.get(ctx, fmt.Sprintf("/workspaces/%s/agent/default-small-model", id), url.Values{"provider_id": []string{providerID}}, nil)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get default small model: %w", err)
+ }
+ defer rsp.Body.Close()
+ if rsp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("failed to get default small model: status code %d", rsp.StatusCode)
+ }
+ var model config.SelectedModel
+ if err := json.NewDecoder(rsp.Body).Decode(&model); err != nil {
+ return nil, fmt.Errorf("failed to decode default small model: %w", err)
+ }
+ return &model, nil
+}
+
+// FileTrackerRecordRead records a file read for a session.
+func (c *Client) FileTrackerRecordRead(ctx context.Context, id string, sessionID, path string) error {
+ rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/filetracker/read", id), nil, jsonBody(struct {
+ SessionID string `json:"session_id"`
+ Path string `json:"path"`
+ }{SessionID: sessionID, Path: path}), http.Header{"Content-Type": []string{"application/json"}})
+ if err != nil {
+ return fmt.Errorf("failed to record file read: %w", err)
+ }
+ defer rsp.Body.Close()
+ if rsp.StatusCode != http.StatusOK {
+ return fmt.Errorf("failed to record file read: status code %d", rsp.StatusCode)
+ }
+ return nil
+}
+
+// FileTrackerLastReadTime returns the last read time for a file in a
+// session.
+func (c *Client) FileTrackerLastReadTime(ctx context.Context, id string, sessionID, path string) (time.Time, error) {
+ rsp, err := c.get(ctx, fmt.Sprintf("/workspaces/%s/filetracker/lastread", id), url.Values{
+ "session_id": []string{sessionID},
+ "path": []string{path},
+ }, nil)
+ if err != nil {
+ return time.Time{}, fmt.Errorf("failed to get last read time: %w", err)
+ }
+ defer rsp.Body.Close()
+ if rsp.StatusCode != http.StatusOK {
+ return time.Time{}, fmt.Errorf("failed to get last read time: status code %d", rsp.StatusCode)
+ }
+ var t time.Time
+ if err := json.NewDecoder(rsp.Body).Decode(&t); err != nil {
+ return time.Time{}, fmt.Errorf("failed to decode last read time: %w", err)
+ }
+ return t, nil
+}
+
+// FileTrackerListReadFiles returns the list of read files for a session.
+func (c *Client) FileTrackerListReadFiles(ctx context.Context, id string, sessionID string) ([]string, error) {
+ rsp, err := c.get(ctx, fmt.Sprintf("/workspaces/%s/sessions/%s/filetracker/files", id, sessionID), nil, nil)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get read files: %w", err)
+ }
+ defer rsp.Body.Close()
+ if rsp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("failed to get read files: status code %d", rsp.StatusCode)
+ }
+ var files []string
+ if err := json.NewDecoder(rsp.Body).Decode(&files); err != nil {
+ return nil, fmt.Errorf("failed to decode read files: %w", err)
+ }
+ return files, nil
+}
+
+// LSPStart starts an LSP server for a path.
+func (c *Client) LSPStart(ctx context.Context, id string, path string) error {
+ rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/lsps/start", id), nil, jsonBody(struct {
+ Path string `json:"path"`
+ }{Path: path}), http.Header{"Content-Type": []string{"application/json"}})
+ if err != nil {
+ return fmt.Errorf("failed to start LSP: %w", err)
+ }
+ defer rsp.Body.Close()
+ if rsp.StatusCode != http.StatusOK {
+ return fmt.Errorf("failed to start LSP: status code %d", rsp.StatusCode)
+ }
+ return nil
+}
+
+// LSPStopAll stops all LSP servers for a workspace.
+func (c *Client) LSPStopAll(ctx context.Context, id string) error {
+ rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/lsps/stop", id), nil, nil, nil)
+ if err != nil {
+ return fmt.Errorf("failed to stop LSPs: %w", err)
+ }
+ defer rsp.Body.Close()
+ if rsp.StatusCode != http.StatusOK {
+ return fmt.Errorf("failed to stop LSPs: status code %d", rsp.StatusCode)
+ }
+ return nil
+}
@@ -10,10 +10,12 @@ import (
"charm.land/lipgloss/v2"
"github.com/atotto/clipboard"
hyperp "github.com/charmbracelet/crush/internal/agent/hyper"
+ "github.com/charmbracelet/crush/internal/client"
"github.com/charmbracelet/crush/internal/config"
"github.com/charmbracelet/crush/internal/oauth"
"github.com/charmbracelet/crush/internal/oauth/copilot"
"github.com/charmbracelet/crush/internal/oauth/hyper"
+ "github.com/charmbracelet/x/ansi"
"github.com/pkg/browser"
"github.com/spf13/cobra"
)
@@ -40,11 +42,17 @@ crush login copilot
},
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
- app, err := setupAppWithProgressBar(cmd)
+ c, ws, cleanup, err := connectToServer(cmd)
if err != nil {
return err
}
- defer app.Shutdown()
+ defer cleanup()
+
+ progressEnabled := ws.Config.Options.Progress == nil || *ws.Config.Options.Progress
+ if progressEnabled && supportsProgressBar() {
+ _, _ = fmt.Fprintf(os.Stderr, ansi.SetIndeterminateProgressBar)
+ defer func() { _, _ = fmt.Fprintf(os.Stderr, ansi.ResetProgressBar) }()
+ }
provider := "hyper"
if len(args) > 0 {
@@ -52,16 +60,16 @@ crush login copilot
}
switch provider {
case "hyper":
- return loginHyper(app.Store())
+ return loginHyper(c, ws.ID)
case "copilot", "github", "github-copilot":
- return loginCopilot(app.Store())
+ return loginCopilot(cmd.Context(), c, ws.ID)
default:
return fmt.Errorf("unknown platform: %s", args[0])
}
},
}
-func loginHyper(cfg *config.ConfigStore) error {
+func loginHyper(c *client.Client, wsID string) error {
if !hyperp.Enabled() {
return fmt.Errorf("hyper not enabled")
}
@@ -112,8 +120,8 @@ func loginHyper(cfg *config.ConfigStore) error {
}
if err := cmp.Or(
- cfg.SetConfigField(config.ScopeGlobal, "providers.hyper.api_key", token.AccessToken),
- cfg.SetConfigField(config.ScopeGlobal, "providers.hyper.oauth", token),
+ c.SetConfigField(ctx, wsID, config.ScopeGlobal, "providers.hyper.api_key", token.AccessToken),
+ c.SetConfigField(ctx, wsID, config.ScopeGlobal, "providers.hyper.oauth", token),
); err != nil {
return err
}
@@ -123,12 +131,15 @@ func loginHyper(cfg *config.ConfigStore) error {
return nil
}
-func loginCopilot(cfg *config.ConfigStore) error {
- ctx := getLoginContext()
+func loginCopilot(ctx context.Context, c *client.Client, wsID string) error {
+ loginCtx := getLoginContext()
- if cfg.HasConfigField(config.ScopeGlobal, "providers.copilot.oauth") {
- fmt.Println("You are already logged in to GitHub Copilot.")
- return nil
+ cfg, err := c.GetConfig(ctx, wsID)
+ if err == nil && cfg != nil {
+ if pc, ok := cfg.Providers.Get("copilot"); ok && pc.OAuthToken != nil {
+ fmt.Println("You are already logged in to GitHub Copilot.")
+ return nil
+ }
}
diskToken, hasDiskToken := copilot.RefreshTokenFromDisk()
@@ -138,14 +149,14 @@ func loginCopilot(cfg *config.ConfigStore) error {
case hasDiskToken:
fmt.Println("Found existing GitHub Copilot token on disk. Using it to authenticate...")
- t, err := copilot.RefreshToken(ctx, diskToken)
+ t, err := copilot.RefreshToken(loginCtx, diskToken)
if err != nil {
return fmt.Errorf("unable to refresh token from disk: %w", err)
}
token = t
default:
fmt.Println("Requesting device code from GitHub...")
- dc, err := copilot.RequestDeviceCode(ctx)
+ dc, err := copilot.RequestDeviceCode(loginCtx)
if err != nil {
return err
}
@@ -159,7 +170,7 @@ func loginCopilot(cfg *config.ConfigStore) error {
fmt.Println()
fmt.Println("Waiting for authorization...")
- t, err := copilot.PollForToken(ctx, dc)
+ t, err := copilot.PollForToken(loginCtx, dc)
if err == copilot.ErrNotAvailable {
fmt.Println()
fmt.Println("GitHub Copilot is unavailable for this account. To signup, go to the following page:")
@@ -177,8 +188,8 @@ func loginCopilot(cfg *config.ConfigStore) error {
}
if err := cmp.Or(
- cfg.SetConfigField(config.ScopeGlobal, "providers.copilot.api_key", token.AccessToken),
- cfg.SetConfigField(config.ScopeGlobal, "providers.copilot.oauth", token),
+ c.SetConfigField(loginCtx, wsID, config.ScopeGlobal, "providers.copilot.api_key", token.AccessToken),
+ c.SetConfigField(loginCtx, wsID, config.ScopeGlobal, "providers.copilot.oauth", token),
); err != nil {
return err
}
@@ -7,24 +7,35 @@ import (
"errors"
"fmt"
"io"
+ "io/fs"
"log/slog"
+ "net/url"
"os"
+ "os/exec"
"path/filepath"
+ "regexp"
"strconv"
"strings"
+ "time"
tea "charm.land/bubbletea/v2"
- "charm.land/fang/v2"
+ fang "charm.land/fang/v2"
"charm.land/lipgloss/v2"
"github.com/charmbracelet/colorprofile"
"github.com/charmbracelet/crush/internal/app"
+ "github.com/charmbracelet/crush/internal/client"
"github.com/charmbracelet/crush/internal/config"
"github.com/charmbracelet/crush/internal/db"
"github.com/charmbracelet/crush/internal/event"
+ crushlog "github.com/charmbracelet/crush/internal/log"
"github.com/charmbracelet/crush/internal/projects"
+ "github.com/charmbracelet/crush/internal/proto"
+ "github.com/charmbracelet/crush/internal/server"
+ "github.com/charmbracelet/crush/internal/session"
"github.com/charmbracelet/crush/internal/ui/common"
ui "github.com/charmbracelet/crush/internal/ui/model"
"github.com/charmbracelet/crush/internal/version"
+ "github.com/charmbracelet/crush/internal/workspace"
uv "github.com/charmbracelet/ultraviolet"
"github.com/charmbracelet/x/ansi"
"github.com/charmbracelet/x/exp/charmtone"
@@ -32,10 +43,13 @@ import (
"github.com/spf13/cobra"
)
+var clientHost string
+
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().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")
@@ -88,15 +102,14 @@ crush --continue
sessionID, _ := cmd.Flags().GetString("session")
continueLast, _ := cmd.Flags().GetBool("continue")
- app, err := setupAppWithProgressBar(cmd)
+ ws, cleanup, err := setupWorkspaceWithProgressBar(cmd)
if err != nil {
return err
}
- defer app.Shutdown()
+ defer cleanup()
- // Resolve session ID if provided
if sessionID != "" {
- sess, err := resolveSessionID(cmd.Context(), app.Sessions, sessionID)
+ sess, err := resolveWorkspaceSessionID(cmd.Context(), ws, sessionID)
if err != nil {
return err
}
@@ -105,19 +118,17 @@ crush --continue
event.AppInitialized()
- // Set up the TUI.
- var env uv.Environ = os.Environ()
-
- com := common.DefaultCommon(app)
+ com := common.DefaultCommon(ws)
model := ui.New(com, sessionID, continueLast)
+ var env uv.Environ = os.Environ()
program := tea.NewProgram(
model,
tea.WithEnvironment(env),
tea.WithContext(cmd.Context()),
- tea.WithFilter(ui.MouseEventFilter), // Filter mouse events based on focus state
+ tea.WithFilter(ui.MouseEventFilter),
)
- go app.Subscribe(program)
+ go ws.Subscribe(program)
if _, err := program.Run(); err != nil {
event.Error(err)
@@ -147,6 +158,15 @@ const defaultVersionTemplate = `{{with .DisplayName}}{{printf "%s " .}}{{end}}{{
`
func Execute() {
+ // FIXME: config.Load uses slog internally during provider resolution,
+ // but the file-based logger isn't set up until after config is loaded
+ // (because the log path depends on the data directory from config).
+ // This creates a window where slog calls in config.Load leak to
+ // stderr. We discard early logs here as a workaround. The proper
+ // fix is to remove slog calls from config.Load and have it return
+ // warnings/diagnostics instead of logging them as a side effect.
+ slog.SetDefault(slog.New(slog.DiscardHandler))
+
// NOTE: very hacky: we create a colorprofile writer with STDOUT, then make
// it forward to a bytes.Buffer, write the colored heartbit to it, and then
// finally prepend it in the version template.
@@ -183,25 +203,44 @@ func supportsProgressBar() bool {
return isWindowsTerminal || strings.Contains(strings.ToLower(termProg), "ghostty")
}
-func setupAppWithProgressBar(cmd *cobra.Command) (*app.App, error) {
- app, err := setupApp(cmd)
- if err != nil {
- return nil, err
- }
+// useClientServer returns true when the client/server architecture is
+// enabled via the CRUSH_CLIENT_SERVER environment variable.
+func useClientServer() bool {
+ v, _ := strconv.ParseBool(os.Getenv("CRUSH_CLIENT_SERVER"))
+ return v
+}
- // Check if progress bar is enabled in config (defaults to true if nil)
- progressEnabled := app.Config().Options.Progress == nil || *app.Config().Options.Progress
- if progressEnabled && supportsProgressBar() {
+// setupWorkspaceWithProgressBar wraps setupWorkspace with an optional
+// terminal progress bar shown during initialization.
+func setupWorkspaceWithProgressBar(cmd *cobra.Command) (workspace.Workspace, func(), error) {
+ showProgress := supportsProgressBar()
+ if showProgress {
_, _ = fmt.Fprintf(os.Stderr, ansi.SetIndeterminateProgressBar)
- defer func() { _, _ = fmt.Fprintf(os.Stderr, ansi.ResetProgressBar) }()
}
- return app, nil
+ ws, cleanup, err := setupWorkspace(cmd)
+
+ if showProgress {
+ _, _ = fmt.Fprintf(os.Stderr, ansi.ResetProgressBar)
+ }
+
+ return ws, cleanup, err
+}
+
+// setupWorkspace returns a Workspace and cleanup function. When
+// CRUSH_CLIENT_SERVER=1, it connects to a server process and returns a
+// ClientWorkspace. Otherwise it creates an in-process app.App and
+// returns an AppWorkspace.
+func setupWorkspace(cmd *cobra.Command) (workspace.Workspace, func(), error) {
+ if useClientServer() {
+ return setupClientServerWorkspace(cmd)
+ }
+ return setupLocalWorkspace(cmd)
}
-// setupApp handles the common setup logic for both interactive and non-interactive modes.
-// It returns the app instance, config, cleanup function, and any error.
-func setupApp(cmd *cobra.Command) (*app.App, error) {
+// setupLocalWorkspace creates an in-process app.App and wraps it in an
+// AppWorkspace.
+func setupLocalWorkspace(cmd *cobra.Command) (workspace.Workspace, func(), error) {
debug, _ := cmd.Flags().GetBool("debug")
yolo, _ := cmd.Flags().GetBool("yolo")
dataDir, _ := cmd.Flags().GetString("data-dir")
@@ -209,47 +248,270 @@ func setupApp(cmd *cobra.Command) (*app.App, error) {
cwd, err := ResolveCwd(cmd)
if err != nil {
- return nil, err
+ return nil, nil, err
}
store, err := config.Init(cwd, dataDir, debug)
if err != nil {
- return nil, err
+ return nil, nil, err
}
cfg := store.Config()
- if cfg.Permissions == nil {
- cfg.Permissions = &config.Permissions{}
+ store.Overrides().SkipPermissionRequests = yolo
+
+ if err := os.MkdirAll(cfg.Options.DataDirectory, 0o700); err != nil {
+ return nil, nil, fmt.Errorf("failed to create data directory: %q %w", cfg.Options.DataDirectory, err)
}
- cfg.Permissions.SkipRequests = yolo
- if err := createDotCrushDir(cfg.Options.DataDirectory); err != nil {
- return nil, err
+ gitIgnorePath := filepath.Join(cfg.Options.DataDirectory, ".gitignore")
+ if _, err := os.Stat(gitIgnorePath); os.IsNotExist(err) {
+ if err := os.WriteFile(gitIgnorePath, []byte("*\n"), 0o644); err != nil {
+ return nil, nil, fmt.Errorf("failed to create .gitignore file: %q %w", gitIgnorePath, err)
+ }
}
- // Register this project in the centralized projects list.
if err := projects.Register(cwd, cfg.Options.DataDirectory); err != nil {
slog.Warn("Failed to register project", "error", err)
- // Non-fatal: continue even if registration fails
}
- // Connect to DB; this will also run migrations.
conn, err := db.Connect(ctx, cfg.Options.DataDirectory)
if err != nil {
- return nil, err
+ return nil, nil, err
}
+ logFile := filepath.Join(cfg.Options.DataDirectory, "logs", "crush.log")
+ crushlog.Setup(logFile, debug)
+
appInstance, err := app.New(ctx, conn, store)
if err != nil {
+ _ = conn.Close()
slog.Error("Failed to create app instance", "error", err)
- return nil, err
+ return nil, nil, err
}
if shouldEnableMetrics(cfg) {
event.Init()
}
- return appInstance, nil
+ ws := workspace.NewAppWorkspace(appInstance, store)
+ cleanup := func() { appInstance.Shutdown() }
+ return ws, cleanup, nil
+}
+
+// setupClientServerWorkspace connects to a server process and wraps the
+// result in a ClientWorkspace.
+func setupClientServerWorkspace(cmd *cobra.Command) (workspace.Workspace, func(), error) {
+ c, protoWs, cleanupServer, err := connectToServer(cmd)
+ if err != nil {
+ return nil, nil, err
+ }
+
+ clientWs := workspace.NewClientWorkspace(c, *protoWs)
+
+ if protoWs.Config.IsConfigured() {
+ if err := clientWs.InitCoderAgent(cmd.Context()); err != nil {
+ slog.Error("Failed to initialize coder agent", "error", err)
+ }
+ }
+
+ return clientWs, cleanupServer, nil
+}
+
+// connectToServer ensures the server is running, creates a client and
+// workspace, and returns a cleanup function that deletes the workspace.
+func connectToServer(cmd *cobra.Command) (*client.Client, *proto.Workspace, func(), error) {
+ hostURL, err := server.ParseHostURL(clientHost)
+ if err != nil {
+ return nil, nil, nil, fmt.Errorf("invalid host URL: %v", err)
+ }
+
+ if err := ensureServer(cmd, hostURL); err != nil {
+ return nil, nil, nil, err
+ }
+
+ debug, _ := cmd.Flags().GetBool("debug")
+ yolo, _ := cmd.Flags().GetBool("yolo")
+ dataDir, _ := cmd.Flags().GetString("data-dir")
+ ctx := cmd.Context()
+
+ cwd, err := ResolveCwd(cmd)
+ if err != nil {
+ return nil, nil, nil, err
+ }
+
+ c, err := client.NewClient(cwd, hostURL.Scheme, hostURL.Host)
+ if err != nil {
+ return nil, nil, nil, err
+ }
+
+ wsReq := proto.Workspace{
+ Path: cwd,
+ DataDir: dataDir,
+ Debug: debug,
+ YOLO: yolo,
+ Version: version.Version,
+ Env: os.Environ(),
+ }
+
+ ws, err := c.CreateWorkspace(ctx, wsReq)
+ if err != nil {
+ // The server socket may exist before the HTTP handler is ready.
+ // Retry a few times with a short backoff.
+ for range 5 {
+ select {
+ case <-ctx.Done():
+ return nil, nil, nil, ctx.Err()
+ case <-time.After(200 * time.Millisecond):
+ }
+ ws, err = c.CreateWorkspace(ctx, wsReq)
+ if err == nil {
+ break
+ }
+ }
+ if err != nil {
+ return nil, nil, nil, fmt.Errorf("failed to create workspace: %v", err)
+ }
+ }
+
+ if shouldEnableMetrics(ws.Config) {
+ event.Init()
+ }
+
+ if ws.Config != nil {
+ logFile := filepath.Join(ws.Config.Options.DataDirectory, "logs", "crush.log")
+ crushlog.Setup(logFile, debug)
+ }
+
+ cleanup := func() { _ = c.DeleteWorkspace(context.Background(), ws.ID) }
+ return c, ws, cleanup, nil
+}
+
+// ensureServer auto-starts a detached server if the socket file does not
+// exist. When the socket exists, it verifies that the running server
+// version matches the client; on mismatch it shuts down the old server
+// and starts a fresh one.
+func ensureServer(cmd *cobra.Command, hostURL *url.URL) error {
+ switch hostURL.Scheme {
+ case "unix", "npipe":
+ needsStart := false
+ if _, err := os.Stat(hostURL.Host); err != nil && errors.Is(err, fs.ErrNotExist) {
+ needsStart = true
+ } else if err == nil {
+ if err := restartIfStale(cmd, hostURL); err != nil {
+ slog.Warn("Failed to check server version, restarting", "error", err)
+ needsStart = true
+ }
+ }
+
+ if needsStart {
+ if err := startDetachedServer(cmd); err != nil {
+ return err
+ }
+ }
+
+ var err error
+ for range 10 {
+ _, err = os.Stat(hostURL.Host)
+ if err == nil {
+ break
+ }
+ select {
+ case <-cmd.Context().Done():
+ return cmd.Context().Err()
+ case <-time.After(100 * time.Millisecond):
+ }
+ }
+ if err != nil {
+ return fmt.Errorf("failed to initialize crush server: %v", err)
+ }
+ }
+
+ return nil
+}
+
+// restartIfStale checks whether the running server matches the current
+// client version. When they differ, it sends a shutdown command and
+// removes the stale socket so the caller can start a fresh server.
+func restartIfStale(cmd *cobra.Command, hostURL *url.URL) error {
+ c, err := client.NewClient("", hostURL.Scheme, hostURL.Host)
+ if err != nil {
+ return err
+ }
+ vi, err := c.VersionInfo(cmd.Context())
+ if err != nil {
+ return err
+ }
+ if vi.Version == version.Version {
+ return nil
+ }
+ slog.Info("Server version mismatch, restarting",
+ "server", vi.Version,
+ "client", version.Version,
+ )
+ _ = c.ShutdownServer(cmd.Context())
+ // Give the old process a moment to release the socket.
+ for range 20 {
+ if _, err := os.Stat(hostURL.Host); errors.Is(err, fs.ErrNotExist) {
+ break
+ }
+ select {
+ case <-cmd.Context().Done():
+ return cmd.Context().Err()
+ case <-time.After(100 * time.Millisecond):
+ }
+ }
+ // Force-remove if the socket is still lingering.
+ _ = os.Remove(hostURL.Host)
+ return nil
+}
+
+var safeNameRegexp = regexp.MustCompile(`[^a-zA-Z0-9._-]`)
+
+func startDetachedServer(cmd *cobra.Command) error {
+ exe, err := os.Executable()
+ if err != nil {
+ return fmt.Errorf("failed to get executable path: %v", err)
+ }
+
+ safeClientHost := safeNameRegexp.ReplaceAllString(clientHost, "_")
+ chDir := filepath.Join(config.GlobalCacheDir(), "server-"+safeClientHost)
+ if err := os.MkdirAll(chDir, 0o700); err != nil {
+ return fmt.Errorf("failed to create server working directory: %v", err)
+ }
+
+ cmdArgs := []string{"server"}
+ if clientHost != server.DefaultHost() {
+ cmdArgs = append(cmdArgs, "--host", clientHost)
+ }
+
+ c := exec.CommandContext(cmd.Context(), exe, cmdArgs...)
+ stdoutPath := filepath.Join(chDir, "stdout.log")
+ stderrPath := filepath.Join(chDir, "stderr.log")
+ detachProcess(c)
+
+ stdout, err := os.Create(stdoutPath)
+ if err != nil {
+ return fmt.Errorf("failed to create stdout log file: %v", err)
+ }
+ defer stdout.Close()
+ c.Stdout = stdout
+
+ stderr, err := os.Create(stderrPath)
+ if err != nil {
+ return fmt.Errorf("failed to create stderr log file: %v", err)
+ }
+ defer stderr.Close()
+ c.Stderr = stderr
+
+ if err := c.Start(); err != nil {
+ return fmt.Errorf("failed to start crush server: %v", err)
+ }
+
+ if err := c.Process.Release(); err != nil {
+ return fmt.Errorf("failed to detach crush server process: %v", err)
+ }
+
+ return nil
}
func shouldEnableMetrics(cfg *config.Config) bool {
@@ -284,6 +546,38 @@ func MaybePrependStdin(prompt string) (string, error) {
return string(bts) + "\n\n" + prompt, nil
}
+// resolveWorkspaceSessionID resolves a session ID that may be a full
+// UUID, full hash, or hash prefix. Works against the Workspace
+// interface so both local and client/server paths get hash prefix
+// support.
+func resolveWorkspaceSessionID(ctx context.Context, ws workspace.Workspace, id string) (session.Session, error) {
+ if sess, err := ws.GetSession(ctx, id); err == nil {
+ return sess, nil
+ }
+
+ sessions, err := ws.ListSessions(ctx)
+ if err != nil {
+ return session.Session{}, err
+ }
+
+ var matches []session.Session
+ for _, s := range sessions {
+ hash := session.HashID(s.ID)
+ if hash == id || strings.HasPrefix(hash, id) {
+ matches = append(matches, s)
+ }
+ }
+
+ switch len(matches) {
+ case 0:
+ return session.Session{}, fmt.Errorf("session not found: %s", id)
+ case 1:
+ return matches[0], nil
+ default:
+ return session.Session{}, fmt.Errorf("session ID %q is ambiguous (%d matches)", id, len(matches))
+ }
+}
+
func ResolveCwd(cmd *cobra.Command) (string, error) {
cwd, _ := cmd.Flags().GetString("cwd")
if cwd != "" {
@@ -0,0 +1,16 @@
+//go:build !windows
+// +build !windows
+
+package cmd
+
+import (
+ "os/exec"
+ "syscall"
+)
+
+func detachProcess(c *exec.Cmd) {
+ if c.SysProcAttr == nil {
+ c.SysProcAttr = &syscall.SysProcAttr{}
+ }
+ c.SysProcAttr.Setsid = true
+}
@@ -0,0 +1,18 @@
+//go:build windows
+// +build windows
+
+package cmd
+
+import (
+ "os/exec"
+ "syscall"
+
+ "golang.org/x/sys/windows"
+)
+
+func detachProcess(c *exec.Cmd) {
+ if c.SysProcAttr == nil {
+ c.SysProcAttr = &syscall.SysProcAttr{}
+ }
+ c.SysProcAttr.CreationFlags = syscall.CREATE_NEW_PROCESS_GROUP | windows.DETACHED_PROCESS
+}
@@ -7,9 +7,23 @@ import (
"os"
"os/signal"
"strings"
+ "time"
+ "charm.land/lipgloss/v2"
"charm.land/log/v2"
+ "github.com/charmbracelet/crush/internal/client"
+ "github.com/charmbracelet/crush/internal/config"
"github.com/charmbracelet/crush/internal/event"
+ "github.com/charmbracelet/crush/internal/format"
+ "github.com/charmbracelet/crush/internal/proto"
+ "github.com/charmbracelet/crush/internal/pubsub"
+ "github.com/charmbracelet/crush/internal/session"
+ "github.com/charmbracelet/crush/internal/ui/anim"
+ "github.com/charmbracelet/crush/internal/ui/styles"
+ "github.com/charmbracelet/crush/internal/workspace"
+ "github.com/charmbracelet/x/ansi"
+ "github.com/charmbracelet/x/exp/charmtone"
+ "github.com/charmbracelet/x/term"
"github.com/spf13/cobra"
)
@@ -59,31 +73,9 @@ crush run --continue "Follow up on your last response"
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, os.Kill)
defer cancel()
- app, err := setupApp(cmd)
- if err != nil {
- return err
- }
- defer app.Shutdown()
-
- if sessionID != "" {
- sess, err := resolveSessionID(ctx, app.Sessions, sessionID)
- if err != nil {
- return err
- }
- sessionID = sess.ID
- }
-
- if !app.Config().IsConfigured() {
- return fmt.Errorf("no providers configured - please run 'crush' to set up a provider interactively")
- }
-
- if verbose {
- slog.SetDefault(slog.New(log.New(os.Stderr)))
- }
-
prompt := strings.Join(args, " ")
- prompt, err = MaybePrependStdin(prompt)
+ prompt, err := MaybePrependStdin(prompt)
if err != nil {
slog.Error("Failed to read from stdin", "error", err)
return err
@@ -103,7 +95,48 @@ crush run --continue "Follow up on your last response"
event.SetContinueLastSession(true)
}
- return app.RunNonInteractive(ctx, os.Stdout, prompt, largeModel, smallModel, quiet || verbose, sessionID, useLast)
+ if useClientServer() {
+ c, ws, cleanup, err := connectToServer(cmd)
+ if err != nil {
+ return err
+ }
+ defer cleanup()
+
+ if sessionID != "" {
+ sess, err := resolveSessionByID(ctx, c, ws.ID, sessionID)
+ if err != nil {
+ return err
+ }
+ sessionID = sess.ID
+ }
+
+ if !ws.Config.IsConfigured() {
+ return fmt.Errorf("no providers configured - please run 'crush' to set up a provider interactively")
+ }
+
+ if verbose {
+ slog.SetDefault(slog.New(log.New(os.Stderr)))
+ }
+
+ return runNonInteractive(ctx, c, ws, prompt, largeModel, smallModel, quiet || verbose, sessionID, useLast)
+ }
+
+ ws, cleanup, err := setupLocalWorkspace(cmd)
+ if err != nil {
+ return err
+ }
+ defer cleanup()
+
+ if !ws.Config().IsConfigured() {
+ return fmt.Errorf("no providers configured - please run 'crush' to set up a provider interactively")
+ }
+
+ if verbose {
+ slog.SetDefault(slog.New(log.New(os.Stderr)))
+ }
+
+ appWs := ws.(*workspace.AppWorkspace)
+ return appWs.App().RunNonInteractive(ctx, os.Stdout, prompt, largeModel, smallModel, quiet || verbose, sessionID, useLast)
},
}
@@ -116,3 +149,382 @@ func init() {
runCmd.Flags().BoolP("continue", "C", false, "Continue the most recent session")
runCmd.MarkFlagsMutuallyExclusive("session", "continue")
}
+
+// runNonInteractive executes the agent via the server and streams output
+// to stdout.
+func runNonInteractive(
+ ctx context.Context,
+ c *client.Client,
+ ws *proto.Workspace,
+ prompt, largeModel, smallModel string,
+ hideSpinner bool,
+ continueSessionID string,
+ useLast bool,
+) error {
+ slog.Info("Running in non-interactive mode")
+
+ ctx, cancel := context.WithCancel(ctx)
+ defer cancel()
+
+ if largeModel != "" || smallModel != "" {
+ if err := overrideModels(ctx, c, ws, largeModel, smallModel); err != nil {
+ return fmt.Errorf("failed to override models: %w", err)
+ }
+ }
+
+ var (
+ spinner *format.Spinner
+ stdoutTTY bool
+ stderrTTY bool
+ stdinTTY bool
+ progress bool
+ )
+
+ stdoutTTY = term.IsTerminal(os.Stdout.Fd())
+ stderrTTY = term.IsTerminal(os.Stderr.Fd())
+ stdinTTY = term.IsTerminal(os.Stdin.Fd())
+ progress = ws.Config.Options.Progress == nil || *ws.Config.Options.Progress
+
+ if !hideSpinner && stderrTTY {
+ t := styles.DefaultStyles()
+
+ hasDarkBG := true
+ if stdinTTY && stdoutTTY {
+ hasDarkBG = lipgloss.HasDarkBackground(os.Stdin, os.Stdout)
+ }
+ defaultFG := lipgloss.LightDark(hasDarkBG)(charmtone.Pepper, t.FgBase)
+
+ spinner = format.NewSpinner(ctx, cancel, anim.Settings{
+ Size: 10,
+ Label: "Generating",
+ LabelColor: defaultFG,
+ GradColorA: t.Primary,
+ GradColorB: t.Secondary,
+ CycleColors: true,
+ })
+ spinner.Start()
+ }
+
+ stopSpinner := func() {
+ if !hideSpinner && spinner != nil {
+ spinner.Stop()
+ spinner = nil
+ }
+ }
+
+ // Wait for the agent to become ready (MCP init, etc).
+ if err := waitForAgent(ctx, c, ws.ID); err != nil {
+ stopSpinner()
+ return fmt.Errorf("agent not ready: %w", err)
+ }
+
+ // Force-update agent models so MCP tools are loaded.
+ if err := c.UpdateAgent(ctx, ws.ID); err != nil {
+ slog.Warn("Failed to update agent", "error", err)
+ }
+
+ defer stopSpinner()
+
+ sess, err := resolveSession(ctx, c, ws.ID, continueSessionID, useLast)
+ if err != nil {
+ return fmt.Errorf("failed to resolve session: %w", err)
+ }
+ if continueSessionID != "" || useLast {
+ slog.Info("Continuing session for non-interactive run", "session_id", sess.ID)
+ } else {
+ slog.Info("Created session for non-interactive run", "session_id", sess.ID)
+ }
+
+ events, err := c.SubscribeEvents(ctx, ws.ID)
+ if err != nil {
+ return fmt.Errorf("failed to subscribe to events: %w", err)
+ }
+
+ if err := c.SendMessage(ctx, ws.ID, sess.ID, prompt); err != nil {
+ return fmt.Errorf("failed to send message: %w", err)
+ }
+
+ messageReadBytes := make(map[string]int)
+ var printed bool
+
+ defer func() {
+ if progress && stderrTTY {
+ _, _ = fmt.Fprintf(os.Stderr, ansi.ResetProgressBar)
+ }
+ _, _ = fmt.Fprintln(os.Stdout)
+ }()
+
+ for {
+ if progress && stderrTTY {
+ _, _ = fmt.Fprintf(os.Stderr, ansi.SetIndeterminateProgressBar)
+ }
+
+ select {
+ case ev, ok := <-events:
+ if !ok {
+ stopSpinner()
+ return nil
+ }
+
+ switch e := ev.(type) {
+ case pubsub.Event[proto.Message]:
+ msg := e.Payload
+ if msg.SessionID != sess.ID || msg.Role != proto.Assistant || len(msg.Parts) == 0 {
+ continue
+ }
+ stopSpinner()
+
+ content := msg.Content().String()
+ readBytes := messageReadBytes[msg.ID]
+
+ if len(content) < readBytes {
+ slog.Error("Non-interactive: message content shorter than read bytes",
+ "message_length", len(content), "read_bytes", readBytes)
+ return fmt.Errorf("message content is shorter than read bytes: %d < %d", len(content), readBytes)
+ }
+
+ part := content[readBytes:]
+ if readBytes == 0 {
+ part = strings.TrimLeft(part, " \t")
+ }
+ if printed || strings.TrimSpace(part) != "" {
+ printed = true
+ fmt.Fprint(os.Stdout, part)
+ }
+ messageReadBytes[msg.ID] = len(content)
+
+ if msg.IsFinished() {
+ return nil
+ }
+
+ case pubsub.Event[proto.AgentEvent]:
+ if e.Payload.Error != nil {
+ stopSpinner()
+ return fmt.Errorf("agent error: %w", e.Payload.Error)
+ }
+ }
+
+ case <-ctx.Done():
+ stopSpinner()
+ return ctx.Err()
+ }
+ }
+}
+
+// waitForAgent polls GetAgentInfo until the agent is ready, with a
+// timeout.
+func waitForAgent(ctx context.Context, c *client.Client, wsID string) error {
+ timeout := time.After(30 * time.Second)
+ for {
+ info, err := c.GetAgentInfo(ctx, wsID)
+ if err == nil && info.IsReady {
+ return nil
+ }
+ select {
+ case <-timeout:
+ if err != nil {
+ return fmt.Errorf("timeout waiting for agent: %w", err)
+ }
+ return fmt.Errorf("timeout waiting for agent readiness")
+ case <-ctx.Done():
+ return ctx.Err()
+ case <-time.After(200 * time.Millisecond):
+ }
+ }
+}
+
+// overrideModels resolves model strings and updates the workspace
+// configuration via the server.
+func overrideModels(
+ ctx context.Context,
+ c *client.Client,
+ ws *proto.Workspace,
+ largeModel, smallModel string,
+) error {
+ cfg, err := c.GetConfig(ctx, ws.ID)
+ if err != nil {
+ return fmt.Errorf("failed to get config: %w", err)
+ }
+
+ providers := cfg.Providers.Copy()
+
+ largeMatches, smallMatches := findModelMatches(providers, largeModel, smallModel)
+
+ var largeProviderID string
+
+ if largeModel != "" {
+ found, err := validateModelMatches(largeMatches, largeModel, "large")
+ if err != nil {
+ return err
+ }
+ largeProviderID = found.provider
+ slog.Info("Overriding large model", "provider", found.provider, "model", found.modelID)
+ if err := c.UpdatePreferredModel(ctx, ws.ID, config.ScopeWorkspace, config.SelectedModelTypeLarge, config.SelectedModel{
+ Provider: found.provider,
+ Model: found.modelID,
+ }); err != nil {
+ return fmt.Errorf("failed to set large model: %w", err)
+ }
+ }
+
+ switch {
+ case smallModel != "":
+ found, err := validateModelMatches(smallMatches, smallModel, "small")
+ if err != nil {
+ return err
+ }
+ slog.Info("Overriding small model", "provider", found.provider, "model", found.modelID)
+ if err := c.UpdatePreferredModel(ctx, ws.ID, config.ScopeWorkspace, config.SelectedModelTypeSmall, config.SelectedModel{
+ Provider: found.provider,
+ Model: found.modelID,
+ }); err != nil {
+ return fmt.Errorf("failed to set small model: %w", err)
+ }
+
+ case largeModel != "":
+ sm, err := c.GetDefaultSmallModel(ctx, ws.ID, largeProviderID)
+ if err != nil {
+ slog.Warn("Failed to get default small model", "error", err)
+ } else if sm != nil {
+ if err := c.UpdatePreferredModel(ctx, ws.ID, config.ScopeWorkspace, config.SelectedModelTypeSmall, *sm); err != nil {
+ return fmt.Errorf("failed to set small model: %w", err)
+ }
+ }
+ }
+
+ return c.UpdateAgent(ctx, ws.ID)
+}
+
+type modelMatch struct {
+ provider string
+ modelID string
+}
+
+// findModelMatches searches providers for matching large/small model
+// strings.
+func findModelMatches(providers map[string]config.ProviderConfig, largeModel, smallModel string) ([]modelMatch, []modelMatch) {
+ largeFilter, largeID := parseModelString(largeModel)
+ smallFilter, smallID := parseModelString(smallModel)
+
+ var largeMatches, smallMatches []modelMatch
+ for name, provider := range providers {
+ if provider.Disable {
+ continue
+ }
+ for _, m := range provider.Models {
+ if matchesModel(largeID, largeFilter, m.ID, name) {
+ largeMatches = append(largeMatches, modelMatch{provider: name, modelID: m.ID})
+ }
+ if matchesModel(smallID, smallFilter, m.ID, name) {
+ smallMatches = append(smallMatches, modelMatch{provider: name, modelID: m.ID})
+ }
+ }
+ }
+ return largeMatches, smallMatches
+}
+
+// parseModelString splits "provider/model" into (provider, model) or
+// ("", model).
+func parseModelString(s string) (string, string) {
+ if s == "" {
+ return "", ""
+ }
+ if idx := strings.Index(s, "/"); idx >= 0 {
+ return s[:idx], s[idx+1:]
+ }
+ return "", s
+}
+
+// matchesModel returns true if the model ID matches the filter
+// criteria.
+func matchesModel(wantID, wantProvider, modelID, providerName string) bool {
+ if wantID == "" {
+ return false
+ }
+ if wantProvider != "" && wantProvider != providerName {
+ return false
+ }
+ return strings.EqualFold(modelID, wantID)
+}
+
+// validateModelMatches ensures exactly one match exists.
+func validateModelMatches(matches []modelMatch, modelID, label string) (modelMatch, error) {
+ switch {
+ case len(matches) == 0:
+ return modelMatch{}, fmt.Errorf("%s model %q not found", label, modelID)
+ case len(matches) > 1:
+ names := make([]string, len(matches))
+ for i, m := range matches {
+ names[i] = m.provider
+ }
+ return modelMatch{}, fmt.Errorf(
+ "%s model: model %q found in multiple providers: %s. Please specify provider using 'provider/model' format",
+ label, modelID, strings.Join(names, ", "),
+ )
+ }
+ return matches[0], nil
+}
+
+// resolveSession returns the session to use for a non-interactive run.
+// If continueSessionID is set it fetches that session; if useLast is set it
+// returns the most recently updated top-level session; otherwise it creates a
+// new one.
+func resolveSession(ctx context.Context, c *client.Client, wsID, continueSessionID string, useLast bool) (*proto.Session, error) {
+ switch {
+ case continueSessionID != "":
+ sess, err := c.GetSession(ctx, wsID, continueSessionID)
+ if err != nil {
+ return nil, fmt.Errorf("session not found: %s", continueSessionID)
+ }
+ if sess.ParentSessionID != "" {
+ return nil, fmt.Errorf("cannot continue a child session: %s", continueSessionID)
+ }
+ return sess, nil
+
+ case useLast:
+ sessions, err := c.ListSessions(ctx, wsID)
+ if err != nil || len(sessions) == 0 {
+ return nil, fmt.Errorf("no sessions found to continue")
+ }
+ last := sessions[0]
+ for _, s := range sessions[1:] {
+ if s.UpdatedAt > last.UpdatedAt && s.ParentSessionID == "" {
+ last = s
+ }
+ }
+ return &last, nil
+
+ default:
+ return c.CreateSession(ctx, wsID, "non-interactive")
+ }
+}
+
+// resolveSessionByID resolves a session ID that may be a full UUID or a hash
+// prefix returned by crush session list.
+func resolveSessionByID(ctx context.Context, c *client.Client, wsID, id string) (*proto.Session, error) {
+ if sess, err := c.GetSession(ctx, wsID, id); err == nil {
+ return sess, nil
+ }
+
+ sessions, err := c.ListSessions(ctx, wsID)
+ if err != nil {
+ return nil, err
+ }
+
+ var matches []proto.Session
+ for _, s := range sessions {
+ hash := session.HashID(s.ID)
+ if hash == id || strings.HasPrefix(hash, id) {
+ matches = append(matches, s)
+ }
+ }
+
+ switch len(matches) {
+ case 0:
+ return nil, fmt.Errorf("session %q not found", id)
+ case 1:
+ return &matches[0], nil
+ default:
+ return nil, fmt.Errorf("session ID %q is ambiguous (%d matches)", id, len(matches))
+ }
+}
@@ -0,0 +1,99 @@
+package cmd
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "log/slog"
+ "os"
+ "os/signal"
+ "path/filepath"
+ "time"
+
+ "github.com/charmbracelet/crush/internal/config"
+ crushlog "github.com/charmbracelet/crush/internal/log"
+ "github.com/charmbracelet/crush/internal/server"
+ "github.com/charmbracelet/x/term"
+ "github.com/spf13/cobra"
+)
+
+var serverHost string
+
+func init() {
+ serverCmd.Flags().StringVarP(&serverHost, "host", "H", server.DefaultHost(), "Server host (TCP or Unix socket)")
+ rootCmd.AddCommand(serverCmd)
+}
+
+var serverCmd = &cobra.Command{
+ Use: "server",
+ Short: "Start the Crush server",
+ RunE: func(cmd *cobra.Command, _ []string) error {
+ dataDir, err := cmd.Flags().GetString("data-dir")
+ if err != nil {
+ return fmt.Errorf("failed to get data directory: %v", err)
+ }
+ debug, err := cmd.Flags().GetBool("debug")
+ if err != nil {
+ return fmt.Errorf("failed to get debug flag: %v", err)
+ }
+
+ cfg, err := config.Load(config.GlobalWorkspaceDir(), dataDir, debug)
+ if err != nil {
+ return fmt.Errorf("failed to load configuration: %v", err)
+ }
+
+ logFile := filepath.Join(config.GlobalCacheDir(), "server-"+safeNameRegexp.ReplaceAllString(serverHost, "_"), "crush.log")
+
+ if term.IsTerminal(os.Stderr.Fd()) {
+ crushlog.Setup(logFile, debug, os.Stderr)
+ } else {
+ crushlog.Setup(logFile, debug)
+ }
+
+ hostURL, err := server.ParseHostURL(serverHost)
+ if err != nil {
+ return fmt.Errorf("invalid server host: %v", err)
+ }
+
+ srv := server.NewServer(cfg, hostURL.Scheme, hostURL.Host)
+ srv.SetLogger(slog.Default())
+ slog.Info("Starting Crush server...", "addr", serverHost)
+
+ errch := make(chan error, 1)
+ sigch := make(chan os.Signal, 1)
+ sigs := []os.Signal{os.Interrupt}
+ sigs = append(sigs, addSignals(sigs)...)
+ signal.Notify(sigch, sigs...)
+
+ go func() {
+ errch <- srv.ListenAndServe()
+ }()
+
+ select {
+ case <-sigch:
+ slog.Info("Received interrupt signal...")
+ case err = <-errch:
+ if err != nil && !errors.Is(err, server.ErrServerClosed) {
+ _ = srv.Close()
+ slog.Error("Server error", "error", err)
+ return fmt.Errorf("server error: %v", err)
+ }
+ }
+
+ if errors.Is(err, server.ErrServerClosed) {
+ return nil
+ }
+
+ ctx, cancel := context.WithTimeout(cmd.Context(), 5*time.Second)
+ defer cancel()
+
+ slog.Info("Shutting down...")
+
+ if err := srv.Shutdown(ctx); err != nil {
+ slog.Error("Failed to shutdown server", "error", err)
+ return fmt.Errorf("failed to shutdown server: %v", err)
+ }
+
+ return nil
+ },
+}
@@ -0,0 +1,13 @@
+//go:build !windows
+// +build !windows
+
+package cmd
+
+import (
+ "os"
+ "syscall"
+)
+
+func addSignals(sigs []os.Signal) []os.Signal {
+ return append(sigs, syscall.SIGTERM)
+}
@@ -0,0 +1,10 @@
+//go:build windows
+// +build windows
+
+package cmd
+
+import "os"
+
+func addSignals(sigs []os.Signal) []os.Signal {
+ return sigs
+}
@@ -213,8 +213,7 @@ func (c Completions) Limits() (depth, items int) {
}
type Permissions struct {
- AllowedTools []string `json:"allowed_tools,omitempty" jsonschema:"description=List of tools that don't require permission prompts,example=bash,example=view"` // Tools that don't require permission prompts
- SkipRequests bool `json:"-"` // Automatically accept all permissions (YOLO mode)
+ AllowedTools []string `json:"allowed_tools,omitempty" jsonschema:"description=List of tools that don't require permission prompts,example=bash,example=view"`
}
type TrailerStyle string
@@ -22,7 +22,6 @@ import (
"github.com/charmbracelet/crush/internal/env"
"github.com/charmbracelet/crush/internal/fsext"
"github.com/charmbracelet/crush/internal/home"
- "github.com/charmbracelet/crush/internal/log"
powernapConfig "github.com/charmbracelet/x/powernap/pkg/config"
"github.com/qjebbs/go-jsons"
)
@@ -52,12 +51,6 @@ func Load(workingDir, dataDir string, debug bool) (*ConfigStore, error) {
cfg.Options.Debug = true
}
- // Setup logs
- log.Setup(
- filepath.Join(cfg.Options.DataDirectory, "logs", fmt.Sprintf("%s.log", appName)),
- cfg.Options.Debug,
- )
-
// Load workspace config last so it has highest priority.
if wsData, err := os.ReadFile(store.workspacePath); err == nil && len(wsData) > 0 {
merged, mergeErr := loadFromBytes(append([][]byte{mustMarshalConfig(cfg)}, wsData))
@@ -749,6 +742,25 @@ func GlobalConfig() string {
return filepath.Join(home.Config(), appName, fmt.Sprintf("%s.json", appName))
}
+// GlobalCacheDir returns the path to the global cache directory for the
+// application.
+func GlobalCacheDir() string {
+ if crushCache := os.Getenv("CRUSH_CACHE_DIR"); crushCache != "" {
+ return crushCache
+ }
+ if xdgCacheHome := os.Getenv("XDG_CACHE_HOME"); xdgCacheHome != "" {
+ return filepath.Join(xdgCacheHome, appName)
+ }
+ if runtime.GOOS == "windows" {
+ localAppData := cmp.Or(
+ os.Getenv("LOCALAPPDATA"),
+ filepath.Join(os.Getenv("USERPROFILE"), "AppData", "Local"),
+ )
+ return filepath.Join(localAppData, appName, "cache")
+ }
+ return filepath.Join(home.Dir(), ".cache", appName)
+}
+
// GlobalConfigData returns the path to the main data directory for the application.
// this config is used when the app overrides configurations instead of updating the global config.
func GlobalConfigData() string {
@@ -773,6 +785,15 @@ func GlobalConfigData() string {
return filepath.Join(home.Dir(), ".local", "share", appName, fmt.Sprintf("%s.json", appName))
}
+// GlobalWorkspaceDir returns the path to the global server workspace
+// directory. This directory acts as a meta-workspace for the server
+// process, giving it a real workingDir so that config loading, scoped
+// writes, and provider resolution behave identically to project
+// workspaces.
+func GlobalWorkspaceDir() string {
+ return filepath.Dir(GlobalConfigData())
+}
+
func assignIfNil[T any](ptr **T, val T) {
if *ptr == nil {
*ptr = &val
@@ -14,6 +14,20 @@ type VariableResolver interface {
ResolveValue(value string) (string, error)
}
+// identityResolver is a no-op resolver that returns values unchanged.
+// Used in client mode where variable resolution is handled server-side.
+type identityResolver struct{}
+
+func (identityResolver) ResolveValue(value string) (string, error) {
+ return value, nil
+}
+
+// IdentityResolver returns a VariableResolver that passes values through
+// unchanged.
+func IdentityResolver() VariableResolver {
+ return identityResolver{}
+}
+
type Shell interface {
Exec(ctx context.Context, command string) (stdout, stderr string, err error)
}
@@ -1,5 +1,7 @@
package config
+import "fmt"
+
// Scope determines which config file is targeted for read/write operations.
type Scope int
@@ -9,3 +11,19 @@ const (
// ScopeWorkspace targets the workspace config (.crush/crush.json).
ScopeWorkspace
)
+
+// String returns a human-readable label for the scope.
+func (s Scope) String() string {
+ switch s {
+ case ScopeGlobal:
+ return "global"
+ case ScopeWorkspace:
+ return "workspace"
+ default:
+ return fmt.Sprintf("Scope(%d)", int(s))
+ }
+}
+
+// ErrNoWorkspaceConfig is returned when a workspace-scoped write is
+// attempted on a ConfigStore that has no workspace config path.
+var ErrNoWorkspaceConfig = fmt.Errorf("no workspace config path configured")
@@ -18,6 +18,13 @@ import (
"github.com/tidwall/sjson"
)
+// RuntimeOverrides holds per-session settings that are never persisted to
+// disk. They are applied on top of the loaded Config and survive only for
+// the lifetime of the process (or workspace).
+type RuntimeOverrides struct {
+ SkipPermissionRequests bool
+}
+
// ConfigStore is the single entry point for all config access. It owns the
// pure-data Config, runtime state (working directory, resolver, known
// providers), and persistence to both global and workspace config files.
@@ -28,6 +35,7 @@ type ConfigStore struct {
globalDataPath string // ~/.local/share/crush/crush.json
workspacePath string // .crush/crush.json
knownProviders []catwalk.Provider
+ overrides RuntimeOverrides
}
// Config returns the pure-data config struct (read-only after load).
@@ -63,20 +71,32 @@ func (s *ConfigStore) SetupAgents() {
s.config.SetupAgents()
}
+// Overrides returns the runtime overrides for this store.
+func (s *ConfigStore) Overrides() *RuntimeOverrides {
+ return &s.overrides
+}
+
// configPath returns the file path for the given scope.
-func (s *ConfigStore) configPath(scope Scope) string {
+func (s *ConfigStore) configPath(scope Scope) (string, error) {
switch scope {
case ScopeWorkspace:
- return s.workspacePath
+ if s.workspacePath == "" {
+ return "", ErrNoWorkspaceConfig
+ }
+ return s.workspacePath, nil
default:
- return s.globalDataPath
+ return s.globalDataPath, nil
}
}
// HasConfigField checks whether a key exists in the config file for the given
// scope.
func (s *ConfigStore) HasConfigField(scope Scope, key string) bool {
- data, err := os.ReadFile(s.configPath(scope))
+ path, err := s.configPath(scope)
+ if err != nil {
+ return false
+ }
+ data, err := os.ReadFile(path)
if err != nil {
return false
}
@@ -85,7 +105,10 @@ func (s *ConfigStore) HasConfigField(scope Scope, key string) bool {
// SetConfigField sets a key/value pair in the config file for the given scope.
func (s *ConfigStore) SetConfigField(scope Scope, key string, value any) error {
- path := s.configPath(scope)
+ path, err := s.configPath(scope)
+ if err != nil {
+ return fmt.Errorf("%s: %w", key, err)
+ }
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
@@ -110,7 +133,10 @@ func (s *ConfigStore) SetConfigField(scope Scope, key string, value any) error {
// RemoveConfigField removes a key from the config file for the given scope.
func (s *ConfigStore) RemoveConfigField(scope Scope, key string) error {
- path := s.configPath(scope)
+ path, err := s.configPath(scope)
+ if err != nil {
+ return fmt.Errorf("%s: %w", key, err)
+ }
data, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("failed to read config file: %w", err)
@@ -0,0 +1,152 @@
+package config
+
+import (
+ "errors"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestConfigStore_ConfigPath_GlobalAlwaysWorks(t *testing.T) {
+ t.Parallel()
+
+ store := &ConfigStore{
+ globalDataPath: "/some/global/crush.json",
+ }
+
+ path, err := store.configPath(ScopeGlobal)
+ require.NoError(t, err)
+ require.Equal(t, "/some/global/crush.json", path)
+}
+
+func TestConfigStore_ConfigPath_WorkspaceReturnsPath(t *testing.T) {
+ t.Parallel()
+
+ store := &ConfigStore{
+ workspacePath: "/some/workspace/.crush/crush.json",
+ }
+
+ path, err := store.configPath(ScopeWorkspace)
+ require.NoError(t, err)
+ require.Equal(t, "/some/workspace/.crush/crush.json", path)
+}
+
+func TestConfigStore_ConfigPath_WorkspaceErrorsWhenEmpty(t *testing.T) {
+ t.Parallel()
+
+ store := &ConfigStore{
+ globalDataPath: "/some/global/crush.json",
+ workspacePath: "",
+ }
+
+ _, err := store.configPath(ScopeWorkspace)
+ require.Error(t, err)
+ require.True(t, errors.Is(err, ErrNoWorkspaceConfig))
+}
+
+func TestConfigStore_SetConfigField_WorkspaceScopeGuard(t *testing.T) {
+ t.Parallel()
+
+ store := &ConfigStore{
+ config: &Config{},
+ globalDataPath: filepath.Join(t.TempDir(), "global.json"),
+ workspacePath: "",
+ }
+
+ err := store.SetConfigField(ScopeWorkspace, "foo", "bar")
+ require.Error(t, err)
+ require.True(t, errors.Is(err, ErrNoWorkspaceConfig))
+}
+
+func TestConfigStore_SetConfigField_GlobalScopeAlwaysWorks(t *testing.T) {
+ t.Parallel()
+
+ dir := t.TempDir()
+ globalPath := filepath.Join(dir, "crush.json")
+ store := &ConfigStore{
+ config: &Config{},
+ globalDataPath: globalPath,
+ }
+
+ err := store.SetConfigField(ScopeGlobal, "foo", "bar")
+ require.NoError(t, err)
+
+ data, err := os.ReadFile(globalPath)
+ require.NoError(t, err)
+ require.Contains(t, string(data), `"foo"`)
+}
+
+func TestConfigStore_RemoveConfigField_WorkspaceScopeGuard(t *testing.T) {
+ t.Parallel()
+
+ store := &ConfigStore{
+ config: &Config{},
+ globalDataPath: filepath.Join(t.TempDir(), "global.json"),
+ workspacePath: "",
+ }
+
+ err := store.RemoveConfigField(ScopeWorkspace, "foo")
+ require.Error(t, err)
+ require.True(t, errors.Is(err, ErrNoWorkspaceConfig))
+}
+
+func TestConfigStore_HasConfigField_WorkspaceScopeGuard(t *testing.T) {
+ t.Parallel()
+
+ store := &ConfigStore{
+ config: &Config{},
+ globalDataPath: filepath.Join(t.TempDir(), "global.json"),
+ workspacePath: "",
+ }
+
+ has := store.HasConfigField(ScopeWorkspace, "foo")
+ require.False(t, has)
+}
+
+func TestConfigStore_RuntimeOverrides_Independent(t *testing.T) {
+ t.Parallel()
+
+ store1 := &ConfigStore{config: &Config{}}
+ store2 := &ConfigStore{config: &Config{}}
+
+ require.False(t, store1.Overrides().SkipPermissionRequests)
+ require.False(t, store2.Overrides().SkipPermissionRequests)
+
+ store1.Overrides().SkipPermissionRequests = true
+
+ require.True(t, store1.Overrides().SkipPermissionRequests)
+ require.False(t, store2.Overrides().SkipPermissionRequests)
+}
+
+func TestConfigStore_RuntimeOverrides_MutableViaPointer(t *testing.T) {
+ t.Parallel()
+
+ store := &ConfigStore{config: &Config{}}
+ overrides := store.Overrides()
+
+ require.False(t, overrides.SkipPermissionRequests)
+
+ overrides.SkipPermissionRequests = true
+ require.True(t, store.Overrides().SkipPermissionRequests)
+}
+
+func TestGlobalWorkspaceDir(t *testing.T) {
+ dir := t.TempDir()
+ t.Setenv("CRUSH_GLOBAL_DATA", dir)
+
+ wsDir := GlobalWorkspaceDir()
+ globalData := GlobalConfigData()
+
+ require.Equal(t, filepath.Dir(globalData), wsDir)
+ require.Equal(t, dir, wsDir)
+}
+
+func TestScope_String(t *testing.T) {
+ t.Parallel()
+
+ require.Equal(t, "global", ScopeGlobal.String())
+ require.Equal(t, "workspace", ScopeWorkspace.String())
+ require.Contains(t, Scope(99).String(), "Scope(99)")
+}
@@ -2,6 +2,7 @@ package log
import (
"fmt"
+ "io"
"log/slog"
"os"
"runtime/debug"
@@ -10,6 +11,7 @@ import (
"time"
"github.com/charmbracelet/crush/internal/event"
+ "github.com/charmbracelet/x/term"
"gopkg.in/natefinch/lumberjack.v2"
)
@@ -18,7 +20,7 @@ var (
initialized atomic.Bool
)
-func Setup(logFile string, debug bool) {
+func Setup(logFile string, debug bool, ws ...io.Writer) {
initOnce.Do(func() {
logRotator := &lumberjack.Logger{
Filename: logFile,
@@ -33,12 +35,26 @@ func Setup(logFile string, debug bool) {
level = slog.LevelDebug
}
- logger := slog.NewJSONHandler(logRotator, &slog.HandlerOptions{
+ opts := &slog.HandlerOptions{
Level: level,
AddSource: true,
- })
+ }
+
+ var handlers []slog.Handler
+ handlers = append(handlers, slog.NewJSONHandler(logRotator, opts))
+
+ for _, w := range ws {
+ if w == nil {
+ continue
+ }
+ if f, ok := w.(term.File); ok && term.IsTerminal(f.Fd()) {
+ handlers = append(handlers, slog.NewTextHandler(w, opts))
+ } else {
+ handlers = append(handlers, slog.NewJSONHandler(w, opts))
+ }
+ }
- slog.SetDefault(slog.New(logger))
+ slog.SetDefault(slog.New(slog.NewMultiHandler(handlers...)))
initialized.Store(true)
})
}
@@ -0,0 +1,75 @@
+package proto
+
+import (
+ "encoding/json"
+ "errors"
+)
+
+// AgentEventType represents the type of agent event.
+type AgentEventType string
+
+const (
+ AgentEventTypeError AgentEventType = "error"
+ AgentEventTypeResponse AgentEventType = "response"
+ AgentEventTypeSummarize AgentEventType = "summarize"
+)
+
+// MarshalText implements the [encoding.TextMarshaler] interface.
+func (t AgentEventType) MarshalText() ([]byte, error) {
+ return []byte(t), nil
+}
+
+// UnmarshalText implements the [encoding.TextUnmarshaler] interface.
+func (t *AgentEventType) UnmarshalText(text []byte) error {
+ *t = AgentEventType(text)
+ return nil
+}
+
+// AgentEvent represents an event emitted by the agent.
+type AgentEvent struct {
+ Type AgentEventType `json:"type"`
+ Message Message `json:"message"`
+ Error error `json:"error,omitempty"`
+
+ // When summarizing.
+ SessionID string `json:"session_id,omitempty"`
+ SessionTitle string `json:"session_title,omitempty"`
+ Progress string `json:"progress,omitempty"`
+ Done bool `json:"done,omitempty"`
+}
+
+// MarshalJSON implements the [json.Marshaler] interface.
+func (e AgentEvent) MarshalJSON() ([]byte, error) {
+ type Alias AgentEvent
+ return json.Marshal(&struct {
+ Error string `json:"error,omitempty"`
+ Alias
+ }{
+ Error: func() string {
+ if e.Error != nil {
+ return e.Error.Error()
+ }
+ return ""
+ }(),
+ Alias: (Alias)(e),
+ })
+}
+
+// UnmarshalJSON implements the [json.Unmarshaler] interface.
+func (e *AgentEvent) UnmarshalJSON(data []byte) error {
+ type Alias AgentEvent
+ aux := &struct {
+ Error string `json:"error,omitempty"`
+ Alias
+ }{
+ Alias: (Alias)(*e),
+ }
+ if err := json.Unmarshal(data, &aux); err != nil {
+ return err
+ }
+ *e = AgentEvent(aux.Alias)
+ if aux.Error != "" {
+ e.Error = errors.New(aux.Error)
+ }
+ return nil
+}
@@ -0,0 +1,12 @@
+package proto
+
+// File represents a file tracked in session history.
+type File struct {
+ ID string `json:"id"`
+ SessionID string `json:"session_id"`
+ Path string `json:"path"`
+ Content string `json:"content"`
+ Version int64 `json:"version"`
+ CreatedAt int64 `json:"created_at"`
+ UpdatedAt int64 `json:"updated_at"`
+}
@@ -0,0 +1,172 @@
+package proto
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "time"
+)
+
+// MCPState represents the current state of an MCP client.
+type MCPState int
+
+const (
+ MCPStateDisabled MCPState = iota
+ MCPStateStarting
+ MCPStateConnected
+ MCPStateError
+)
+
+// MarshalText implements the [encoding.TextMarshaler] interface.
+func (s MCPState) MarshalText() ([]byte, error) {
+ return []byte(s.String()), nil
+}
+
+// UnmarshalText implements the [encoding.TextUnmarshaler] interface.
+func (s *MCPState) UnmarshalText(data []byte) error {
+ switch string(data) {
+ case "disabled":
+ *s = MCPStateDisabled
+ case "starting":
+ *s = MCPStateStarting
+ case "connected":
+ *s = MCPStateConnected
+ case "error":
+ *s = MCPStateError
+ default:
+ return fmt.Errorf("unknown mcp state: %s", data)
+ }
+ return nil
+}
+
+// String returns the string representation of the MCPState.
+func (s MCPState) String() string {
+ switch s {
+ case MCPStateDisabled:
+ return "disabled"
+ case MCPStateStarting:
+ return "starting"
+ case MCPStateConnected:
+ return "connected"
+ case MCPStateError:
+ return "error"
+ default:
+ return "unknown"
+ }
+}
+
+// MCPEventType represents the type of MCP event.
+type MCPEventType string
+
+const (
+ MCPEventStateChanged MCPEventType = "state_changed"
+ MCPEventToolsListChanged MCPEventType = "tools_list_changed"
+ MCPEventPromptsListChanged MCPEventType = "prompts_list_changed"
+ MCPEventResourcesListChanged MCPEventType = "resources_list_changed"
+)
+
+// MarshalText implements the [encoding.TextMarshaler] interface.
+func (t MCPEventType) MarshalText() ([]byte, error) {
+ return []byte(t), nil
+}
+
+// UnmarshalText implements the [encoding.TextUnmarshaler] interface.
+func (t *MCPEventType) UnmarshalText(data []byte) error {
+ *t = MCPEventType(data)
+ return nil
+}
+
+// MCPEvent represents an event in the MCP system.
+type MCPEvent struct {
+ Type MCPEventType `json:"type"`
+ Name string `json:"name"`
+ State MCPState `json:"state"`
+ Error error `json:"error,omitempty"`
+ ToolCount int `json:"tool_count,omitempty"`
+ PromptCount int `json:"prompt_count,omitempty"`
+ ResourceCount int `json:"resource_count,omitempty"`
+}
+
+// MarshalJSON implements the [json.Marshaler] interface.
+func (e MCPEvent) MarshalJSON() ([]byte, error) {
+ type Alias MCPEvent
+ return json.Marshal(&struct {
+ Error string `json:"error,omitempty"`
+ Alias
+ }{
+ Error: func() string {
+ if e.Error != nil {
+ return e.Error.Error()
+ }
+ return ""
+ }(),
+ Alias: (Alias)(e),
+ })
+}
+
+// UnmarshalJSON implements the [json.Unmarshaler] interface.
+func (e *MCPEvent) UnmarshalJSON(data []byte) error {
+ type Alias MCPEvent
+ aux := &struct {
+ Error string `json:"error,omitempty"`
+ Alias
+ }{
+ Alias: (Alias)(*e),
+ }
+ if err := json.Unmarshal(data, &aux); err != nil {
+ return err
+ }
+ *e = MCPEvent(aux.Alias)
+ if aux.Error != "" {
+ e.Error = errors.New(aux.Error)
+ }
+ return nil
+}
+
+// MCPClientInfo is the wire-format representation of an MCP client's
+// state, suitable for JSON transport between server and client.
+type MCPClientInfo struct {
+ Name string `json:"name"`
+ State MCPState `json:"state"`
+ Error error `json:"error,omitempty"`
+ ToolCount int `json:"tool_count,omitempty"`
+ PromptCount int `json:"prompt_count,omitempty"`
+ ResourceCount int `json:"resource_count,omitempty"`
+ ConnectedAt time.Time `json:"connected_at"`
+}
+
+// MarshalJSON implements the [json.Marshaler] interface.
+func (i MCPClientInfo) MarshalJSON() ([]byte, error) {
+ type Alias MCPClientInfo
+ return json.Marshal(&struct {
+ Error string `json:"error,omitempty"`
+ Alias
+ }{
+ Error: func() string {
+ if i.Error != nil {
+ return i.Error.Error()
+ }
+ return ""
+ }(),
+ Alias: (Alias)(i),
+ })
+}
+
+// UnmarshalJSON implements the [json.Unmarshaler] interface.
+func (i *MCPClientInfo) UnmarshalJSON(data []byte) error {
+ type Alias MCPClientInfo
+ aux := &struct {
+ Error string `json:"error,omitempty"`
+ Alias
+ }{
+ Alias: (Alias)(*i),
+ }
+ if err := json.Unmarshal(data, &aux); err != nil {
+ return err
+ }
+ *i = MCPClientInfo(aux.Alias)
+ if aux.Error != "" {
+ i.Error = errors.New(aux.Error)
+ }
+ return nil
+}
@@ -0,0 +1,653 @@
+package proto
+
+import (
+ "encoding/base64"
+ "encoding/json"
+ "fmt"
+ "slices"
+ "time"
+
+ "charm.land/catwalk/pkg/catwalk"
+)
+
+// CreateMessageParams represents parameters for creating a message.
+type CreateMessageParams struct {
+ Role MessageRole `json:"role"`
+ Parts []ContentPart `json:"parts"`
+ Model string `json:"model"`
+ Provider string `json:"provider,omitempty"`
+}
+
+// Message represents a message in the proto layer.
+type Message struct {
+ ID string `json:"id"`
+ Role MessageRole `json:"role"`
+ SessionID string `json:"session_id"`
+ Parts []ContentPart `json:"parts"`
+ Model string `json:"model"`
+ Provider string `json:"provider"`
+ CreatedAt int64 `json:"created_at"`
+ UpdatedAt int64 `json:"updated_at"`
+}
+
+// MessageRole represents the role of a message sender.
+type MessageRole string
+
+const (
+ Assistant MessageRole = "assistant"
+ User MessageRole = "user"
+ System MessageRole = "system"
+ Tool MessageRole = "tool"
+)
+
+// MarshalText implements the [encoding.TextMarshaler] interface.
+func (r MessageRole) MarshalText() ([]byte, error) {
+ return []byte(r), nil
+}
+
+// UnmarshalText implements the [encoding.TextUnmarshaler] interface.
+func (r *MessageRole) UnmarshalText(data []byte) error {
+ *r = MessageRole(data)
+ return nil
+}
+
+// FinishReason represents why a message generation finished.
+type FinishReason string
+
+const (
+ FinishReasonEndTurn FinishReason = "end_turn"
+ FinishReasonMaxTokens FinishReason = "max_tokens"
+ FinishReasonToolUse FinishReason = "tool_use"
+ FinishReasonCanceled FinishReason = "canceled"
+ FinishReasonError FinishReason = "error"
+ FinishReasonPermissionDenied FinishReason = "permission_denied"
+ FinishReasonUnknown FinishReason = "unknown"
+)
+
+// MarshalText implements the [encoding.TextMarshaler] interface.
+func (fr FinishReason) MarshalText() ([]byte, error) {
+ return []byte(fr), nil
+}
+
+// UnmarshalText implements the [encoding.TextUnmarshaler] interface.
+func (fr *FinishReason) UnmarshalText(data []byte) error {
+ *fr = FinishReason(data)
+ return nil
+}
+
+// ContentPart is a part of a message's content.
+type ContentPart interface {
+ isPart()
+}
+
+// ReasoningContent represents the reasoning/thinking part of a message.
+type ReasoningContent struct {
+ Thinking string `json:"thinking"`
+ Signature string `json:"signature"`
+ StartedAt int64 `json:"started_at,omitempty"`
+ FinishedAt int64 `json:"finished_at,omitempty"`
+}
+
+// String returns the thinking content as a string.
+func (tc ReasoningContent) String() string {
+ return tc.Thinking
+}
+
+func (ReasoningContent) isPart() {}
+
+// TextContent represents a text part of a message.
+type TextContent struct {
+ Text string `json:"text"`
+}
+
+// String returns the text content as a string.
+func (tc TextContent) String() string {
+ return tc.Text
+}
+
+func (TextContent) isPart() {}
+
+// ImageURLContent represents an image URL part of a message.
+type ImageURLContent struct {
+ URL string `json:"url"`
+ Detail string `json:"detail,omitempty"`
+}
+
+// String returns the image URL as a string.
+func (iuc ImageURLContent) String() string {
+ return iuc.URL
+}
+
+func (ImageURLContent) isPart() {}
+
+// BinaryContent represents binary data in a message.
+type BinaryContent struct {
+ Path string
+ MIMEType string
+ Data []byte
+}
+
+// String returns a base64-encoded string of the binary data.
+func (bc BinaryContent) String(p catwalk.InferenceProvider) string {
+ base64Encoded := base64.StdEncoding.EncodeToString(bc.Data)
+ if p == catwalk.InferenceProviderOpenAI {
+ return "data:" + bc.MIMEType + ";base64," + base64Encoded
+ }
+ return base64Encoded
+}
+
+func (BinaryContent) isPart() {}
+
+// ToolCall represents a tool call in a message.
+type ToolCall struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Input string `json:"input"`
+ Type string `json:"type,omitempty"`
+ Finished bool `json:"finished,omitempty"`
+}
+
+func (ToolCall) isPart() {}
+
+// ToolResult represents the result of a tool call.
+type ToolResult struct {
+ ToolCallID string `json:"tool_call_id"`
+ Name string `json:"name"`
+ Content string `json:"content"`
+ Metadata string `json:"metadata"`
+ IsError bool `json:"is_error"`
+}
+
+func (ToolResult) isPart() {}
+
+// Finish represents the end of a message generation.
+type Finish struct {
+ Reason FinishReason `json:"reason"`
+ Time int64 `json:"time"`
+ Message string `json:"message,omitempty"`
+ Details string `json:"details,omitempty"`
+}
+
+func (Finish) isPart() {}
+
+// MarshalJSON implements the [json.Marshaler] interface.
+func (m Message) MarshalJSON() ([]byte, error) {
+ parts, err := MarshalParts(m.Parts)
+ if err != nil {
+ return nil, err
+ }
+
+ type Alias Message
+ return json.Marshal(&struct {
+ Parts json.RawMessage `json:"parts"`
+ *Alias
+ }{
+ Parts: json.RawMessage(parts),
+ Alias: (*Alias)(&m),
+ })
+}
+
+// UnmarshalJSON implements the [json.Unmarshaler] interface.
+func (m *Message) UnmarshalJSON(data []byte) error {
+ type Alias Message
+ aux := &struct {
+ Parts json.RawMessage `json:"parts"`
+ *Alias
+ }{
+ Alias: (*Alias)(m),
+ }
+
+ if err := json.Unmarshal(data, &aux); err != nil {
+ return err
+ }
+
+ parts, err := UnmarshalParts([]byte(aux.Parts))
+ if err != nil {
+ return err
+ }
+
+ m.Parts = parts
+ return nil
+}
+
+// Content returns the first text content part.
+func (m *Message) Content() TextContent {
+ for _, part := range m.Parts {
+ if c, ok := part.(TextContent); ok {
+ return c
+ }
+ }
+ return TextContent{}
+}
+
+// ReasoningContent returns the first reasoning content part.
+func (m *Message) ReasoningContent() ReasoningContent {
+ for _, part := range m.Parts {
+ if c, ok := part.(ReasoningContent); ok {
+ return c
+ }
+ }
+ return ReasoningContent{}
+}
+
+// ImageURLContent returns all image URL content parts.
+func (m *Message) ImageURLContent() []ImageURLContent {
+ imageURLContents := make([]ImageURLContent, 0)
+ for _, part := range m.Parts {
+ if c, ok := part.(ImageURLContent); ok {
+ imageURLContents = append(imageURLContents, c)
+ }
+ }
+ return imageURLContents
+}
+
+// BinaryContent returns all binary content parts.
+func (m *Message) BinaryContent() []BinaryContent {
+ binaryContents := make([]BinaryContent, 0)
+ for _, part := range m.Parts {
+ if c, ok := part.(BinaryContent); ok {
+ binaryContents = append(binaryContents, c)
+ }
+ }
+ return binaryContents
+}
+
+// ToolCalls returns all tool call parts.
+func (m *Message) ToolCalls() []ToolCall {
+ toolCalls := make([]ToolCall, 0)
+ for _, part := range m.Parts {
+ if c, ok := part.(ToolCall); ok {
+ toolCalls = append(toolCalls, c)
+ }
+ }
+ return toolCalls
+}
+
+// ToolResults returns all tool result parts.
+func (m *Message) ToolResults() []ToolResult {
+ toolResults := make([]ToolResult, 0)
+ for _, part := range m.Parts {
+ if c, ok := part.(ToolResult); ok {
+ toolResults = append(toolResults, c)
+ }
+ }
+ return toolResults
+}
+
+// IsFinished returns true if the message has a finish part.
+func (m *Message) IsFinished() bool {
+ for _, part := range m.Parts {
+ if _, ok := part.(Finish); ok {
+ return true
+ }
+ }
+ return false
+}
+
+// FinishPart returns the finish part if present.
+func (m *Message) FinishPart() *Finish {
+ for _, part := range m.Parts {
+ if c, ok := part.(Finish); ok {
+ return &c
+ }
+ }
+ return nil
+}
+
+// FinishReason returns the finish reason if present.
+func (m *Message) FinishReason() FinishReason {
+ for _, part := range m.Parts {
+ if c, ok := part.(Finish); ok {
+ return c.Reason
+ }
+ }
+ return ""
+}
+
+// IsThinking returns true if the message is currently in a thinking state.
+func (m *Message) IsThinking() bool {
+ return m.ReasoningContent().Thinking != "" && m.Content().Text == "" && !m.IsFinished()
+}
+
+// AppendContent appends text to the text content part.
+func (m *Message) AppendContent(delta string) {
+ found := false
+ for i, part := range m.Parts {
+ if c, ok := part.(TextContent); ok {
+ m.Parts[i] = TextContent{Text: c.Text + delta}
+ found = true
+ }
+ }
+ if !found {
+ m.Parts = append(m.Parts, TextContent{Text: delta})
+ }
+}
+
+// AppendReasoningContent appends text to the reasoning content part.
+func (m *Message) AppendReasoningContent(delta string) {
+ found := false
+ for i, part := range m.Parts {
+ if c, ok := part.(ReasoningContent); ok {
+ m.Parts[i] = ReasoningContent{
+ Thinking: c.Thinking + delta,
+ Signature: c.Signature,
+ StartedAt: c.StartedAt,
+ FinishedAt: c.FinishedAt,
+ }
+ found = true
+ }
+ }
+ if !found {
+ m.Parts = append(m.Parts, ReasoningContent{
+ Thinking: delta,
+ StartedAt: time.Now().Unix(),
+ })
+ }
+}
+
+// AppendReasoningSignature appends a signature to the reasoning content part.
+func (m *Message) AppendReasoningSignature(signature string) {
+ for i, part := range m.Parts {
+ if c, ok := part.(ReasoningContent); ok {
+ m.Parts[i] = ReasoningContent{
+ Thinking: c.Thinking,
+ Signature: c.Signature + signature,
+ StartedAt: c.StartedAt,
+ FinishedAt: c.FinishedAt,
+ }
+ return
+ }
+ }
+ m.Parts = append(m.Parts, ReasoningContent{Signature: signature})
+}
+
+// FinishThinking marks the reasoning content as finished.
+func (m *Message) FinishThinking() {
+ for i, part := range m.Parts {
+ if c, ok := part.(ReasoningContent); ok {
+ if c.FinishedAt == 0 {
+ m.Parts[i] = ReasoningContent{
+ Thinking: c.Thinking,
+ Signature: c.Signature,
+ StartedAt: c.StartedAt,
+ FinishedAt: time.Now().Unix(),
+ }
+ }
+ return
+ }
+ }
+}
+
+// ThinkingDuration returns the duration of the thinking phase.
+func (m *Message) ThinkingDuration() time.Duration {
+ reasoning := m.ReasoningContent()
+ if reasoning.StartedAt == 0 {
+ return 0
+ }
+
+ endTime := reasoning.FinishedAt
+ if endTime == 0 {
+ endTime = time.Now().Unix()
+ }
+
+ return time.Duration(endTime-reasoning.StartedAt) * time.Second
+}
+
+// FinishToolCall marks a tool call as finished.
+func (m *Message) FinishToolCall(toolCallID string) {
+ for i, part := range m.Parts {
+ if c, ok := part.(ToolCall); ok {
+ if c.ID == toolCallID {
+ m.Parts[i] = ToolCall{
+ ID: c.ID,
+ Name: c.Name,
+ Input: c.Input,
+ Type: c.Type,
+ Finished: true,
+ }
+ return
+ }
+ }
+ }
+}
+
+// AppendToolCallInput appends input to a tool call.
+func (m *Message) AppendToolCallInput(toolCallID string, inputDelta string) {
+ for i, part := range m.Parts {
+ if c, ok := part.(ToolCall); ok {
+ if c.ID == toolCallID {
+ m.Parts[i] = ToolCall{
+ ID: c.ID,
+ Name: c.Name,
+ Input: c.Input + inputDelta,
+ Type: c.Type,
+ Finished: c.Finished,
+ }
+ return
+ }
+ }
+ }
+}
+
+// AddToolCall adds or updates a tool call.
+func (m *Message) AddToolCall(tc ToolCall) {
+ for i, part := range m.Parts {
+ if c, ok := part.(ToolCall); ok {
+ if c.ID == tc.ID {
+ m.Parts[i] = tc
+ return
+ }
+ }
+ }
+ m.Parts = append(m.Parts, tc)
+}
+
+// SetToolCalls replaces all tool call parts.
+func (m *Message) SetToolCalls(tc []ToolCall) {
+ parts := make([]ContentPart, 0)
+ for _, part := range m.Parts {
+ if _, ok := part.(ToolCall); ok {
+ continue
+ }
+ parts = append(parts, part)
+ }
+ m.Parts = parts
+ for _, toolCall := range tc {
+ m.Parts = append(m.Parts, toolCall)
+ }
+}
+
+// AddToolResult adds a tool result.
+func (m *Message) AddToolResult(tr ToolResult) {
+ m.Parts = append(m.Parts, tr)
+}
+
+// SetToolResults adds multiple tool results.
+func (m *Message) SetToolResults(tr []ToolResult) {
+ for _, toolResult := range tr {
+ m.Parts = append(m.Parts, toolResult)
+ }
+}
+
+// AddFinish adds a finish part to the message.
+func (m *Message) AddFinish(reason FinishReason, message, details string) {
+ for i, part := range m.Parts {
+ if _, ok := part.(Finish); ok {
+ m.Parts = slices.Delete(m.Parts, i, i+1)
+ break
+ }
+ }
+ m.Parts = append(m.Parts, Finish{Reason: reason, Time: time.Now().Unix(), Message: message, Details: details})
+}
+
+// AddImageURL adds an image URL part to the message.
+func (m *Message) AddImageURL(url, detail string) {
+ m.Parts = append(m.Parts, ImageURLContent{URL: url, Detail: detail})
+}
+
+// AddBinary adds a binary content part to the message.
+func (m *Message) AddBinary(mimeType string, data []byte) {
+ m.Parts = append(m.Parts, BinaryContent{MIMEType: mimeType, Data: data})
+}
+
+type partType string
+
+const (
+ reasoningType partType = "reasoning"
+ textType partType = "text"
+ imageURLType partType = "image_url"
+ binaryType partType = "binary"
+ toolCallType partType = "tool_call"
+ toolResultType partType = "tool_result"
+ finishType partType = "finish"
+)
+
+type partWrapper struct {
+ Type partType `json:"type"`
+ Data ContentPart `json:"data"`
+}
+
+// MarshalParts marshals content parts to JSON.
+func MarshalParts(parts []ContentPart) ([]byte, error) {
+ wrappedParts := make([]partWrapper, len(parts))
+
+ for i, part := range parts {
+ var typ partType
+
+ switch part.(type) {
+ case ReasoningContent:
+ typ = reasoningType
+ case TextContent:
+ typ = textType
+ case ImageURLContent:
+ typ = imageURLType
+ case BinaryContent:
+ typ = binaryType
+ case ToolCall:
+ typ = toolCallType
+ case ToolResult:
+ typ = toolResultType
+ case Finish:
+ typ = finishType
+ default:
+ return nil, fmt.Errorf("unknown part type: %T", part)
+ }
+
+ wrappedParts[i] = partWrapper{
+ Type: typ,
+ Data: part,
+ }
+ }
+ return json.Marshal(wrappedParts)
+}
+
+// UnmarshalParts unmarshals content parts from JSON.
+func UnmarshalParts(data []byte) ([]ContentPart, error) {
+ temp := []json.RawMessage{}
+
+ if err := json.Unmarshal(data, &temp); err != nil {
+ return nil, err
+ }
+
+ parts := make([]ContentPart, 0)
+
+ for _, rawPart := range temp {
+ var wrapper struct {
+ Type partType `json:"type"`
+ Data json.RawMessage `json:"data"`
+ }
+
+ if err := json.Unmarshal(rawPart, &wrapper); err != nil {
+ return nil, err
+ }
+
+ switch wrapper.Type {
+ case reasoningType:
+ part := ReasoningContent{}
+ if err := json.Unmarshal(wrapper.Data, &part); err != nil {
+ return nil, err
+ }
+ parts = append(parts, part)
+ case textType:
+ part := TextContent{}
+ if err := json.Unmarshal(wrapper.Data, &part); err != nil {
+ return nil, err
+ }
+ parts = append(parts, part)
+ case imageURLType:
+ part := ImageURLContent{}
+ if err := json.Unmarshal(wrapper.Data, &part); err != nil {
+ return nil, err
+ }
+ parts = append(parts, part)
+ case binaryType:
+ part := BinaryContent{}
+ if err := json.Unmarshal(wrapper.Data, &part); err != nil {
+ return nil, err
+ }
+ parts = append(parts, part)
+ case toolCallType:
+ part := ToolCall{}
+ if err := json.Unmarshal(wrapper.Data, &part); err != nil {
+ return nil, err
+ }
+ parts = append(parts, part)
+ case toolResultType:
+ part := ToolResult{}
+ if err := json.Unmarshal(wrapper.Data, &part); err != nil {
+ return nil, err
+ }
+ parts = append(parts, part)
+ case finishType:
+ part := Finish{}
+ if err := json.Unmarshal(wrapper.Data, &part); err != nil {
+ return nil, err
+ }
+ parts = append(parts, part)
+ default:
+ return nil, fmt.Errorf("unknown part type: %s", wrapper.Type)
+ }
+ }
+
+ return parts, nil
+}
+
+// Attachment represents a file attachment.
+type Attachment struct {
+ FilePath string `json:"file_path"`
+ FileName string `json:"file_name"`
+ MimeType string `json:"mime_type"`
+ Content []byte `json:"content"`
+}
+
+// MarshalJSON implements the [json.Marshaler] interface.
+func (a Attachment) MarshalJSON() ([]byte, error) {
+ type Alias Attachment
+ return json.Marshal(&struct {
+ Content string `json:"content"`
+ *Alias
+ }{
+ Content: base64.StdEncoding.EncodeToString(a.Content),
+ Alias: (*Alias)(&a),
+ })
+}
+
+// UnmarshalJSON implements the [json.Unmarshaler] interface.
+func (a *Attachment) UnmarshalJSON(data []byte) error {
+ type Alias Attachment
+ aux := &struct {
+ Content string `json:"content"`
+ *Alias
+ }{
+ Alias: (*Alias)(a),
+ }
+ if err := json.Unmarshal(data, &aux); err != nil {
+ return err
+ }
+ content, err := base64.StdEncoding.DecodeString(aux.Content)
+ if err != nil {
+ return err
+ }
+ a.Content = content
+ return nil
+}
@@ -0,0 +1,141 @@
+package proto
+
+import (
+ "encoding/json"
+)
+
+// CreatePermissionRequest represents a request to create a permission.
+type CreatePermissionRequest struct {
+ SessionID string `json:"session_id"`
+ ToolCallID string `json:"tool_call_id"`
+ ToolName string `json:"tool_name"`
+ Description string `json:"description"`
+ Action string `json:"action"`
+ Params any `json:"params"`
+ Path string `json:"path"`
+}
+
+// PermissionNotification represents a notification about a permission change.
+type PermissionNotification struct {
+ ToolCallID string `json:"tool_call_id"`
+ Granted bool `json:"granted"`
+ Denied bool `json:"denied"`
+}
+
+// PermissionRequest represents a pending permission request.
+type PermissionRequest struct {
+ ID string `json:"id"`
+ SessionID string `json:"session_id"`
+ ToolCallID string `json:"tool_call_id"`
+ ToolName string `json:"tool_name"`
+ Description string `json:"description"`
+ Action string `json:"action"`
+ Params any `json:"params"`
+ Path string `json:"path"`
+}
+
+// UnmarshalJSON implements the json.Unmarshaler interface. This is needed
+// because the Params field is of type any, so we need to unmarshal it into
+// its appropriate type based on the [PermissionRequest.ToolName].
+func (p *PermissionRequest) UnmarshalJSON(data []byte) error {
+ type Alias PermissionRequest
+ aux := &struct {
+ Params json.RawMessage `json:"params"`
+ *Alias
+ }{
+ Alias: (*Alias)(p),
+ }
+ if err := json.Unmarshal(data, &aux); err != nil {
+ return err
+ }
+
+ params, err := unmarshalToolParams(p.ToolName, aux.Params)
+ if err != nil {
+ return err
+ }
+ p.Params = params
+ return nil
+}
+
+// UnmarshalJSON implements the json.Unmarshaler interface. This is needed
+// because the Params field is of type any, so we need to unmarshal it into
+// its appropriate type based on the [CreatePermissionRequest.ToolName].
+func (p *CreatePermissionRequest) UnmarshalJSON(data []byte) error {
+ type Alias CreatePermissionRequest
+ aux := &struct {
+ Params json.RawMessage `json:"params"`
+ *Alias
+ }{
+ Alias: (*Alias)(p),
+ }
+ if err := json.Unmarshal(data, &aux); err != nil {
+ return err
+ }
+
+ params, err := unmarshalToolParams(p.ToolName, aux.Params)
+ if err != nil {
+ return err
+ }
+ p.Params = params
+ return nil
+}
+
+func unmarshalToolParams(toolName string, raw json.RawMessage) (any, error) {
+ switch toolName {
+ case BashToolName:
+ var params BashPermissionsParams
+ if err := json.Unmarshal(raw, ¶ms); err != nil {
+ return nil, err
+ }
+ return params, nil
+ case DownloadToolName:
+ var params DownloadPermissionsParams
+ if err := json.Unmarshal(raw, ¶ms); err != nil {
+ return nil, err
+ }
+ return params, nil
+ case EditToolName:
+ var params EditPermissionsParams
+ if err := json.Unmarshal(raw, ¶ms); err != nil {
+ return nil, err
+ }
+ return params, nil
+ case WriteToolName:
+ var params WritePermissionsParams
+ if err := json.Unmarshal(raw, ¶ms); err != nil {
+ return nil, err
+ }
+ return params, nil
+ case MultiEditToolName:
+ var params MultiEditPermissionsParams
+ if err := json.Unmarshal(raw, ¶ms); err != nil {
+ return nil, err
+ }
+ return params, nil
+ case FetchToolName:
+ var params FetchPermissionsParams
+ if err := json.Unmarshal(raw, ¶ms); err != nil {
+ return nil, err
+ }
+ return params, nil
+ case ViewToolName:
+ var params ViewPermissionsParams
+ if err := json.Unmarshal(raw, ¶ms); err != nil {
+ return nil, err
+ }
+ return params, nil
+ case LSToolName:
+ var params LSPermissionsParams
+ if err := json.Unmarshal(raw, ¶ms); err != nil {
+ return nil, err
+ }
+ return params, nil
+ default:
+ // For unknown tools, keep the raw JSON as-is.
+ var generic map[string]any
+ if err := json.Unmarshal(raw, &generic); err != nil {
+ return nil, err
+ }
+ return generic, nil
+ }
+}
@@ -0,0 +1,200 @@
+package proto
+
+import (
+ "encoding/json"
+ "errors"
+ "time"
+
+ "charm.land/catwalk/pkg/catwalk"
+ "github.com/charmbracelet/crush/internal/config"
+ "github.com/charmbracelet/crush/internal/lsp"
+)
+
+// Workspace represents a running app.App workspace with its associated
+// resources and state.
+type Workspace struct {
+ ID string `json:"id"`
+ Path string `json:"path"`
+ YOLO bool `json:"yolo,omitempty"`
+ Debug bool `json:"debug,omitempty"`
+ DataDir string `json:"data_dir,omitempty"`
+ Version string `json:"version,omitempty"`
+ Config *config.Config `json:"config,omitempty"`
+ Env []string `json:"env,omitempty"`
+}
+
+// Error represents an error response.
+type Error struct {
+ Message string `json:"message"`
+}
+
+// AgentInfo represents information about the agent.
+type AgentInfo struct {
+ IsBusy bool `json:"is_busy"`
+ IsReady bool `json:"is_ready"`
+ Model catwalk.Model `json:"model"`
+ ModelCfg config.SelectedModel `json:"model_cfg"`
+}
+
+// IsZero checks if the AgentInfo is zero-valued.
+func (a AgentInfo) IsZero() bool {
+ return !a.IsBusy && !a.IsReady && a.Model.ID == ""
+}
+
+// AgentMessage represents a message sent to the agent.
+type AgentMessage struct {
+ SessionID string `json:"session_id"`
+ Prompt string `json:"prompt"`
+ Attachments []Attachment `json:"attachments,omitempty"`
+}
+
+// AgentSession represents a session with its busy status.
+type AgentSession struct {
+ Session
+ IsBusy bool `json:"is_busy"`
+}
+
+// IsZero checks if the AgentSession is zero-valued.
+func (a AgentSession) IsZero() bool {
+ return a == AgentSession{}
+}
+
+// PermissionAction represents an action taken on a permission request.
+type PermissionAction string
+
+const (
+ PermissionAllow PermissionAction = "allow"
+ PermissionAllowForSession PermissionAction = "allow_session"
+ PermissionDeny PermissionAction = "deny"
+)
+
+// MarshalText implements the [encoding.TextMarshaler] interface.
+func (p PermissionAction) MarshalText() ([]byte, error) {
+ return []byte(p), nil
+}
+
+// UnmarshalText implements the [encoding.TextUnmarshaler] interface.
+func (p *PermissionAction) UnmarshalText(text []byte) error {
+ *p = PermissionAction(text)
+ return nil
+}
+
+// PermissionGrant represents a permission grant request.
+type PermissionGrant struct {
+ Permission PermissionRequest `json:"permission"`
+ Action PermissionAction `json:"action"`
+}
+
+// PermissionSkipRequest represents a request to skip permission prompts.
+type PermissionSkipRequest struct {
+ Skip bool `json:"skip"`
+}
+
+// LSPEventType represents the type of LSP event.
+type LSPEventType string
+
+const (
+ LSPEventStateChanged LSPEventType = "state_changed"
+ LSPEventDiagnosticsChanged LSPEventType = "diagnostics_changed"
+)
+
+// MarshalText implements the [encoding.TextMarshaler] interface.
+func (e LSPEventType) MarshalText() ([]byte, error) {
+ return []byte(e), nil
+}
+
+// UnmarshalText implements the [encoding.TextUnmarshaler] interface.
+func (e *LSPEventType) UnmarshalText(data []byte) error {
+ *e = LSPEventType(data)
+ return nil
+}
+
+// LSPEvent represents an event in the LSP system.
+type LSPEvent struct {
+ Type LSPEventType `json:"type"`
+ Name string `json:"name"`
+ State lsp.ServerState `json:"state"`
+ Error error `json:"error,omitempty"`
+ DiagnosticCount int `json:"diagnostic_count,omitempty"`
+}
+
+// MarshalJSON implements the [json.Marshaler] interface.
+func (e LSPEvent) MarshalJSON() ([]byte, error) {
+ type Alias LSPEvent
+ return json.Marshal(&struct {
+ Error string `json:"error,omitempty"`
+ Alias
+ }{
+ Error: func() string {
+ if e.Error != nil {
+ return e.Error.Error()
+ }
+ return ""
+ }(),
+ Alias: (Alias)(e),
+ })
+}
+
+// UnmarshalJSON implements the [json.Unmarshaler] interface.
+func (e *LSPEvent) UnmarshalJSON(data []byte) error {
+ type Alias LSPEvent
+ aux := &struct {
+ Error string `json:"error,omitempty"`
+ Alias
+ }{
+ Alias: (Alias)(*e),
+ }
+ if err := json.Unmarshal(data, &aux); err != nil {
+ return err
+ }
+ *e = LSPEvent(aux.Alias)
+ if aux.Error != "" {
+ e.Error = errors.New(aux.Error)
+ }
+ return nil
+}
+
+// LSPClientInfo holds information about an LSP client's state.
+type LSPClientInfo struct {
+ Name string `json:"name"`
+ State lsp.ServerState `json:"state"`
+ Error error `json:"error,omitempty"`
+ DiagnosticCount int `json:"diagnostic_count,omitempty"`
+ ConnectedAt time.Time `json:"connected_at"`
+}
+
+// MarshalJSON implements the [json.Marshaler] interface.
+func (i LSPClientInfo) MarshalJSON() ([]byte, error) {
+ type Alias LSPClientInfo
+ return json.Marshal(&struct {
+ Error string `json:"error,omitempty"`
+ Alias
+ }{
+ Error: func() string {
+ if i.Error != nil {
+ return i.Error.Error()
+ }
+ return ""
+ }(),
+ Alias: (Alias)(i),
+ })
+}
+
+// UnmarshalJSON implements the [json.Unmarshaler] interface.
+func (i *LSPClientInfo) UnmarshalJSON(data []byte) error {
+ type Alias LSPClientInfo
+ aux := &struct {
+ Error string `json:"error,omitempty"`
+ Alias
+ }{
+ Alias: (Alias)(*i),
+ }
+ if err := json.Unmarshal(data, &aux); err != nil {
+ return err
+ }
+ *i = LSPClientInfo(aux.Alias)
+ if aux.Error != "" {
+ i.Error = errors.New(aux.Error)
+ }
+ return nil
+}
@@ -0,0 +1,92 @@
+package proto
+
+import "github.com/charmbracelet/crush/internal/config"
+
+// ConfigSetRequest represents a request to set a config field.
+type ConfigSetRequest struct {
+ Scope config.Scope `json:"scope"`
+ Key string `json:"key"`
+ Value any `json:"value"`
+}
+
+// ConfigRemoveRequest represents a request to remove a config field.
+type ConfigRemoveRequest struct {
+ Scope config.Scope `json:"scope"`
+ Key string `json:"key"`
+}
+
+// ConfigModelRequest represents a request to update the preferred model.
+type ConfigModelRequest struct {
+ Scope config.Scope `json:"scope"`
+ ModelType config.SelectedModelType `json:"model_type"`
+ Model config.SelectedModel `json:"model"`
+}
+
+// ConfigCompactRequest represents a request to set compact mode.
+type ConfigCompactRequest struct {
+ Scope config.Scope `json:"scope"`
+ Enabled bool `json:"enabled"`
+}
+
+// ConfigProviderKeyRequest represents a request to set a provider API key.
+type ConfigProviderKeyRequest struct {
+ Scope config.Scope `json:"scope"`
+ ProviderID string `json:"provider_id"`
+ APIKey any `json:"api_key"`
+}
+
+// ConfigRefreshOAuthRequest represents a request to refresh an OAuth token.
+type ConfigRefreshOAuthRequest struct {
+ Scope config.Scope `json:"scope"`
+ ProviderID string `json:"provider_id"`
+}
+
+// ImportCopilotResponse represents the response from importing Copilot credentials.
+type ImportCopilotResponse struct {
+ Token any `json:"token"`
+ Success bool `json:"success"`
+}
+
+// ProjectNeedsInitResponse represents whether a project needs initialization.
+type ProjectNeedsInitResponse struct {
+ NeedsInit bool `json:"needs_init"`
+}
+
+// ProjectInitPromptResponse represents the project initialization prompt.
+type ProjectInitPromptResponse struct {
+ Prompt string `json:"prompt"`
+}
+
+// LSPStartRequest represents a request to start an LSP for a path.
+type LSPStartRequest struct {
+ Path string `json:"path"`
+}
+
+// FileTrackerReadRequest represents a request to record a file read.
+type FileTrackerReadRequest struct {
+ SessionID string `json:"session_id"`
+ Path string `json:"path"`
+}
+
+// MCPNameRequest represents a request targeting a named MCP server.
+type MCPNameRequest struct {
+ Name string `json:"name"`
+}
+
+// MCPReadResourceRequest represents a request to read an MCP resource.
+type MCPReadResourceRequest struct {
+ Name string `json:"name"`
+ URI string `json:"uri"`
+}
+
+// MCPGetPromptRequest represents a request to get an MCP prompt.
+type MCPGetPromptRequest struct {
+ ClientID string `json:"client_id"`
+ PromptID string `json:"prompt_id"`
+ Args map[string]string `json:"args"`
+}
+
+// MCPGetPromptResponse represents the response from getting an MCP prompt.
+type MCPGetPromptResponse struct {
+ Prompt string `json:"prompt"`
+}
@@ -0,0 +1,6 @@
+package proto
+
+// ServerControl represents a server control request.
+type ServerControl struct {
+ Command string `json:"command"`
+}
@@ -0,0 +1,15 @@
+package proto
+
+// Session represents a session in the proto layer.
+type Session struct {
+ ID string `json:"id"`
+ ParentSessionID string `json:"parent_session_id"`
+ Title string `json:"title"`
+ MessageCount int64 `json:"message_count"`
+ PromptTokens int64 `json:"prompt_tokens"`
+ CompletionTokens int64 `json:"completion_tokens"`
+ SummaryMessageID string `json:"summary_message_id"`
+ Cost float64 `json:"cost"`
+ CreatedAt int64 `json:"created_at"`
+ UpdatedAt int64 `json:"updated_at"`
+}
@@ -0,0 +1,250 @@
+package proto
+
+// ToolResponseType represents the type of tool response.
+type ToolResponseType string
+
+const (
+ ToolResponseTypeText ToolResponseType = "text"
+ ToolResponseTypeImage ToolResponseType = "image"
+)
+
+// ToolResponse represents a response from a tool.
+type ToolResponse struct {
+ Type ToolResponseType `json:"type"`
+ Content string `json:"content"`
+ Metadata string `json:"metadata,omitempty"`
+ IsError bool `json:"is_error"`
+}
+
+const BashToolName = "bash"
+
+// BashParams represents the parameters for the bash tool.
+type BashParams struct {
+ Command string `json:"command"`
+ Timeout int `json:"timeout"`
+}
+
+// BashPermissionsParams represents the permission parameters for the bash tool.
+type BashPermissionsParams struct {
+ Command string `json:"command"`
+ Timeout int `json:"timeout"`
+}
+
+// BashResponseMetadata represents the metadata for a bash tool response.
+type BashResponseMetadata struct {
+ StartTime int64 `json:"start_time"`
+ EndTime int64 `json:"end_time"`
+ Output string `json:"output"`
+ WorkingDirectory string `json:"working_directory"`
+}
+
+// DiagnosticsParams represents the parameters for the diagnostics tool.
+type DiagnosticsParams struct {
+ FilePath string `json:"file_path"`
+}
+
+const DownloadToolName = "download"
+
+// DownloadParams represents the parameters for the download tool.
+type DownloadParams struct {
+ URL string `json:"url"`
+ FilePath string `json:"file_path"`
+ Timeout int `json:"timeout,omitempty"`
+}
+
+// DownloadPermissionsParams represents the permission parameters for the download tool.
+type DownloadPermissionsParams struct {
+ URL string `json:"url"`
+ FilePath string `json:"file_path"`
+ Timeout int `json:"timeout,omitempty"`
+}
+
+const EditToolName = "edit"
+
+// EditParams represents the parameters for the edit tool.
+type EditParams struct {
+ FilePath string `json:"file_path"`
+ OldString string `json:"old_string"`
+ NewString string `json:"new_string"`
+ ReplaceAll bool `json:"replace_all,omitempty"`
+}
+
+// EditPermissionsParams represents the permission parameters for the edit tool.
+type EditPermissionsParams struct {
+ FilePath string `json:"file_path"`
+ OldContent string `json:"old_content,omitempty"`
+ NewContent string `json:"new_content,omitempty"`
+}
+
+// EditResponseMetadata represents the metadata for an edit tool response.
+type EditResponseMetadata struct {
+ Additions int `json:"additions"`
+ Removals int `json:"removals"`
+ OldContent string `json:"old_content,omitempty"`
+ NewContent string `json:"new_content,omitempty"`
+}
+
+const FetchToolName = "fetch"
+
+// FetchParams represents the parameters for the fetch tool.
+type FetchParams struct {
+ URL string `json:"url"`
+ Format string `json:"format"`
+ Timeout int `json:"timeout,omitempty"`
+}
+
+// FetchPermissionsParams represents the permission parameters for the fetch tool.
+type FetchPermissionsParams struct {
+ URL string `json:"url"`
+ Format string `json:"format"`
+ Timeout int `json:"timeout,omitempty"`
+}
+
+const GlobToolName = "glob"
+
+// GlobParams represents the parameters for the glob tool.
+type GlobParams struct {
+ Pattern string `json:"pattern"`
+ Path string `json:"path"`
+}
+
+// GlobResponseMetadata represents the metadata for a glob tool response.
+type GlobResponseMetadata struct {
+ NumberOfFiles int `json:"number_of_files"`
+ Truncated bool `json:"truncated"`
+}
+
+const GrepToolName = "grep"
+
+// GrepParams represents the parameters for the grep tool.
+type GrepParams struct {
+ Pattern string `json:"pattern"`
+ Path string `json:"path"`
+ Include string `json:"include"`
+ LiteralText bool `json:"literal_text"`
+}
+
+// GrepResponseMetadata represents the metadata for a grep tool response.
+type GrepResponseMetadata struct {
+ NumberOfMatches int `json:"number_of_matches"`
+ Truncated bool `json:"truncated"`
+}
+
+const LSToolName = "ls"
+
+// LSParams represents the parameters for the ls tool.
+type LSParams struct {
+ Path string `json:"path"`
+ Ignore []string `json:"ignore"`
+}
+
+// LSPermissionsParams represents the permission parameters for the ls tool.
+type LSPermissionsParams struct {
+ Path string `json:"path"`
+ Ignore []string `json:"ignore"`
+}
+
+// TreeNode represents a node in a directory tree.
+type TreeNode struct {
+ Name string `json:"name"`
+ Path string `json:"path"`
+ Type string `json:"type"`
+ Children []*TreeNode `json:"children,omitempty"`
+}
+
+// LSResponseMetadata represents the metadata for an ls tool response.
+type LSResponseMetadata struct {
+ NumberOfFiles int `json:"number_of_files"`
+ Truncated bool `json:"truncated"`
+}
+
+const MultiEditToolName = "multiedit"
+
+// MultiEditOperation represents a single edit operation in a multi-edit.
+type MultiEditOperation struct {
+ OldString string `json:"old_string"`
+ NewString string `json:"new_string"`
+ ReplaceAll bool `json:"replace_all,omitempty"`
+}
+
+// MultiEditParams represents the parameters for the multi-edit tool.
+type MultiEditParams struct {
+ FilePath string `json:"file_path"`
+ Edits []MultiEditOperation `json:"edits"`
+}
+
+// MultiEditPermissionsParams represents the permission parameters for the multi-edit tool.
+type MultiEditPermissionsParams struct {
+ FilePath string `json:"file_path"`
+ OldContent string `json:"old_content,omitempty"`
+ NewContent string `json:"new_content,omitempty"`
+}
+
+// MultiEditResponseMetadata represents the metadata for a multi-edit tool response.
+type MultiEditResponseMetadata struct {
+ Additions int `json:"additions"`
+ Removals int `json:"removals"`
+ OldContent string `json:"old_content,omitempty"`
+ NewContent string `json:"new_content,omitempty"`
+ EditsApplied int `json:"edits_applied"`
+}
+
+const SourcegraphToolName = "sourcegraph"
+
+// SourcegraphParams represents the parameters for the sourcegraph tool.
+type SourcegraphParams struct {
+ Query string `json:"query"`
+ Count int `json:"count,omitempty"`
+ ContextWindow int `json:"context_window,omitempty"`
+ Timeout int `json:"timeout,omitempty"`
+}
+
+// SourcegraphResponseMetadata represents the metadata for a sourcegraph tool response.
+type SourcegraphResponseMetadata struct {
+ NumberOfMatches int `json:"number_of_matches"`
+ Truncated bool `json:"truncated"`
+}
+
+const ViewToolName = "view"
+
+// ViewParams represents the parameters for the view tool.
+type ViewParams struct {
+ FilePath string `json:"file_path"`
+ Offset int `json:"offset"`
+ Limit int `json:"limit"`
+}
+
+// ViewPermissionsParams represents the permission parameters for the view tool.
+type ViewPermissionsParams struct {
+ FilePath string `json:"file_path"`
+ Offset int `json:"offset"`
+ Limit int `json:"limit"`
+}
+
+// ViewResponseMetadata represents the metadata for a view tool response.
+type ViewResponseMetadata struct {
+ FilePath string `json:"file_path"`
+ Content string `json:"content"`
+}
+
+const WriteToolName = "write"
+
+// WriteParams represents the parameters for the write tool.
+type WriteParams struct {
+ FilePath string `json:"file_path"`
+ Content string `json:"content"`
+}
+
+// WritePermissionsParams represents the permission parameters for the write tool.
+type WritePermissionsParams struct {
+ FilePath string `json:"file_path"`
+ OldContent string `json:"old_content,omitempty"`
+ NewContent string `json:"new_content,omitempty"`
+}
+
+// WriteResponseMetadata represents the metadata for a write tool response.
+type WriteResponseMetadata struct {
+ Diff string `json:"diff"`
+ Additions int `json:"additions"`
+ Removals int `json:"removals"`
+}
@@ -0,0 +1,9 @@
+package proto
+
+// VersionInfo represents version information about the server.
+type VersionInfo struct {
+ Version string `json:"version"`
+ Commit string `json:"commit"`
+ GoVersion string `json:"go_version"`
+ Platform string `json:"platform"`
+}
@@ -1,6 +1,9 @@
package pubsub
-import "context"
+import (
+ "context"
+ "encoding/json"
+)
const (
CreatedEvent EventType = "created"
@@ -8,20 +11,43 @@ const (
DeletedEvent EventType = "deleted"
)
+// PayloadType identifies the type of event payload for discriminated
+// deserialization over JSON.
+type PayloadType = string
+
+const (
+ PayloadTypeLSPEvent PayloadType = "lsp_event"
+ PayloadTypeMCPEvent PayloadType = "mcp_event"
+ PayloadTypePermissionRequest PayloadType = "permission_request"
+ PayloadTypePermissionNotification PayloadType = "permission_notification"
+ PayloadTypeMessage PayloadType = "message"
+ PayloadTypeSession PayloadType = "session"
+ PayloadTypeFile PayloadType = "file"
+ PayloadTypeAgentEvent PayloadType = "agent_event"
+)
+
+// Payload wraps a discriminated JSON payload with a type tag.
+type Payload struct {
+ Type PayloadType `json:"type"`
+ Payload json.RawMessage `json:"payload"`
+}
+
+// Subscriber can subscribe to events of type T.
type Subscriber[T any] interface {
Subscribe(context.Context) <-chan Event[T]
}
type (
- // EventType identifies the type of event
+ // EventType identifies the type of event.
EventType string
- // Event represents an event in the lifecycle of a resource
+ // Event represents an event in the lifecycle of a resource.
Event[T any] struct {
- Type EventType
- Payload T
+ Type EventType `json:"type"`
+ Payload T `json:"payload"`
}
+ // Publisher can publish events of type T.
Publisher[T any] interface {
Publish(EventType, T)
}
@@ -0,0 +1,467 @@
+package server
+
+import (
+ "encoding/json"
+ "net/http"
+
+ "github.com/charmbracelet/crush/internal/proto"
+)
+
+// handlePostWorkspaceConfigSet sets a configuration field.
+//
+// @Summary Set a config field
+// @Tags config
+// @Accept json
+// @Param id path string true "Workspace ID"
+// @Param request body proto.ConfigSetRequest true "Config set request"
+// @Success 200
+// @Failure 400 {object} proto.Error
+// @Failure 404 {object} proto.Error
+// @Failure 500 {object} proto.Error
+// @Router /workspaces/{id}/config/set [post]
+func (c *controllerV1) handlePostWorkspaceConfigSet(w http.ResponseWriter, r *http.Request) {
+ id := r.PathValue("id")
+
+ var req proto.ConfigSetRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ c.server.logError(r, "Failed to decode request", "error", err)
+ jsonError(w, http.StatusBadRequest, "failed to decode request")
+ return
+ }
+
+ if err := c.backend.SetConfigField(id, req.Scope, req.Key, req.Value); err != nil {
+ c.handleError(w, r, err)
+ return
+ }
+ w.WriteHeader(http.StatusOK)
+}
+
+// handlePostWorkspaceConfigRemove removes a configuration field.
+//
+// @Summary Remove a config field
+// @Tags config
+// @Accept json
+// @Param id path string true "Workspace ID"
+// @Param request body proto.ConfigRemoveRequest true "Config remove request"
+// @Success 200
+// @Failure 400 {object} proto.Error
+// @Failure 404 {object} proto.Error
+// @Failure 500 {object} proto.Error
+// @Router /workspaces/{id}/config/remove [post]
+func (c *controllerV1) handlePostWorkspaceConfigRemove(w http.ResponseWriter, r *http.Request) {
+ id := r.PathValue("id")
+
+ var req proto.ConfigRemoveRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ c.server.logError(r, "Failed to decode request", "error", err)
+ jsonError(w, http.StatusBadRequest, "failed to decode request")
+ return
+ }
+
+ if err := c.backend.RemoveConfigField(id, req.Scope, req.Key); err != nil {
+ c.handleError(w, r, err)
+ return
+ }
+ w.WriteHeader(http.StatusOK)
+}
+
+// handlePostWorkspaceConfigModel updates the preferred model.
+//
+// @Summary Set the preferred model
+// @Tags config
+// @Accept json
+// @Param id path string true "Workspace ID"
+// @Param request body proto.ConfigModelRequest true "Config model request"
+// @Success 200
+// @Failure 400 {object} proto.Error
+// @Failure 404 {object} proto.Error
+// @Failure 500 {object} proto.Error
+// @Router /workspaces/{id}/config/model [post]
+func (c *controllerV1) handlePostWorkspaceConfigModel(w http.ResponseWriter, r *http.Request) {
+ id := r.PathValue("id")
+
+ var req proto.ConfigModelRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ c.server.logError(r, "Failed to decode request", "error", err)
+ jsonError(w, http.StatusBadRequest, "failed to decode request")
+ return
+ }
+
+ if err := c.backend.UpdatePreferredModel(id, req.Scope, req.ModelType, req.Model); err != nil {
+ c.handleError(w, r, err)
+ return
+ }
+ w.WriteHeader(http.StatusOK)
+}
+
+// handlePostWorkspaceConfigCompact sets compact mode.
+//
+// @Summary Set compact mode
+// @Tags config
+// @Accept json
+// @Param id path string true "Workspace ID"
+// @Param request body proto.ConfigCompactRequest true "Config compact request"
+// @Success 200
+// @Failure 400 {object} proto.Error
+// @Failure 404 {object} proto.Error
+// @Failure 500 {object} proto.Error
+// @Router /workspaces/{id}/config/compact [post]
+func (c *controllerV1) handlePostWorkspaceConfigCompact(w http.ResponseWriter, r *http.Request) {
+ id := r.PathValue("id")
+
+ var req proto.ConfigCompactRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ c.server.logError(r, "Failed to decode request", "error", err)
+ jsonError(w, http.StatusBadRequest, "failed to decode request")
+ return
+ }
+
+ if err := c.backend.SetCompactMode(id, req.Scope, req.Enabled); err != nil {
+ c.handleError(w, r, err)
+ return
+ }
+ w.WriteHeader(http.StatusOK)
+}
+
+// handlePostWorkspaceConfigProviderKey sets a provider API key.
+//
+// @Summary Set provider API key
+// @Tags config
+// @Accept json
+// @Param id path string true "Workspace ID"
+// @Param request body proto.ConfigProviderKeyRequest true "Config provider key request"
+// @Success 200
+// @Failure 400 {object} proto.Error
+// @Failure 404 {object} proto.Error
+// @Failure 500 {object} proto.Error
+// @Router /workspaces/{id}/config/provider-key [post]
+func (c *controllerV1) handlePostWorkspaceConfigProviderKey(w http.ResponseWriter, r *http.Request) {
+ id := r.PathValue("id")
+
+ var req proto.ConfigProviderKeyRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ c.server.logError(r, "Failed to decode request", "error", err)
+ jsonError(w, http.StatusBadRequest, "failed to decode request")
+ return
+ }
+
+ if err := c.backend.SetProviderAPIKey(id, req.Scope, req.ProviderID, req.APIKey); err != nil {
+ c.handleError(w, r, err)
+ return
+ }
+ w.WriteHeader(http.StatusOK)
+}
+
+// handlePostWorkspaceConfigImportCopilot imports Copilot credentials.
+//
+// @Summary Import Copilot credentials
+// @Tags config
+// @Produce json
+// @Param id path string true "Workspace ID"
+// @Success 200 {object} proto.ImportCopilotResponse
+// @Failure 404 {object} proto.Error
+// @Failure 500 {object} proto.Error
+// @Router /workspaces/{id}/config/import-copilot [post]
+func (c *controllerV1) handlePostWorkspaceConfigImportCopilot(w http.ResponseWriter, r *http.Request) {
+ id := r.PathValue("id")
+ token, ok, err := c.backend.ImportCopilot(id)
+ if err != nil {
+ c.handleError(w, r, err)
+ return
+ }
+ jsonEncode(w, proto.ImportCopilotResponse{Token: token, Success: ok})
+}
+
+// handlePostWorkspaceConfigRefreshOAuth refreshes an OAuth token for a provider.
+//
+// @Summary Refresh OAuth token
+// @Tags config
+// @Accept json
+// @Param id path string true "Workspace ID"
+// @Param request body proto.ConfigRefreshOAuthRequest true "Refresh OAuth request"
+// @Success 200
+// @Failure 400 {object} proto.Error
+// @Failure 404 {object} proto.Error
+// @Failure 500 {object} proto.Error
+// @Router /workspaces/{id}/config/refresh-oauth [post]
+func (c *controllerV1) handlePostWorkspaceConfigRefreshOAuth(w http.ResponseWriter, r *http.Request) {
+ id := r.PathValue("id")
+
+ var req proto.ConfigRefreshOAuthRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ c.server.logError(r, "Failed to decode request", "error", err)
+ jsonError(w, http.StatusBadRequest, "failed to decode request")
+ return
+ }
+
+ if err := c.backend.RefreshOAuthToken(r.Context(), id, req.Scope, req.ProviderID); err != nil {
+ c.handleError(w, r, err)
+ return
+ }
+ w.WriteHeader(http.StatusOK)
+}
+
+// handleGetWorkspaceProjectNeedsInit reports whether a project needs initialization.
+//
+// @Summary Check if project needs initialization
+// @Tags project
+// @Produce json
+// @Param id path string true "Workspace ID"
+// @Success 200 {object} proto.ProjectNeedsInitResponse
+// @Failure 404 {object} proto.Error
+// @Failure 500 {object} proto.Error
+// @Router /workspaces/{id}/project/needs-init [get]
+func (c *controllerV1) handleGetWorkspaceProjectNeedsInit(w http.ResponseWriter, r *http.Request) {
+ id := r.PathValue("id")
+ needs, err := c.backend.ProjectNeedsInitialization(id)
+ if err != nil {
+ c.handleError(w, r, err)
+ return
+ }
+ jsonEncode(w, proto.ProjectNeedsInitResponse{NeedsInit: needs})
+}
+
+// handlePostWorkspaceProjectInit marks the project as initialized.
+//
+// @Summary Mark project as initialized
+// @Tags project
+// @Param id path string true "Workspace ID"
+// @Success 200
+// @Failure 404 {object} proto.Error
+// @Failure 500 {object} proto.Error
+// @Router /workspaces/{id}/project/init [post]
+func (c *controllerV1) handlePostWorkspaceProjectInit(w http.ResponseWriter, r *http.Request) {
+ id := r.PathValue("id")
+ if err := c.backend.MarkProjectInitialized(id); err != nil {
+ c.handleError(w, r, err)
+ return
+ }
+ w.WriteHeader(http.StatusOK)
+}
+
+// handleGetWorkspaceProjectInitPrompt returns the project initialization prompt.
+//
+// @Summary Get project initialization prompt
+// @Tags project
+// @Produce json
+// @Param id path string true "Workspace ID"
+// @Success 200 {object} proto.ProjectInitPromptResponse
+// @Failure 404 {object} proto.Error
+// @Failure 500 {object} proto.Error
+// @Router /workspaces/{id}/project/init-prompt [get]
+func (c *controllerV1) handleGetWorkspaceProjectInitPrompt(w http.ResponseWriter, r *http.Request) {
+ id := r.PathValue("id")
+ prompt, err := c.backend.InitializePrompt(id)
+ if err != nil {
+ c.handleError(w, r, err)
+ return
+ }
+ 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
+// @Tags mcp
+// @Accept json
+// @Param id path string true "Workspace ID"
+// @Param request body proto.MCPNameRequest true "MCP name request"
+// @Success 200
+// @Failure 400 {object} proto.Error
+// @Failure 404 {object} proto.Error
+// @Failure 500 {object} proto.Error
+// @Router /workspaces/{id}/mcp/refresh-tools [post]
+func (c *controllerV1) handlePostWorkspaceMCPRefreshTools(w http.ResponseWriter, r *http.Request) {
+ id := r.PathValue("id")
+
+ var req proto.MCPNameRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ c.server.logError(r, "Failed to decode request", "error", err)
+ jsonError(w, http.StatusBadRequest, "failed to decode request")
+ return
+ }
+
+ if err := c.backend.RefreshMCPTools(r.Context(), id, req.Name); err != nil {
+ c.handleError(w, r, err)
+ return
+ }
+ w.WriteHeader(http.StatusOK)
+}
+
+// handlePostWorkspaceMCPReadResource reads a resource from an MCP server.
+//
+// @Summary Read MCP resource
+// @Tags mcp
+// @Accept json
+// @Produce json
+// @Param id path string true "Workspace ID"
+// @Param request body proto.MCPReadResourceRequest true "MCP read resource request"
+// @Success 200 {object} object
+// @Failure 400 {object} proto.Error
+// @Failure 404 {object} proto.Error
+// @Failure 500 {object} proto.Error
+// @Router /workspaces/{id}/mcp/read-resource [post]
+func (c *controllerV1) handlePostWorkspaceMCPReadResource(w http.ResponseWriter, r *http.Request) {
+ id := r.PathValue("id")
+
+ var req proto.MCPReadResourceRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ c.server.logError(r, "Failed to decode request", "error", err)
+ jsonError(w, http.StatusBadRequest, "failed to decode request")
+ return
+ }
+
+ contents, err := c.backend.ReadMCPResource(r.Context(), id, req.Name, req.URI)
+ if err != nil {
+ c.handleError(w, r, err)
+ return
+ }
+ jsonEncode(w, contents)
+}
+
+// handlePostWorkspaceMCPGetPrompt retrieves a prompt from an MCP server.
+//
+// @Summary Get MCP prompt
+// @Tags mcp
+// @Accept json
+// @Produce json
+// @Param id path string true "Workspace ID"
+// @Param request body proto.MCPGetPromptRequest true "MCP get prompt request"
+// @Success 200 {object} proto.MCPGetPromptResponse
+// @Failure 400 {object} proto.Error
+// @Failure 404 {object} proto.Error
+// @Failure 500 {object} proto.Error
+// @Router /workspaces/{id}/mcp/get-prompt [post]
+func (c *controllerV1) handlePostWorkspaceMCPGetPrompt(w http.ResponseWriter, r *http.Request) {
+ id := r.PathValue("id")
+
+ var req proto.MCPGetPromptRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ c.server.logError(r, "Failed to decode request", "error", err)
+ jsonError(w, http.StatusBadRequest, "failed to decode request")
+ return
+ }
+
+ prompt, err := c.backend.GetMCPPrompt(id, req.ClientID, req.PromptID, req.Args)
+ if err != nil {
+ c.handleError(w, r, err)
+ return
+ }
+ jsonEncode(w, proto.MCPGetPromptResponse{Prompt: prompt})
+}
+
+// handleGetWorkspaceMCPStates returns the state of all MCP clients.
+//
+// @Summary Get MCP client states
+// @Tags mcp
+// @Produce json
+// @Param id path string true "Workspace ID"
+// @Success 200 {object} map[string]proto.MCPClientInfo
+// @Failure 404 {object} proto.Error
+// @Failure 500 {object} proto.Error
+// @Router /workspaces/{id}/mcp/states [get]
+func (c *controllerV1) handleGetWorkspaceMCPStates(w http.ResponseWriter, r *http.Request) {
+ id := r.PathValue("id")
+ states := c.backend.MCPGetStates(id)
+ result := make(map[string]proto.MCPClientInfo, len(states))
+ for k, v := range states {
+ result[k] = proto.MCPClientInfo{
+ Name: v.Name,
+ State: proto.MCPState(v.State),
+ Error: v.Error,
+ ToolCount: v.Counts.Tools,
+ PromptCount: v.Counts.Prompts,
+ ResourceCount: v.Counts.Resources,
+ ConnectedAt: v.ConnectedAt,
+ }
+ }
+ jsonEncode(w, result)
+}
+
+// handlePostWorkspaceMCPRefreshPrompts refreshes prompts for a named MCP server.
+//
+// @Summary Refresh MCP prompts
+// @Tags mcp
+// @Accept json
+// @Param id path string true "Workspace ID"
+// @Param request body proto.MCPNameRequest true "MCP name request"
+// @Success 200
+// @Failure 400 {object} proto.Error
+// @Failure 404 {object} proto.Error
+// @Failure 500 {object} proto.Error
+// @Router /workspaces/{id}/mcp/refresh-prompts [post]
+func (c *controllerV1) handlePostWorkspaceMCPRefreshPrompts(w http.ResponseWriter, r *http.Request) {
+ id := r.PathValue("id")
+
+ var req proto.MCPNameRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ c.server.logError(r, "Failed to decode request", "error", err)
+ jsonError(w, http.StatusBadRequest, "failed to decode request")
+ return
+ }
+
+ c.backend.MCPRefreshPrompts(r.Context(), id, req.Name)
+ w.WriteHeader(http.StatusOK)
+}
+
+// handlePostWorkspaceMCPRefreshResources refreshes resources for a named MCP server.
+//
+// @Summary Refresh MCP resources
+// @Tags mcp
+// @Accept json
+// @Param id path string true "Workspace ID"
+// @Param request body proto.MCPNameRequest true "MCP name request"
+// @Success 200
+// @Failure 400 {object} proto.Error
+// @Failure 404 {object} proto.Error
+// @Failure 500 {object} proto.Error
+// @Router /workspaces/{id}/mcp/refresh-resources [post]
+func (c *controllerV1) handlePostWorkspaceMCPRefreshResources(w http.ResponseWriter, r *http.Request) {
+ id := r.PathValue("id")
+
+ var req proto.MCPNameRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ c.server.logError(r, "Failed to decode request", "error", err)
+ jsonError(w, http.StatusBadRequest, "failed to decode request")
+ return
+ }
+
+ c.backend.MCPRefreshResources(r.Context(), id, req.Name)
+ w.WriteHeader(http.StatusOK)
+}
@@ -0,0 +1,214 @@
+package server
+
+import (
+ "encoding/json"
+ "fmt"
+ "log/slog"
+
+ "github.com/charmbracelet/crush/internal/agent/notify"
+ "github.com/charmbracelet/crush/internal/agent/tools/mcp"
+ "github.com/charmbracelet/crush/internal/app"
+ "github.com/charmbracelet/crush/internal/history"
+ "github.com/charmbracelet/crush/internal/message"
+ "github.com/charmbracelet/crush/internal/permission"
+ "github.com/charmbracelet/crush/internal/proto"
+ "github.com/charmbracelet/crush/internal/pubsub"
+ "github.com/charmbracelet/crush/internal/session"
+)
+
+// wrapEvent converts a raw tea.Msg (a pubsub.Event[T] from the app
+// event fan-in) into a pubsub.Payload envelope with the correct
+// PayloadType discriminator and a proto-typed inner payload that has
+// proper JSON tags. Returns nil if the event type is unrecognized.
+func wrapEvent(ev any) *pubsub.Payload {
+ switch e := ev.(type) {
+ case pubsub.Event[app.LSPEvent]:
+ return envelope(pubsub.PayloadTypeLSPEvent, pubsub.Event[proto.LSPEvent]{
+ Type: e.Type,
+ Payload: proto.LSPEvent{
+ Type: proto.LSPEventType(e.Payload.Type),
+ Name: e.Payload.Name,
+ State: e.Payload.State,
+ Error: e.Payload.Error,
+ DiagnosticCount: e.Payload.DiagnosticCount,
+ },
+ })
+ case pubsub.Event[mcp.Event]:
+ return envelope(pubsub.PayloadTypeMCPEvent, pubsub.Event[proto.MCPEvent]{
+ Type: e.Type,
+ Payload: proto.MCPEvent{
+ Type: mcpEventTypeToProto(e.Payload.Type),
+ Name: e.Payload.Name,
+ State: proto.MCPState(e.Payload.State),
+ Error: e.Payload.Error,
+ ToolCount: e.Payload.Counts.Tools,
+ },
+ })
+ case pubsub.Event[permission.PermissionRequest]:
+ return envelope(pubsub.PayloadTypePermissionRequest, pubsub.Event[proto.PermissionRequest]{
+ Type: e.Type,
+ Payload: proto.PermissionRequest{
+ ID: e.Payload.ID,
+ SessionID: e.Payload.SessionID,
+ ToolCallID: e.Payload.ToolCallID,
+ ToolName: e.Payload.ToolName,
+ Description: e.Payload.Description,
+ Action: e.Payload.Action,
+ Path: e.Payload.Path,
+ Params: e.Payload.Params,
+ },
+ })
+ case pubsub.Event[permission.PermissionNotification]:
+ return envelope(pubsub.PayloadTypePermissionNotification, pubsub.Event[proto.PermissionNotification]{
+ Type: e.Type,
+ Payload: proto.PermissionNotification{
+ ToolCallID: e.Payload.ToolCallID,
+ Granted: e.Payload.Granted,
+ Denied: e.Payload.Denied,
+ },
+ })
+ case pubsub.Event[message.Message]:
+ return envelope(pubsub.PayloadTypeMessage, pubsub.Event[proto.Message]{
+ Type: e.Type,
+ Payload: messageToProto(e.Payload),
+ })
+ case pubsub.Event[session.Session]:
+ return envelope(pubsub.PayloadTypeSession, pubsub.Event[proto.Session]{
+ Type: e.Type,
+ Payload: sessionToProto(e.Payload),
+ })
+ case pubsub.Event[history.File]:
+ return envelope(pubsub.PayloadTypeFile, pubsub.Event[proto.File]{
+ Type: e.Type,
+ Payload: fileToProto(e.Payload),
+ })
+ case pubsub.Event[notify.Notification]:
+ return envelope(pubsub.PayloadTypeAgentEvent, pubsub.Event[proto.AgentEvent]{
+ Type: e.Type,
+ Payload: proto.AgentEvent{
+ SessionID: e.Payload.SessionID,
+ SessionTitle: e.Payload.SessionTitle,
+ Type: proto.AgentEventType(e.Payload.Type),
+ },
+ })
+ default:
+ slog.Warn("Unrecognized event type for SSE wrapping", "type", fmt.Sprintf("%T", ev))
+ return nil
+ }
+}
+
+// envelope marshals the inner event and wraps it in a pubsub.Payload.
+func envelope(payloadType pubsub.PayloadType, inner any) *pubsub.Payload {
+ raw, err := json.Marshal(inner)
+ if err != nil {
+ slog.Error("Failed to marshal event payload", "error", err)
+ return nil
+ }
+ return &pubsub.Payload{
+ Type: payloadType,
+ Payload: raw,
+ }
+}
+
+func mcpEventTypeToProto(t mcp.EventType) proto.MCPEventType {
+ switch t {
+ case mcp.EventStateChanged:
+ return proto.MCPEventStateChanged
+ case mcp.EventToolsListChanged:
+ return proto.MCPEventToolsListChanged
+ case mcp.EventPromptsListChanged:
+ return proto.MCPEventPromptsListChanged
+ case mcp.EventResourcesListChanged:
+ return proto.MCPEventResourcesListChanged
+ default:
+ return proto.MCPEventStateChanged
+ }
+}
+
+func sessionToProto(s session.Session) proto.Session {
+ return proto.Session{
+ ID: s.ID,
+ ParentSessionID: s.ParentSessionID,
+ Title: s.Title,
+ SummaryMessageID: s.SummaryMessageID,
+ MessageCount: s.MessageCount,
+ PromptTokens: s.PromptTokens,
+ CompletionTokens: s.CompletionTokens,
+ Cost: s.Cost,
+ CreatedAt: s.CreatedAt,
+ UpdatedAt: s.UpdatedAt,
+ }
+}
+
+func fileToProto(f history.File) proto.File {
+ return proto.File{
+ ID: f.ID,
+ SessionID: f.SessionID,
+ Path: f.Path,
+ Content: f.Content,
+ Version: f.Version,
+ CreatedAt: f.CreatedAt,
+ UpdatedAt: f.UpdatedAt,
+ }
+}
+
+func messageToProto(m message.Message) proto.Message {
+ msg := proto.Message{
+ ID: m.ID,
+ SessionID: m.SessionID,
+ Role: proto.MessageRole(m.Role),
+ Model: m.Model,
+ Provider: m.Provider,
+ CreatedAt: m.CreatedAt,
+ UpdatedAt: m.UpdatedAt,
+ }
+
+ for _, p := range m.Parts {
+ switch v := p.(type) {
+ case message.TextContent:
+ msg.Parts = append(msg.Parts, proto.TextContent{Text: v.Text})
+ case message.ReasoningContent:
+ msg.Parts = append(msg.Parts, proto.ReasoningContent{
+ Thinking: v.Thinking,
+ Signature: v.Signature,
+ StartedAt: v.StartedAt,
+ FinishedAt: v.FinishedAt,
+ })
+ case message.ToolCall:
+ msg.Parts = append(msg.Parts, proto.ToolCall{
+ ID: v.ID,
+ Name: v.Name,
+ Input: v.Input,
+ Finished: v.Finished,
+ })
+ case message.ToolResult:
+ msg.Parts = append(msg.Parts, proto.ToolResult{
+ ToolCallID: v.ToolCallID,
+ Name: v.Name,
+ Content: v.Content,
+ IsError: v.IsError,
+ })
+ case message.Finish:
+ msg.Parts = append(msg.Parts, proto.Finish{
+ Reason: proto.FinishReason(v.Reason),
+ Time: v.Time,
+ Message: v.Message,
+ Details: v.Details,
+ })
+ case message.ImageURLContent:
+ msg.Parts = append(msg.Parts, proto.ImageURLContent{URL: v.URL, Detail: v.Detail})
+ case message.BinaryContent:
+ msg.Parts = append(msg.Parts, proto.BinaryContent{Path: v.Path, MIMEType: v.MIMEType, Data: v.Data})
+ }
+ }
+
+ return msg
+}
+
+func messagesToProto(msgs []message.Message) []proto.Message {
+ out := make([]proto.Message, len(msgs))
+ for i, m := range msgs {
+ out[i] = messageToProto(m)
+ }
+ return out
+}
@@ -0,0 +1,51 @@
+package server
+
+import (
+ "log/slog"
+ "net/http"
+ "time"
+)
+
+func (s *Server) loggingHandler(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if s.logger == nil {
+ next.ServeHTTP(w, r)
+ return
+ }
+
+ start := time.Now()
+ lrw := &loggingResponseWriter{ResponseWriter: w, statusCode: http.StatusOK}
+ s.logger.Debug("HTTP request",
+ slog.String("method", r.Method),
+ slog.String("path", r.URL.Path),
+ slog.String("remote_addr", r.RemoteAddr),
+ slog.String("user_agent", r.UserAgent()),
+ )
+
+ next.ServeHTTP(lrw, r)
+ duration := time.Since(start)
+
+ s.logger.Debug("HTTP response",
+ slog.String("method", r.Method),
+ slog.String("path", r.URL.Path),
+ slog.Int("status", lrw.statusCode),
+ slog.Duration("duration", duration),
+ slog.String("remote_addr", r.RemoteAddr),
+ slog.String("user_agent", r.UserAgent()),
+ )
+ })
+}
+
+type loggingResponseWriter struct {
+ http.ResponseWriter
+ statusCode int
+}
+
+func (lrw *loggingResponseWriter) WriteHeader(code int) {
+ lrw.statusCode = code
+ lrw.ResponseWriter.WriteHeader(code)
+}
+
+func (lrw *loggingResponseWriter) Unwrap() http.ResponseWriter {
+ return lrw.ResponseWriter
+}
@@ -0,0 +1,11 @@
+//go:build !windows
+// +build !windows
+
+package server
+
+import "net"
+
+func listen(network, address string) (net.Listener, error) {
+ //nolint:noctx
+ return net.Listen(network, address)
+}
@@ -0,0 +1,24 @@
+//go:build windows
+// +build windows
+
+package server
+
+import (
+ "net"
+
+ "github.com/Microsoft/go-winio"
+)
+
+func listen(network, address string) (net.Listener, error) {
+ switch network {
+ case "npipe":
+ cfg := &winio.PipeConfig{
+ MessageMode: true,
+ InputBufferSize: 65536,
+ OutputBufferSize: 65536,
+ }
+ return winio.ListenPipe(address, cfg)
+ default:
+ return net.Listen(network, address) //nolint:noctx
+ }
+}
@@ -0,0 +1,969 @@
+package server
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "net/http"
+
+ "github.com/charmbracelet/crush/internal/backend"
+ "github.com/charmbracelet/crush/internal/proto"
+ "github.com/charmbracelet/crush/internal/session"
+)
+
+type controllerV1 struct {
+ backend *backend.Backend
+ server *Server
+}
+
+// handleGetHealth checks server health.
+//
+// @Summary Health check
+// @Tags system
+// @Success 200
+// @Router /health [get]
+func (c *controllerV1) handleGetHealth(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusOK)
+}
+
+// handleGetVersion returns server version information.
+//
+// @Summary Get server version
+// @Tags system
+// @Produce json
+// @Success 200 {object} proto.VersionInfo
+// @Router /version [get]
+func (c *controllerV1) handleGetVersion(w http.ResponseWriter, _ *http.Request) {
+ jsonEncode(w, c.backend.VersionInfo())
+}
+
+// handlePostControl sends a control command to the server.
+//
+// @Summary Send server control command
+// @Tags system
+// @Accept json
+// @Param request body proto.ServerControl true "Control command (e.g. shutdown)"
+// @Success 200
+// @Failure 400 {object} proto.Error
+// @Router /control [post]
+func (c *controllerV1) handlePostControl(w http.ResponseWriter, r *http.Request) {
+ var req proto.ServerControl
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ c.server.logError(r, "Failed to decode request", "error", err)
+ jsonError(w, http.StatusBadRequest, "failed to decode request")
+ return
+ }
+
+ switch req.Command {
+ case "shutdown":
+ c.backend.Shutdown()
+ default:
+ c.server.logError(r, "Unknown command", "command", req.Command)
+ jsonError(w, http.StatusBadRequest, "unknown command")
+ return
+ }
+}
+
+// handleGetConfig returns global server configuration.
+//
+// @Summary Get server config
+// @Tags system
+// @Produce json
+// @Success 200 {object} object
+// @Router /config [get]
+func (c *controllerV1) handleGetConfig(w http.ResponseWriter, _ *http.Request) {
+ jsonEncode(w, c.backend.Config())
+}
+
+// handleGetWorkspaces lists all workspaces.
+//
+// @Summary List workspaces
+// @Tags workspaces
+// @Produce json
+// @Success 200 {array} proto.Workspace
+// @Router /workspaces [get]
+func (c *controllerV1) handleGetWorkspaces(w http.ResponseWriter, _ *http.Request) {
+ jsonEncode(w, c.backend.ListWorkspaces())
+}
+
+// handleGetWorkspace returns a single workspace by ID.
+//
+// @Summary Get workspace
+// @Tags workspaces
+// @Produce json
+// @Param id path string true "Workspace ID"
+// @Success 200 {object} proto.Workspace
+// @Failure 404 {object} proto.Error
+// @Failure 500 {object} proto.Error
+// @Router /workspaces/{id} [get]
+func (c *controllerV1) handleGetWorkspace(w http.ResponseWriter, r *http.Request) {
+ id := r.PathValue("id")
+ ws, err := c.backend.GetWorkspaceProto(id)
+ if err != nil {
+ c.handleError(w, r, err)
+ return
+ }
+ jsonEncode(w, ws)
+}
+
+// handlePostWorkspaces creates a new workspace.
+//
+// @Summary Create workspace
+// @Tags workspaces
+// @Accept json
+// @Produce json
+// @Param request body proto.Workspace true "Workspace creation params"
+// @Success 200 {object} proto.Workspace
+// @Failure 400 {object} proto.Error
+// @Failure 500 {object} proto.Error
+// @Router /workspaces [post]
+func (c *controllerV1) handlePostWorkspaces(w http.ResponseWriter, r *http.Request) {
+ var args proto.Workspace
+ if err := json.NewDecoder(r.Body).Decode(&args); err != nil {
+ c.server.logError(r, "Failed to decode request", "error", err)
+ jsonError(w, http.StatusBadRequest, "failed to decode request")
+ return
+ }
+
+ _, result, err := c.backend.CreateWorkspace(args)
+ if err != nil {
+ c.handleError(w, r, err)
+ return
+ }
+ jsonEncode(w, result)
+}
+
+// handleDeleteWorkspaces deletes a workspace.
+//
+// @Summary Delete workspace
+// @Tags workspaces
+// @Param id path string true "Workspace ID"
+// @Success 200
+// @Failure 404 {object} proto.Error
+// @Router /workspaces/{id} [delete]
+func (c *controllerV1) handleDeleteWorkspaces(w http.ResponseWriter, r *http.Request) {
+ id := r.PathValue("id")
+ c.backend.DeleteWorkspace(id)
+}
+
+// handleGetWorkspaceConfig returns workspace configuration.
+//
+// @Summary Get workspace config
+// @Tags workspaces
+// @Produce json
+// @Param id path string true "Workspace ID"
+// @Success 200 {object} object
+// @Failure 404 {object} proto.Error
+// @Failure 500 {object} proto.Error
+// @Router /workspaces/{id}/config [get]
+func (c *controllerV1) handleGetWorkspaceConfig(w http.ResponseWriter, r *http.Request) {
+ id := r.PathValue("id")
+ cfg, err := c.backend.GetWorkspaceConfig(id)
+ if err != nil {
+ c.handleError(w, r, err)
+ return
+ }
+ jsonEncode(w, cfg)
+}
+
+// handleGetWorkspaceProviders lists available providers for a workspace.
+//
+// @Summary Get workspace providers
+// @Tags workspaces
+// @Produce json
+// @Param id path string true "Workspace ID"
+// @Success 200 {object} object
+// @Failure 404 {object} proto.Error
+// @Failure 500 {object} proto.Error
+// @Router /workspaces/{id}/providers [get]
+func (c *controllerV1) handleGetWorkspaceProviders(w http.ResponseWriter, r *http.Request) {
+ id := r.PathValue("id")
+ providers, err := c.backend.GetWorkspaceProviders(id)
+ if err != nil {
+ c.handleError(w, r, err)
+ return
+ }
+ jsonEncode(w, providers)
+}
+
+// handleGetWorkspaceEvents streams workspace events as Server-Sent Events.
+//
+// @Summary Stream workspace events (SSE)
+// @Tags workspaces
+// @Produce text/event-stream
+// @Param id path string true "Workspace ID"
+// @Success 200
+// @Failure 404 {object} proto.Error
+// @Failure 500 {object} proto.Error
+// @Router /workspaces/{id}/events [get]
+func (c *controllerV1) handleGetWorkspaceEvents(w http.ResponseWriter, r *http.Request) {
+ flusher := http.NewResponseController(w)
+ id := r.PathValue("id")
+ events, err := c.backend.SubscribeEvents(id)
+ if err != nil {
+ c.handleError(w, r, err)
+ return
+ }
+
+ w.Header().Set("Content-Type", "text/event-stream")
+ w.Header().Set("Cache-Control", "no-cache")
+ w.Header().Set("Connection", "keep-alive")
+
+ for {
+ select {
+ case <-r.Context().Done():
+ c.server.logDebug(r, "Stopping event stream")
+ return
+ case ev, ok := <-events:
+ if !ok {
+ return
+ }
+ c.server.logDebug(r, "Sending event", "event", fmt.Sprintf("%T %+v", ev, ev))
+ wrapped := wrapEvent(ev)
+ if wrapped == nil {
+ continue
+ }
+ data, err := json.Marshal(wrapped)
+ if err != nil {
+ c.server.logError(r, "Failed to marshal event", "error", err)
+ continue
+ }
+
+ fmt.Fprintf(w, "data: %s\n\n", data)
+ flusher.Flush()
+ }
+ }
+}
+
+// handleGetWorkspaceLSPs lists LSP clients for a workspace.
+//
+// @Summary List LSP clients
+// @Tags lsp
+// @Produce json
+// @Param id path string true "Workspace ID"
+// @Success 200 {object} map[string]proto.LSPClientInfo
+// @Failure 404 {object} proto.Error
+// @Failure 500 {object} proto.Error
+// @Router /workspaces/{id}/lsps [get]
+func (c *controllerV1) handleGetWorkspaceLSPs(w http.ResponseWriter, r *http.Request) {
+ id := r.PathValue("id")
+ states, err := c.backend.GetLSPStates(id)
+ if err != nil {
+ c.handleError(w, r, err)
+ return
+ }
+ result := make(map[string]proto.LSPClientInfo, len(states))
+ for k, v := range states {
+ result[k] = proto.LSPClientInfo{
+ Name: v.Name,
+ State: v.State,
+ Error: v.Error,
+ DiagnosticCount: v.DiagnosticCount,
+ ConnectedAt: v.ConnectedAt,
+ }
+ }
+ jsonEncode(w, result)
+}
+
+// handleGetWorkspaceLSPDiagnostics returns diagnostics for an LSP client.
+//
+// @Summary Get LSP diagnostics
+// @Tags lsp
+// @Produce json
+// @Param id path string true "Workspace ID"
+// @Param lsp path string true "LSP client name"
+// @Success 200 {object} object
+// @Failure 404 {object} proto.Error
+// @Failure 500 {object} proto.Error
+// @Router /workspaces/{id}/lsps/{lsp}/diagnostics [get]
+func (c *controllerV1) handleGetWorkspaceLSPDiagnostics(w http.ResponseWriter, r *http.Request) {
+ id := r.PathValue("id")
+ lspName := r.PathValue("lsp")
+ diagnostics, err := c.backend.GetLSPDiagnostics(id, lspName)
+ if err != nil {
+ c.handleError(w, r, err)
+ return
+ }
+ jsonEncode(w, diagnostics)
+}
+
+// handleGetWorkspaceSessions lists sessions for a workspace.
+//
+// @Summary List sessions
+// @Tags sessions
+// @Produce json
+// @Param id path string true "Workspace ID"
+// @Success 200 {array} proto.Session
+// @Failure 404 {object} proto.Error
+// @Failure 500 {object} proto.Error
+// @Router /workspaces/{id}/sessions [get]
+func (c *controllerV1) handleGetWorkspaceSessions(w http.ResponseWriter, r *http.Request) {
+ id := r.PathValue("id")
+ sessions, err := c.backend.ListSessions(r.Context(), id)
+ if err != nil {
+ c.handleError(w, r, err)
+ return
+ }
+ result := make([]proto.Session, len(sessions))
+ for i, s := range sessions {
+ result[i] = sessionToProto(s)
+ }
+ jsonEncode(w, result)
+}
+
+// handlePostWorkspaceSessions creates a new session in a workspace.
+//
+// @Summary Create session
+// @Tags sessions
+// @Accept json
+// @Produce json
+// @Param id path string true "Workspace ID"
+// @Param request body proto.Session true "Session creation params (title)"
+// @Success 200 {object} proto.Session
+// @Failure 400 {object} proto.Error
+// @Failure 404 {object} proto.Error
+// @Failure 500 {object} proto.Error
+// @Router /workspaces/{id}/sessions [post]
+func (c *controllerV1) handlePostWorkspaceSessions(w http.ResponseWriter, r *http.Request) {
+ id := r.PathValue("id")
+
+ var args session.Session
+ if err := json.NewDecoder(r.Body).Decode(&args); err != nil {
+ c.server.logError(r, "Failed to decode request", "error", err)
+ jsonError(w, http.StatusBadRequest, "failed to decode request")
+ return
+ }
+
+ sess, err := c.backend.CreateSession(r.Context(), id, args.Title)
+ if err != nil {
+ c.handleError(w, r, err)
+ return
+ }
+ jsonEncode(w, sessionToProto(sess))
+}
+
+// handleGetWorkspaceSession returns a single session.
+//
+// @Summary Get session
+// @Tags sessions
+// @Produce json
+// @Param id path string true "Workspace ID"
+// @Param sid path string true "Session ID"
+// @Success 200 {object} proto.Session
+// @Failure 404 {object} proto.Error
+// @Failure 500 {object} proto.Error
+// @Router /workspaces/{id}/sessions/{sid} [get]
+func (c *controllerV1) handleGetWorkspaceSession(w http.ResponseWriter, r *http.Request) {
+ id := r.PathValue("id")
+ sid := r.PathValue("sid")
+ sess, err := c.backend.GetSession(r.Context(), id, sid)
+ if err != nil {
+ c.handleError(w, r, err)
+ return
+ }
+ jsonEncode(w, sessionToProto(sess))
+}
+
+// handleGetWorkspaceSessionHistory returns the history for a session.
+//
+// @Summary Get session history
+// @Tags sessions
+// @Produce json
+// @Param id path string true "Workspace ID"
+// @Param sid path string true "Session ID"
+// @Success 200 {array} proto.File
+// @Failure 404 {object} proto.Error
+// @Failure 500 {object} proto.Error
+// @Router /workspaces/{id}/sessions/{sid}/history [get]
+func (c *controllerV1) handleGetWorkspaceSessionHistory(w http.ResponseWriter, r *http.Request) {
+ id := r.PathValue("id")
+ sid := r.PathValue("sid")
+ history, err := c.backend.ListSessionHistory(r.Context(), id, sid)
+ if err != nil {
+ c.handleError(w, r, err)
+ return
+ }
+ jsonEncode(w, history)
+}
+
+// handleGetWorkspaceSessionMessages returns all messages for a session.
+//
+// @Summary Get session messages
+// @Tags sessions
+// @Produce json
+// @Param id path string true "Workspace ID"
+// @Param sid path string true "Session ID"
+// @Success 200 {array} proto.Message
+// @Failure 404 {object} proto.Error
+// @Failure 500 {object} proto.Error
+// @Router /workspaces/{id}/sessions/{sid}/messages [get]
+func (c *controllerV1) handleGetWorkspaceSessionMessages(w http.ResponseWriter, r *http.Request) {
+ id := r.PathValue("id")
+ sid := r.PathValue("sid")
+ messages, err := c.backend.ListSessionMessages(r.Context(), id, sid)
+ if err != nil {
+ c.handleError(w, r, err)
+ return
+ }
+ jsonEncode(w, messagesToProto(messages))
+}
+
+// handlePutWorkspaceSession updates a session.
+//
+// @Summary Update session
+// @Tags sessions
+// @Accept json
+// @Produce json
+// @Param id path string true "Workspace ID"
+// @Param sid path string true "Session ID"
+// @Param request body proto.Session true "Updated session"
+// @Success 200 {object} proto.Session
+// @Failure 400 {object} proto.Error
+// @Failure 404 {object} proto.Error
+// @Failure 500 {object} proto.Error
+// @Router /workspaces/{id}/sessions/{sid} [put]
+func (c *controllerV1) handlePutWorkspaceSession(w http.ResponseWriter, r *http.Request) {
+ id := r.PathValue("id")
+
+ var sess session.Session
+ if err := json.NewDecoder(r.Body).Decode(&sess); err != nil {
+ c.server.logError(r, "Failed to decode request", "error", err)
+ jsonError(w, http.StatusBadRequest, "failed to decode request")
+ return
+ }
+
+ saved, err := c.backend.SaveSession(r.Context(), id, sess)
+ if err != nil {
+ c.handleError(w, r, err)
+ return
+ }
+ jsonEncode(w, sessionToProto(saved))
+}
+
+// handleDeleteWorkspaceSession deletes a session.
+//
+// @Summary Delete session
+// @Tags sessions
+// @Param id path string true "Workspace ID"
+// @Param sid path string true "Session ID"
+// @Success 200
+// @Failure 404 {object} proto.Error
+// @Failure 500 {object} proto.Error
+// @Router /workspaces/{id}/sessions/{sid} [delete]
+func (c *controllerV1) handleDeleteWorkspaceSession(w http.ResponseWriter, r *http.Request) {
+ id := r.PathValue("id")
+ sid := r.PathValue("sid")
+ if err := c.backend.DeleteSession(r.Context(), id, sid); err != nil {
+ c.handleError(w, r, err)
+ return
+ }
+ w.WriteHeader(http.StatusOK)
+}
+
+// handleGetWorkspaceSessionUserMessages returns user messages for a session.
+//
+// @Summary Get user messages for session
+// @Tags sessions
+// @Produce json
+// @Param id path string true "Workspace ID"
+// @Param sid path string true "Session ID"
+// @Success 200 {array} proto.Message
+// @Failure 404 {object} proto.Error
+// @Failure 500 {object} proto.Error
+// @Router /workspaces/{id}/sessions/{sid}/messages/user [get]
+func (c *controllerV1) handleGetWorkspaceSessionUserMessages(w http.ResponseWriter, r *http.Request) {
+ id := r.PathValue("id")
+ sid := r.PathValue("sid")
+ messages, err := c.backend.ListUserMessages(r.Context(), id, sid)
+ if err != nil {
+ c.handleError(w, r, err)
+ return
+ }
+ jsonEncode(w, messagesToProto(messages))
+}
+
+// handleGetWorkspaceAllUserMessages returns all user messages across sessions.
+//
+// @Summary Get all user messages for workspace
+// @Tags workspaces
+// @Produce json
+// @Param id path string true "Workspace ID"
+// @Success 200 {array} proto.Message
+// @Failure 404 {object} proto.Error
+// @Failure 500 {object} proto.Error
+// @Router /workspaces/{id}/messages/user [get]
+func (c *controllerV1) handleGetWorkspaceAllUserMessages(w http.ResponseWriter, r *http.Request) {
+ id := r.PathValue("id")
+ messages, err := c.backend.ListAllUserMessages(r.Context(), id)
+ if err != nil {
+ c.handleError(w, r, err)
+ return
+ }
+ jsonEncode(w, messagesToProto(messages))
+}
+
+// handleGetWorkspaceSessionFileTrackerFiles lists files read in a session.
+//
+// @Summary List tracked files for session
+// @Tags filetracker
+// @Produce json
+// @Param id path string true "Workspace ID"
+// @Param sid path string true "Session ID"
+// @Success 200 {array} string
+// @Failure 404 {object} proto.Error
+// @Failure 500 {object} proto.Error
+// @Router /workspaces/{id}/sessions/{sid}/filetracker/files [get]
+func (c *controllerV1) handleGetWorkspaceSessionFileTrackerFiles(w http.ResponseWriter, r *http.Request) {
+ id := r.PathValue("id")
+ sid := r.PathValue("sid")
+ files, err := c.backend.FileTrackerListReadFiles(r.Context(), id, sid)
+ if err != nil {
+ c.handleError(w, r, err)
+ return
+ }
+ jsonEncode(w, files)
+}
+
+// handlePostWorkspaceFileTrackerRead records a file read event.
+//
+// @Summary Record file read
+// @Tags filetracker
+// @Accept json
+// @Param id path string true "Workspace ID"
+// @Param request body proto.FileTrackerReadRequest true "File tracker read request"
+// @Success 200
+// @Failure 400 {object} proto.Error
+// @Failure 404 {object} proto.Error
+// @Failure 500 {object} proto.Error
+// @Router /workspaces/{id}/filetracker/read [post]
+func (c *controllerV1) handlePostWorkspaceFileTrackerRead(w http.ResponseWriter, r *http.Request) {
+ id := r.PathValue("id")
+
+ var req proto.FileTrackerReadRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ c.server.logError(r, "Failed to decode request", "error", err)
+ jsonError(w, http.StatusBadRequest, "failed to decode request")
+ return
+ }
+
+ if err := c.backend.FileTrackerRecordRead(r.Context(), id, req.SessionID, req.Path); err != nil {
+ c.handleError(w, r, err)
+ return
+ }
+ w.WriteHeader(http.StatusOK)
+}
+
+// handleGetWorkspaceFileTrackerLastRead returns the last read time for a file.
+//
+// @Summary Get last read time for file
+// @Tags filetracker
+// @Produce json
+// @Param id path string true "Workspace ID"
+// @Param session_id query string false "Session ID"
+// @Param path query string true "File path"
+// @Success 200 {object} object
+// @Failure 404 {object} proto.Error
+// @Failure 500 {object} proto.Error
+// @Router /workspaces/{id}/filetracker/lastread [get]
+func (c *controllerV1) handleGetWorkspaceFileTrackerLastRead(w http.ResponseWriter, r *http.Request) {
+ id := r.PathValue("id")
+ sid := r.URL.Query().Get("session_id")
+ path := r.URL.Query().Get("path")
+
+ t, err := c.backend.FileTrackerLastReadTime(r.Context(), id, sid, path)
+ if err != nil {
+ c.handleError(w, r, err)
+ return
+ }
+ jsonEncode(w, t)
+}
+
+// handlePostWorkspaceLSPStart starts an LSP server for a path.
+//
+// @Summary Start LSP server
+// @Tags lsp
+// @Accept json
+// @Param id path string true "Workspace ID"
+// @Param request body proto.LSPStartRequest true "LSP start request"
+// @Success 200
+// @Failure 400 {object} proto.Error
+// @Failure 404 {object} proto.Error
+// @Failure 500 {object} proto.Error
+// @Router /workspaces/{id}/lsps/start [post]
+func (c *controllerV1) handlePostWorkspaceLSPStart(w http.ResponseWriter, r *http.Request) {
+ id := r.PathValue("id")
+
+ var req proto.LSPStartRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ c.server.logError(r, "Failed to decode request", "error", err)
+ jsonError(w, http.StatusBadRequest, "failed to decode request")
+ return
+ }
+
+ if err := c.backend.LSPStart(r.Context(), id, req.Path); err != nil {
+ c.handleError(w, r, err)
+ return
+ }
+ w.WriteHeader(http.StatusOK)
+}
+
+// handlePostWorkspaceLSPStopAll stops all LSP servers.
+//
+// @Summary Stop all LSP servers
+// @Tags lsp
+// @Param id path string true "Workspace ID"
+// @Success 200
+// @Failure 404 {object} proto.Error
+// @Failure 500 {object} proto.Error
+// @Router /workspaces/{id}/lsps/stop [post]
+func (c *controllerV1) handlePostWorkspaceLSPStopAll(w http.ResponseWriter, r *http.Request) {
+ id := r.PathValue("id")
+ if err := c.backend.LSPStopAll(r.Context(), id); err != nil {
+ c.handleError(w, r, err)
+ return
+ }
+ w.WriteHeader(http.StatusOK)
+}
+
+// handleGetWorkspaceAgent returns agent info for a workspace.
+//
+// @Summary Get agent info
+// @Tags agent
+// @Produce json
+// @Param id path string true "Workspace ID"
+// @Success 200 {object} proto.AgentInfo
+// @Failure 404 {object} proto.Error
+// @Failure 500 {object} proto.Error
+// @Router /workspaces/{id}/agent [get]
+func (c *controllerV1) handleGetWorkspaceAgent(w http.ResponseWriter, r *http.Request) {
+ id := r.PathValue("id")
+ info, err := c.backend.GetAgentInfo(id)
+ if err != nil {
+ c.handleError(w, r, err)
+ return
+ }
+ jsonEncode(w, info)
+}
+
+// handlePostWorkspaceAgent sends a message to the agent.
+//
+// @Summary Send message to agent
+// @Tags agent
+// @Accept json
+// @Param id path string true "Workspace ID"
+// @Param request body proto.AgentMessage true "Agent message"
+// @Success 200
+// @Failure 400 {object} proto.Error
+// @Failure 404 {object} proto.Error
+// @Failure 500 {object} proto.Error
+// @Router /workspaces/{id}/agent [post]
+func (c *controllerV1) handlePostWorkspaceAgent(w http.ResponseWriter, r *http.Request) {
+ id := r.PathValue("id")
+
+ var msg proto.AgentMessage
+ if err := json.NewDecoder(r.Body).Decode(&msg); err != nil {
+ c.server.logError(r, "Failed to decode request", "error", err)
+ jsonError(w, http.StatusBadRequest, "failed to decode request")
+ return
+ }
+
+ if err := c.backend.SendMessage(r.Context(), id, msg); err != nil {
+ c.handleError(w, r, err)
+ return
+ }
+ w.WriteHeader(http.StatusOK)
+}
+
+// handlePostWorkspaceAgentInit initializes the agent for a workspace.
+//
+// @Summary Initialize agent
+// @Tags agent
+// @Param id path string true "Workspace ID"
+// @Success 200
+// @Failure 404 {object} proto.Error
+// @Failure 500 {object} proto.Error
+// @Router /workspaces/{id}/agent/init [post]
+func (c *controllerV1) handlePostWorkspaceAgentInit(w http.ResponseWriter, r *http.Request) {
+ id := r.PathValue("id")
+ if err := c.backend.InitAgent(r.Context(), id); err != nil {
+ c.handleError(w, r, err)
+ return
+ }
+ w.WriteHeader(http.StatusOK)
+}
+
+// handlePostWorkspaceAgentUpdate updates the agent for a workspace.
+//
+// @Summary Update agent
+// @Tags agent
+// @Param id path string true "Workspace ID"
+// @Success 200
+// @Failure 404 {object} proto.Error
+// @Failure 500 {object} proto.Error
+// @Router /workspaces/{id}/agent/update [post]
+func (c *controllerV1) handlePostWorkspaceAgentUpdate(w http.ResponseWriter, r *http.Request) {
+ id := r.PathValue("id")
+ if err := c.backend.UpdateAgent(r.Context(), id); err != nil {
+ c.handleError(w, r, err)
+ return
+ }
+ w.WriteHeader(http.StatusOK)
+}
+
+// handleGetWorkspaceAgentSession returns a specific agent session.
+//
+// @Summary Get agent session
+// @Tags agent
+// @Produce json
+// @Param id path string true "Workspace ID"
+// @Param sid path string true "Session ID"
+// @Success 200 {object} proto.AgentSession
+// @Failure 404 {object} proto.Error
+// @Failure 500 {object} proto.Error
+// @Router /workspaces/{id}/agent/sessions/{sid} [get]
+func (c *controllerV1) handleGetWorkspaceAgentSession(w http.ResponseWriter, r *http.Request) {
+ id := r.PathValue("id")
+ sid := r.PathValue("sid")
+ agentSession, err := c.backend.GetAgentSession(r.Context(), id, sid)
+ if err != nil {
+ c.handleError(w, r, err)
+ return
+ }
+ jsonEncode(w, agentSession)
+}
+
+// handlePostWorkspaceAgentSessionCancel cancels a running agent session.
+//
+// @Summary Cancel agent session
+// @Tags agent
+// @Param id path string true "Workspace ID"
+// @Param sid path string true "Session ID"
+// @Success 200
+// @Failure 404 {object} proto.Error
+// @Failure 500 {object} proto.Error
+// @Router /workspaces/{id}/agent/sessions/{sid}/cancel [post]
+func (c *controllerV1) handlePostWorkspaceAgentSessionCancel(w http.ResponseWriter, r *http.Request) {
+ id := r.PathValue("id")
+ sid := r.PathValue("sid")
+ if err := c.backend.CancelSession(id, sid); err != nil {
+ c.handleError(w, r, err)
+ return
+ }
+ w.WriteHeader(http.StatusOK)
+}
+
+// handleGetWorkspaceAgentSessionPromptQueued returns whether a queued prompt exists.
+//
+// @Summary Get queued prompt status
+// @Tags agent
+// @Produce json
+// @Param id path string true "Workspace ID"
+// @Param sid path string true "Session ID"
+// @Success 200 {object} object
+// @Failure 404 {object} proto.Error
+// @Failure 500 {object} proto.Error
+// @Router /workspaces/{id}/agent/sessions/{sid}/prompts/queued [get]
+func (c *controllerV1) handleGetWorkspaceAgentSessionPromptQueued(w http.ResponseWriter, r *http.Request) {
+ id := r.PathValue("id")
+ sid := r.PathValue("sid")
+ queued, err := c.backend.QueuedPrompts(id, sid)
+ if err != nil {
+ c.handleError(w, r, err)
+ return
+ }
+ jsonEncode(w, queued)
+}
+
+// handlePostWorkspaceAgentSessionPromptClear clears the prompt queue for a session.
+//
+// @Summary Clear prompt queue
+// @Tags agent
+// @Param id path string true "Workspace ID"
+// @Param sid path string true "Session ID"
+// @Success 200
+// @Failure 404 {object} proto.Error
+// @Failure 500 {object} proto.Error
+// @Router /workspaces/{id}/agent/sessions/{sid}/prompts/clear [post]
+func (c *controllerV1) handlePostWorkspaceAgentSessionPromptClear(w http.ResponseWriter, r *http.Request) {
+ id := r.PathValue("id")
+ sid := r.PathValue("sid")
+ if err := c.backend.ClearQueue(id, sid); err != nil {
+ c.handleError(w, r, err)
+ return
+ }
+ w.WriteHeader(http.StatusOK)
+}
+
+// handlePostWorkspaceAgentSessionSummarize summarizes a session.
+//
+// @Summary Summarize session
+// @Tags agent
+// @Param id path string true "Workspace ID"
+// @Param sid path string true "Session ID"
+// @Success 200
+// @Failure 404 {object} proto.Error
+// @Failure 500 {object} proto.Error
+// @Router /workspaces/{id}/agent/sessions/{sid}/summarize [post]
+func (c *controllerV1) handlePostWorkspaceAgentSessionSummarize(w http.ResponseWriter, r *http.Request) {
+ id := r.PathValue("id")
+ sid := r.PathValue("sid")
+ if err := c.backend.SummarizeSession(r.Context(), id, sid); err != nil {
+ c.handleError(w, r, err)
+ return
+ }
+ w.WriteHeader(http.StatusOK)
+}
+
+// handleGetWorkspaceAgentSessionPromptList returns the list of queued prompts.
+//
+// @Summary List queued prompts
+// @Tags agent
+// @Produce json
+// @Param id path string true "Workspace ID"
+// @Param sid path string true "Session ID"
+// @Success 200 {array} string
+// @Failure 404 {object} proto.Error
+// @Failure 500 {object} proto.Error
+// @Router /workspaces/{id}/agent/sessions/{sid}/prompts/list [get]
+func (c *controllerV1) handleGetWorkspaceAgentSessionPromptList(w http.ResponseWriter, r *http.Request) {
+ id := r.PathValue("id")
+ sid := r.PathValue("sid")
+ prompts, err := c.backend.QueuedPromptsList(id, sid)
+ if err != nil {
+ c.handleError(w, r, err)
+ return
+ }
+ jsonEncode(w, prompts)
+}
+
+// handleGetWorkspaceAgentDefaultSmallModel returns the default small model for a provider.
+//
+// @Summary Get default small model
+// @Tags agent
+// @Produce json
+// @Param id path string true "Workspace ID"
+// @Param provider_id query string false "Provider ID"
+// @Success 200 {object} object
+// @Failure 404 {object} proto.Error
+// @Failure 500 {object} proto.Error
+// @Router /workspaces/{id}/agent/default-small-model [get]
+func (c *controllerV1) handleGetWorkspaceAgentDefaultSmallModel(w http.ResponseWriter, r *http.Request) {
+ id := r.PathValue("id")
+ providerID := r.URL.Query().Get("provider_id")
+ model, err := c.backend.GetDefaultSmallModel(id, providerID)
+ if err != nil {
+ c.handleError(w, r, err)
+ return
+ }
+ jsonEncode(w, model)
+}
+
+// handlePostWorkspacePermissionsGrant grants a permission request.
+//
+// @Summary Grant permission
+// @Tags permissions
+// @Accept json
+// @Param id path string true "Workspace ID"
+// @Param request body proto.PermissionGrant true "Permission grant"
+// @Success 200
+// @Failure 400 {object} proto.Error
+// @Failure 404 {object} proto.Error
+// @Failure 500 {object} proto.Error
+// @Router /workspaces/{id}/permissions/grant [post]
+func (c *controllerV1) handlePostWorkspacePermissionsGrant(w http.ResponseWriter, r *http.Request) {
+ id := r.PathValue("id")
+
+ var req proto.PermissionGrant
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ c.server.logError(r, "Failed to decode request", "error", err)
+ jsonError(w, http.StatusBadRequest, "failed to decode request")
+ return
+ }
+
+ if err := c.backend.GrantPermission(id, req); err != nil {
+ c.handleError(w, r, err)
+ return
+ }
+ w.WriteHeader(http.StatusOK)
+}
+
+// handlePostWorkspacePermissionsSkip sets whether to skip permission prompts.
+//
+// @Summary Set skip permissions
+// @Tags permissions
+// @Accept json
+// @Param id path string true "Workspace ID"
+// @Param request body proto.PermissionSkipRequest true "Permission skip request"
+// @Success 200
+// @Failure 400 {object} proto.Error
+// @Failure 404 {object} proto.Error
+// @Failure 500 {object} proto.Error
+// @Router /workspaces/{id}/permissions/skip [post]
+func (c *controllerV1) handlePostWorkspacePermissionsSkip(w http.ResponseWriter, r *http.Request) {
+ id := r.PathValue("id")
+
+ var req proto.PermissionSkipRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ c.server.logError(r, "Failed to decode request", "error", err)
+ jsonError(w, http.StatusBadRequest, "failed to decode request")
+ return
+ }
+
+ if err := c.backend.SetPermissionsSkip(id, req.Skip); err != nil {
+ c.handleError(w, r, err)
+ return
+ }
+}
+
+// handleGetWorkspacePermissionsSkip returns whether permission prompts are skipped.
+//
+// @Summary Get skip permissions status
+// @Tags permissions
+// @Produce json
+// @Param id path string true "Workspace ID"
+// @Success 200 {object} proto.PermissionSkipRequest
+// @Failure 404 {object} proto.Error
+// @Failure 500 {object} proto.Error
+// @Router /workspaces/{id}/permissions/skip [get]
+func (c *controllerV1) handleGetWorkspacePermissionsSkip(w http.ResponseWriter, r *http.Request) {
+ id := r.PathValue("id")
+ skip, err := c.backend.GetPermissionsSkip(id)
+ if err != nil {
+ c.handleError(w, r, err)
+ return
+ }
+ jsonEncode(w, proto.PermissionSkipRequest{Skip: skip})
+}
+
+// handleError maps backend errors to HTTP status codes and writes the
+// JSON error response.
+func (c *controllerV1) handleError(w http.ResponseWriter, r *http.Request, err error) {
+ status := http.StatusInternalServerError
+ switch {
+ case errors.Is(err, backend.ErrWorkspaceNotFound):
+ status = http.StatusNotFound
+ case errors.Is(err, backend.ErrLSPClientNotFound):
+ status = http.StatusNotFound
+ case errors.Is(err, backend.ErrAgentNotInitialized):
+ status = http.StatusBadRequest
+ case errors.Is(err, backend.ErrPathRequired):
+ status = http.StatusBadRequest
+ case errors.Is(err, backend.ErrInvalidPermissionAction):
+ status = http.StatusBadRequest
+ case errors.Is(err, backend.ErrUnknownCommand):
+ status = http.StatusBadRequest
+ }
+ c.server.logError(r, err.Error())
+ jsonError(w, status, err.Error())
+}
+
+func jsonEncode(w http.ResponseWriter, v any) {
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(v)
+}
+
+func jsonError(w http.ResponseWriter, status int, message string) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(status)
+ _ = json.NewEncoder(w).Encode(proto.Error{Message: message})
+}
@@ -0,0 +1,234 @@
+package server
+
+import (
+ "context"
+ "fmt"
+ "log/slog"
+ "net"
+ "net/http"
+ "net/url"
+ "os/user"
+ "runtime"
+ "strings"
+
+ "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"
+)
+
+// ErrServerClosed is returned when the server is closed.
+var ErrServerClosed = http.ErrServerClosed
+
+// ParseHostURL parses a host URL into a [url.URL].
+func ParseHostURL(host string) (*url.URL, error) {
+ proto, addr, ok := strings.Cut(host, "://")
+ if !ok {
+ return nil, fmt.Errorf("invalid host format: %s", host)
+ }
+
+ var basePath string
+ if proto == "tcp" {
+ parsed, err := url.Parse("tcp://" + addr)
+ if err != nil {
+ return nil, fmt.Errorf("invalid tcp address: %v", err)
+ }
+ addr = parsed.Host
+ basePath = parsed.Path
+ }
+ return &url.URL{
+ Scheme: proto,
+ Host: addr,
+ Path: basePath,
+ }, nil
+}
+
+// DefaultHost returns the default server host.
+func DefaultHost() string {
+ sock := "crush.sock"
+ usr, err := user.Current()
+ if err == nil && usr.Uid != "" {
+ sock = fmt.Sprintf("crush-%s.sock", usr.Uid)
+ }
+ if runtime.GOOS == "windows" {
+ return fmt.Sprintf("npipe:////./pipe/%s", sock)
+ }
+ return fmt.Sprintf("unix:///tmp/%s", sock)
+}
+
+// Server represents a Crush server bound to a specific address.
+type Server struct {
+ // Addr can be a TCP address, a Unix socket path, or a Windows named pipe.
+ Addr string
+ network string
+
+ h *http.Server
+ ln net.Listener
+
+ backend *backend.Backend
+ logger *slog.Logger
+}
+
+// SetLogger sets the logger for the server.
+func (s *Server) SetLogger(logger *slog.Logger) {
+ s.logger = logger
+}
+
+// DefaultServer returns a new [Server] with the default address.
+func DefaultServer(cfg *config.ConfigStore) *Server {
+ hostURL, err := ParseHostURL(DefaultHost())
+ if err != nil {
+ panic("invalid default host")
+ }
+ return NewServer(cfg, hostURL.Scheme, hostURL.Host)
+}
+
+// NewServer creates a new [Server] with the given network and address.
+func NewServer(cfg *config.ConfigStore, network, address string) *Server {
+ s := new(Server)
+ s.Addr = address
+ s.network = network
+
+ // The backend is created with a shutdown callback that triggers
+ // a graceful server shutdown (e.g. when the last workspace is
+ // removed).
+ s.backend = backend.New(context.Background(), cfg, func() {
+ go func() {
+ slog.Info("Shutting down server...")
+ if err := s.Shutdown(context.Background()); err != nil {
+ slog.Error("Failed to shutdown server", "error", err)
+ }
+ }()
+ })
+
+ var p http.Protocols
+ p.SetHTTP1(true)
+ p.SetUnencryptedHTTP2(true)
+ c := &controllerV1{backend: s.backend, server: s}
+ mux := http.NewServeMux()
+ mux.HandleFunc("GET /v1/health", c.handleGetHealth)
+ mux.HandleFunc("GET /v1/version", c.handleGetVersion)
+ mux.HandleFunc("GET /v1/config", c.handleGetConfig)
+ mux.HandleFunc("POST /v1/control", c.handlePostControl)
+ mux.HandleFunc("GET /v1/workspaces", c.handleGetWorkspaces)
+ mux.HandleFunc("POST /v1/workspaces", c.handlePostWorkspaces)
+ mux.HandleFunc("DELETE /v1/workspaces/{id}", c.handleDeleteWorkspaces)
+ mux.HandleFunc("GET /v1/workspaces/{id}", c.handleGetWorkspace)
+ mux.HandleFunc("GET /v1/workspaces/{id}/config", c.handleGetWorkspaceConfig)
+ mux.HandleFunc("GET /v1/workspaces/{id}/events", c.handleGetWorkspaceEvents)
+ mux.HandleFunc("GET /v1/workspaces/{id}/providers", c.handleGetWorkspaceProviders)
+ mux.HandleFunc("GET /v1/workspaces/{id}/sessions", c.handleGetWorkspaceSessions)
+ mux.HandleFunc("POST /v1/workspaces/{id}/sessions", c.handlePostWorkspaceSessions)
+ mux.HandleFunc("GET /v1/workspaces/{id}/sessions/{sid}", c.handleGetWorkspaceSession)
+ mux.HandleFunc("PUT /v1/workspaces/{id}/sessions/{sid}", c.handlePutWorkspaceSession)
+ mux.HandleFunc("DELETE /v1/workspaces/{id}/sessions/{sid}", c.handleDeleteWorkspaceSession)
+ mux.HandleFunc("GET /v1/workspaces/{id}/sessions/{sid}/history", c.handleGetWorkspaceSessionHistory)
+ mux.HandleFunc("GET /v1/workspaces/{id}/sessions/{sid}/messages", c.handleGetWorkspaceSessionMessages)
+ mux.HandleFunc("GET /v1/workspaces/{id}/sessions/{sid}/messages/user", c.handleGetWorkspaceSessionUserMessages)
+ mux.HandleFunc("GET /v1/workspaces/{id}/messages/user", c.handleGetWorkspaceAllUserMessages)
+ mux.HandleFunc("GET /v1/workspaces/{id}/sessions/{sid}/filetracker/files", c.handleGetWorkspaceSessionFileTrackerFiles)
+ mux.HandleFunc("POST /v1/workspaces/{id}/filetracker/read", c.handlePostWorkspaceFileTrackerRead)
+ mux.HandleFunc("GET /v1/workspaces/{id}/filetracker/lastread", c.handleGetWorkspaceFileTrackerLastRead)
+ mux.HandleFunc("GET /v1/workspaces/{id}/lsps", c.handleGetWorkspaceLSPs)
+ mux.HandleFunc("GET /v1/workspaces/{id}/lsps/{lsp}/diagnostics", c.handleGetWorkspaceLSPDiagnostics)
+ mux.HandleFunc("POST /v1/workspaces/{id}/lsps/start", c.handlePostWorkspaceLSPStart)
+ mux.HandleFunc("POST /v1/workspaces/{id}/lsps/stop", c.handlePostWorkspaceLSPStopAll)
+ mux.HandleFunc("GET /v1/workspaces/{id}/permissions/skip", c.handleGetWorkspacePermissionsSkip)
+ mux.HandleFunc("POST /v1/workspaces/{id}/permissions/skip", c.handlePostWorkspacePermissionsSkip)
+ mux.HandleFunc("POST /v1/workspaces/{id}/permissions/grant", c.handlePostWorkspacePermissionsGrant)
+ mux.HandleFunc("GET /v1/workspaces/{id}/agent", c.handleGetWorkspaceAgent)
+ mux.HandleFunc("POST /v1/workspaces/{id}/agent", c.handlePostWorkspaceAgent)
+ mux.HandleFunc("POST /v1/workspaces/{id}/agent/init", c.handlePostWorkspaceAgentInit)
+ mux.HandleFunc("POST /v1/workspaces/{id}/agent/update", c.handlePostWorkspaceAgentUpdate)
+ mux.HandleFunc("GET /v1/workspaces/{id}/agent/sessions/{sid}", c.handleGetWorkspaceAgentSession)
+ mux.HandleFunc("POST /v1/workspaces/{id}/agent/sessions/{sid}/cancel", c.handlePostWorkspaceAgentSessionCancel)
+ mux.HandleFunc("GET /v1/workspaces/{id}/agent/sessions/{sid}/prompts/queued", c.handleGetWorkspaceAgentSessionPromptQueued)
+ mux.HandleFunc("GET /v1/workspaces/{id}/agent/sessions/{sid}/prompts/list", c.handleGetWorkspaceAgentSessionPromptList)
+ mux.HandleFunc("POST /v1/workspaces/{id}/agent/sessions/{sid}/prompts/clear", c.handlePostWorkspaceAgentSessionPromptClear)
+ mux.HandleFunc("POST /v1/workspaces/{id}/agent/sessions/{sid}/summarize", c.handlePostWorkspaceAgentSessionSummarize)
+ mux.HandleFunc("GET /v1/workspaces/{id}/agent/default-small-model", c.handleGetWorkspaceAgentDefaultSmallModel)
+ mux.HandleFunc("POST /v1/workspaces/{id}/config/set", c.handlePostWorkspaceConfigSet)
+ mux.HandleFunc("POST /v1/workspaces/{id}/config/remove", c.handlePostWorkspaceConfigRemove)
+ mux.HandleFunc("POST /v1/workspaces/{id}/config/model", c.handlePostWorkspaceConfigModel)
+ mux.HandleFunc("POST /v1/workspaces/{id}/config/compact", c.handlePostWorkspaceConfigCompact)
+ mux.HandleFunc("POST /v1/workspaces/{id}/config/provider-key", c.handlePostWorkspaceConfigProviderKey)
+ mux.HandleFunc("POST /v1/workspaces/{id}/config/import-copilot", c.handlePostWorkspaceConfigImportCopilot)
+ mux.HandleFunc("POST /v1/workspaces/{id}/config/refresh-oauth", c.handlePostWorkspaceConfigRefreshOAuth)
+ mux.HandleFunc("GET /v1/workspaces/{id}/project/needs-init", c.handleGetWorkspaceProjectNeedsInit)
+ mux.HandleFunc("POST /v1/workspaces/{id}/project/init", c.handlePostWorkspaceProjectInit)
+ mux.HandleFunc("GET /v1/workspaces/{id}/project/init-prompt", c.handleGetWorkspaceProjectInitPrompt)
+ mux.HandleFunc("POST /v1/workspaces/{id}/mcp/refresh-tools", c.handlePostWorkspaceMCPRefreshTools)
+ mux.HandleFunc("POST /v1/workspaces/{id}/mcp/read-resource", c.handlePostWorkspaceMCPReadResource)
+ mux.HandleFunc("POST /v1/workspaces/{id}/mcp/get-prompt", c.handlePostWorkspaceMCPGetPrompt)
+ 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,
+ Handler: s.loggingHandler(mux),
+ }
+ if network == "tcp" {
+ s.h.Addr = address
+ }
+ return s
+}
+
+// Serve accepts incoming connections on the listener.
+func (s *Server) Serve(ln net.Listener) error {
+ return s.h.Serve(ln)
+}
+
+// ListenAndServe starts the server and begins accepting connections.
+func (s *Server) ListenAndServe() error {
+ if s.ln != nil {
+ return fmt.Errorf("server already started")
+ }
+ ln, err := listen(s.network, s.Addr)
+ if err != nil {
+ return fmt.Errorf("failed to listen on %s: %w", s.Addr, err)
+ }
+ return s.Serve(ln)
+}
+
+func (s *Server) closeListener() {
+ if s.ln != nil {
+ s.ln.Close()
+ s.ln = nil
+ }
+}
+
+// Close force closes all listeners and connections.
+func (s *Server) Close() error {
+ defer func() { s.closeListener() }()
+ return s.h.Close()
+}
+
+// Shutdown gracefully shuts down the server without interrupting active
+// connections.
+func (s *Server) Shutdown(ctx context.Context) error {
+ defer func() { s.closeListener() }()
+ return s.h.Shutdown(ctx)
+}
+
+func (s *Server) logDebug(r *http.Request, msg string, args ...any) {
+ if s.logger != nil {
+ s.logger.With(
+ slog.String("method", r.Method),
+ slog.String("url", r.URL.String()),
+ slog.String("remote_addr", r.RemoteAddr),
+ ).Debug(msg, args...)
+ }
+}
+
+func (s *Server) logError(r *http.Request, msg string, args ...any) {
+ if s.logger != nil {
+ s.logger.With(
+ slog.String("method", r.Method),
+ slog.String("url", r.URL.String()),
+ slog.String("remote_addr", r.RemoteAddr),
+ ).Error(msg, args...)
+ }
+}
@@ -0,0 +1,3589 @@
+// Package swagger Code generated by swaggo/swag. DO NOT EDIT
+package swagger
+
+import "github.com/swaggo/swag"
+
+const docTemplate = `{
+ "schemes": {{ marshal .Schemes }},
+ "swagger": "2.0",
+ "info": {
+ "description": "{{escape .Description}}",
+ "title": "{{.Title}}",
+ "contact": {
+ "name": "Charm",
+ "url": "https://charm.sh"
+ },
+ "license": {
+ "name": "MIT",
+ "url": "https://github.com/charmbracelet/crush/blob/main/LICENSE"
+ },
+ "version": "{{.Version}}"
+ },
+ "host": "{{.Host}}",
+ "basePath": "{{.BasePath}}",
+ "paths": {
+ "/config": {
+ "get": {
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "system"
+ ],
+ "summary": "Get server config",
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "type": "object"
+ }
+ }
+ }
+ }
+ },
+ "/control": {
+ "post": {
+ "consumes": [
+ "application/json"
+ ],
+ "tags": [
+ "system"
+ ],
+ "summary": "Send server control command",
+ "parameters": [
+ {
+ "description": "Control command (e.g. shutdown)",
+ "name": "request",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/proto.ServerControl"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK"
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ }
+ }
+ }
+ },
+ "/health": {
+ "get": {
+ "tags": [
+ "system"
+ ],
+ "summary": "Health check",
+ "responses": {
+ "200": {
+ "description": "OK"
+ }
+ }
+ }
+ },
+ "/version": {
+ "get": {
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "system"
+ ],
+ "summary": "Get server version",
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/proto.VersionInfo"
+ }
+ }
+ }
+ }
+ },
+ "/workspaces": {
+ "get": {
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "workspaces"
+ ],
+ "summary": "List workspaces",
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/proto.Workspace"
+ }
+ }
+ }
+ }
+ },
+ "post": {
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "workspaces"
+ ],
+ "summary": "Create workspace",
+ "parameters": [
+ {
+ "description": "Workspace creation params",
+ "name": "request",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/proto.Workspace"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/proto.Workspace"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ }
+ }
+ }
+ },
+ "/workspaces/{id}": {
+ "get": {
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "workspaces"
+ ],
+ "summary": "Get workspace",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Workspace ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/proto.Workspace"
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ }
+ }
+ },
+ "delete": {
+ "tags": [
+ "workspaces"
+ ],
+ "summary": "Delete workspace",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Workspace ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK"
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ }
+ }
+ }
+ },
+ "/workspaces/{id}/agent": {
+ "get": {
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "agent"
+ ],
+ "summary": "Get agent info",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Workspace ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/proto.AgentInfo"
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ }
+ }
+ },
+ "post": {
+ "consumes": [
+ "application/json"
+ ],
+ "tags": [
+ "agent"
+ ],
+ "summary": "Send message to agent",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Workspace ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ },
+ {
+ "description": "Agent message",
+ "name": "request",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/proto.AgentMessage"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK"
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ }
+ }
+ }
+ },
+ "/workspaces/{id}/agent/default-small-model": {
+ "get": {
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "agent"
+ ],
+ "summary": "Get default small model",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Workspace ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ },
+ {
+ "type": "string",
+ "description": "Provider ID",
+ "name": "provider_id",
+ "in": "query"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "type": "object"
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ }
+ }
+ }
+ },
+ "/workspaces/{id}/agent/init": {
+ "post": {
+ "tags": [
+ "agent"
+ ],
+ "summary": "Initialize agent",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Workspace ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK"
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ }
+ }
+ }
+ },
+ "/workspaces/{id}/agent/sessions/{sid}": {
+ "get": {
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "agent"
+ ],
+ "summary": "Get agent session",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Workspace ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ },
+ {
+ "type": "string",
+ "description": "Session ID",
+ "name": "sid",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/proto.AgentSession"
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ }
+ }
+ }
+ },
+ "/workspaces/{id}/agent/sessions/{sid}/cancel": {
+ "post": {
+ "tags": [
+ "agent"
+ ],
+ "summary": "Cancel agent session",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Workspace ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ },
+ {
+ "type": "string",
+ "description": "Session ID",
+ "name": "sid",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK"
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ }
+ }
+ }
+ },
+ "/workspaces/{id}/agent/sessions/{sid}/prompts/clear": {
+ "post": {
+ "tags": [
+ "agent"
+ ],
+ "summary": "Clear prompt queue",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Workspace ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ },
+ {
+ "type": "string",
+ "description": "Session ID",
+ "name": "sid",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK"
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ }
+ }
+ }
+ },
+ "/workspaces/{id}/agent/sessions/{sid}/prompts/list": {
+ "get": {
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "agent"
+ ],
+ "summary": "List queued prompts",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Workspace ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ },
+ {
+ "type": "string",
+ "description": "Session ID",
+ "name": "sid",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ }
+ }
+ }
+ },
+ "/workspaces/{id}/agent/sessions/{sid}/prompts/queued": {
+ "get": {
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "agent"
+ ],
+ "summary": "Get queued prompt status",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Workspace ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ },
+ {
+ "type": "string",
+ "description": "Session ID",
+ "name": "sid",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "type": "object"
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ }
+ }
+ }
+ },
+ "/workspaces/{id}/agent/sessions/{sid}/summarize": {
+ "post": {
+ "tags": [
+ "agent"
+ ],
+ "summary": "Summarize session",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Workspace ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ },
+ {
+ "type": "string",
+ "description": "Session ID",
+ "name": "sid",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK"
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ }
+ }
+ }
+ },
+ "/workspaces/{id}/agent/update": {
+ "post": {
+ "tags": [
+ "agent"
+ ],
+ "summary": "Update agent",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Workspace ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK"
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ }
+ }
+ }
+ },
+ "/workspaces/{id}/config": {
+ "get": {
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "workspaces"
+ ],
+ "summary": "Get workspace config",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Workspace ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "type": "object"
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ }
+ }
+ }
+ },
+ "/workspaces/{id}/config/compact": {
+ "post": {
+ "consumes": [
+ "application/json"
+ ],
+ "tags": [
+ "config"
+ ],
+ "summary": "Set compact mode",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Workspace ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ },
+ {
+ "description": "Config compact request",
+ "name": "request",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/proto.ConfigCompactRequest"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK"
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ }
+ }
+ }
+ },
+ "/workspaces/{id}/config/import-copilot": {
+ "post": {
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "config"
+ ],
+ "summary": "Import Copilot credentials",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Workspace ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/proto.ImportCopilotResponse"
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ }
+ }
+ }
+ },
+ "/workspaces/{id}/config/model": {
+ "post": {
+ "consumes": [
+ "application/json"
+ ],
+ "tags": [
+ "config"
+ ],
+ "summary": "Set the preferred model",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Workspace ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ },
+ {
+ "description": "Config model request",
+ "name": "request",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/proto.ConfigModelRequest"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK"
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ }
+ }
+ }
+ },
+ "/workspaces/{id}/config/provider-key": {
+ "post": {
+ "consumes": [
+ "application/json"
+ ],
+ "tags": [
+ "config"
+ ],
+ "summary": "Set provider API key",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Workspace ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ },
+ {
+ "description": "Config provider key request",
+ "name": "request",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/proto.ConfigProviderKeyRequest"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK"
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ }
+ }
+ }
+ },
+ "/workspaces/{id}/config/refresh-oauth": {
+ "post": {
+ "consumes": [
+ "application/json"
+ ],
+ "tags": [
+ "config"
+ ],
+ "summary": "Refresh OAuth token",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Workspace ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ },
+ {
+ "description": "Refresh OAuth request",
+ "name": "request",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/proto.ConfigRefreshOAuthRequest"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK"
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ }
+ }
+ }
+ },
+ "/workspaces/{id}/config/remove": {
+ "post": {
+ "consumes": [
+ "application/json"
+ ],
+ "tags": [
+ "config"
+ ],
+ "summary": "Remove a config field",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Workspace ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ },
+ {
+ "description": "Config remove request",
+ "name": "request",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/proto.ConfigRemoveRequest"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK"
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ }
+ }
+ }
+ },
+ "/workspaces/{id}/config/set": {
+ "post": {
+ "consumes": [
+ "application/json"
+ ],
+ "tags": [
+ "config"
+ ],
+ "summary": "Set a config field",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Workspace ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ },
+ {
+ "description": "Config set request",
+ "name": "request",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/proto.ConfigSetRequest"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK"
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ }
+ }
+ }
+ },
+ "/workspaces/{id}/events": {
+ "get": {
+ "produces": [
+ "text/event-stream"
+ ],
+ "tags": [
+ "workspaces"
+ ],
+ "summary": "Stream workspace events (SSE)",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Workspace ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK"
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ }
+ }
+ }
+ },
+ "/workspaces/{id}/filetracker/lastread": {
+ "get": {
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "filetracker"
+ ],
+ "summary": "Get last read time for file",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Workspace ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ },
+ {
+ "type": "string",
+ "description": "Session ID",
+ "name": "session_id",
+ "in": "query"
+ },
+ {
+ "type": "string",
+ "description": "File path",
+ "name": "path",
+ "in": "query",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "type": "object"
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ }
+ }
+ }
+ },
+ "/workspaces/{id}/filetracker/read": {
+ "post": {
+ "consumes": [
+ "application/json"
+ ],
+ "tags": [
+ "filetracker"
+ ],
+ "summary": "Record file read",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Workspace ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ },
+ {
+ "description": "File tracker read request",
+ "name": "request",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/proto.FileTrackerReadRequest"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK"
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ }
+ }
+ }
+ },
+ "/workspaces/{id}/lsps": {
+ "get": {
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "lsp"
+ ],
+ "summary": "List LSP clients",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Workspace ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "type": "object",
+ "additionalProperties": {
+ "$ref": "#/definitions/proto.LSPClientInfo"
+ }
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ }
+ }
+ }
+ },
+ "/workspaces/{id}/lsps/start": {
+ "post": {
+ "consumes": [
+ "application/json"
+ ],
+ "tags": [
+ "lsp"
+ ],
+ "summary": "Start LSP server",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Workspace ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ },
+ {
+ "description": "LSP start request",
+ "name": "request",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/proto.LSPStartRequest"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK"
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ }
+ }
+ }
+ },
+ "/workspaces/{id}/lsps/stop": {
+ "post": {
+ "tags": [
+ "lsp"
+ ],
+ "summary": "Stop all LSP servers",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Workspace ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK"
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ }
+ }
+ }
+ },
+ "/workspaces/{id}/lsps/{lsp}/diagnostics": {
+ "get": {
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "lsp"
+ ],
+ "summary": "Get LSP diagnostics",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Workspace ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ },
+ {
+ "type": "string",
+ "description": "LSP client name",
+ "name": "lsp",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "type": "object"
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ }
+ }
+ }
+ },
+ "/workspaces/{id}/mcp/get-prompt": {
+ "post": {
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "mcp"
+ ],
+ "summary": "Get MCP prompt",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Workspace ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ },
+ {
+ "description": "MCP get prompt request",
+ "name": "request",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/proto.MCPGetPromptRequest"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/proto.MCPGetPromptResponse"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ }
+ }
+ }
+ },
+ "/workspaces/{id}/mcp/read-resource": {
+ "post": {
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "mcp"
+ ],
+ "summary": "Read MCP resource",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Workspace ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ },
+ {
+ "description": "MCP read resource request",
+ "name": "request",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/proto.MCPReadResourceRequest"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "type": "object"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ }
+ }
+ }
+ },
+ "/workspaces/{id}/mcp/refresh-prompts": {
+ "post": {
+ "consumes": [
+ "application/json"
+ ],
+ "tags": [
+ "mcp"
+ ],
+ "summary": "Refresh MCP prompts",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Workspace ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ },
+ {
+ "description": "MCP name request",
+ "name": "request",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/proto.MCPNameRequest"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK"
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ }
+ }
+ }
+ },
+ "/workspaces/{id}/mcp/refresh-resources": {
+ "post": {
+ "consumes": [
+ "application/json"
+ ],
+ "tags": [
+ "mcp"
+ ],
+ "summary": "Refresh MCP resources",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Workspace ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ },
+ {
+ "description": "MCP name request",
+ "name": "request",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/proto.MCPNameRequest"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK"
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ }
+ }
+ }
+ },
+ "/workspaces/{id}/mcp/refresh-tools": {
+ "post": {
+ "consumes": [
+ "application/json"
+ ],
+ "tags": [
+ "mcp"
+ ],
+ "summary": "Refresh MCP tools",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Workspace ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ },
+ {
+ "description": "MCP name request",
+ "name": "request",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/proto.MCPNameRequest"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK"
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ }
+ }
+ }
+ },
+ "/workspaces/{id}/mcp/states": {
+ "get": {
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "mcp"
+ ],
+ "summary": "Get MCP client states",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Workspace ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "type": "object",
+ "additionalProperties": {
+ "$ref": "#/definitions/proto.MCPClientInfo"
+ }
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ }
+ }
+ }
+ },
+ "/workspaces/{id}/messages/user": {
+ "get": {
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "workspaces"
+ ],
+ "summary": "Get all user messages for workspace",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Workspace ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/github_com_charmbracelet_crush_internal_proto.Message"
+ }
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ }
+ }
+ }
+ },
+ "/workspaces/{id}/permissions/grant": {
+ "post": {
+ "consumes": [
+ "application/json"
+ ],
+ "tags": [
+ "permissions"
+ ],
+ "summary": "Grant permission",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Workspace ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ },
+ {
+ "description": "Permission grant",
+ "name": "request",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/proto.PermissionGrant"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK"
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ }
+ }
+ }
+ },
+ "/workspaces/{id}/permissions/skip": {
+ "get": {
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "permissions"
+ ],
+ "summary": "Get skip permissions status",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Workspace ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/proto.PermissionSkipRequest"
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ }
+ }
+ },
+ "post": {
+ "consumes": [
+ "application/json"
+ ],
+ "tags": [
+ "permissions"
+ ],
+ "summary": "Set skip permissions",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Workspace ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ },
+ {
+ "description": "Permission skip request",
+ "name": "request",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/proto.PermissionSkipRequest"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK"
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ }
+ }
+ }
+ },
+ "/workspaces/{id}/project/init": {
+ "post": {
+ "tags": [
+ "project"
+ ],
+ "summary": "Mark project as initialized",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Workspace ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK"
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ }
+ }
+ }
+ },
+ "/workspaces/{id}/project/init-prompt": {
+ "get": {
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "project"
+ ],
+ "summary": "Get project initialization prompt",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Workspace ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/proto.ProjectInitPromptResponse"
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ }
+ }
+ }
+ },
+ "/workspaces/{id}/project/needs-init": {
+ "get": {
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "project"
+ ],
+ "summary": "Check if project needs initialization",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Workspace ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/proto.ProjectNeedsInitResponse"
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ }
+ }
+ }
+ },
+ "/workspaces/{id}/providers": {
+ "get": {
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "workspaces"
+ ],
+ "summary": "Get workspace providers",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Workspace ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "type": "object"
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ }
+ }
+ }
+ },
+ "/workspaces/{id}/sessions": {
+ "get": {
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "sessions"
+ ],
+ "summary": "List sessions",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Workspace ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/proto.Session"
+ }
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ }
+ }
+ },
+ "post": {
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "sessions"
+ ],
+ "summary": "Create session",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Workspace ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ },
+ {
+ "description": "Session creation params (title)",
+ "name": "request",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/proto.Session"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/proto.Session"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ }
+ }
+ }
+ },
+ "/workspaces/{id}/sessions/{sid}": {
+ "get": {
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "sessions"
+ ],
+ "summary": "Get session",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Workspace ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ },
+ {
+ "type": "string",
+ "description": "Session ID",
+ "name": "sid",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/proto.Session"
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ }
+ }
+ },
+ "put": {
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "sessions"
+ ],
+ "summary": "Update session",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Workspace ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ },
+ {
+ "type": "string",
+ "description": "Session ID",
+ "name": "sid",
+ "in": "path",
+ "required": true
+ },
+ {
+ "description": "Updated session",
+ "name": "request",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/proto.Session"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/proto.Session"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ }
+ }
+ },
+ "delete": {
+ "tags": [
+ "sessions"
+ ],
+ "summary": "Delete session",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Workspace ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ },
+ {
+ "type": "string",
+ "description": "Session ID",
+ "name": "sid",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK"
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ }
+ }
+ }
+ },
+ "/workspaces/{id}/sessions/{sid}/filetracker/files": {
+ "get": {
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "filetracker"
+ ],
+ "summary": "List tracked files for session",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Workspace ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ },
+ {
+ "type": "string",
+ "description": "Session ID",
+ "name": "sid",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ }
+ }
+ }
+ },
+ "/workspaces/{id}/sessions/{sid}/history": {
+ "get": {
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "sessions"
+ ],
+ "summary": "Get session history",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Workspace ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ },
+ {
+ "type": "string",
+ "description": "Session ID",
+ "name": "sid",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/proto.File"
+ }
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ }
+ }
+ }
+ },
+ "/workspaces/{id}/sessions/{sid}/messages": {
+ "get": {
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "sessions"
+ ],
+ "summary": "Get session messages",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Workspace ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ },
+ {
+ "type": "string",
+ "description": "Session ID",
+ "name": "sid",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/github_com_charmbracelet_crush_internal_proto.Message"
+ }
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ }
+ }
+ }
+ },
+ "/workspaces/{id}/sessions/{sid}/messages/user": {
+ "get": {
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "sessions"
+ ],
+ "summary": "Get user messages for session",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Workspace ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ },
+ {
+ "type": "string",
+ "description": "Session ID",
+ "name": "sid",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/github_com_charmbracelet_crush_internal_proto.Message"
+ }
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ }
+ }
+ }
+ }
+ },
+ "definitions": {
+ "catwalk.Model": {
+ "type": "object",
+ "properties": {
+ "can_reason": {
+ "type": "boolean"
+ },
+ "context_window": {
+ "type": "integer"
+ },
+ "cost_per_1m_in": {
+ "type": "number"
+ },
+ "cost_per_1m_in_cached": {
+ "type": "number"
+ },
+ "cost_per_1m_out": {
+ "type": "number"
+ },
+ "cost_per_1m_out_cached": {
+ "type": "number"
+ },
+ "default_max_tokens": {
+ "type": "integer"
+ },
+ "default_reasoning_effort": {
+ "type": "string"
+ },
+ "id": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ },
+ "options": {
+ "$ref": "#/definitions/catwalk.ModelOptions"
+ },
+ "reasoning_levels": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "supports_attachments": {
+ "type": "boolean"
+ }
+ }
+ },
+ "catwalk.ModelOptions": {
+ "type": "object",
+ "properties": {
+ "frequency_penalty": {
+ "type": "number"
+ },
+ "presence_penalty": {
+ "type": "number"
+ },
+ "provider_options": {
+ "type": "object",
+ "additionalProperties": {}
+ },
+ "temperature": {
+ "type": "number"
+ },
+ "top_k": {
+ "type": "integer"
+ },
+ "top_p": {
+ "type": "number"
+ }
+ }
+ },
+ "config.Attribution": {
+ "type": "object",
+ "properties": {
+ "co_authored_by": {
+ "type": "boolean"
+ },
+ "generated_with": {
+ "type": "boolean"
+ },
+ "trailer_style": {
+ "$ref": "#/definitions/config.TrailerStyle"
+ }
+ }
+ },
+ "config.Completions": {
+ "type": "object",
+ "properties": {
+ "max_depth": {
+ "type": "integer"
+ },
+ "max_items": {
+ "type": "integer"
+ }
+ }
+ },
+ "config.LSPConfig": {
+ "type": "object",
+ "properties": {
+ "args": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "command": {
+ "type": "string"
+ },
+ "disabled": {
+ "type": "boolean"
+ },
+ "env": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ },
+ "filetypes": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "init_options": {
+ "type": "object",
+ "additionalProperties": {}
+ },
+ "options": {
+ "type": "object",
+ "additionalProperties": {}
+ },
+ "root_markers": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "timeout": {
+ "type": "integer"
+ }
+ }
+ },
+ "config.LSPs": {
+ "type": "object",
+ "additionalProperties": {
+ "$ref": "#/definitions/config.LSPConfig"
+ }
+ },
+ "config.MCPConfig": {
+ "type": "object",
+ "properties": {
+ "args": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "command": {
+ "type": "string"
+ },
+ "disabled": {
+ "type": "boolean"
+ },
+ "disabled_tools": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "env": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ },
+ "headers": {
+ "description": "TODO: maybe make it possible to get the value from the env",
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ },
+ "timeout": {
+ "type": "integer"
+ },
+ "type": {
+ "$ref": "#/definitions/config.MCPType"
+ },
+ "url": {
+ "type": "string"
+ }
+ }
+ },
+ "config.MCPType": {
+ "type": "string",
+ "enum": [
+ "stdio",
+ "sse",
+ "http"
+ ],
+ "x-enum-varnames": [
+ "MCPStdio",
+ "MCPSSE",
+ "MCPHttp"
+ ]
+ },
+ "config.MCPs": {
+ "type": "object",
+ "additionalProperties": {
+ "$ref": "#/definitions/config.MCPConfig"
+ }
+ },
+ "config.Permissions": {
+ "type": "object",
+ "properties": {
+ "allowed_tools": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ }
+ },
+ "config.Scope": {
+ "type": "integer",
+ "enum": [
+ 0,
+ 1
+ ],
+ "x-enum-varnames": [
+ "ScopeGlobal",
+ "ScopeWorkspace"
+ ]
+ },
+ "config.SelectedModel": {
+ "type": "object",
+ "properties": {
+ "frequency_penalty": {
+ "type": "number"
+ },
+ "max_tokens": {
+ "description": "Overrides the default model configuration.",
+ "type": "integer"
+ },
+ "model": {
+ "description": "The model id as used by the provider API.\nRequired.",
+ "type": "string"
+ },
+ "presence_penalty": {
+ "type": "number"
+ },
+ "provider": {
+ "description": "The model provider, same as the key/id used in the providers config.\nRequired.",
+ "type": "string"
+ },
+ "provider_options": {
+ "description": "Override provider specific options.",
+ "type": "object",
+ "additionalProperties": {}
+ },
+ "reasoning_effort": {
+ "description": "Only used by models that use the openai provider and need this set.",
+ "type": "string"
+ },
+ "temperature": {
+ "type": "number"
+ },
+ "think": {
+ "description": "Used by anthropic models that can reason to indicate if the model should think.",
+ "type": "boolean"
+ },
+ "top_k": {
+ "type": "integer"
+ },
+ "top_p": {
+ "type": "number"
+ }
+ }
+ },
+ "config.SelectedModelType": {
+ "type": "string",
+ "enum": [
+ "large",
+ "small"
+ ],
+ "x-enum-varnames": [
+ "SelectedModelTypeLarge",
+ "SelectedModelTypeSmall"
+ ]
+ },
+ "config.TUIOptions": {
+ "type": "object",
+ "properties": {
+ "compact_mode": {
+ "type": "boolean"
+ },
+ "completions": {
+ "$ref": "#/definitions/config.Completions"
+ },
+ "diff_mode": {
+ "type": "string"
+ },
+ "transparent": {
+ "type": "boolean"
+ }
+ }
+ },
+ "config.ToolGrep": {
+ "type": "object",
+ "properties": {
+ "timeout": {
+ "$ref": "#/definitions/time.Duration"
+ }
+ }
+ },
+ "config.ToolLs": {
+ "type": "object",
+ "properties": {
+ "max_depth": {
+ "type": "integer"
+ },
+ "max_items": {
+ "type": "integer"
+ }
+ }
+ },
+ "config.Tools": {
+ "type": "object",
+ "properties": {
+ "grep": {
+ "$ref": "#/definitions/config.ToolGrep"
+ },
+ "ls": {
+ "$ref": "#/definitions/config.ToolLs"
+ }
+ }
+ },
+ "config.TrailerStyle": {
+ "type": "string",
+ "enum": [
+ "none",
+ "co-authored-by",
+ "assisted-by"
+ ],
+ "x-enum-varnames": [
+ "TrailerStyleNone",
+ "TrailerStyleCoAuthoredBy",
+ "TrailerStyleAssistedBy"
+ ]
+ },
+ "csync.Map-string-config_ProviderConfig": {
+ "type": "object"
+ },
+ "github_com_charmbracelet_crush_internal_config.Config": {
+ "type": "object",
+ "properties": {
+ "$schema": {
+ "type": "string"
+ },
+ "lsp": {
+ "$ref": "#/definitions/config.LSPs"
+ },
+ "mcp": {
+ "$ref": "#/definitions/config.MCPs"
+ },
+ "models": {
+ "description": "We currently only support large/small as values here.",
+ "type": "object",
+ "additionalProperties": {
+ "$ref": "#/definitions/config.SelectedModel"
+ }
+ },
+ "options": {
+ "$ref": "#/definitions/github_com_charmbracelet_crush_internal_config.Options"
+ },
+ "permissions": {
+ "$ref": "#/definitions/config.Permissions"
+ },
+ "providers": {
+ "description": "The providers that are configured",
+ "allOf": [
+ {
+ "$ref": "#/definitions/csync.Map-string-config_ProviderConfig"
+ }
+ ]
+ },
+ "recent_models": {
+ "description": "Recently used models stored in the data directory config.",
+ "type": "object",
+ "additionalProperties": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/config.SelectedModel"
+ }
+ }
+ },
+ "tools": {
+ "$ref": "#/definitions/config.Tools"
+ }
+ }
+ },
+ "github_com_charmbracelet_crush_internal_config.Options": {
+ "type": "object",
+ "properties": {
+ "attribution": {
+ "$ref": "#/definitions/config.Attribution"
+ },
+ "auto_lsp": {
+ "type": "boolean"
+ },
+ "context_paths": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "data_directory": {
+ "description": "Relative to the cwd",
+ "type": "string"
+ },
+ "debug": {
+ "type": "boolean"
+ },
+ "debug_lsp": {
+ "type": "boolean"
+ },
+ "disable_auto_summarize": {
+ "type": "boolean"
+ },
+ "disable_default_providers": {
+ "type": "boolean"
+ },
+ "disable_metrics": {
+ "type": "boolean"
+ },
+ "disable_notifications": {
+ "type": "boolean"
+ },
+ "disable_provider_auto_update": {
+ "type": "boolean"
+ },
+ "disabled_tools": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "initialize_as": {
+ "type": "string"
+ },
+ "progress": {
+ "type": "boolean"
+ },
+ "skills_paths": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "tui": {
+ "$ref": "#/definitions/config.TUIOptions"
+ }
+ }
+ },
+ "github_com_charmbracelet_crush_internal_proto.Message": {
+ "type": "object",
+ "properties": {
+ "created_at": {
+ "type": "integer"
+ },
+ "id": {
+ "type": "string"
+ },
+ "model": {
+ "type": "string"
+ },
+ "parts": {
+ "type": "array",
+ "items": {}
+ },
+ "provider": {
+ "type": "string"
+ },
+ "role": {
+ "$ref": "#/definitions/proto.MessageRole"
+ },
+ "session_id": {
+ "type": "string"
+ },
+ "updated_at": {
+ "type": "integer"
+ }
+ }
+ },
+ "lsp.ServerState": {
+ "type": "integer",
+ "enum": [
+ 0,
+ 1,
+ 2,
+ 3,
+ 4,
+ 5
+ ],
+ "x-enum-varnames": [
+ "StateUnstarted",
+ "StateStarting",
+ "StateReady",
+ "StateError",
+ "StateStopped",
+ "StateDisabled"
+ ]
+ },
+ "proto.AgentInfo": {
+ "type": "object",
+ "properties": {
+ "is_busy": {
+ "type": "boolean"
+ },
+ "is_ready": {
+ "type": "boolean"
+ },
+ "model": {
+ "$ref": "#/definitions/catwalk.Model"
+ },
+ "model_cfg": {
+ "$ref": "#/definitions/config.SelectedModel"
+ }
+ }
+ },
+ "proto.AgentMessage": {
+ "type": "object",
+ "properties": {
+ "attachments": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/proto.Attachment"
+ }
+ },
+ "prompt": {
+ "type": "string"
+ },
+ "session_id": {
+ "type": "string"
+ }
+ }
+ },
+ "proto.AgentSession": {
+ "type": "object",
+ "properties": {
+ "completion_tokens": {
+ "type": "integer"
+ },
+ "cost": {
+ "type": "number"
+ },
+ "created_at": {
+ "type": "integer"
+ },
+ "id": {
+ "type": "string"
+ },
+ "is_busy": {
+ "type": "boolean"
+ },
+ "message_count": {
+ "type": "integer"
+ },
+ "parent_session_id": {
+ "type": "string"
+ },
+ "prompt_tokens": {
+ "type": "integer"
+ },
+ "summary_message_id": {
+ "type": "string"
+ },
+ "title": {
+ "type": "string"
+ },
+ "updated_at": {
+ "type": "integer"
+ }
+ }
+ },
+ "proto.Attachment": {
+ "type": "object",
+ "properties": {
+ "content": {
+ "type": "array",
+ "items": {
+ "type": "integer"
+ }
+ },
+ "file_name": {
+ "type": "string"
+ },
+ "file_path": {
+ "type": "string"
+ },
+ "mime_type": {
+ "type": "string"
+ }
+ }
+ },
+ "proto.ConfigCompactRequest": {
+ "type": "object",
+ "properties": {
+ "enabled": {
+ "type": "boolean"
+ },
+ "scope": {
+ "$ref": "#/definitions/config.Scope"
+ }
+ }
+ },
+ "proto.ConfigModelRequest": {
+ "type": "object",
+ "properties": {
+ "model": {
+ "$ref": "#/definitions/config.SelectedModel"
+ },
+ "model_type": {
+ "$ref": "#/definitions/config.SelectedModelType"
+ },
+ "scope": {
+ "$ref": "#/definitions/config.Scope"
+ }
+ }
+ },
+ "proto.ConfigProviderKeyRequest": {
+ "type": "object",
+ "properties": {
+ "api_key": {},
+ "provider_id": {
+ "type": "string"
+ },
+ "scope": {
+ "$ref": "#/definitions/config.Scope"
+ }
+ }
+ },
+ "proto.ConfigRefreshOAuthRequest": {
+ "type": "object",
+ "properties": {
+ "provider_id": {
+ "type": "string"
+ },
+ "scope": {
+ "$ref": "#/definitions/config.Scope"
+ }
+ }
+ },
+ "proto.ConfigRemoveRequest": {
+ "type": "object",
+ "properties": {
+ "key": {
+ "type": "string"
+ },
+ "scope": {
+ "$ref": "#/definitions/config.Scope"
+ }
+ }
+ },
+ "proto.ConfigSetRequest": {
+ "type": "object",
+ "properties": {
+ "key": {
+ "type": "string"
+ },
+ "scope": {
+ "$ref": "#/definitions/config.Scope"
+ },
+ "value": {}
+ }
+ },
+ "proto.Error": {
+ "type": "object",
+ "properties": {
+ "message": {
+ "type": "string"
+ }
+ }
+ },
+ "proto.File": {
+ "type": "object",
+ "properties": {
+ "content": {
+ "type": "string"
+ },
+ "created_at": {
+ "type": "integer"
+ },
+ "id": {
+ "type": "string"
+ },
+ "path": {
+ "type": "string"
+ },
+ "session_id": {
+ "type": "string"
+ },
+ "updated_at": {
+ "type": "integer"
+ },
+ "version": {
+ "type": "integer"
+ }
+ }
+ },
+ "proto.FileTrackerReadRequest": {
+ "type": "object",
+ "properties": {
+ "path": {
+ "type": "string"
+ },
+ "session_id": {
+ "type": "string"
+ }
+ }
+ },
+ "proto.ImportCopilotResponse": {
+ "type": "object",
+ "properties": {
+ "success": {
+ "type": "boolean"
+ },
+ "token": {}
+ }
+ },
+ "proto.LSPClientInfo": {
+ "type": "object",
+ "properties": {
+ "connected_at": {
+ "type": "string"
+ },
+ "diagnostic_count": {
+ "type": "integer"
+ },
+ "error": {},
+ "name": {
+ "type": "string"
+ },
+ "state": {
+ "$ref": "#/definitions/lsp.ServerState"
+ }
+ }
+ },
+ "proto.LSPStartRequest": {
+ "type": "object",
+ "properties": {
+ "path": {
+ "type": "string"
+ }
+ }
+ },
+ "proto.MCPClientInfo": {
+ "type": "object",
+ "properties": {
+ "connected_at": {
+ "type": "string"
+ },
+ "error": {},
+ "name": {
+ "type": "string"
+ },
+ "prompt_count": {
+ "type": "integer"
+ },
+ "resource_count": {
+ "type": "integer"
+ },
+ "state": {
+ "$ref": "#/definitions/proto.MCPState"
+ },
+ "tool_count": {
+ "type": "integer"
+ }
+ }
+ },
+ "proto.MCPGetPromptRequest": {
+ "type": "object",
+ "properties": {
+ "args": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ },
+ "client_id": {
+ "type": "string"
+ },
+ "prompt_id": {
+ "type": "string"
+ }
+ }
+ },
+ "proto.MCPGetPromptResponse": {
+ "type": "object",
+ "properties": {
+ "prompt": {
+ "type": "string"
+ }
+ }
+ },
+ "proto.MCPNameRequest": {
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string"
+ }
+ }
+ },
+ "proto.MCPReadResourceRequest": {
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string"
+ },
+ "uri": {
+ "type": "string"
+ }
+ }
+ },
+ "proto.MCPState": {
+ "type": "integer",
+ "enum": [
+ 0,
+ 1,
+ 2,
+ 3
+ ],
+ "x-enum-varnames": [
+ "MCPStateDisabled",
+ "MCPStateStarting",
+ "MCPStateConnected",
+ "MCPStateError"
+ ]
+ },
+ "proto.MessageRole": {
+ "type": "string",
+ "enum": [
+ "assistant",
+ "user",
+ "system",
+ "tool"
+ ],
+ "x-enum-varnames": [
+ "Assistant",
+ "User",
+ "System",
+ "Tool"
+ ]
+ },
+ "proto.PermissionAction": {
+ "type": "string",
+ "enum": [
+ "allow",
+ "allow_session",
+ "deny"
+ ],
+ "x-enum-varnames": [
+ "PermissionAllow",
+ "PermissionAllowForSession",
+ "PermissionDeny"
+ ]
+ },
+ "proto.PermissionGrant": {
+ "type": "object",
+ "properties": {
+ "action": {
+ "$ref": "#/definitions/proto.PermissionAction"
+ },
+ "permission": {
+ "$ref": "#/definitions/proto.PermissionRequest"
+ }
+ }
+ },
+ "proto.PermissionRequest": {
+ "type": "object",
+ "properties": {
+ "action": {
+ "type": "string"
+ },
+ "description": {
+ "type": "string"
+ },
+ "id": {
+ "type": "string"
+ },
+ "params": {},
+ "path": {
+ "type": "string"
+ },
+ "session_id": {
+ "type": "string"
+ },
+ "tool_call_id": {
+ "type": "string"
+ },
+ "tool_name": {
+ "type": "string"
+ }
+ }
+ },
+ "proto.PermissionSkipRequest": {
+ "type": "object",
+ "properties": {
+ "skip": {
+ "type": "boolean"
+ }
+ }
+ },
+ "proto.ProjectInitPromptResponse": {
+ "type": "object",
+ "properties": {
+ "prompt": {
+ "type": "string"
+ }
+ }
+ },
+ "proto.ProjectNeedsInitResponse": {
+ "type": "object",
+ "properties": {
+ "needs_init": {
+ "type": "boolean"
+ }
+ }
+ },
+ "proto.ServerControl": {
+ "type": "object",
+ "properties": {
+ "command": {
+ "type": "string"
+ }
+ }
+ },
+ "proto.Session": {
+ "type": "object",
+ "properties": {
+ "completion_tokens": {
+ "type": "integer"
+ },
+ "cost": {
+ "type": "number"
+ },
+ "created_at": {
+ "type": "integer"
+ },
+ "id": {
+ "type": "string"
+ },
+ "message_count": {
+ "type": "integer"
+ },
+ "parent_session_id": {
+ "type": "string"
+ },
+ "prompt_tokens": {
+ "type": "integer"
+ },
+ "summary_message_id": {
+ "type": "string"
+ },
+ "title": {
+ "type": "string"
+ },
+ "updated_at": {
+ "type": "integer"
+ }
+ }
+ },
+ "proto.VersionInfo": {
+ "type": "object",
+ "properties": {
+ "commit": {
+ "type": "string"
+ },
+ "go_version": {
+ "type": "string"
+ },
+ "platform": {
+ "type": "string"
+ },
+ "version": {
+ "type": "string"
+ }
+ }
+ },
+ "proto.Workspace": {
+ "type": "object",
+ "properties": {
+ "config": {
+ "$ref": "#/definitions/github_com_charmbracelet_crush_internal_config.Config"
+ },
+ "data_dir": {
+ "type": "string"
+ },
+ "debug": {
+ "type": "boolean"
+ },
+ "env": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "id": {
+ "type": "string"
+ },
+ "path": {
+ "type": "string"
+ },
+ "version": {
+ "type": "string"
+ },
+ "yolo": {
+ "type": "boolean"
+ }
+ }
+ },
+ "time.Duration": {
+ "type": "integer",
+ "format": "int64",
+ "enum": [
+ -9223372036854775808,
+ 9223372036854775807,
+ 1,
+ 1000,
+ 1000000,
+ 1000000000,
+ 60000000000,
+ 3600000000000
+ ],
+ "x-enum-varnames": [
+ "minDuration",
+ "maxDuration",
+ "Nanosecond",
+ "Microsecond",
+ "Millisecond",
+ "Second",
+ "Minute",
+ "Hour"
+ ]
+ }
+ }
+}`
+
+// SwaggerInfo holds exported Swagger Info so clients can modify it
+var SwaggerInfo = &swag.Spec{
+ Version: "1.0",
+ Host: "",
+ BasePath: "/v1",
+ Schemes: []string{},
+ Title: "Crush API",
+ Description: "Crush is a terminal-based AI coding assistant. This API is served over a Unix socket (or Windows named pipe) and provides programmatic access to workspaces, sessions, agents, LSP, MCP, and more.",
+ InfoInstanceName: "swagger",
+ SwaggerTemplate: docTemplate,
+ LeftDelim: "{{",
+ RightDelim: "}}",
+}
+
+func init() {
+ swag.Register(SwaggerInfo.InstanceName(), SwaggerInfo)
+}
@@ -0,0 +1,3564 @@
+{
+ "swagger": "2.0",
+ "info": {
+ "description": "Crush is a terminal-based AI coding assistant. This API is served over a Unix socket (or Windows named pipe) and provides programmatic access to workspaces, sessions, agents, LSP, MCP, and more.",
+ "title": "Crush API",
+ "contact": {
+ "name": "Charm",
+ "url": "https://charm.sh"
+ },
+ "license": {
+ "name": "MIT",
+ "url": "https://github.com/charmbracelet/crush/blob/main/LICENSE"
+ },
+ "version": "1.0"
+ },
+ "basePath": "/v1",
+ "paths": {
+ "/config": {
+ "get": {
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "system"
+ ],
+ "summary": "Get server config",
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "type": "object"
+ }
+ }
+ }
+ }
+ },
+ "/control": {
+ "post": {
+ "consumes": [
+ "application/json"
+ ],
+ "tags": [
+ "system"
+ ],
+ "summary": "Send server control command",
+ "parameters": [
+ {
+ "description": "Control command (e.g. shutdown)",
+ "name": "request",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/proto.ServerControl"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK"
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ }
+ }
+ }
+ },
+ "/health": {
+ "get": {
+ "tags": [
+ "system"
+ ],
+ "summary": "Health check",
+ "responses": {
+ "200": {
+ "description": "OK"
+ }
+ }
+ }
+ },
+ "/version": {
+ "get": {
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "system"
+ ],
+ "summary": "Get server version",
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/proto.VersionInfo"
+ }
+ }
+ }
+ }
+ },
+ "/workspaces": {
+ "get": {
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "workspaces"
+ ],
+ "summary": "List workspaces",
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/proto.Workspace"
+ }
+ }
+ }
+ }
+ },
+ "post": {
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "workspaces"
+ ],
+ "summary": "Create workspace",
+ "parameters": [
+ {
+ "description": "Workspace creation params",
+ "name": "request",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/proto.Workspace"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/proto.Workspace"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ }
+ }
+ }
+ },
+ "/workspaces/{id}": {
+ "get": {
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "workspaces"
+ ],
+ "summary": "Get workspace",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Workspace ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/proto.Workspace"
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ }
+ }
+ },
+ "delete": {
+ "tags": [
+ "workspaces"
+ ],
+ "summary": "Delete workspace",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Workspace ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK"
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ }
+ }
+ }
+ },
+ "/workspaces/{id}/agent": {
+ "get": {
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "agent"
+ ],
+ "summary": "Get agent info",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Workspace ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/proto.AgentInfo"
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ }
+ }
+ },
+ "post": {
+ "consumes": [
+ "application/json"
+ ],
+ "tags": [
+ "agent"
+ ],
+ "summary": "Send message to agent",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Workspace ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ },
+ {
+ "description": "Agent message",
+ "name": "request",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/proto.AgentMessage"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK"
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ }
+ }
+ }
+ },
+ "/workspaces/{id}/agent/default-small-model": {
+ "get": {
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "agent"
+ ],
+ "summary": "Get default small model",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Workspace ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ },
+ {
+ "type": "string",
+ "description": "Provider ID",
+ "name": "provider_id",
+ "in": "query"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "type": "object"
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ }
+ }
+ }
+ },
+ "/workspaces/{id}/agent/init": {
+ "post": {
+ "tags": [
+ "agent"
+ ],
+ "summary": "Initialize agent",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Workspace ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK"
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ }
+ }
+ }
+ },
+ "/workspaces/{id}/agent/sessions/{sid}": {
+ "get": {
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "agent"
+ ],
+ "summary": "Get agent session",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Workspace ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ },
+ {
+ "type": "string",
+ "description": "Session ID",
+ "name": "sid",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/proto.AgentSession"
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ }
+ }
+ }
+ },
+ "/workspaces/{id}/agent/sessions/{sid}/cancel": {
+ "post": {
+ "tags": [
+ "agent"
+ ],
+ "summary": "Cancel agent session",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Workspace ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ },
+ {
+ "type": "string",
+ "description": "Session ID",
+ "name": "sid",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK"
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ }
+ }
+ }
+ },
+ "/workspaces/{id}/agent/sessions/{sid}/prompts/clear": {
+ "post": {
+ "tags": [
+ "agent"
+ ],
+ "summary": "Clear prompt queue",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Workspace ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ },
+ {
+ "type": "string",
+ "description": "Session ID",
+ "name": "sid",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK"
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ }
+ }
+ }
+ },
+ "/workspaces/{id}/agent/sessions/{sid}/prompts/list": {
+ "get": {
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "agent"
+ ],
+ "summary": "List queued prompts",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Workspace ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ },
+ {
+ "type": "string",
+ "description": "Session ID",
+ "name": "sid",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ }
+ }
+ }
+ },
+ "/workspaces/{id}/agent/sessions/{sid}/prompts/queued": {
+ "get": {
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "agent"
+ ],
+ "summary": "Get queued prompt status",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Workspace ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ },
+ {
+ "type": "string",
+ "description": "Session ID",
+ "name": "sid",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "type": "object"
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ }
+ }
+ }
+ },
+ "/workspaces/{id}/agent/sessions/{sid}/summarize": {
+ "post": {
+ "tags": [
+ "agent"
+ ],
+ "summary": "Summarize session",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Workspace ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ },
+ {
+ "type": "string",
+ "description": "Session ID",
+ "name": "sid",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK"
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ }
+ }
+ }
+ },
+ "/workspaces/{id}/agent/update": {
+ "post": {
+ "tags": [
+ "agent"
+ ],
+ "summary": "Update agent",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Workspace ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK"
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ }
+ }
+ }
+ },
+ "/workspaces/{id}/config": {
+ "get": {
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "workspaces"
+ ],
+ "summary": "Get workspace config",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Workspace ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "type": "object"
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ }
+ }
+ }
+ },
+ "/workspaces/{id}/config/compact": {
+ "post": {
+ "consumes": [
+ "application/json"
+ ],
+ "tags": [
+ "config"
+ ],
+ "summary": "Set compact mode",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Workspace ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ },
+ {
+ "description": "Config compact request",
+ "name": "request",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/proto.ConfigCompactRequest"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK"
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ }
+ }
+ }
+ },
+ "/workspaces/{id}/config/import-copilot": {
+ "post": {
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "config"
+ ],
+ "summary": "Import Copilot credentials",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Workspace ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/proto.ImportCopilotResponse"
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ }
+ }
+ }
+ },
+ "/workspaces/{id}/config/model": {
+ "post": {
+ "consumes": [
+ "application/json"
+ ],
+ "tags": [
+ "config"
+ ],
+ "summary": "Set the preferred model",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Workspace ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ },
+ {
+ "description": "Config model request",
+ "name": "request",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/proto.ConfigModelRequest"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK"
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ }
+ }
+ }
+ },
+ "/workspaces/{id}/config/provider-key": {
+ "post": {
+ "consumes": [
+ "application/json"
+ ],
+ "tags": [
+ "config"
+ ],
+ "summary": "Set provider API key",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Workspace ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ },
+ {
+ "description": "Config provider key request",
+ "name": "request",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/proto.ConfigProviderKeyRequest"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK"
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ }
+ }
+ }
+ },
+ "/workspaces/{id}/config/refresh-oauth": {
+ "post": {
+ "consumes": [
+ "application/json"
+ ],
+ "tags": [
+ "config"
+ ],
+ "summary": "Refresh OAuth token",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Workspace ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ },
+ {
+ "description": "Refresh OAuth request",
+ "name": "request",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/proto.ConfigRefreshOAuthRequest"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK"
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ }
+ }
+ }
+ },
+ "/workspaces/{id}/config/remove": {
+ "post": {
+ "consumes": [
+ "application/json"
+ ],
+ "tags": [
+ "config"
+ ],
+ "summary": "Remove a config field",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Workspace ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ },
+ {
+ "description": "Config remove request",
+ "name": "request",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/proto.ConfigRemoveRequest"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK"
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ }
+ }
+ }
+ },
+ "/workspaces/{id}/config/set": {
+ "post": {
+ "consumes": [
+ "application/json"
+ ],
+ "tags": [
+ "config"
+ ],
+ "summary": "Set a config field",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Workspace ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ },
+ {
+ "description": "Config set request",
+ "name": "request",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/proto.ConfigSetRequest"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK"
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ }
+ }
+ }
+ },
+ "/workspaces/{id}/events": {
+ "get": {
+ "produces": [
+ "text/event-stream"
+ ],
+ "tags": [
+ "workspaces"
+ ],
+ "summary": "Stream workspace events (SSE)",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Workspace ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK"
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ }
+ }
+ }
+ },
+ "/workspaces/{id}/filetracker/lastread": {
+ "get": {
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "filetracker"
+ ],
+ "summary": "Get last read time for file",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Workspace ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ },
+ {
+ "type": "string",
+ "description": "Session ID",
+ "name": "session_id",
+ "in": "query"
+ },
+ {
+ "type": "string",
+ "description": "File path",
+ "name": "path",
+ "in": "query",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "type": "object"
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ }
+ }
+ }
+ },
+ "/workspaces/{id}/filetracker/read": {
+ "post": {
+ "consumes": [
+ "application/json"
+ ],
+ "tags": [
+ "filetracker"
+ ],
+ "summary": "Record file read",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Workspace ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ },
+ {
+ "description": "File tracker read request",
+ "name": "request",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/proto.FileTrackerReadRequest"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK"
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ }
+ }
+ }
+ },
+ "/workspaces/{id}/lsps": {
+ "get": {
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "lsp"
+ ],
+ "summary": "List LSP clients",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Workspace ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "type": "object",
+ "additionalProperties": {
+ "$ref": "#/definitions/proto.LSPClientInfo"
+ }
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ }
+ }
+ }
+ },
+ "/workspaces/{id}/lsps/start": {
+ "post": {
+ "consumes": [
+ "application/json"
+ ],
+ "tags": [
+ "lsp"
+ ],
+ "summary": "Start LSP server",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Workspace ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ },
+ {
+ "description": "LSP start request",
+ "name": "request",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/proto.LSPStartRequest"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK"
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ }
+ }
+ }
+ },
+ "/workspaces/{id}/lsps/stop": {
+ "post": {
+ "tags": [
+ "lsp"
+ ],
+ "summary": "Stop all LSP servers",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Workspace ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK"
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ }
+ }
+ }
+ },
+ "/workspaces/{id}/lsps/{lsp}/diagnostics": {
+ "get": {
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "lsp"
+ ],
+ "summary": "Get LSP diagnostics",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Workspace ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ },
+ {
+ "type": "string",
+ "description": "LSP client name",
+ "name": "lsp",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "type": "object"
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ }
+ }
+ }
+ },
+ "/workspaces/{id}/mcp/get-prompt": {
+ "post": {
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "mcp"
+ ],
+ "summary": "Get MCP prompt",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Workspace ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ },
+ {
+ "description": "MCP get prompt request",
+ "name": "request",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/proto.MCPGetPromptRequest"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/proto.MCPGetPromptResponse"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ }
+ }
+ }
+ },
+ "/workspaces/{id}/mcp/read-resource": {
+ "post": {
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "mcp"
+ ],
+ "summary": "Read MCP resource",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Workspace ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ },
+ {
+ "description": "MCP read resource request",
+ "name": "request",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/proto.MCPReadResourceRequest"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "type": "object"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ }
+ }
+ }
+ },
+ "/workspaces/{id}/mcp/refresh-prompts": {
+ "post": {
+ "consumes": [
+ "application/json"
+ ],
+ "tags": [
+ "mcp"
+ ],
+ "summary": "Refresh MCP prompts",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Workspace ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ },
+ {
+ "description": "MCP name request",
+ "name": "request",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/proto.MCPNameRequest"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK"
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ }
+ }
+ }
+ },
+ "/workspaces/{id}/mcp/refresh-resources": {
+ "post": {
+ "consumes": [
+ "application/json"
+ ],
+ "tags": [
+ "mcp"
+ ],
+ "summary": "Refresh MCP resources",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Workspace ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ },
+ {
+ "description": "MCP name request",
+ "name": "request",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/proto.MCPNameRequest"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK"
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ }
+ }
+ }
+ },
+ "/workspaces/{id}/mcp/refresh-tools": {
+ "post": {
+ "consumes": [
+ "application/json"
+ ],
+ "tags": [
+ "mcp"
+ ],
+ "summary": "Refresh MCP tools",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Workspace ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ },
+ {
+ "description": "MCP name request",
+ "name": "request",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/proto.MCPNameRequest"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK"
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ }
+ }
+ }
+ },
+ "/workspaces/{id}/mcp/states": {
+ "get": {
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "mcp"
+ ],
+ "summary": "Get MCP client states",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Workspace ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "type": "object",
+ "additionalProperties": {
+ "$ref": "#/definitions/proto.MCPClientInfo"
+ }
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ }
+ }
+ }
+ },
+ "/workspaces/{id}/messages/user": {
+ "get": {
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "workspaces"
+ ],
+ "summary": "Get all user messages for workspace",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Workspace ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/github_com_charmbracelet_crush_internal_proto.Message"
+ }
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ }
+ }
+ }
+ },
+ "/workspaces/{id}/permissions/grant": {
+ "post": {
+ "consumes": [
+ "application/json"
+ ],
+ "tags": [
+ "permissions"
+ ],
+ "summary": "Grant permission",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Workspace ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ },
+ {
+ "description": "Permission grant",
+ "name": "request",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/proto.PermissionGrant"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK"
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ }
+ }
+ }
+ },
+ "/workspaces/{id}/permissions/skip": {
+ "get": {
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "permissions"
+ ],
+ "summary": "Get skip permissions status",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Workspace ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/proto.PermissionSkipRequest"
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ }
+ }
+ },
+ "post": {
+ "consumes": [
+ "application/json"
+ ],
+ "tags": [
+ "permissions"
+ ],
+ "summary": "Set skip permissions",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Workspace ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ },
+ {
+ "description": "Permission skip request",
+ "name": "request",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/proto.PermissionSkipRequest"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK"
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ }
+ }
+ }
+ },
+ "/workspaces/{id}/project/init": {
+ "post": {
+ "tags": [
+ "project"
+ ],
+ "summary": "Mark project as initialized",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Workspace ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK"
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ }
+ }
+ }
+ },
+ "/workspaces/{id}/project/init-prompt": {
+ "get": {
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "project"
+ ],
+ "summary": "Get project initialization prompt",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Workspace ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/proto.ProjectInitPromptResponse"
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ }
+ }
+ }
+ },
+ "/workspaces/{id}/project/needs-init": {
+ "get": {
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "project"
+ ],
+ "summary": "Check if project needs initialization",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Workspace ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/proto.ProjectNeedsInitResponse"
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ }
+ }
+ }
+ },
+ "/workspaces/{id}/providers": {
+ "get": {
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "workspaces"
+ ],
+ "summary": "Get workspace providers",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Workspace ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "type": "object"
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ }
+ }
+ }
+ },
+ "/workspaces/{id}/sessions": {
+ "get": {
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "sessions"
+ ],
+ "summary": "List sessions",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Workspace ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/proto.Session"
+ }
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ }
+ }
+ },
+ "post": {
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "sessions"
+ ],
+ "summary": "Create session",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Workspace ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ },
+ {
+ "description": "Session creation params (title)",
+ "name": "request",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/proto.Session"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/proto.Session"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ }
+ }
+ }
+ },
+ "/workspaces/{id}/sessions/{sid}": {
+ "get": {
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "sessions"
+ ],
+ "summary": "Get session",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Workspace ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ },
+ {
+ "type": "string",
+ "description": "Session ID",
+ "name": "sid",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/proto.Session"
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ }
+ }
+ },
+ "put": {
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "sessions"
+ ],
+ "summary": "Update session",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Workspace ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ },
+ {
+ "type": "string",
+ "description": "Session ID",
+ "name": "sid",
+ "in": "path",
+ "required": true
+ },
+ {
+ "description": "Updated session",
+ "name": "request",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/proto.Session"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/proto.Session"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ }
+ }
+ },
+ "delete": {
+ "tags": [
+ "sessions"
+ ],
+ "summary": "Delete session",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Workspace ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ },
+ {
+ "type": "string",
+ "description": "Session ID",
+ "name": "sid",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK"
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ }
+ }
+ }
+ },
+ "/workspaces/{id}/sessions/{sid}/filetracker/files": {
+ "get": {
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "filetracker"
+ ],
+ "summary": "List tracked files for session",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Workspace ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ },
+ {
+ "type": "string",
+ "description": "Session ID",
+ "name": "sid",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ }
+ }
+ }
+ },
+ "/workspaces/{id}/sessions/{sid}/history": {
+ "get": {
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "sessions"
+ ],
+ "summary": "Get session history",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Workspace ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ },
+ {
+ "type": "string",
+ "description": "Session ID",
+ "name": "sid",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/proto.File"
+ }
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ }
+ }
+ }
+ },
+ "/workspaces/{id}/sessions/{sid}/messages": {
+ "get": {
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "sessions"
+ ],
+ "summary": "Get session messages",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Workspace ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ },
+ {
+ "type": "string",
+ "description": "Session ID",
+ "name": "sid",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/github_com_charmbracelet_crush_internal_proto.Message"
+ }
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ }
+ }
+ }
+ },
+ "/workspaces/{id}/sessions/{sid}/messages/user": {
+ "get": {
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "sessions"
+ ],
+ "summary": "Get user messages for session",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Workspace ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ },
+ {
+ "type": "string",
+ "description": "Session ID",
+ "name": "sid",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/github_com_charmbracelet_crush_internal_proto.Message"
+ }
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/proto.Error"
+ }
+ }
+ }
+ }
+ }
+ },
+ "definitions": {
+ "catwalk.Model": {
+ "type": "object",
+ "properties": {
+ "can_reason": {
+ "type": "boolean"
+ },
+ "context_window": {
+ "type": "integer"
+ },
+ "cost_per_1m_in": {
+ "type": "number"
+ },
+ "cost_per_1m_in_cached": {
+ "type": "number"
+ },
+ "cost_per_1m_out": {
+ "type": "number"
+ },
+ "cost_per_1m_out_cached": {
+ "type": "number"
+ },
+ "default_max_tokens": {
+ "type": "integer"
+ },
+ "default_reasoning_effort": {
+ "type": "string"
+ },
+ "id": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ },
+ "options": {
+ "$ref": "#/definitions/catwalk.ModelOptions"
+ },
+ "reasoning_levels": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "supports_attachments": {
+ "type": "boolean"
+ }
+ }
+ },
+ "catwalk.ModelOptions": {
+ "type": "object",
+ "properties": {
+ "frequency_penalty": {
+ "type": "number"
+ },
+ "presence_penalty": {
+ "type": "number"
+ },
+ "provider_options": {
+ "type": "object",
+ "additionalProperties": {}
+ },
+ "temperature": {
+ "type": "number"
+ },
+ "top_k": {
+ "type": "integer"
+ },
+ "top_p": {
+ "type": "number"
+ }
+ }
+ },
+ "config.Attribution": {
+ "type": "object",
+ "properties": {
+ "co_authored_by": {
+ "type": "boolean"
+ },
+ "generated_with": {
+ "type": "boolean"
+ },
+ "trailer_style": {
+ "$ref": "#/definitions/config.TrailerStyle"
+ }
+ }
+ },
+ "config.Completions": {
+ "type": "object",
+ "properties": {
+ "max_depth": {
+ "type": "integer"
+ },
+ "max_items": {
+ "type": "integer"
+ }
+ }
+ },
+ "config.LSPConfig": {
+ "type": "object",
+ "properties": {
+ "args": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "command": {
+ "type": "string"
+ },
+ "disabled": {
+ "type": "boolean"
+ },
+ "env": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ },
+ "filetypes": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "init_options": {
+ "type": "object",
+ "additionalProperties": {}
+ },
+ "options": {
+ "type": "object",
+ "additionalProperties": {}
+ },
+ "root_markers": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "timeout": {
+ "type": "integer"
+ }
+ }
+ },
+ "config.LSPs": {
+ "type": "object",
+ "additionalProperties": {
+ "$ref": "#/definitions/config.LSPConfig"
+ }
+ },
+ "config.MCPConfig": {
+ "type": "object",
+ "properties": {
+ "args": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "command": {
+ "type": "string"
+ },
+ "disabled": {
+ "type": "boolean"
+ },
+ "disabled_tools": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "env": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ },
+ "headers": {
+ "description": "TODO: maybe make it possible to get the value from the env",
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ },
+ "timeout": {
+ "type": "integer"
+ },
+ "type": {
+ "$ref": "#/definitions/config.MCPType"
+ },
+ "url": {
+ "type": "string"
+ }
+ }
+ },
+ "config.MCPType": {
+ "type": "string",
+ "enum": [
+ "stdio",
+ "sse",
+ "http"
+ ],
+ "x-enum-varnames": [
+ "MCPStdio",
+ "MCPSSE",
+ "MCPHttp"
+ ]
+ },
+ "config.MCPs": {
+ "type": "object",
+ "additionalProperties": {
+ "$ref": "#/definitions/config.MCPConfig"
+ }
+ },
+ "config.Permissions": {
+ "type": "object",
+ "properties": {
+ "allowed_tools": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ }
+ },
+ "config.Scope": {
+ "type": "integer",
+ "enum": [
+ 0,
+ 1
+ ],
+ "x-enum-varnames": [
+ "ScopeGlobal",
+ "ScopeWorkspace"
+ ]
+ },
+ "config.SelectedModel": {
+ "type": "object",
+ "properties": {
+ "frequency_penalty": {
+ "type": "number"
+ },
+ "max_tokens": {
+ "description": "Overrides the default model configuration.",
+ "type": "integer"
+ },
+ "model": {
+ "description": "The model id as used by the provider API.\nRequired.",
+ "type": "string"
+ },
+ "presence_penalty": {
+ "type": "number"
+ },
+ "provider": {
+ "description": "The model provider, same as the key/id used in the providers config.\nRequired.",
+ "type": "string"
+ },
+ "provider_options": {
+ "description": "Override provider specific options.",
+ "type": "object",
+ "additionalProperties": {}
+ },
+ "reasoning_effort": {
+ "description": "Only used by models that use the openai provider and need this set.",
+ "type": "string"
+ },
+ "temperature": {
+ "type": "number"
+ },
+ "think": {
+ "description": "Used by anthropic models that can reason to indicate if the model should think.",
+ "type": "boolean"
+ },
+ "top_k": {
+ "type": "integer"
+ },
+ "top_p": {
+ "type": "number"
+ }
+ }
+ },
+ "config.SelectedModelType": {
+ "type": "string",
+ "enum": [
+ "large",
+ "small"
+ ],
+ "x-enum-varnames": [
+ "SelectedModelTypeLarge",
+ "SelectedModelTypeSmall"
+ ]
+ },
+ "config.TUIOptions": {
+ "type": "object",
+ "properties": {
+ "compact_mode": {
+ "type": "boolean"
+ },
+ "completions": {
+ "$ref": "#/definitions/config.Completions"
+ },
+ "diff_mode": {
+ "type": "string"
+ },
+ "transparent": {
+ "type": "boolean"
+ }
+ }
+ },
+ "config.ToolGrep": {
+ "type": "object",
+ "properties": {
+ "timeout": {
+ "$ref": "#/definitions/time.Duration"
+ }
+ }
+ },
+ "config.ToolLs": {
+ "type": "object",
+ "properties": {
+ "max_depth": {
+ "type": "integer"
+ },
+ "max_items": {
+ "type": "integer"
+ }
+ }
+ },
+ "config.Tools": {
+ "type": "object",
+ "properties": {
+ "grep": {
+ "$ref": "#/definitions/config.ToolGrep"
+ },
+ "ls": {
+ "$ref": "#/definitions/config.ToolLs"
+ }
+ }
+ },
+ "config.TrailerStyle": {
+ "type": "string",
+ "enum": [
+ "none",
+ "co-authored-by",
+ "assisted-by"
+ ],
+ "x-enum-varnames": [
+ "TrailerStyleNone",
+ "TrailerStyleCoAuthoredBy",
+ "TrailerStyleAssistedBy"
+ ]
+ },
+ "csync.Map-string-config_ProviderConfig": {
+ "type": "object"
+ },
+ "github_com_charmbracelet_crush_internal_config.Config": {
+ "type": "object",
+ "properties": {
+ "$schema": {
+ "type": "string"
+ },
+ "lsp": {
+ "$ref": "#/definitions/config.LSPs"
+ },
+ "mcp": {
+ "$ref": "#/definitions/config.MCPs"
+ },
+ "models": {
+ "description": "We currently only support large/small as values here.",
+ "type": "object",
+ "additionalProperties": {
+ "$ref": "#/definitions/config.SelectedModel"
+ }
+ },
+ "options": {
+ "$ref": "#/definitions/github_com_charmbracelet_crush_internal_config.Options"
+ },
+ "permissions": {
+ "$ref": "#/definitions/config.Permissions"
+ },
+ "providers": {
+ "description": "The providers that are configured",
+ "allOf": [
+ {
+ "$ref": "#/definitions/csync.Map-string-config_ProviderConfig"
+ }
+ ]
+ },
+ "recent_models": {
+ "description": "Recently used models stored in the data directory config.",
+ "type": "object",
+ "additionalProperties": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/config.SelectedModel"
+ }
+ }
+ },
+ "tools": {
+ "$ref": "#/definitions/config.Tools"
+ }
+ }
+ },
+ "github_com_charmbracelet_crush_internal_config.Options": {
+ "type": "object",
+ "properties": {
+ "attribution": {
+ "$ref": "#/definitions/config.Attribution"
+ },
+ "auto_lsp": {
+ "type": "boolean"
+ },
+ "context_paths": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "data_directory": {
+ "description": "Relative to the cwd",
+ "type": "string"
+ },
+ "debug": {
+ "type": "boolean"
+ },
+ "debug_lsp": {
+ "type": "boolean"
+ },
+ "disable_auto_summarize": {
+ "type": "boolean"
+ },
+ "disable_default_providers": {
+ "type": "boolean"
+ },
+ "disable_metrics": {
+ "type": "boolean"
+ },
+ "disable_notifications": {
+ "type": "boolean"
+ },
+ "disable_provider_auto_update": {
+ "type": "boolean"
+ },
+ "disabled_tools": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "initialize_as": {
+ "type": "string"
+ },
+ "progress": {
+ "type": "boolean"
+ },
+ "skills_paths": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "tui": {
+ "$ref": "#/definitions/config.TUIOptions"
+ }
+ }
+ },
+ "github_com_charmbracelet_crush_internal_proto.Message": {
+ "type": "object",
+ "properties": {
+ "created_at": {
+ "type": "integer"
+ },
+ "id": {
+ "type": "string"
+ },
+ "model": {
+ "type": "string"
+ },
+ "parts": {
+ "type": "array",
+ "items": {}
+ },
+ "provider": {
+ "type": "string"
+ },
+ "role": {
+ "$ref": "#/definitions/proto.MessageRole"
+ },
+ "session_id": {
+ "type": "string"
+ },
+ "updated_at": {
+ "type": "integer"
+ }
+ }
+ },
+ "lsp.ServerState": {
+ "type": "integer",
+ "enum": [
+ 0,
+ 1,
+ 2,
+ 3,
+ 4,
+ 5
+ ],
+ "x-enum-varnames": [
+ "StateUnstarted",
+ "StateStarting",
+ "StateReady",
+ "StateError",
+ "StateStopped",
+ "StateDisabled"
+ ]
+ },
+ "proto.AgentInfo": {
+ "type": "object",
+ "properties": {
+ "is_busy": {
+ "type": "boolean"
+ },
+ "is_ready": {
+ "type": "boolean"
+ },
+ "model": {
+ "$ref": "#/definitions/catwalk.Model"
+ },
+ "model_cfg": {
+ "$ref": "#/definitions/config.SelectedModel"
+ }
+ }
+ },
+ "proto.AgentMessage": {
+ "type": "object",
+ "properties": {
+ "attachments": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/proto.Attachment"
+ }
+ },
+ "prompt": {
+ "type": "string"
+ },
+ "session_id": {
+ "type": "string"
+ }
+ }
+ },
+ "proto.AgentSession": {
+ "type": "object",
+ "properties": {
+ "completion_tokens": {
+ "type": "integer"
+ },
+ "cost": {
+ "type": "number"
+ },
+ "created_at": {
+ "type": "integer"
+ },
+ "id": {
+ "type": "string"
+ },
+ "is_busy": {
+ "type": "boolean"
+ },
+ "message_count": {
+ "type": "integer"
+ },
+ "parent_session_id": {
+ "type": "string"
+ },
+ "prompt_tokens": {
+ "type": "integer"
+ },
+ "summary_message_id": {
+ "type": "string"
+ },
+ "title": {
+ "type": "string"
+ },
+ "updated_at": {
+ "type": "integer"
+ }
+ }
+ },
+ "proto.Attachment": {
+ "type": "object",
+ "properties": {
+ "content": {
+ "type": "array",
+ "items": {
+ "type": "integer"
+ }
+ },
+ "file_name": {
+ "type": "string"
+ },
+ "file_path": {
+ "type": "string"
+ },
+ "mime_type": {
+ "type": "string"
+ }
+ }
+ },
+ "proto.ConfigCompactRequest": {
+ "type": "object",
+ "properties": {
+ "enabled": {
+ "type": "boolean"
+ },
+ "scope": {
+ "$ref": "#/definitions/config.Scope"
+ }
+ }
+ },
+ "proto.ConfigModelRequest": {
+ "type": "object",
+ "properties": {
+ "model": {
+ "$ref": "#/definitions/config.SelectedModel"
+ },
+ "model_type": {
+ "$ref": "#/definitions/config.SelectedModelType"
+ },
+ "scope": {
+ "$ref": "#/definitions/config.Scope"
+ }
+ }
+ },
+ "proto.ConfigProviderKeyRequest": {
+ "type": "object",
+ "properties": {
+ "api_key": {},
+ "provider_id": {
+ "type": "string"
+ },
+ "scope": {
+ "$ref": "#/definitions/config.Scope"
+ }
+ }
+ },
+ "proto.ConfigRefreshOAuthRequest": {
+ "type": "object",
+ "properties": {
+ "provider_id": {
+ "type": "string"
+ },
+ "scope": {
+ "$ref": "#/definitions/config.Scope"
+ }
+ }
+ },
+ "proto.ConfigRemoveRequest": {
+ "type": "object",
+ "properties": {
+ "key": {
+ "type": "string"
+ },
+ "scope": {
+ "$ref": "#/definitions/config.Scope"
+ }
+ }
+ },
+ "proto.ConfigSetRequest": {
+ "type": "object",
+ "properties": {
+ "key": {
+ "type": "string"
+ },
+ "scope": {
+ "$ref": "#/definitions/config.Scope"
+ },
+ "value": {}
+ }
+ },
+ "proto.Error": {
+ "type": "object",
+ "properties": {
+ "message": {
+ "type": "string"
+ }
+ }
+ },
+ "proto.File": {
+ "type": "object",
+ "properties": {
+ "content": {
+ "type": "string"
+ },
+ "created_at": {
+ "type": "integer"
+ },
+ "id": {
+ "type": "string"
+ },
+ "path": {
+ "type": "string"
+ },
+ "session_id": {
+ "type": "string"
+ },
+ "updated_at": {
+ "type": "integer"
+ },
+ "version": {
+ "type": "integer"
+ }
+ }
+ },
+ "proto.FileTrackerReadRequest": {
+ "type": "object",
+ "properties": {
+ "path": {
+ "type": "string"
+ },
+ "session_id": {
+ "type": "string"
+ }
+ }
+ },
+ "proto.ImportCopilotResponse": {
+ "type": "object",
+ "properties": {
+ "success": {
+ "type": "boolean"
+ },
+ "token": {}
+ }
+ },
+ "proto.LSPClientInfo": {
+ "type": "object",
+ "properties": {
+ "connected_at": {
+ "type": "string"
+ },
+ "diagnostic_count": {
+ "type": "integer"
+ },
+ "error": {},
+ "name": {
+ "type": "string"
+ },
+ "state": {
+ "$ref": "#/definitions/lsp.ServerState"
+ }
+ }
+ },
+ "proto.LSPStartRequest": {
+ "type": "object",
+ "properties": {
+ "path": {
+ "type": "string"
+ }
+ }
+ },
+ "proto.MCPClientInfo": {
+ "type": "object",
+ "properties": {
+ "connected_at": {
+ "type": "string"
+ },
+ "error": {},
+ "name": {
+ "type": "string"
+ },
+ "prompt_count": {
+ "type": "integer"
+ },
+ "resource_count": {
+ "type": "integer"
+ },
+ "state": {
+ "$ref": "#/definitions/proto.MCPState"
+ },
+ "tool_count": {
+ "type": "integer"
+ }
+ }
+ },
+ "proto.MCPGetPromptRequest": {
+ "type": "object",
+ "properties": {
+ "args": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ },
+ "client_id": {
+ "type": "string"
+ },
+ "prompt_id": {
+ "type": "string"
+ }
+ }
+ },
+ "proto.MCPGetPromptResponse": {
+ "type": "object",
+ "properties": {
+ "prompt": {
+ "type": "string"
+ }
+ }
+ },
+ "proto.MCPNameRequest": {
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string"
+ }
+ }
+ },
+ "proto.MCPReadResourceRequest": {
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string"
+ },
+ "uri": {
+ "type": "string"
+ }
+ }
+ },
+ "proto.MCPState": {
+ "type": "integer",
+ "enum": [
+ 0,
+ 1,
+ 2,
+ 3
+ ],
+ "x-enum-varnames": [
+ "MCPStateDisabled",
+ "MCPStateStarting",
+ "MCPStateConnected",
+ "MCPStateError"
+ ]
+ },
+ "proto.MessageRole": {
+ "type": "string",
+ "enum": [
+ "assistant",
+ "user",
+ "system",
+ "tool"
+ ],
+ "x-enum-varnames": [
+ "Assistant",
+ "User",
+ "System",
+ "Tool"
+ ]
+ },
+ "proto.PermissionAction": {
+ "type": "string",
+ "enum": [
+ "allow",
+ "allow_session",
+ "deny"
+ ],
+ "x-enum-varnames": [
+ "PermissionAllow",
+ "PermissionAllowForSession",
+ "PermissionDeny"
+ ]
+ },
+ "proto.PermissionGrant": {
+ "type": "object",
+ "properties": {
+ "action": {
+ "$ref": "#/definitions/proto.PermissionAction"
+ },
+ "permission": {
+ "$ref": "#/definitions/proto.PermissionRequest"
+ }
+ }
+ },
+ "proto.PermissionRequest": {
+ "type": "object",
+ "properties": {
+ "action": {
+ "type": "string"
+ },
+ "description": {
+ "type": "string"
+ },
+ "id": {
+ "type": "string"
+ },
+ "params": {},
+ "path": {
+ "type": "string"
+ },
+ "session_id": {
+ "type": "string"
+ },
+ "tool_call_id": {
+ "type": "string"
+ },
+ "tool_name": {
+ "type": "string"
+ }
+ }
+ },
+ "proto.PermissionSkipRequest": {
+ "type": "object",
+ "properties": {
+ "skip": {
+ "type": "boolean"
+ }
+ }
+ },
+ "proto.ProjectInitPromptResponse": {
+ "type": "object",
+ "properties": {
+ "prompt": {
+ "type": "string"
+ }
+ }
+ },
+ "proto.ProjectNeedsInitResponse": {
+ "type": "object",
+ "properties": {
+ "needs_init": {
+ "type": "boolean"
+ }
+ }
+ },
+ "proto.ServerControl": {
+ "type": "object",
+ "properties": {
+ "command": {
+ "type": "string"
+ }
+ }
+ },
+ "proto.Session": {
+ "type": "object",
+ "properties": {
+ "completion_tokens": {
+ "type": "integer"
+ },
+ "cost": {
+ "type": "number"
+ },
+ "created_at": {
+ "type": "integer"
+ },
+ "id": {
+ "type": "string"
+ },
+ "message_count": {
+ "type": "integer"
+ },
+ "parent_session_id": {
+ "type": "string"
+ },
+ "prompt_tokens": {
+ "type": "integer"
+ },
+ "summary_message_id": {
+ "type": "string"
+ },
+ "title": {
+ "type": "string"
+ },
+ "updated_at": {
+ "type": "integer"
+ }
+ }
+ },
+ "proto.VersionInfo": {
+ "type": "object",
+ "properties": {
+ "commit": {
+ "type": "string"
+ },
+ "go_version": {
+ "type": "string"
+ },
+ "platform": {
+ "type": "string"
+ },
+ "version": {
+ "type": "string"
+ }
+ }
+ },
+ "proto.Workspace": {
+ "type": "object",
+ "properties": {
+ "config": {
+ "$ref": "#/definitions/github_com_charmbracelet_crush_internal_config.Config"
+ },
+ "data_dir": {
+ "type": "string"
+ },
+ "debug": {
+ "type": "boolean"
+ },
+ "env": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "id": {
+ "type": "string"
+ },
+ "path": {
+ "type": "string"
+ },
+ "version": {
+ "type": "string"
+ },
+ "yolo": {
+ "type": "boolean"
+ }
+ }
+ },
+ "time.Duration": {
+ "type": "integer",
+ "format": "int64",
+ "enum": [
+ -9223372036854775808,
+ 9223372036854775807,
+ 1,
+ 1000,
+ 1000000,
+ 1000000000,
+ 60000000000,
+ 3600000000000
+ ],
+ "x-enum-varnames": [
+ "minDuration",
+ "maxDuration",
+ "Nanosecond",
+ "Microsecond",
+ "Millisecond",
+ "Second",
+ "Minute",
+ "Hour"
+ ]
+ }
+ }
+}
@@ -0,0 +1,2361 @@
+basePath: /v1
+definitions:
+ catwalk.Model:
+ properties:
+ can_reason:
+ type: boolean
+ context_window:
+ type: integer
+ cost_per_1m_in:
+ type: number
+ cost_per_1m_in_cached:
+ type: number
+ cost_per_1m_out:
+ type: number
+ cost_per_1m_out_cached:
+ type: number
+ default_max_tokens:
+ type: integer
+ default_reasoning_effort:
+ type: string
+ id:
+ type: string
+ name:
+ type: string
+ options:
+ $ref: '#/definitions/catwalk.ModelOptions'
+ reasoning_levels:
+ items:
+ type: string
+ type: array
+ supports_attachments:
+ type: boolean
+ type: object
+ catwalk.ModelOptions:
+ properties:
+ frequency_penalty:
+ type: number
+ presence_penalty:
+ type: number
+ provider_options:
+ additionalProperties: {}
+ type: object
+ temperature:
+ type: number
+ top_k:
+ type: integer
+ top_p:
+ type: number
+ type: object
+ config.Attribution:
+ properties:
+ co_authored_by:
+ type: boolean
+ generated_with:
+ type: boolean
+ trailer_style:
+ $ref: '#/definitions/config.TrailerStyle'
+ type: object
+ config.Completions:
+ properties:
+ max_depth:
+ type: integer
+ max_items:
+ type: integer
+ type: object
+ config.LSPConfig:
+ properties:
+ args:
+ items:
+ type: string
+ type: array
+ command:
+ type: string
+ disabled:
+ type: boolean
+ env:
+ additionalProperties:
+ type: string
+ type: object
+ filetypes:
+ items:
+ type: string
+ type: array
+ init_options:
+ additionalProperties: {}
+ type: object
+ options:
+ additionalProperties: {}
+ type: object
+ root_markers:
+ items:
+ type: string
+ type: array
+ timeout:
+ type: integer
+ type: object
+ config.LSPs:
+ additionalProperties:
+ $ref: '#/definitions/config.LSPConfig'
+ type: object
+ config.MCPConfig:
+ properties:
+ args:
+ items:
+ type: string
+ type: array
+ command:
+ type: string
+ disabled:
+ type: boolean
+ disabled_tools:
+ items:
+ type: string
+ type: array
+ env:
+ additionalProperties:
+ type: string
+ type: object
+ headers:
+ additionalProperties:
+ type: string
+ description: 'TODO: maybe make it possible to get the value from the env'
+ type: object
+ timeout:
+ type: integer
+ type:
+ $ref: '#/definitions/config.MCPType'
+ url:
+ type: string
+ type: object
+ config.MCPType:
+ enum:
+ - stdio
+ - sse
+ - http
+ type: string
+ x-enum-varnames:
+ - MCPStdio
+ - MCPSSE
+ - MCPHttp
+ config.MCPs:
+ additionalProperties:
+ $ref: '#/definitions/config.MCPConfig'
+ type: object
+ config.Permissions:
+ properties:
+ allowed_tools:
+ items:
+ type: string
+ type: array
+ type: object
+ config.Scope:
+ enum:
+ - 0
+ - 1
+ type: integer
+ x-enum-varnames:
+ - ScopeGlobal
+ - ScopeWorkspace
+ config.SelectedModel:
+ properties:
+ frequency_penalty:
+ type: number
+ max_tokens:
+ description: Overrides the default model configuration.
+ type: integer
+ model:
+ description: |-
+ The model id as used by the provider API.
+ Required.
+ type: string
+ presence_penalty:
+ type: number
+ provider:
+ description: |-
+ The model provider, same as the key/id used in the providers config.
+ Required.
+ type: string
+ provider_options:
+ additionalProperties: {}
+ description: Override provider specific options.
+ type: object
+ reasoning_effort:
+ description: Only used by models that use the openai provider and need this
+ set.
+ type: string
+ temperature:
+ type: number
+ think:
+ description: Used by anthropic models that can reason to indicate if the model
+ should think.
+ type: boolean
+ top_k:
+ type: integer
+ top_p:
+ type: number
+ type: object
+ config.SelectedModelType:
+ enum:
+ - large
+ - small
+ type: string
+ x-enum-varnames:
+ - SelectedModelTypeLarge
+ - SelectedModelTypeSmall
+ config.TUIOptions:
+ properties:
+ compact_mode:
+ type: boolean
+ completions:
+ $ref: '#/definitions/config.Completions'
+ diff_mode:
+ type: string
+ transparent:
+ type: boolean
+ type: object
+ config.ToolGrep:
+ properties:
+ timeout:
+ $ref: '#/definitions/time.Duration'
+ type: object
+ config.ToolLs:
+ properties:
+ max_depth:
+ type: integer
+ max_items:
+ type: integer
+ type: object
+ config.Tools:
+ properties:
+ grep:
+ $ref: '#/definitions/config.ToolGrep'
+ ls:
+ $ref: '#/definitions/config.ToolLs'
+ type: object
+ config.TrailerStyle:
+ enum:
+ - none
+ - co-authored-by
+ - assisted-by
+ type: string
+ x-enum-varnames:
+ - TrailerStyleNone
+ - TrailerStyleCoAuthoredBy
+ - TrailerStyleAssistedBy
+ csync.Map-string-config_ProviderConfig:
+ type: object
+ github_com_charmbracelet_crush_internal_config.Config:
+ properties:
+ $schema:
+ type: string
+ lsp:
+ $ref: '#/definitions/config.LSPs'
+ mcp:
+ $ref: '#/definitions/config.MCPs'
+ models:
+ additionalProperties:
+ $ref: '#/definitions/config.SelectedModel'
+ description: We currently only support large/small as values here.
+ type: object
+ options:
+ $ref: '#/definitions/github_com_charmbracelet_crush_internal_config.Options'
+ permissions:
+ $ref: '#/definitions/config.Permissions'
+ providers:
+ allOf:
+ - $ref: '#/definitions/csync.Map-string-config_ProviderConfig'
+ description: The providers that are configured
+ recent_models:
+ additionalProperties:
+ items:
+ $ref: '#/definitions/config.SelectedModel'
+ type: array
+ description: Recently used models stored in the data directory config.
+ type: object
+ tools:
+ $ref: '#/definitions/config.Tools'
+ type: object
+ github_com_charmbracelet_crush_internal_config.Options:
+ properties:
+ attribution:
+ $ref: '#/definitions/config.Attribution'
+ auto_lsp:
+ type: boolean
+ context_paths:
+ items:
+ type: string
+ type: array
+ data_directory:
+ description: Relative to the cwd
+ type: string
+ debug:
+ type: boolean
+ debug_lsp:
+ type: boolean
+ disable_auto_summarize:
+ type: boolean
+ disable_default_providers:
+ type: boolean
+ disable_metrics:
+ type: boolean
+ disable_notifications:
+ type: boolean
+ disable_provider_auto_update:
+ type: boolean
+ disabled_tools:
+ items:
+ type: string
+ type: array
+ initialize_as:
+ type: string
+ progress:
+ type: boolean
+ skills_paths:
+ items:
+ type: string
+ type: array
+ tui:
+ $ref: '#/definitions/config.TUIOptions'
+ type: object
+ github_com_charmbracelet_crush_internal_proto.Message:
+ properties:
+ created_at:
+ type: integer
+ id:
+ type: string
+ model:
+ type: string
+ parts:
+ items: {}
+ type: array
+ provider:
+ type: string
+ role:
+ $ref: '#/definitions/proto.MessageRole'
+ session_id:
+ type: string
+ updated_at:
+ type: integer
+ type: object
+ lsp.ServerState:
+ enum:
+ - 0
+ - 1
+ - 2
+ - 3
+ - 4
+ - 5
+ type: integer
+ x-enum-varnames:
+ - StateUnstarted
+ - StateStarting
+ - StateReady
+ - StateError
+ - StateStopped
+ - StateDisabled
+ proto.AgentInfo:
+ properties:
+ is_busy:
+ type: boolean
+ is_ready:
+ type: boolean
+ model:
+ $ref: '#/definitions/catwalk.Model'
+ model_cfg:
+ $ref: '#/definitions/config.SelectedModel'
+ type: object
+ proto.AgentMessage:
+ properties:
+ attachments:
+ items:
+ $ref: '#/definitions/proto.Attachment'
+ type: array
+ prompt:
+ type: string
+ session_id:
+ type: string
+ type: object
+ proto.AgentSession:
+ properties:
+ completion_tokens:
+ type: integer
+ cost:
+ type: number
+ created_at:
+ type: integer
+ id:
+ type: string
+ is_busy:
+ type: boolean
+ message_count:
+ type: integer
+ parent_session_id:
+ type: string
+ prompt_tokens:
+ type: integer
+ summary_message_id:
+ type: string
+ title:
+ type: string
+ updated_at:
+ type: integer
+ type: object
+ proto.Attachment:
+ properties:
+ content:
+ items:
+ type: integer
+ type: array
+ file_name:
+ type: string
+ file_path:
+ type: string
+ mime_type:
+ type: string
+ type: object
+ proto.ConfigCompactRequest:
+ properties:
+ enabled:
+ type: boolean
+ scope:
+ $ref: '#/definitions/config.Scope'
+ type: object
+ proto.ConfigModelRequest:
+ properties:
+ model:
+ $ref: '#/definitions/config.SelectedModel'
+ model_type:
+ $ref: '#/definitions/config.SelectedModelType'
+ scope:
+ $ref: '#/definitions/config.Scope'
+ type: object
+ proto.ConfigProviderKeyRequest:
+ properties:
+ api_key: {}
+ provider_id:
+ type: string
+ scope:
+ $ref: '#/definitions/config.Scope'
+ type: object
+ proto.ConfigRefreshOAuthRequest:
+ properties:
+ provider_id:
+ type: string
+ scope:
+ $ref: '#/definitions/config.Scope'
+ type: object
+ proto.ConfigRemoveRequest:
+ properties:
+ key:
+ type: string
+ scope:
+ $ref: '#/definitions/config.Scope'
+ type: object
+ proto.ConfigSetRequest:
+ properties:
+ key:
+ type: string
+ scope:
+ $ref: '#/definitions/config.Scope'
+ value: {}
+ type: object
+ proto.Error:
+ properties:
+ message:
+ type: string
+ type: object
+ proto.File:
+ properties:
+ content:
+ type: string
+ created_at:
+ type: integer
+ id:
+ type: string
+ path:
+ type: string
+ session_id:
+ type: string
+ updated_at:
+ type: integer
+ version:
+ type: integer
+ type: object
+ proto.FileTrackerReadRequest:
+ properties:
+ path:
+ type: string
+ session_id:
+ type: string
+ type: object
+ proto.ImportCopilotResponse:
+ properties:
+ success:
+ type: boolean
+ token: {}
+ type: object
+ proto.LSPClientInfo:
+ properties:
+ connected_at:
+ type: string
+ diagnostic_count:
+ type: integer
+ error: {}
+ name:
+ type: string
+ state:
+ $ref: '#/definitions/lsp.ServerState'
+ type: object
+ proto.LSPStartRequest:
+ properties:
+ path:
+ type: string
+ type: object
+ proto.MCPClientInfo:
+ properties:
+ connected_at:
+ type: string
+ error: {}
+ name:
+ type: string
+ prompt_count:
+ type: integer
+ resource_count:
+ type: integer
+ state:
+ $ref: '#/definitions/proto.MCPState'
+ tool_count:
+ type: integer
+ type: object
+ proto.MCPGetPromptRequest:
+ properties:
+ args:
+ additionalProperties:
+ type: string
+ type: object
+ client_id:
+ type: string
+ prompt_id:
+ type: string
+ type: object
+ proto.MCPGetPromptResponse:
+ properties:
+ prompt:
+ type: string
+ type: object
+ proto.MCPNameRequest:
+ properties:
+ name:
+ type: string
+ type: object
+ proto.MCPReadResourceRequest:
+ properties:
+ name:
+ type: string
+ uri:
+ type: string
+ type: object
+ proto.MCPState:
+ enum:
+ - 0
+ - 1
+ - 2
+ - 3
+ type: integer
+ x-enum-varnames:
+ - MCPStateDisabled
+ - MCPStateStarting
+ - MCPStateConnected
+ - MCPStateError
+ proto.MessageRole:
+ enum:
+ - assistant
+ - user
+ - system
+ - tool
+ type: string
+ x-enum-varnames:
+ - Assistant
+ - User
+ - System
+ - Tool
+ proto.PermissionAction:
+ enum:
+ - allow
+ - allow_session
+ - deny
+ type: string
+ x-enum-varnames:
+ - PermissionAllow
+ - PermissionAllowForSession
+ - PermissionDeny
+ proto.PermissionGrant:
+ properties:
+ action:
+ $ref: '#/definitions/proto.PermissionAction'
+ permission:
+ $ref: '#/definitions/proto.PermissionRequest'
+ type: object
+ proto.PermissionRequest:
+ properties:
+ action:
+ type: string
+ description:
+ type: string
+ id:
+ type: string
+ params: {}
+ path:
+ type: string
+ session_id:
+ type: string
+ tool_call_id:
+ type: string
+ tool_name:
+ type: string
+ type: object
+ proto.PermissionSkipRequest:
+ properties:
+ skip:
+ type: boolean
+ type: object
+ proto.ProjectInitPromptResponse:
+ properties:
+ prompt:
+ type: string
+ type: object
+ proto.ProjectNeedsInitResponse:
+ properties:
+ needs_init:
+ type: boolean
+ type: object
+ proto.ServerControl:
+ properties:
+ command:
+ type: string
+ type: object
+ proto.Session:
+ properties:
+ completion_tokens:
+ type: integer
+ cost:
+ type: number
+ created_at:
+ type: integer
+ id:
+ type: string
+ message_count:
+ type: integer
+ parent_session_id:
+ type: string
+ prompt_tokens:
+ type: integer
+ summary_message_id:
+ type: string
+ title:
+ type: string
+ updated_at:
+ type: integer
+ type: object
+ proto.VersionInfo:
+ properties:
+ commit:
+ type: string
+ go_version:
+ type: string
+ platform:
+ type: string
+ version:
+ type: string
+ type: object
+ proto.Workspace:
+ properties:
+ config:
+ $ref: '#/definitions/github_com_charmbracelet_crush_internal_config.Config'
+ data_dir:
+ type: string
+ debug:
+ type: boolean
+ env:
+ items:
+ type: string
+ type: array
+ id:
+ type: string
+ path:
+ type: string
+ version:
+ type: string
+ yolo:
+ type: boolean
+ type: object
+ time.Duration:
+ enum:
+ - -9223372036854775808
+ - 9223372036854775807
+ - 1
+ - 1000
+ - 1000000
+ - 1000000000
+ - 60000000000
+ - 3600000000000
+ format: int64
+ type: integer
+ x-enum-varnames:
+ - minDuration
+ - maxDuration
+ - Nanosecond
+ - Microsecond
+ - Millisecond
+ - Second
+ - Minute
+ - Hour
+info:
+ contact:
+ name: Charm
+ url: https://charm.sh
+ description: Crush is a terminal-based AI coding assistant. This API is served over
+ a Unix socket (or Windows named pipe) and provides programmatic access to workspaces,
+ sessions, agents, LSP, MCP, and more.
+ license:
+ name: MIT
+ url: https://github.com/charmbracelet/crush/blob/main/LICENSE
+ title: Crush API
+ version: "1.0"
+paths:
+ /config:
+ get:
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: OK
+ schema:
+ type: object
+ summary: Get server config
+ tags:
+ - system
+ /control:
+ post:
+ consumes:
+ - application/json
+ parameters:
+ - description: Control command (e.g. shutdown)
+ in: body
+ name: request
+ required: true
+ schema:
+ $ref: '#/definitions/proto.ServerControl'
+ responses:
+ "200":
+ description: OK
+ "400":
+ description: Bad Request
+ schema:
+ $ref: '#/definitions/proto.Error'
+ summary: Send server control command
+ tags:
+ - system
+ /health:
+ get:
+ responses:
+ "200":
+ description: OK
+ summary: Health check
+ tags:
+ - system
+ /version:
+ get:
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: OK
+ schema:
+ $ref: '#/definitions/proto.VersionInfo'
+ summary: Get server version
+ tags:
+ - system
+ /workspaces:
+ get:
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: OK
+ schema:
+ items:
+ $ref: '#/definitions/proto.Workspace'
+ type: array
+ summary: List workspaces
+ tags:
+ - workspaces
+ post:
+ consumes:
+ - application/json
+ parameters:
+ - description: Workspace creation params
+ in: body
+ name: request
+ required: true
+ schema:
+ $ref: '#/definitions/proto.Workspace'
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: OK
+ schema:
+ $ref: '#/definitions/proto.Workspace'
+ "400":
+ description: Bad Request
+ schema:
+ $ref: '#/definitions/proto.Error'
+ "500":
+ description: Internal Server Error
+ schema:
+ $ref: '#/definitions/proto.Error'
+ summary: Create workspace
+ tags:
+ - workspaces
+ /workspaces/{id}:
+ delete:
+ parameters:
+ - description: Workspace ID
+ in: path
+ name: id
+ required: true
+ type: string
+ responses:
+ "200":
+ description: OK
+ "404":
+ description: Not Found
+ schema:
+ $ref: '#/definitions/proto.Error'
+ summary: Delete workspace
+ tags:
+ - workspaces
+ get:
+ parameters:
+ - description: Workspace ID
+ in: path
+ name: id
+ required: true
+ type: string
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: OK
+ schema:
+ $ref: '#/definitions/proto.Workspace'
+ "404":
+ description: Not Found
+ schema:
+ $ref: '#/definitions/proto.Error'
+ "500":
+ description: Internal Server Error
+ schema:
+ $ref: '#/definitions/proto.Error'
+ summary: Get workspace
+ tags:
+ - workspaces
+ /workspaces/{id}/agent:
+ get:
+ parameters:
+ - description: Workspace ID
+ in: path
+ name: id
+ required: true
+ type: string
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: OK
+ schema:
+ $ref: '#/definitions/proto.AgentInfo'
+ "404":
+ description: Not Found
+ schema:
+ $ref: '#/definitions/proto.Error'
+ "500":
+ description: Internal Server Error
+ schema:
+ $ref: '#/definitions/proto.Error'
+ summary: Get agent info
+ tags:
+ - agent
+ post:
+ consumes:
+ - application/json
+ parameters:
+ - description: Workspace ID
+ in: path
+ name: id
+ required: true
+ type: string
+ - description: Agent message
+ in: body
+ name: request
+ required: true
+ schema:
+ $ref: '#/definitions/proto.AgentMessage'
+ responses:
+ "200":
+ description: OK
+ "400":
+ description: Bad Request
+ schema:
+ $ref: '#/definitions/proto.Error'
+ "404":
+ description: Not Found
+ schema:
+ $ref: '#/definitions/proto.Error'
+ "500":
+ description: Internal Server Error
+ schema:
+ $ref: '#/definitions/proto.Error'
+ summary: Send message to agent
+ tags:
+ - agent
+ /workspaces/{id}/agent/default-small-model:
+ get:
+ parameters:
+ - description: Workspace ID
+ in: path
+ name: id
+ required: true
+ type: string
+ - description: Provider ID
+ in: query
+ name: provider_id
+ type: string
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: OK
+ schema:
+ type: object
+ "404":
+ description: Not Found
+ schema:
+ $ref: '#/definitions/proto.Error'
+ "500":
+ description: Internal Server Error
+ schema:
+ $ref: '#/definitions/proto.Error'
+ summary: Get default small model
+ tags:
+ - agent
+ /workspaces/{id}/agent/init:
+ post:
+ parameters:
+ - description: Workspace ID
+ in: path
+ name: id
+ required: true
+ type: string
+ responses:
+ "200":
+ description: OK
+ "404":
+ description: Not Found
+ schema:
+ $ref: '#/definitions/proto.Error'
+ "500":
+ description: Internal Server Error
+ schema:
+ $ref: '#/definitions/proto.Error'
+ summary: Initialize agent
+ tags:
+ - agent
+ /workspaces/{id}/agent/sessions/{sid}:
+ get:
+ parameters:
+ - description: Workspace ID
+ in: path
+ name: id
+ required: true
+ type: string
+ - description: Session ID
+ in: path
+ name: sid
+ required: true
+ type: string
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: OK
+ schema:
+ $ref: '#/definitions/proto.AgentSession'
+ "404":
+ description: Not Found
+ schema:
+ $ref: '#/definitions/proto.Error'
+ "500":
+ description: Internal Server Error
+ schema:
+ $ref: '#/definitions/proto.Error'
+ summary: Get agent session
+ tags:
+ - agent
+ /workspaces/{id}/agent/sessions/{sid}/cancel:
+ post:
+ parameters:
+ - description: Workspace ID
+ in: path
+ name: id
+ required: true
+ type: string
+ - description: Session ID
+ in: path
+ name: sid
+ required: true
+ type: string
+ responses:
+ "200":
+ description: OK
+ "404":
+ description: Not Found
+ schema:
+ $ref: '#/definitions/proto.Error'
+ "500":
+ description: Internal Server Error
+ schema:
+ $ref: '#/definitions/proto.Error'
+ summary: Cancel agent session
+ tags:
+ - agent
+ /workspaces/{id}/agent/sessions/{sid}/prompts/clear:
+ post:
+ parameters:
+ - description: Workspace ID
+ in: path
+ name: id
+ required: true
+ type: string
+ - description: Session ID
+ in: path
+ name: sid
+ required: true
+ type: string
+ responses:
+ "200":
+ description: OK
+ "404":
+ description: Not Found
+ schema:
+ $ref: '#/definitions/proto.Error'
+ "500":
+ description: Internal Server Error
+ schema:
+ $ref: '#/definitions/proto.Error'
+ summary: Clear prompt queue
+ tags:
+ - agent
+ /workspaces/{id}/agent/sessions/{sid}/prompts/list:
+ get:
+ parameters:
+ - description: Workspace ID
+ in: path
+ name: id
+ required: true
+ type: string
+ - description: Session ID
+ in: path
+ name: sid
+ required: true
+ type: string
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: OK
+ schema:
+ items:
+ type: string
+ type: array
+ "404":
+ description: Not Found
+ schema:
+ $ref: '#/definitions/proto.Error'
+ "500":
+ description: Internal Server Error
+ schema:
+ $ref: '#/definitions/proto.Error'
+ summary: List queued prompts
+ tags:
+ - agent
+ /workspaces/{id}/agent/sessions/{sid}/prompts/queued:
+ get:
+ parameters:
+ - description: Workspace ID
+ in: path
+ name: id
+ required: true
+ type: string
+ - description: Session ID
+ in: path
+ name: sid
+ required: true
+ type: string
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: OK
+ schema:
+ type: object
+ "404":
+ description: Not Found
+ schema:
+ $ref: '#/definitions/proto.Error'
+ "500":
+ description: Internal Server Error
+ schema:
+ $ref: '#/definitions/proto.Error'
+ summary: Get queued prompt status
+ tags:
+ - agent
+ /workspaces/{id}/agent/sessions/{sid}/summarize:
+ post:
+ parameters:
+ - description: Workspace ID
+ in: path
+ name: id
+ required: true
+ type: string
+ - description: Session ID
+ in: path
+ name: sid
+ required: true
+ type: string
+ responses:
+ "200":
+ description: OK
+ "404":
+ description: Not Found
+ schema:
+ $ref: '#/definitions/proto.Error'
+ "500":
+ description: Internal Server Error
+ schema:
+ $ref: '#/definitions/proto.Error'
+ summary: Summarize session
+ tags:
+ - agent
+ /workspaces/{id}/agent/update:
+ post:
+ parameters:
+ - description: Workspace ID
+ in: path
+ name: id
+ required: true
+ type: string
+ responses:
+ "200":
+ description: OK
+ "404":
+ description: Not Found
+ schema:
+ $ref: '#/definitions/proto.Error'
+ "500":
+ description: Internal Server Error
+ schema:
+ $ref: '#/definitions/proto.Error'
+ summary: Update agent
+ tags:
+ - agent
+ /workspaces/{id}/config:
+ get:
+ parameters:
+ - description: Workspace ID
+ in: path
+ name: id
+ required: true
+ type: string
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: OK
+ schema:
+ type: object
+ "404":
+ description: Not Found
+ schema:
+ $ref: '#/definitions/proto.Error'
+ "500":
+ description: Internal Server Error
+ schema:
+ $ref: '#/definitions/proto.Error'
+ summary: Get workspace config
+ tags:
+ - workspaces
+ /workspaces/{id}/config/compact:
+ post:
+ consumes:
+ - application/json
+ parameters:
+ - description: Workspace ID
+ in: path
+ name: id
+ required: true
+ type: string
+ - description: Config compact request
+ in: body
+ name: request
+ required: true
+ schema:
+ $ref: '#/definitions/proto.ConfigCompactRequest'
+ responses:
+ "200":
+ description: OK
+ "400":
+ description: Bad Request
+ schema:
+ $ref: '#/definitions/proto.Error'
+ "404":
+ description: Not Found
+ schema:
+ $ref: '#/definitions/proto.Error'
+ "500":
+ description: Internal Server Error
+ schema:
+ $ref: '#/definitions/proto.Error'
+ summary: Set compact mode
+ tags:
+ - config
+ /workspaces/{id}/config/import-copilot:
+ post:
+ parameters:
+ - description: Workspace ID
+ in: path
+ name: id
+ required: true
+ type: string
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: OK
+ schema:
+ $ref: '#/definitions/proto.ImportCopilotResponse'
+ "404":
+ description: Not Found
+ schema:
+ $ref: '#/definitions/proto.Error'
+ "500":
+ description: Internal Server Error
+ schema:
+ $ref: '#/definitions/proto.Error'
+ summary: Import Copilot credentials
+ tags:
+ - config
+ /workspaces/{id}/config/model:
+ post:
+ consumes:
+ - application/json
+ parameters:
+ - description: Workspace ID
+ in: path
+ name: id
+ required: true
+ type: string
+ - description: Config model request
+ in: body
+ name: request
+ required: true
+ schema:
+ $ref: '#/definitions/proto.ConfigModelRequest'
+ responses:
+ "200":
+ description: OK
+ "400":
+ description: Bad Request
+ schema:
+ $ref: '#/definitions/proto.Error'
+ "404":
+ description: Not Found
+ schema:
+ $ref: '#/definitions/proto.Error'
+ "500":
+ description: Internal Server Error
+ schema:
+ $ref: '#/definitions/proto.Error'
+ summary: Set the preferred model
+ tags:
+ - config
+ /workspaces/{id}/config/provider-key:
+ post:
+ consumes:
+ - application/json
+ parameters:
+ - description: Workspace ID
+ in: path
+ name: id
+ required: true
+ type: string
+ - description: Config provider key request
+ in: body
+ name: request
+ required: true
+ schema:
+ $ref: '#/definitions/proto.ConfigProviderKeyRequest'
+ responses:
+ "200":
+ description: OK
+ "400":
+ description: Bad Request
+ schema:
+ $ref: '#/definitions/proto.Error'
+ "404":
+ description: Not Found
+ schema:
+ $ref: '#/definitions/proto.Error'
+ "500":
+ description: Internal Server Error
+ schema:
+ $ref: '#/definitions/proto.Error'
+ summary: Set provider API key
+ tags:
+ - config
+ /workspaces/{id}/config/refresh-oauth:
+ post:
+ consumes:
+ - application/json
+ parameters:
+ - description: Workspace ID
+ in: path
+ name: id
+ required: true
+ type: string
+ - description: Refresh OAuth request
+ in: body
+ name: request
+ required: true
+ schema:
+ $ref: '#/definitions/proto.ConfigRefreshOAuthRequest'
+ responses:
+ "200":
+ description: OK
+ "400":
+ description: Bad Request
+ schema:
+ $ref: '#/definitions/proto.Error'
+ "404":
+ description: Not Found
+ schema:
+ $ref: '#/definitions/proto.Error'
+ "500":
+ description: Internal Server Error
+ schema:
+ $ref: '#/definitions/proto.Error'
+ summary: Refresh OAuth token
+ tags:
+ - config
+ /workspaces/{id}/config/remove:
+ post:
+ consumes:
+ - application/json
+ parameters:
+ - description: Workspace ID
+ in: path
+ name: id
+ required: true
+ type: string
+ - description: Config remove request
+ in: body
+ name: request
+ required: true
+ schema:
+ $ref: '#/definitions/proto.ConfigRemoveRequest'
+ responses:
+ "200":
+ description: OK
+ "400":
+ description: Bad Request
+ schema:
+ $ref: '#/definitions/proto.Error'
+ "404":
+ description: Not Found
+ schema:
+ $ref: '#/definitions/proto.Error'
+ "500":
+ description: Internal Server Error
+ schema:
+ $ref: '#/definitions/proto.Error'
+ summary: Remove a config field
+ tags:
+ - config
+ /workspaces/{id}/config/set:
+ post:
+ consumes:
+ - application/json
+ parameters:
+ - description: Workspace ID
+ in: path
+ name: id
+ required: true
+ type: string
+ - description: Config set request
+ in: body
+ name: request
+ required: true
+ schema:
+ $ref: '#/definitions/proto.ConfigSetRequest'
+ responses:
+ "200":
+ description: OK
+ "400":
+ description: Bad Request
+ schema:
+ $ref: '#/definitions/proto.Error'
+ "404":
+ description: Not Found
+ schema:
+ $ref: '#/definitions/proto.Error'
+ "500":
+ description: Internal Server Error
+ schema:
+ $ref: '#/definitions/proto.Error'
+ summary: Set a config field
+ tags:
+ - config
+ /workspaces/{id}/events:
+ get:
+ parameters:
+ - description: Workspace ID
+ in: path
+ name: id
+ required: true
+ type: string
+ produces:
+ - text/event-stream
+ responses:
+ "200":
+ description: OK
+ "404":
+ description: Not Found
+ schema:
+ $ref: '#/definitions/proto.Error'
+ "500":
+ description: Internal Server Error
+ schema:
+ $ref: '#/definitions/proto.Error'
+ summary: Stream workspace events (SSE)
+ tags:
+ - workspaces
+ /workspaces/{id}/filetracker/lastread:
+ get:
+ parameters:
+ - description: Workspace ID
+ in: path
+ name: id
+ required: true
+ type: string
+ - description: Session ID
+ in: query
+ name: session_id
+ type: string
+ - description: File path
+ in: query
+ name: path
+ required: true
+ type: string
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: OK
+ schema:
+ type: object
+ "404":
+ description: Not Found
+ schema:
+ $ref: '#/definitions/proto.Error'
+ "500":
+ description: Internal Server Error
+ schema:
+ $ref: '#/definitions/proto.Error'
+ summary: Get last read time for file
+ tags:
+ - filetracker
+ /workspaces/{id}/filetracker/read:
+ post:
+ consumes:
+ - application/json
+ parameters:
+ - description: Workspace ID
+ in: path
+ name: id
+ required: true
+ type: string
+ - description: File tracker read request
+ in: body
+ name: request
+ required: true
+ schema:
+ $ref: '#/definitions/proto.FileTrackerReadRequest'
+ responses:
+ "200":
+ description: OK
+ "400":
+ description: Bad Request
+ schema:
+ $ref: '#/definitions/proto.Error'
+ "404":
+ description: Not Found
+ schema:
+ $ref: '#/definitions/proto.Error'
+ "500":
+ description: Internal Server Error
+ schema:
+ $ref: '#/definitions/proto.Error'
+ summary: Record file read
+ tags:
+ - filetracker
+ /workspaces/{id}/lsps:
+ get:
+ parameters:
+ - description: Workspace ID
+ in: path
+ name: id
+ required: true
+ type: string
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: OK
+ schema:
+ additionalProperties:
+ $ref: '#/definitions/proto.LSPClientInfo'
+ type: object
+ "404":
+ description: Not Found
+ schema:
+ $ref: '#/definitions/proto.Error'
+ "500":
+ description: Internal Server Error
+ schema:
+ $ref: '#/definitions/proto.Error'
+ summary: List LSP clients
+ tags:
+ - lsp
+ /workspaces/{id}/lsps/{lsp}/diagnostics:
+ get:
+ parameters:
+ - description: Workspace ID
+ in: path
+ name: id
+ required: true
+ type: string
+ - description: LSP client name
+ in: path
+ name: lsp
+ required: true
+ type: string
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: OK
+ schema:
+ type: object
+ "404":
+ description: Not Found
+ schema:
+ $ref: '#/definitions/proto.Error'
+ "500":
+ description: Internal Server Error
+ schema:
+ $ref: '#/definitions/proto.Error'
+ summary: Get LSP diagnostics
+ tags:
+ - lsp
+ /workspaces/{id}/lsps/start:
+ post:
+ consumes:
+ - application/json
+ parameters:
+ - description: Workspace ID
+ in: path
+ name: id
+ required: true
+ type: string
+ - description: LSP start request
+ in: body
+ name: request
+ required: true
+ schema:
+ $ref: '#/definitions/proto.LSPStartRequest'
+ responses:
+ "200":
+ description: OK
+ "400":
+ description: Bad Request
+ schema:
+ $ref: '#/definitions/proto.Error'
+ "404":
+ description: Not Found
+ schema:
+ $ref: '#/definitions/proto.Error'
+ "500":
+ description: Internal Server Error
+ schema:
+ $ref: '#/definitions/proto.Error'
+ summary: Start LSP server
+ tags:
+ - lsp
+ /workspaces/{id}/lsps/stop:
+ post:
+ parameters:
+ - description: Workspace ID
+ in: path
+ name: id
+ required: true
+ type: string
+ responses:
+ "200":
+ description: OK
+ "404":
+ description: Not Found
+ schema:
+ $ref: '#/definitions/proto.Error'
+ "500":
+ description: Internal Server Error
+ schema:
+ $ref: '#/definitions/proto.Error'
+ summary: Stop all LSP servers
+ tags:
+ - lsp
+ /workspaces/{id}/mcp/get-prompt:
+ post:
+ consumes:
+ - application/json
+ parameters:
+ - description: Workspace ID
+ in: path
+ name: id
+ required: true
+ type: string
+ - description: MCP get prompt request
+ in: body
+ name: request
+ required: true
+ schema:
+ $ref: '#/definitions/proto.MCPGetPromptRequest'
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: OK
+ schema:
+ $ref: '#/definitions/proto.MCPGetPromptResponse'
+ "400":
+ description: Bad Request
+ schema:
+ $ref: '#/definitions/proto.Error'
+ "404":
+ description: Not Found
+ schema:
+ $ref: '#/definitions/proto.Error'
+ "500":
+ description: Internal Server Error
+ schema:
+ $ref: '#/definitions/proto.Error'
+ summary: Get MCP prompt
+ tags:
+ - mcp
+ /workspaces/{id}/mcp/read-resource:
+ post:
+ consumes:
+ - application/json
+ parameters:
+ - description: Workspace ID
+ in: path
+ name: id
+ required: true
+ type: string
+ - description: MCP read resource request
+ in: body
+ name: request
+ required: true
+ schema:
+ $ref: '#/definitions/proto.MCPReadResourceRequest'
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: OK
+ schema:
+ type: object
+ "400":
+ description: Bad Request
+ schema:
+ $ref: '#/definitions/proto.Error'
+ "404":
+ description: Not Found
+ schema:
+ $ref: '#/definitions/proto.Error'
+ "500":
+ description: Internal Server Error
+ schema:
+ $ref: '#/definitions/proto.Error'
+ summary: Read MCP resource
+ tags:
+ - mcp
+ /workspaces/{id}/mcp/refresh-prompts:
+ post:
+ consumes:
+ - application/json
+ parameters:
+ - description: Workspace ID
+ in: path
+ name: id
+ required: true
+ type: string
+ - description: MCP name request
+ in: body
+ name: request
+ required: true
+ schema:
+ $ref: '#/definitions/proto.MCPNameRequest'
+ responses:
+ "200":
+ description: OK
+ "400":
+ description: Bad Request
+ schema:
+ $ref: '#/definitions/proto.Error'
+ "404":
+ description: Not Found
+ schema:
+ $ref: '#/definitions/proto.Error'
+ "500":
+ description: Internal Server Error
+ schema:
+ $ref: '#/definitions/proto.Error'
+ summary: Refresh MCP prompts
+ tags:
+ - mcp
+ /workspaces/{id}/mcp/refresh-resources:
+ post:
+ consumes:
+ - application/json
+ parameters:
+ - description: Workspace ID
+ in: path
+ name: id
+ required: true
+ type: string
+ - description: MCP name request
+ in: body
+ name: request
+ required: true
+ schema:
+ $ref: '#/definitions/proto.MCPNameRequest'
+ responses:
+ "200":
+ description: OK
+ "400":
+ description: Bad Request
+ schema:
+ $ref: '#/definitions/proto.Error'
+ "404":
+ description: Not Found
+ schema:
+ $ref: '#/definitions/proto.Error'
+ "500":
+ description: Internal Server Error
+ schema:
+ $ref: '#/definitions/proto.Error'
+ summary: Refresh MCP resources
+ tags:
+ - mcp
+ /workspaces/{id}/mcp/refresh-tools:
+ post:
+ consumes:
+ - application/json
+ parameters:
+ - description: Workspace ID
+ in: path
+ name: id
+ required: true
+ type: string
+ - description: MCP name request
+ in: body
+ name: request
+ required: true
+ schema:
+ $ref: '#/definitions/proto.MCPNameRequest'
+ responses:
+ "200":
+ description: OK
+ "400":
+ description: Bad Request
+ schema:
+ $ref: '#/definitions/proto.Error'
+ "404":
+ description: Not Found
+ schema:
+ $ref: '#/definitions/proto.Error'
+ "500":
+ description: Internal Server Error
+ schema:
+ $ref: '#/definitions/proto.Error'
+ summary: Refresh MCP tools
+ tags:
+ - mcp
+ /workspaces/{id}/mcp/states:
+ get:
+ parameters:
+ - description: Workspace ID
+ in: path
+ name: id
+ required: true
+ type: string
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: OK
+ schema:
+ additionalProperties:
+ $ref: '#/definitions/proto.MCPClientInfo'
+ type: object
+ "404":
+ description: Not Found
+ schema:
+ $ref: '#/definitions/proto.Error'
+ "500":
+ description: Internal Server Error
+ schema:
+ $ref: '#/definitions/proto.Error'
+ summary: Get MCP client states
+ tags:
+ - mcp
+ /workspaces/{id}/messages/user:
+ get:
+ parameters:
+ - description: Workspace ID
+ in: path
+ name: id
+ required: true
+ type: string
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: OK
+ schema:
+ items:
+ $ref: '#/definitions/github_com_charmbracelet_crush_internal_proto.Message'
+ type: array
+ "404":
+ description: Not Found
+ schema:
+ $ref: '#/definitions/proto.Error'
+ "500":
+ description: Internal Server Error
+ schema:
+ $ref: '#/definitions/proto.Error'
+ summary: Get all user messages for workspace
+ tags:
+ - workspaces
+ /workspaces/{id}/permissions/grant:
+ post:
+ consumes:
+ - application/json
+ parameters:
+ - description: Workspace ID
+ in: path
+ name: id
+ required: true
+ type: string
+ - description: Permission grant
+ in: body
+ name: request
+ required: true
+ schema:
+ $ref: '#/definitions/proto.PermissionGrant'
+ responses:
+ "200":
+ description: OK
+ "400":
+ description: Bad Request
+ schema:
+ $ref: '#/definitions/proto.Error'
+ "404":
+ description: Not Found
+ schema:
+ $ref: '#/definitions/proto.Error'
+ "500":
+ description: Internal Server Error
+ schema:
+ $ref: '#/definitions/proto.Error'
+ summary: Grant permission
+ tags:
+ - permissions
+ /workspaces/{id}/permissions/skip:
+ get:
+ parameters:
+ - description: Workspace ID
+ in: path
+ name: id
+ required: true
+ type: string
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: OK
+ schema:
+ $ref: '#/definitions/proto.PermissionSkipRequest'
+ "404":
+ description: Not Found
+ schema:
+ $ref: '#/definitions/proto.Error'
+ "500":
+ description: Internal Server Error
+ schema:
+ $ref: '#/definitions/proto.Error'
+ summary: Get skip permissions status
+ tags:
+ - permissions
+ post:
+ consumes:
+ - application/json
+ parameters:
+ - description: Workspace ID
+ in: path
+ name: id
+ required: true
+ type: string
+ - description: Permission skip request
+ in: body
+ name: request
+ required: true
+ schema:
+ $ref: '#/definitions/proto.PermissionSkipRequest'
+ responses:
+ "200":
+ description: OK
+ "400":
+ description: Bad Request
+ schema:
+ $ref: '#/definitions/proto.Error'
+ "404":
+ description: Not Found
+ schema:
+ $ref: '#/definitions/proto.Error'
+ "500":
+ description: Internal Server Error
+ schema:
+ $ref: '#/definitions/proto.Error'
+ summary: Set skip permissions
+ tags:
+ - permissions
+ /workspaces/{id}/project/init:
+ post:
+ parameters:
+ - description: Workspace ID
+ in: path
+ name: id
+ required: true
+ type: string
+ responses:
+ "200":
+ description: OK
+ "404":
+ description: Not Found
+ schema:
+ $ref: '#/definitions/proto.Error'
+ "500":
+ description: Internal Server Error
+ schema:
+ $ref: '#/definitions/proto.Error'
+ summary: Mark project as initialized
+ tags:
+ - project
+ /workspaces/{id}/project/init-prompt:
+ get:
+ parameters:
+ - description: Workspace ID
+ in: path
+ name: id
+ required: true
+ type: string
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: OK
+ schema:
+ $ref: '#/definitions/proto.ProjectInitPromptResponse'
+ "404":
+ description: Not Found
+ schema:
+ $ref: '#/definitions/proto.Error'
+ "500":
+ description: Internal Server Error
+ schema:
+ $ref: '#/definitions/proto.Error'
+ summary: Get project initialization prompt
+ tags:
+ - project
+ /workspaces/{id}/project/needs-init:
+ get:
+ parameters:
+ - description: Workspace ID
+ in: path
+ name: id
+ required: true
+ type: string
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: OK
+ schema:
+ $ref: '#/definitions/proto.ProjectNeedsInitResponse'
+ "404":
+ description: Not Found
+ schema:
+ $ref: '#/definitions/proto.Error'
+ "500":
+ description: Internal Server Error
+ schema:
+ $ref: '#/definitions/proto.Error'
+ summary: Check if project needs initialization
+ tags:
+ - project
+ /workspaces/{id}/providers:
+ get:
+ parameters:
+ - description: Workspace ID
+ in: path
+ name: id
+ required: true
+ type: string
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: OK
+ schema:
+ type: object
+ "404":
+ description: Not Found
+ schema:
+ $ref: '#/definitions/proto.Error'
+ "500":
+ description: Internal Server Error
+ schema:
+ $ref: '#/definitions/proto.Error'
+ summary: Get workspace providers
+ tags:
+ - workspaces
+ /workspaces/{id}/sessions:
+ get:
+ parameters:
+ - description: Workspace ID
+ in: path
+ name: id
+ required: true
+ type: string
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: OK
+ schema:
+ items:
+ $ref: '#/definitions/proto.Session'
+ type: array
+ "404":
+ description: Not Found
+ schema:
+ $ref: '#/definitions/proto.Error'
+ "500":
+ description: Internal Server Error
+ schema:
+ $ref: '#/definitions/proto.Error'
+ summary: List sessions
+ tags:
+ - sessions
+ post:
+ consumes:
+ - application/json
+ parameters:
+ - description: Workspace ID
+ in: path
+ name: id
+ required: true
+ type: string
+ - description: Session creation params (title)
+ in: body
+ name: request
+ required: true
+ schema:
+ $ref: '#/definitions/proto.Session'
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: OK
+ schema:
+ $ref: '#/definitions/proto.Session'
+ "400":
+ description: Bad Request
+ schema:
+ $ref: '#/definitions/proto.Error'
+ "404":
+ description: Not Found
+ schema:
+ $ref: '#/definitions/proto.Error'
+ "500":
+ description: Internal Server Error
+ schema:
+ $ref: '#/definitions/proto.Error'
+ summary: Create session
+ tags:
+ - sessions
+ /workspaces/{id}/sessions/{sid}:
+ delete:
+ parameters:
+ - description: Workspace ID
+ in: path
+ name: id
+ required: true
+ type: string
+ - description: Session ID
+ in: path
+ name: sid
+ required: true
+ type: string
+ responses:
+ "200":
+ description: OK
+ "404":
+ description: Not Found
+ schema:
+ $ref: '#/definitions/proto.Error'
+ "500":
+ description: Internal Server Error
+ schema:
+ $ref: '#/definitions/proto.Error'
+ summary: Delete session
+ tags:
+ - sessions
+ get:
+ parameters:
+ - description: Workspace ID
+ in: path
+ name: id
+ required: true
+ type: string
+ - description: Session ID
+ in: path
+ name: sid
+ required: true
+ type: string
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: OK
+ schema:
+ $ref: '#/definitions/proto.Session'
+ "404":
+ description: Not Found
+ schema:
+ $ref: '#/definitions/proto.Error'
+ "500":
+ description: Internal Server Error
+ schema:
+ $ref: '#/definitions/proto.Error'
+ summary: Get session
+ tags:
+ - sessions
+ put:
+ consumes:
+ - application/json
+ parameters:
+ - description: Workspace ID
+ in: path
+ name: id
+ required: true
+ type: string
+ - description: Session ID
+ in: path
+ name: sid
+ required: true
+ type: string
+ - description: Updated session
+ in: body
+ name: request
+ required: true
+ schema:
+ $ref: '#/definitions/proto.Session'
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: OK
+ schema:
+ $ref: '#/definitions/proto.Session'
+ "400":
+ description: Bad Request
+ schema:
+ $ref: '#/definitions/proto.Error'
+ "404":
+ description: Not Found
+ schema:
+ $ref: '#/definitions/proto.Error'
+ "500":
+ description: Internal Server Error
+ schema:
+ $ref: '#/definitions/proto.Error'
+ summary: Update session
+ tags:
+ - sessions
+ /workspaces/{id}/sessions/{sid}/filetracker/files:
+ get:
+ parameters:
+ - description: Workspace ID
+ in: path
+ name: id
+ required: true
+ type: string
+ - description: Session ID
+ in: path
+ name: sid
+ required: true
+ type: string
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: OK
+ schema:
+ items:
+ type: string
+ type: array
+ "404":
+ description: Not Found
+ schema:
+ $ref: '#/definitions/proto.Error'
+ "500":
+ description: Internal Server Error
+ schema:
+ $ref: '#/definitions/proto.Error'
+ summary: List tracked files for session
+ tags:
+ - filetracker
+ /workspaces/{id}/sessions/{sid}/history:
+ get:
+ parameters:
+ - description: Workspace ID
+ in: path
+ name: id
+ required: true
+ type: string
+ - description: Session ID
+ in: path
+ name: sid
+ required: true
+ type: string
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: OK
+ schema:
+ items:
+ $ref: '#/definitions/proto.File'
+ type: array
+ "404":
+ description: Not Found
+ schema:
+ $ref: '#/definitions/proto.Error'
+ "500":
+ description: Internal Server Error
+ schema:
+ $ref: '#/definitions/proto.Error'
+ summary: Get session history
+ tags:
+ - sessions
+ /workspaces/{id}/sessions/{sid}/messages:
+ get:
+ parameters:
+ - description: Workspace ID
+ in: path
+ name: id
+ required: true
+ type: string
+ - description: Session ID
+ in: path
+ name: sid
+ required: true
+ type: string
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: OK
+ schema:
+ items:
+ $ref: '#/definitions/github_com_charmbracelet_crush_internal_proto.Message'
+ type: array
+ "404":
+ description: Not Found
+ schema:
+ $ref: '#/definitions/proto.Error'
+ "500":
+ description: Internal Server Error
+ schema:
+ $ref: '#/definitions/proto.Error'
+ summary: Get session messages
+ tags:
+ - sessions
+ /workspaces/{id}/sessions/{sid}/messages/user:
+ get:
+ parameters:
+ - description: Workspace ID
+ in: path
+ name: id
+ required: true
+ type: string
+ - description: Session ID
+ in: path
+ name: sid
+ required: true
+ type: string
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: OK
+ schema:
+ items:
+ $ref: '#/definitions/github_com_charmbracelet_crush_internal_proto.Message'
+ type: array
+ "404":
+ description: Not Found
+ schema:
+ $ref: '#/definitions/proto.Error'
+ "500":
+ description: Internal Server Error
+ schema:
+ $ref: '#/definitions/proto.Error'
+ summary: Get user messages for session
+ tags:
+ - sessions
+swagger: "2.0"
@@ -7,10 +7,10 @@ import (
tea "charm.land/bubbletea/v2"
"github.com/atotto/clipboard"
- "github.com/charmbracelet/crush/internal/app"
"github.com/charmbracelet/crush/internal/config"
"github.com/charmbracelet/crush/internal/ui/styles"
"github.com/charmbracelet/crush/internal/ui/util"
+ "github.com/charmbracelet/crush/internal/workspace"
uv "github.com/charmbracelet/ultraviolet"
)
@@ -22,26 +22,21 @@ var AllowedImageTypes = []string{".jpg", ".jpeg", ".png"}
// Common defines common UI options and configurations.
type Common struct {
- App *app.App
- Styles *styles.Styles
+ Workspace workspace.Workspace
+ Styles *styles.Styles
}
// Config returns the pure-data configuration associated with this [Common] instance.
func (c *Common) Config() *config.Config {
- return c.App.Config()
-}
-
-// Store returns the config store associated with this [Common] instance.
-func (c *Common) Store() *config.ConfigStore {
- return c.App.Store()
+ return c.Workspace.Config()
}
// DefaultCommon returns the default common UI configurations.
-func DefaultCommon(app *app.App) *Common {
+func DefaultCommon(ws workspace.Workspace) *Common {
s := styles.DefaultStyles()
return &Common{
- App: app,
- Styles: &s,
+ Workspace: ws,
+ Styles: &s,
}
}
@@ -291,7 +291,7 @@ func (m *APIKeyInput) verifyAPIKey() tea.Msg {
Type: m.provider.Type,
BaseURL: m.provider.APIEndpoint,
}
- err := providerConfig.TestConnection(m.com.Store().Resolver())
+ err := providerConfig.TestConnection(m.com.Workspace.Resolver())
// intentionally wait for at least 750ms to make sure the user sees the spinner
elapsed := time.Since(start)
@@ -307,9 +307,7 @@ func (m *APIKeyInput) verifyAPIKey() tea.Msg {
}
func (m *APIKeyInput) saveKeyAndContinue() Action {
- store := m.com.Store()
-
- err := store.SetProviderAPIKey(config.ScopeGlobal, string(m.provider.ID), m.input.Value())
+ err := m.com.Workspace.SetProviderAPIKey(config.ScopeGlobal, string(m.provider.ID), m.input.Value())
if err != nil {
return ActionCmd{util.ReportError(fmt.Errorf("failed to save API key: %w", err))}
}
@@ -123,7 +123,7 @@ func (f *FilePicker) SetImageCapabilities(caps *common.Capabilities) {
// WorkingDir returns the current working directory of the [FilePicker].
func (f *FilePicker) WorkingDir() string {
- wd := f.com.Store().WorkingDir()
+ wd := f.com.Workspace.WorkingDir()
if len(wd) > 0 {
return wd
}
@@ -485,7 +485,7 @@ func (m *Models) setProviderItems() error {
if len(validRecentItems) != len(recentItems) {
// FIXME: Does this need to be here? Is it mutating the config during a read?
- if err := m.com.Store().SetConfigField(config.ScopeGlobal, fmt.Sprintf("recent_models.%s", selectedType), validRecentItems); err != nil {
+ if err := m.com.Workspace.SetConfigField(config.ScopeGlobal, fmt.Sprintf("recent_models.%s", selectedType), validRecentItems); err != nil {
return fmt.Errorf("failed to update recent models: %w", err)
}
}
@@ -373,9 +373,7 @@ func (d *OAuth) copyCodeAndOpenURL() tea.Cmd {
}
func (m *OAuth) saveKeyAndContinue() Action {
- store := m.com.Store()
-
- err := store.SetProviderAPIKey(config.ScopeGlobal, string(m.provider.ID), m.token)
+ err := m.com.Workspace.SetProviderAPIKey(config.ScopeGlobal, string(m.provider.ID), m.token)
if err != nil {
return ActionCmd{util.ReportError(fmt.Errorf("failed to save API key: %w", err))}
}
@@ -61,7 +61,7 @@ func NewSessions(com *common.Common, selectedSessionID string) (*Session, error)
s := new(Session)
s.sessionsMode = sessionsModeNormal
s.com = com
- sessions, err := com.App.Sessions.List(context.TODO())
+ sessions, err := com.Workspace.ListSessions(context.TODO())
if err != nil {
return nil, err
}
@@ -349,7 +349,7 @@ func (s *Session) removeSession(id string) {
func (s *Session) deleteSessionCmd(id string) tea.Cmd {
return func() tea.Msg {
- err := s.com.App.Sessions.Delete(context.TODO(), id)
+ err := s.com.Workspace.DeleteSession(context.TODO(), id)
if err != nil {
return util.NewErrorMsg(err)
}
@@ -385,7 +385,7 @@ func (s *Session) updateSession(session session.Session) {
func (s *Session) updateSessionCmd(session session.Session) tea.Cmd {
return func() tea.Msg {
- _, err := s.com.App.Sessions.Save(context.TODO(), session)
+ _, err := s.com.Workspace.SaveSession(context.TODO(), session)
if err != nil {
return util.NewErrorMsg(err)
}
@@ -399,11 +399,11 @@ func (s *Session) isCurrentSessionBusy() bool {
return false
}
- if s.com.App.AgentCoordinator == nil {
+ if !s.com.Workspace.AgentIsReady() {
return false
}
- return s.com.App.AgentCoordinator.IsSessionBusy(sessionItem.ID())
+ return s.com.Workspace.AgentIsSessionBusy(sessionItem.ID())
}
// ShortHelp implements [help.KeyMap].
@@ -6,9 +6,7 @@ import (
"charm.land/lipgloss/v2"
"github.com/charmbracelet/crush/internal/config"
- "github.com/charmbracelet/crush/internal/csync"
"github.com/charmbracelet/crush/internal/fsext"
- "github.com/charmbracelet/crush/internal/lsp"
"github.com/charmbracelet/crush/internal/session"
"github.com/charmbracelet/crush/internal/ui/common"
"github.com/charmbracelet/crush/internal/ui/styles"
@@ -62,7 +60,7 @@ func (h *header) drawHeader(
h.width = width
h.compact = compact
- if !compact || session == nil || h.com.App == nil {
+ if !compact || session == nil {
uv.NewStyledString(h.logo).Draw(scr, area)
return
}
@@ -75,10 +73,14 @@ func (h *header) drawHeader(
b.WriteString(h.compactLogo)
availDetailWidth := width - leftPadding - rightPadding - lipgloss.Width(b.String()) - minHeaderDiags - diagToDetailsSpacing
+ lspErrorCount := 0
+ for _, info := range h.com.Workspace.LSPGetStates() {
+ lspErrorCount += info.DiagnosticCount
+ }
details := renderHeaderDetails(
h.com,
session,
- h.com.App.LSPManager.Clients(),
+ lspErrorCount,
detailsOpen,
availDetailWidth,
)
@@ -108,7 +110,7 @@ func (h *header) drawHeader(
func renderHeaderDetails(
com *common.Common,
session *session.Session,
- lspClients *csync.Map[string, *lsp.Client],
+ lspErrorCount int,
detailsOpen bool,
availWidth int,
) string {
@@ -116,20 +118,17 @@ func renderHeaderDetails(
var parts []string
- errorCount := 0
- for l := range lspClients.Seq() {
- errorCount += l.GetDiagnosticCounts().Error
- }
-
- if errorCount > 0 {
- parts = append(parts, t.LSP.ErrorDiagnostic.Render(fmt.Sprintf("%s%d", styles.LSPErrorIcon, errorCount)))
+ if lspErrorCount > 0 {
+ parts = append(parts, t.LSP.ErrorDiagnostic.Render(fmt.Sprintf("%s%d", styles.LSPErrorIcon, lspErrorCount)))
}
agentCfg := com.Config().Agents[config.AgentCoder]
model := com.Config().GetModelByType(agentCfg.Model)
- percentage := (float64(session.CompletionTokens+session.PromptTokens) / float64(model.ContextWindow)) * 100
- formattedPercentage := t.Header.Percentage.Render(fmt.Sprintf("%d%%", int(percentage)))
- parts = append(parts, formattedPercentage)
+ if model != nil && model.ContextWindow > 0 {
+ percentage := (float64(session.CompletionTokens+session.PromptTokens) / float64(model.ContextWindow)) * 100
+ formattedPercentage := t.Header.Percentage.Render(fmt.Sprintf("%d%%", int(percentage)))
+ parts = append(parts, formattedPercentage)
+ }
const keystroke = "ctrl+d"
if detailsOpen {
@@ -143,7 +142,7 @@ func renderHeaderDetails(
metadata = dot + metadata
const dirTrimLimit = 4
- cwd := fsext.DirTrim(fsext.PrettyPath(com.Store().WorkingDir()), dirTrimLimit)
+ cwd := fsext.DirTrim(fsext.PrettyPath(com.Workspace.WorkingDir()), dirTrimLimit)
cwd = t.Header.WorkingDir.Render(cwd)
result := cwd + metadata
@@ -22,9 +22,9 @@ func (m *UI) loadPromptHistory() tea.Cmd {
var err error
if m.session != nil {
- messages, err = m.com.App.Messages.ListUserMessages(ctx, m.session.ID)
+ messages, err = m.com.Workspace.ListUserMessages(ctx, m.session.ID)
} else {
- messages, err = m.com.App.Messages.ListAllUserMessages(ctx)
+ messages, err = m.com.Workspace.ListAllUserMessages(ctx)
}
if err != nil {
slog.Error("Failed to load prompt history", "error", err)
@@ -2,16 +2,16 @@ package model
import (
"charm.land/lipgloss/v2"
- "github.com/charmbracelet/crush/internal/agent"
"github.com/charmbracelet/crush/internal/ui/common"
+ "github.com/charmbracelet/crush/internal/workspace"
"github.com/charmbracelet/ultraviolet/layout"
)
// selectedLargeModel returns the currently selected large language model from
// the agent coordinator, if one exists.
-func (m *UI) selectedLargeModel() *agent.Model {
- if m.com.App.AgentCoordinator != nil {
- model := m.com.App.AgentCoordinator.Model()
+func (m *UI) selectedLargeModel() *workspace.AgentModel {
+ if m.com.Workspace.AgentIsReady() {
+ model := m.com.Workspace.AgentModel()
return &model
}
return nil
@@ -22,7 +22,7 @@ func (m *UI) selectedLargeModel() *agent.Model {
func (m *UI) landingView() string {
t := m.com.Styles
width := m.layout.main.Dx()
- cwd := common.PrettyPath(t, m.com.Store().WorkingDir(), width)
+ cwd := common.PrettyPath(t, m.com.Workspace.WorkingDir(), width)
parts := []string{
cwd,
@@ -7,16 +7,16 @@ import (
"strings"
"charm.land/lipgloss/v2"
- "github.com/charmbracelet/crush/internal/app"
"github.com/charmbracelet/crush/internal/lsp"
"github.com/charmbracelet/crush/internal/ui/common"
"github.com/charmbracelet/crush/internal/ui/styles"
+ "github.com/charmbracelet/crush/internal/workspace"
"github.com/charmbracelet/x/powernap/pkg/lsp/protocol"
)
// LSPInfo wraps LSP client information with diagnostic counts by severity.
type LSPInfo struct {
- app.LSPClientInfo
+ workspace.LSPClientInfo
Diagnostics map[protocol.DiagnosticSeverity]int
}
@@ -25,20 +25,18 @@ type LSPInfo struct {
func (m *UI) lspInfo(width, maxItems int, isSection bool) string {
t := m.com.Styles
- states := slices.SortedFunc(maps.Values(m.lspStates), func(a, b app.LSPClientInfo) int {
+ states := slices.SortedFunc(maps.Values(m.lspStates), func(a, b workspace.LSPClientInfo) int {
return strings.Compare(a.Name, b.Name)
})
var lsps []LSPInfo
for _, state := range states {
lspErrs := map[protocol.DiagnosticSeverity]int{}
- if client, ok := m.com.App.LSPManager.Clients().Get(state.Name); ok {
- counts := client.GetDiagnosticCounts()
- lspErrs[protocol.SeverityError] = counts.Error
- lspErrs[protocol.SeverityWarning] = counts.Warning
- lspErrs[protocol.SeverityHint] = counts.Hint
- lspErrs[protocol.SeverityInformation] = counts.Information
- }
+ counts := m.com.Workspace.LSPGetDiagnosticCounts(state.Name)
+ lspErrs[protocol.SeverityError] = counts.Error
+ lspErrs[protocol.SeverityWarning] = counts.Warning
+ lspErrs[protocol.SeverityHint] = counts.Hint
+ lspErrs[protocol.SeverityInformation] = counts.Information
lsps = append(lsps, LSPInfo{LSPClientInfo: state, Diagnostics: lspErrs})
}
@@ -9,8 +9,6 @@ import (
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
- "github.com/charmbracelet/crush/internal/agent"
- "github.com/charmbracelet/crush/internal/config"
"github.com/charmbracelet/crush/internal/home"
"github.com/charmbracelet/crush/internal/ui/common"
"github.com/charmbracelet/crush/internal/ui/util"
@@ -19,7 +17,7 @@ import (
// markProjectInitialized marks the current project as initialized in the config.
func (m *UI) markProjectInitialized() tea.Msg {
// TODO: handle error so we show it in the tui footer
- err := config.MarkProjectInitialized(m.com.Store())
+ err := m.com.Workspace.MarkProjectInitialized()
if err != nil {
slog.Error(err.Error())
}
@@ -52,12 +50,13 @@ func (m *UI) initializeProject() tea.Cmd {
if cmd := m.newSession(); cmd != nil {
cmds = append(cmds, cmd)
}
- cfg := m.com.Store()
-
initialize := func() tea.Msg {
- initPrompt, err := agent.InitializePrompt(cfg)
+ initPrompt, err := m.com.Workspace.InitializePrompt()
if err != nil {
- return util.InfoMsg{Type: util.InfoTypeError, Msg: err.Error()}
+ return util.InfoMsg{
+ Type: util.InfoTypeError,
+ Msg: fmt.Sprintf("Failed to initialize project: %v", err),
+ }
}
return sendMessageMsg{Content: initPrompt}
}
@@ -78,7 +77,7 @@ func (m *UI) skipInitializeProject() tea.Cmd {
// initializeView renders the project initialization prompt with Yes/No buttons.
func (m *UI) initializeView() string {
s := m.com.Styles.Initialize
- cwd := home.Short(m.com.Store().WorkingDir())
+ cwd := home.Short(m.com.Workspace.WorkingDir())
initFile := m.com.Config().Options.InitializeAs
header := s.Header.Render("Would you like to initialize this project?")
@@ -249,8 +249,8 @@ func (m *UI) renderPills() {
if todosFocused && hasIncomplete {
expandedList = todoList(m.session.Todos, inProgressIcon, t, contentWidth)
} else if queueFocused && hasQueue {
- if m.com.App != nil && m.com.App.AgentCoordinator != nil {
- queueItems := m.com.App.AgentCoordinator.QueuedPromptsList(m.session.ID)
+ if m.com.Workspace.AgentIsReady() {
+ queueItems := m.com.Workspace.AgentQueuedPromptsList(m.session.ID)
expandedList = queueList(queueItems, t)
}
}
@@ -66,7 +66,7 @@ type SessionFile struct {
// returns a sessionFilesLoadedMsg containing the processed session files.
func (m *UI) loadSession(sessionID string) tea.Cmd {
return func() tea.Msg {
- session, err := m.com.App.Sessions.Get(context.Background(), sessionID)
+ session, err := m.com.Workspace.GetSession(context.Background(), sessionID)
if err != nil {
return util.ReportError(err)
}
@@ -76,7 +76,7 @@ func (m *UI) loadSession(sessionID string) tea.Cmd {
return util.ReportError(err)
}
- readFiles, err := m.com.App.FileTracker.ListReadFiles(context.Background(), sessionID)
+ readFiles, err := m.com.Workspace.FileTrackerListReadFiles(context.Background(), sessionID)
if err != nil {
slog.Error("Failed to load read files for session", "error", err)
}
@@ -90,7 +90,7 @@ func (m *UI) loadSession(sessionID string) tea.Cmd {
}
func (m *UI) loadSessionFiles(sessionID string) ([]SessionFile, error) {
- files, err := m.com.App.History.ListBySession(context.Background(), sessionID)
+ files, err := m.com.Workspace.ListSessionHistory(context.Background(), sessionID)
if err != nil {
return nil, err
}
@@ -241,7 +241,7 @@ func (m *UI) startLSPs(paths []string) tea.Cmd {
return func() tea.Msg {
ctx := context.Background()
for _, path := range paths {
- m.com.App.LSPManager.Start(ctx, path)
+ m.com.Workspace.LSPStart(ctx, path)
}
return nil
}
@@ -48,7 +48,11 @@ func (m *UI) modelInfo(width int) string {
ModelContext: model.CatwalkCfg.ContextWindow,
}
}
- return common.ModelInfo(m.com.Styles, model.CatwalkCfg.Name, providerName, reasoningInfo, modelContext, width)
+ var modelName string
+ if model != nil {
+ modelName = model.CatwalkCfg.Name
+ }
+ return common.ModelInfo(m.com.Styles, modelName, providerName, reasoningInfo, modelContext, width)
}
// getDynamicHeightLimits will give us the num of items to show in each section based on the hight
@@ -112,7 +116,7 @@ func (m *UI) drawSidebar(scr uv.Screen, area uv.Rectangle) {
height := area.Dy()
title := t.Muted.Width(width).MaxHeight(2).Render(m.session.Title)
- cwd := common.PrettyPath(t, m.com.Store().WorkingDir(), width)
+ cwd := common.PrettyPath(t, m.com.Workspace.WorkingDir(), width)
sidebarLogo := m.sidebarLogo
if height < logoHeightBreakpoint {
sidebarLogo = logo.SmallRender(m.com.Styles, width)
@@ -138,7 +142,7 @@ func (m *UI) drawSidebar(scr uv.Screen, area uv.Rectangle) {
lspSection := m.lspInfo(width, maxLSPs, true)
mcpSection := m.mcpInfo(width, maxMCPs, true)
- filesSection := m.filesInfo(m.com.Store().WorkingDir(), width, maxFiles, true)
+ filesSection := m.filesInfo(m.com.Workspace.WorkingDir(), width, maxFiles, true)
uv.NewStyledString(
lipgloss.NewStyle().
@@ -28,7 +28,6 @@ import (
"github.com/charmbracelet/crush/internal/agent/notify"
agenttools "github.com/charmbracelet/crush/internal/agent/tools"
"github.com/charmbracelet/crush/internal/agent/tools/mcp"
- "github.com/charmbracelet/crush/internal/app"
"github.com/charmbracelet/crush/internal/commands"
"github.com/charmbracelet/crush/internal/config"
"github.com/charmbracelet/crush/internal/fsext"
@@ -50,6 +49,7 @@ import (
"github.com/charmbracelet/crush/internal/ui/styles"
"github.com/charmbracelet/crush/internal/ui/util"
"github.com/charmbracelet/crush/internal/version"
+ "github.com/charmbracelet/crush/internal/workspace"
uv "github.com/charmbracelet/ultraviolet"
"github.com/charmbracelet/ultraviolet/layout"
"github.com/charmbracelet/ultraviolet/screen"
@@ -213,7 +213,7 @@ type UI struct {
}
// lsp
- lspStates map[string]app.LSPClientInfo
+ lspStates map[string]workspace.LSPClientInfo
// mcp
mcpStates map[string]mcp.ClientInfo
@@ -315,7 +315,7 @@ func New(com *common.Common, initialSessionID string, continueLast bool) *UI {
completions: comp,
attachments: attachments,
todoSpinner: todoSpinner,
- lspStates: make(map[string]app.LSPClientInfo),
+ lspStates: make(map[string]workspace.LSPClientInfo),
mcpStates: make(map[string]mcp.ClientInfo),
notifyBackend: notification.NoopBackend{},
notifyWindowFocused: true,
@@ -340,7 +340,7 @@ func New(com *common.Common, initialSessionID string, continueLast bool) *UI {
desiredFocus := uiFocusEditor
if !com.Config().IsConfigured() {
desiredState = uiOnboarding
- } else if n, _ := config.ProjectNeedsInitialization(com.Store()); n {
+ } else if n, _ := com.Workspace.ProjectNeedsInitialization(); n {
desiredState = uiInitialize
}
@@ -386,11 +386,11 @@ func (m *UI) loadInitialSession() tea.Cmd {
return m.loadSession(m.initialSessionID)
case m.continueLastSession:
return func() tea.Msg {
- sess, err := m.com.App.Sessions.GetLast(context.Background())
- if err != nil {
+ sessions, err := m.com.Workspace.ListSessions(context.Background())
+ if err != nil || len(sessions) == 0 {
return nil
}
- return m.loadSession(sess.ID)()
+ return m.loadSession(sessions[0].ID)()
}
default:
return nil
@@ -463,7 +463,7 @@ func (m *UI) loadMCPrompts() tea.Msg {
func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
if m.hasSession() && m.isAgentBusy() {
- queueSize := m.com.App.AgentCoordinator.QueuedPrompts(m.session.ID)
+ queueSize := m.com.Workspace.AgentQueuedPrompts(m.session.ID)
if queueSize != m.promptQueue {
m.promptQueue = queueSize
m.updateLayoutAndSize()
@@ -498,7 +498,7 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.session = msg.session
m.sessionFiles = msg.files
cmds = append(cmds, m.startLSPs(msg.lspFilePaths()))
- msgs, err := m.com.App.Messages.List(context.Background(), m.session.ID)
+ msgs, err := m.com.Workspace.ListMessages(context.Background(), m.session.ID)
if err != nil {
cmds = append(cmds, util.ReportError(err))
break
@@ -615,8 +615,8 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.renderPills()
case pubsub.Event[history.File]:
cmds = append(cmds, m.handleFileEvent(msg.Payload))
- case pubsub.Event[app.LSPEvent]:
- m.lspStates = app.GetLSPStates()
+ case pubsub.Event[workspace.LSPEvent]:
+ m.lspStates = m.com.Workspace.LSPGetStates()
case pubsub.Event[mcp.Event]:
switch msg.Payload.Type {
case mcp.EventStateChanged:
@@ -625,11 +625,11 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.loadMCPrompts,
)
case mcp.EventPromptsListChanged:
- return m, handleMCPPromptsEvent(msg.Payload.Name)
+ return m, handleMCPPromptsEvent(m.com.Workspace, msg.Payload.Name)
case mcp.EventToolsListChanged:
- return m, handleMCPToolsEvent(m.com.Store(), msg.Payload.Name)
+ return m, handleMCPToolsEvent(m.com.Workspace, msg.Payload.Name)
case mcp.EventResourcesListChanged:
- return m, handleMCPResourcesEvent(msg.Payload.Name)
+ return m, handleMCPResourcesEvent(m.com.Workspace, msg.Payload.Name)
}
case pubsub.Event[permission.PermissionRequest]:
if cmd := m.openPermissionsDialog(msg.Payload); cmd != nil {
@@ -836,6 +836,9 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.textarea.MoveToEnd()
cmds = append(cmds, m.updateTextareaWithPrevHeight(msg, prevHeight))
case util.InfoMsg:
+ if msg.Type == util.InfoTypeError {
+ slog.Error("Error reported", "error", msg.Msg)
+ }
m.status.SetInfoMsg(msg)
ttl := msg.TTL
if ttl <= 0 {
@@ -872,7 +875,7 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} else {
m.textarea.Placeholder = m.readyPlaceholder
}
- if m.com.App.Permissions.SkipRequests() {
+ if m.com.Workspace.PermissionSkipRequests() {
m.textarea.Placeholder = "Yolo mode!"
}
}
@@ -951,10 +954,10 @@ func (m *UI) loadNestedToolCalls(items []chat.MessageItem) {
messageID := toolItem.MessageID()
// Get the agent tool session ID.
- agentSessionID := m.com.App.Sessions.CreateAgentToolSessionID(messageID, tc.ID)
+ agentSessionID := m.com.Workspace.CreateAgentToolSessionID(messageID, tc.ID)
// Fetch nested messages.
- nestedMsgs, err := m.com.App.Messages.List(context.Background(), agentSessionID)
+ nestedMsgs, err := m.com.Workspace.ListMessages(context.Background(), agentSessionID)
if err != nil || len(nestedMsgs) == 0 {
continue
}
@@ -1156,7 +1159,7 @@ func (m *UI) handleChildSessionMessage(event pubsub.Event[message.Message]) tea.
// Check if this is an agent tool session and parse it.
childSessionID := event.Payload.SessionID
- _, toolCallID, ok := m.com.App.Sessions.ParseAgentToolSessionID(childSessionID)
+ _, toolCallID, ok := m.com.Workspace.ParseAgentToolSessionID(childSessionID)
if !ok {
return nil
}
@@ -1288,8 +1291,8 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
// Command dialog messages.
case dialog.ActionToggleYoloMode:
- yolo := !m.com.App.Permissions.SkipRequests()
- m.com.App.Permissions.SetSkipRequests(yolo)
+ yolo := !m.com.Workspace.PermissionSkipRequests()
+ m.com.Workspace.PermissionSetSkipRequests(yolo)
m.setEditorPrompt(yolo)
m.dialog.CloseDialog(dialog.CommandsID)
case dialog.ActionToggleNotifications:
@@ -1297,7 +1300,7 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
if cfg != nil && cfg.Options != nil {
disabled := !cfg.Options.DisableNotifications
cfg.Options.DisableNotifications = disabled
- if err := m.com.Store().SetConfigField(config.ScopeGlobal, "options.disable_notifications", disabled); err != nil {
+ if err := m.com.Workspace.SetConfigField(config.ScopeGlobal, "options.disable_notifications", disabled); err != nil {
cmds = append(cmds, util.ReportError(err))
} else {
status := "enabled"
@@ -1323,7 +1326,7 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
break
}
cmds = append(cmds, func() tea.Msg {
- err := m.com.App.AgentCoordinator.Summarize(context.Background(), msg.SessionID)
+ err := m.com.Workspace.AgentSummarize(context.Background(), msg.SessionID)
if err != nil {
return util.ReportError(err)()
}
@@ -1362,10 +1365,10 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
currentModel := cfg.Models[agentCfg.Model]
currentModel.Think = !currentModel.Think
- if err := m.com.Store().UpdatePreferredModel(config.ScopeGlobal, agentCfg.Model, currentModel); err != nil {
+ if err := m.com.Workspace.UpdatePreferredModel(config.ScopeGlobal, agentCfg.Model, currentModel); err != nil {
return util.ReportError(err)()
}
- m.com.App.UpdateAgentModel(context.TODO())
+ m.com.Workspace.UpdateAgentModel(context.TODO())
status := "disabled"
if currentModel.Think {
status = "enabled"
@@ -1382,7 +1385,7 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
isTransparent := cfg.Options != nil && cfg.Options.TUI.Transparent != nil && *cfg.Options.TUI.Transparent
newValue := !isTransparent
- if err := m.com.Store().SetTransparentBackground(config.ScopeGlobal, newValue); err != nil {
+ if err := m.com.Workspace.SetConfigField(config.ScopeGlobal, "options.tui.transparent", newValue); err != nil {
return util.ReportError(err)()
}
m.isTransparent = newValue
@@ -1430,7 +1433,7 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
// Attempt to import GitHub Copilot tokens from VSCode if available.
if isCopilot && !isConfigured() && !msg.ReAuthenticate {
- m.com.Store().ImportCopilot()
+ m.com.Workspace.ImportCopilot()
}
if !isConfigured() || msg.ReAuthenticate {
@@ -1441,18 +1444,18 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
break
}
- if err := m.com.Store().UpdatePreferredModel(config.ScopeGlobal, msg.ModelType, msg.Model); err != nil {
+ if err := m.com.Workspace.UpdatePreferredModel(config.ScopeGlobal, msg.ModelType, msg.Model); err != nil {
cmds = append(cmds, util.ReportError(err))
} else if _, ok := cfg.Models[config.SelectedModelTypeSmall]; !ok {
// Ensure small model is set is unset.
- smallModel := m.com.App.GetDefaultSmallModel(providerID)
- if err := m.com.Store().UpdatePreferredModel(config.ScopeGlobal, config.SelectedModelTypeSmall, smallModel); err != nil {
+ smallModel := m.com.Workspace.GetDefaultSmallModel(providerID)
+ if err := m.com.Workspace.UpdatePreferredModel(config.ScopeGlobal, config.SelectedModelTypeSmall, smallModel); err != nil {
cmds = append(cmds, util.ReportError(err))
}
}
cmds = append(cmds, func() tea.Msg {
- if err := m.com.App.UpdateAgentModel(context.TODO()); err != nil {
+ if err := m.com.Workspace.UpdateAgentModel(context.TODO()); err != nil {
return util.ReportError(err)
}
@@ -1468,7 +1471,7 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
if isOnboarding {
m.setState(uiLanding, uiFocusEditor)
m.com.Config().SetupAgents()
- if err := m.com.App.InitCoderAgent(context.TODO()); err != nil {
+ if err := m.com.Workspace.InitCoderAgent(context.TODO()); err != nil {
cmds = append(cmds, util.ReportError(err))
}
}
@@ -1492,13 +1495,13 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
currentModel := cfg.Models[agentCfg.Model]
currentModel.ReasoningEffort = msg.Effort
- if err := m.com.Store().UpdatePreferredModel(config.ScopeGlobal, agentCfg.Model, currentModel); err != nil {
+ if err := m.com.Workspace.UpdatePreferredModel(config.ScopeGlobal, agentCfg.Model, currentModel); err != nil {
cmds = append(cmds, util.ReportError(err))
break
}
cmds = append(cmds, func() tea.Msg {
- m.com.App.UpdateAgentModel(context.TODO())
+ m.com.Workspace.UpdateAgentModel(context.TODO())
return util.NewInfoMsg("Reasoning effort set to " + msg.Effort)
})
m.dialog.CloseDialog(dialog.ReasoningID)
@@ -1506,11 +1509,11 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
m.dialog.CloseDialog(dialog.PermissionsID)
switch msg.Action {
case dialog.PermissionAllow:
- m.com.App.Permissions.Grant(msg.Permission)
+ m.com.Workspace.PermissionGrant(msg.Permission)
case dialog.PermissionAllowForSession:
- m.com.App.Permissions.GrantPersistent(msg.Permission)
+ m.com.Workspace.PermissionGrantPersistent(msg.Permission)
case dialog.PermissionDeny:
- m.com.App.Permissions.Deny(msg.Permission)
+ m.com.Workspace.PermissionDeny(msg.Permission)
}
case dialog.ActionFilePickerSelected:
@@ -2115,7 +2118,7 @@ func (m *UI) View() tea.View {
}
v.MouseMode = tea.MouseModeCellMotion
v.ReportFocus = m.caps.ReportFocusEvents
- v.WindowTitle = "crush " + home.Short(m.com.Store().WorkingDir())
+ v.WindowTitle = "crush " + home.Short(m.com.Workspace.WorkingDir())
canvas := uv.NewScreenBuffer(m.width, m.height)
v.Cursor = m.Draw(canvas, canvas.Bounds())
@@ -2158,7 +2161,7 @@ func (m *UI) ShortHelp() []key.Binding {
cancelBinding := k.Chat.Cancel
if m.isCanceling {
cancelBinding.SetHelp("esc", "press again to cancel")
- } else if m.com.App.AgentCoordinator.QueuedPrompts(m.session.ID) > 0 {
+ } else if m.com.Workspace.AgentQueuedPrompts(m.session.ID) > 0 {
cancelBinding.SetHelp("esc", "clear queue")
}
binds = append(binds, cancelBinding)
@@ -2237,7 +2240,7 @@ func (m *UI) FullHelp() [][]key.Binding {
cancelBinding := k.Chat.Cancel
if m.isCanceling {
cancelBinding.SetHelp("esc", "press again to cancel")
- } else if m.com.App.AgentCoordinator.QueuedPrompts(m.session.ID) > 0 {
+ } else if m.com.Workspace.AgentQueuedPrompts(m.session.ID) > 0 {
cancelBinding.SetHelp("esc", "clear queue")
}
binds = append(binds, []key.Binding{cancelBinding})
@@ -2364,7 +2367,7 @@ func (m *UI) currentModelSupportsImages() bool {
func (m *UI) toggleCompactMode() tea.Cmd {
m.forceCompactMode = !m.forceCompactMode
- err := m.com.Store().SetCompactMode(config.ScopeGlobal, m.forceCompactMode)
+ err := m.com.Workspace.SetCompactMode(config.ScopeGlobal, m.forceCompactMode)
if err != nil {
return util.ReportError(err)
}
@@ -2751,7 +2754,7 @@ func (m *UI) insertFileCompletion(path string) tea.Cmd {
if m.hasSession() {
// Skip attachment if file was already read and hasn't been modified.
- lastRead := m.com.App.FileTracker.LastReadTime(context.Background(), m.session.ID, absPath)
+ lastRead := m.com.Workspace.FileTrackerLastReadTime(context.Background(), m.session.ID, absPath)
if !lastRead.IsZero() {
if info, err := os.Stat(path); err == nil && !info.ModTime().After(lastRead) {
return nil
@@ -2792,9 +2795,8 @@ func (m *UI) insertMCPResourceCompletion(item completions.ResourceCompletionValu
heightCmd := m.handleTextareaHeightChange(prevHeight)
resourceCmd := func() tea.Msg {
- contents, err := mcp.ReadResource(
+ contents, err := m.com.Workspace.ReadMCPResource(
context.Background(),
- m.com.Store(),
item.MCPName,
item.URI,
)
@@ -2863,9 +2865,8 @@ func isWhitespace(b byte) bool {
// isAgentBusy returns true if the agent coordinator exists and is currently
// busy processing a request.
func (m *UI) isAgentBusy() bool {
- return m.com.App != nil &&
- m.com.App.AgentCoordinator != nil &&
- m.com.App.AgentCoordinator.IsBusy()
+ return m.com.Workspace.AgentIsReady() &&
+ m.com.Workspace.AgentIsBusy()
}
// hasSession returns true if there is an active session with a valid ID.
@@ -2922,13 +2923,13 @@ func (m *UI) cacheSidebarLogo(width int) {
// sendMessage sends a message with the given content and attachments.
func (m *UI) sendMessage(content string, attachments ...message.Attachment) tea.Cmd {
- if m.com.App.AgentCoordinator == nil {
+ if !m.com.Workspace.AgentIsReady() {
return util.ReportError(fmt.Errorf("coder agent is not initialized"))
}
var cmds []tea.Cmd
if !m.hasSession() {
- newSession, err := m.com.App.Sessions.Create(context.Background(), "New Session")
+ newSession, err := m.com.Workspace.CreateSession(context.Background(), "New Session")
if err != nil {
return util.ReportError(err)
}
@@ -2945,8 +2946,8 @@ func (m *UI) sendMessage(content string, attachments ...message.Attachment) tea.
ctx := context.Background()
cmds = append(cmds, func() tea.Msg {
for _, path := range m.sessionFileReads {
- m.com.App.FileTracker.RecordRead(ctx, m.session.ID, path)
- m.com.App.LSPManager.Start(ctx, path)
+ m.com.Workspace.FileTrackerRecordRead(ctx, m.session.ID, path)
+ m.com.Workspace.LSPStart(ctx, path)
}
return nil
})
@@ -2954,7 +2955,7 @@ func (m *UI) sendMessage(content string, attachments ...message.Attachment) tea.
// Capture session ID to avoid race with main goroutine updating m.session.
sessionID := m.session.ID
cmds = append(cmds, func() tea.Msg {
- _, err := m.com.App.AgentCoordinator.Run(context.Background(), sessionID, content, attachments...)
+ err := m.com.Workspace.AgentRun(context.Background(), sessionID, content, attachments...)
if err != nil {
isCancelErr := errors.Is(err, context.Canceled)
isPermissionErr := errors.Is(err, permission.ErrorPermissionDenied)
@@ -2963,7 +2964,7 @@ func (m *UI) sendMessage(content string, attachments ...message.Attachment) tea.
}
return util.InfoMsg{
Type: util.InfoTypeError,
- Msg: err.Error(),
+ Msg: fmt.Sprintf("Failed to run agent: %v", err),
}
}
return nil
@@ -2988,15 +2989,14 @@ func (m *UI) cancelAgent() tea.Cmd {
return nil
}
- coordinator := m.com.App.AgentCoordinator
- if coordinator == nil {
+ if !m.com.Workspace.AgentIsReady() {
return nil
}
if m.isCanceling {
// Second escape press - actually cancel the agent.
m.isCanceling = false
- coordinator.Cancel(m.session.ID)
+ m.com.Workspace.AgentCancel(m.session.ID)
// Stop the spinning todo indicator.
m.todoIsSpinning = false
m.renderPills()
@@ -3004,8 +3004,8 @@ func (m *UI) cancelAgent() tea.Cmd {
}
// Check if there are queued prompts - if so, clear the queue.
- if coordinator.QueuedPrompts(m.session.ID) > 0 {
- coordinator.ClearQueue(m.session.ID)
+ if m.com.Workspace.AgentQueuedPrompts(m.session.ID) > 0 {
+ m.com.Workspace.AgentClearQueue(m.session.ID)
return nil
}
@@ -3230,7 +3230,7 @@ func (m *UI) newSession() tea.Cmd {
agenttools.ResetCache()
return tea.Batch(
func() tea.Msg {
- m.com.App.LSPManager.StopAll(context.Background())
+ m.com.Workspace.LSPStopAll(context.Background())
return nil
},
m.loadPromptHistory(),
@@ -3476,7 +3476,7 @@ func (m *UI) drawSessionDetails(scr uv.Screen, area uv.Rectangle) {
lspSection := m.lspInfo(sectionWidth, maxItemsPerSection, false)
mcpSection := m.mcpInfo(sectionWidth, maxItemsPerSection, false)
- filesSection := m.filesInfo(m.com.Store().WorkingDir(), sectionWidth, maxItemsPerSection, false)
+ filesSection := m.filesInfo(m.com.Workspace.WorkingDir(), sectionWidth, maxItemsPerSection, false)
sections := lipgloss.JoinHorizontal(lipgloss.Top, filesSection, " ", lspSection, " ", mcpSection)
uv.NewStyledString(
s.CompactDetails.View.
@@ -3494,7 +3494,7 @@ func (m *UI) drawSessionDetails(scr uv.Screen, area uv.Rectangle) {
func (m *UI) runMCPPrompt(clientID, promptID string, arguments map[string]string) tea.Cmd {
load := func() tea.Msg {
- prompt, err := commands.GetMCPPrompt(m.com.Store(), clientID, promptID, arguments)
+ prompt, err := m.com.Workspace.GetMCPPrompt(clientID, promptID, arguments)
if err != nil {
// TODO: make this better
return util.ReportError(err)()
@@ -3521,34 +3521,30 @@ func (m *UI) runMCPPrompt(clientID, promptID string, arguments map[string]string
func (m *UI) handleStateChanged() tea.Cmd {
return func() tea.Msg {
- m.com.App.UpdateAgentModel(context.Background())
+ m.com.Workspace.UpdateAgentModel(context.Background())
return mcpStateChangedMsg{
- states: mcp.GetStates(),
+ states: m.com.Workspace.MCPGetStates(),
}
}
}
-func handleMCPPromptsEvent(name string) tea.Cmd {
+func handleMCPPromptsEvent(ws workspace.Workspace, name string) tea.Cmd {
return func() tea.Msg {
- mcp.RefreshPrompts(context.Background(), name)
+ ws.MCPRefreshPrompts(context.Background(), name)
return nil
}
}
-func handleMCPToolsEvent(cfg *config.ConfigStore, name string) tea.Cmd {
+func handleMCPToolsEvent(ws workspace.Workspace, name string) tea.Cmd {
return func() tea.Msg {
- mcp.RefreshTools(
- context.Background(),
- cfg,
- name,
- )
+ ws.RefreshMCPTools(context.Background(), name)
return nil
}
}
-func handleMCPResourcesEvent(name string) tea.Cmd {
+func handleMCPResourcesEvent(ws workspace.Workspace, name string) tea.Cmd {
return func() tea.Msg {
- mcp.RefreshResources(context.Background(), name)
+ ws.MCPRefreshResources(context.Background(), name)
return nil
}
}
@@ -3566,40 +3562,16 @@ func (m *UI) copyChatHighlight() tea.Cmd {
}
func (m *UI) enableDockerMCP() tea.Msg {
- store := m.com.Store()
- // Stage Docker MCP in memory first so startup and persistence can be atomic.
- mcpConfig, err := store.PrepareDockerMCPConfig()
- if err != nil {
- return util.ReportError(err)()
- }
-
ctx := context.Background()
- if err := mcp.InitializeSingle(ctx, config.DockerMCPName, store); err != nil {
- // Roll back runtime and in-memory state when startup fails.
- disableErr := mcp.DisableSingle(store, config.DockerMCPName)
- delete(store.Config().MCP, config.DockerMCPName)
- return util.ReportError(fmt.Errorf("failed to start docker MCP: %w", errors.Join(err, disableErr)))()
- }
-
- if err := store.PersistDockerMCPConfig(mcpConfig); err != nil {
- // Roll back runtime and in-memory state if persistence fails.
- disableErr := mcp.DisableSingle(store, config.DockerMCPName)
- delete(store.Config().MCP, config.DockerMCPName)
- return util.ReportError(fmt.Errorf("docker MCP started but failed to persist configuration: %w", errors.Join(err, disableErr)))()
+ 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 {
- store := m.com.Store()
- // Close the Docker MCP client.
- if err := mcp.DisableSingle(store, config.DockerMCPName); err != nil {
- return util.ReportError(fmt.Errorf("failed to disable docker MCP: %w", err))()
- }
-
- // Remove from config and persist.
- if err := store.DisableDockerMCP(); err != nil {
+ if err := m.com.Workspace.DisableDockerMCP(); err != nil {
return util.ReportError(err)()
}
@@ -1,15 +1,13 @@
package model
import (
- "reflect"
"testing"
- "unsafe"
"charm.land/catwalk/pkg/catwalk"
- "github.com/charmbracelet/crush/internal/app"
"github.com/charmbracelet/crush/internal/config"
"github.com/charmbracelet/crush/internal/csync"
"github.com/charmbracelet/crush/internal/ui/common"
+ "github.com/charmbracelet/crush/internal/workspace"
"github.com/stretchr/testify/require"
)
@@ -79,29 +77,19 @@ func TestCurrentModelSupportsImages(t *testing.T) {
func newTestUIWithConfig(t *testing.T, cfg *config.Config) *UI {
t.Helper()
- store := &config.ConfigStore{}
- setUnexportedField(t, store, "config", cfg)
-
- appInstance := &app.App{}
- setUnexportedField(t, appInstance, "config", store)
-
return &UI{
com: &common.Common{
- App: appInstance,
+ Workspace: &testWorkspace{cfg: cfg},
},
}
}
-func setUnexportedField(t *testing.T, target any, name string, value any) {
- t.Helper()
-
- v := reflect.ValueOf(target)
- require.Equal(t, reflect.Pointer, v.Kind())
- require.False(t, v.IsNil())
-
- field := v.Elem().FieldByName(name)
- require.Truef(t, field.IsValid(), "field %q not found", name)
+// testWorkspace is a minimal [workspace.Workspace] stub for unit tests.
+type testWorkspace struct {
+ workspace.Workspace
+ cfg *config.Config
+}
- fieldValue := reflect.NewAt(field.Type(), unsafe.Pointer(field.UnsafeAddr())).Elem()
- fieldValue.Set(reflect.ValueOf(value))
+func (w *testWorkspace) Config() *config.Config {
+ return w.cfg
}
@@ -4,7 +4,6 @@ package util
import (
"context"
"errors"
- "log/slog"
"os/exec"
"time"
@@ -23,7 +22,6 @@ func CmdHandler(msg tea.Msg) tea.Cmd {
}
func ReportError(err error) tea.Cmd {
- slog.Error("Error reported", "error", err)
return CmdHandler(NewErrorMsg(err))
}
@@ -2,9 +2,12 @@ package version
import "runtime/debug"
-// Build-time parameters set via -ldflags
+// Build-time parameters set via -ldflags.
-var Version = "devel"
+var (
+ Version = "devel"
+ Commit = "unknown"
+)
// A user may install crush using `go install github.com/charmbracelet/crush@latest`.
// without -ldflags, in which case the version above is unset. As a workaround
@@ -0,0 +1,389 @@
+package workspace
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "time"
+
+ tea "charm.land/bubbletea/v2"
+ "github.com/charmbracelet/crush/internal/agent"
+ mcptools "github.com/charmbracelet/crush/internal/agent/tools/mcp"
+ "github.com/charmbracelet/crush/internal/app"
+ "github.com/charmbracelet/crush/internal/commands"
+ "github.com/charmbracelet/crush/internal/config"
+ "github.com/charmbracelet/crush/internal/history"
+ "github.com/charmbracelet/crush/internal/lsp"
+ "github.com/charmbracelet/crush/internal/message"
+ "github.com/charmbracelet/crush/internal/oauth"
+ "github.com/charmbracelet/crush/internal/permission"
+ "github.com/charmbracelet/crush/internal/session"
+)
+
+// AppWorkspace implements the Workspace interface by delegating
+// directly to an in-process [app.App] instance. This is the default
+// mode when the client/server architecture is not enabled.
+type AppWorkspace struct {
+ app *app.App
+ store *config.ConfigStore
+}
+
+// NewAppWorkspace creates a new AppWorkspace wrapping the given app
+// and config store.
+func NewAppWorkspace(a *app.App, store *config.ConfigStore) *AppWorkspace {
+ return &AppWorkspace{
+ app: a,
+ store: store,
+ }
+}
+
+// -- Sessions --
+
+func (w *AppWorkspace) CreateSession(ctx context.Context, title string) (session.Session, error) {
+ return w.app.Sessions.Create(ctx, title)
+}
+
+func (w *AppWorkspace) GetSession(ctx context.Context, sessionID string) (session.Session, error) {
+ return w.app.Sessions.Get(ctx, sessionID)
+}
+
+func (w *AppWorkspace) ListSessions(ctx context.Context) ([]session.Session, error) {
+ return w.app.Sessions.List(ctx)
+}
+
+func (w *AppWorkspace) SaveSession(ctx context.Context, sess session.Session) (session.Session, error) {
+ return w.app.Sessions.Save(ctx, sess)
+}
+
+func (w *AppWorkspace) DeleteSession(ctx context.Context, sessionID string) error {
+ return w.app.Sessions.Delete(ctx, sessionID)
+}
+
+func (w *AppWorkspace) CreateAgentToolSessionID(messageID, toolCallID string) string {
+ return w.app.Sessions.CreateAgentToolSessionID(messageID, toolCallID)
+}
+
+func (w *AppWorkspace) ParseAgentToolSessionID(sessionID string) (string, string, bool) {
+ return w.app.Sessions.ParseAgentToolSessionID(sessionID)
+}
+
+// -- Messages --
+
+func (w *AppWorkspace) ListMessages(ctx context.Context, sessionID string) ([]message.Message, error) {
+ return w.app.Messages.List(ctx, sessionID)
+}
+
+func (w *AppWorkspace) ListUserMessages(ctx context.Context, sessionID string) ([]message.Message, error) {
+ return w.app.Messages.ListUserMessages(ctx, sessionID)
+}
+
+func (w *AppWorkspace) ListAllUserMessages(ctx context.Context) ([]message.Message, error) {
+ return w.app.Messages.ListAllUserMessages(ctx)
+}
+
+// -- Agent --
+
+func (w *AppWorkspace) AgentRun(ctx context.Context, sessionID, prompt string, attachments ...message.Attachment) error {
+ if w.app.AgentCoordinator == nil {
+ return errors.New("agent coordinator not initialized")
+ }
+ _, err := w.app.AgentCoordinator.Run(ctx, sessionID, prompt, attachments...)
+ return err
+}
+
+func (w *AppWorkspace) AgentCancel(sessionID string) {
+ if w.app.AgentCoordinator != nil {
+ w.app.AgentCoordinator.Cancel(sessionID)
+ }
+}
+
+func (w *AppWorkspace) AgentIsBusy() bool {
+ if w.app.AgentCoordinator == nil {
+ return false
+ }
+ return w.app.AgentCoordinator.IsBusy()
+}
+
+func (w *AppWorkspace) AgentIsSessionBusy(sessionID string) bool {
+ if w.app.AgentCoordinator == nil {
+ return false
+ }
+ return w.app.AgentCoordinator.IsSessionBusy(sessionID)
+}
+
+func (w *AppWorkspace) AgentModel() AgentModel {
+ if w.app.AgentCoordinator == nil {
+ return AgentModel{}
+ }
+ m := w.app.AgentCoordinator.Model()
+ return AgentModel{
+ CatwalkCfg: m.CatwalkCfg,
+ ModelCfg: m.ModelCfg,
+ }
+}
+
+func (w *AppWorkspace) AgentIsReady() bool {
+ return w.app.AgentCoordinator != nil
+}
+
+func (w *AppWorkspace) AgentQueuedPrompts(sessionID string) int {
+ if w.app.AgentCoordinator == nil {
+ return 0
+ }
+ return w.app.AgentCoordinator.QueuedPrompts(sessionID)
+}
+
+func (w *AppWorkspace) AgentQueuedPromptsList(sessionID string) []string {
+ if w.app.AgentCoordinator == nil {
+ return nil
+ }
+ return w.app.AgentCoordinator.QueuedPromptsList(sessionID)
+}
+
+func (w *AppWorkspace) AgentClearQueue(sessionID string) {
+ if w.app.AgentCoordinator != nil {
+ w.app.AgentCoordinator.ClearQueue(sessionID)
+ }
+}
+
+func (w *AppWorkspace) AgentSummarize(ctx context.Context, sessionID string) error {
+ if w.app.AgentCoordinator == nil {
+ return errors.New("agent coordinator not initialized")
+ }
+ return w.app.AgentCoordinator.Summarize(ctx, sessionID)
+}
+
+func (w *AppWorkspace) UpdateAgentModel(ctx context.Context) error {
+ return w.app.UpdateAgentModel(ctx)
+}
+
+func (w *AppWorkspace) InitCoderAgent(ctx context.Context) error {
+ return w.app.InitCoderAgent(ctx)
+}
+
+func (w *AppWorkspace) GetDefaultSmallModel(providerID string) config.SelectedModel {
+ return w.app.GetDefaultSmallModel(providerID)
+}
+
+// -- Permissions --
+
+func (w *AppWorkspace) PermissionGrant(perm permission.PermissionRequest) {
+ w.app.Permissions.Grant(perm)
+}
+
+func (w *AppWorkspace) PermissionGrantPersistent(perm permission.PermissionRequest) {
+ w.app.Permissions.GrantPersistent(perm)
+}
+
+func (w *AppWorkspace) PermissionDeny(perm permission.PermissionRequest) {
+ w.app.Permissions.Deny(perm)
+}
+
+func (w *AppWorkspace) PermissionSkipRequests() bool {
+ return w.app.Permissions.SkipRequests()
+}
+
+func (w *AppWorkspace) PermissionSetSkipRequests(skip bool) {
+ w.app.Permissions.SetSkipRequests(skip)
+}
+
+// -- FileTracker --
+
+func (w *AppWorkspace) FileTrackerRecordRead(ctx context.Context, sessionID, path string) {
+ w.app.FileTracker.RecordRead(ctx, sessionID, path)
+}
+
+func (w *AppWorkspace) FileTrackerLastReadTime(ctx context.Context, sessionID, path string) time.Time {
+ return w.app.FileTracker.LastReadTime(ctx, sessionID, path)
+}
+
+func (w *AppWorkspace) FileTrackerListReadFiles(ctx context.Context, sessionID string) ([]string, error) {
+ return w.app.FileTracker.ListReadFiles(ctx, sessionID)
+}
+
+// -- History --
+
+func (w *AppWorkspace) ListSessionHistory(ctx context.Context, sessionID string) ([]history.File, error) {
+ return w.app.History.ListBySession(ctx, sessionID)
+}
+
+// -- LSP --
+
+func (w *AppWorkspace) LSPStart(ctx context.Context, path string) {
+ w.app.LSPManager.Start(ctx, path)
+}
+
+func (w *AppWorkspace) LSPStopAll(ctx context.Context) {
+ w.app.LSPManager.StopAll(ctx)
+}
+
+func (w *AppWorkspace) LSPGetStates() map[string]LSPClientInfo {
+ states := app.GetLSPStates()
+ result := make(map[string]LSPClientInfo, len(states))
+ for k, v := range states {
+ result[k] = LSPClientInfo{
+ Name: v.Name,
+ State: v.State,
+ Error: v.Error,
+ DiagnosticCount: v.DiagnosticCount,
+ ConnectedAt: v.ConnectedAt,
+ }
+ }
+ return result
+}
+
+func (w *AppWorkspace) LSPGetDiagnosticCounts(name string) lsp.DiagnosticCounts {
+ state, ok := app.GetLSPState(name)
+ if !ok || state.Client == nil {
+ return lsp.DiagnosticCounts{}
+ }
+ return state.Client.GetDiagnosticCounts()
+}
+
+// -- Config (read-only) --
+
+func (w *AppWorkspace) Config() *config.Config {
+ return w.store.Config()
+}
+
+func (w *AppWorkspace) WorkingDir() string {
+ return w.store.WorkingDir()
+}
+
+func (w *AppWorkspace) Resolver() config.VariableResolver {
+ return w.store.Resolver()
+}
+
+// -- Config mutations --
+
+func (w *AppWorkspace) UpdatePreferredModel(scope config.Scope, modelType config.SelectedModelType, model config.SelectedModel) error {
+ return w.store.UpdatePreferredModel(scope, modelType, model)
+}
+
+func (w *AppWorkspace) SetCompactMode(scope config.Scope, enabled bool) error {
+ return w.store.SetCompactMode(scope, enabled)
+}
+
+func (w *AppWorkspace) SetProviderAPIKey(scope config.Scope, providerID string, apiKey any) error {
+ return w.store.SetProviderAPIKey(scope, providerID, apiKey)
+}
+
+func (w *AppWorkspace) SetConfigField(scope config.Scope, key string, value any) error {
+ return w.store.SetConfigField(scope, key, value)
+}
+
+func (w *AppWorkspace) RemoveConfigField(scope config.Scope, key string) error {
+ return w.store.RemoveConfigField(scope, key)
+}
+
+func (w *AppWorkspace) ImportCopilot() (*oauth.Token, bool) {
+ return w.store.ImportCopilot()
+}
+
+func (w *AppWorkspace) RefreshOAuthToken(ctx context.Context, scope config.Scope, providerID string) error {
+ return w.store.RefreshOAuthToken(ctx, scope, providerID)
+}
+
+// -- Project lifecycle --
+
+func (w *AppWorkspace) ProjectNeedsInitialization() (bool, error) {
+ return config.ProjectNeedsInitialization(w.store)
+}
+
+func (w *AppWorkspace) MarkProjectInitialized() error {
+ return config.MarkProjectInitialized(w.store)
+}
+
+func (w *AppWorkspace) InitializePrompt() (string, error) {
+ return agent.InitializePrompt(w.store)
+}
+
+// -- MCP operations --
+
+func (w *AppWorkspace) MCPGetStates() map[string]mcptools.ClientInfo {
+ return mcptools.GetStates()
+}
+
+func (w *AppWorkspace) MCPRefreshPrompts(ctx context.Context, name string) {
+ mcptools.RefreshPrompts(ctx, name)
+}
+
+func (w *AppWorkspace) MCPRefreshResources(ctx context.Context, name string) {
+ mcptools.RefreshResources(ctx, name)
+}
+
+func (w *AppWorkspace) RefreshMCPTools(ctx context.Context, name string) {
+ mcptools.RefreshTools(ctx, w.store, name)
+}
+
+func (w *AppWorkspace) ReadMCPResource(ctx context.Context, name, uri string) ([]MCPResourceContents, error) {
+ contents, err := mcptools.ReadResource(ctx, w.store, name, uri)
+ if err != nil {
+ return nil, err
+ }
+ result := make([]MCPResourceContents, len(contents))
+ for i, c := range contents {
+ result[i] = MCPResourceContents{
+ URI: c.URI,
+ MIMEType: c.MIMEType,
+ Text: c.Text,
+ Blob: c.Blob,
+ }
+ }
+ return result, nil
+}
+
+func (w *AppWorkspace) GetMCPPrompt(clientID, promptID string, args map[string]string) (string, error) {
+ return commands.GetMCPPrompt(w.store, clientID, promptID, args)
+}
+
+func (w *AppWorkspace) EnableDockerMCP(ctx context.Context) error {
+ mcpConfig, err := w.store.PrepareDockerMCPConfig()
+ if err != nil {
+ return err
+ }
+
+ if err := mcptools.InitializeSingle(ctx, config.DockerMCPName, w.store); err != nil {
+ disableErr := mcptools.DisableSingle(w.store, config.DockerMCPName)
+ delete(w.store.Config().MCP, config.DockerMCPName)
+ return fmt.Errorf("failed to start docker MCP: %w", errors.Join(err, disableErr))
+ }
+
+ if err := w.store.PersistDockerMCPConfig(mcpConfig); err != nil {
+ disableErr := mcptools.DisableSingle(w.store, config.DockerMCPName)
+ delete(w.store.Config().MCP, config.DockerMCPName)
+ return fmt.Errorf("docker MCP started but failed to persist configuration: %w", errors.Join(err, disableErr))
+ }
+
+ return nil
+}
+
+func (w *AppWorkspace) DisableDockerMCP() error {
+ if err := mcptools.DisableSingle(w.store, config.DockerMCPName); err != nil {
+ return fmt.Errorf("failed to disable docker MCP: %w", err)
+ }
+ return w.store.DisableDockerMCP()
+}
+
+// -- Lifecycle --
+
+func (w *AppWorkspace) Subscribe(program *tea.Program) {
+ w.app.Subscribe(program)
+}
+
+func (w *AppWorkspace) Shutdown() {
+ w.app.Shutdown()
+}
+
+// App returns the underlying app.App instance.
+func (w *AppWorkspace) App() *app.App {
+ return w.app
+}
+
+// Store returns the underlying config store.
+func (w *AppWorkspace) Store() *config.ConfigStore {
+ return w.store
+}
+
+// Compile-time check that AppWorkspace implements Workspace.
+var _ Workspace = (*AppWorkspace)(nil)
@@ -0,0 +1,773 @@
+package workspace
+
+import (
+ "context"
+ "fmt"
+ "log/slog"
+ "strings"
+ "sync"
+ "time"
+
+ tea "charm.land/bubbletea/v2"
+ "github.com/charmbracelet/crush/internal/agent/notify"
+ "github.com/charmbracelet/crush/internal/agent/tools/mcp"
+ "github.com/charmbracelet/crush/internal/client"
+ "github.com/charmbracelet/crush/internal/config"
+ "github.com/charmbracelet/crush/internal/history"
+ "github.com/charmbracelet/crush/internal/log"
+ "github.com/charmbracelet/crush/internal/lsp"
+ "github.com/charmbracelet/crush/internal/message"
+ "github.com/charmbracelet/crush/internal/oauth"
+ "github.com/charmbracelet/crush/internal/permission"
+ "github.com/charmbracelet/crush/internal/proto"
+ "github.com/charmbracelet/crush/internal/pubsub"
+ "github.com/charmbracelet/crush/internal/session"
+ "github.com/charmbracelet/x/powernap/pkg/lsp/protocol"
+)
+
+// ClientWorkspace implements the Workspace interface by delegating all
+// operations to a remote server via the client SDK. It caches the
+// proto.Workspace returned at creation time and refreshes it after
+// config-mutating operations.
+type ClientWorkspace struct {
+ client *client.Client
+
+ mu sync.RWMutex
+ ws proto.Workspace
+}
+
+// NewClientWorkspace creates a new ClientWorkspace that proxies all
+// operations through the given client SDK. The ws parameter is the
+// proto.Workspace snapshot returned by the server at creation time.
+func NewClientWorkspace(c *client.Client, ws proto.Workspace) *ClientWorkspace {
+ if ws.Config != nil {
+ ws.Config.SetupAgents()
+ }
+ return &ClientWorkspace{
+ client: c,
+ ws: ws,
+ }
+}
+
+// refreshWorkspace re-fetches the workspace from the server, updating
+// the cached snapshot. Called after config-mutating operations.
+func (w *ClientWorkspace) refreshWorkspace() {
+ updated, err := w.client.GetWorkspace(context.Background(), w.ws.ID)
+ if err != nil {
+ slog.Error("Failed to refresh workspace", "error", err)
+ return
+ }
+ if updated.Config != nil {
+ updated.Config.SetupAgents()
+ }
+ w.mu.Lock()
+ w.ws = *updated
+ w.mu.Unlock()
+}
+
+// cached returns a snapshot of the cached workspace.
+func (w *ClientWorkspace) cached() proto.Workspace {
+ w.mu.RLock()
+ defer w.mu.RUnlock()
+ return w.ws
+}
+
+// workspaceID returns the cached workspace ID.
+func (w *ClientWorkspace) workspaceID() string {
+ return w.cached().ID
+}
+
+// -- Sessions --
+
+func (w *ClientWorkspace) CreateSession(ctx context.Context, title string) (session.Session, error) {
+ sess, err := w.client.CreateSession(ctx, w.workspaceID(), title)
+ if err != nil {
+ return session.Session{}, err
+ }
+ return protoToSession(*sess), nil
+}
+
+func (w *ClientWorkspace) GetSession(ctx context.Context, sessionID string) (session.Session, error) {
+ sess, err := w.client.GetSession(ctx, w.workspaceID(), sessionID)
+ if err != nil {
+ return session.Session{}, err
+ }
+ return protoToSession(*sess), nil
+}
+
+func (w *ClientWorkspace) ListSessions(ctx context.Context) ([]session.Session, error) {
+ protoSessions, err := w.client.ListSessions(ctx, w.workspaceID())
+ if err != nil {
+ return nil, err
+ }
+ sessions := make([]session.Session, len(protoSessions))
+ for i, s := range protoSessions {
+ sessions[i] = protoToSession(s)
+ }
+ return sessions, nil
+}
+
+func (w *ClientWorkspace) SaveSession(ctx context.Context, sess session.Session) (session.Session, error) {
+ saved, err := w.client.SaveSession(ctx, w.workspaceID(), sessionToProto(sess))
+ if err != nil {
+ return session.Session{}, err
+ }
+ return protoToSession(*saved), nil
+}
+
+func (w *ClientWorkspace) DeleteSession(ctx context.Context, sessionID string) error {
+ return w.client.DeleteSession(ctx, w.workspaceID(), sessionID)
+}
+
+func (w *ClientWorkspace) CreateAgentToolSessionID(messageID, toolCallID string) string {
+ return fmt.Sprintf("%s$$%s", messageID, toolCallID)
+}
+
+func (w *ClientWorkspace) ParseAgentToolSessionID(sessionID string) (string, string, bool) {
+ parts := strings.Split(sessionID, "$$")
+ if len(parts) != 2 {
+ return "", "", false
+ }
+ return parts[0], parts[1], true
+}
+
+// -- Messages --
+
+func (w *ClientWorkspace) ListMessages(ctx context.Context, sessionID string) ([]message.Message, error) {
+ msgs, err := w.client.ListMessages(ctx, w.workspaceID(), sessionID)
+ if err != nil {
+ return nil, err
+ }
+ return protoToMessages(msgs), nil
+}
+
+func (w *ClientWorkspace) ListUserMessages(ctx context.Context, sessionID string) ([]message.Message, error) {
+ msgs, err := w.client.ListUserMessages(ctx, w.workspaceID(), sessionID)
+ if err != nil {
+ return nil, err
+ }
+ return protoToMessages(msgs), nil
+}
+
+func (w *ClientWorkspace) ListAllUserMessages(ctx context.Context) ([]message.Message, error) {
+ msgs, err := w.client.ListAllUserMessages(ctx, w.workspaceID())
+ if err != nil {
+ return nil, err
+ }
+ return protoToMessages(msgs), nil
+}
+
+// -- Agent --
+
+func (w *ClientWorkspace) AgentRun(ctx context.Context, sessionID, prompt string, attachments ...message.Attachment) error {
+ return w.client.SendMessage(ctx, w.workspaceID(), sessionID, prompt, attachments...)
+}
+
+func (w *ClientWorkspace) AgentCancel(sessionID string) {
+ _ = w.client.CancelAgentSession(context.Background(), w.workspaceID(), sessionID)
+}
+
+func (w *ClientWorkspace) AgentIsBusy() bool {
+ info, err := w.client.GetAgentInfo(context.Background(), w.workspaceID())
+ if err != nil {
+ return false
+ }
+ return info.IsBusy
+}
+
+func (w *ClientWorkspace) AgentIsSessionBusy(sessionID string) bool {
+ info, err := w.client.GetAgentSessionInfo(context.Background(), w.workspaceID(), sessionID)
+ if err != nil {
+ return false
+ }
+ return info.IsBusy
+}
+
+func (w *ClientWorkspace) AgentModel() AgentModel {
+ info, err := w.client.GetAgentInfo(context.Background(), w.workspaceID())
+ if err != nil {
+ return AgentModel{}
+ }
+ return AgentModel{
+ CatwalkCfg: info.Model,
+ ModelCfg: info.ModelCfg,
+ }
+}
+
+func (w *ClientWorkspace) AgentIsReady() bool {
+ info, err := w.client.GetAgentInfo(context.Background(), w.workspaceID())
+ if err != nil {
+ return false
+ }
+ return info.IsReady
+}
+
+func (w *ClientWorkspace) AgentQueuedPrompts(sessionID string) int {
+ count, err := w.client.GetAgentSessionQueuedPrompts(context.Background(), w.workspaceID(), sessionID)
+ if err != nil {
+ return 0
+ }
+ return count
+}
+
+func (w *ClientWorkspace) AgentQueuedPromptsList(sessionID string) []string {
+ prompts, err := w.client.GetAgentSessionQueuedPromptsList(context.Background(), w.workspaceID(), sessionID)
+ if err != nil {
+ return nil
+ }
+ return prompts
+}
+
+func (w *ClientWorkspace) AgentClearQueue(sessionID string) {
+ _ = w.client.ClearAgentSessionQueuedPrompts(context.Background(), w.workspaceID(), sessionID)
+}
+
+func (w *ClientWorkspace) AgentSummarize(ctx context.Context, sessionID string) error {
+ return w.client.AgentSummarizeSession(ctx, w.workspaceID(), sessionID)
+}
+
+func (w *ClientWorkspace) UpdateAgentModel(ctx context.Context) error {
+ return w.client.UpdateAgent(ctx, w.workspaceID())
+}
+
+func (w *ClientWorkspace) InitCoderAgent(ctx context.Context) error {
+ return w.client.InitiateAgentProcessing(ctx, w.workspaceID())
+}
+
+func (w *ClientWorkspace) GetDefaultSmallModel(providerID string) config.SelectedModel {
+ model, err := w.client.GetDefaultSmallModel(context.Background(), w.workspaceID(), providerID)
+ if err != nil {
+ return config.SelectedModel{}
+ }
+ return *model
+}
+
+// -- Permissions --
+
+func (w *ClientWorkspace) PermissionGrant(perm permission.PermissionRequest) {
+ _ = w.client.GrantPermission(context.Background(), w.workspaceID(), proto.PermissionGrant{
+ Permission: proto.PermissionRequest{
+ ID: perm.ID,
+ SessionID: perm.SessionID,
+ ToolCallID: perm.ToolCallID,
+ ToolName: perm.ToolName,
+ Description: perm.Description,
+ Action: perm.Action,
+ Path: perm.Path,
+ Params: perm.Params,
+ },
+ Action: proto.PermissionAllowForSession,
+ })
+}
+
+func (w *ClientWorkspace) PermissionGrantPersistent(perm permission.PermissionRequest) {
+ _ = w.client.GrantPermission(context.Background(), w.workspaceID(), proto.PermissionGrant{
+ Permission: proto.PermissionRequest{
+ ID: perm.ID,
+ SessionID: perm.SessionID,
+ ToolCallID: perm.ToolCallID,
+ ToolName: perm.ToolName,
+ Description: perm.Description,
+ Action: perm.Action,
+ Path: perm.Path,
+ Params: perm.Params,
+ },
+ Action: proto.PermissionAllow,
+ })
+}
+
+func (w *ClientWorkspace) PermissionDeny(perm permission.PermissionRequest) {
+ _ = w.client.GrantPermission(context.Background(), w.workspaceID(), proto.PermissionGrant{
+ Permission: proto.PermissionRequest{
+ ID: perm.ID,
+ SessionID: perm.SessionID,
+ ToolCallID: perm.ToolCallID,
+ ToolName: perm.ToolName,
+ Description: perm.Description,
+ Action: perm.Action,
+ Path: perm.Path,
+ Params: perm.Params,
+ },
+ Action: proto.PermissionDeny,
+ })
+}
+
+func (w *ClientWorkspace) PermissionSkipRequests() bool {
+ skip, err := w.client.GetPermissionsSkipRequests(context.Background(), w.workspaceID())
+ if err != nil {
+ return false
+ }
+ return skip
+}
+
+func (w *ClientWorkspace) PermissionSetSkipRequests(skip bool) {
+ _ = w.client.SetPermissionsSkipRequests(context.Background(), w.workspaceID(), skip)
+}
+
+// -- FileTracker --
+
+func (w *ClientWorkspace) FileTrackerRecordRead(ctx context.Context, sessionID, path string) {
+ _ = w.client.FileTrackerRecordRead(ctx, w.workspaceID(), sessionID, path)
+}
+
+func (w *ClientWorkspace) FileTrackerLastReadTime(ctx context.Context, sessionID, path string) time.Time {
+ t, err := w.client.FileTrackerLastReadTime(ctx, w.workspaceID(), sessionID, path)
+ if err != nil {
+ return time.Time{}
+ }
+ return t
+}
+
+func (w *ClientWorkspace) FileTrackerListReadFiles(ctx context.Context, sessionID string) ([]string, error) {
+ return w.client.FileTrackerListReadFiles(ctx, w.workspaceID(), sessionID)
+}
+
+// -- History --
+
+func (w *ClientWorkspace) ListSessionHistory(ctx context.Context, sessionID string) ([]history.File, error) {
+ files, err := w.client.ListSessionHistoryFiles(ctx, w.workspaceID(), sessionID)
+ if err != nil {
+ return nil, err
+ }
+ return protoToFiles(files), nil
+}
+
+// -- LSP --
+
+func (w *ClientWorkspace) LSPStart(ctx context.Context, path string) {
+ _ = w.client.LSPStart(ctx, w.workspaceID(), path)
+}
+
+func (w *ClientWorkspace) LSPStopAll(ctx context.Context) {
+ _ = w.client.LSPStopAll(ctx, w.workspaceID())
+}
+
+func (w *ClientWorkspace) LSPGetStates() map[string]LSPClientInfo {
+ states, err := w.client.GetLSPs(context.Background(), w.workspaceID())
+ if err != nil {
+ return nil
+ }
+ result := make(map[string]LSPClientInfo, len(states))
+ for k, v := range states {
+ result[k] = LSPClientInfo{
+ Name: v.Name,
+ State: v.State,
+ Error: v.Error,
+ DiagnosticCount: v.DiagnosticCount,
+ ConnectedAt: v.ConnectedAt,
+ }
+ }
+ return result
+}
+
+func (w *ClientWorkspace) LSPGetDiagnosticCounts(name string) lsp.DiagnosticCounts {
+ diags, err := w.client.GetLSPDiagnostics(context.Background(), w.workspaceID(), name)
+ if err != nil {
+ return lsp.DiagnosticCounts{}
+ }
+ var counts lsp.DiagnosticCounts
+ for _, fileDiags := range diags {
+ for _, d := range fileDiags {
+ switch d.Severity {
+ case protocol.SeverityError:
+ counts.Error++
+ case protocol.SeverityWarning:
+ counts.Warning++
+ case protocol.SeverityInformation:
+ counts.Information++
+ case protocol.SeverityHint:
+ counts.Hint++
+ }
+ }
+ }
+ return counts
+}
+
+// -- Config (read-only) --
+
+func (w *ClientWorkspace) Config() *config.Config {
+ return w.cached().Config
+}
+
+func (w *ClientWorkspace) WorkingDir() string {
+ return w.cached().Path
+}
+
+func (w *ClientWorkspace) Resolver() config.VariableResolver {
+ return config.IdentityResolver()
+}
+
+// -- Config mutations --
+
+func (w *ClientWorkspace) UpdatePreferredModel(scope config.Scope, modelType config.SelectedModelType, model config.SelectedModel) error {
+ err := w.client.UpdatePreferredModel(context.Background(), w.workspaceID(), scope, modelType, model)
+ if err == nil {
+ w.refreshWorkspace()
+ }
+ return err
+}
+
+func (w *ClientWorkspace) SetCompactMode(scope config.Scope, enabled bool) error {
+ err := w.client.SetCompactMode(context.Background(), w.workspaceID(), scope, enabled)
+ if err == nil {
+ w.refreshWorkspace()
+ }
+ return err
+}
+
+func (w *ClientWorkspace) SetProviderAPIKey(scope config.Scope, providerID string, apiKey any) error {
+ err := w.client.SetProviderAPIKey(context.Background(), w.workspaceID(), scope, providerID, apiKey)
+ if err == nil {
+ w.refreshWorkspace()
+ }
+ return err
+}
+
+func (w *ClientWorkspace) SetConfigField(scope config.Scope, key string, value any) error {
+ err := w.client.SetConfigField(context.Background(), w.workspaceID(), scope, key, value)
+ if err == nil {
+ w.refreshWorkspace()
+ }
+ return err
+}
+
+func (w *ClientWorkspace) RemoveConfigField(scope config.Scope, key string) error {
+ err := w.client.RemoveConfigField(context.Background(), w.workspaceID(), scope, key)
+ if err == nil {
+ w.refreshWorkspace()
+ }
+ return err
+}
+
+func (w *ClientWorkspace) ImportCopilot() (*oauth.Token, bool) {
+ token, ok, err := w.client.ImportCopilot(context.Background(), w.workspaceID())
+ if err != nil {
+ return nil, false
+ }
+ if ok {
+ w.refreshWorkspace()
+ }
+ return token, ok
+}
+
+func (w *ClientWorkspace) RefreshOAuthToken(ctx context.Context, scope config.Scope, providerID string) error {
+ err := w.client.RefreshOAuthToken(ctx, w.workspaceID(), scope, providerID)
+ if err == nil {
+ w.refreshWorkspace()
+ }
+ return err
+}
+
+// -- Project lifecycle --
+
+func (w *ClientWorkspace) ProjectNeedsInitialization() (bool, error) {
+ return w.client.ProjectNeedsInitialization(context.Background(), w.workspaceID())
+}
+
+func (w *ClientWorkspace) MarkProjectInitialized() error {
+ return w.client.MarkProjectInitialized(context.Background(), w.workspaceID())
+}
+
+func (w *ClientWorkspace) InitializePrompt() (string, error) {
+ return w.client.GetInitializePrompt(context.Background(), w.workspaceID())
+}
+
+// -- MCP operations --
+
+func (w *ClientWorkspace) MCPGetStates() map[string]mcp.ClientInfo {
+ states, err := w.client.MCPGetStates(context.Background(), w.workspaceID())
+ if err != nil {
+ return nil
+ }
+ result := make(map[string]mcp.ClientInfo, len(states))
+ for k, v := range states {
+ result[k] = mcp.ClientInfo{
+ Name: v.Name,
+ State: mcp.State(v.State),
+ Error: v.Error,
+ Counts: mcp.Counts{
+ Tools: v.ToolCount,
+ Prompts: v.PromptCount,
+ Resources: v.ResourceCount,
+ },
+ ConnectedAt: v.ConnectedAt,
+ }
+ }
+ return result
+}
+
+func (w *ClientWorkspace) MCPRefreshPrompts(ctx context.Context, name string) {
+ _ = w.client.MCPRefreshPrompts(ctx, w.workspaceID(), name)
+}
+
+func (w *ClientWorkspace) MCPRefreshResources(ctx context.Context, name string) {
+ _ = w.client.MCPRefreshResources(ctx, w.workspaceID(), name)
+}
+
+func (w *ClientWorkspace) RefreshMCPTools(ctx context.Context, name string) {
+ _ = w.client.RefreshMCPTools(ctx, w.workspaceID(), name)
+}
+
+func (w *ClientWorkspace) ReadMCPResource(ctx context.Context, name, uri string) ([]MCPResourceContents, error) {
+ contents, err := w.client.ReadMCPResource(ctx, w.workspaceID(), name, uri)
+ if err != nil {
+ return nil, err
+ }
+ result := make([]MCPResourceContents, len(contents))
+ for i, c := range contents {
+ result[i] = MCPResourceContents{
+ URI: c.URI,
+ MIMEType: c.MIMEType,
+ Text: c.Text,
+ Blob: c.Blob,
+ }
+ }
+ return result, nil
+}
+
+func (w *ClientWorkspace) GetMCPPrompt(clientID, promptID string, args map[string]string) (string, error) {
+ 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) {
+ defer log.RecoverPanic("ClientWorkspace.Subscribe", func() {
+ slog.Info("TUI subscription panic: attempting graceful shutdown")
+ program.Quit()
+ })
+
+ evc, err := w.client.SubscribeEvents(context.Background(), w.workspaceID())
+ if err != nil {
+ slog.Error("Failed to subscribe to events", "error", err)
+ return
+ }
+
+ for ev := range evc {
+ translated := translateEvent(ev)
+ if translated != nil {
+ program.Send(translated)
+ }
+ }
+}
+
+func (w *ClientWorkspace) Shutdown() {
+ _ = w.client.DeleteWorkspace(context.Background(), w.workspaceID())
+}
+
+// translateEvent converts proto-typed SSE events into the domain types
+// that the TUI's Update() method expects.
+func translateEvent(ev any) tea.Msg {
+ switch e := ev.(type) {
+ case pubsub.Event[proto.LSPEvent]:
+ return pubsub.Event[LSPEvent]{
+ Type: e.Type,
+ Payload: LSPEvent{
+ Type: LSPEventType(e.Payload.Type),
+ Name: e.Payload.Name,
+ State: e.Payload.State,
+ Error: e.Payload.Error,
+ DiagnosticCount: e.Payload.DiagnosticCount,
+ },
+ }
+ case pubsub.Event[proto.MCPEvent]:
+ return pubsub.Event[mcp.Event]{
+ Type: e.Type,
+ Payload: mcp.Event{
+ Type: protoToMCPEventType(e.Payload.Type),
+ Name: e.Payload.Name,
+ State: mcp.State(e.Payload.State),
+ Error: e.Payload.Error,
+ Counts: mcp.Counts{
+ Tools: e.Payload.ToolCount,
+ Prompts: e.Payload.PromptCount,
+ Resources: e.Payload.ResourceCount,
+ },
+ },
+ }
+ case pubsub.Event[proto.PermissionRequest]:
+ return pubsub.Event[permission.PermissionRequest]{
+ Type: e.Type,
+ Payload: permission.PermissionRequest{
+ ID: e.Payload.ID,
+ SessionID: e.Payload.SessionID,
+ ToolCallID: e.Payload.ToolCallID,
+ ToolName: e.Payload.ToolName,
+ Description: e.Payload.Description,
+ Action: e.Payload.Action,
+ Path: e.Payload.Path,
+ Params: e.Payload.Params,
+ },
+ }
+ case pubsub.Event[proto.PermissionNotification]:
+ return pubsub.Event[permission.PermissionNotification]{
+ Type: e.Type,
+ Payload: permission.PermissionNotification{
+ ToolCallID: e.Payload.ToolCallID,
+ Granted: e.Payload.Granted,
+ Denied: e.Payload.Denied,
+ },
+ }
+ case pubsub.Event[proto.Message]:
+ return pubsub.Event[message.Message]{
+ Type: e.Type,
+ Payload: protoToMessage(e.Payload),
+ }
+ case pubsub.Event[proto.Session]:
+ return pubsub.Event[session.Session]{
+ Type: e.Type,
+ Payload: protoToSession(e.Payload),
+ }
+ case pubsub.Event[proto.File]:
+ return pubsub.Event[history.File]{
+ Type: e.Type,
+ Payload: protoToFile(e.Payload),
+ }
+ case pubsub.Event[proto.AgentEvent]:
+ return pubsub.Event[notify.Notification]{
+ Type: e.Type,
+ Payload: notify.Notification{
+ SessionID: e.Payload.SessionID,
+ SessionTitle: e.Payload.SessionTitle,
+ Type: notify.Type(e.Payload.Type),
+ },
+ }
+ default:
+ slog.Warn("Unknown event type in translateEvent", "type", fmt.Sprintf("%T", ev))
+ return nil
+ }
+}
+
+func protoToMCPEventType(t proto.MCPEventType) mcp.EventType {
+ switch t {
+ case proto.MCPEventStateChanged:
+ return mcp.EventStateChanged
+ case proto.MCPEventToolsListChanged:
+ return mcp.EventToolsListChanged
+ case proto.MCPEventPromptsListChanged:
+ return mcp.EventPromptsListChanged
+ case proto.MCPEventResourcesListChanged:
+ return mcp.EventResourcesListChanged
+ default:
+ return mcp.EventStateChanged
+ }
+}
+
+func protoToSession(s proto.Session) session.Session {
+ return session.Session{
+ ID: s.ID,
+ ParentSessionID: s.ParentSessionID,
+ Title: s.Title,
+ SummaryMessageID: s.SummaryMessageID,
+ MessageCount: s.MessageCount,
+ PromptTokens: s.PromptTokens,
+ CompletionTokens: s.CompletionTokens,
+ Cost: s.Cost,
+ CreatedAt: s.CreatedAt,
+ UpdatedAt: s.UpdatedAt,
+ }
+}
+
+func protoToFile(f proto.File) history.File {
+ return history.File{
+ ID: f.ID,
+ SessionID: f.SessionID,
+ Path: f.Path,
+ Content: f.Content,
+ Version: f.Version,
+ CreatedAt: f.CreatedAt,
+ UpdatedAt: f.UpdatedAt,
+ }
+}
+
+func protoToMessage(m proto.Message) message.Message {
+ msg := message.Message{
+ ID: m.ID,
+ SessionID: m.SessionID,
+ Role: message.MessageRole(m.Role),
+ Model: m.Model,
+ Provider: m.Provider,
+ CreatedAt: m.CreatedAt,
+ UpdatedAt: m.UpdatedAt,
+ }
+
+ for _, p := range m.Parts {
+ switch v := p.(type) {
+ case proto.TextContent:
+ msg.Parts = append(msg.Parts, message.TextContent{Text: v.Text})
+ case proto.ReasoningContent:
+ msg.Parts = append(msg.Parts, message.ReasoningContent{
+ Thinking: v.Thinking,
+ Signature: v.Signature,
+ StartedAt: v.StartedAt,
+ FinishedAt: v.FinishedAt,
+ })
+ case proto.ToolCall:
+ msg.Parts = append(msg.Parts, message.ToolCall{
+ ID: v.ID,
+ Name: v.Name,
+ Input: v.Input,
+ Finished: v.Finished,
+ })
+ case proto.ToolResult:
+ msg.Parts = append(msg.Parts, message.ToolResult{
+ ToolCallID: v.ToolCallID,
+ Name: v.Name,
+ Content: v.Content,
+ IsError: v.IsError,
+ })
+ case proto.Finish:
+ msg.Parts = append(msg.Parts, message.Finish{
+ Reason: message.FinishReason(v.Reason),
+ Time: v.Time,
+ Message: v.Message,
+ Details: v.Details,
+ })
+ case proto.ImageURLContent:
+ msg.Parts = append(msg.Parts, message.ImageURLContent{URL: v.URL, Detail: v.Detail})
+ case proto.BinaryContent:
+ msg.Parts = append(msg.Parts, message.BinaryContent{Path: v.Path, MIMEType: v.MIMEType, Data: v.Data})
+ }
+ }
+
+ return msg
+}
+
+func protoToMessages(msgs []proto.Message) []message.Message {
+ out := make([]message.Message, len(msgs))
+ for i, m := range msgs {
+ out[i] = protoToMessage(m)
+ }
+ return out
+}
+
+func protoToFiles(files []proto.File) []history.File {
+ out := make([]history.File, len(files))
+ for i, f := range files {
+ out[i] = protoToFile(f)
+ }
+ return out
+}
+
+func sessionToProto(s session.Session) proto.Session {
+ return proto.Session{
+ ID: s.ID,
+ ParentSessionID: s.ParentSessionID,
+ Title: s.Title,
+ SummaryMessageID: s.SummaryMessageID,
+ MessageCount: s.MessageCount,
+ PromptTokens: s.PromptTokens,
+ CompletionTokens: s.CompletionTokens,
+ Cost: s.Cost,
+ CreatedAt: s.CreatedAt,
+ UpdatedAt: s.UpdatedAt,
+ }
+}
@@ -0,0 +1,152 @@
+// Package workspace defines the Workspace interface used by all
+// frontends (TUI, CLI) to interact with a running workspace. Two
+// implementations exist: one wrapping a local app.App instance and one
+// wrapping the HTTP client SDK.
+package workspace
+
+import (
+ "context"
+ "time"
+
+ tea "charm.land/bubbletea/v2"
+ "charm.land/catwalk/pkg/catwalk"
+ mcptools "github.com/charmbracelet/crush/internal/agent/tools/mcp"
+ "github.com/charmbracelet/crush/internal/config"
+ "github.com/charmbracelet/crush/internal/history"
+ "github.com/charmbracelet/crush/internal/lsp"
+ "github.com/charmbracelet/crush/internal/message"
+ "github.com/charmbracelet/crush/internal/oauth"
+ "github.com/charmbracelet/crush/internal/permission"
+ "github.com/charmbracelet/crush/internal/session"
+)
+
+// LSPClientInfo holds information about an LSP client's state. This is
+// the frontend-facing type; implementations translate from the
+// underlying app or proto representation.
+type LSPClientInfo struct {
+ Name string
+ State lsp.ServerState
+ Error error
+ DiagnosticCount int
+ ConnectedAt time.Time
+}
+
+// LSPEventType represents the type of LSP event.
+type LSPEventType string
+
+const (
+ LSPEventStateChanged LSPEventType = "state_changed"
+ LSPEventDiagnosticsChanged LSPEventType = "diagnostics_changed"
+)
+
+// LSPEvent represents an LSP event forwarded to the TUI.
+type LSPEvent struct {
+ Type LSPEventType
+ Name string
+ State lsp.ServerState
+ Error error
+ DiagnosticCount int
+}
+
+// AgentModel holds the model information exposed to the UI.
+type AgentModel struct {
+ CatwalkCfg catwalk.Model
+ ModelCfg config.SelectedModel
+}
+
+// Workspace is the main abstraction consumed by the TUI and CLI. It
+// groups every operation a frontend needs to perform against a running
+// workspace, regardless of whether the workspace is in-process or
+// remote.
+type Workspace interface {
+ // Sessions
+ CreateSession(ctx context.Context, title string) (session.Session, error)
+ GetSession(ctx context.Context, sessionID string) (session.Session, error)
+ ListSessions(ctx context.Context) ([]session.Session, error)
+ SaveSession(ctx context.Context, sess session.Session) (session.Session, error)
+ DeleteSession(ctx context.Context, sessionID string) error
+ CreateAgentToolSessionID(messageID, toolCallID string) string
+ ParseAgentToolSessionID(sessionID string) (messageID string, toolCallID string, ok bool)
+
+ // Messages
+ ListMessages(ctx context.Context, sessionID string) ([]message.Message, error)
+ ListUserMessages(ctx context.Context, sessionID string) ([]message.Message, error)
+ ListAllUserMessages(ctx context.Context) ([]message.Message, error)
+
+ // Agent
+ AgentRun(ctx context.Context, sessionID, prompt string, attachments ...message.Attachment) error
+ AgentCancel(sessionID string)
+ AgentIsBusy() bool
+ AgentIsSessionBusy(sessionID string) bool
+ AgentModel() AgentModel
+ AgentIsReady() bool
+ AgentQueuedPrompts(sessionID string) int
+ AgentQueuedPromptsList(sessionID string) []string
+ AgentClearQueue(sessionID string)
+ AgentSummarize(ctx context.Context, sessionID string) error
+ UpdateAgentModel(ctx context.Context) error
+ InitCoderAgent(ctx context.Context) error
+ GetDefaultSmallModel(providerID string) config.SelectedModel
+
+ // Permissions
+ PermissionGrant(perm permission.PermissionRequest)
+ PermissionGrantPersistent(perm permission.PermissionRequest)
+ PermissionDeny(perm permission.PermissionRequest)
+ PermissionSkipRequests() bool
+ PermissionSetSkipRequests(skip bool)
+
+ // FileTracker
+ FileTrackerRecordRead(ctx context.Context, sessionID, path string)
+ FileTrackerLastReadTime(ctx context.Context, sessionID, path string) time.Time
+ FileTrackerListReadFiles(ctx context.Context, sessionID string) ([]string, error)
+
+ // History
+ ListSessionHistory(ctx context.Context, sessionID string) ([]history.File, error)
+
+ // LSP
+ LSPStart(ctx context.Context, path string)
+ LSPStopAll(ctx context.Context)
+ LSPGetStates() map[string]LSPClientInfo
+ LSPGetDiagnosticCounts(name string) lsp.DiagnosticCounts
+
+ // Config (read-only data)
+ Config() *config.Config
+ WorkingDir() string
+ Resolver() config.VariableResolver
+
+ // Config mutations (proxied to server in client mode)
+ UpdatePreferredModel(scope config.Scope, modelType config.SelectedModelType, model config.SelectedModel) error
+ SetCompactMode(scope config.Scope, enabled bool) error
+ SetProviderAPIKey(scope config.Scope, providerID string, apiKey any) error
+ SetConfigField(scope config.Scope, key string, value any) error
+ RemoveConfigField(scope config.Scope, key string) error
+ ImportCopilot() (*oauth.Token, bool)
+ RefreshOAuthToken(ctx context.Context, scope config.Scope, providerID string) error
+
+ // Project lifecycle
+ ProjectNeedsInitialization() (bool, error)
+ MarkProjectInitialized() error
+ InitializePrompt() (string, error)
+
+ // MCP operations (server-side in client mode)
+ MCPGetStates() map[string]mcptools.ClientInfo
+ MCPRefreshPrompts(ctx context.Context, name string)
+ MCPRefreshResources(ctx context.Context, name string)
+ 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)
+ Shutdown()
+}
+
+// MCPResourceContents holds the contents of an MCP resource.
+type MCPResourceContents struct {
+ URI string `json:"uri"`
+ MIMEType string `json:"mime_type,omitempty"`
+ Text string `json:"text,omitempty"`
+ Blob []byte `json:"blob,omitempty"`
+}
@@ -1,3 +1,13 @@
+// Package main is the entry point for the Crush CLI.
+//
+// @title Crush API
+// @version 1.0
+// @description Crush is a terminal-based AI coding assistant. This API is served over a Unix socket (or Windows named pipe) and provides programmatic access to workspaces, sessions, agents, LSP, MCP, and more.
+// @contact.name Charm
+// @contact.url https://charm.sh
+// @license.name MIT
+// @license.url https://github.com/charmbracelet/crush/blob/main/LICENSE
+// @BasePath /v1
package main
import (