diff --git a/Taskfile.yaml b/Taskfile.yaml index 4f80d893be1b47668477c7639868a43b5591239c..9d61e9216a3e78774ff658fc8c46e97b4e4baeeb 100644 --- a/Taskfile.yaml +++ b/Taskfile.yaml @@ -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: diff --git a/go.mod b/go.mod index 602541be3c2fc34ce83cde007db89a6409377455..ea8bc5a0176985e0b4e6be52f15638a32fdbce54 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 36b5228cb0737362e3bce40fd210e861d8df7d4e..868eca135dc6eaa1294c065c00bdc09725709ec3 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/app/app.go b/internal/app/app.go index a3828891978c1b83429036799ab588d20f672852..890b1ce7765728f78adba8c47a4bbbe87c29131c 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -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 diff --git a/internal/backend/agent.go b/internal/backend/agent.go new file mode 100644 index 0000000000000000000000000000000000000000..8cc2ada26ca737bbe79fe989cc52e4ede9712dc5 --- /dev/null +++ b/internal/backend/agent.go @@ -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 +} diff --git a/internal/backend/backend.go b/internal/backend/backend.go new file mode 100644 index 0000000000000000000000000000000000000000..f14e5b7229939f5b9af11047cec7bb68a5e59cab --- /dev/null +++ b/internal/backend/backend.go @@ -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, + } +} diff --git a/internal/backend/config.go b/internal/backend/config.go new file mode 100644 index 0000000000000000000000000000000000000000..c7e01ff3bd08d3e96edcf875d6198d168fbeb1a5 --- /dev/null +++ b/internal/backend/config.go @@ -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 +} diff --git a/internal/backend/events.go b/internal/backend/events.go new file mode 100644 index 0000000000000000000000000000000000000000..a91bad1d5322d1c0ed909b3239e9e97c0eb0c366 --- /dev/null +++ b/internal/backend/events.go @@ -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) +} diff --git a/internal/backend/filetracker.go b/internal/backend/filetracker.go new file mode 100644 index 0000000000000000000000000000000000000000..14ae99bc7fad5cac541e530cb94162d2218ccaac --- /dev/null +++ b/internal/backend/filetracker.go @@ -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) +} diff --git a/internal/backend/permission.go b/internal/backend/permission.go new file mode 100644 index 0000000000000000000000000000000000000000..bb7876d6989ec8bee6a99362cb5f5ef914fc5c49 --- /dev/null +++ b/internal/backend/permission.go @@ -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 +} diff --git a/internal/backend/session.go b/internal/backend/session.go new file mode 100644 index 0000000000000000000000000000000000000000..10e21ed8932ccbc990a525785166517cd231595c --- /dev/null +++ b/internal/backend/session.go @@ -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) +} diff --git a/internal/backend/util.go b/internal/backend/util.go new file mode 100644 index 0000000000000000000000000000000000000000..9b29d0f704ac7473963b5a3765ca60bb7e649bf5 --- /dev/null +++ b/internal/backend/util.go @@ -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 +} diff --git a/internal/client/client.go b/internal/client/client.go new file mode 100644 index 0000000000000000000000000000000000000000..e97a0570e42e7176debf3e6ca4d91760483a197d --- /dev/null +++ b/internal/client/client.go @@ -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 +} diff --git a/internal/client/config.go b/internal/client/config.go new file mode 100644 index 0000000000000000000000000000000000000000..64c45ecd91cfabbe6727695071e7a62e5fe435ba --- /dev/null +++ b/internal/client/config.go @@ -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 +} diff --git a/internal/client/dial_other.go b/internal/client/dial_other.go new file mode 100644 index 0000000000000000000000000000000000000000..f2ba8569ba3326f2df82dc34bbf842eac30918d9 --- /dev/null +++ b/internal/client/dial_other.go @@ -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 +} diff --git a/internal/client/dial_windows.go b/internal/client/dial_windows.go new file mode 100644 index 0000000000000000000000000000000000000000..750ce98b152c7583ce8e7889ef35c12098f6da8f --- /dev/null +++ b/internal/client/dial_windows.go @@ -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) +} diff --git a/internal/client/proto.go b/internal/client/proto.go new file mode 100644 index 0000000000000000000000000000000000000000..f444cfc04f1e185a4551d6ac43eae4d99f3a02ba --- /dev/null +++ b/internal/client/proto.go @@ -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 +} diff --git a/internal/cmd/login.go b/internal/cmd/login.go index c9acb12df19875f48b242bee96e377bf5548aacb..9ce9a3e28deb168f7a78b38417e8d93d02ae69ce 100644 --- a/internal/cmd/login.go +++ b/internal/cmd/login.go @@ -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 } diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 5890961af8e5a3727df1b4624414fd63782f4dee..2d474a5f99b58dc6ffe018f04d7aa69b9fe2d65e 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -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 != "" { diff --git a/internal/cmd/root_other.go b/internal/cmd/root_other.go new file mode 100644 index 0000000000000000000000000000000000000000..6d178a07a6e55c85c7fdd4d6a4d98d923aad5a71 --- /dev/null +++ b/internal/cmd/root_other.go @@ -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 +} diff --git a/internal/cmd/root_windows.go b/internal/cmd/root_windows.go new file mode 100644 index 0000000000000000000000000000000000000000..134b6de258cd798ccfc2dbb7803099f11e31b052 --- /dev/null +++ b/internal/cmd/root_windows.go @@ -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 +} diff --git a/internal/cmd/run.go b/internal/cmd/run.go index a648a6d60fbb07e870d7c66de4b49323e6bedb69..054965f5b72441c0fd56e7876963292e813bb135 100644 --- a/internal/cmd/run.go +++ b/internal/cmd/run.go @@ -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)) + } +} diff --git a/internal/cmd/server.go b/internal/cmd/server.go new file mode 100644 index 0000000000000000000000000000000000000000..460d5280e18930c2008db1199aac18a5b281a83d --- /dev/null +++ b/internal/cmd/server.go @@ -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 + }, +} diff --git a/internal/cmd/server_other.go b/internal/cmd/server_other.go new file mode 100644 index 0000000000000000000000000000000000000000..58b05629bf5b85a579ded6379dec53f555fb68e7 --- /dev/null +++ b/internal/cmd/server_other.go @@ -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) +} diff --git a/internal/cmd/server_windows.go b/internal/cmd/server_windows.go new file mode 100644 index 0000000000000000000000000000000000000000..eff60b8b635128e08a6e27f4273520607f05a4c3 --- /dev/null +++ b/internal/cmd/server_windows.go @@ -0,0 +1,10 @@ +//go:build windows +// +build windows + +package cmd + +import "os" + +func addSignals(sigs []os.Signal) []os.Signal { + return sigs +} diff --git a/internal/config/config.go b/internal/config/config.go index 8381e2bf8ae7b240d3f823c2e037c527fcb2542d..7aece9cdc2b38085f40306028050c96b8f17101c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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 diff --git a/internal/config/load.go b/internal/config/load.go index 1040a20b5fb84f7aa265a49f370091f89532c41b..8f1ea635d36881da660dbd8df0db4c73a77fc50f 100644 --- a/internal/config/load.go +++ b/internal/config/load.go @@ -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 diff --git a/internal/config/resolve.go b/internal/config/resolve.go index 3ef3522b09e504d3c57105e8bbe393b0f7c38b2b..b9e7753386bb8c95b877b99172897ea4fdb0a045 100644 --- a/internal/config/resolve.go +++ b/internal/config/resolve.go @@ -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) } diff --git a/internal/config/scope.go b/internal/config/scope.go index 971ce32c3ed662dd0d0627c4f1c858372f3b4514..89f7cb181f2750995450a3384132252ccf9603cf 100644 --- a/internal/config/scope.go +++ b/internal/config/scope.go @@ -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") diff --git a/internal/config/store.go b/internal/config/store.go index f95a6fc7f124c71492969b212b2e806071020e10..7df0d4f27f5c0c4d15c45e0f92ce4128fc829c38 100644 --- a/internal/config/store.go +++ b/internal/config/store.go @@ -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) diff --git a/internal/config/store_test.go b/internal/config/store_test.go new file mode 100644 index 0000000000000000000000000000000000000000..91107943b4be67023ebcd9b2473fae53d5148b73 --- /dev/null +++ b/internal/config/store_test.go @@ -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)") +} diff --git a/internal/log/log.go b/internal/log/log.go index 9463c3bd97956da3ab895b8600f79d1c05790844..54a0620f18debcf442ed3dd2f8c1739d6aec3696 100644 --- a/internal/log/log.go +++ b/internal/log/log.go @@ -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) }) } diff --git a/internal/proto/agent.go b/internal/proto/agent.go new file mode 100644 index 0000000000000000000000000000000000000000..2deb906afb24c8da0f774276229dc1bcc100a813 --- /dev/null +++ b/internal/proto/agent.go @@ -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 +} diff --git a/internal/proto/history.go b/internal/proto/history.go new file mode 100644 index 0000000000000000000000000000000000000000..caf60a7127c817d7d893b8418307226509d2e10a --- /dev/null +++ b/internal/proto/history.go @@ -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"` +} diff --git a/internal/proto/mcp.go b/internal/proto/mcp.go new file mode 100644 index 0000000000000000000000000000000000000000..cc9dcbe78bd89c7176fc67b9332798b844a4ddac --- /dev/null +++ b/internal/proto/mcp.go @@ -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 +} diff --git a/internal/proto/message.go b/internal/proto/message.go new file mode 100644 index 0000000000000000000000000000000000000000..f24cf80584a802cad34a91cf64895faf81e3a32d --- /dev/null +++ b/internal/proto/message.go @@ -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 +} diff --git a/internal/proto/permission.go b/internal/proto/permission.go new file mode 100644 index 0000000000000000000000000000000000000000..5834de628e41a290d0bc391fbe3ead2505eb742a --- /dev/null +++ b/internal/proto/permission.go @@ -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 + } +} diff --git a/internal/proto/proto.go b/internal/proto/proto.go new file mode 100644 index 0000000000000000000000000000000000000000..9c84c6c8bf0c2f14da75933f8eebd7a36ca534ba --- /dev/null +++ b/internal/proto/proto.go @@ -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 +} diff --git a/internal/proto/requests.go b/internal/proto/requests.go new file mode 100644 index 0000000000000000000000000000000000000000..fe5327079b4f6514c23b34ce5c8e00666c75ba43 --- /dev/null +++ b/internal/proto/requests.go @@ -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"` +} diff --git a/internal/proto/server.go b/internal/proto/server.go new file mode 100644 index 0000000000000000000000000000000000000000..612772381a58aad04a5a3d1bc1216f6dd8882769 --- /dev/null +++ b/internal/proto/server.go @@ -0,0 +1,6 @@ +package proto + +// ServerControl represents a server control request. +type ServerControl struct { + Command string `json:"command"` +} diff --git a/internal/proto/session.go b/internal/proto/session.go new file mode 100644 index 0000000000000000000000000000000000000000..846ac592017e6ce447c6c6a94535d9317adad7d8 --- /dev/null +++ b/internal/proto/session.go @@ -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"` +} diff --git a/internal/proto/tools.go b/internal/proto/tools.go new file mode 100644 index 0000000000000000000000000000000000000000..09774ac0a22b672ff7df81d968db21ef35517c02 --- /dev/null +++ b/internal/proto/tools.go @@ -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"` +} diff --git a/internal/proto/version.go b/internal/proto/version.go new file mode 100644 index 0000000000000000000000000000000000000000..b728a8b966068a7810f86aae74cfcc6f57e03d39 --- /dev/null +++ b/internal/proto/version.go @@ -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"` +} diff --git a/internal/pubsub/events.go b/internal/pubsub/events.go index 827158d52fd671aeda828c0383fce98850e27fc7..44963e3cfbdefc2ddc4657c293615df5329d885d 100644 --- a/internal/pubsub/events.go +++ b/internal/pubsub/events.go @@ -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) } diff --git a/internal/server/config.go b/internal/server/config.go new file mode 100644 index 0000000000000000000000000000000000000000..b5277d08e32935a59ee0748809b0868f3dc3b5a9 --- /dev/null +++ b/internal/server/config.go @@ -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) +} diff --git a/internal/server/events.go b/internal/server/events.go new file mode 100644 index 0000000000000000000000000000000000000000..752311666bb6fcc2b1efde4d037711eaafaa0162 --- /dev/null +++ b/internal/server/events.go @@ -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 +} diff --git a/internal/server/logging.go b/internal/server/logging.go new file mode 100644 index 0000000000000000000000000000000000000000..736e3d57cfb6697a07cc61a03c4157a42140df54 --- /dev/null +++ b/internal/server/logging.go @@ -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 +} diff --git a/internal/server/net_other.go b/internal/server/net_other.go new file mode 100644 index 0000000000000000000000000000000000000000..1ef7066c13abc1ab94494641d29449823ea8dd15 --- /dev/null +++ b/internal/server/net_other.go @@ -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) +} diff --git a/internal/server/net_windows.go b/internal/server/net_windows.go new file mode 100644 index 0000000000000000000000000000000000000000..3692021aa7e39de8df711395c50b194d8e358047 --- /dev/null +++ b/internal/server/net_windows.go @@ -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 + } +} diff --git a/internal/server/proto.go b/internal/server/proto.go new file mode 100644 index 0000000000000000000000000000000000000000..af34131810a7af8c3672fe460198d25afe9ba064 --- /dev/null +++ b/internal/server/proto.go @@ -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}) +} diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 0000000000000000000000000000000000000000..9ac4dba4c908050a0381b49258941d1b3a931970 --- /dev/null +++ b/internal/server/server.go @@ -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...) + } +} diff --git a/internal/swagger/docs.go b/internal/swagger/docs.go new file mode 100644 index 0000000000000000000000000000000000000000..e36cae3f015d0cfc474bffd9f697e6170cc5a73a --- /dev/null +++ b/internal/swagger/docs.go @@ -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) +} diff --git a/internal/swagger/swagger.json b/internal/swagger/swagger.json new file mode 100644 index 0000000000000000000000000000000000000000..3785d226e8d08af1be38786f83cea1f400c3eb68 --- /dev/null +++ b/internal/swagger/swagger.json @@ -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" + ] + } + } +} \ No newline at end of file diff --git a/internal/swagger/swagger.yaml b/internal/swagger/swagger.yaml new file mode 100644 index 0000000000000000000000000000000000000000..f0de0979b842425fa5b5287377aa1d1f18b9a4e5 --- /dev/null +++ b/internal/swagger/swagger.yaml @@ -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" diff --git a/internal/ui/common/common.go b/internal/ui/common/common.go index 143b20305464da33d2f350a36176bab0e45b85aa..8e00f0d0d2a74396df36e4b4d97762a9087087be 100644 --- a/internal/ui/common/common.go +++ b/internal/ui/common/common.go @@ -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, } } diff --git a/internal/ui/dialog/api_key_input.go b/internal/ui/dialog/api_key_input.go index 928cbd411a99aff600991d22d4cf869acefa49ec..f6ba67d2652c84477685c6666c9954fd331a7786 100644 --- a/internal/ui/dialog/api_key_input.go +++ b/internal/ui/dialog/api_key_input.go @@ -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))} } diff --git a/internal/ui/dialog/filepicker.go b/internal/ui/dialog/filepicker.go index 78f82a05f7e2e0db7a9bb561fb1b6248d8045513..82fca6f47ddc9338057f7cdf027cce03d1be65ef 100644 --- a/internal/ui/dialog/filepicker.go +++ b/internal/ui/dialog/filepicker.go @@ -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 } diff --git a/internal/ui/dialog/models.go b/internal/ui/dialog/models.go index 51a1766cb7a94db942c7957cd82494cc49a46c94..bf2d0e793d692f4470f5ddc0f2190a778f5e11fa 100644 --- a/internal/ui/dialog/models.go +++ b/internal/ui/dialog/models.go @@ -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) } } diff --git a/internal/ui/dialog/oauth.go b/internal/ui/dialog/oauth.go index dc073f41e21dde1d15e41ef59aec98b8161e00ba..af54b6befdeb6f3b3465986e6959c6c7618c60c6 100644 --- a/internal/ui/dialog/oauth.go +++ b/internal/ui/dialog/oauth.go @@ -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))} } diff --git a/internal/ui/dialog/sessions.go b/internal/ui/dialog/sessions.go index 6f9b7724a796818c789e19ba9455c23e7e51c9b4..8f3ce81960e5170c0059d44261c1e79ca2bbfea8 100644 --- a/internal/ui/dialog/sessions.go +++ b/internal/ui/dialog/sessions.go @@ -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]. diff --git a/internal/ui/model/header.go b/internal/ui/model/header.go index 06bb4ff92981b28625efb11683081e29fc55a21e..f4e4fd49a00ebb0280ec3583ce7fa3ea6513bd40 100644 --- a/internal/ui/model/header.go +++ b/internal/ui/model/header.go @@ -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 diff --git a/internal/ui/model/history.go b/internal/ui/model/history.go index d5eb7c464f410359f7a6dc1af84fe9c92c5aed28..7495bd7808367d2093823d382550c004df1c8398 100644 --- a/internal/ui/model/history.go +++ b/internal/ui/model/history.go @@ -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) diff --git a/internal/ui/model/landing.go b/internal/ui/model/landing.go index 72c2671ccd297f4bade087f6b2cb960f6c6a92a9..e78d03e2afb5bd826e7d5ffd5c4d571575fcf949 100644 --- a/internal/ui/model/landing.go +++ b/internal/ui/model/landing.go @@ -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, diff --git a/internal/ui/model/lsp.go b/internal/ui/model/lsp.go index 1458d3402cbfa1536e5bef31f7d72ac5d58dddfe..a129a53b619c9bbaef92597b27d99e850319634c 100644 --- a/internal/ui/model/lsp.go +++ b/internal/ui/model/lsp.go @@ -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}) } diff --git a/internal/ui/model/onboarding.go b/internal/ui/model/onboarding.go index 5bba37ea8599944df77602014aa8c8d61dd73e80..c905660a0570d1402d85be8aed4e805c11506510 100644 --- a/internal/ui/model/onboarding.go +++ b/internal/ui/model/onboarding.go @@ -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?") diff --git a/internal/ui/model/pills.go b/internal/ui/model/pills.go index 9b3307135aec89105adb895ef07bbe30484ec658..d2f843848956bdd5f4d05674da1d164caf1e04c6 100644 --- a/internal/ui/model/pills.go +++ b/internal/ui/model/pills.go @@ -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) } } diff --git a/internal/ui/model/session.go b/internal/ui/model/session.go index c043255c041c20523a2e14b85285bccc7ee7eeb1..62d6a050c38d06bdd16b595ffe16b436e7224c96 100644 --- a/internal/ui/model/session.go +++ b/internal/ui/model/session.go @@ -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 } diff --git a/internal/ui/model/sidebar.go b/internal/ui/model/sidebar.go index 8849d86a8e1c8bda02092e3f165e85b8e32a8b1d..13e1797f3f2f7155a17d93bbe01ae7bb14e8246f 100644 --- a/internal/ui/model/sidebar.go +++ b/internal/ui/model/sidebar.go @@ -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(). diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index a33cd51760fe03b33a75fb02d56d35eb2934d0f6..edd5035522eb4df6d7dea63ca16da20e1f20489c 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -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)() } diff --git a/internal/ui/model/ui_test.go b/internal/ui/model/ui_test.go index 84b216e5470619e08af404967a347a831b53bcc2..4032c80a059b3b41f1ea2814cd87e8749e7ff100 100644 --- a/internal/ui/model/ui_test.go +++ b/internal/ui/model/ui_test.go @@ -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 } diff --git a/internal/ui/util/util.go b/internal/ui/util/util.go index 7a53df7d1e4e676b3b142de9ec74deff614c8af2..b8cd107753009c9853709f61aeb4f99b19b71d14 100644 --- a/internal/ui/util/util.go +++ b/internal/ui/util/util.go @@ -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)) } diff --git a/internal/version/version.go b/internal/version/version.go index 6faef3251ca071a0a210ac1bc2327ca848a73ad0..3eb4f74139a752c1567986a8b9344913d55f08b1 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -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 diff --git a/internal/workspace/app_workspace.go b/internal/workspace/app_workspace.go new file mode 100644 index 0000000000000000000000000000000000000000..57b1228e7eacb28a16141283ee2703a33511bd18 --- /dev/null +++ b/internal/workspace/app_workspace.go @@ -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) diff --git a/internal/workspace/client_workspace.go b/internal/workspace/client_workspace.go new file mode 100644 index 0000000000000000000000000000000000000000..7c4e1408882cc70859ea2ab05981461d262513e9 --- /dev/null +++ b/internal/workspace/client_workspace.go @@ -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, + } +} diff --git a/internal/workspace/workspace.go b/internal/workspace/workspace.go new file mode 100644 index 0000000000000000000000000000000000000000..02c54c616f3251140bbee441451c3a4cb14845bd --- /dev/null +++ b/internal/workspace/workspace.go @@ -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"` +} diff --git a/main.go b/main.go index e75cb03e3575cf902c2ff4b44ddd15e0405f0b60..83fd1cf96ae8ee34089d5a577711f569ec2faeb6 100644 --- a/main.go +++ b/main.go @@ -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 (