diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index ba2c56a67e35665b955683fcec659e1da0282ede..75b8023df6416ebfe776ce5daac758d1b7c38bef 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -67,7 +67,7 @@ jobs: persist-credentials: false - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 with: - go-version: 1.26.0 + go-version: 1.26.1 - name: Install govulncheck run: go install golang.org/x/vuln/cmd/govulncheck@latest - name: Run govulncheck diff --git a/README.md b/README.md index 49563822772d28762215f90f257e17a9710ac25f..46d5da5413ac3e30aaec094dbfb12d832d8647d5 100644 --- a/README.md +++ b/README.md @@ -424,6 +424,25 @@ git clone https://github.com/anthropics/skills.git _temp mv _temp/skills/* . ; rm -r -force _temp ``` +### Desktop notifications + +Crush sends desktop notifications when a tool call requires permission and when +the agent finishes its turn. They're only sent when the terminal window isn't +focused _and_ your terminal supports reporting the focus state. + +```jsonc +{ + "$schema": "https://charm.land/crush.json", + "options": { + "disable_notifications": false // default + } +} +``` + +To disable desktop notifications, set `disable_notifications` to `true` in your +configuration. On macOS, notifications currently lack icons due to platform +limitations. + ### Initialization When you initialize a project, Crush analyzes your codebase and creates diff --git a/go.mod b/go.mod index 6075460c2ac79460cf586ab6752d15a715445812..ea74d44a4c00ebafd8adaa298909f219d5b32d62 100644 --- a/go.mod +++ b/go.mod @@ -1,15 +1,15 @@ module github.com/charmbracelet/crush -go 1.26.0 +go 1.26.1 require ( charm.land/bubbles/v2 v2.0.0 - charm.land/bubbletea/v2 v2.0.1 - charm.land/catwalk v0.28.1 - charm.land/fantasy v0.11.1 - charm.land/glamour/v2 v2.0.0-20260123212943-6014aa153a9b - charm.land/lipgloss/v2 v2.0.0 - charm.land/log/v2 v2.0.0-20251110204020-529bb77f35da + charm.land/bubbletea/v2 v2.0.2 + charm.land/catwalk v0.28.4 + charm.land/fantasy v0.12.1 + charm.land/glamour/v2 v2.0.0 + charm.land/lipgloss/v2 v2.0.1 + charm.land/log/v2 v2.0.0 charm.land/x/vcr v0.1.1 github.com/JohannesKaufmann/html-to-markdown v1.6.0 github.com/MakeNowJust/heredoc v1.0.0 @@ -17,11 +17,11 @@ require ( github.com/alecthomas/chroma/v2 v2.23.1 github.com/atotto/clipboard v0.1.4 github.com/aymanbagabas/go-nativeclipboard v0.1.3 - github.com/aymanbagabas/go-udiff v0.4.0 + github.com/aymanbagabas/go-udiff v0.4.1 github.com/bmatcuk/doublestar/v4 v4.10.0 github.com/charlievieth/fastwalk v1.0.14 - github.com/charmbracelet/colorprofile v0.4.2 - github.com/charmbracelet/fang v0.4.4 + github.com/charmbracelet/colorprofile v0.4.3 + github.com/charmbracelet/fang v1.0.0 github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 github.com/charmbracelet/x/ansi v0.11.6 github.com/charmbracelet/x/editor v0.2.0 @@ -38,6 +38,7 @@ require ( github.com/denisbrodbeck/machineid v1.0.1 github.com/disintegration/imaging v1.6.2 github.com/dustin/go-humanize v1.0.1 + github.com/gen2brain/beeep v0.11.2 github.com/go-git/go-git/v5 v5.17.0 github.com/google/uuid v1.6.0 github.com/invopop/jsonschema v0.13.0 @@ -46,7 +47,7 @@ require ( github.com/lucasb-eyer/go-colorful v1.3.0 github.com/mattn/go-isatty v0.0.20 github.com/modelcontextprotocol/go-sdk v1.4.0 - github.com/ncruces/go-sqlite3 v0.30.5 + github.com/ncruces/go-sqlite3 v0.31.1 github.com/nxadm/tail v1.4.11 github.com/openai/openai-go/v2 v2.7.1 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c @@ -63,13 +64,13 @@ require ( github.com/zeebo/xxh3 v1.1.0 go.uber.org/goleak v1.3.0 golang.org/x/net v0.51.0 - golang.org/x/sync v0.19.0 + golang.org/x/sync v0.20.0 golang.org/x/text v0.34.0 gopkg.in/natefinch/lumberjack.v2 v2.2.1 gopkg.in/yaml.v3 v3.0.1 modernc.org/sqlite v1.46.1 mvdan.cc/sh/moreinterp v0.0.0-20250902163504-3cf4fd5717a5 - mvdan.cc/sh/v3 v3.12.1-0.20250902163504-3cf4fd5717a5 + mvdan.cc/sh/v3 v3.13.0 ) require ( @@ -77,24 +78,25 @@ require ( cloud.google.com/go/auth v0.18.2 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect cloud.google.com/go/compute/metadata v0.9.0 // indirect + 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/Microsoft/go-winio v0.6.2 // indirect github.com/andybalholm/cascadia v1.3.3 // indirect - github.com/aws/aws-sdk-go-v2 v1.41.2 // indirect + github.com/aws/aws-sdk-go-v2 v1.41.3 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.5 // indirect - github.com/aws/aws-sdk-go-v2/config v1.32.10 // indirect - github.com/aws/aws-sdk-go-v2/credentials v1.19.10 // indirect - github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.18 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.18 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.18 // indirect - github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.5 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.18 // indirect - github.com/aws/aws-sdk-go-v2/service/signin v1.0.6 // indirect - github.com/aws/aws-sdk-go-v2/service/sso v1.30.11 // indirect - github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.15 // indirect - github.com/aws/aws-sdk-go-v2/service/sts v1.41.7 // indirect + github.com/aws/aws-sdk-go-v2/config v1.32.11 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.19.11 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.19 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.19 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.19 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.8.5 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.6 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.19 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.0.7 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.30.12 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.16 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.41.8 // indirect github.com/aws/smithy-go v1.24.2 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/bahlo/generic-list-go v0.2.0 // indirect @@ -107,6 +109,7 @@ require ( github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dlclark/regexp2 v1.11.5 // indirect github.com/ebitengine/purego v0.10.0 // indirect + github.com/esiqveland/notify v0.13.3 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect @@ -115,9 +118,11 @@ require ( github.com/go-logfmt/logfmt v0.6.0 // indirect 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-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 + github.com/godbus/dbus/v5 v5.2.2 // indirect github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/jsonschema-go v0.4.2 // indirect @@ -128,6 +133,7 @@ require ( github.com/gorilla/websocket v1.5.3 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect 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/kaptinlin/go-i18n v0.2.12 // indirect github.com/kaptinlin/jsonpointer v0.4.17 // indirect @@ -148,13 +154,17 @@ require ( github.com/muesli/roff v0.1.0 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect github.com/ncruces/julianday v1.0.0 // indirect + github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect github.com/pierrec/lz4/v4 v4.1.25 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/segmentio/asm v1.2.1 // indirect github.com/segmentio/encoding v0.5.3 // indirect + github.com/sergeymakinen/go-bmp v1.0.0 // indirect + 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/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af // indirect github.com/tetratelabs/wazero v1.11.0 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect @@ -176,12 +186,12 @@ require ( golang.org/x/crypto v0.48.0 // indirect golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect golang.org/x/image v0.36.0 // indirect - golang.org/x/oauth2 v0.35.0 // indirect - golang.org/x/sys v0.41.0 // indirect + golang.org/x/oauth2 v0.36.0 // indirect + golang.org/x/sys v0.42.0 // indirect golang.org/x/term v0.40.0 // indirect golang.org/x/time v0.14.0 // indirect google.golang.org/api v0.269.0 // indirect - google.golang.org/genai v1.48.0 // indirect + google.golang.org/genai v1.49.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect google.golang.org/grpc v1.79.1 // indirect google.golang.org/protobuf v1.36.11 // indirect diff --git a/go.sum b/go.sum index 171527b9b755f5ffdd3592d43c4ebfbe17a1c82f..0f7775949fa7fa47e7887a68c25f2884f3f2dd4d 100644 --- a/go.sum +++ b/go.sum @@ -1,17 +1,17 @@ charm.land/bubbles/v2 v2.0.0 h1:tE3eK/pHjmtrDiRdoC9uGNLgpopOd8fjhEe31B/ai5s= charm.land/bubbles/v2 v2.0.0/go.mod h1:rCHoleP2XhU8um45NTuOWBPNVHxnkXKTiZqcclL/qOI= -charm.land/bubbletea/v2 v2.0.1 h1:B8e9zzK7x9JJ+XvHGF4xnYu9Xa0E0y0MyggY6dbaCfQ= -charm.land/bubbletea/v2 v2.0.1/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ= -charm.land/catwalk v0.28.1 h1:4YJiRRNUb7i8qDEZjFLJPMKvquihGrGsTNTcf5Dfqq0= -charm.land/catwalk v0.28.1/go.mod h1:rFC/V96rIHX7VES215c/qzI1EW/Moo1ggs1Q6seTy5s= -charm.land/fantasy v0.11.1 h1:G1dRqkzEQ0RJN1Ls5mte8HOi0wFKxYd5bfnRAmeYvDk= -charm.land/fantasy v0.11.1/go.mod h1:C8wNxWlw+b2z54zsTor9r1tG2GE2C4QotvAlgXh9KF8= -charm.land/glamour/v2 v2.0.0-20260123212943-6014aa153a9b h1:A6IUUyChZDWP16RUdRJCfmYISAKWQGyIcfhZJUCViQ0= -charm.land/glamour/v2 v2.0.0-20260123212943-6014aa153a9b/go.mod h1:J3kVhY6oHXZq5f+8vC3hmDO95fEvbqj3z7xDwxrfzU8= -charm.land/lipgloss/v2 v2.0.0 h1:sd8N/B3x892oiOjFfBQdXBQp3cAkvjGaU5TvVZC3ivo= -charm.land/lipgloss/v2 v2.0.0/go.mod h1:w6SnmsBFBmEFBodiEDurGS/sdUY/u1+v72DqUzc6J14= -charm.land/log/v2 v2.0.0-20251110204020-529bb77f35da h1:vZa/Ow0uLclpfaDY0ubjzE+B0eLQqi2zanmpeALanow= -charm.land/log/v2 v2.0.0-20251110204020-529bb77f35da/go.mod h1:Tj12StbPc4GwksDF6XwhC9wdXouinIVxRGKKmmmzdSU= +charm.land/bubbletea/v2 v2.0.2 h1:4CRtRnuZOdFDTWSff9r8QFt/9+z6Emubz3aDMnf/dx0= +charm.land/bubbletea/v2 v2.0.2/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ= +charm.land/catwalk v0.28.4 h1:YaaXA1k0v7CKvvT+Gh1pDD7XrlUR93kROdaWqkkglRw= +charm.land/catwalk v0.28.4/go.mod h1:+fqw/6YGNtvapvPy9vhwA/fAMxVjD2K8hVIKYov8Vhg= +charm.land/fantasy v0.12.1 h1:awszoi5O9FIjMEkfyCMiLJfVRNLckp/zQkFrA6IxQqc= +charm.land/fantasy v0.12.1/go.mod h1:QeRVUeG1XNTWBszRAbhUtPyX1VWs6zjkCxwfcwnICdc= +charm.land/glamour/v2 v2.0.0 h1:IDBoqLEy7Hdpb9VOXN+khLP/XSxtJy1VsHuW/yF87+U= +charm.land/glamour/v2 v2.0.0/go.mod h1:kjq9WB0s8vuUYZNYey2jp4Lgd9f4cKdzAw88FZtpj/w= +charm.land/lipgloss/v2 v2.0.1 h1:6Xzrn49+Py1Um5q/wZG1gWgER2+7dUyZ9XMEufqPSys= +charm.land/lipgloss/v2 v2.0.1/go.mod h1:KjPle2Qd3YmvP1KL5OMHiHysGcNwq6u83MUjYkFvEkM= +charm.land/log/v2 v2.0.0 h1:SY3Cey7ipx86/MBXQHwsguOT6X1exT94mmJRdzTNs+s= +charm.land/log/v2 v2.0.0/go.mod h1:c3cZSRqm20qUVVAR1WmS/7ab8bgha3C6G7DjPcaVZz0= charm.land/x/vcr v0.1.1 h1:PXCFMUG0rPtyk35rhfzYCJEduOzWXCIbrXTFq4OF/9Q= charm.land/x/vcr v0.1.1/go.mod h1:eByq2gqzWvcct/8XE2XO5KznoWEBiXH56+y2gphbltM= cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE= @@ -22,6 +22,8 @@ cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIi cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= +git.sr.ht/~jackmordaunt/go-toast v1.1.2 h1:/yrfI55LRt1M7H1vkaw+NaH1+L1CDxrqDltwm5euVuE= +git.sr.ht/~jackmordaunt/go-toast v1.1.2/go.mod h1:jA4OqHKTQ4AFBdwrSnwnskUIIS3HYzlJSgdzCKqfavo= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0 h1:g0EZJwz7xkXQiZAI5xi9f3WWFYBlX1CPTrR+NDToRkQ= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0/go.mod h1:XCW7KnZet0Opnr7HccfUw1PLc4CjHqpcaxW8DHklNkQ= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.7.0 h1:tfLQ34V6F7tVSwoTf/4lH5sE0o6eCJuNDTmH09nDpbc= @@ -50,40 +52,40 @@ github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kk github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= -github.com/aws/aws-sdk-go-v2 v1.41.2 h1:LuT2rzqNQsauaGkPK/7813XxcZ3o3yePY0Iy891T2ls= -github.com/aws/aws-sdk-go-v2 v1.41.2/go.mod h1:IvvlAZQXvTXznUPfRVfryiG1fbzE2NGK6m9u39YQ+S4= +github.com/aws/aws-sdk-go-v2 v1.41.3 h1:4kQ/fa22KjDt13QCy1+bYADvdgcxpfH18f0zP542kZA= +github.com/aws/aws-sdk-go-v2 v1.41.3/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.5 h1:zWFmPmgw4sveAYi1mRqG+E/g0461cJ5M4bJ8/nc6d3Q= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.5/go.mod h1:nVUlMLVV8ycXSb7mSkcNu9e3v/1TJq2RTlrPwhYWr5c= -github.com/aws/aws-sdk-go-v2/config v1.32.10 h1:9DMthfO6XWZYLfzZglAgW5Fyou2nRI5CuV44sTedKBI= -github.com/aws/aws-sdk-go-v2/config v1.32.10/go.mod h1:2rUIOnA2JaiqYmSKYmRJlcMWy6qTj1vuRFscppSBMcw= -github.com/aws/aws-sdk-go-v2/credentials v1.19.10 h1:EEhmEUFCE1Yhl7vDhNOI5OCL/iKMdkkYFTRpZXNw7m8= -github.com/aws/aws-sdk-go-v2/credentials v1.19.10/go.mod h1:RnnlFCAlxQCkN2Q379B67USkBMu1PipEEiibzYN5UTE= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.18 h1:Ii4s+Sq3yDfaMLpjrJsqD6SmG/Wq/P5L/hw2qa78UAY= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.18/go.mod h1:6x81qnY++ovptLE6nWQeWrpXxbnlIex+4H4eYYGcqfc= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.18 h1:F43zk1vemYIqPAwhjTjYIz0irU2EY7sOb/F5eJ3HuyM= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.18/go.mod h1:w1jdlZXrGKaJcNoL+Nnrj+k5wlpGXqnNrKoP22HvAug= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.18 h1:xCeWVjj0ki0l3nruoyP2slHsGArMxeiiaoPN5QZH6YQ= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.18/go.mod h1:r/eLGuGCBw6l36ZRWiw6PaZwPXb6YOj+i/7MizNl5/k= -github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk= -github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.5 h1:CeY9LUdur+Dxoeldqoun6y4WtJ3RQtzk0JMP2gfUay0= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.5/go.mod h1:AZLZf2fMaahW5s/wMRciu1sYbdsikT/UHwbUjOdEVTc= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.18 h1:LTRCYFlnnKFlKsyIQxKhJuDuA3ZkrDQMRYm6rXiHlLY= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.18/go.mod h1:XhwkgGG6bHSd00nO/mexWTcTjgd6PjuvWQMqSn2UaEk= -github.com/aws/aws-sdk-go-v2/service/signin v1.0.6 h1:MzORe+J94I+hYu2a6XmV5yC9huoTv8NRcCrUNedDypQ= -github.com/aws/aws-sdk-go-v2/service/signin v1.0.6/go.mod h1:hXzcHLARD7GeWnifd8j9RWqtfIgxj4/cAtIVIK7hg8g= -github.com/aws/aws-sdk-go-v2/service/sso v1.30.11 h1:7oGD8KPfBOJGXiCoRKrrrQkbvCp8N++u36hrLMPey6o= -github.com/aws/aws-sdk-go-v2/service/sso v1.30.11/go.mod h1:0DO9B5EUJQlIDif+XJRWCljZRKsAFKh3gpFz7UnDtOo= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.15 h1:edCcNp9eGIUDUCrzoCu1jWAXLGFIizeqkdkKgRlJwWc= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.15/go.mod h1:lyRQKED9xWfgkYC/wmmYfv7iVIM68Z5OQ88ZdcV1QbU= -github.com/aws/aws-sdk-go-v2/service/sts v1.41.7 h1:NITQpgo9A5NrDZ57uOWj+abvXSb83BbyggcUBVksN7c= -github.com/aws/aws-sdk-go-v2/service/sts v1.41.7/go.mod h1:sks5UWBhEuWYDPdwlnRFn1w7xWdH29Jcpe+/PJQefEs= +github.com/aws/aws-sdk-go-v2/config v1.32.11 h1:ftxI5sgz8jZkckuUHXfC/wMUc8u3fG1vQS0plr2F2Zs= +github.com/aws/aws-sdk-go-v2/config v1.32.11/go.mod h1:twF11+6ps9aNRKEDimksp923o44w/Thk9+8YIlzWMmo= +github.com/aws/aws-sdk-go-v2/credentials v1.19.11 h1:NdV8cwCcAXrCWyxArt58BrvZJ9pZ9Fhf9w6Uh5W3Uyc= +github.com/aws/aws-sdk-go-v2/credentials v1.19.11/go.mod h1:30yY2zqkMPdrvxBqzI9xQCM+WrlrZKSOpSJEsylVU+8= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.19 h1:INUvJxmhdEbVulJYHI061k4TVuS3jzzthNvjqvVvTKM= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.19/go.mod h1:FpZN2QISLdEBWkayloda+sZjVJL+e9Gl0k1SyTgcswU= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.19 h1:/sECfyq2JTifMI2JPyZ4bdRN77zJmr6SrS1eL3augIA= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.19/go.mod h1:dMf8A5oAqr9/oxOfLkC/c2LU/uMcALP0Rgn2BD5LWn0= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.19 h1:AWeJMk33GTBf6J20XJe6qZoRSJo0WfUhsMdUKhoODXE= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.19/go.mod h1:+GWrYoaAsV7/4pNHpwh1kiNLXkKaSoppxQq9lbH8Ejw= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.5 h1:clHU5fm//kWS1C2HgtgWxfQbFbx4b6rx+5jzhgX9HrI= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.5/go.mod h1:O3h0IK87yXci+kg6flUKzJnWeziQUKciKrLjcatSNcY= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.6 h1:XAq62tBTJP/85lFD5oqOOe7YYgWxY9LvWq8plyDvDVg= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.6/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.19 h1:X1Tow7suZk9UCJHE1Iw9GMZJJl0dAnKXXP1NaSDHwmw= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.19/go.mod h1:/rARO8psX+4sfjUQXp5LLifjUt8DuATZ31WptNJTyQA= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.7 h1:Y2cAXlClHsXkkOvWZFXATr34b0hxxloeQu/pAZz2row= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.7/go.mod h1:idzZ7gmDeqeNrSPkdbtMp9qWMgcBwykA7P7Rzh5DXVU= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.12 h1:iSsvB9EtQ09YrsmIc44Heqlx5ByGErqhPK1ZQLppias= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.12/go.mod h1:fEWYKTRGoZNl8tZ77i61/ccwOMJdGxwOhWCkp6TXAr0= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.16 h1:EnUdUqRP1CNzt2DkV67tJx6XDN4xlfBFm+bzeNOQVb0= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.16/go.mod h1:Jic/xv0Rq/pFNCh3WwpH4BEqdbSAl+IyHro8LbibHD8= +github.com/aws/aws-sdk-go-v2/service/sts v1.41.8 h1:XQTQTF75vnug2TXS8m7CVJfC2nniYPZnO1D4Np761Oo= +github.com/aws/aws-sdk-go-v2/service/sts v1.41.8/go.mod h1:Xgx+PR1NUOjNmQY+tRMnouRp83JRM8pRMw/vCaVhPkI= github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng= github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/aymanbagabas/go-nativeclipboard v0.1.3 h1:FmAWHPTwneAixu7uGDn3cL42xPlUCdNp2J8egMn3P1k= github.com/aymanbagabas/go-nativeclipboard v0.1.3/go.mod h1:2o7MyZwwi4pmXXpOpvOS5FwaHyoCIUks0ktjUvB0EoE= -github.com/aymanbagabas/go-udiff v0.4.0 h1:TKnLPh7IbnizJIBKFWa9mKayRUBQ9Kh1BPCk6w2PnYM= -github.com/aymanbagabas/go-udiff v0.4.0/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= +github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o= +github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= @@ -98,10 +100,10 @@ github.com/charlievieth/fastwalk v1.0.14 h1:3Eh5uaFGwHZd8EGwTjJnSpBkfwfsak9h6ICg github.com/charlievieth/fastwalk v1.0.14/go.mod h1:diVcUreiU1aQ4/Wu3NbxxH4/KYdKpLDojrQ1Bb2KgNY= github.com/charmbracelet/anthropic-sdk-go v0.0.0-20260223140439-63879b0b8dab h1:J7XQLgl9sefgTnTGrmX3xqvp5o6MCiBzEjGv5igAlc4= github.com/charmbracelet/anthropic-sdk-go v0.0.0-20260223140439-63879b0b8dab/go.mod h1:hqlYqR7uPKOKfnNeicUbZp0Ps0GeYFlKYtwh5HGDCx8= -github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY= -github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8= -github.com/charmbracelet/fang v0.4.4 h1:G4qKxF6or/eTPgmAolwPuRNyuci3hTUGGX1rj1YkHJY= -github.com/charmbracelet/fang v0.4.4/go.mod h1:P5/DNb9DddQ0Z0dbc0P3ol4/ix5Po7Ofr2KMBfAqoCo= +github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= +github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= +github.com/charmbracelet/fang v1.0.0 h1:jESBY40agJOlLYnnv9jE0mLqDGTxEk0hkOnx7YGyRlQ= +github.com/charmbracelet/fang v1.0.0/go.mod h1:P5/DNb9DddQ0Z0dbc0P3ol4/ix5Po7Ofr2KMBfAqoCo= github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 h1:eyFRbAmexyt43hVfeyBofiGSEmJ7krjLOYt/9CF5NKA= github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8/go.mod h1:SQpCTRNBtzJkwku5ye4S3HEuthAlGy2n9VXZnWkEW98= github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= @@ -160,11 +162,15 @@ github.com/envoyproxy/go-control-plane/envoy v1.37.0 h1:u3riX6BoYRfF4Dr7dwSOroNf github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQOAOHNYMALuowAnbjjEMkkWOi6A= github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds= github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0= +github.com/esiqveland/notify v0.13.3 h1:QCMw6o1n+6rl+oLUfg8P1IIDSFsDEb2WlXvVvIJbI/o= +github.com/esiqveland/notify v0.13.3/go.mod h1:hesw/IRYTO0x99u1JPweAl4+5mwXJibQVUcP0Iu5ORE= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/gen2brain/beeep v0.11.2 h1:+KfiKQBbQCuhfJFPANZuJ+oxsSKAYNe88hIpJuyKWDA= +github.com/gen2brain/beeep v0.11.2/go.mod h1:jQVvuwnLuwOcdctHn/uyh8horSBNJ8uGb9Cn2W4tvoc= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= github.com/go-git/go-billy/v5 v5.8.0 h1:I8hjc3LbBlXTtVuFNJuwYuMiHvQJDq1AT6u4DwDzZG0= @@ -180,6 +186,8 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 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-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= @@ -188,6 +196,9 @@ github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= +github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g= @@ -222,6 +233,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E= github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= +github.com/jackmordaunt/icns/v3 v3.0.1 h1:xxot6aNuGrU+lNgxz5I5H0qSeCjNKp8uTXB1j8D4S3o= +github.com/jackmordaunt/icns/v3 v3.0.1/go.mod h1:5sHL59nqTd2ynTnowxB/MDQFhKNqkK8X687uKNygaSQ= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= @@ -278,12 +291,14 @@ github.com/muesli/mango-pflag v0.1.0 h1:UADqbYgpUyRoBja3g6LUL+3LErjpsOwaC9ywvBWe github.com/muesli/mango-pflag v0.1.0/go.mod h1:YEQomTxaCUp8PrbhFh10UfbhbQrM/xJ4i2PB8VTLLW0= github.com/muesli/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8= github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig= -github.com/ncruces/go-sqlite3 v0.30.5 h1:6usmTQ6khriL8oWilkAZSJM/AIpAlVL2zFrlcpDldCE= -github.com/ncruces/go-sqlite3 v0.30.5/go.mod h1:0I0JFflTKzfs3Ogfv8erP7CCoV/Z8uxigVDNOR0AQ5E= +github.com/ncruces/go-sqlite3 v0.31.1 h1:F76NF4NTLNOabLUKuEb2xqjBW+/Ub+MR59/Q7dACRo8= +github.com/ncruces/go-sqlite3 v0.31.1/go.mod h1:L9OWFjYG/+4dq9O6bFCYoWLG0a7LmtgR6v26TvABmwg= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/ncruces/julianday v1.0.0 h1:fH0OKwa7NWvniGQtxdJRxAgkBMolni2BjDHaWTxqt7M= 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/nxadm/tail v1.4.11 h1:8feyoE3OzPrcshW5/MJ4sGESc5cqmGkGCWlco4l0bqY= github.com/nxadm/tail v1.4.11/go.mod h1:OTaG3NK980DZzxbRq6lEuzgU+mug70nY11sMd4JXXHc= github.com/openai/openai-go/v2 v2.7.1 h1:/tfvTJhfv7hTSL8mWwc5VL4WLLSDL5yn9VqVykdu9r8= @@ -323,6 +338,10 @@ github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= github.com/segmentio/encoding v0.5.3 h1:OjMgICtcSFuNvQCdwqMCv9Tg7lEOXGwm1J5RPQccx6w= github.com/segmentio/encoding v0.5.3/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= +github.com/sergeymakinen/go-bmp v1.0.0 h1:SdGTzp9WvCV0A1V0mBeaS7kQAwNLdVJbmHlqNWq0R+M= +github.com/sergeymakinen/go-bmp v1.0.0/go.mod h1:/mxlAQZRLxSvJFNIEGGLBE/m40f3ZnUifpgVDlcUIEY= +github.com/sergeymakinen/go-ico v1.0.0-beta.0 h1:m5qKH7uPKLdrygMWxbamVn+tl2HfiA3K6MFJw4GfZvQ= +github.com/sergeymakinen/go-ico v1.0.0-beta.0/go.mod h1:wQ47mTczswBO5F0NoDt7O0IXgnV4Xy3ojrroMQzyhUk= github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= @@ -336,10 +355,17 @@ github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiT github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +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.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/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/tetratelabs/wazero v1.11.0 h1:+gKemEuKCTevU4d7ZTzlsvgd1uaToIDtlQlmNbwqYhA= github.com/tetratelabs/wazero v1.11.0/go.mod h1:eV28rsN8Q+xwjogd7f4/Pp4xFxO7uOGbLcD/LzB1wiU= github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= @@ -429,8 +455,8 @@ golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= -golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= -golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -438,8 +464,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -456,8 +482,8 @@ golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= -golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= @@ -497,8 +523,8 @@ gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= google.golang.org/api v0.269.0 h1:qDrTOxKUQ/P0MveH6a7vZ+DNHxJQjtGm/uvdbdGXCQg= google.golang.org/api v0.269.0/go.mod h1:N8Wpcu23Tlccl0zSHEkcAZQKDLdquxK+l9r2LkwAauE= -google.golang.org/genai v1.48.0 h1:1vb15G291wAjJJueisMDpUhssljhEdJU2t5qTidrVPs= -google.golang.org/genai v1.48.0/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk= +google.golang.org/genai v1.49.0 h1:Se+QJaH2GYK1aaR1o5S38mlU2GD5FnVvP76nfkV7LH0= +google.golang.org/genai v1.49.0/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk= google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ= google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY= @@ -520,6 +546,7 @@ gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRN 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.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= @@ -552,5 +579,5 @@ modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= mvdan.cc/sh/moreinterp v0.0.0-20250902163504-3cf4fd5717a5 h1:mO2lyKtGwu4mGQ+Qqjx0+fd5UU5BXhX/rslFmxd5aco= mvdan.cc/sh/moreinterp v0.0.0-20250902163504-3cf4fd5717a5/go.mod h1:Of9PCedbLDYT8b3EyiYG64rNnx5nOp27OLCVdDrjJyo= -mvdan.cc/sh/v3 v3.12.1-0.20250902163504-3cf4fd5717a5 h1:e7Z/Lgw/zMijvQBVrfh/vUDZ+9FpuSLrJDVGBuoJtuo= -mvdan.cc/sh/v3 v3.12.1-0.20250902163504-3cf4fd5717a5/go.mod h1:P21wo2gLLe3426sP+CmANLBaixSEbRtPl35w3YlM6dg= +mvdan.cc/sh/v3 v3.13.0 h1:dSfq/MVsY4w0Vsi6Lbs0IcQquMVqLdKLESAOZjuHdLg= +mvdan.cc/sh/v3 v3.13.0/go.mod h1:KV1GByGPc/Ho0X1E6Uz9euhsIQEj4hwyKnodLlFLoDM= diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 53305884e9e6f0f10cb0613c2a8e892901d31e5d..7d41339811b6f4ca1d74fc903f5058ec833d5b8d 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -32,14 +32,17 @@ import ( "charm.land/fantasy/providers/vercel" "charm.land/lipgloss/v2" "github.com/charmbracelet/crush/internal/agent/hyper" + "github.com/charmbracelet/crush/internal/agent/notify" "github.com/charmbracelet/crush/internal/agent/tools" "github.com/charmbracelet/crush/internal/agent/tools/mcp" "github.com/charmbracelet/crush/internal/config" "github.com/charmbracelet/crush/internal/csync" "github.com/charmbracelet/crush/internal/message" "github.com/charmbracelet/crush/internal/permission" + "github.com/charmbracelet/crush/internal/pubsub" "github.com/charmbracelet/crush/internal/session" "github.com/charmbracelet/crush/internal/stringext" + "github.com/charmbracelet/crush/internal/version" "github.com/charmbracelet/x/exp/charmtone" ) @@ -72,6 +75,7 @@ type SessionAgentCall struct { TopK *int64 FrequencyPenalty *float64 PresencePenalty *float64 + NonInteractive bool } type SessionAgent interface { @@ -108,6 +112,7 @@ type sessionAgent struct { messages message.Service disableAutoSummarize bool isYolo bool + notify pubsub.Publisher[notify.Notification] messageQueue *csync.Map[string, []SessionAgentCall] activeRequests *csync.Map[string, context.CancelFunc] @@ -124,6 +129,7 @@ type SessionAgentOptions struct { Sessions session.Service Messages message.Service Tools []fantasy.AgentTool + Notify pubsub.Publisher[notify.Notification] } func NewSessionAgent( @@ -140,6 +146,7 @@ func NewSessionAgent( disableAutoSummarize: opts.DisableAutoSummarize, tools: csync.NewSliceFrom(opts.Tools), isYolo: opts.IsYolo, + notify: opts.Notify, messageQueue: csync.NewMap[string, []SessionAgentCall](), activeRequests: csync.NewMap[string, context.CancelFunc](), } @@ -194,6 +201,7 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy largeModel.Model, fantasy.WithSystemPrompt(systemPrompt), fantasy.WithTools(agentTools...), + fantasy.WithUserAgent("Charm Crush/"+version.Version), ) sessionLock := sync.Mutex{} @@ -530,6 +538,16 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy return nil, err } + // Send notification that agent has finished its turn (skip for + // nested/non-interactive sessions). + if !call.NonInteractive && a.notify != nil { + a.notify.Publish(pubsub.CreatedEvent, notify.Notification{ + SessionID: call.SessionID, + SessionTitle: currentSession.Title, + Type: notify.TypeAgentFinished, + }) + } + if shouldSummarize { a.activeRequests.Del(call.SessionID) if summarizeErr := a.Summarize(genCtx, call.SessionID, call.ProviderOptions); summarizeErr != nil { @@ -592,6 +610,7 @@ func (a *sessionAgent) Summarize(ctx context.Context, sessionID string, opts fan agent := fantasy.NewAgent(largeModel.Model, fantasy.WithSystemPrompt(string(summaryPrompt)), + fantasy.WithUserAgent("Charm Crush/"+version.Version), ) summaryMessage, err := a.messages.Create(ctx, sessionID, message.CreateMessageParams{ Role: message.Assistant, @@ -787,6 +806,7 @@ func (a *sessionAgent) generateTitle(ctx context.Context, sessionID string, user return fantasy.NewAgent(m, fantasy.WithSystemPrompt(string(p)+"\n /no_think"), fantasy.WithMaxOutputTokens(tok), + fantasy.WithUserAgent("Charm Crush/"+version.Version), ) } diff --git a/internal/agent/agent_tool.go b/internal/agent/agent_tool.go index 1a7286e342d245c7e7ac1161111d8c205300018b..0d7677dee702b813e0a0d6f02e67837f084d5c29 100644 --- a/internal/agent/agent_tool.go +++ b/internal/agent/agent_tool.go @@ -24,7 +24,7 @@ const ( ) func (c *coordinator) agentTool(ctx context.Context) (fantasy.AgentTool, error) { - agentCfg, ok := c.cfg.Agents[config.AgentTask] + agentCfg, ok := c.cfg.Config().Agents[config.AgentTask] if !ok { return nil, errors.New("task agent not configured") } diff --git a/internal/agent/agentic_fetch_tool.go b/internal/agent/agentic_fetch_tool.go index 0bd942e013b706389fb90352c891a4f2ea014f30..ffbe0f49e45c259db3f0bba9f07fda771ad3ecd4 100644 --- a/internal/agent/agentic_fetch_tool.go +++ b/internal/agent/agentic_fetch_tool.go @@ -98,7 +98,7 @@ func (c *coordinator) agenticFetchTool(_ context.Context, client *http.Client) ( return fantasy.ToolResponse{}, permission.ErrorPermissionDenied } - tmpDir, err := os.MkdirTemp(c.cfg.Options.DataDirectory, "crush-fetch-*") + tmpDir, err := os.MkdirTemp(c.cfg.Config().Options.DataDirectory, "crush-fetch-*") if err != nil { return fantasy.NewTextErrorResponse(fmt.Sprintf("Failed to create temporary directory: %s", err)), nil } @@ -151,12 +151,12 @@ func (c *coordinator) agenticFetchTool(_ context.Context, client *http.Client) ( return fantasy.ToolResponse{}, fmt.Errorf("error building models: %s", err) } - systemPrompt, err := promptTemplate.Build(ctx, small.Model.Provider(), small.Model.Model(), *c.cfg) + systemPrompt, err := promptTemplate.Build(ctx, small.Model.Provider(), small.Model.Model(), c.cfg) if err != nil { return fantasy.ToolResponse{}, fmt.Errorf("error building system prompt: %s", err) } - smallProviderCfg, ok := c.cfg.Providers.Get(small.ModelCfg.Provider) + smallProviderCfg, ok := c.cfg.Config().Providers.Get(small.ModelCfg.Provider) if !ok { return fantasy.ToolResponse{}, errors.New("small model provider not configured") } @@ -167,7 +167,7 @@ func (c *coordinator) agenticFetchTool(_ context.Context, client *http.Client) ( webFetchTool, webSearchTool, tools.NewGlobTool(tmpDir), - tools.NewGrepTool(tmpDir, c.cfg.Tools.Grep), + tools.NewGrepTool(tmpDir, c.cfg.Config().Tools.Grep), tools.NewSourcegraphTool(client), tools.NewViewTool(c.lspManager, c.permissions, c.filetracker, tmpDir), } @@ -177,7 +177,7 @@ func (c *coordinator) agenticFetchTool(_ context.Context, client *http.Client) ( SmallModel: small, SystemPromptPrefix: smallProviderCfg.SystemPromptPrefix, SystemPrompt: systemPrompt, - DisableAutoSummarize: c.cfg.Options.DisableAutoSummarize, + DisableAutoSummarize: c.cfg.Config().Options.DisableAutoSummarize, IsYolo: c.permissions.SkipRequests(), Sessions: c.sessions, Messages: c.messages, diff --git a/internal/agent/common_test.go b/internal/agent/common_test.go index 101b987f2417659828fa68ae68405c1a723322b3..132c27d21aee81bd3930c469963f1d73885d58a7 100644 --- a/internal/agent/common_test.go +++ b/internal/agent/common_test.go @@ -153,7 +153,15 @@ func testSessionAgent(env fakeEnv, large, small fantasy.LanguageModel, systemPro DefaultMaxTokens: 10000, }, } - agent := NewSessionAgent(SessionAgentOptions{largeModel, smallModel, "", systemPrompt, false, false, true, env.sessions, env.messages, tools}) + agent := NewSessionAgent(SessionAgentOptions{ + LargeModel: largeModel, + SmallModel: smallModel, + SystemPrompt: systemPrompt, + IsYolo: true, + Sessions: env.sessions, + Messages: env.messages, + Tools: tools, + }) return agent } @@ -177,36 +185,36 @@ func coderAgent(r *vcr.Recorder, env fakeEnv, large, small fantasy.LanguageModel // NOTE(@andreynering): Set a fixed config to ensure cassettes match // independently of user config on `$HOME/.config/crush/crush.json`. - cfg.Options.Attribution = &config.Attribution{ + cfg.Config().Options.Attribution = &config.Attribution{ TrailerStyle: "co-authored-by", GeneratedWith: true, } // Clear some fields to avoid issues with VCR cassette matching. - cfg.Options.SkillsPaths = nil - cfg.Options.ContextPaths = nil - cfg.LSP = nil + cfg.Config().Options.SkillsPaths = nil + cfg.Config().Options.ContextPaths = nil + cfg.Config().LSP = nil - systemPrompt, err := prompt.Build(context.TODO(), large.Provider(), large.Model(), *cfg) + systemPrompt, err := prompt.Build(context.TODO(), large.Provider(), large.Model(), cfg) if err != nil { return nil, err } // Get the model name for the bash tool modelName := large.Model() // fallback to ID if Name not available - if model := cfg.GetModel(large.Provider(), large.Model()); model != nil { + if model := cfg.Config().GetModel(large.Provider(), large.Model()); model != nil { modelName = model.Name } allTools := []fantasy.AgentTool{ - tools.NewBashTool(env.permissions, env.workingDir, cfg.Options.Attribution, modelName), + tools.NewBashTool(env.permissions, env.workingDir, cfg.Config().Options.Attribution, modelName), tools.NewDownloadTool(env.permissions, env.workingDir, r.GetDefaultClient()), tools.NewEditTool(nil, env.permissions, env.history, *env.filetracker, env.workingDir), tools.NewMultiEditTool(nil, env.permissions, env.history, *env.filetracker, env.workingDir), tools.NewFetchTool(env.permissions, env.workingDir, r.GetDefaultClient()), tools.NewGlobTool(env.workingDir), - tools.NewGrepTool(env.workingDir, cfg.Tools.Grep), - tools.NewLsTool(env.permissions, env.workingDir, cfg.Tools.Ls), + tools.NewGrepTool(env.workingDir, cfg.Config().Tools.Grep), + tools.NewLsTool(env.permissions, env.workingDir, cfg.Config().Tools.Ls), tools.NewSourcegraphTool(r.GetDefaultClient()), tools.NewViewTool(nil, env.permissions, *env.filetracker, env.workingDir), tools.NewWriteTool(nil, env.permissions, env.history, *env.filetracker, env.workingDir), diff --git a/internal/agent/coordinator.go b/internal/agent/coordinator.go index 6fd36661ed6ee3065b86cceb78e9253ddd5b42b7..4bca96d5946630423fc532ac6ccb5be833638dd2 100644 --- a/internal/agent/coordinator.go +++ b/internal/agent/coordinator.go @@ -18,6 +18,7 @@ import ( "charm.land/catwalk/pkg/catwalk" "charm.land/fantasy" "github.com/charmbracelet/crush/internal/agent/hyper" + "github.com/charmbracelet/crush/internal/agent/notify" "github.com/charmbracelet/crush/internal/agent/prompt" "github.com/charmbracelet/crush/internal/agent/tools" "github.com/charmbracelet/crush/internal/config" @@ -28,6 +29,7 @@ import ( "github.com/charmbracelet/crush/internal/message" "github.com/charmbracelet/crush/internal/oauth/copilot" "github.com/charmbracelet/crush/internal/permission" + "github.com/charmbracelet/crush/internal/pubsub" "github.com/charmbracelet/crush/internal/session" "golang.org/x/sync/errgroup" @@ -72,13 +74,14 @@ type Coordinator interface { } type coordinator struct { - cfg *config.Config + cfg *config.ConfigStore sessions session.Service messages message.Service permissions permission.Service history history.Service filetracker filetracker.Service lspManager *lsp.Manager + notify pubsub.Publisher[notify.Notification] currentAgent SessionAgent agents map[string]SessionAgent @@ -88,13 +91,14 @@ type coordinator struct { func NewCoordinator( ctx context.Context, - cfg *config.Config, + cfg *config.ConfigStore, sessions session.Service, messages message.Service, permissions permission.Service, history history.Service, filetracker filetracker.Service, lspManager *lsp.Manager, + notify pubsub.Publisher[notify.Notification], ) (Coordinator, error) { c := &coordinator{ cfg: cfg, @@ -104,10 +108,11 @@ func NewCoordinator( history: history, filetracker: filetracker, lspManager: lspManager, + notify: notify, agents: make(map[string]SessionAgent), } - agentCfg, ok := cfg.Agents[config.AgentCoder] + agentCfg, ok := cfg.Config().Agents[config.AgentCoder] if !ok { return nil, errCoderAgentNotConfigured } @@ -155,7 +160,7 @@ func (c *coordinator) Run(ctx context.Context, sessionID string, prompt string, attachments = filteredAttachments } - providerCfg, ok := c.cfg.Providers.Get(model.ModelCfg.Provider) + providerCfg, ok := c.cfg.Config().Providers.Get(model.ModelCfg.Provider) if !ok { return nil, errModelProviderNotConfigured } @@ -378,22 +383,23 @@ func (c *coordinator) buildAgent(ctx context.Context, prompt *prompt.Prompt, age return nil, err } - largeProviderCfg, _ := c.cfg.Providers.Get(large.ModelCfg.Provider) + largeProviderCfg, _ := c.cfg.Config().Providers.Get(large.ModelCfg.Provider) result := NewSessionAgent(SessionAgentOptions{ - large, - small, - largeProviderCfg.SystemPromptPrefix, - "", - isSubAgent, - c.cfg.Options.DisableAutoSummarize, - c.permissions.SkipRequests(), - c.sessions, - c.messages, - nil, + LargeModel: large, + SmallModel: small, + SystemPromptPrefix: largeProviderCfg.SystemPromptPrefix, + SystemPrompt: "", + IsSubAgent: isSubAgent, + DisableAutoSummarize: c.cfg.Config().Options.DisableAutoSummarize, + IsYolo: c.permissions.SkipRequests(), + Sessions: c.sessions, + Messages: c.messages, + Tools: nil, + Notify: c.notify, }) c.readyWg.Go(func() error { - systemPrompt, err := prompt.Build(ctx, large.Model.Provider(), large.Model.Model(), *c.cfg) + systemPrompt, err := prompt.Build(ctx, large.Model.Provider(), large.Model.Model(), c.cfg) if err != nil { return err } @@ -433,14 +439,14 @@ func (c *coordinator) buildTools(ctx context.Context, agent config.Agent) ([]fan // Get the model name for the agent modelName := "" - if modelCfg, ok := c.cfg.Models[agent.Model]; ok { - if model := c.cfg.GetModel(modelCfg.Provider, modelCfg.Model); model != nil { + if modelCfg, ok := c.cfg.Config().Models[agent.Model]; ok { + if model := c.cfg.Config().GetModel(modelCfg.Provider, modelCfg.Model); model != nil { modelName = model.Name } } allTools = append(allTools, - tools.NewBashTool(c.permissions, c.cfg.WorkingDir(), c.cfg.Options.Attribution, modelName), + tools.NewBashTool(c.permissions, c.cfg.WorkingDir(), c.cfg.Config().Options.Attribution, modelName), tools.NewJobOutputTool(), tools.NewJobKillTool(), tools.NewDownloadTool(c.permissions, c.cfg.WorkingDir(), nil), @@ -448,20 +454,20 @@ func (c *coordinator) buildTools(ctx context.Context, agent config.Agent) ([]fan tools.NewMultiEditTool(c.lspManager, c.permissions, c.history, c.filetracker, c.cfg.WorkingDir()), tools.NewFetchTool(c.permissions, c.cfg.WorkingDir(), nil), tools.NewGlobTool(c.cfg.WorkingDir()), - tools.NewGrepTool(c.cfg.WorkingDir(), c.cfg.Tools.Grep), - tools.NewLsTool(c.permissions, c.cfg.WorkingDir(), c.cfg.Tools.Ls), + tools.NewGrepTool(c.cfg.WorkingDir(), c.cfg.Config().Tools.Grep), + tools.NewLsTool(c.permissions, c.cfg.WorkingDir(), c.cfg.Config().Tools.Ls), tools.NewSourcegraphTool(nil), tools.NewTodosTool(c.sessions), - tools.NewViewTool(c.lspManager, c.permissions, c.filetracker, c.cfg.WorkingDir(), c.cfg.Options.SkillsPaths...), + tools.NewViewTool(c.lspManager, c.permissions, c.filetracker, c.cfg.WorkingDir(), c.cfg.Config().Options.SkillsPaths...), tools.NewWriteTool(c.lspManager, c.permissions, c.history, c.filetracker, c.cfg.WorkingDir()), ) // Add LSP tools if user has configured LSPs or auto_lsp is enabled (nil or true). - if len(c.cfg.LSP) > 0 || c.cfg.Options.AutoLSP == nil || *c.cfg.Options.AutoLSP { + if len(c.cfg.Config().LSP) > 0 || c.cfg.Config().Options.AutoLSP == nil || *c.cfg.Config().Options.AutoLSP { allTools = append(allTools, tools.NewDiagnosticsTool(c.lspManager), tools.NewReferencesTool(c.lspManager), tools.NewLSPRestartTool(c.lspManager)) } - if len(c.cfg.MCP) > 0 { + if len(c.cfg.Config().MCP) > 0 { allTools = append( allTools, tools.NewListMCPResourcesTool(c.cfg, c.permissions), @@ -507,16 +513,16 @@ func (c *coordinator) buildTools(ctx context.Context, agent config.Agent) ([]fan // TODO: when we support multiple agents we need to change this so that we pass in the agent specific model config func (c *coordinator) buildAgentModels(ctx context.Context, isSubAgent bool) (Model, Model, error) { - largeModelCfg, ok := c.cfg.Models[config.SelectedModelTypeLarge] + largeModelCfg, ok := c.cfg.Config().Models[config.SelectedModelTypeLarge] if !ok { return Model{}, Model{}, errLargeModelNotSelected } - smallModelCfg, ok := c.cfg.Models[config.SelectedModelTypeSmall] + smallModelCfg, ok := c.cfg.Config().Models[config.SelectedModelTypeSmall] if !ok { return Model{}, Model{}, errSmallModelNotSelected } - largeProviderCfg, ok := c.cfg.Providers.Get(largeModelCfg.Provider) + largeProviderCfg, ok := c.cfg.Config().Providers.Get(largeModelCfg.Provider) if !ok { return Model{}, Model{}, errLargeModelProviderNotConfigured } @@ -526,7 +532,7 @@ func (c *coordinator) buildAgentModels(ctx context.Context, isSubAgent bool) (Mo return Model{}, Model{}, err } - smallProviderCfg, ok := c.cfg.Providers.Get(smallModelCfg.Provider) + smallProviderCfg, ok := c.cfg.Config().Providers.Get(smallModelCfg.Provider) if !ok { return Model{}, Model{}, errSmallModelProviderNotConfigured } @@ -614,7 +620,7 @@ func (c *coordinator) buildAnthropicProvider(baseURL, apiKey string, headers map opts = append(opts, anthropic.WithBaseURL(baseURL)) } - if c.cfg.Options.Debug { + if c.cfg.Config().Options.Debug { httpClient := log.NewHTTPClient() opts = append(opts, anthropic.WithHTTPClient(httpClient)) } @@ -626,7 +632,7 @@ func (c *coordinator) buildOpenaiProvider(baseURL, apiKey string, headers map[st openai.WithAPIKey(apiKey), openai.WithUseResponsesAPI(), } - if c.cfg.Options.Debug { + if c.cfg.Config().Options.Debug { httpClient := log.NewHTTPClient() opts = append(opts, openai.WithHTTPClient(httpClient)) } @@ -643,7 +649,7 @@ func (c *coordinator) buildOpenrouterProvider(_, apiKey string, headers map[stri opts := []openrouter.Option{ openrouter.WithAPIKey(apiKey), } - if c.cfg.Options.Debug { + if c.cfg.Config().Options.Debug { httpClient := log.NewHTTPClient() opts = append(opts, openrouter.WithHTTPClient(httpClient)) } @@ -657,7 +663,7 @@ func (c *coordinator) buildVercelProvider(_, apiKey string, headers map[string]s opts := []vercel.Option{ vercel.WithAPIKey(apiKey), } - if c.cfg.Options.Debug { + if c.cfg.Config().Options.Debug { httpClient := log.NewHTTPClient() opts = append(opts, vercel.WithHTTPClient(httpClient)) } @@ -677,8 +683,8 @@ func (c *coordinator) buildOpenaiCompatProvider(baseURL, apiKey string, headers var httpClient *http.Client if providerID == string(catwalk.InferenceProviderCopilot) { opts = append(opts, openaicompat.WithUseResponsesAPI()) - httpClient = copilot.NewClient(isSubAgent, c.cfg.Options.Debug) - } else if c.cfg.Options.Debug { + httpClient = copilot.NewClient(isSubAgent, c.cfg.Config().Options.Debug) + } else if c.cfg.Config().Options.Debug { httpClient = log.NewHTTPClient() } if httpClient != nil { @@ -702,7 +708,7 @@ func (c *coordinator) buildAzureProvider(baseURL, apiKey string, headers map[str azure.WithAPIKey(apiKey), azure.WithUseResponsesAPI(), } - if c.cfg.Options.Debug { + if c.cfg.Config().Options.Debug { httpClient := log.NewHTTPClient() opts = append(opts, azure.WithHTTPClient(httpClient)) } @@ -721,7 +727,7 @@ func (c *coordinator) buildAzureProvider(baseURL, apiKey string, headers map[str func (c *coordinator) buildBedrockProvider(headers map[string]string) (fantasy.Provider, error) { var opts []bedrock.Option - if c.cfg.Options.Debug { + if c.cfg.Config().Options.Debug { httpClient := log.NewHTTPClient() opts = append(opts, bedrock.WithHTTPClient(httpClient)) } @@ -740,7 +746,7 @@ func (c *coordinator) buildGoogleProvider(baseURL, apiKey string, headers map[st google.WithBaseURL(baseURL), google.WithGeminiAPIKey(apiKey), } - if c.cfg.Options.Debug { + if c.cfg.Config().Options.Debug { httpClient := log.NewHTTPClient() opts = append(opts, google.WithHTTPClient(httpClient)) } @@ -752,7 +758,7 @@ func (c *coordinator) buildGoogleProvider(baseURL, apiKey string, headers map[st func (c *coordinator) buildGoogleVertexProvider(headers map[string]string, options map[string]string) (fantasy.Provider, error) { opts := []google.Option{} - if c.cfg.Options.Debug { + if c.cfg.Config().Options.Debug { httpClient := log.NewHTTPClient() opts = append(opts, google.WithHTTPClient(httpClient)) } @@ -773,7 +779,7 @@ func (c *coordinator) buildHyperProvider(baseURL, apiKey string) (fantasy.Provid hyper.WithBaseURL(baseURL), hyper.WithAPIKey(apiKey), } - if c.cfg.Options.Debug { + if c.cfg.Config().Options.Debug { httpClient := log.NewHTTPClient() opts = append(opts, hyper.WithHTTPClient(httpClient)) } @@ -881,7 +887,7 @@ func (c *coordinator) UpdateModels(ctx context.Context) error { } c.currentAgent.SetModels(large, small) - agentCfg, ok := c.cfg.Agents[config.AgentCoder] + agentCfg, ok := c.cfg.Config().Agents[config.AgentCoder] if !ok { return errCoderAgentNotConfigured } @@ -903,7 +909,7 @@ func (c *coordinator) QueuedPromptsList(sessionID string) []string { } func (c *coordinator) Summarize(ctx context.Context, sessionID string) error { - providerCfg, ok := c.cfg.Providers.Get(c.currentAgent.Model().ModelCfg.Provider) + providerCfg, ok := c.cfg.Config().Providers.Get(c.currentAgent.Model().ModelCfg.Provider) if !ok { return errModelProviderNotConfigured } @@ -916,7 +922,7 @@ func (c *coordinator) isUnauthorized(err error) bool { } func (c *coordinator) refreshOAuth2Token(ctx context.Context, providerCfg config.ProviderConfig) error { - if err := c.cfg.RefreshOAuthToken(ctx, providerCfg.ID); err != nil { + if err := c.cfg.RefreshOAuthToken(ctx, config.ScopeGlobal, providerCfg.ID); err != nil { slog.Error("Failed to refresh OAuth token after 401 error", "provider", providerCfg.ID, "error", err) return err } @@ -934,7 +940,7 @@ func (c *coordinator) refreshApiKeyTemplate(ctx context.Context, providerCfg con } providerCfg.APIKey = newAPIKey - c.cfg.Providers.Set(providerCfg.ID, providerCfg) + c.cfg.Config().Providers.Set(providerCfg.ID, providerCfg) if err := c.UpdateModels(ctx); err != nil { return err @@ -978,7 +984,7 @@ func (c *coordinator) runSubAgent(ctx context.Context, params subAgentParams) (f maxTokens = model.ModelCfg.MaxTokens } - providerCfg, ok := c.cfg.Providers.Get(model.ModelCfg.Provider) + providerCfg, ok := c.cfg.Config().Providers.Get(model.ModelCfg.Provider) if !ok { return fantasy.ToolResponse{}, errModelProviderNotConfigured } @@ -994,6 +1000,7 @@ func (c *coordinator) runSubAgent(ctx context.Context, params subAgentParams) (f TopK: model.ModelCfg.TopK, FrequencyPenalty: model.ModelCfg.FrequencyPenalty, PresencePenalty: model.ModelCfg.PresencePenalty, + NonInteractive: true, }) if err != nil { return fantasy.NewTextErrorResponse("error generating response"), nil diff --git a/internal/agent/coordinator_test.go b/internal/agent/coordinator_test.go index 3c270394cba9c1758e4a9029a149027af6bf36c2..657575b6458d7fb815c7a9646a9d605c8b89ec42 100644 --- a/internal/agent/coordinator_test.go +++ b/internal/agent/coordinator_test.go @@ -44,7 +44,7 @@ func (m *mockSessionAgent) Summarize(context.Context, string, fantasy.ProviderOp func newTestCoordinator(t *testing.T, env fakeEnv, providerID string, providerCfg config.ProviderConfig) *coordinator { cfg, err := config.Init(env.workingDir, "", false) require.NoError(t, err) - cfg.Providers.Set(providerID, providerCfg) + cfg.Config().Providers.Set(providerID, providerCfg) return &coordinator{ cfg: cfg, sessions: env.sessions, diff --git a/internal/agent/notify/notify.go b/internal/agent/notify/notify.go new file mode 100644 index 0000000000000000000000000000000000000000..aba0069a1dc945dd42dd8f6a513095fa8d14157e --- /dev/null +++ b/internal/agent/notify/notify.go @@ -0,0 +1,19 @@ +// Package notify defines domain notification types for agent events. +// These types are decoupled from UI concerns so the agent can publish +// events without importing UI packages. +package notify + +// Type identifies the kind of agent notification. +type Type string + +const ( + // TypeAgentFinished indicates the agent has completed its turn. + TypeAgentFinished Type = "agent_finished" +) + +// Notification represents a domain event published by the agent. +type Notification struct { + SessionID string + SessionTitle string + Type Type +} diff --git a/internal/agent/prompt/prompt.go b/internal/agent/prompt/prompt.go index d68c7c132116c49cd004bee52169be7487133efa..c8f488319f04238c476aae4719728fb94521695e 100644 --- a/internal/agent/prompt/prompt.go +++ b/internal/agent/prompt/prompt.go @@ -76,13 +76,13 @@ func NewPrompt(name, promptTemplate string, opts ...Option) (*Prompt, error) { return p, nil } -func (p *Prompt) Build(ctx context.Context, provider, model string, cfg config.Config) (string, error) { +func (p *Prompt) Build(ctx context.Context, provider, model string, store *config.ConfigStore) (string, error) { t, err := template.New(p.name).Parse(p.template) if err != nil { return "", fmt.Errorf("parsing template: %w", err) } var sb strings.Builder - d, err := p.promptData(ctx, provider, model, cfg) + d, err := p.promptData(ctx, provider, model, store) if err != nil { return "", err } @@ -104,11 +104,11 @@ func processFile(filePath string) *ContextFile { } } -func processContextPath(p string, cfg config.Config) []ContextFile { +func processContextPath(p string, store *config.ConfigStore) []ContextFile { var contexts []ContextFile fullPath := p if !filepath.IsAbs(p) { - fullPath = filepath.Join(cfg.WorkingDir(), p) + fullPath = filepath.Join(store.WorkingDir(), p) } info, err := os.Stat(fullPath) if err != nil { @@ -136,11 +136,11 @@ func processContextPath(p string, cfg config.Config) []ContextFile { } // expandPath expands ~ and environment variables in file paths -func expandPath(path string, cfg config.Config) string { +func expandPath(path string, store *config.ConfigStore) string { path = home.Long(path) // Handle environment variable expansion using the same pattern as config if strings.HasPrefix(path, "$") { - if expanded, err := cfg.Resolver().ResolveValue(path); err == nil { + if expanded, err := store.Resolver().ResolveValue(path); err == nil { path = expanded } } @@ -148,19 +148,20 @@ func expandPath(path string, cfg config.Config) string { return path } -func (p *Prompt) promptData(ctx context.Context, provider, model string, cfg config.Config) (PromptDat, error) { - workingDir := cmp.Or(p.workingDir, cfg.WorkingDir()) +func (p *Prompt) promptData(ctx context.Context, provider, model string, store *config.ConfigStore) (PromptDat, error) { + workingDir := cmp.Or(p.workingDir, store.WorkingDir()) platform := cmp.Or(p.platform, runtime.GOOS) files := map[string][]ContextFile{} + cfg := store.Config() for _, pth := range cfg.Options.ContextPaths { - expanded := expandPath(pth, cfg) + expanded := expandPath(pth, store) pathKey := strings.ToLower(expanded) if _, ok := files[pathKey]; ok { continue } - content := processContextPath(expanded, cfg) + content := processContextPath(expanded, store) files[pathKey] = content } @@ -169,18 +170,18 @@ func (p *Prompt) promptData(ctx context.Context, provider, model string, cfg con if len(cfg.Options.SkillsPaths) > 0 { expandedPaths := make([]string, 0, len(cfg.Options.SkillsPaths)) for _, pth := range cfg.Options.SkillsPaths { - expandedPaths = append(expandedPaths, expandPath(pth, cfg)) + expandedPaths = append(expandedPaths, expandPath(pth, store)) } if discoveredSkills := skills.Discover(expandedPaths); len(discoveredSkills) > 0 { availSkillXML = skills.ToPromptXML(discoveredSkills) } } - isGit := isGitRepo(cfg.WorkingDir()) + isGit := isGitRepo(store.WorkingDir()) data := PromptDat{ Provider: provider, Model: model, - Config: cfg, + Config: *cfg, WorkingDir: filepath.ToSlash(workingDir), IsGitRepo: isGit, Platform: platform, @@ -189,7 +190,7 @@ func (p *Prompt) promptData(ctx context.Context, provider, model string, cfg con } if isGit { var err error - data.GitStatus, err = getGitStatus(ctx, cfg.WorkingDir()) + data.GitStatus, err = getGitStatus(ctx, store.WorkingDir()) if err != nil { return PromptDat{}, err } diff --git a/internal/agent/prompts.go b/internal/agent/prompts.go index 577d32e4e274d9cb8274bd862af583208a613f08..448fe0425c3b700b1d6edafc842c4815ad3d5760 100644 --- a/internal/agent/prompts.go +++ b/internal/agent/prompts.go @@ -33,7 +33,7 @@ func taskPrompt(opts ...prompt.Option) (*prompt.Prompt, error) { return systemPrompt, nil } -func InitializePrompt(cfg config.Config) (string, error) { +func InitializePrompt(cfg *config.ConfigStore) (string, error) { systemPrompt, err := prompt.NewPrompt("initialize", string(initializePromptTmpl)) if err != nil { return "", err diff --git a/internal/agent/tools/list_mcp_resources.go b/internal/agent/tools/list_mcp_resources.go index 032d1eb1888a65e9a14daecc3b503698a6fa60d4..7ea8998a1dc80955b2a5b0a79d4aef7d19fb9011 100644 --- a/internal/agent/tools/list_mcp_resources.go +++ b/internal/agent/tools/list_mcp_resources.go @@ -28,7 +28,7 @@ const ListMCPResourcesToolName = "list_mcp_resources" //go:embed list_mcp_resources.md var listMCPResourcesDescription []byte -func NewListMCPResourcesTool(cfg *config.Config, permissions permission.Service) fantasy.AgentTool { +func NewListMCPResourcesTool(cfg *config.ConfigStore, permissions permission.Service) fantasy.AgentTool { return fantasy.NewParallelAgentTool( ListMCPResourcesToolName, string(listMCPResourcesDescription), diff --git a/internal/agent/tools/mcp-tools.go b/internal/agent/tools/mcp-tools.go index 429cadaf6b686b83e170ef35976881d839b07e17..e1184118552ee62e75f60c6943f59ecca2868563 100644 --- a/internal/agent/tools/mcp-tools.go +++ b/internal/agent/tools/mcp-tools.go @@ -11,7 +11,7 @@ import ( ) // GetMCPTools gets all the currently available MCP tools. -func GetMCPTools(permissions permission.Service, cfg *config.Config, wd string) []*Tool { +func GetMCPTools(permissions permission.Service, cfg *config.ConfigStore, wd string) []*Tool { var result []*Tool for mcpName, tools := range mcp.Tools() { for _, tool := range tools { @@ -31,7 +31,7 @@ func GetMCPTools(permissions permission.Service, cfg *config.Config, wd string) type Tool struct { mcpName string tool *mcp.Tool - cfg *config.Config + cfg *config.ConfigStore permissions permission.Service workingDir string providerOptions fantasy.ProviderOptions diff --git a/internal/agent/tools/mcp/init.go b/internal/agent/tools/mcp/init.go index f8cfe0ce84bf7b1987496607d42753b8ca72263f..cba9a51c717b1866b823762f85bfadf90e1a7a10 100644 --- a/internal/agent/tools/mcp/init.go +++ b/internal/agent/tools/mcp/init.go @@ -163,11 +163,11 @@ func Close(ctx context.Context) error { } // Initialize initializes MCP clients based on the provided configuration. -func Initialize(ctx context.Context, permissions permission.Service, cfg *config.Config) { +func Initialize(ctx context.Context, permissions permission.Service, cfg *config.ConfigStore) { slog.Info("Initializing MCP clients") var wg sync.WaitGroup // Initialize states for all configured MCPs - for name, m := range cfg.MCP { + for name, m := range cfg.Config().MCP { if m.Disabled { updateState(name, StateDisabled, nil, nil, Counts{}) slog.Debug("Skipping disabled MCP", "name", name) @@ -253,13 +253,13 @@ func WaitForInit(ctx context.Context) error { } } -func getOrRenewClient(ctx context.Context, cfg *config.Config, name string) (*ClientSession, error) { +func getOrRenewClient(ctx context.Context, cfg *config.ConfigStore, name string) (*ClientSession, error) { sess, ok := sessions.Get(name) if !ok { return nil, fmt.Errorf("mcp '%s' not available", name) } - m := cfg.MCP[name] + m := cfg.Config().MCP[name] state, _ := states.Get(name) timeout := mcpTimeout(m) diff --git a/internal/agent/tools/mcp/prompts.go b/internal/agent/tools/mcp/prompts.go index 2b39d5dc2db43aff418c3dd7561edbcebd6af865..d84be303ecb103d4fdd37423b7b6d088374d2c70 100644 --- a/internal/agent/tools/mcp/prompts.go +++ b/internal/agent/tools/mcp/prompts.go @@ -20,7 +20,7 @@ func Prompts() iter.Seq2[string, []*Prompt] { } // GetPromptMessages retrieves the content of an MCP prompt with the given arguments. -func GetPromptMessages(ctx context.Context, cfg *config.Config, clientName, promptName string, args map[string]string) ([]string, error) { +func GetPromptMessages(ctx context.Context, cfg *config.ConfigStore, clientName, promptName string, args map[string]string) ([]string, error) { c, err := getOrRenewClient(ctx, cfg, clientName) if err != nil { return nil, err diff --git a/internal/agent/tools/mcp/resources.go b/internal/agent/tools/mcp/resources.go index 8e2bcc796b28c698481dd90b0c70511273f7c98d..21616761e81212960f4d6ad59da1505049abbffb 100644 --- a/internal/agent/tools/mcp/resources.go +++ b/internal/agent/tools/mcp/resources.go @@ -24,7 +24,7 @@ func Resources() iter.Seq2[string, []*Resource] { } // ListResources returns the current resources for an MCP server. -func ListResources(ctx context.Context, cfg *config.Config, name string) ([]*Resource, error) { +func ListResources(ctx context.Context, cfg *config.ConfigStore, name string) ([]*Resource, error) { session, err := getOrRenewClient(ctx, cfg, name) if err != nil { return nil, err @@ -43,7 +43,7 @@ func ListResources(ctx context.Context, cfg *config.Config, name string) ([]*Res } // ReadResource reads the contents of a resource from an MCP server. -func ReadResource(ctx context.Context, cfg *config.Config, name, uri string) ([]*ResourceContents, error) { +func ReadResource(ctx context.Context, cfg *config.ConfigStore, name, uri string) ([]*ResourceContents, error) { session, err := getOrRenewClient(ctx, cfg, name) if err != nil { return nil, err diff --git a/internal/agent/tools/mcp/tools.go b/internal/agent/tools/mcp/tools.go index b6e208f7ccb3363bee0a0b60ef56c103ad9cd41b..8d1d2649ba4381e14fa8d99933f1dfb3b42d27ae 100644 --- a/internal/agent/tools/mcp/tools.go +++ b/internal/agent/tools/mcp/tools.go @@ -32,7 +32,7 @@ func Tools() iter.Seq2[string, []*Tool] { } // RunTool runs an MCP tool with the given input parameters. -func RunTool(ctx context.Context, cfg *config.Config, name, toolName string, input string) (ToolResult, error) { +func RunTool(ctx context.Context, cfg *config.ConfigStore, name, toolName string, input string) (ToolResult, error) { var args map[string]any if err := json.Unmarshal([]byte(input), &args); err != nil { return ToolResult{}, fmt.Errorf("error parsing parameters: %s", err) @@ -108,7 +108,7 @@ func RunTool(ctx context.Context, cfg *config.Config, name, toolName string, inp // RefreshTools gets the updated list of tools from the MCP and updates the // global state. -func RefreshTools(ctx context.Context, cfg *config.Config, name string) { +func RefreshTools(ctx context.Context, cfg *config.ConfigStore, name string) { session, ok := sessions.Get(name) if !ok { slog.Warn("Refresh tools: no session", "name", name) @@ -139,7 +139,7 @@ func getTools(ctx context.Context, session *ClientSession) ([]*Tool, error) { return result.Tools, nil } -func updateTools(cfg *config.Config, name string, tools []*Tool) int { +func updateTools(cfg *config.ConfigStore, name string, tools []*Tool) int { tools = filterDisabledTools(cfg, name, tools) if len(tools) == 0 { allTools.Del(name) @@ -150,8 +150,8 @@ func updateTools(cfg *config.Config, name string, tools []*Tool) int { } // filterDisabledTools removes tools that are disabled via config. -func filterDisabledTools(cfg *config.Config, mcpName string, tools []*Tool) []*Tool { - mcpCfg, ok := cfg.MCP[mcpName] +func filterDisabledTools(cfg *config.ConfigStore, mcpName string, tools []*Tool) []*Tool { + mcpCfg, ok := cfg.Config().MCP[mcpName] if !ok || len(mcpCfg.DisabledTools) == 0 { return tools } diff --git a/internal/agent/tools/read_mcp_resource.go b/internal/agent/tools/read_mcp_resource.go index cc0450d63aa94574e45e4264906c77fc2b7a1127..c96b00194b92b05e40953a49a90c3453fe6b16b2 100644 --- a/internal/agent/tools/read_mcp_resource.go +++ b/internal/agent/tools/read_mcp_resource.go @@ -30,7 +30,7 @@ const ReadMCPResourceToolName = "read_mcp_resource" //go:embed read_mcp_resource.md var readMCPResourceDescription []byte -func NewReadMCPResourceTool(cfg *config.Config, permissions permission.Service) fantasy.AgentTool { +func NewReadMCPResourceTool(cfg *config.ConfigStore, permissions permission.Service) fantasy.AgentTool { return fantasy.NewParallelAgentTool( ReadMCPResourceToolName, string(readMCPResourceDescription), diff --git a/internal/app/app.go b/internal/app/app.go index 3c02a734c703079c364983ecf47dbe27564798bb..20ff19dedcaef4d0cbcddf268b0f2e1ac7b3b3d4 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -19,6 +19,7 @@ import ( "charm.land/fantasy" "charm.land/lipgloss/v2" "github.com/charmbracelet/crush/internal/agent" + "github.com/charmbracelet/crush/internal/agent/notify" "github.com/charmbracelet/crush/internal/agent/tools/mcp" "github.com/charmbracelet/crush/internal/config" "github.com/charmbracelet/crush/internal/db" @@ -60,7 +61,7 @@ type App struct { LSPManager *lsp.Manager - config *config.Config + config *config.ConfigStore serviceEventsWG *sync.WaitGroup eventsCtx context.Context @@ -68,16 +69,18 @@ type App struct { tuiWG *sync.WaitGroup // global context and cleanup functions - globalCtx context.Context - cleanupFuncs []func(context.Context) error + globalCtx context.Context + cleanupFuncs []func(context.Context) error + agentNotifications *pubsub.Broker[notify.Notification] } // New initializes a new application instance. -func New(ctx context.Context, conn *sql.DB, cfg *config.Config) (*App, error) { +func New(ctx context.Context, conn *sql.DB, store *config.ConfigStore) (*App, error) { q := db.New(conn) sessions := session.NewService(q, conn) messages := message.NewService(q) files := history.NewService(q, conn) + cfg := store.Config() skipPermissionsRequests := cfg.Permissions != nil && cfg.Permissions.SkipRequests var allowedTools []string if cfg.Permissions != nil && cfg.Permissions.AllowedTools != nil { @@ -88,17 +91,18 @@ func New(ctx context.Context, conn *sql.DB, cfg *config.Config) (*App, error) { Sessions: sessions, Messages: messages, History: files, - Permissions: permission.NewPermissionService(cfg.WorkingDir(), skipPermissionsRequests, allowedTools), + Permissions: permission.NewPermissionService(store.WorkingDir(), skipPermissionsRequests, allowedTools), FileTracker: filetracker.NewService(q), - LSPManager: lsp.NewManager(cfg), + LSPManager: lsp.NewManager(store), globalCtx: ctx, - config: cfg, + config: store, - events: make(chan tea.Msg, 100), - serviceEventsWG: &sync.WaitGroup{}, - tuiWG: &sync.WaitGroup{}, + events: make(chan tea.Msg, 100), + serviceEventsWG: &sync.WaitGroup{}, + tuiWG: &sync.WaitGroup{}, + agentNotifications: pubsub.NewBroker[notify.Notification](), } app.setupEvents() @@ -106,13 +110,13 @@ func New(ctx context.Context, conn *sql.DB, cfg *config.Config) (*App, error) { // Check for updates in the background. go app.checkForUpdates(ctx) - go mcp.Initialize(ctx, app.Permissions, cfg) + go mcp.Initialize(ctx, app.Permissions, store) // cleanup database upon app shutdown app.cleanupFuncs = append( app.cleanupFuncs, func(context.Context) error { return conn.Close() }, - mcp.Close, + func(ctx context.Context) error { return mcp.Close(ctx) }, ) // TODO: remove the concept of agent config, most likely. @@ -138,8 +142,13 @@ func New(ctx context.Context, conn *sql.DB, cfg *config.Config) (*App, error) { return app, nil } -// Config returns the application configuration. +// Config returns the pure-data configuration. func (app *App) Config() *config.Config { + return app.config.Config() +} + +// Store returns the config store. +func (app *App) Store() *config.ConfigStore { return app.config } @@ -157,6 +166,11 @@ func (app *App) SendEvent(msg tea.Msg) { } } +// AgentNotifications returns the broker for agent notification events. +func (app *App) AgentNotifications() *pubsub.Broker[notify.Notification] { + return app.agentNotifications +} + // RunNonInteractive runs the application in non-interactive mode with the // given prompt, printing to stdout. func (app *App) RunNonInteractive(ctx context.Context, output io.Writer, prompt, largeModel, smallModel string, hideSpinner bool) error { @@ -184,7 +198,7 @@ func (app *App) RunNonInteractive(ctx context.Context, output io.Writer, prompt, } stderrTTY = term.IsTerminal(os.Stderr.Fd()) stdinTTY = term.IsTerminal(os.Stdin.Fd()) - progress = app.config.Options.Progress == nil || *app.config.Options.Progress + progress = app.config.Config().Options.Progress == nil || *app.config.Config().Options.Progress if !hideSpinner && stderrTTY { t := styles.DefaultStyles() @@ -337,7 +351,7 @@ func (app *App) UpdateAgentModel(ctx context.Context) error { // If largeModel is provided but smallModel is not, the small model defaults to // the provider's default small model. func (app *App) overrideModelsForNonInteractive(ctx context.Context, largeModel, smallModel string) error { - providers := app.config.Providers.Copy() + providers := app.config.Config().Providers.Copy() largeMatches, smallMatches, err := findModels(providers, largeModel, smallModel) if err != nil { @@ -354,7 +368,7 @@ func (app *App) overrideModelsForNonInteractive(ctx context.Context, largeModel, } largeProviderID = found.provider slog.Info("Overriding large model for non-interactive run", "provider", found.provider, "model", found.modelID) - app.config.Models[config.SelectedModelTypeLarge] = config.SelectedModel{ + app.config.Config().Models[config.SelectedModelTypeLarge] = config.SelectedModel{ Provider: found.provider, Model: found.modelID, } @@ -368,7 +382,7 @@ func (app *App) overrideModelsForNonInteractive(ctx context.Context, largeModel, return err } slog.Info("Overriding small model for non-interactive run", "provider", found.provider, "model", found.modelID) - app.config.Models[config.SelectedModelTypeSmall] = config.SelectedModel{ + app.config.Config().Models[config.SelectedModelTypeSmall] = config.SelectedModel{ Provider: found.provider, Model: found.modelID, } @@ -376,7 +390,7 @@ func (app *App) overrideModelsForNonInteractive(ctx context.Context, largeModel, case largeModel != "": // No small model specified, but large model was - use provider's default. smallCfg := app.GetDefaultSmallModel(largeProviderID) - app.config.Models[config.SelectedModelTypeSmall] = smallCfg + app.config.Config().Models[config.SelectedModelTypeSmall] = smallCfg } return app.AgentCoordinator.UpdateModels(ctx) @@ -385,7 +399,7 @@ func (app *App) overrideModelsForNonInteractive(ctx context.Context, largeModel, // GetDefaultSmallModel returns the default small model for the given // provider. Falls back to the large model if no default is found. func (app *App) GetDefaultSmallModel(providerID string) config.SelectedModel { - cfg := app.config + cfg := app.config.Config() largeModelCfg := cfg.Models[config.SelectedModelTypeLarge] // Find the provider in the known providers list to get its default small model. @@ -428,6 +442,7 @@ func (app *App) setupEvents() { setupSubscriber(ctx, app.serviceEventsWG, "permissions", app.Permissions.Subscribe, app.events) setupSubscriber(ctx, app.serviceEventsWG, "permissions-notifications", app.Permissions.SubscribeNotifications, app.events) setupSubscriber(ctx, app.serviceEventsWG, "history", app.History.Subscribe, app.events) + setupSubscriber(ctx, app.serviceEventsWG, "agent-notifications", app.agentNotifications.Subscribe, app.events) setupSubscriber(ctx, app.serviceEventsWG, "mcp", mcp.SubscribeEvents, app.events) setupSubscriber(ctx, app.serviceEventsWG, "lsp", SubscribeLSPEvents, app.events) cleanupFunc := func(context.Context) error { @@ -486,7 +501,7 @@ func setupSubscriber[T any]( } func (app *App) InitCoderAgent(ctx context.Context) error { - coderAgentCfg := app.config.Agents[config.AgentCoder] + coderAgentCfg := app.config.Config().Agents[config.AgentCoder] if coderAgentCfg.ID == "" { return fmt.Errorf("coder agent configuration is missing") } @@ -500,6 +515,7 @@ func (app *App) InitCoderAgent(ctx context.Context) error { app.History, app.FileTracker, app.LSPManager, + app.agentNotifications, ) if err != nil { slog.Error("Failed to create coder agent", "err", err) diff --git a/internal/backend/backend.go b/internal/backend/backend.go index ee7f03cc7046c180d80ea6d5ac4478c6c1d32db8..d4d9dfdc8be730b351fffd88593573ceab174bbe 100644 --- a/internal/backend/backend.go +++ b/internal/backend/backend.go @@ -38,7 +38,7 @@ type ShutdownFunc func() // server. It manages workspaces and delegates to [app.App] services. type Backend struct { workspaces *csync.Map[string, *Workspace] - cfg *config.Config + cfg *config.ConfigStore ctx context.Context shutdownFn ShutdownFunc } @@ -49,12 +49,12 @@ type Workspace struct { *app.App ID string Path string - Cfg *config.Config + Cfg *config.ConfigStore Env []string } // New creates a new [Backend]. -func New(ctx context.Context, cfg *config.Config, shutdownFn ShutdownFunc) *Backend { +func New(ctx context.Context, cfg *config.ConfigStore, shutdownFn ShutdownFunc) *Backend { return &Backend{ workspaces: csync.NewMap[string, *Workspace](), cfg: cfg, @@ -95,16 +95,16 @@ func (b *Backend) CreateWorkspace(args proto.Workspace) (*Workspace, proto.Works return nil, proto.Workspace{}, fmt.Errorf("failed to initialize config: %w", err) } - if cfg.Permissions == nil { - cfg.Permissions = &config.Permissions{} + if cfg.Config().Permissions == nil { + cfg.Config().Permissions = &config.Permissions{} } - cfg.Permissions.SkipRequests = args.YOLO + cfg.Config().Permissions.SkipRequests = args.YOLO - if err := createDotCrushDir(cfg.Options.DataDirectory); err != nil { + 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.Options.DataDirectory) + 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) } @@ -138,10 +138,10 @@ func (b *Backend) CreateWorkspace(args proto.Workspace) (*Workspace, proto.Works result := proto.Workspace{ ID: id, Path: args.Path, - DataDir: cfg.Options.DataDirectory, - Debug: cfg.Options.Debug, - YOLO: cfg.Permissions.SkipRequests, - Config: cfg, + DataDir: cfg.Config().Options.DataDirectory, + Debug: cfg.Config().Options.Debug, + YOLO: cfg.Config().Permissions.SkipRequests, + Config: cfg.Config(), Env: args.Env, } @@ -183,7 +183,7 @@ func (b *Backend) VersionInfo() proto.VersionInfo { } // Config returns the server-level configuration. -func (b *Backend) Config() *config.Config { +func (b *Backend) Config() *config.ConfigStore { return b.cfg } @@ -195,12 +195,13 @@ func (b *Backend) Shutdown() { } func workspaceToProto(ws *Workspace) proto.Workspace { + cfg := ws.Cfg.Config() return proto.Workspace{ ID: ws.ID, Path: ws.Path, - YOLO: ws.Cfg.Permissions != nil && ws.Cfg.Permissions.SkipRequests, - DataDir: ws.Cfg.Options.DataDirectory, - Debug: ws.Cfg.Options.Debug, - Config: ws.Cfg, + YOLO: cfg.Permissions != nil && cfg.Permissions.SkipRequests, + DataDir: cfg.Options.DataDirectory, + Debug: cfg.Options.Debug, + Config: cfg, } } diff --git a/internal/backend/events.go b/internal/backend/events.go index bb104a18d0c220f7ceb3ef8ca96c8d65227409e3..06a63d0c7bba1e411469349341ce6b85dc628ed2 100644 --- a/internal/backend/events.go +++ b/internal/backend/events.go @@ -51,7 +51,7 @@ func (b *Backend) GetWorkspaceConfig(workspaceID string) (*config.Config, error) return nil, err } - return ws.Cfg, nil + return ws.Cfg.Config(), nil } // GetWorkspaceProviders returns the configured providers for a @@ -62,6 +62,6 @@ func (b *Backend) GetWorkspaceProviders(workspaceID string) (any, error) { return nil, err } - providers, _ := config.Providers(ws.Cfg) + providers, _ := config.Providers(ws.Cfg.Config()) return providers, nil } diff --git a/internal/cmd/login.go b/internal/cmd/login.go index bdad4547d6f583b5ae7e5a97bbbbd88a1421e6ee..c9acb12df19875f48b242bee96e377bf5548aacb 100644 --- a/internal/cmd/login.go +++ b/internal/cmd/login.go @@ -52,16 +52,16 @@ crush login copilot } switch provider { case "hyper": - return loginHyper(app.Config()) + return loginHyper(app.Store()) case "copilot", "github", "github-copilot": - return loginCopilot(app.Config()) + return loginCopilot(app.Store()) default: return fmt.Errorf("unknown platform: %s", args[0]) } }, } -func loginHyper(cfg *config.Config) error { +func loginHyper(cfg *config.ConfigStore) error { if !hyperp.Enabled() { return fmt.Errorf("hyper not enabled") } @@ -112,8 +112,8 @@ func loginHyper(cfg *config.Config) error { } if err := cmp.Or( - cfg.SetConfigField("providers.hyper.api_key", token.AccessToken), - cfg.SetConfigField("providers.hyper.oauth", token), + cfg.SetConfigField(config.ScopeGlobal, "providers.hyper.api_key", token.AccessToken), + cfg.SetConfigField(config.ScopeGlobal, "providers.hyper.oauth", token), ); err != nil { return err } @@ -123,10 +123,10 @@ func loginHyper(cfg *config.Config) error { return nil } -func loginCopilot(cfg *config.Config) error { +func loginCopilot(cfg *config.ConfigStore) error { ctx := getLoginContext() - if cfg.HasConfigField("providers.copilot.oauth") { + if cfg.HasConfigField(config.ScopeGlobal, "providers.copilot.oauth") { fmt.Println("You are already logged in to GitHub Copilot.") return nil } @@ -177,8 +177,8 @@ func loginCopilot(cfg *config.Config) error { } if err := cmp.Or( - cfg.SetConfigField("providers.copilot.api_key", token.AccessToken), - cfg.SetConfigField("providers.copilot.oauth", token), + cfg.SetConfigField(config.ScopeGlobal, "providers.copilot.api_key", token.AccessToken), + cfg.SetConfigField(config.ScopeGlobal, "providers.copilot.oauth", token), ); err != nil { return err } diff --git a/internal/cmd/logs.go b/internal/cmd/logs.go index 804b23310fa1e3fb86e4b32983bfcdd571df47aa..87e106feb7cc934567b183d454ef1537970dca88 100644 --- a/internal/cmd/logs.go +++ b/internal/cmd/logs.go @@ -55,7 +55,7 @@ var logsCmd = &cobra.Command{ if err != nil { return fmt.Errorf("failed to load configuration: %v", err) } - logsFile := filepath.Join(cfg.Options.DataDirectory, "logs", "crush.log") + logsFile := filepath.Join(cfg.Config().Options.DataDirectory, "logs", "crush.log") _, err = os.Stat(logsFile) if os.IsNotExist(err) { log.Warn("Looks like you are not in a crush project. No logs found.") diff --git a/internal/cmd/models.go b/internal/cmd/models.go index e2aa5c991d5cf49ba78dbff9d3f79c4f6493523d..f4fa559ebe41d93bee54ed5e2272f8fb0b8dc9ad 100644 --- a/internal/cmd/models.go +++ b/internal/cmd/models.go @@ -38,7 +38,7 @@ crush models gpt5`, return err } - if !cfg.IsConfigured() { + if !cfg.Config().IsConfigured() { return fmt.Errorf("no providers configured - please run 'crush' to set up a provider interactively") } @@ -55,7 +55,7 @@ crush models gpt5`, var providerIDs []string providerModels := make(map[string][]string) - for providerID, provider := range cfg.Providers.Seq2() { + for providerID, provider := range cfg.Config().Providers.Seq2() { if provider.Disable { continue } diff --git a/internal/cmd/root.go b/internal/cmd/root.go index bd71dba1f2a1e327978f41bf71588812f13bd34c..7cb101c4e035558615288e9e446d4ddecd4ab788 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -235,11 +235,12 @@ func setupApp(cmd *cobra.Command) (*app.App, error) { return nil, err } - cfg, err := config.Init(cwd, dataDir, debug) + store, err := config.Init(cwd, dataDir, debug) if err != nil { return nil, err } + cfg := store.Config() if cfg.Permissions == nil { cfg.Permissions = &config.Permissions{} } @@ -261,7 +262,7 @@ func setupApp(cmd *cobra.Command) (*app.App, error) { return nil, err } - appInstance, err := app.New(ctx, conn, cfg) + appInstance, err := app.New(ctx, conn, store) if err != nil { slog.Error("Failed to create app instance", "error", err) return nil, err diff --git a/internal/cmd/stats.go b/internal/cmd/stats.go index 8831c2a647a283bfe6d6edff15c5eff4dafb3377..3900acadec059869b1896c8adeb49f93155f17fa 100644 --- a/internal/cmd/stats.go +++ b/internal/cmd/stats.go @@ -131,7 +131,7 @@ func runStats(cmd *cobra.Command, _ []string) error { if err != nil { return fmt.Errorf("failed to initialize config: %w", err) } - dataDir = cfg.Options.DataDirectory + dataDir = cfg.Config().Options.DataDirectory } conn, err := db.Connect(ctx, dataDir) diff --git a/internal/commands/commands.go b/internal/commands/commands.go index aeb2ca305dc984c2c450d249d51028858e4e9802..96302bde1281adebfe74a009e4f76443f5368afe 100644 --- a/internal/commands/commands.go +++ b/internal/commands/commands.go @@ -227,7 +227,7 @@ func isMarkdownFile(name string) bool { return strings.HasSuffix(strings.ToLower(name), ".md") } -func GetMCPPrompt(cfg *config.Config, clientID, promptID string, args map[string]string) (string, error) { +func GetMCPPrompt(cfg *config.ConfigStore, clientID, promptID string, args map[string]string) (string, error) { // TODO: we should pass the context down result, err := mcp.GetPromptMessages(context.Background(), cfg, clientID, promptID, args) if err != nil { diff --git a/internal/config/config.go b/internal/config/config.go index c4ef08760ca329d5d0b5644985552e6013d9edd2..8e9b3f0fb7349f4b911c9a6c41fc3e3890f3f19e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -8,22 +8,16 @@ import ( "maps" "net/http" "net/url" - "os" - "path/filepath" "slices" "strings" "time" "charm.land/catwalk/pkg/catwalk" - hyperp "github.com/charmbracelet/crush/internal/agent/hyper" "github.com/charmbracelet/crush/internal/csync" "github.com/charmbracelet/crush/internal/env" "github.com/charmbracelet/crush/internal/oauth" "github.com/charmbracelet/crush/internal/oauth/copilot" - "github.com/charmbracelet/crush/internal/oauth/hyper" "github.com/invopop/jsonschema" - "github.com/tidwall/gjson" - "github.com/tidwall/sjson" ) const ( @@ -261,6 +255,7 @@ type Options struct { InitializeAs string `json:"initialize_as,omitempty" jsonschema:"description=Name of the context file to create/update during project initialization,default=AGENTS.md,example=AGENTS.md,example=CRUSH.md,example=CLAUDE.md,example=docs/LLMs.md"` AutoLSP *bool `json:"auto_lsp,omitempty" jsonschema:"description=Automatically setup LSPs based on root markers,default=true"` Progress *bool `json:"progress,omitempty" jsonschema:"description=Show indeterminate progress updates during long operations,default=true"` + DisableNotifications bool `json:"disable_notifications,omitempty" jsonschema:"description=Disable desktop notifications,default=false"` } type MCPs map[string]MCPConfig @@ -397,17 +392,6 @@ type Config struct { Tools Tools `json:"tools,omitzero" jsonschema:"description=Tool configurations"` Agents map[string]Agent `json:"-"` - - // Internal - workingDir string `json:"-"` - // TODO: find a better way to do this this should probably not be part of the config - resolver VariableResolver - dataConfigDir string `json:"-"` - knownProviders []catwalk.Provider `json:"-"` -} - -func (c *Config) WorkingDir() string { - return c.workingDir } func (c *Config) EnabledProviders() []ProviderConfig { @@ -471,235 +455,8 @@ func (c *Config) SmallModel() *catwalk.Model { return c.GetModel(model.Provider, model.Model) } -func (c *Config) SetCompactMode(enabled bool) error { - if c.Options == nil { - c.Options = &Options{} - } - c.Options.TUI.CompactMode = enabled - return c.SetConfigField("options.tui.compact_mode", enabled) -} - -func (c *Config) Resolve(key string) (string, error) { - if c.resolver == nil { - return "", fmt.Errorf("no variable resolver configured") - } - return c.resolver.ResolveValue(key) -} - -func (c *Config) UpdatePreferredModel(modelType SelectedModelType, model SelectedModel) error { - c.Models[modelType] = model - if err := c.SetConfigField(fmt.Sprintf("models.%s", modelType), model); err != nil { - return fmt.Errorf("failed to update preferred model: %w", err) - } - if err := c.recordRecentModel(modelType, model); err != nil { - return err - } - return nil -} - -func (c *Config) HasConfigField(key string) bool { - data, err := os.ReadFile(c.dataConfigDir) - if err != nil { - return false - } - return gjson.Get(string(data), key).Exists() -} - -func (c *Config) SetConfigField(key string, value any) error { - data, err := os.ReadFile(c.dataConfigDir) - if err != nil { - if os.IsNotExist(err) { - data = []byte("{}") - } else { - return fmt.Errorf("failed to read config file: %w", err) - } - } - - newValue, err := sjson.Set(string(data), key, value) - if err != nil { - return fmt.Errorf("failed to set config field %s: %w", key, err) - } - if err := os.MkdirAll(filepath.Dir(c.dataConfigDir), 0o755); err != nil { - return fmt.Errorf("failed to create config directory %q: %w", c.dataConfigDir, err) - } - if err := os.WriteFile(c.dataConfigDir, []byte(newValue), 0o600); err != nil { - return fmt.Errorf("failed to write config file: %w", err) - } - return nil -} - -func (c *Config) RemoveConfigField(key string) error { - data, err := os.ReadFile(c.dataConfigDir) - if err != nil { - return fmt.Errorf("failed to read config file: %w", err) - } - - newValue, err := sjson.Delete(string(data), key) - if err != nil { - return fmt.Errorf("failed to delete config field %s: %w", key, err) - } - if err := os.MkdirAll(filepath.Dir(c.dataConfigDir), 0o755); err != nil { - return fmt.Errorf("failed to create config directory %q: %w", c.dataConfigDir, err) - } - if err := os.WriteFile(c.dataConfigDir, []byte(newValue), 0o600); err != nil { - return fmt.Errorf("failed to write config file: %w", err) - } - return nil -} - -// RefreshOAuthToken refreshes the OAuth token for the given provider. -func (c *Config) RefreshOAuthToken(ctx context.Context, providerID string) error { - providerConfig, exists := c.Providers.Get(providerID) - if !exists { - return fmt.Errorf("provider %s not found", providerID) - } - - if providerConfig.OAuthToken == nil { - return fmt.Errorf("provider %s does not have an OAuth token", providerID) - } - - var newToken *oauth.Token - var refreshErr error - switch providerID { - case string(catwalk.InferenceProviderCopilot): - newToken, refreshErr = copilot.RefreshToken(ctx, providerConfig.OAuthToken.RefreshToken) - case hyperp.Name: - newToken, refreshErr = hyper.ExchangeToken(ctx, providerConfig.OAuthToken.RefreshToken) - default: - return fmt.Errorf("OAuth refresh not supported for provider %s", providerID) - } - if refreshErr != nil { - return fmt.Errorf("failed to refresh OAuth token for provider %s: %w", providerID, refreshErr) - } - - slog.Info("Successfully refreshed OAuth token", "provider", providerID) - providerConfig.OAuthToken = newToken - providerConfig.APIKey = newToken.AccessToken - - switch providerID { - case string(catwalk.InferenceProviderCopilot): - providerConfig.SetupGitHubCopilot() - } - - c.Providers.Set(providerID, providerConfig) - - if err := cmp.Or( - c.SetConfigField(fmt.Sprintf("providers.%s.api_key", providerID), newToken.AccessToken), - c.SetConfigField(fmt.Sprintf("providers.%s.oauth", providerID), newToken), - ); err != nil { - return fmt.Errorf("failed to persist refreshed token: %w", err) - } - - return nil -} - -func (c *Config) SetProviderAPIKey(providerID string, apiKey any) error { - var providerConfig ProviderConfig - var exists bool - var setKeyOrToken func() - - switch v := apiKey.(type) { - case string: - if err := c.SetConfigField(fmt.Sprintf("providers.%s.api_key", providerID), v); err != nil { - return fmt.Errorf("failed to save api key to config file: %w", err) - } - setKeyOrToken = func() { providerConfig.APIKey = v } - case *oauth.Token: - if err := cmp.Or( - c.SetConfigField(fmt.Sprintf("providers.%s.api_key", providerID), v.AccessToken), - c.SetConfigField(fmt.Sprintf("providers.%s.oauth", providerID), v), - ); err != nil { - return err - } - setKeyOrToken = func() { - providerConfig.APIKey = v.AccessToken - providerConfig.OAuthToken = v - switch providerID { - case string(catwalk.InferenceProviderCopilot): - providerConfig.SetupGitHubCopilot() - } - } - } - - providerConfig, exists = c.Providers.Get(providerID) - if exists { - setKeyOrToken() - c.Providers.Set(providerID, providerConfig) - return nil - } - - var foundProvider *catwalk.Provider - for _, p := range c.knownProviders { - if string(p.ID) == providerID { - foundProvider = &p - break - } - } - - if foundProvider != nil { - // Create new provider config based on known provider - providerConfig = ProviderConfig{ - ID: providerID, - Name: foundProvider.Name, - BaseURL: foundProvider.APIEndpoint, - Type: foundProvider.Type, - Disable: false, - ExtraHeaders: make(map[string]string), - ExtraParams: make(map[string]string), - Models: foundProvider.Models, - } - setKeyOrToken() - } else { - return fmt.Errorf("provider with ID %s not found in known providers", providerID) - } - // Store the updated provider config - c.Providers.Set(providerID, providerConfig) - return nil -} - const maxRecentModelsPerType = 5 -func (c *Config) recordRecentModel(modelType SelectedModelType, model SelectedModel) error { - if model.Provider == "" || model.Model == "" { - return nil - } - - if c.RecentModels == nil { - c.RecentModels = make(map[SelectedModelType][]SelectedModel) - } - - eq := func(a, b SelectedModel) bool { - return a.Provider == b.Provider && a.Model == b.Model - } - - entry := SelectedModel{ - Provider: model.Provider, - Model: model.Model, - } - - current := c.RecentModels[modelType] - withoutCurrent := slices.DeleteFunc(slices.Clone(current), func(existing SelectedModel) bool { - return eq(existing, entry) - }) - - updated := append([]SelectedModel{entry}, withoutCurrent...) - if len(updated) > maxRecentModelsPerType { - updated = updated[:maxRecentModelsPerType] - } - - if slices.EqualFunc(current, updated, eq) { - return nil - } - - c.RecentModels[modelType] = updated - - if err := c.SetConfigField(fmt.Sprintf("recent_models.%s", modelType), updated); err != nil { - return fmt.Errorf("failed to persist recent models: %w", err) - } - - return nil -} - func allToolNames() []string { return []string{ "agent", @@ -779,10 +536,6 @@ func (c *Config) SetupAgents() { c.Agents = agents } -func (c *Config) Resolver() VariableResolver { - return c.resolver -} - func (c *ProviderConfig) TestConnection(resolver VariableResolver) error { var ( providerID = catwalk.InferenceProvider(c.ID) diff --git a/internal/config/copilot.go b/internal/config/copilot.go index d72e7d5048ba4d31c88d7f7152a6b3a9510960a2..d912156bec00a9f00850ab2ec3a3baf1016c2141 100644 --- a/internal/config/copilot.go +++ b/internal/config/copilot.go @@ -1,48 +1 @@ package config - -import ( - "cmp" - "context" - "log/slog" - "testing" - - "charm.land/catwalk/pkg/catwalk" - "github.com/charmbracelet/crush/internal/oauth" - "github.com/charmbracelet/crush/internal/oauth/copilot" -) - -func (c *Config) ImportCopilot() (*oauth.Token, bool) { - if testing.Testing() { - return nil, false - } - - if c.HasConfigField("providers.copilot.api_key") || c.HasConfigField("providers.copilot.oauth") { - return nil, false - } - - diskToken, hasDiskToken := copilot.RefreshTokenFromDisk() - if !hasDiskToken { - return nil, false - } - - slog.Info("Found existing GitHub Copilot token on disk. Authenticating...") - token, err := copilot.RefreshToken(context.TODO(), diskToken) - if err != nil { - slog.Error("Unable to import GitHub Copilot token", "error", err) - return nil, false - } - - if err := c.SetProviderAPIKey(string(catwalk.InferenceProviderCopilot), token); err != nil { - return token, false - } - - if err := cmp.Or( - c.SetConfigField("providers.copilot.api_key", token.AccessToken), - c.SetConfigField("providers.copilot.oauth", token), - ); err != nil { - slog.Error("Unable to save GitHub Copilot token to disk", "error", err) - } - - slog.Info("GitHub Copilot successfully imported") - return token, true -} diff --git a/internal/config/init.go b/internal/config/init.go index 5a4683f77485f54409d4372a33d1933b47abd33f..6138c49d496b16d054ea9ff3f6c49906b3f433ca 100644 --- a/internal/config/init.go +++ b/internal/config/init.go @@ -18,19 +18,20 @@ type ProjectInitFlag struct { Initialized bool `json:"initialized"` } -func Init(workingDir, dataDir string, debug bool) (*Config, error) { - cfg, err := Load(workingDir, dataDir, debug) +func Init(workingDir, dataDir string, debug bool) (*ConfigStore, error) { + store, err := Load(workingDir, dataDir, debug) if err != nil { return nil, err } - return cfg, nil + return store, nil } -func ProjectNeedsInitialization(cfg *Config) (bool, error) { - if cfg == nil { +func ProjectNeedsInitialization(store *ConfigStore) (bool, error) { + if store == nil { return false, fmt.Errorf("config not loaded") } + cfg := store.Config() flagFilePath := filepath.Join(cfg.Options.DataDirectory, InitFlagFilename) _, err := os.Stat(flagFilePath) @@ -42,7 +43,7 @@ func ProjectNeedsInitialization(cfg *Config) (bool, error) { return false, fmt.Errorf("failed to check init flag file: %w", err) } - someContextFileExists, err := contextPathsExist(cfg.WorkingDir()) + someContextFileExists, err := contextPathsExist(store.WorkingDir()) if err != nil { return false, fmt.Errorf("failed to check for context files: %w", err) } @@ -51,7 +52,7 @@ func ProjectNeedsInitialization(cfg *Config) (bool, error) { } // If the working directory has no non-ignored files, skip initialization step - empty, err := dirHasNoVisibleFiles(cfg.WorkingDir()) + empty, err := dirHasNoVisibleFiles(store.WorkingDir()) if err != nil { return false, fmt.Errorf("failed to check if directory is empty: %w", err) } @@ -90,7 +91,7 @@ func contextPathsExist(dir string) (bool, error) { return false, nil } -// dirHasNoVisibleFiles returns true if the directory has no files/dirs after applying ignore rules +// dirHasNoVisibleFiles returns true if the directory has no files/dirs after applying ignore rules. func dirHasNoVisibleFiles(dir string) (bool, error) { files, _, err := fsext.ListDirectory(dir, nil, 1, 1) if err != nil { @@ -99,11 +100,11 @@ func dirHasNoVisibleFiles(dir string) (bool, error) { return len(files) == 0, nil } -func MarkProjectInitialized(cfg *Config) error { - if cfg == nil { +func MarkProjectInitialized(store *ConfigStore) error { + if store == nil { return fmt.Errorf("config not loaded") } - flagFilePath := filepath.Join(cfg.Options.DataDirectory, InitFlagFilename) + flagFilePath := filepath.Join(store.Config().Options.DataDirectory, InitFlagFilename) file, err := os.Create(flagFilePath) if err != nil { @@ -114,13 +115,13 @@ func MarkProjectInitialized(cfg *Config) error { return nil } -func HasInitialDataConfig(cfg *Config) bool { - if cfg == nil { +func HasInitialDataConfig(store *ConfigStore) bool { + if store == nil { return false } cfgPath := GlobalConfigData() if _, err := os.Stat(cfgPath); err != nil { return false } - return cfg.IsConfigured() + return store.Config().IsConfigured() } diff --git a/internal/config/load.go b/internal/config/load.go index 7bbc6b983439df387cfbc3430debf6b3a8f39bf5..4c42fc232fbeaaa6f4d40ba597ee7299f17111df 100644 --- a/internal/config/load.go +++ b/internal/config/load.go @@ -29,8 +29,9 @@ import ( const defaultCatwalkURL = "https://catwalk.charm.sh" -// Load loads the configuration from the default paths. -func Load(workingDir, dataDir string, debug bool) (*Config, error) { +// Load loads the configuration from the default paths and returns a +// ConfigStore that owns both the pure-data Config and all runtime state. +func Load(workingDir, dataDir string, debug bool) (*ConfigStore, error) { configPaths := lookupConfigs(workingDir) cfg, err := loadFromConfigPaths(configPaths) @@ -38,10 +39,15 @@ func Load(workingDir, dataDir string, debug bool) (*Config, error) { return nil, fmt.Errorf("failed to load config from paths %v: %w", configPaths, err) } - cfg.dataConfigDir = GlobalConfigData() - cfg.setDefaults(workingDir, dataDir) + store := &ConfigStore{ + config: cfg, + workingDir: workingDir, + globalDataPath: GlobalConfigData(), + workspacePath: filepath.Join(cfg.Options.DataDirectory, fmt.Sprintf("%s.json", appName)), + } + if debug { cfg.Options.Debug = true } @@ -52,6 +58,18 @@ func Load(workingDir, dataDir string, debug bool) (*Config, error) { 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)) + if mergeErr == nil { + // Preserve defaults that setDefaults already applied. + dataDir := cfg.Options.DataDirectory + *cfg = *merged + cfg.setDefaults(workingDir, dataDir) + store.config = cfg + } + } + if !isInsideWorktree() { const depth = 2 const items = 100 @@ -72,26 +90,36 @@ func Load(workingDir, dataDir string, debug bool) (*Config, error) { if err != nil { return nil, err } - cfg.knownProviders = providers + store.knownProviders = providers env := env.New() // Configure providers valueResolver := NewShellVariableResolver(env) - cfg.resolver = valueResolver - if err := cfg.configureProviders(env, valueResolver, cfg.knownProviders); err != nil { + store.resolver = valueResolver + if err := cfg.configureProviders(store, env, valueResolver, store.knownProviders); err != nil { return nil, fmt.Errorf("failed to configure providers: %w", err) } if !cfg.IsConfigured() { slog.Warn("No providers configured") - return cfg, nil + return store, nil } - if err := cfg.configureSelectedModels(cfg.knownProviders); err != nil { + if err := configureSelectedModels(store, store.knownProviders); err != nil { return nil, fmt.Errorf("failed to configure selected models: %w", err) } - cfg.SetupAgents() - return cfg, nil + store.SetupAgents() + return store, nil +} + +// mustMarshalConfig marshals the config to JSON bytes, returning empty JSON on +// error. +func mustMarshalConfig(cfg *Config) []byte { + data, err := json.Marshal(cfg) + if err != nil { + return []byte("{}") + } + return data } func PushPopCrushEnv() func() { @@ -122,7 +150,7 @@ func PushPopCrushEnv() func() { return restore } -func (c *Config) configureProviders(env env.Env, resolver VariableResolver, knownProviders []catwalk.Provider) error { +func (c *Config) configureProviders(store *ConfigStore, env env.Env, resolver VariableResolver, knownProviders []catwalk.Provider) error { knownProviderNames := make(map[string]bool) restore := PushPopCrushEnv() defer restore() @@ -209,7 +237,7 @@ func (c *Config) configureProviders(env env.Env, resolver VariableResolver, know switch { case p.ID == catwalk.InferenceProviderAnthropic && config.OAuthToken != nil: // Claude Code subscription is not supported anymore. Remove to show onboarding. - c.RemoveConfigField("providers.anthropic") + store.RemoveConfigField(ScopeGlobal, "providers.anthropic") c.Providers.Del(string(p.ID)) continue case p.ID == catwalk.InferenceProviderCopilot && config.OAuthToken != nil: @@ -340,7 +368,6 @@ func (c *Config) configureProviders(env env.Env, resolver VariableResolver, know } func (c *Config) setDefaults(workingDir, dataDir string) { - c.workingDir = workingDir if c.Options == nil { c.Options = &Options{} } @@ -524,7 +551,8 @@ func (c *Config) defaultModelSelection(knownProviders []catwalk.Provider) (large return largeModel, smallModel, err } -func (c *Config) configureSelectedModels(knownProviders []catwalk.Provider) error { +func configureSelectedModels(store *ConfigStore, knownProviders []catwalk.Provider) error { + c := store.config defaultLarge, defaultSmall, err := c.defaultModelSelection(knownProviders) if err != nil { return fmt.Errorf("failed to select default models: %w", err) @@ -543,7 +571,7 @@ func (c *Config) configureSelectedModels(knownProviders []catwalk.Provider) erro if model == nil { large = defaultLarge // override the model type to large - err := c.UpdatePreferredModel(SelectedModelTypeLarge, large) + err := store.UpdatePreferredModel(ScopeGlobal, SelectedModelTypeLarge, large) if err != nil { return fmt.Errorf("failed to update preferred large model: %w", err) } @@ -587,7 +615,7 @@ func (c *Config) configureSelectedModels(knownProviders []catwalk.Provider) erro if model == nil { small = defaultSmall // override the model type to small - err := c.UpdatePreferredModel(SelectedModelTypeSmall, small) + err := store.UpdatePreferredModel(ScopeGlobal, SelectedModelTypeSmall, small) if err != nil { return fmt.Errorf("failed to update preferred small model: %w", err) } diff --git a/internal/config/load_test.go b/internal/config/load_test.go index 93d2245193463e2a6539e23aeb0e16ac14c0ccef..62b1eaa2437116b0a051fe10183689650d388472 100644 --- a/internal/config/load_test.go +++ b/internal/config/load_test.go @@ -36,6 +36,11 @@ func TestConfig_LoadFromBytes(t *testing.T) { require.Equal(t, "https://api.openai.com/v2", pc.BaseURL) } +// testStore wraps a Config in a minimal ConfigStore for testing. +func testStore(cfg *Config) *ConfigStore { + return &ConfigStore{config: cfg} +} + func TestConfig_setDefaults(t *testing.T) { cfg := &Config{} @@ -53,7 +58,6 @@ func TestConfig_setDefaults(t *testing.T) { for _, path := range defaultContextPaths { require.Contains(t, cfg.Options.ContextPaths, path) } - require.Equal(t, "/tmp", cfg.workingDir) } func TestConfig_configureProviders(t *testing.T) { @@ -74,7 +78,7 @@ func TestConfig_configureProviders(t *testing.T) { "OPENAI_API_KEY": "test-key", }) resolver := NewEnvironmentVariableResolver(env) - err := cfg.configureProviders(env, resolver, knownProviders) + err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders) require.NoError(t, err) require.Equal(t, 1, cfg.Providers.Len()) @@ -117,7 +121,7 @@ func TestConfig_configureProvidersWithOverride(t *testing.T) { "OPENAI_API_KEY": "test-key", }) resolver := NewEnvironmentVariableResolver(env) - err := cfg.configureProviders(env, resolver, knownProviders) + err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders) require.NoError(t, err) require.Equal(t, 1, cfg.Providers.Len()) @@ -159,7 +163,7 @@ func TestConfig_configureProvidersWithNewProvider(t *testing.T) { "OPENAI_API_KEY": "test-key", }) resolver := NewEnvironmentVariableResolver(env) - err := cfg.configureProviders(env, resolver, knownProviders) + err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders) require.NoError(t, err) // Should be to because of the env variable require.Equal(t, cfg.Providers.Len(), 2) @@ -195,7 +199,7 @@ func TestConfig_configureProvidersBedrockWithCredentials(t *testing.T) { "AWS_SECRET_ACCESS_KEY": "test-secret-key", }) resolver := NewEnvironmentVariableResolver(env) - err := cfg.configureProviders(env, resolver, knownProviders) + err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders) require.NoError(t, err) require.Equal(t, cfg.Providers.Len(), 1) @@ -221,7 +225,7 @@ func TestConfig_configureProvidersBedrockWithoutCredentials(t *testing.T) { cfg.setDefaults("/tmp", "") env := env.NewFromMap(map[string]string{}) resolver := NewEnvironmentVariableResolver(env) - err := cfg.configureProviders(env, resolver, knownProviders) + err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders) require.NoError(t, err) // Provider should not be configured without credentials require.Equal(t, cfg.Providers.Len(), 0) @@ -246,7 +250,7 @@ func TestConfig_configureProvidersBedrockWithoutUnsupportedModel(t *testing.T) { "AWS_SECRET_ACCESS_KEY": "test-secret-key", }) resolver := NewEnvironmentVariableResolver(env) - err := cfg.configureProviders(env, resolver, knownProviders) + err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders) require.Error(t, err) } @@ -269,7 +273,7 @@ func TestConfig_configureProvidersVertexAIWithCredentials(t *testing.T) { "VERTEXAI_LOCATION": "us-central1", }) resolver := NewEnvironmentVariableResolver(env) - err := cfg.configureProviders(env, resolver, knownProviders) + err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders) require.NoError(t, err) require.Equal(t, cfg.Providers.Len(), 1) @@ -301,7 +305,7 @@ func TestConfig_configureProvidersVertexAIWithoutCredentials(t *testing.T) { "GOOGLE_CLOUD_LOCATION": "us-central1", }) resolver := NewEnvironmentVariableResolver(env) - err := cfg.configureProviders(env, resolver, knownProviders) + err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders) require.NoError(t, err) // Provider should not be configured without proper credentials require.Equal(t, cfg.Providers.Len(), 0) @@ -326,7 +330,7 @@ func TestConfig_configureProvidersVertexAIMissingProject(t *testing.T) { "GOOGLE_CLOUD_LOCATION": "us-central1", }) resolver := NewEnvironmentVariableResolver(env) - err := cfg.configureProviders(env, resolver, knownProviders) + err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders) require.NoError(t, err) // Provider should not be configured without project require.Equal(t, cfg.Providers.Len(), 0) @@ -350,7 +354,7 @@ func TestConfig_configureProvidersSetProviderID(t *testing.T) { "OPENAI_API_KEY": "test-key", }) resolver := NewEnvironmentVariableResolver(env) - err := cfg.configureProviders(env, resolver, knownProviders) + err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders) require.NoError(t, err) require.Equal(t, cfg.Providers.Len(), 1) @@ -541,7 +545,7 @@ func TestConfig_configureProvidersWithDisabledProvider(t *testing.T) { "OPENAI_API_KEY": "test-key", }) resolver := NewEnvironmentVariableResolver(env) - err := cfg.configureProviders(env, resolver, knownProviders) + err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders) require.NoError(t, err) require.Equal(t, cfg.Providers.Len(), 1) @@ -569,7 +573,7 @@ func TestConfig_configureProvidersCustomProviderValidation(t *testing.T) { env := env.NewFromMap(map[string]string{}) resolver := NewEnvironmentVariableResolver(env) - err := cfg.configureProviders(env, resolver, []catwalk.Provider{}) + err := cfg.configureProviders(testStore(cfg), env, resolver, []catwalk.Provider{}) require.NoError(t, err) require.Equal(t, cfg.Providers.Len(), 1) @@ -592,7 +596,7 @@ func TestConfig_configureProvidersCustomProviderValidation(t *testing.T) { env := env.NewFromMap(map[string]string{}) resolver := NewEnvironmentVariableResolver(env) - err := cfg.configureProviders(env, resolver, []catwalk.Provider{}) + err := cfg.configureProviders(testStore(cfg), env, resolver, []catwalk.Provider{}) require.NoError(t, err) require.Equal(t, cfg.Providers.Len(), 0) @@ -614,7 +618,7 @@ func TestConfig_configureProvidersCustomProviderValidation(t *testing.T) { env := env.NewFromMap(map[string]string{}) resolver := NewEnvironmentVariableResolver(env) - err := cfg.configureProviders(env, resolver, []catwalk.Provider{}) + err := cfg.configureProviders(testStore(cfg), env, resolver, []catwalk.Provider{}) require.NoError(t, err) require.Equal(t, cfg.Providers.Len(), 0) @@ -639,7 +643,7 @@ func TestConfig_configureProvidersCustomProviderValidation(t *testing.T) { env := env.NewFromMap(map[string]string{}) resolver := NewEnvironmentVariableResolver(env) - err := cfg.configureProviders(env, resolver, []catwalk.Provider{}) + err := cfg.configureProviders(testStore(cfg), env, resolver, []catwalk.Provider{}) require.NoError(t, err) require.Equal(t, cfg.Providers.Len(), 0) @@ -664,7 +668,7 @@ func TestConfig_configureProvidersCustomProviderValidation(t *testing.T) { env := env.NewFromMap(map[string]string{}) resolver := NewEnvironmentVariableResolver(env) - err := cfg.configureProviders(env, resolver, []catwalk.Provider{}) + err := cfg.configureProviders(testStore(cfg), env, resolver, []catwalk.Provider{}) require.NoError(t, err) require.Equal(t, cfg.Providers.Len(), 1) @@ -692,7 +696,7 @@ func TestConfig_configureProvidersCustomProviderValidation(t *testing.T) { env := env.NewFromMap(map[string]string{}) resolver := NewEnvironmentVariableResolver(env) - err := cfg.configureProviders(env, resolver, []catwalk.Provider{}) + err := cfg.configureProviders(testStore(cfg), env, resolver, []catwalk.Provider{}) require.NoError(t, err) require.Equal(t, cfg.Providers.Len(), 1) @@ -722,7 +726,7 @@ func TestConfig_configureProvidersCustomProviderValidation(t *testing.T) { env := env.NewFromMap(map[string]string{}) resolver := NewEnvironmentVariableResolver(env) - err := cfg.configureProviders(env, resolver, []catwalk.Provider{}) + err := cfg.configureProviders(testStore(cfg), env, resolver, []catwalk.Provider{}) require.NoError(t, err) require.Equal(t, cfg.Providers.Len(), 0) @@ -757,7 +761,7 @@ func TestConfig_configureProvidersEnhancedCredentialValidation(t *testing.T) { "GOOGLE_GENAI_USE_VERTEXAI": "false", }) resolver := NewEnvironmentVariableResolver(env) - err := cfg.configureProviders(env, resolver, knownProviders) + err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders) require.NoError(t, err) require.Equal(t, cfg.Providers.Len(), 0) @@ -788,7 +792,7 @@ func TestConfig_configureProvidersEnhancedCredentialValidation(t *testing.T) { env := env.NewFromMap(map[string]string{}) resolver := NewEnvironmentVariableResolver(env) - err := cfg.configureProviders(env, resolver, knownProviders) + err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders) require.NoError(t, err) require.Equal(t, cfg.Providers.Len(), 0) @@ -819,7 +823,7 @@ func TestConfig_configureProvidersEnhancedCredentialValidation(t *testing.T) { env := env.NewFromMap(map[string]string{}) resolver := NewEnvironmentVariableResolver(env) - err := cfg.configureProviders(env, resolver, knownProviders) + err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders) require.NoError(t, err) require.Equal(t, cfg.Providers.Len(), 0) @@ -852,7 +856,7 @@ func TestConfig_configureProvidersEnhancedCredentialValidation(t *testing.T) { "OPENAI_API_KEY": "test-key", }) resolver := NewEnvironmentVariableResolver(env) - err := cfg.configureProviders(env, resolver, knownProviders) + err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders) require.NoError(t, err) require.Equal(t, cfg.Providers.Len(), 1) @@ -886,7 +890,7 @@ func TestConfig_defaultModelSelection(t *testing.T) { cfg.setDefaults("/tmp", "") env := env.NewFromMap(map[string]string{}) resolver := NewEnvironmentVariableResolver(env) - err := cfg.configureProviders(env, resolver, knownProviders) + err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders) require.NoError(t, err) large, small, err := cfg.defaultModelSelection(knownProviders) @@ -922,7 +926,7 @@ func TestConfig_defaultModelSelection(t *testing.T) { cfg.setDefaults("/tmp", "") env := env.NewFromMap(map[string]string{}) resolver := NewEnvironmentVariableResolver(env) - err := cfg.configureProviders(env, resolver, knownProviders) + err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders) require.NoError(t, err) _, _, err = cfg.defaultModelSelection(knownProviders) @@ -952,7 +956,7 @@ func TestConfig_defaultModelSelection(t *testing.T) { cfg.setDefaults("/tmp", "") env := env.NewFromMap(map[string]string{}) resolver := NewEnvironmentVariableResolver(env) - err := cfg.configureProviders(env, resolver, knownProviders) + err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders) require.NoError(t, err) _, _, err = cfg.defaultModelSelection(knownProviders) require.Error(t, err) @@ -995,7 +999,7 @@ func TestConfig_defaultModelSelection(t *testing.T) { cfg.setDefaults("/tmp", "") env := env.NewFromMap(map[string]string{}) resolver := NewEnvironmentVariableResolver(env) - err := cfg.configureProviders(env, resolver, knownProviders) + err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders) require.NoError(t, err) large, small, err := cfg.defaultModelSelection(knownProviders) require.NoError(t, err) @@ -1039,7 +1043,7 @@ func TestConfig_defaultModelSelection(t *testing.T) { cfg.setDefaults("/tmp", "") env := env.NewFromMap(map[string]string{}) resolver := NewEnvironmentVariableResolver(env) - err := cfg.configureProviders(env, resolver, knownProviders) + err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders) require.NoError(t, err) _, _, err = cfg.defaultModelSelection(knownProviders) require.Error(t, err) @@ -1081,7 +1085,7 @@ func TestConfig_defaultModelSelection(t *testing.T) { cfg.setDefaults("/tmp", "") env := env.NewFromMap(map[string]string{}) resolver := NewEnvironmentVariableResolver(env) - err := cfg.configureProviders(env, resolver, knownProviders) + err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders) require.NoError(t, err) large, small, err := cfg.defaultModelSelection(knownProviders) require.NoError(t, err) @@ -1126,7 +1130,7 @@ func TestConfig_configureProvidersDisableDefaultProviders(t *testing.T) { "OPENAI_API_KEY": "test-key", }) resolver := NewEnvironmentVariableResolver(env) - err := cfg.configureProviders(env, resolver, knownProviders) + err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders) require.ErrorContains(t, err, "no custom providers") // openai should NOT be present because it lacks base_url and models. @@ -1169,7 +1173,7 @@ func TestConfig_configureProvidersDisableDefaultProviders(t *testing.T) { "OPENAI_API_KEY": "test-key", }) resolver := NewEnvironmentVariableResolver(env) - err := cfg.configureProviders(env, resolver, knownProviders) + err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders) require.NoError(t, err) // Only fully specified provider should be present. @@ -1223,7 +1227,7 @@ func TestConfig_configureProvidersDisableDefaultProviders(t *testing.T) { "ANTHROPIC_API_KEY": "test-key", }) resolver := NewEnvironmentVariableResolver(env) - err := cfg.configureProviders(env, resolver, knownProviders) + err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders) require.NoError(t, err) // Both providers should be present. @@ -1251,7 +1255,7 @@ func TestConfig_configureProvidersDisableDefaultProviders(t *testing.T) { env := env.NewFromMap(map[string]string{}) resolver := NewEnvironmentVariableResolver(env) - err := cfg.configureProviders(env, resolver, []catwalk.Provider{}) + err := cfg.configureProviders(testStore(cfg), env, resolver, []catwalk.Provider{}) require.ErrorContains(t, err, "no custom providers") // Provider should be rejected for missing models. @@ -1275,7 +1279,7 @@ func TestConfig_configureProvidersDisableDefaultProviders(t *testing.T) { env := env.NewFromMap(map[string]string{}) resolver := NewEnvironmentVariableResolver(env) - err := cfg.configureProviders(env, resolver, []catwalk.Provider{}) + err := cfg.configureProviders(testStore(cfg), env, resolver, []catwalk.Provider{}) require.ErrorContains(t, err, "no custom providers") // Provider should be rejected for missing base_url. @@ -1340,10 +1344,10 @@ func TestConfig_configureSelectedModels(t *testing.T) { cfg.setDefaults("/tmp", "") env := env.NewFromMap(map[string]string{}) resolver := NewEnvironmentVariableResolver(env) - err := cfg.configureProviders(env, resolver, knownProviders) + err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders) require.NoError(t, err) - err = cfg.configureSelectedModels(knownProviders) + err = configureSelectedModels(testStore(cfg), knownProviders) require.NoError(t, err) large := cfg.Models[SelectedModelTypeLarge] small := cfg.Models[SelectedModelTypeSmall] @@ -1402,10 +1406,10 @@ func TestConfig_configureSelectedModels(t *testing.T) { cfg.setDefaults("/tmp", "") env := env.NewFromMap(map[string]string{}) resolver := NewEnvironmentVariableResolver(env) - err := cfg.configureProviders(env, resolver, knownProviders) + err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders) require.NoError(t, err) - err = cfg.configureSelectedModels(knownProviders) + err = configureSelectedModels(testStore(cfg), knownProviders) require.NoError(t, err) large := cfg.Models[SelectedModelTypeLarge] small := cfg.Models[SelectedModelTypeSmall] @@ -1447,10 +1451,10 @@ func TestConfig_configureSelectedModels(t *testing.T) { cfg.setDefaults("/tmp", "") env := env.NewFromMap(map[string]string{}) resolver := NewEnvironmentVariableResolver(env) - err := cfg.configureProviders(env, resolver, knownProviders) + err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders) require.NoError(t, err) - err = cfg.configureSelectedModels(knownProviders) + err = configureSelectedModels(testStore(cfg), knownProviders) require.NoError(t, err) large := cfg.Models[SelectedModelTypeLarge] require.Equal(t, "large-model", large.Model) diff --git a/internal/config/recent_models_test.go b/internal/config/recent_models_test.go index 739ddc0031a65cab261723772c3f38658dcd1561..7c46d5d5202927932ed154a4da8b0719ce9e114e 100644 --- a/internal/config/recent_models_test.go +++ b/internal/config/recent_models_test.go @@ -31,15 +31,23 @@ func readRecentModels(t *testing.T, path string) map[string]any { return rm } +// testStoreWithPath creates a ConfigStore backed by a Config for recent model tests. +func testStoreWithPath(cfg *Config, dir string) *ConfigStore { + return &ConfigStore{ + config: cfg, + globalDataPath: filepath.Join(dir, "config.json"), + } +} + func TestRecordRecentModel_AddsAndPersists(t *testing.T) { t.Parallel() dir := t.TempDir() cfg := &Config{} cfg.setDefaults(dir, "") - cfg.dataConfigDir = filepath.Join(dir, "config.json") + store := testStoreWithPath(cfg, dir) - err := cfg.recordRecentModel(SelectedModelTypeLarge, SelectedModel{Provider: "openai", Model: "gpt-4o"}) + err := store.recordRecentModel(ScopeGlobal, SelectedModelTypeLarge, SelectedModel{Provider: "openai", Model: "gpt-4o"}) require.NoError(t, err) // in-memory state @@ -48,7 +56,7 @@ func TestRecordRecentModel_AddsAndPersists(t *testing.T) { require.Equal(t, "gpt-4o", cfg.RecentModels[SelectedModelTypeLarge][0].Model) // persisted state - rm := readRecentModels(t, cfg.dataConfigDir) + rm := readRecentModels(t, store.globalDataPath) large, ok := rm[string(SelectedModelTypeLarge)].([]any) require.True(t, ok) require.Len(t, large, 1) @@ -64,13 +72,13 @@ func TestRecordRecentModel_DedupeAndMoveToFront(t *testing.T) { dir := t.TempDir() cfg := &Config{} cfg.setDefaults(dir, "") - cfg.dataConfigDir = filepath.Join(dir, "config.json") + store := testStoreWithPath(cfg, dir) // Add two entries - require.NoError(t, cfg.recordRecentModel(SelectedModelTypeLarge, SelectedModel{Provider: "openai", Model: "gpt-4o"})) - require.NoError(t, cfg.recordRecentModel(SelectedModelTypeLarge, SelectedModel{Provider: "anthropic", Model: "claude"})) + require.NoError(t, store.recordRecentModel(ScopeGlobal, SelectedModelTypeLarge, SelectedModel{Provider: "openai", Model: "gpt-4o"})) + require.NoError(t, store.recordRecentModel(ScopeGlobal, SelectedModelTypeLarge, SelectedModel{Provider: "anthropic", Model: "claude"})) // Re-add first; should move to front and not duplicate - require.NoError(t, cfg.recordRecentModel(SelectedModelTypeLarge, SelectedModel{Provider: "openai", Model: "gpt-4o"})) + require.NoError(t, store.recordRecentModel(ScopeGlobal, SelectedModelTypeLarge, SelectedModel{Provider: "openai", Model: "gpt-4o"})) got := cfg.RecentModels[SelectedModelTypeLarge] require.Len(t, got, 2) @@ -84,7 +92,7 @@ func TestRecordRecentModel_TrimsToMax(t *testing.T) { dir := t.TempDir() cfg := &Config{} cfg.setDefaults(dir, "") - cfg.dataConfigDir = filepath.Join(dir, "config.json") + store := testStoreWithPath(cfg, dir) // Insert 6 unique models; max is 5 entries := []SelectedModel{ @@ -96,7 +104,7 @@ func TestRecordRecentModel_TrimsToMax(t *testing.T) { {Provider: "p6", Model: "m6"}, } for _, e := range entries { - require.NoError(t, cfg.recordRecentModel(SelectedModelTypeLarge, e)) + require.NoError(t, store.recordRecentModel(ScopeGlobal, SelectedModelTypeLarge, e)) } // in-memory state @@ -110,7 +118,7 @@ func TestRecordRecentModel_TrimsToMax(t *testing.T) { require.Equal(t, SelectedModel{Provider: "p2", Model: "m2"}, got[4]) // persisted state: verify trimmed to 5 and newest-first order - rm := readRecentModels(t, cfg.dataConfigDir) + rm := readRecentModels(t, store.globalDataPath) large, ok := rm[string(SelectedModelTypeLarge)].([]any) require.True(t, ok) require.Len(t, large, 5) @@ -129,12 +137,12 @@ func TestRecordRecentModel_SkipsEmptyValues(t *testing.T) { dir := t.TempDir() cfg := &Config{} cfg.setDefaults(dir, "") - cfg.dataConfigDir = filepath.Join(dir, "config.json") + store := testStoreWithPath(cfg, dir) // Missing provider - require.NoError(t, cfg.recordRecentModel(SelectedModelTypeLarge, SelectedModel{Provider: "", Model: "m"})) + require.NoError(t, store.recordRecentModel(ScopeGlobal, SelectedModelTypeLarge, SelectedModel{Provider: "", Model: "m"})) // Missing model - require.NoError(t, cfg.recordRecentModel(SelectedModelTypeLarge, SelectedModel{Provider: "p", Model: ""})) + require.NoError(t, store.recordRecentModel(ScopeGlobal, SelectedModelTypeLarge, SelectedModel{Provider: "p", Model: ""})) _, ok := cfg.RecentModels[SelectedModelTypeLarge] // Map may be initialized, but should have no entries @@ -142,8 +150,8 @@ func TestRecordRecentModel_SkipsEmptyValues(t *testing.T) { require.Len(t, cfg.RecentModels[SelectedModelTypeLarge], 0) } // No file should be written (stat via fs.FS) - baseDir := filepath.Dir(cfg.dataConfigDir) - fileName := filepath.Base(cfg.dataConfigDir) + baseDir := filepath.Dir(store.globalDataPath) + fileName := filepath.Base(store.globalDataPath) _, err := fs.Stat(os.DirFS(baseDir), fileName) require.True(t, os.IsNotExist(err)) } @@ -154,13 +162,13 @@ func TestRecordRecentModel_NoPersistOnNoop(t *testing.T) { dir := t.TempDir() cfg := &Config{} cfg.setDefaults(dir, "") - cfg.dataConfigDir = filepath.Join(dir, "config.json") + store := testStoreWithPath(cfg, dir) entry := SelectedModel{Provider: "openai", Model: "gpt-4o"} - require.NoError(t, cfg.recordRecentModel(SelectedModelTypeLarge, entry)) + require.NoError(t, store.recordRecentModel(ScopeGlobal, SelectedModelTypeLarge, entry)) - baseDir := filepath.Dir(cfg.dataConfigDir) - fileName := filepath.Base(cfg.dataConfigDir) + baseDir := filepath.Dir(store.globalDataPath) + fileName := filepath.Base(store.globalDataPath) before, err := fs.ReadFile(os.DirFS(baseDir), fileName) require.NoError(t, err) @@ -170,7 +178,7 @@ func TestRecordRecentModel_NoPersistOnNoop(t *testing.T) { beforeMod := stBefore.ModTime() // Re-record same entry should be a no-op (no write) - require.NoError(t, cfg.recordRecentModel(SelectedModelTypeLarge, entry)) + require.NoError(t, store.recordRecentModel(ScopeGlobal, SelectedModelTypeLarge, entry)) after, err := fs.ReadFile(os.DirFS(baseDir), fileName) require.NoError(t, err) @@ -188,17 +196,17 @@ func TestUpdatePreferredModel_UpdatesRecents(t *testing.T) { dir := t.TempDir() cfg := &Config{} cfg.setDefaults(dir, "") - cfg.dataConfigDir = filepath.Join(dir, "config.json") + store := testStoreWithPath(cfg, dir) sel := SelectedModel{Provider: "openai", Model: "gpt-4o"} - require.NoError(t, cfg.UpdatePreferredModel(SelectedModelTypeSmall, sel)) + require.NoError(t, store.UpdatePreferredModel(ScopeGlobal, SelectedModelTypeSmall, sel)) // in-memory require.Equal(t, sel, cfg.Models[SelectedModelTypeSmall]) require.Len(t, cfg.RecentModels[SelectedModelTypeSmall], 1) // persisted (read via fs.FS) - rm := readRecentModels(t, cfg.dataConfigDir) + rm := readRecentModels(t, store.globalDataPath) small, ok := rm[string(SelectedModelTypeSmall)].([]any) require.True(t, ok) require.Len(t, small, 1) @@ -210,14 +218,14 @@ func TestRecordRecentModel_TypeIsolation(t *testing.T) { dir := t.TempDir() cfg := &Config{} cfg.setDefaults(dir, "") - cfg.dataConfigDir = filepath.Join(dir, "config.json") + store := testStoreWithPath(cfg, dir) // Add models to both large and small types largeModel := SelectedModel{Provider: "openai", Model: "gpt-4o"} smallModel := SelectedModel{Provider: "anthropic", Model: "claude"} - require.NoError(t, cfg.recordRecentModel(SelectedModelTypeLarge, largeModel)) - require.NoError(t, cfg.recordRecentModel(SelectedModelTypeSmall, smallModel)) + require.NoError(t, store.recordRecentModel(ScopeGlobal, SelectedModelTypeLarge, largeModel)) + require.NoError(t, store.recordRecentModel(ScopeGlobal, SelectedModelTypeSmall, smallModel)) // in-memory: verify types maintain separate histories require.Len(t, cfg.RecentModels[SelectedModelTypeLarge], 1) @@ -227,14 +235,14 @@ func TestRecordRecentModel_TypeIsolation(t *testing.T) { // Add another to large, verify small unchanged anotherLarge := SelectedModel{Provider: "google", Model: "gemini"} - require.NoError(t, cfg.recordRecentModel(SelectedModelTypeLarge, anotherLarge)) + require.NoError(t, store.recordRecentModel(ScopeGlobal, SelectedModelTypeLarge, anotherLarge)) require.Len(t, cfg.RecentModels[SelectedModelTypeLarge], 2) require.Len(t, cfg.RecentModels[SelectedModelTypeSmall], 1) require.Equal(t, smallModel, cfg.RecentModels[SelectedModelTypeSmall][0]) // persisted state: verify both types exist with correct lengths and contents - rm := readRecentModels(t, cfg.dataConfigDir) + rm := readRecentModels(t, store.globalDataPath) large, ok := rm[string(SelectedModelTypeLarge)].([]any) require.True(t, ok) diff --git a/internal/config/scope.go b/internal/config/scope.go new file mode 100644 index 0000000000000000000000000000000000000000..971ce32c3ed662dd0d0627c4f1c858372f3b4514 --- /dev/null +++ b/internal/config/scope.go @@ -0,0 +1,11 @@ +package config + +// Scope determines which config file is targeted for read/write operations. +type Scope int + +const ( + // ScopeGlobal targets the global data config (~/.local/share/crush/crush.json). + ScopeGlobal Scope = iota + // ScopeWorkspace targets the workspace config (.crush/crush.json). + ScopeWorkspace +) diff --git a/internal/config/store.go b/internal/config/store.go new file mode 100644 index 0000000000000000000000000000000000000000..4dfe6130bc23007ec3df12e5a88cb53bc3ad5a2d --- /dev/null +++ b/internal/config/store.go @@ -0,0 +1,336 @@ +package config + +import ( + "cmp" + "context" + "fmt" + "log/slog" + "os" + "path/filepath" + "slices" + + "charm.land/catwalk/pkg/catwalk" + hyperp "github.com/charmbracelet/crush/internal/agent/hyper" + "github.com/charmbracelet/crush/internal/oauth" + "github.com/charmbracelet/crush/internal/oauth/copilot" + "github.com/charmbracelet/crush/internal/oauth/hyper" + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +// 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. +type ConfigStore struct { + config *Config + workingDir string + resolver VariableResolver + globalDataPath string // ~/.local/share/crush/crush.json + workspacePath string // .crush/crush.json + knownProviders []catwalk.Provider +} + +// Config returns the pure-data config struct (read-only after load). +func (s *ConfigStore) Config() *Config { + return s.config +} + +// WorkingDir returns the current working directory. +func (s *ConfigStore) WorkingDir() string { + return s.workingDir +} + +// Resolver returns the variable resolver. +func (s *ConfigStore) Resolver() VariableResolver { + return s.resolver +} + +// Resolve resolves a variable reference using the configured resolver. +func (s *ConfigStore) Resolve(key string) (string, error) { + if s.resolver == nil { + return "", fmt.Errorf("no variable resolver configured") + } + return s.resolver.ResolveValue(key) +} + +// KnownProviders returns the list of known providers. +func (s *ConfigStore) KnownProviders() []catwalk.Provider { + return s.knownProviders +} + +// SetupAgents configures the coder and task agents on the config. +func (s *ConfigStore) SetupAgents() { + s.config.SetupAgents() +} + +// configPath returns the file path for the given scope. +func (s *ConfigStore) configPath(scope Scope) string { + switch scope { + case ScopeWorkspace: + return s.workspacePath + default: + return s.globalDataPath + } +} + +// 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)) + if err != nil { + return false + } + return gjson.Get(string(data), key).Exists() +} + +// 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) + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + data = []byte("{}") + } else { + return fmt.Errorf("failed to read config file: %w", err) + } + } + + newValue, err := sjson.Set(string(data), key, value) + if err != nil { + return fmt.Errorf("failed to set config field %s: %w", key, err) + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return fmt.Errorf("failed to create config directory %q: %w", path, err) + } + if err := os.WriteFile(path, []byte(newValue), 0o600); err != nil { + return fmt.Errorf("failed to write config file: %w", err) + } + return nil +} + +// 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) + data, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("failed to read config file: %w", err) + } + + newValue, err := sjson.Delete(string(data), key) + if err != nil { + return fmt.Errorf("failed to delete config field %s: %w", key, err) + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return fmt.Errorf("failed to create config directory %q: %w", path, err) + } + if err := os.WriteFile(path, []byte(newValue), 0o600); err != nil { + return fmt.Errorf("failed to write config file: %w", err) + } + return nil +} + +// UpdatePreferredModel updates the preferred model for the given type and +// persists it to the config file at the given scope. +func (s *ConfigStore) UpdatePreferredModel(scope Scope, modelType SelectedModelType, model SelectedModel) error { + s.config.Models[modelType] = model + if err := s.SetConfigField(scope, fmt.Sprintf("models.%s", modelType), model); err != nil { + return fmt.Errorf("failed to update preferred model: %w", err) + } + if err := s.recordRecentModel(scope, modelType, model); err != nil { + return err + } + return nil +} + +// SetCompactMode sets the compact mode setting and persists it. +func (s *ConfigStore) SetCompactMode(scope Scope, enabled bool) error { + if s.config.Options == nil { + s.config.Options = &Options{} + } + s.config.Options.TUI.CompactMode = enabled + return s.SetConfigField(scope, "options.tui.compact_mode", enabled) +} + +// SetProviderAPIKey sets the API key for a provider and persists it. +func (s *ConfigStore) SetProviderAPIKey(scope Scope, providerID string, apiKey any) error { + var providerConfig ProviderConfig + var exists bool + var setKeyOrToken func() + + switch v := apiKey.(type) { + case string: + if err := s.SetConfigField(scope, fmt.Sprintf("providers.%s.api_key", providerID), v); err != nil { + return fmt.Errorf("failed to save api key to config file: %w", err) + } + setKeyOrToken = func() { providerConfig.APIKey = v } + case *oauth.Token: + if err := cmp.Or( + s.SetConfigField(scope, fmt.Sprintf("providers.%s.api_key", providerID), v.AccessToken), + s.SetConfigField(scope, fmt.Sprintf("providers.%s.oauth", providerID), v), + ); err != nil { + return err + } + setKeyOrToken = func() { + providerConfig.APIKey = v.AccessToken + providerConfig.OAuthToken = v + switch providerID { + case string(catwalk.InferenceProviderCopilot): + providerConfig.SetupGitHubCopilot() + } + } + } + + providerConfig, exists = s.config.Providers.Get(providerID) + if exists { + setKeyOrToken() + s.config.Providers.Set(providerID, providerConfig) + return nil + } + + var foundProvider *catwalk.Provider + for _, p := range s.knownProviders { + if string(p.ID) == providerID { + foundProvider = &p + break + } + } + + if foundProvider != nil { + providerConfig = ProviderConfig{ + ID: providerID, + Name: foundProvider.Name, + BaseURL: foundProvider.APIEndpoint, + Type: foundProvider.Type, + Disable: false, + ExtraHeaders: make(map[string]string), + ExtraParams: make(map[string]string), + Models: foundProvider.Models, + } + setKeyOrToken() + } else { + return fmt.Errorf("provider with ID %s not found in known providers", providerID) + } + s.config.Providers.Set(providerID, providerConfig) + return nil +} + +// RefreshOAuthToken refreshes the OAuth token for the given provider. +func (s *ConfigStore) RefreshOAuthToken(ctx context.Context, scope Scope, providerID string) error { + providerConfig, exists := s.config.Providers.Get(providerID) + if !exists { + return fmt.Errorf("provider %s not found", providerID) + } + + if providerConfig.OAuthToken == nil { + return fmt.Errorf("provider %s does not have an OAuth token", providerID) + } + + var newToken *oauth.Token + var refreshErr error + switch providerID { + case string(catwalk.InferenceProviderCopilot): + newToken, refreshErr = copilot.RefreshToken(ctx, providerConfig.OAuthToken.RefreshToken) + case hyperp.Name: + newToken, refreshErr = hyper.ExchangeToken(ctx, providerConfig.OAuthToken.RefreshToken) + default: + return fmt.Errorf("OAuth refresh not supported for provider %s", providerID) + } + if refreshErr != nil { + return fmt.Errorf("failed to refresh OAuth token for provider %s: %w", providerID, refreshErr) + } + + slog.Info("Successfully refreshed OAuth token", "provider", providerID) + providerConfig.OAuthToken = newToken + providerConfig.APIKey = newToken.AccessToken + + switch providerID { + case string(catwalk.InferenceProviderCopilot): + providerConfig.SetupGitHubCopilot() + } + + s.config.Providers.Set(providerID, providerConfig) + + if err := cmp.Or( + s.SetConfigField(scope, fmt.Sprintf("providers.%s.api_key", providerID), newToken.AccessToken), + s.SetConfigField(scope, fmt.Sprintf("providers.%s.oauth", providerID), newToken), + ); err != nil { + return fmt.Errorf("failed to persist refreshed token: %w", err) + } + + return nil +} + +// recordRecentModel records a model in the recent models list. +func (s *ConfigStore) recordRecentModel(scope Scope, modelType SelectedModelType, model SelectedModel) error { + if model.Provider == "" || model.Model == "" { + return nil + } + + if s.config.RecentModels == nil { + s.config.RecentModels = make(map[SelectedModelType][]SelectedModel) + } + + eq := func(a, b SelectedModel) bool { + return a.Provider == b.Provider && a.Model == b.Model + } + + entry := SelectedModel{ + Provider: model.Provider, + Model: model.Model, + } + + current := s.config.RecentModels[modelType] + withoutCurrent := slices.DeleteFunc(slices.Clone(current), func(existing SelectedModel) bool { + return eq(existing, entry) + }) + + updated := append([]SelectedModel{entry}, withoutCurrent...) + if len(updated) > maxRecentModelsPerType { + updated = updated[:maxRecentModelsPerType] + } + + if slices.EqualFunc(current, updated, eq) { + return nil + } + + s.config.RecentModels[modelType] = updated + + if err := s.SetConfigField(scope, fmt.Sprintf("recent_models.%s", modelType), updated); err != nil { + return fmt.Errorf("failed to persist recent models: %w", err) + } + + return nil +} + +// ImportCopilot attempts to import a GitHub Copilot token from disk. +func (s *ConfigStore) ImportCopilot() (*oauth.Token, bool) { + if s.HasConfigField(ScopeGlobal, "providers.copilot.api_key") || s.HasConfigField(ScopeGlobal, "providers.copilot.oauth") { + return nil, false + } + + diskToken, hasDiskToken := copilot.RefreshTokenFromDisk() + if !hasDiskToken { + return nil, false + } + + slog.Info("Found existing GitHub Copilot token on disk. Authenticating...") + token, err := copilot.RefreshToken(context.TODO(), diskToken) + if err != nil { + slog.Error("Unable to import GitHub Copilot token", "error", err) + return nil, false + } + + if err := s.SetProviderAPIKey(ScopeGlobal, string(catwalk.InferenceProviderCopilot), token); err != nil { + return token, false + } + + if err := cmp.Or( + s.SetConfigField(ScopeGlobal, "providers.copilot.api_key", token.AccessToken), + s.SetConfigField(ScopeGlobal, "providers.copilot.oauth", token), + ); err != nil { + slog.Error("Unable to save GitHub Copilot token to disk", "error", err) + } + + slog.Info("GitHub Copilot successfully imported") + return token, true +} diff --git a/internal/lsp/manager.go b/internal/lsp/manager.go index 13a78cef2a471a71c1e741e32e08e8d7edcb7484..b564c0e602c0234462a32cfaae67c8f8179551c4 100644 --- a/internal/lsp/manager.go +++ b/internal/lsp/manager.go @@ -26,18 +26,18 @@ var unavailable = csync.NewMap[string, struct{}]() // Manager handles lazy initialization of LSP clients based on file types. type Manager struct { clients *csync.Map[string, *Client] - cfg *config.Config + cfg *config.ConfigStore manager *powernapconfig.Manager callback func(name string, client *Client) } // NewManager creates a new LSP manager service. -func NewManager(cfg *config.Config) *Manager { +func NewManager(cfg *config.ConfigStore) *Manager { manager := powernapconfig.NewManager() manager.LoadDefaults() // Merge user-configured LSPs into the manager. - for name, clientConfig := range cfg.LSP { + for name, clientConfig := range cfg.Config().LSP { if clientConfig.Disabled { slog.Debug("LSP disabled by user config", "name", name) manager.RemoveServer(name) @@ -194,7 +194,7 @@ func (s *Manager) startServer(ctx context.Context, name, filepath string, server cfg, s.cfg.Resolver(), s.cfg.WorkingDir(), - s.cfg.Options.DebugLSP, + s.cfg.Config().Options.DebugLSP, ) if err != nil { slog.Error("Failed to create LSP client", "name", name, "error", err) @@ -244,7 +244,7 @@ func (s *Manager) startServer(ctx context.Context, name, filepath string, server } func (s *Manager) isUserConfigured(name string) bool { - cfg, ok := s.cfg.LSP[name] + cfg, ok := s.cfg.Config().LSP[name] return ok && !cfg.Disabled } @@ -258,7 +258,7 @@ func (s *Manager) buildConfig(name string, server *powernapconfig.ServerConfig) InitOptions: server.InitOptions, Options: server.Settings, } - if userCfg, ok := s.cfg.LSP[name]; ok { + if userCfg, ok := s.cfg.Config().LSP[name]; ok { cfg.Timeout = userCfg.Timeout } return cfg diff --git a/internal/permission/permission.go b/internal/permission/permission.go index fc47b7dc93869a1b0a39d30ddb0e408ce479429f..a5d238b379137362dba4989a954f0b1fbb84979e 100644 --- a/internal/permission/permission.go +++ b/internal/permission/permission.go @@ -134,6 +134,12 @@ func (s *permissionService) Request(ctx context.Context, opts CreatePermissionRe return true, nil } + // Check if the tool/action combination is in the allowlist + commandKey := opts.ToolName + ":" + opts.Action + if slices.Contains(s.allowedTools, commandKey) || slices.Contains(s.allowedTools, opts.ToolName) { + return true, nil + } + // tell the UI that a permission was requested s.notificationBroker.Publish(pubsub.CreatedEvent, PermissionNotification{ ToolCallID: opts.ToolCallID, @@ -141,12 +147,6 @@ func (s *permissionService) Request(ctx context.Context, opts CreatePermissionRe s.requestMu.Lock() defer s.requestMu.Unlock() - // Check if the tool/action combination is in the allowlist - commandKey := opts.ToolName + ":" + opts.Action - if slices.Contains(s.allowedTools, commandKey) || slices.Contains(s.allowedTools, opts.ToolName) { - return true, nil - } - s.autoApproveSessionsMu.RLock() autoApprove := s.autoApproveSessions[opts.SessionID] s.autoApproveSessionsMu.RUnlock() diff --git a/internal/server/server.go b/internal/server/server.go index 72e64be0d9c82dbae7599d96acf5d9ded50fcc15..94211f46c397b40e77891a51be6b2a48f5502018 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -73,7 +73,7 @@ func (s *Server) SetLogger(logger *slog.Logger) { } // DefaultServer returns a new [Server] with the default address. -func DefaultServer(cfg *config.Config) *Server { +func DefaultServer(cfg *config.ConfigStore) *Server { hostURL, err := ParseHostURL(DefaultHost()) if err != nil { panic("invalid default host") @@ -82,7 +82,7 @@ func DefaultServer(cfg *config.Config) *Server { } // NewServer creates a new [Server] with the given network and address. -func NewServer(cfg *config.Config, network, address string) *Server { +func NewServer(cfg *config.ConfigStore, network, address string) *Server { s := new(Server) s.Addr = address s.network = network diff --git a/internal/ui/common/capabilities.go b/internal/ui/common/capabilities.go index b9a9de674d4b17e4ac59ff41a93b9f0db2e0028a..36d72f3e27f5d837107712849c3d4d7882c94cac 100644 --- a/internal/ui/common/capabilities.go +++ b/internal/ui/common/capabilities.go @@ -58,7 +58,7 @@ func (c *Capabilities) Update(msg any) { } case tea.TerminalVersionMsg: c.TerminalVersion = m.Name - case uv.ModeReportEvent: + case tea.ModeReportMsg: switch m.Mode { case ansi.ModeFocusEvent: c.ReportFocusEvents = modeSupported(m.Value) @@ -77,7 +77,7 @@ func QueryCmd(env uv.Environ) tea.Cmd { shouldQueryFor := shouldQueryCapabilities(env) if shouldQueryFor { sb.WriteString(ansi.RequestNameVersion) - // sb.WriteString(ansi.RequestModeFocusEvent) // TODO: re-enable when we need notifications. + sb.WriteString(ansi.RequestModeFocusEvent) sb.WriteString(ansi.WindowOp(14)) // Window size in pixels kittyReq := ansi.KittyGraphics([]byte("AAAA"), "i=31", "s=1", "v=1", "a=q", "t=d", "f=24") if _, isTmux := env.LookupEnv("TMUX"); isTmux { diff --git a/internal/ui/common/common.go b/internal/ui/common/common.go index 6e7c632474389aa5455295e4132818941bc18244..143b20305464da33d2f350a36176bab0e45b85aa 100644 --- a/internal/ui/common/common.go +++ b/internal/ui/common/common.go @@ -26,11 +26,16 @@ type Common struct { Styles *styles.Styles } -// Config returns the configuration associated with this [Common] instance. +// 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() +} + // DefaultCommon returns the default common UI configurations. func DefaultCommon(app *app.App) *Common { s := styles.DefaultStyles() diff --git a/internal/ui/dialog/api_key_input.go b/internal/ui/dialog/api_key_input.go index 9677763b2f4f2436376f5bf16ab58aed79140c68..cc37d742903d5a80bbcffcf1ff24fb24596dfccd 100644 --- a/internal/ui/dialog/api_key_input.go +++ b/internal/ui/dialog/api_key_input.go @@ -296,7 +296,7 @@ func (m *APIKeyInput) verifyAPIKey() tea.Msg { Type: m.provider.Type, BaseURL: m.provider.APIEndpoint, } - err := providerConfig.TestConnection(m.com.Config().Resolver()) + err := providerConfig.TestConnection(m.com.Store().Resolver()) // intentionally wait for at least 750ms to make sure the user sees the spinner elapsed := time.Since(start) @@ -312,9 +312,9 @@ func (m *APIKeyInput) verifyAPIKey() tea.Msg { } func (m *APIKeyInput) saveKeyAndContinue() Action { - cfg := m.com.Config() + store := m.com.Store() - err := cfg.SetProviderAPIKey(string(m.provider.ID), m.input.Value()) + err := store.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 4b0b844e4ed869a4347af10e9d0b1b3c70a7d2f0..78f82a05f7e2e0db7a9bb561fb1b6248d8045513 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.Config().WorkingDir() + wd := f.com.Store().WorkingDir() if len(wd) > 0 { return wd } diff --git a/internal/ui/dialog/models.go b/internal/ui/dialog/models.go index 977f04a61e98f79adb9bb35777fac905508f47d5..434f699e91b4c227c4e54f6ff553affff76a1c43 100644 --- a/internal/ui/dialog/models.go +++ b/internal/ui/dialog/models.go @@ -490,7 +490,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 := cfg.SetConfigField(fmt.Sprintf("recent_models.%s", selectedType), validRecentItems); err != nil { + if err := m.com.Store().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 93d5fe052db11d036d29d7790810807d5630bb57..2803070381e65bd0380a8ddab5f256481c117c15 100644 --- a/internal/ui/dialog/oauth.go +++ b/internal/ui/dialog/oauth.go @@ -373,9 +373,9 @@ func (d *OAuth) copyCodeAndOpenURL() tea.Cmd { } func (m *OAuth) saveKeyAndContinue() Action { - cfg := m.com.Config() + store := m.com.Store() - err := cfg.SetProviderAPIKey(string(m.provider.ID), m.token) + err := store.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/model/header.go b/internal/ui/model/header.go index 24254a0f69e5803e4bcbe89274f21db5b04ef541..06bb4ff92981b28625efb11683081e29fc55a21e 100644 --- a/internal/ui/model/header.go +++ b/internal/ui/model/header.go @@ -143,8 +143,7 @@ func renderHeaderDetails( metadata = dot + metadata const dirTrimLimit = 4 - cfg := com.Config() - cwd := fsext.DirTrim(fsext.PrettyPath(cfg.WorkingDir()), dirTrimLimit) + cwd := fsext.DirTrim(fsext.PrettyPath(com.Store().WorkingDir()), dirTrimLimit) cwd = t.Header.WorkingDir.Render(cwd) result := cwd + metadata diff --git a/internal/ui/model/landing.go b/internal/ui/model/landing.go index 45d376ff5ddc691b978e438ddef04a702af100f9..72c2671ccd297f4bade087f6b2cb960f6c6a92a9 100644 --- a/internal/ui/model/landing.go +++ b/internal/ui/model/landing.go @@ -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.Config().WorkingDir(), width) + cwd := common.PrettyPath(t, m.com.Store().WorkingDir(), width) parts := []string{ cwd, diff --git a/internal/ui/model/onboarding.go b/internal/ui/model/onboarding.go index deff34ced3481b0e4af65893bfebe0e66db0135e..f094ae957113a2d7cf6cad92d76cca7df82e32e3 100644 --- a/internal/ui/model/onboarding.go +++ b/internal/ui/model/onboarding.go @@ -19,7 +19,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.Config()) + err := config.MarkProjectInitialized(m.com.Store()) if err != nil { slog.Error(err.Error()) } @@ -52,10 +52,10 @@ func (m *UI) initializeProject() tea.Cmd { if cmd := m.newSession(); cmd != nil { cmds = append(cmds, cmd) } - cfg := m.com.Config() + cfg := m.com.Store() initialize := func() tea.Msg { - initPrompt, err := agent.InitializePrompt(*cfg) + initPrompt, err := agent.InitializePrompt(cfg) if err != nil { return util.InfoMsg{ Type: util.InfoTypeError, @@ -80,10 +80,9 @@ func (m *UI) skipInitializeProject() tea.Cmd { // initializeView renders the project initialization prompt with Yes/No buttons. func (m *UI) initializeView() string { - cfg := m.com.Config() s := m.com.Styles.Initialize - cwd := home.Short(cfg.WorkingDir()) - initFile := cfg.Options.InitializeAs + cwd := home.Short(m.com.Store().WorkingDir()) + initFile := m.com.Config().Options.InitializeAs header := s.Header.Render("Would you like to initialize this project?") path := s.Accent.PaddingLeft(2).Render(cwd) diff --git a/internal/ui/model/sidebar.go b/internal/ui/model/sidebar.go index 88113a593034b09ed8d2859bc7628a103f5728b1..8849d86a8e1c8bda02092e3f165e85b8e32a8b1d 100644 --- a/internal/ui/model/sidebar.go +++ b/internal/ui/model/sidebar.go @@ -112,7 +112,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.Config().WorkingDir(), width) + cwd := common.PrettyPath(t, m.com.Store().WorkingDir(), width) sidebarLogo := m.sidebarLogo if height < logoHeightBreakpoint { sidebarLogo = logo.SmallRender(m.com.Styles, width) @@ -138,7 +138,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.Config().WorkingDir(), width, maxFiles, true) + filesSection := m.filesInfo(m.com.Store().WorkingDir(), width, maxFiles, true) uv.NewStyledString( lipgloss.NewStyle(). diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index da8663cff5d20c43da09ac70587457c5fda8fe5b..f5a822d6f16ef6c79fc2fbf003bf2a5d689f5643 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -25,6 +25,7 @@ import ( tea "charm.land/bubbletea/v2" "charm.land/catwalk/pkg/catwalk" "charm.land/lipgloss/v2" + "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" @@ -45,6 +46,7 @@ import ( "github.com/charmbracelet/crush/internal/ui/dialog" fimage "github.com/charmbracelet/crush/internal/ui/image" "github.com/charmbracelet/crush/internal/ui/logo" + "github.com/charmbracelet/crush/internal/ui/notification" "github.com/charmbracelet/crush/internal/ui/styles" "github.com/charmbracelet/crush/internal/ui/util" "github.com/charmbracelet/crush/internal/version" @@ -201,6 +203,9 @@ type UI struct { // sidebarLogo keeps a cached version of the sidebar sidebarLogo. sidebarLogo string + // Notification state + notifyBackend notification.Backend + notifyWindowFocused bool // custom commands & mcp commands customCommands []commands.CustomCommand mcpPrompts []commands.MCPPrompt @@ -280,17 +285,19 @@ func New(com *common.Common) *UI { header := newHeader(com) ui := &UI{ - com: com, - dialog: dialog.NewOverlay(), - keyMap: keyMap, - textarea: ta, - chat: ch, - header: header, - completions: comp, - attachments: attachments, - todoSpinner: todoSpinner, - lspStates: make(map[string]app.LSPClientInfo), - mcpStates: make(map[string]mcp.ClientInfo), + com: com, + dialog: dialog.NewOverlay(), + keyMap: keyMap, + textarea: ta, + chat: ch, + header: header, + completions: comp, + attachments: attachments, + todoSpinner: todoSpinner, + lspStates: make(map[string]app.LSPClientInfo), + mcpStates: make(map[string]mcp.ClientInfo), + notifyBackend: notification.NoopBackend{}, + notifyWindowFocused: true, } status := NewStatus(com, ui) @@ -310,7 +317,7 @@ func New(com *common.Common) *UI { desiredFocus := uiFocusEditor if !com.Config().IsConfigured() { desiredState = uiOnboarding - } else if n, _ := config.ProjectNeedsInitialization(com.Config()); n { + } else if n, _ := config.ProjectNeedsInitialization(com.Store()); n { desiredState = uiInitialize } @@ -342,6 +349,32 @@ func (m *UI) Init() tea.Cmd { return tea.Batch(cmds...) } +// sendNotification returns a command that sends a notification if allowed by policy. +func (m *UI) sendNotification(n notification.Notification) tea.Cmd { + if !m.shouldSendNotification() { + return nil + } + + backend := m.notifyBackend + return func() tea.Msg { + if err := backend.Send(n); err != nil { + slog.Error("Failed to send notification", "error", err) + } + return nil + } +} + +// shouldSendNotification returns true if notifications should be sent based on +// current state. Focus reporting must be supported, window must not focused, +// and notifications must not be disabled in config. +func (m *UI) shouldSendNotification() bool { + cfg := m.com.Config() + if cfg != nil && cfg.Options != nil && cfg.Options.DisableNotifications { + return false + } + return m.caps.ReportFocusEvents && !m.notifyWindowFocused +} + // setState changes the UI state and focus. func (m *UI) setState(state uiState, focus uiFocusState) { if state == uiLanding { @@ -397,6 +430,18 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.sendProgressBar = slices.Contains(msg, "WT_SESSION") } cmds = append(cmds, common.QueryCmd(uv.Environ(msg))) + case tea.ModeReportMsg: + if m.caps.ReportFocusEvents { + m.notifyBackend = notification.NewNativeBackend(notification.Icon) + } + case tea.FocusMsg: + m.notifyWindowFocused = true + case tea.BlurMsg: + m.notifyWindowFocused = false + case pubsub.Event[notify.Notification]: + if cmd := m.handleAgentNotification(msg.Payload); cmd != nil { + cmds = append(cmds, cmd) + } case loadSessionMsg: if m.forceCompactMode { m.isCompact = true @@ -534,7 +579,7 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case mcp.EventPromptsListChanged: return m, handleMCPPromptsEvent(msg.Payload.Name) case mcp.EventToolsListChanged: - return m, handleMCPToolsEvent(m.com.Config(), msg.Payload.Name) + return m, handleMCPToolsEvent(m.com.Store(), msg.Payload.Name) case mcp.EventResourcesListChanged: return m, handleMCPResourcesEvent(msg.Payload.Name) } @@ -542,6 +587,12 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if cmd := m.openPermissionsDialog(msg.Payload); cmd != nil { cmds = append(cmds, cmd) } + if cmd := m.sendNotification(notification.Notification{ + Title: "Crush is waiting...", + Message: fmt.Sprintf("Permission required to execute \"%s\"", msg.Payload.ToolName), + }); cmd != nil { + cmds = append(cmds, cmd) + } case pubsub.Event[permission.PermissionNotification]: m.handlePermissionNotification(msg.Payload) case cancelTimerExpiredMsg: @@ -1253,7 +1304,7 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd { currentModel := cfg.Models[agentCfg.Model] currentModel.Think = !currentModel.Think - if err := cfg.UpdatePreferredModel(agentCfg.Model, currentModel); err != nil { + if err := m.com.Store().UpdatePreferredModel(config.ScopeGlobal, agentCfg.Model, currentModel); err != nil { return util.ReportError(err)() } m.com.App.UpdateAgentModel(context.TODO()) @@ -1294,7 +1345,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.Config().ImportCopilot() + m.com.Store().ImportCopilot() } if !isConfigured() || msg.ReAuthenticate { @@ -1305,12 +1356,12 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd { break } - if err := cfg.UpdatePreferredModel(msg.ModelType, msg.Model); err != nil { + if err := m.com.Store().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 := cfg.UpdatePreferredModel(config.SelectedModelTypeSmall, smallModel); err != nil { + if err := m.com.Store().UpdatePreferredModel(config.ScopeGlobal, config.SelectedModelTypeSmall, smallModel); err != nil { cmds = append(cmds, util.ReportError(err)) } } @@ -1356,7 +1407,7 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd { currentModel := cfg.Models[agentCfg.Model] currentModel.ReasoningEffort = msg.Effort - if err := cfg.UpdatePreferredModel(agentCfg.Model, currentModel); err != nil { + if err := m.com.Store().UpdatePreferredModel(config.ScopeGlobal, agentCfg.Model, currentModel); err != nil { cmds = append(cmds, util.ReportError(err)) break } @@ -1967,7 +2018,8 @@ func (m *UI) View() tea.View { v.BackgroundColor = m.com.Styles.Background } v.MouseMode = tea.MouseModeCellMotion - v.WindowTitle = "crush " + home.Short(m.com.Config().WorkingDir()) + v.ReportFocus = m.caps.ReportFocusEvents + v.WindowTitle = "crush " + home.Short(m.com.Store().WorkingDir()) canvas := uv.NewScreenBuffer(m.width, m.height) v.Cursor = m.Draw(canvas, canvas.Bounds()) @@ -2206,7 +2258,7 @@ func (m *UI) FullHelp() [][]key.Binding { func (m *UI) toggleCompactMode() tea.Cmd { m.forceCompactMode = !m.forceCompactMode - err := m.com.Config().SetCompactMode(m.forceCompactMode) + err := m.com.Store().SetCompactMode(config.ScopeGlobal, m.forceCompactMode) if err != nil { return util.ReportError(err) } @@ -2588,7 +2640,7 @@ func (m *UI) insertMCPResourceCompletion(item completions.ResourceCompletionValu return func() tea.Msg { contents, err := mcp.ReadResource( context.Background(), - m.com.Config(), + m.com.Store(), item.MCPName, item.URI, ) @@ -2983,6 +3035,20 @@ func (m *UI) handlePermissionNotification(notification permission.PermissionNoti } } +// handleAgentNotification translates domain agent events into desktop +// notifications using the UI notification backend. +func (m *UI) handleAgentNotification(n notify.Notification) tea.Cmd { + switch n.Type { + case notify.TypeAgentFinished: + return m.sendNotification(notification.Notification{ + Title: "Crush is waiting...", + Message: fmt.Sprintf("Agent's turn completed in \"%s\"", n.SessionTitle), + }) + default: + return nil + } +} + // newSession clears the current session state and prepares for a new session. // The actual session creation happens when the user sends their first message. // Returns a command to reload prompt history. @@ -3236,7 +3302,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.Config().WorkingDir(), sectionWidth, maxItemsPerSection, false) + filesSection := m.filesInfo(m.com.Store().WorkingDir(), sectionWidth, maxItemsPerSection, false) sections := lipgloss.JoinHorizontal(lipgloss.Top, filesSection, " ", lspSection, " ", mcpSection) uv.NewStyledString( s.CompactDetails.View. @@ -3254,7 +3320,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.Config(), clientID, promptID, arguments) + prompt, err := commands.GetMCPPrompt(m.com.Store(), clientID, promptID, arguments) if err != nil { // TODO: make this better return util.ReportError(err)() @@ -3295,7 +3361,7 @@ func handleMCPPromptsEvent(name string) tea.Cmd { } } -func handleMCPToolsEvent(cfg *config.Config, name string) tea.Cmd { +func handleMCPToolsEvent(cfg *config.ConfigStore, name string) tea.Cmd { return func() tea.Msg { mcp.RefreshTools( context.Background(), diff --git a/internal/ui/notification/crush-icon-solo.png b/internal/ui/notification/crush-icon-solo.png new file mode 100644 index 0000000000000000000000000000000000000000..eed026660d0d5882c9b6e98912ee2afd9748f2a6 Binary files /dev/null and b/internal/ui/notification/crush-icon-solo.png differ diff --git a/internal/ui/notification/crush-icon.png b/internal/ui/notification/crush-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..138a7ab6d246a0989cf9fca296e7c7df78523465 Binary files /dev/null and b/internal/ui/notification/crush-icon.png differ diff --git a/internal/ui/notification/icon_darwin.go b/internal/ui/notification/icon_darwin.go new file mode 100644 index 0000000000000000000000000000000000000000..27df25009be6bb849afc7b39b631fbbe3c61b6b3 --- /dev/null +++ b/internal/ui/notification/icon_darwin.go @@ -0,0 +1,7 @@ +//go:build darwin + +package notification + +// Icon is currently empty on darwin because platform icon support is broken. Do +// use the icon for OSC notifications, just not native. +var Icon any = "" diff --git a/internal/ui/notification/icon_other.go b/internal/ui/notification/icon_other.go new file mode 100644 index 0000000000000000000000000000000000000000..27240ad93fc653c9e742a879e76914481e5f1d55 --- /dev/null +++ b/internal/ui/notification/icon_other.go @@ -0,0 +1,13 @@ +//go:build !darwin + +package notification + +import ( + _ "embed" +) + +//go:embed crush-icon-solo.png +var icon []byte + +// Icon contains the embedded PNG icon data for desktop notifications. +var Icon any = icon diff --git a/internal/ui/notification/native.go b/internal/ui/notification/native.go new file mode 100644 index 0000000000000000000000000000000000000000..4fffa6d2de6798f8c343c3789689844a911b6eb0 --- /dev/null +++ b/internal/ui/notification/native.go @@ -0,0 +1,49 @@ +package notification + +import ( + "log/slog" + + "github.com/gen2brain/beeep" +) + +// NativeBackend sends desktop notifications using the native OS notification +// system via beeep. +type NativeBackend struct { + // icon is the notification icon data (platform-specific). + icon any + // notifyFunc is the function used to send notifications (swappable for testing). + notifyFunc func(title, message string, icon any) error +} + +// NewNativeBackend creates a new native notification backend. +func NewNativeBackend(icon any) *NativeBackend { + beeep.AppName = "Crush" + return &NativeBackend{ + icon: icon, + notifyFunc: beeep.Notify, + } +} + +// Send sends a desktop notification using the native OS notification system. +func (b *NativeBackend) Send(n Notification) error { + slog.Debug("Sending native notification", "title", n.Title, "message", n.Message) + + err := b.notifyFunc(n.Title, n.Message, b.icon) + if err != nil { + slog.Error("Failed to send notification", "error", err) + } else { + slog.Debug("Notification sent successfully") + } + + return err +} + +// SetNotifyFunc allows replacing the notification function for testing. +func (b *NativeBackend) SetNotifyFunc(fn func(title, message string, icon any) error) { + b.notifyFunc = fn +} + +// ResetNotifyFunc resets the notification function to the default. +func (b *NativeBackend) ResetNotifyFunc() { + b.notifyFunc = beeep.Notify +} diff --git a/internal/ui/notification/noop.go b/internal/ui/notification/noop.go new file mode 100644 index 0000000000000000000000000000000000000000..7e943e38af15ad4e2dcd47c95158bb4abcb6bb56 --- /dev/null +++ b/internal/ui/notification/noop.go @@ -0,0 +1,10 @@ +package notification + +// NoopBackend is a no-op notification backend that does nothing. +// This is the default backend used when notifications are not supported. +type NoopBackend struct{} + +// Send does nothing and returns nil. +func (NoopBackend) Send(_ Notification) error { + return nil +} diff --git a/internal/ui/notification/notification.go b/internal/ui/notification/notification.go new file mode 100644 index 0000000000000000000000000000000000000000..f6be12bfe8b84c2cf18b4c5f1ae3720e820e6cd5 --- /dev/null +++ b/internal/ui/notification/notification.go @@ -0,0 +1,15 @@ +// Package notification provides desktop notification support for the UI. +package notification + +// Notification represents a desktop notification request. +type Notification struct { + Title string + Message string +} + +// Backend defines the interface for sending desktop notifications. +// Implementations are pure transport - policy decisions (config, focus state) +// are handled by the caller. +type Backend interface { + Send(n Notification) error +} diff --git a/internal/ui/notification/notification_test.go b/internal/ui/notification/notification_test.go new file mode 100644 index 0000000000000000000000000000000000000000..715be608c75328e3bc2b9e820c301a62a17f08a5 --- /dev/null +++ b/internal/ui/notification/notification_test.go @@ -0,0 +1,43 @@ +package notification_test + +import ( + "testing" + + "github.com/charmbracelet/crush/internal/ui/notification" + "github.com/stretchr/testify/require" +) + +func TestNoopBackend_Send(t *testing.T) { + t.Parallel() + + backend := notification.NoopBackend{} + err := backend.Send(notification.Notification{ + Title: "Test Title", + Message: "Test Message", + }) + require.NoError(t, err) +} + +func TestNativeBackend_Send(t *testing.T) { + t.Parallel() + + backend := notification.NewNativeBackend(nil) + + var capturedTitle, capturedMessage string + var capturedIcon any + backend.SetNotifyFunc(func(title, message string, icon any) error { + capturedTitle = title + capturedMessage = message + capturedIcon = icon + return nil + }) + + err := backend.Send(notification.Notification{ + Title: "Hello", + Message: "World", + }) + require.NoError(t, err) + require.Equal(t, "Hello", capturedTitle) + require.Equal(t, "World", capturedMessage) + require.Nil(t, capturedIcon) +} diff --git a/schema.json b/schema.json index 298d8fe814b80fa693759e9de5a1dafb921b389f..3f9754158f3bc91cc1b6570d5e5393a6b594c22d 100644 --- a/schema.json +++ b/schema.json @@ -451,6 +451,11 @@ "type": "boolean", "description": "Show indeterminate progress updates during long operations", "default": true + }, + "disable_notifications": { + "type": "boolean", + "description": "Disable desktop notifications", + "default": false } }, "additionalProperties": false,