Detailed changes
@@ -679,6 +679,38 @@
"created_at": "2025-09-29T01:19:40Z",
"repoId": 987670088,
"pullRequestNo": 1148
+ },
+ {
+ "name": "kucukkanat",
+ "id": 914316,
+ "comment_id": 3369230313,
+ "created_at": "2025-10-05T18:13:57Z",
+ "repoId": 987670088,
+ "pullRequestNo": 1195
+ },
+ {
+ "name": "thuggys",
+ "id": 150315417,
+ "comment_id": 3369149503,
+ "created_at": "2025-10-05T15:59:55Z",
+ "repoId": 987670088,
+ "pullRequestNo": 1194
+ },
+ {
+ "name": "nikaro",
+ "id": 3918653,
+ "comment_id": 3373586148,
+ "created_at": "2025-10-06T19:31:50Z",
+ "repoId": 987670088,
+ "pullRequestNo": 1200
+ },
+ {
+ "name": "daps94",
+ "id": 35882689,
+ "comment_id": 3395964275,
+ "created_at": "2025-10-13T05:56:20Z",
+ "repoId": 987670088,
+ "pullRequestNo": 1223
}
]
}
@@ -17,7 +17,7 @@ jobs:
with:
go-version-file: go.mod
- run: go run . schema > ./schema.json
- - uses: stefanzweifel/git-auto-commit-action@778341af668090896ca464160c2def5d1d1a3eb0 # v5
+ - uses: stefanzweifel/git-auto-commit-action@28e16e81777b558cc906c8750092100bbb34c5e3 # v5
with:
commit_message: "chore: auto-update generated files"
branch: main
@@ -312,19 +312,19 @@ changelog:
- "^wip "
- "^wip:"
groups:
- - title: "New Features"
- regexp: '^.*?feat(\(.+\))??!?:.+$'
+ - title: "Deps"
+ regexp: "^.*\\(deps\\)*:+.*$"
+ order: 300
+ - title: "New!"
+ regexp: "^.*feat[(\\w)]*:+.*$"
order: 100
- - title: "Security updates"
- regexp: '^.*?sec(\(.+\))??!?:.+$'
- order: 150
- - title: "Bug fixes and improvements"
- regexp: '^.*?(fix|refactor)(\(.+\))??!?:.+$'
+ - title: "Fixed"
+ regexp: "^.*fix[(\\w)]*:+.*$"
order: 200
- - title: "Documentation updates"
- regexp: ^.*?docs?(\(.+\))??!?:.+$
+ - title: "Docs"
+ regexp: "^.*docs[(\\w)]*:+.*$"
order: 400
- - title: Other work
+ - title: "Other stuff"
order: 9999
release:
@@ -38,7 +38,7 @@ tasks:
run:
desc: Run build
cmds:
- - go run .
+ - go run . {{.CLI_ARGS}}
test:
desc: Run tests
@@ -89,7 +89,7 @@ tasks:
desc: Create and push a new tag following semver
vars:
NEXT:
- sh: go run github.com/caarlos0/svu/v3@latest next --always
+ sh: svu next --always || go run github.com/caarlos0/svu/v3@latest next --always
prompt: "This will release {{.NEXT}}. Continue?"
preconditions:
- sh: '[ $(git symbolic-ref --short HEAD) = "main" ]'
@@ -97,8 +97,13 @@ tasks:
- sh: "[ $(git status --porcelain=2 | wc -l) = 0 ]"
msg: "Git is dirty"
cmds:
+ - task: fetch-tags
- git commit --allow-empty -m "{{.NEXT}}"
- - git tag -d nightly
- - git tag --sign {{.NEXT}} {{.CLI_ARGS}}
+ - git tag --annotate -m "{{.NEXT}}" {{.NEXT}} {{.CLI_ARGS}}
- echo "Pushing {{.NEXT}}..."
- - git push origin --tags
+ - git push origin main --follow-tags
+
+ fetch-tags:
+ cmds:
+ - git tag -d nightly || true
+ - git fetch --tags
@@ -7,32 +7,32 @@ require (
github.com/MakeNowJust/heredoc v1.0.0
github.com/PuerkitoBio/goquery v1.10.3
github.com/alecthomas/chroma/v2 v2.20.0
- github.com/anthropics/anthropic-sdk-go v1.12.0
+ github.com/anthropics/anthropic-sdk-go v1.13.0
github.com/atotto/clipboard v0.1.4
github.com/aymanbagabas/go-udiff v0.3.1
github.com/bmatcuk/doublestar/v4 v4.9.1
github.com/charlievieth/fastwalk v1.0.14
github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250820203609-601216f68ee2
- github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250910155747-997384b0b35e
- github.com/charmbracelet/catwalk v0.6.1
- github.com/charmbracelet/fang v0.4.2
+ github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20251011205917-3b687ffc1619
+ github.com/charmbracelet/catwalk v0.6.4
+ github.com/charmbracelet/fang v0.4.3
github.com/charmbracelet/glamour/v2 v2.0.0-20250811143442-a27abb32f018
github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3.0.20250917201909-41ff0bf215ea
github.com/charmbracelet/log/v2 v2.0.0-20250226163916-c379e29ff706
- github.com/charmbracelet/x/ansi v0.10.1
+ github.com/charmbracelet/x/ansi v0.10.2
github.com/charmbracelet/x/exp/charmtone v0.0.0-20250708181618-a60a724ba6c3
github.com/charmbracelet/x/exp/golden v0.0.0-20250207160936-21c02780d27a
github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec
github.com/google/uuid v1.6.0
github.com/invopop/jsonschema v0.13.0
github.com/joho/godotenv v1.5.1
- github.com/mark3labs/mcp-go v0.41.0
+ github.com/modelcontextprotocol/go-sdk v1.0.0
github.com/muesli/termenv v0.16.0
- github.com/ncruces/go-sqlite3 v0.29.0
+ github.com/ncruces/go-sqlite3 v0.29.1
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646
github.com/nxadm/tail v1.4.11
github.com/openai/openai-go v1.12.0
- github.com/pressly/goose/v3 v3.25.0
+ github.com/pressly/goose/v3 v3.26.0
github.com/qjebbs/go-jsons v1.0.0-alpha.4
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06
github.com/sahilm/fuzzy v0.1.1
@@ -72,7 +72,7 @@ require (
github.com/aymerick/douceur v0.2.0 // indirect
github.com/bahlo/generic-list-go v0.2.0 // indirect
github.com/buger/jsonparser v1.1.1 // indirect
- github.com/charmbracelet/colorprofile v0.3.2 // indirect
+ github.com/charmbracelet/colorprofile v0.3.2
github.com/charmbracelet/ultraviolet v0.0.0-20250915111650-81d4262876ef
github.com/charmbracelet/x/cellbuf v0.0.14-0.20250811133356-e0c5dbe5ea4a // indirect
github.com/charmbracelet/x/exp/slice v0.0.0-20250829135019-44e44e21330d
@@ -91,6 +91,7 @@ require (
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/google/go-cmp v0.7.0 // indirect
+ github.com/google/jsonschema-go v0.3.0 // indirect
github.com/google/s2a-go v0.1.8 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect
github.com/googleapis/gax-go/v2 v2.14.1 // indirect
@@ -104,7 +105,7 @@ require (
github.com/lucasb-eyer/go-colorful v1.3.0
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
- github.com/mattn/go-runewidth v0.0.16 // indirect
+ github.com/mattn/go-runewidth v0.0.17 // indirect
github.com/mfridman/interpolate v0.0.2 // indirect
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
@@ -116,12 +117,11 @@ require (
github.com/ncruces/julianday v1.0.0 // indirect
github.com/pierrec/lz4/v4 v4.1.22 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
- github.com/posthog/posthog-go v1.6.10
+ github.com/posthog/posthog-go v1.6.11
github.com/rivo/uniseg v0.4.7
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
github.com/sethvargo/go-retry v0.3.0 // indirect
github.com/sourcegraph/jsonrpc2 v0.2.1 // indirect
- github.com/spf13/cast v1.7.1 // indirect
github.com/spf13/pflag v1.0.9 // indirect
github.com/tetratelabs/wazero v1.9.0 // indirect
github.com/tidwall/gjson v1.18.0 // indirect
@@ -141,18 +141,18 @@ require (
go.opentelemetry.io/otel/metric v1.37.0 // indirect
go.opentelemetry.io/otel/trace v1.37.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
- golang.org/x/crypto v0.41.0 // indirect
+ golang.org/x/crypto v0.42.0 // indirect
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
golang.org/x/image v0.26.0 // indirect
golang.org/x/net v0.43.0 // indirect
golang.org/x/oauth2 v0.30.0 // indirect
golang.org/x/sync v0.17.0 // indirect
golang.org/x/sys v0.36.0 // indirect
- golang.org/x/term v0.34.0 // indirect
- golang.org/x/text v0.29.0
+ golang.org/x/term v0.35.0 // indirect
+ golang.org/x/text v0.30.0
golang.org/x/time v0.8.0 // indirect
google.golang.org/api v0.211.0 // indirect
- google.golang.org/genai v1.26.0
+ google.golang.org/genai v1.30.0
google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463 // indirect
google.golang.org/grpc v1.71.0 // indirect
google.golang.org/protobuf v1.36.8 // indirect
@@ -30,8 +30,8 @@ github.com/alecthomas/repr v0.5.1/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW5
github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU=
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
-github.com/anthropics/anthropic-sdk-go v1.12.0 h1:xPqlGnq7rWrTiHazIvCiumA0u7mGQnwDQtvA1M82h9U=
-github.com/anthropics/anthropic-sdk-go v1.12.0/go.mod h1:WTz31rIUHUHqai2UslPpw5CwXrQP3geYBioRV4WOLvE=
+github.com/anthropics/anthropic-sdk-go v1.13.0 h1:Bhbe8sRoDPtipttg8bQYrMCKe2b79+q6rFW1vOKEUKI=
+github.com/anthropics/anthropic-sdk-go v1.13.0/go.mod h1:WTz31rIUHUHqai2UslPpw5CwXrQP3geYBioRV4WOLvE=
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.30.3 h1:jUeBtG0Ih+ZIFH0F4UkmL9w3cSpaMv9tYYDbzILP8dY=
@@ -78,14 +78,14 @@ 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/bubbles/v2 v2.0.0-beta.1.0.20250820203609-601216f68ee2 h1:973OHYuq2Jx9deyuPwe/6lsuQrDCatOsjP8uCd02URE=
github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250820203609-601216f68ee2/go.mod h1:6HamsBKWqEC/FVHuQMHgQL+knPyvHH55HwJDHl/adMw=
-github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250910155747-997384b0b35e h1:4BBnKWFwJ5FLyhw/ijFxKE04i9rubr8WIPR1kjO57iA=
-github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250910155747-997384b0b35e/go.mod h1:F7AfLKYQqpM3NNBVs7ctW417tavhvoh9SBjsgtwpzbY=
-github.com/charmbracelet/catwalk v0.6.1 h1:2rRqUlwo+fdyIty8jEvUufRTgqBl0aea21LV6YQPqb0=
-github.com/charmbracelet/catwalk v0.6.1/go.mod h1:ReU4SdrLfe63jkEjWMdX2wlZMV3k9r11oQAmzN0m+KY=
+github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20251011205917-3b687ffc1619 h1:hjOhtqsxa+LVuCAkzhfA43wtusOaUPyQdSTg/wbRscw=
+github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20251011205917-3b687ffc1619/go.mod h1:5IzIGXU1n0foRc8bRAherC8ZuQCQURPlwx3ANLq1138=
+github.com/charmbracelet/catwalk v0.6.4 h1:zFHtuP94mSDE48nST3DS3a37wfsQqNcVnsFkS3v6N6E=
+github.com/charmbracelet/catwalk v0.6.4/go.mod h1:ReU4SdrLfe63jkEjWMdX2wlZMV3k9r11oQAmzN0m+KY=
github.com/charmbracelet/colorprofile v0.3.2 h1:9J27WdztfJQVAQKX2WOlSSRB+5gaKqqITmrvb1uTIiI=
github.com/charmbracelet/colorprofile v0.3.2/go.mod h1:mTD5XzNeWHj8oqHb+S1bssQb7vIHbepiebQ2kPKVKbI=
-github.com/charmbracelet/fang v0.4.2 h1:nWr7Tb82/TTNNGMGG35aTZ1X68loAOQmpb0qxkKXjas=
-github.com/charmbracelet/fang v0.4.2/go.mod h1:wHJKQYO5ReYsxx+yZl+skDtrlKO/4LLEQ6EXsdHhRhg=
+github.com/charmbracelet/fang v0.4.3 h1:qXeMxnL4H6mSKBUhDefHu8NfikFbP/MBNTfqTrXvzmY=
+github.com/charmbracelet/fang v0.4.3/go.mod h1:wHJKQYO5ReYsxx+yZl+skDtrlKO/4LLEQ6EXsdHhRhg=
github.com/charmbracelet/glamour/v2 v2.0.0-20250811143442-a27abb32f018 h1:PU4Zvpagsk5sgaDxn5W4sxHuLp9QRMBZB3bFSk40A4w=
github.com/charmbracelet/glamour/v2 v2.0.0-20250811143442-a27abb32f018/go.mod h1:Z/GLmp9fzaqX4ze3nXG7StgWez5uBM5XtlLHK8V/qSk=
github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3.0.20250917201909-41ff0bf215ea h1:g1HfUgSMvye8mgecMD1mPscpt+pzJoDEiSA+p2QXzdQ=
@@ -94,8 +94,8 @@ github.com/charmbracelet/log/v2 v2.0.0-20250226163916-c379e29ff706 h1:WkwO6Ks3mS
github.com/charmbracelet/log/v2 v2.0.0-20250226163916-c379e29ff706/go.mod h1:mjJGp00cxcfvD5xdCa+bso251Jt4owrQvuimJtVmEmM=
github.com/charmbracelet/ultraviolet v0.0.0-20250915111650-81d4262876ef h1:VrWaUi2LXYLjfjCHowdSOEc6dQ9Ro14KY7Bw4IWd19M=
github.com/charmbracelet/ultraviolet v0.0.0-20250915111650-81d4262876ef/go.mod h1:AThRsQH1t+dfyOKIwXRoJBniYFQUkUpQq4paheHMc2o=
-github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ=
-github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
+github.com/charmbracelet/x/ansi v0.10.2 h1:ith2ArZS0CJG30cIUfID1LXN7ZFXRCww6RUvAPA+Pzw=
+github.com/charmbracelet/x/ansi v0.10.2/go.mod h1:HbLdJjQH4UH4AqA2HpRWuWNluRE6zxJH/yteYEYCFa8=
github.com/charmbracelet/x/cellbuf v0.0.14-0.20250811133356-e0c5dbe5ea4a h1:zYSNtEJM9jwHbJts2k+Hroj+xQwsW1yxc4Wopdv7KaI=
github.com/charmbracelet/x/cellbuf v0.0.14-0.20250811133356-e0c5dbe5ea4a/go.mod h1:rc2bsPC6MWae3LdOxNO1mOb443NlMrrDL0xEya48NNc=
github.com/charmbracelet/x/exp/charmtone v0.0.0-20250708181618-a60a724ba6c3 h1:1xwHZg6eMZ9Wv5TE1UGub6ARubyOd1Lo5kPUI/6VL50=
@@ -130,8 +130,6 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
-github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
-github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
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=
@@ -144,13 +142,15 @@ 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-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI=
github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow=
-github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
-github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
+github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
+github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
+github.com/google/jsonschema-go v0.3.0 h1:6AH2TxVNtk3IlvkkhjrtbUc4S8AvO0Xii0DxIygDg+Q=
+github.com/google/jsonschema-go v0.3.0/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
github.com/google/s2a-go v0.1.8 h1:zZDs9gcbt9ZPLV0ndSyQk6Kacx2g/X+SKYovpnz3SMM=
github.com/google/s2a-go v0.1.8/go.mod h1:6iNWHTpQ+nfNRN5E00MSdfDwVesa8hhS32PhPO8deJA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -194,18 +194,18 @@ github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQ
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
-github.com/mark3labs/mcp-go v0.41.0 h1:IFfJaovCet65F3av00bE1HzSnmHpMRWM1kz96R98I70=
-github.com/mark3labs/mcp-go v0.41.0/go.mod h1:T7tUa2jO6MavG+3P25Oy/jR7iCeJPHImCZHRymCn39g=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
-github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
-github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
+github.com/mattn/go-runewidth v0.0.17 h1:78v8ZlW0bP43XfmAfPsdXcoNCelfMHsDmd/pkENfrjQ=
+github.com/mattn/go-runewidth v0.0.17/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
+github.com/modelcontextprotocol/go-sdk v1.0.0 h1:Z4MSjLi38bTgLrd/LjSmofqRqyBiVKRyQSJgw8q8V74=
+github.com/modelcontextprotocol/go-sdk v1.0.0/go.mod h1:nYtYQroQ2KQiM0/SbyEPUWQ6xs4B95gJjEalc9AQyOs=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/mango v0.1.0 h1:DZQK45d2gGbql1arsYA4vfg4d7I9Hfx5rX/GCmzsAvI=
@@ -218,8 +218,8 @@ github.com/muesli/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8=
github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
-github.com/ncruces/go-sqlite3 v0.29.0 h1:1tsLiagCoqZEfcHDeKsNSv5jvrY/Iu393pAnw2wLNJU=
-github.com/ncruces/go-sqlite3 v0.29.0/go.mod h1:r1hSvYKPNJ+OlUA1O3r8o9LAawzPAlqeZiIdxTBBBJ0=
+github.com/ncruces/go-sqlite3 v0.29.1 h1:NIi8AISWBToRHyoz01FXiTNvU147Tqdibgj2tFzJCqM=
+github.com/ncruces/go-sqlite3 v0.29.1/go.mod h1:PpccBNNhvjwUOwDQEn2gXQPFPTWdlromj0+fSkd5KSg=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/ncruces/julianday v1.0.0 h1:fH0OKwa7NWvniGQtxdJRxAgkBMolni2BjDHaWTxqt7M=
@@ -237,10 +237,10 @@ github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjL
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
-github.com/posthog/posthog-go v1.6.10 h1:OA6bkiUg89rI7f5cSXbcrH5+wLinyS6hHplnD92Pu/M=
-github.com/posthog/posthog-go v1.6.10/go.mod h1:LcC1Nu4AgvV22EndTtrMXTy+7RGVC0MhChSw7Qk5XkY=
-github.com/pressly/goose/v3 v3.25.0 h1:6WeYhMWGRCzpyd89SpODFnCBCKz41KrVbRT58nVjGng=
-github.com/pressly/goose/v3 v3.25.0/go.mod h1:4hC1KrritdCxtuFsqgs1R4AU5bWtTAf+cnWvfhf2DNY=
+github.com/posthog/posthog-go v1.6.11 h1:5G8Y3pxnOpc3S4+PK1z1dCmZRuldiWxBsqqvvSfC2+w=
+github.com/posthog/posthog-go v1.6.11/go.mod h1:LcC1Nu4AgvV22EndTtrMXTy+7RGVC0MhChSw7Qk5XkY=
+github.com/pressly/goose/v3 v3.26.0 h1:KJakav68jdH0WDvoAcj8+n61WqOIaPGgH0bJWS6jpmM=
+github.com/pressly/goose/v3 v3.26.0/go.mod h1:4hC1KrritdCxtuFsqgs1R4AU5bWtTAf+cnWvfhf2DNY=
github.com/qjebbs/go-jsons v1.0.0-alpha.4 h1:Qsb4ohRUHQODIUAsJKdKJ/SIDbsO7oGOzsfy+h1yQZs=
github.com/qjebbs/go-jsons v1.0.0-alpha.4/go.mod h1:wNJrtinHyC3YSf6giEh4FJN8+yZV7nXBjvmfjhBIcw4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
@@ -265,8 +265,6 @@ github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
github.com/sourcegraph/jsonrpc2 v0.2.1 h1:2GtljixMQYUYCmIg7W9aF2dFmniq/mOr2T9tFRh6zSQ=
github.com/sourcegraph/jsonrpc2 v0.2.1/go.mod h1:ZafdZgk/axhT1cvZAPOhw+95nz2I/Ra5qMlU4gTRwIo=
-github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
-github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
@@ -338,8 +336,8 @@ golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDf
golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
-golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
-golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
+golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
+golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
golang.org/x/image v0.26.0 h1:4XjIFEZWQmCZi6Wv8BoxsDhRU3RVnLX04dToTDAEPlY=
@@ -401,8 +399,8 @@ golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
-golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4=
-golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw=
+golang.org/x/term v0.35.0 h1:bZBVKBudEyhRcajGcNc3jIfWPqV4y/Kt2XcoigOWtDQ=
+golang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
@@ -412,8 +410,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
-golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
-golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
+golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
+golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg=
golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -422,11 +420,13 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
+golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
+golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/api v0.211.0 h1:IUpLjq09jxBSV1lACO33CGY3jsRcbctfGzhj+ZSE/Bg=
google.golang.org/api v0.211.0/go.mod h1:XOloB4MXFH4UTlQSGuNUxw0UT74qdENK8d6JNsXKLi0=
-google.golang.org/genai v1.26.0 h1:r4HGL54kFv/WCRMTAbZg05Ct+vXfhAbTRlXhFyBkEQo=
-google.golang.org/genai v1.26.0/go.mod h1:OClfdf+r5aaD+sCd4aUSkPzJItmg2wD/WON9lQnRPaY=
+google.golang.org/genai v1.30.0 h1:7021aneIvl24nEBLbtQFEWleHsMbjzpcQvkT4WcJ1dc=
+google.golang.org/genai v1.30.0/go.mod h1:7pAilaICJlQBonjKKJNhftDFv3SREhZcTe9F6nRcjbg=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463 h1:e0AIkUUhxyBKh6ssZNrAMeqhA7RKUj42346d1y02i2g=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
google.golang.org/grpc v1.71.0 h1:kF77BGdPTQ4/JZWMlb9VpJ5pa25aqvVqogsxNHHdeBg=
@@ -17,13 +17,13 @@ import (
"github.com/charmbracelet/crush/internal/history"
"github.com/charmbracelet/crush/internal/llm/agent"
"github.com/charmbracelet/crush/internal/log"
- "github.com/charmbracelet/crush/internal/pubsub"
-
"github.com/charmbracelet/crush/internal/lsp"
"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/update"
+ "github.com/charmbracelet/x/ansi"
)
type App struct {
@@ -111,7 +111,6 @@ func (app *App) RunNonInteractive(ctx context.Context, prompt string, quiet bool
ctx, cancel := context.WithCancel(ctx)
defer cancel()
- // Start spinner if not in quiet mode.
var spinner *format.Spinner
if !quiet {
spinner = format.NewSpinner(ctx, cancel, "Generating")
@@ -155,7 +154,11 @@ func (app *App) RunNonInteractive(ctx context.Context, prompt string, quiet bool
messageEvents := app.Messages.Subscribe(ctx)
messageReadBytes := make(map[string]int)
+ defer fmt.Printf(ansi.ResetProgressBar)
for {
+ // HACK: add it again on every iteration so it doesn't get hidden by
+ // the terminal due to inactivity.
+ fmt.Printf(ansi.SetIndeterminateProgressBar)
select {
case result := <-done:
stopSpinner()
@@ -0,0 +1,66 @@
+package cmd
+
+import (
+ "os"
+ "path/filepath"
+
+ "github.com/charmbracelet/crush/internal/config"
+ "github.com/charmbracelet/lipgloss/v2"
+ "github.com/charmbracelet/lipgloss/v2/table"
+ "github.com/charmbracelet/x/term"
+ "github.com/spf13/cobra"
+)
+
+var dirsCmd = &cobra.Command{
+ Use: "dirs",
+ Short: "Print directories used by Crush",
+ Long: `Print the directories where Crush stores its configuration and data files.
+This includes the global configuration directory and data directory.`,
+ Example: `
+# Print all directories
+crush dirs
+
+# Print only the config directory
+crush dirs config
+
+# Print only the data directory
+crush dirs data
+ `,
+ Run: func(cmd *cobra.Command, args []string) {
+ if term.IsTerminal(os.Stdout.Fd()) {
+ // We're in a TTY: make it fancy.
+ t := table.New().
+ Border(lipgloss.RoundedBorder()).
+ StyleFunc(func(row, col int) lipgloss.Style {
+ return lipgloss.NewStyle().Padding(0, 2)
+ }).
+ Row("Config", filepath.Dir(config.GlobalConfig())).
+ Row("Data", filepath.Dir(config.GlobalConfigData()))
+ lipgloss.Println(t)
+ return
+ }
+ // Not a TTY.
+ cmd.Println(filepath.Dir(config.GlobalConfig()))
+ cmd.Println(filepath.Dir(config.GlobalConfigData()))
+ },
+}
+
+var configDirCmd = &cobra.Command{
+ Use: "config",
+ Short: "Print the configuration directory used by Crush",
+ Run: func(cmd *cobra.Command, args []string) {
+ cmd.Println(filepath.Dir(config.GlobalConfig()))
+ },
+}
+
+var dataDirCmd = &cobra.Command{
+ Use: "data",
+ Short: "Print the datauration directory used by Crush",
+ Run: func(cmd *cobra.Command, args []string) {
+ cmd.Println(filepath.Dir(config.GlobalConfigData()))
+ },
+}
+
+func init() {
+ dirsCmd.AddCommand(configDirCmd, dataDirCmd)
+}
@@ -68,7 +68,6 @@ var logsCmd = &cobra.Command{
func init() {
logsCmd.Flags().BoolP("follow", "f", false, "Follow log output")
logsCmd.Flags().IntP("tail", "t", defaultTailLines, "Show only the last N lines default: 1000 for performance")
- rootCmd.AddCommand(logsCmd)
}
func followLogs(ctx context.Context, logsFile string, tailLines int) error {
@@ -1,7 +1,9 @@
package cmd
import (
+ "bytes"
"context"
+ "errors"
"fmt"
"io"
"log/slog"
@@ -10,6 +12,7 @@ import (
"strconv"
tea "github.com/charmbracelet/bubbletea/v2"
+ "github.com/charmbracelet/colorprofile"
"github.com/charmbracelet/crush/internal/app"
"github.com/charmbracelet/crush/internal/config"
"github.com/charmbracelet/crush/internal/db"
@@ -17,6 +20,8 @@ import (
"github.com/charmbracelet/crush/internal/tui"
"github.com/charmbracelet/crush/internal/version"
"github.com/charmbracelet/fang"
+ "github.com/charmbracelet/lipgloss/v2"
+ "github.com/charmbracelet/x/exp/charmtone"
"github.com/charmbracelet/x/term"
"github.com/spf13/cobra"
)
@@ -29,8 +34,13 @@ func init() {
rootCmd.Flags().BoolP("help", "h", false, "Help")
rootCmd.Flags().BoolP("yolo", "y", false, "Automatically accept all permissions (dangerous mode)")
- rootCmd.AddCommand(runCmd)
- rootCmd.AddCommand(updateProvidersCmd)
+ rootCmd.AddCommand(
+ runCmd,
+ dirsCmd,
+ updateProvidersCmd,
+ logsCmd,
+ schemaCmd,
+ )
}
var rootCmd = &cobra.Command{
@@ -84,7 +94,7 @@ crush -y
if _, err := program.Run(); err != nil {
event.Error(err)
slog.Error("TUI run error", "error", err)
- return fmt.Errorf("TUI error: %v", err)
+ return errors.New("Crush crashed. If metrics are enabled, we were notified about it. If you'd like to report it, please copy the stacktrace above and open an issue at https://github.com/charmbracelet/crush/issues/new?template=bug.yml") //nolint:staticcheck
}
return nil
},
@@ -93,7 +103,39 @@ crush -y
},
}
+var heartbit = lipgloss.NewStyle().Foreground(charmtone.Dolly).SetString(`
+ โโโโโโโโ โโโโโโโโ
+ โโโโโโโโโโโ โโโโโโโโโโโ
+โโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+โโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+โโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+โโโโโโโโโโ โโโโโโ โโโโโโโโโโ
+โโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+ โโโโโโโโโโโโโโโโโโโโโโโโ
+ โโโโโโโโโโโโโโโโโโโโ
+ โโโโโโโโโโโโโโ
+ โโโโโโ
+`)
+
+// copied from cobra:
+const defaultVersionTemplate = `{{with .DisplayName}}{{printf "%s " .}}{{end}}{{printf "version %s" .Version}}
+`
+
func Execute() {
+ // NOTE: very hacky: we create a colorprofile writer with STDOUT, then make
+ // it forward to a bytes.Buffer, write the colored heartbit to it, and then
+ // finally prepend it in the version template.
+ // Unfortunately cobra doesn't give us a way to set a function to handle
+ // printing the version, and PreRunE runs after the version is already
+ // handled, so that doesn't work either.
+ // This is the only way I could find that works relatively well.
+ if term.IsTerminal(os.Stdout.Fd()) {
+ var b bytes.Buffer
+ w := colorprofile.NewWriter(os.Stdout, os.Environ())
+ w.Forward = &b
+ _, _ = w.WriteString(heartbit.String())
+ rootCmd.SetVersionTemplate(b.String() + "\n" + defaultVersionTemplate)
+ }
if err := fang.Execute(
context.Background(),
rootCmd,
@@ -24,7 +24,3 @@ var schemaCmd = &cobra.Command{
return nil
},
}
-
-func init() {
- rootCmd.AddCommand(schemaCmd)
-}
@@ -99,7 +99,7 @@ type MCPType string
const (
MCPStdio MCPType = "stdio"
- MCPSse MCPType = "sse"
+ MCPSSE MCPType = "sse"
MCPHttp MCPType = "http"
)
@@ -131,6 +131,19 @@ type TUIOptions struct {
CompactMode bool `json:"compact_mode,omitempty" jsonschema:"description=Enable compact mode for the TUI interface,default=false"`
DiffMode string `json:"diff_mode,omitempty" jsonschema:"description=Diff mode for the TUI interface,enum=unified,enum=split"`
// Here we can add themes later or any TUI related options
+ //
+
+ Completions Completions `json:"completions,omitzero" jsonschema:"description=Completions UI options"`
+}
+
+// Completions defines options for the completions UI.
+type Completions struct {
+ MaxDepth *int `json:"max_depth,omitempty" jsonschema:"description=Maximum depth for the ls tool,default=0,example=10"`
+ MaxItems *int `json:"max_items,omitempty" jsonschema:"description=Maximum number of items to return for the ls tool,default=1000,example=100"`
+}
+
+func (c Completions) Limits() (depth, items int) {
+ return ptrValOr(c.MaxDepth, 0), ptrValOr(c.MaxItems, 0)
}
type Permissions struct {
@@ -246,6 +259,19 @@ type Agent struct {
ContextPaths []string `json:"context_paths,omitempty"`
}
+type Tools struct {
+ Ls ToolLs `json:"ls,omitzero"`
+}
+
+type ToolLs struct {
+ MaxDepth *int `json:"max_depth,omitempty" jsonschema:"description=Maximum depth for the ls tool,default=0,example=10"`
+ MaxItems *int `json:"max_items,omitempty" jsonschema:"description=Maximum number of items to return for the ls tool,default=1000,example=100"`
+}
+
+func (t ToolLs) Limits() (depth, items int) {
+ return ptrValOr(t.MaxDepth, 0), ptrValOr(t.MaxItems, 0)
+}
+
// Config holds the configuration for crush.
type Config struct {
Schema string `json:"$schema,omitempty"`
@@ -264,6 +290,8 @@ type Config struct {
Permissions *Permissions `json:"permissions,omitempty" jsonschema:"description=Permission settings for tool usage"`
+ Tools Tools `json:"tools,omitzero" jsonschema:"description=Tool configurations"`
+
// Internal
workingDir string `json:"-"`
// TODO: most likely remove this concept when I come back to it
@@ -579,3 +607,10 @@ func resolveEnvs(envs map[string]string) []string {
}
return res
}
+
+func ptrValOr[T any](t *T, el T) T {
+ if t == nil {
+ return el
+ }
+ return *t
+}
@@ -1,12 +1,14 @@
package config
import (
+ "context"
"encoding/json"
"fmt"
"io"
"log/slog"
"maps"
"os"
+ "os/exec"
"path/filepath"
"runtime"
"slices"
@@ -62,6 +64,16 @@ func Load(workingDir, dataDir string, debug bool) (*Config, error) {
cfg.Options.Debug,
)
+ if !isInsideWorktree() {
+ const depth = 2
+ const items = 100
+ slog.Warn("No git repository detected in working directory, will limit file walk operations", "depth", depth, "items", items)
+ assignIfNil(&cfg.Tools.Ls.MaxDepth, depth)
+ assignIfNil(&cfg.Tools.Ls.MaxItems, items)
+ assignIfNil(&cfg.Options.TUI.Completions.MaxDepth, depth)
+ assignIfNil(&cfg.Options.TUI.Completions.MaxItems, items)
+ }
+
// Load known providers, this loads the config from catwalk
providers, err := Providers(cfg)
if err != nil {
@@ -520,7 +532,7 @@ func (c *Config) configureSelectedModels(knownProviders []catwalk.Provider) erro
func lookupConfigs(cwd string) []string {
// prepend default config paths
configPaths := []string{
- globalConfig(),
+ GlobalConfig(),
GlobalConfigData(),
}
@@ -593,10 +605,16 @@ func hasAWSCredentials(env env.Env) bool {
env.Get("AWS_CONTAINER_CREDENTIALS_FULL_URI") != "" {
return true
}
+
+ if _, err := os.Stat(filepath.Join(home.Dir(), ".aws/credentials")); err == nil {
+ return true
+ }
+
return false
}
-func globalConfig() string {
+// GlobalConfig returns the global configuration file path for the application.
+func GlobalConfig() string {
xdgConfigHome := os.Getenv("XDG_CONFIG_HOME")
if xdgConfigHome != "" {
return filepath.Join(xdgConfigHome, appName, fmt.Sprintf("%s.json", appName))
@@ -637,3 +655,18 @@ func GlobalConfigData() string {
return filepath.Join(home.Dir(), ".local", "share", appName, fmt.Sprintf("%s.json", appName))
}
+
+func assignIfNil[T any](ptr **T, val T) {
+ if *ptr == nil {
+ *ptr = &val
+ }
+}
+
+func isInsideWorktree() bool {
+ bts, err := exec.CommandContext(
+ context.Background(),
+ "git", "rev-parse",
+ "--is-inside-work-tree",
+ ).CombinedOutput()
+ return err == nil && strings.TrimSpace(string(bts)) == "true"
+}
@@ -27,6 +27,25 @@ func NewMapFrom[K comparable, V any](m map[K]V) *Map[K, V] {
}
}
+// NewLazyMap creates a new lazy-loaded map. The provided load function is
+// executed in a separate goroutine to populate the map.
+func NewLazyMap[K comparable, V any](load func() map[K]V) *Map[K, V] {
+ m := &Map[K, V]{}
+ m.mu.Lock()
+ go func() {
+ m.inner = load()
+ m.mu.Unlock()
+ }()
+ return m
+}
+
+// Reset replaces the inner map with the new one.
+func (m *Map[K, V]) Reset(input map[K]V) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ m.inner = input
+}
+
// Set sets the value for the specified key in the map.
func (m *Map[K, V]) Set(key K, value V) {
m.mu.Lock()
@@ -5,6 +5,8 @@ import (
"maps"
"sync"
"testing"
+ "testing/synctest"
+ "time"
"github.com/stretchr/testify/require"
)
@@ -36,6 +38,56 @@ func TestNewMapFrom(t *testing.T) {
require.Equal(t, 1, value)
}
+func TestNewLazyMap(t *testing.T) {
+ t.Parallel()
+
+ synctest.Test(t, func(t *testing.T) {
+ t.Helper()
+
+ waiter := sync.Mutex{}
+ waiter.Lock()
+ loadCalled := false
+
+ loadFunc := func() map[string]int {
+ waiter.Lock()
+ defer waiter.Unlock()
+ loadCalled = true
+ return map[string]int{
+ "key1": 1,
+ "key2": 2,
+ }
+ }
+
+ m := NewLazyMap(loadFunc)
+ require.NotNil(t, m)
+
+ waiter.Unlock() // Allow the load function to proceed
+ time.Sleep(100 * time.Millisecond)
+ require.True(t, loadCalled)
+ require.Equal(t, 2, m.Len())
+
+ value, ok := m.Get("key1")
+ require.True(t, ok)
+ require.Equal(t, 1, value)
+ })
+}
+
+func TestMap_Reset(t *testing.T) {
+ t.Parallel()
+
+ m := NewMapFrom(map[string]int{
+ "a": 10,
+ })
+
+ m.Reset(map[string]int{
+ "b": 20,
+ })
+ value, ok := m.Get("b")
+ require.True(t, ok)
+ require.Equal(t, 20, value)
+ require.Equal(t, 1, m.Len())
+}
+
func TestMap_Set(t *testing.T) {
t.Parallel()
@@ -1,15 +1,17 @@
package fsext
import (
+ "errors"
"fmt"
"os"
"path/filepath"
- "sort"
+ "slices"
"strings"
"time"
"github.com/bmatcuk/doublestar/v4"
"github.com/charlievieth/fastwalk"
+ "github.com/charmbracelet/crush/internal/csync"
"github.com/charmbracelet/crush/internal/home"
)
@@ -80,10 +82,9 @@ func GlobWithDoubleStar(pattern, searchPath string, limit int) ([]string, bool,
pattern = filepath.ToSlash(pattern)
walker := NewFastGlobWalker(searchPath)
- var matches []FileInfo
+ found := csync.NewSlice[FileInfo]()
conf := fastwalk.Config{
- Follow: true,
- // Use forward slashes when running a Windows binary under WSL or MSYS
+ Follow: true,
ToSlash: fastwalk.DefaultToSlash(),
Sort: fastwalk.SortFilesFirst,
}
@@ -121,31 +122,26 @@ func GlobWithDoubleStar(pattern, searchPath string, limit int) ([]string, bool,
return nil
}
- matches = append(matches, FileInfo{Path: path, ModTime: info.ModTime()})
- if limit > 0 && len(matches) >= limit*2 {
+ found.Append(FileInfo{Path: path, ModTime: info.ModTime()})
+ if limit > 0 && found.Len() >= limit*2 { // NOTE: why x2?
return filepath.SkipAll
}
return nil
})
- if err != nil {
+ if err != nil && !errors.Is(err, filepath.SkipAll) {
return nil, false, fmt.Errorf("fastwalk error: %w", err)
}
- sort.Slice(matches, func(i, j int) bool {
- return matches[i].ModTime.After(matches[j].ModTime)
+ matches := slices.SortedFunc(found.Seq(), func(a, b FileInfo) int {
+ return b.ModTime.Compare(a.ModTime)
})
-
- truncated := false
- if limit > 0 && len(matches) > limit {
- matches = matches[:limit]
- truncated = true
- }
+ matches, truncated := truncate(matches, limit)
results := make([]string, len(matches))
for i, m := range matches {
results[i] = m.Path
}
- return results, truncated, nil
+ return results, truncated || errors.Is(err, filepath.SkipAll), nil
}
// ShouldExcludeFile checks if a file should be excluded from processing
@@ -155,36 +151,6 @@ func ShouldExcludeFile(rootPath, filePath string) bool {
shouldIgnore(filePath, nil)
}
-// WalkDirectories walks a directory tree and calls the provided function for each directory,
-// respecting hierarchical .gitignore/.crushignore files like git does.
-func WalkDirectories(rootPath string, fn func(path string, d os.DirEntry, err error) error) error {
- dl := NewDirectoryLister(rootPath)
-
- conf := fastwalk.Config{
- Follow: true,
- ToSlash: fastwalk.DefaultToSlash(),
- Sort: fastwalk.SortDirsFirst,
- }
-
- return fastwalk.Walk(&conf, rootPath, func(path string, d os.DirEntry, err error) error {
- if err != nil {
- return fn(path, d, err)
- }
-
- // Only process directories
- if !d.IsDir() {
- return nil
- }
-
- // Check if directory should be ignored
- if dl.shouldIgnore(path, nil) {
- return filepath.SkipDir
- }
-
- return fn(path, d, err)
- })
-}
-
func PrettyPath(path string) string {
return home.Short(path)
}
@@ -248,3 +214,10 @@ func ToWindowsLineEndings(content string) (string, bool) {
}
return content, false
}
+
+func truncate[T any](input []T, limit int) ([]T, bool) {
+ if limit > 0 && len(input) > limit {
+ return input[:limit], true
+ }
+ return input, false
+}
@@ -5,7 +5,6 @@ import (
"os"
"path/filepath"
"testing"
- "testing/synctest"
"time"
"github.com/stretchr/testify/require"
@@ -148,37 +147,35 @@ func TestGlobWithDoubleStar(t *testing.T) {
require.NoError(t, err)
require.False(t, truncated)
- require.Equal(t, matches, []string{file1})
+ require.Equal(t, []string{file1}, matches)
})
t.Run("returns results sorted by modification time (newest first)", func(t *testing.T) {
- synctest.Test(t, func(t *testing.T) {
- testDir := t.TempDir()
+ testDir := t.TempDir()
- file1 := filepath.Join(testDir, "file1.txt")
- require.NoError(t, os.WriteFile(file1, []byte("first"), 0o644))
+ file1 := filepath.Join(testDir, "file1.txt")
+ require.NoError(t, os.WriteFile(file1, []byte("first"), 0o644))
- file2 := filepath.Join(testDir, "file2.txt")
- require.NoError(t, os.WriteFile(file2, []byte("second"), 0o644))
+ file2 := filepath.Join(testDir, "file2.txt")
+ require.NoError(t, os.WriteFile(file2, []byte("second"), 0o644))
- file3 := filepath.Join(testDir, "file3.txt")
- require.NoError(t, os.WriteFile(file3, []byte("third"), 0o644))
+ file3 := filepath.Join(testDir, "file3.txt")
+ require.NoError(t, os.WriteFile(file3, []byte("third"), 0o644))
- base := time.Now()
- m1 := base
- m2 := base.Add(1 * time.Millisecond)
- m3 := base.Add(2 * time.Millisecond)
+ base := time.Now()
+ m1 := base
+ m2 := base.Add(10 * time.Hour)
+ m3 := base.Add(20 * time.Hour)
- require.NoError(t, os.Chtimes(file1, m1, m1))
- require.NoError(t, os.Chtimes(file2, m2, m2))
- require.NoError(t, os.Chtimes(file3, m3, m3))
+ require.NoError(t, os.Chtimes(file1, m1, m1))
+ require.NoError(t, os.Chtimes(file2, m2, m2))
+ require.NoError(t, os.Chtimes(file3, m3, m3))
- matches, truncated, err := GlobWithDoubleStar("*.txt", testDir, 0)
- require.NoError(t, err)
- require.False(t, truncated)
+ matches, truncated, err := GlobWithDoubleStar("*.txt", testDir, 0)
+ require.NoError(t, err)
+ require.False(t, truncated)
- require.Equal(t, matches, []string{file3, file2, file1})
- })
+ require.Equal(t, []string{file3, file2, file1}, matches)
})
t.Run("handles empty directory", func(t *testing.T) {
@@ -188,7 +185,7 @@ func TestGlobWithDoubleStar(t *testing.T) {
require.NoError(t, err)
require.False(t, truncated)
// Even empty directories should return the directory itself
- require.Equal(t, matches, []string{testDir})
+ require.Equal(t, []string{testDir}, matches)
})
t.Run("handles non-existent search path", func(t *testing.T) {
@@ -235,39 +232,38 @@ func TestGlobWithDoubleStar(t *testing.T) {
matches, truncated, err = GlobWithDoubleStar("*.txt", testDir, 0)
require.NoError(t, err)
require.False(t, truncated)
- require.Equal(t, matches, []string{goodFile})
+ require.Equal(t, []string{goodFile}, matches)
})
t.Run("handles mixed file and directory matching with sorting", func(t *testing.T) {
- synctest.Test(t, func(t *testing.T) {
- testDir := t.TempDir()
+ testDir := t.TempDir()
- oldestFile := filepath.Join(testDir, "old.test")
- require.NoError(t, os.WriteFile(oldestFile, []byte("old"), 0o644))
+ oldestFile := filepath.Join(testDir, "old.rs")
+ require.NoError(t, os.WriteFile(oldestFile, []byte("old"), 0o644))
- middleDir := filepath.Join(testDir, "mid.test")
- require.NoError(t, os.MkdirAll(middleDir, 0o755))
+ middleDir := filepath.Join(testDir, "mid.rs")
+ require.NoError(t, os.MkdirAll(middleDir, 0o755))
- newestFile := filepath.Join(testDir, "new.test")
- require.NoError(t, os.WriteFile(newestFile, []byte("new"), 0o644))
+ newestFile := filepath.Join(testDir, "new.rs")
+ require.NoError(t, os.WriteFile(newestFile, []byte("new"), 0o644))
- base := time.Now()
- tOldest := base
- tMiddle := base.Add(1 * time.Millisecond)
- tNewest := base.Add(2 * time.Millisecond)
+ base := time.Now()
+ tOldest := base
+ tMiddle := base.Add(10 * time.Hour)
+ tNewest := base.Add(20 * time.Hour)
- // Reverse the expected order
- require.NoError(t, os.Chtimes(newestFile, tOldest, tOldest))
- require.NoError(t, os.Chtimes(middleDir, tMiddle, tMiddle))
- require.NoError(t, os.Chtimes(oldestFile, tNewest, tNewest))
+ // Reverse the expected order
+ require.NoError(t, os.Chtimes(newestFile, tOldest, tOldest))
+ require.NoError(t, os.Chtimes(middleDir, tMiddle, tMiddle))
+ require.NoError(t, os.Chtimes(oldestFile, tNewest, tNewest))
- matches, truncated, err := GlobWithDoubleStar("*.test", testDir, 0)
- require.NoError(t, err)
- require.False(t, truncated)
+ matches, truncated, err := GlobWithDoubleStar("*.rs", testDir, 0)
+ require.NoError(t, err)
+ require.False(t, truncated)
+ require.Len(t, matches, 3)
- // Results should be sorted by mod time, but we set the oldestFile
- // to have the most recent mod time
- require.Equal(t, matches, []string{oldestFile, middleDir, newestFile})
- })
+ // Results should be sorted by mod time, but we set the oldestFile
+ // to have the most recent mod time
+ require.Equal(t, []string{oldestFile, middleDir, newestFile}, matches)
})
}
@@ -9,14 +9,8 @@ import (
)
func TestCrushIgnore(t *testing.T) {
- // Create a temporary directory for testing
tempDir := t.TempDir()
-
- // Change to temp directory
- oldWd, _ := os.Getwd()
- err := os.Chdir(tempDir)
- require.NoError(t, err)
- defer os.Chdir(oldWd)
+ t.Chdir(tempDir)
// Create test files
require.NoError(t, os.WriteFile("test1.txt", []byte("test"), 0o644))
@@ -12,15 +12,7 @@ import (
func TestLookupClosest(t *testing.T) {
tempDir := t.TempDir()
-
- // Change to temp directory
- oldWd, _ := os.Getwd()
- err := os.Chdir(tempDir)
- require.NoError(t, err)
-
- t.Cleanup(func() {
- os.Chdir(oldWd)
- })
+ t.Chdir(tempDir)
t.Run("target found in starting directory", func(t *testing.T) {
testDir := t.TempDir()
@@ -114,24 +106,15 @@ func TestLookupClosest(t *testing.T) {
})
t.Run("relative path handling", func(t *testing.T) {
- testDir := t.TempDir()
-
- // Change to test directory
- oldWd, _ := os.Getwd()
- err := os.Chdir(testDir)
- require.NoError(t, err)
- defer os.Chdir(oldWd)
-
// Create target file in current directory
- err = os.WriteFile("target.txt", []byte("test"), 0o644)
- require.NoError(t, err)
+ require.NoError(t, os.WriteFile("target.txt", []byte("test"), 0o644))
// Search using relative path
foundPath, found := LookupClosest(".", "target.txt")
require.True(t, found)
// Resolve symlinks to handle macOS /private/var vs /var discrepancy
- expectedPath, err := filepath.EvalSymlinks(filepath.Join(testDir, "target.txt"))
+ expectedPath, err := filepath.EvalSymlinks(filepath.Join(tempDir, "target.txt"))
require.NoError(t, err)
actualPath, err := filepath.EvalSymlinks(foundPath)
require.NoError(t, err)
@@ -145,15 +128,7 @@ func TestLookupClosestWithOwnership(t *testing.T) {
// This test focuses on the basic functionality when ownership checks pass.
tempDir := t.TempDir()
-
- // Change to temp directory
- oldWd, _ := os.Getwd()
- err := os.Chdir(tempDir)
- require.NoError(t, err)
-
- t.Cleanup(func() {
- os.Chdir(oldWd)
- })
+ t.Chdir(tempDir)
t.Run("search respects same ownership", func(t *testing.T) {
testDir := t.TempDir()
@@ -177,15 +152,7 @@ func TestLookupClosestWithOwnership(t *testing.T) {
func TestLookup(t *testing.T) {
tempDir := t.TempDir()
-
- // Change to temp directory
- oldWd, _ := os.Getwd()
- err := os.Chdir(tempDir)
- require.NoError(t, err)
-
- t.Cleanup(func() {
- os.Chdir(oldWd)
- })
+ t.Chdir(tempDir)
t.Run("no targets returns empty slice", func(t *testing.T) {
testDir := t.TempDir()
@@ -358,22 +325,9 @@ func TestLookup(t *testing.T) {
})
t.Run("relative path handling", func(t *testing.T) {
- testDir := t.TempDir()
-
- // Change to test directory
- oldWd, _ := os.Getwd()
- err := os.Chdir(testDir)
- require.NoError(t, err)
-
- t.Cleanup(func() {
- os.Chdir(oldWd)
- })
-
// Create target files in current directory
- err = os.WriteFile("target1.txt", []byte("test1"), 0o644)
- require.NoError(t, err)
- err = os.WriteFile("target2.txt", []byte("test2"), 0o644)
- require.NoError(t, err)
+ require.NoError(t, os.WriteFile("target1.txt", []byte("test1"), 0o644))
+ require.NoError(t, os.WriteFile("target2.txt", []byte("test2"), 0o644))
// Search using relative path
found, err := Lookup(".", "target1.txt", "target2.txt")
@@ -381,9 +335,9 @@ func TestLookup(t *testing.T) {
require.Len(t, found, 2)
// Resolve symlinks to handle macOS /private/var vs /var discrepancy
- expectedPath1, err := filepath.EvalSymlinks(filepath.Join(testDir, "target1.txt"))
+ expectedPath1, err := filepath.EvalSymlinks(filepath.Join(tempDir, "target1.txt"))
require.NoError(t, err)
- expectedPath2, err := filepath.EvalSymlinks(filepath.Join(testDir, "target2.txt"))
+ expectedPath2, err := filepath.EvalSymlinks(filepath.Join(tempDir, "target2.txt"))
require.NoError(t, err)
// Check that found paths match expected paths (order may vary)
@@ -1,6 +1,7 @@
package fsext
import (
+ "errors"
"log/slog"
"os"
"path/filepath"
@@ -71,6 +72,11 @@ var commonIgnorePatterns = sync.OnceValue(func() ignore.IgnoreParser {
// Crush
".crush",
+
+ // macOS stuff
+ "OrbStack",
+ ".local",
+ ".share",
)
})
@@ -200,16 +206,17 @@ func (dl *directoryLister) getIgnore(path string) ignore.IgnoreParser {
}
// ListDirectory lists files and directories in the specified path,
-func ListDirectory(initialPath string, ignorePatterns []string, limit int) ([]string, bool, error) {
- results := csync.NewSlice[string]()
- truncated := false
+func ListDirectory(initialPath string, ignorePatterns []string, depth, limit int) ([]string, bool, error) {
+ found := csync.NewSlice[string]()
dl := NewDirectoryLister(initialPath)
+ slog.Debug("listing directory", "path", initialPath, "depth", depth, "limit", limit, "ignorePatterns", ignorePatterns)
+
conf := fastwalk.Config{
- Follow: true,
- // Use forward slashes when running a Windows binary under WSL or MSYS
- ToSlash: fastwalk.DefaultToSlash(),
- Sort: fastwalk.SortDirsFirst,
+ Follow: true,
+ ToSlash: fastwalk.DefaultToSlash(),
+ Sort: fastwalk.SortDirsFirst,
+ MaxDepth: depth,
}
err := fastwalk.Walk(&conf, initialPath, func(path string, d os.DirEntry, err error) error {
@@ -228,19 +235,19 @@ func ListDirectory(initialPath string, ignorePatterns []string, limit int) ([]st
if d.IsDir() {
path = path + string(filepath.Separator)
}
- results.Append(path)
+ found.Append(path)
}
- if limit > 0 && results.Len() >= limit {
- truncated = true
+ if limit > 0 && found.Len() >= limit {
return filepath.SkipAll
}
return nil
})
- if err != nil && results.Len() == 0 {
- return nil, truncated, err
+ if err != nil && !errors.Is(err, filepath.SkipAll) {
+ return nil, false, err
}
- return slices.Collect(results.Seq()), truncated, nil
+ matches, truncated := truncate(slices.Collect(found.Seq()), limit)
+ return matches, truncated || errors.Is(err, filepath.SkipAll), nil
}
@@ -5,26 +5,11 @@ import (
"path/filepath"
"testing"
- "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
-func chdir(t *testing.T, dir string) {
- original, err := os.Getwd()
- require.NoError(t, err)
-
- err = os.Chdir(dir)
- require.NoError(t, err)
-
- t.Cleanup(func() {
- err := os.Chdir(original)
- require.NoError(t, err)
- })
-}
-
func TestListDirectory(t *testing.T) {
- tempDir := t.TempDir()
- chdir(t, tempDir)
+ tmp := t.TempDir()
testFiles := map[string]string{
"regular.txt": "content",
@@ -35,32 +20,40 @@ func TestListDirectory(t *testing.T) {
"build.log": "build output",
}
- for filePath, content := range testFiles {
- dir := filepath.Dir(filePath)
- if dir != "." {
- require.NoError(t, os.MkdirAll(dir, 0o755))
- }
-
- err := os.WriteFile(filePath, []byte(content), 0o644)
- require.NoError(t, err)
+ for name, content := range testFiles {
+ fp := filepath.Join(tmp, name)
+ dir := filepath.Dir(fp)
+ require.NoError(t, os.MkdirAll(dir, 0o755))
+ require.NoError(t, os.WriteFile(fp, []byte(content), 0o644))
}
- files, truncated, err := ListDirectory(".", nil, 0)
- require.NoError(t, err)
- assert.False(t, truncated)
- assert.Equal(t, len(files), 4)
+ t.Run("no limit", func(t *testing.T) {
+ files, truncated, err := ListDirectory(tmp, nil, -1, -1)
+ require.NoError(t, err)
+ require.False(t, truncated)
+ require.Len(t, files, 4)
+ require.ElementsMatch(t, []string{
+ "regular.txt",
+ "subdir",
+ "subdir/.another",
+ "subdir/file.go",
+ }, relPaths(t, files, tmp))
+ })
+ t.Run("limit", func(t *testing.T) {
+ files, truncated, err := ListDirectory(tmp, nil, -1, 2)
+ require.NoError(t, err)
+ require.True(t, truncated)
+ require.Len(t, files, 2)
+ })
+}
- fileSet := make(map[string]bool)
- for _, file := range files {
- fileSet[filepath.ToSlash(file)] = true
+func relPaths(tb testing.TB, in []string, base string) []string {
+ tb.Helper()
+ out := make([]string, 0, len(in))
+ for _, p := range in {
+ rel, err := filepath.Rel(base, p)
+ require.NoError(tb, err)
+ out = append(out, filepath.ToSlash(rel))
}
-
- assert.True(t, fileSet["./regular.txt"])
- assert.True(t, fileSet["./subdir/"])
- assert.True(t, fileSet["./subdir/file.go"])
- assert.True(t, fileSet["./regular.txt"])
-
- assert.False(t, fileSet["./.hidden"])
- assert.False(t, fileSet["./.gitignore"])
- assert.False(t, fileSet["./build.log"])
+ return out
}
@@ -1,3 +1,4 @@
+// Package agent contains the implementation of the AI agent service.
package agent
import (
@@ -5,6 +6,7 @@ import (
"errors"
"fmt"
"log/slog"
+ "maps"
"slices"
"strings"
"time"
@@ -65,11 +67,13 @@ type agent struct {
sessions session.Service
messages message.Service
permissions permission.Service
- mcpTools []McpTool
+ baseTools *csync.Map[string, tools.BaseTool]
+ mcpTools *csync.Map[string, tools.BaseTool]
+ lspClients *csync.Map[string, *lsp.Client]
- tools *csync.LazySlice[tools.BaseTool]
// We need this to be able to update it when model changes
- agentToolFn func() (tools.BaseTool, error)
+ agentToolFn func() (tools.BaseTool, error)
+ cleanupFuncs []func()
provider provider.Provider
providerID string
@@ -171,14 +175,16 @@ func NewAgent(
return nil, err
}
- toolFn := func() []tools.BaseTool {
- slog.Info("Initializing agent tools", "agent", agentCfg.ID)
+ baseToolsFn := func() map[string]tools.BaseTool {
+ slog.Debug("Initializing agent base tools", "agent", agentCfg.ID)
defer func() {
- slog.Info("Initialized agent tools", "agent", agentCfg.ID)
+ slog.Debug("Initialized agent base tools", "agent", agentCfg.ID)
}()
+ // Base tools available to all agents
cwd := cfg.WorkingDir()
- allTools := []tools.BaseTool{
+ result := make(map[string]tools.BaseTool)
+ for _, tool := range []tools.BaseTool{
tools.NewBashTool(permissions, cwd, cfg.Options.Attribution),
tools.NewDownloadTool(permissions, cwd),
tools.NewEditTool(lspClients, permissions, history, cwd),
@@ -190,36 +196,25 @@ func NewAgent(
tools.NewSourcegraphTool(),
tools.NewViewTool(lspClients, permissions, cwd),
tools.NewWriteTool(lspClients, permissions, history, cwd),
+ } {
+ result[tool.Name()] = tool
}
+ return result
+ }
+ mcpToolsFn := func() map[string]tools.BaseTool {
+ slog.Debug("Initializing agent mcp tools", "agent", agentCfg.ID)
+ defer func() {
+ slog.Debug("Initialized agent mcp tools", "agent", agentCfg.ID)
+ }()
mcpToolsOnce.Do(func() {
- mcpTools = doGetMCPTools(ctx, permissions, cfg)
+ doGetMCPTools(ctx, permissions, cfg)
})
- withCoderTools := func(t []tools.BaseTool) []tools.BaseTool {
- if agentCfg.ID == "coder" {
- t = append(t, mcpTools...)
- if lspClients.Len() > 0 {
- t = append(t, tools.NewDiagnosticsTool(lspClients))
- }
- }
- return t
- }
-
- if agentCfg.AllowedTools == nil {
- return withCoderTools(allTools)
- }
-
- var filteredTools []tools.BaseTool
- for _, tool := range allTools {
- if slices.Contains(agentCfg.AllowedTools, tool.Name()) {
- filteredTools = append(filteredTools, tool)
- }
- }
- return withCoderTools(filteredTools)
+ return maps.Collect(mcpTools.Seq2())
}
- return &agent{
+ a := &agent{
Broker: pubsub.NewBroker[AgentEvent](),
agentCfg: agentCfg,
provider: agentProvider,
@@ -231,10 +226,14 @@ func NewAgent(
summarizeProviderID: string(providerCfg.ID),
agentToolFn: agentToolFn,
activeRequests: csync.NewMap[string, context.CancelFunc](),
- tools: csync.NewLazySlice(toolFn),
+ mcpTools: csync.NewLazyMap(mcpToolsFn),
+ baseTools: csync.NewLazyMap(baseToolsFn),
promptQueue: csync.NewMap[string, []string](),
permissions: permissions,
- }, nil
+ lspClients: lspClients,
+ }
+ a.setupEvents(ctx)
+ return a, nil
}
func (a *agent) Model() catwalk.Model {
@@ -517,7 +516,18 @@ func (a *agent) createUserMessage(ctx context.Context, sessionID, content string
}
func (a *agent) getAllTools() ([]tools.BaseTool, error) {
- allTools := slices.Collect(a.tools.Seq())
+ var allTools []tools.BaseTool
+ for tool := range a.baseTools.Seq() {
+ if a.agentCfg.AllowedTools == nil || slices.Contains(a.agentCfg.AllowedTools, tool.Name()) {
+ allTools = append(allTools, tool)
+ }
+ }
+ if a.agentCfg.ID == "coder" {
+ allTools = slices.AppendSeq(allTools, a.mcpTools.Seq())
+ if a.lspClients.Len() > 0 {
+ allTools = append(allTools, tools.NewDiagnosticsTool(a.lspClients))
+ }
+ }
if a.agentToolFn != nil {
agentTool, agentToolErr := a.agentToolFn()
if agentToolErr != nil {
@@ -591,7 +601,7 @@ loop:
default:
// Continue processing
var tool tools.BaseTool
- allTools, _ := a.getAllTools()
+ allTools, _ = a.getAllTools()
for _, availableTool := range allTools {
if availableTool.Info().Name == toolCall.Name {
tool = availableTool
@@ -960,6 +970,12 @@ func (a *agent) CancelAll() {
a.Cancel(key) // key is sessionID
}
+ for _, cleanup := range a.cleanupFuncs {
+ if cleanup != nil {
+ cleanup()
+ }
+ }
+
timeout := time.After(5 * time.Second)
for a.IsBusy() {
select {
@@ -1071,3 +1087,48 @@ func (a *agent) UpdateModel() error {
return nil
}
+
+func (a *agent) setupEvents(ctx context.Context) {
+ ctx, cancel := context.WithCancel(ctx)
+
+ go func() {
+ subCh := SubscribeMCPEvents(ctx)
+
+ for {
+ select {
+ case event, ok := <-subCh:
+ if !ok {
+ slog.Debug("MCPEvents subscription channel closed")
+ return
+ }
+ switch event.Payload.Type {
+ case MCPEventToolsListChanged:
+ name := event.Payload.Name
+ c, ok := mcpClients.Get(name)
+ if !ok {
+ slog.Warn("MCP client not found for tools update", "name", name)
+ continue
+ }
+ cfg := config.Get()
+ tools, err := getTools(ctx, name, a.permissions, c, cfg.WorkingDir())
+ if err != nil {
+ slog.Error("error listing tools", "error", err)
+ updateMCPState(name, MCPStateError, err, nil, 0)
+ _ = c.Close()
+ continue
+ }
+ updateMcpTools(name, tools)
+ a.mcpTools.Reset(maps.Collect(mcpTools.Seq2()))
+ updateMCPState(name, MCPStateConnected, nil, c, a.mcpTools.Len())
+ default:
+ continue
+ }
+ case <-ctx.Done():
+ slog.Debug("MCPEvents subscription cancelled")
+ return
+ }
+ }
+ }()
+
+ a.cleanupFuncs = append(a.cleanupFuncs, cancel)
+}
@@ -6,9 +6,11 @@ import (
"encoding/json"
"errors"
"fmt"
+ "io"
"log/slog"
"maps"
- "slices"
+ "net/http"
+ "os/exec"
"strings"
"sync"
"time"
@@ -20,9 +22,7 @@ import (
"github.com/charmbracelet/crush/internal/permission"
"github.com/charmbracelet/crush/internal/pubsub"
"github.com/charmbracelet/crush/internal/version"
- "github.com/mark3labs/mcp-go/client"
- "github.com/mark3labs/mcp-go/client/transport"
- "github.com/mark3labs/mcp-go/mcp"
+ "github.com/modelcontextprotocol/go-sdk/mcp"
)
// MCPState represents the current state of an MCP client
@@ -54,7 +54,8 @@ func (s MCPState) String() string {
type MCPEventType string
const (
- MCPEventStateChanged MCPEventType = "state_changed"
+ MCPEventStateChanged MCPEventType = "state_changed"
+ MCPEventToolsListChanged MCPEventType = "tools_list_changed"
)
// MCPEvent represents an event in the MCP system
@@ -71,22 +72,23 @@ type MCPClientInfo struct {
Name string
State MCPState
Error error
- Client *client.Client
+ Client *mcp.ClientSession
ToolCount int
ConnectedAt time.Time
}
var (
- mcpToolsOnce sync.Once
- mcpTools []tools.BaseTool
- mcpClients = csync.NewMap[string, *client.Client]()
- mcpStates = csync.NewMap[string, MCPClientInfo]()
- mcpBroker = pubsub.NewBroker[MCPEvent]()
+ mcpToolsOnce sync.Once
+ mcpTools = csync.NewMap[string, tools.BaseTool]()
+ mcpClient2Tools = csync.NewMap[string, []tools.BaseTool]()
+ mcpClients = csync.NewMap[string, *mcp.ClientSession]()
+ mcpStates = csync.NewMap[string, MCPClientInfo]()
+ mcpBroker = pubsub.NewBroker[MCPEvent]()
)
type McpTool struct {
mcpName string
- tool mcp.Tool
+ tool *mcp.Tool
permissions permission.Service
workingDir string
}
@@ -96,14 +98,9 @@ func (b *McpTool) Name() string {
}
func (b *McpTool) Info() tools.ToolInfo {
- required := b.tool.InputSchema.Required
- if required == nil {
- required = make([]string, 0)
- }
- parameters := b.tool.InputSchema.Properties
- if parameters == nil {
- parameters = make(map[string]any)
- }
+ input := b.tool.InputSchema.(map[string]any)
+ required, _ := input["required"].([]string)
+ parameters, _ := input["properties"].(map[string]any)
return tools.ToolInfo{
Name: fmt.Sprintf("mcp_%s_%s", b.mcpName, b.tool.Name),
Description: b.tool.Description,
@@ -122,11 +119,9 @@ func runTool(ctx context.Context, name, toolName string, input string) (tools.To
if err != nil {
return tools.NewTextErrorResponse(err.Error()), nil
}
- result, err := c.CallTool(ctx, mcp.CallToolRequest{
- Params: mcp.CallToolParams{
- Name: toolName,
- Arguments: args,
- },
+ result, err := c.CallTool(ctx, &mcp.CallToolParams{
+ Name: toolName,
+ Arguments: args,
})
if err != nil {
return tools.NewTextErrorResponse(err.Error()), nil
@@ -134,8 +129,8 @@ func runTool(ctx context.Context, name, toolName string, input string) (tools.To
output := make([]string, 0, len(result.Content))
for _, v := range result.Content {
- if v, ok := v.(mcp.TextContent); ok {
- output = append(output, v.Text)
+ if vv, ok := v.(*mcp.TextContent); ok {
+ output = append(output, vv.Text)
} else {
output = append(output, fmt.Sprintf("%v", v))
}
@@ -143,8 +138,8 @@ func runTool(ctx context.Context, name, toolName string, input string) (tools.To
return tools.NewTextResponse(strings.Join(output, "\n")), nil
}
-func getOrRenewClient(ctx context.Context, name string) (*client.Client, error) {
- c, ok := mcpClients.Get(name)
+func getOrRenewClient(ctx context.Context, name string) (*mcp.ClientSession, error) {
+ sess, ok := mcpClients.Get(name)
if !ok {
return nil, fmt.Errorf("mcp '%s' not available", name)
}
@@ -156,20 +151,20 @@ func getOrRenewClient(ctx context.Context, name string) (*client.Client, error)
timeout := mcpTimeout(m)
pingCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
- err := c.Ping(pingCtx)
+ err := sess.Ping(pingCtx, nil)
if err == nil {
- return c, nil
+ return sess, nil
}
updateMCPState(name, MCPStateError, maybeTimeoutErr(err, timeout), nil, state.ToolCount)
- c, err = createAndInitializeClient(ctx, name, m, cfg.Resolver())
+ sess, err = createMCPSession(ctx, name, m, cfg.Resolver())
if err != nil {
return nil, err
}
- updateMCPState(name, MCPStateConnected, nil, c, state.ToolCount)
- mcpClients.Set(name, c)
- return c, nil
+ updateMCPState(name, MCPStateConnected, nil, sess, state.ToolCount)
+ mcpClients.Set(name, sess)
+ return sess, nil
}
func (b *McpTool) Run(ctx context.Context, params tools.ToolCall) (tools.ToolResponse, error) {
@@ -196,8 +191,8 @@ func (b *McpTool) Run(ctx context.Context, params tools.ToolCall) (tools.ToolRes
return runTool(ctx, b.mcpName, b.tool.Name, params.Input)
}
-func getTools(ctx context.Context, name string, permissions permission.Service, c *client.Client, workingDir string) ([]tools.BaseTool, error) {
- result, err := c.ListTools(ctx, mcp.ListToolsRequest{})
+func getTools(ctx context.Context, name string, permissions permission.Service, c *mcp.ClientSession, workingDir string) ([]tools.BaseTool, error) {
+ result, err := c.ListTools(ctx, &mcp.ListToolsParams{})
if err != nil {
return nil, err
}
@@ -229,7 +224,7 @@ func GetMCPState(name string) (MCPClientInfo, bool) {
}
// updateMCPState updates the state of an MCP client and publishes an event
-func updateMCPState(name string, state MCPState, err error, client *client.Client, toolCount int) {
+func updateMCPState(name string, state MCPState, err error, client *mcp.ClientSession, toolCount int) {
info := MCPClientInfo{
Name: name,
State: state,
@@ -237,8 +232,12 @@ func updateMCPState(name string, state MCPState, err error, client *client.Clien
Client: client,
ToolCount: toolCount,
}
- if state == MCPStateConnected {
+ switch state {
+ case MCPStateConnected:
info.ConnectedAt = time.Now()
+ case MCPStateError:
+ updateMcpTools(name, nil)
+ mcpClients.Del(name)
}
mcpStates.Set(name, info)
@@ -256,7 +255,10 @@ func updateMCPState(name string, state MCPState, err error, client *client.Clien
func CloseMCPClients() error {
var errs []error
for name, c := range mcpClients.Seq2() {
- if err := c.Close(); err != nil {
+ if err := c.Close(); err != nil &&
+ !errors.Is(err, io.EOF) &&
+ !errors.Is(err, context.Canceled) &&
+ err.Error() != "signal: killed" {
errs = append(errs, fmt.Errorf("close mcp: %s: %w", name, err))
}
}
@@ -264,20 +266,8 @@ func CloseMCPClients() error {
return errors.Join(errs...)
}
-var mcpInitRequest = mcp.InitializeRequest{
- Params: mcp.InitializeParams{
- ProtocolVersion: mcp.LATEST_PROTOCOL_VERSION,
- ClientInfo: mcp.Implementation{
- Name: "Crush",
- Version: version.Version,
- },
- },
-}
-
-func doGetMCPTools(ctx context.Context, permissions permission.Service, cfg *config.Config) []tools.BaseTool {
+func doGetMCPTools(ctx context.Context, permissions permission.Service, cfg *config.Config) {
var wg sync.WaitGroup
- result := csync.NewSlice[tools.BaseTool]()
-
// Initialize states for all configured MCPs
for name, m := range cfg.MCP {
if m.Disabled {
@@ -311,11 +301,13 @@ func doGetMCPTools(ctx context.Context, permissions permission.Service, cfg *con
ctx, cancel := context.WithTimeout(ctx, mcpTimeout(m))
defer cancel()
- c, err := createAndInitializeClient(ctx, name, m, cfg.Resolver())
+ c, err := createMCPSession(ctx, name, m, cfg.Resolver())
if err != nil {
return
}
+ mcpClients.Set(name, c)
+
tools, err := getTools(ctx, name, permissions, c, cfg.WorkingDir())
if err != nil {
slog.Error("error listing tools", "error", err)
@@ -324,45 +316,68 @@ func doGetMCPTools(ctx context.Context, permissions permission.Service, cfg *con
return
}
+ updateMcpTools(name, tools)
mcpClients.Set(name, c)
updateMCPState(name, MCPStateConnected, nil, c, len(tools))
- result.Append(tools...)
}(name, m)
}
wg.Wait()
- return slices.Collect(result.Seq())
}
-func createAndInitializeClient(ctx context.Context, name string, m config.MCPConfig, resolver config.VariableResolver) (*client.Client, error) {
- c, err := createMcpClient(name, m, resolver)
- if err != nil {
- updateMCPState(name, MCPStateError, err, nil, 0)
- slog.Error("error creating mcp client", "error", err, "name", name)
- return nil, err
+// updateMcpTools updates the global mcpTools and mcpClient2Tools maps
+func updateMcpTools(mcpName string, tools []tools.BaseTool) {
+ if len(tools) == 0 {
+ mcpClient2Tools.Del(mcpName)
+ } else {
+ mcpClient2Tools.Set(mcpName, tools)
+ }
+ for _, tools := range mcpClient2Tools.Seq2() {
+ for _, t := range tools {
+ mcpTools.Set(t.Name(), t)
+ }
}
+}
- // XXX: ideally we should be able to use context.WithTimeout here, but,
- // the SSE MCP client will start failing once that context is canceled.
+func createMCPSession(ctx context.Context, name string, m config.MCPConfig, resolver config.VariableResolver) (*mcp.ClientSession, error) {
timeout := mcpTimeout(m)
mcpCtx, cancel := context.WithCancel(ctx)
cancelTimer := time.AfterFunc(timeout, cancel)
- if err := c.Start(mcpCtx); err != nil {
- updateMCPState(name, MCPStateError, maybeTimeoutErr(err, timeout), nil, 0)
- slog.Error("error starting mcp client", "error", err, "name", name)
- _ = c.Close()
- cancel()
+
+ transport, err := createMCPTransport(mcpCtx, m, resolver)
+ if err != nil {
+ updateMCPState(name, MCPStateError, err, nil, 0)
+ slog.Error("error creating mcp client", "error", err, "name", name)
return nil, err
}
- if _, err := c.Initialize(mcpCtx, mcpInitRequest); err != nil {
+
+ client := mcp.NewClient(
+ &mcp.Implementation{
+ Name: "crush",
+ Version: version.Version,
+ Title: "Crush",
+ },
+ &mcp.ClientOptions{
+ ToolListChangedHandler: func(context.Context, *mcp.ToolListChangedRequest) {
+ mcpBroker.Publish(pubsub.UpdatedEvent, MCPEvent{
+ Type: MCPEventToolsListChanged,
+ Name: name,
+ })
+ },
+ KeepAlive: time.Minute * 10,
+ },
+ )
+
+ session, err := client.Connect(mcpCtx, transport, nil)
+ if err != nil {
updateMCPState(name, MCPStateError, maybeTimeoutErr(err, timeout), nil, 0)
- slog.Error("error initializing mcp client", "error", err, "name", name)
- _ = c.Close()
+ slog.Error("error starting mcp client", "error", err, "name", name)
cancel()
return nil, err
}
+
cancelTimer.Stop()
slog.Info("Initialized mcp client", "name", name)
- return c, nil
+ return session, nil
}
func maybeTimeoutErr(err error, timeout time.Duration) error {
@@ -372,7 +387,7 @@ func maybeTimeoutErr(err error, timeout time.Duration) error {
return err
}
-func createMcpClient(name string, m config.MCPConfig, resolver config.VariableResolver) (*client.Client, error) {
+func createMCPTransport(ctx context.Context, m config.MCPConfig, resolver config.VariableResolver) (mcp.Transport, error) {
switch m.Type {
case config.MCPStdio:
command, err := resolver.ResolveValue(m.Command)
@@ -382,44 +397,51 @@ func createMcpClient(name string, m config.MCPConfig, resolver config.VariableRe
if strings.TrimSpace(command) == "" {
return nil, fmt.Errorf("mcp stdio config requires a non-empty 'command' field")
}
- return client.NewStdioMCPClientWithOptions(
- home.Long(command),
- m.ResolvedEnv(),
- m.Args,
- transport.WithCommandLogger(mcpLogger{name: name}),
- )
+ cmd := exec.CommandContext(ctx, home.Long(command), m.Args...)
+ cmd.Env = m.ResolvedEnv()
+ return &mcp.CommandTransport{
+ Command: cmd,
+ }, nil
case config.MCPHttp:
if strings.TrimSpace(m.URL) == "" {
return nil, fmt.Errorf("mcp http config requires a non-empty 'url' field")
}
- return client.NewStreamableHttpClient(
- m.URL,
- transport.WithHTTPHeaders(m.ResolvedHeaders()),
- transport.WithHTTPLogger(mcpLogger{name: name}),
- )
- case config.MCPSse:
+ client := &http.Client{
+ Transport: &headerRoundTripper{
+ headers: m.ResolvedHeaders(),
+ },
+ }
+ return &mcp.StreamableClientTransport{
+ Endpoint: m.URL,
+ HTTPClient: client,
+ }, nil
+ case config.MCPSSE:
if strings.TrimSpace(m.URL) == "" {
return nil, fmt.Errorf("mcp sse config requires a non-empty 'url' field")
}
- return client.NewSSEMCPClient(
- m.URL,
- client.WithHeaders(m.ResolvedHeaders()),
- transport.WithSSELogger(mcpLogger{name: name}),
- )
+ client := &http.Client{
+ Transport: &headerRoundTripper{
+ headers: m.ResolvedHeaders(),
+ },
+ }
+ return &mcp.SSEClientTransport{
+ Endpoint: m.URL,
+ HTTPClient: client,
+ }, nil
default:
return nil, fmt.Errorf("unsupported mcp type: %s", m.Type)
}
}
-// for MCP's clients.
-type mcpLogger struct{ name string }
-
-func (l mcpLogger) Errorf(format string, v ...any) {
- slog.Error(fmt.Sprintf(format, v...), "name", l.name)
+type headerRoundTripper struct {
+ headers map[string]string
}
-func (l mcpLogger) Infof(format string, v ...any) {
- slog.Info(fmt.Sprintf(format, v...), "name", l.name)
+func (rt headerRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
+ for k, v := range rt.headers {
+ req.Header.Set(k, v)
+ }
+ return http.DefaultTransport.RoundTrip(req)
}
func mcpTimeout(m config.MCPConfig) time.Duration {
@@ -53,7 +53,7 @@ func getEnvironmentInfo() string {
isGit := isGitRepo(cwd)
platform := runtime.GOOS
date := time.Now().Format("1/2/2006")
- output, _ := tools.ListDirectoryTree(cwd, nil)
+ output, _, _ := tools.ListDirectoryTree(cwd, tools.LSParams{})
return fmt.Sprintf(`Here is useful information about the environment you are running in:
<env>
Working directory: %s
@@ -30,7 +30,7 @@ func newVertexAIClient(opts providerClientOptions) VertexAIClient {
}
model := opts.model(opts.modelType)
- if strings.Contains(model.ID, "anthropic") || strings.Contains(model.ID, "claude-sonnet") {
+ if strings.Contains(model.ID, "anthropic") || strings.Contains(model.ID, "claude") || strings.Contains(model.ID, "sonnet") {
return newAnthropicClient(opts, AnthropicClientTypeVertex)
}
return &geminiClient{
@@ -7,6 +7,7 @@ import (
"encoding/json"
"fmt"
"io"
+ "net/http"
"os"
"os/exec"
"path/filepath"
@@ -390,8 +391,8 @@ func searchFilesWithRegex(pattern, rootPath, include string) ([]grepMatch, error
}
func fileContainsPattern(filePath string, pattern *regexp.Regexp) (bool, int, string, error) {
- // Quick binary file detection
- if isBinaryFile(filePath) {
+ // Only search text files.
+ if !isTextFile(filePath) {
return false, 0, "", nil
}
@@ -414,45 +415,30 @@ func fileContainsPattern(filePath string, pattern *regexp.Regexp) (bool, int, st
return false, 0, "", scanner.Err()
}
-var binaryExts = map[string]struct{}{
- ".exe": {}, ".dll": {}, ".so": {}, ".dylib": {},
- ".bin": {}, ".obj": {}, ".o": {}, ".a": {},
- ".zip": {}, ".tar": {}, ".gz": {}, ".bz2": {},
- ".jpg": {}, ".jpeg": {}, ".png": {}, ".gif": {},
- ".pdf": {}, ".doc": {}, ".docx": {}, ".xls": {},
- ".mp3": {}, ".mp4": {}, ".avi": {}, ".mov": {},
-}
-
-// isBinaryFile performs a quick check to determine if a file is binary
-func isBinaryFile(filePath string) bool {
- // Check file extension first (fastest)
- ext := strings.ToLower(filepath.Ext(filePath))
- if _, isBinary := binaryExts[ext]; isBinary {
- return true
- }
-
- // Quick content check for files without clear extensions
+// isTextFile checks if a file is a text file by examining its MIME type.
+func isTextFile(filePath string) bool {
file, err := os.Open(filePath)
if err != nil {
- return false // If we can't open it, let the caller handle the error
+ return false
}
defer file.Close()
- // Read first 512 bytes to check for null bytes
+ // Read first 512 bytes for MIME type detection.
buffer := make([]byte, 512)
n, err := file.Read(buffer)
if err != nil && err != io.EOF {
return false
}
- // Check for null bytes (common in binary files)
- for i := range n {
- if buffer[i] == 0 {
- return true
- }
- }
+ // Detect content type.
+ contentType := http.DetectContentType(buffer[:n])
- return false
+ // Check if it's a text MIME type.
+ return strings.HasPrefix(contentType, "text/") ||
+ contentType == "application/json" ||
+ contentType == "application/xml" ||
+ contentType == "application/javascript" ||
+ contentType == "application/x-sh"
}
func globToRegex(glob string) string {
@@ -198,3 +198,195 @@ func BenchmarkRegexCacheVsCompile(b *testing.B) {
}
})
}
+
+func TestIsTextFile(t *testing.T) {
+ t.Parallel()
+ tempDir := t.TempDir()
+
+ tests := []struct {
+ name string
+ filename string
+ content []byte
+ wantText bool
+ }{
+ {
+ name: "go file",
+ filename: "test.go",
+ content: []byte("package main\n\nfunc main() {}\n"),
+ wantText: true,
+ },
+ {
+ name: "yaml file",
+ filename: "config.yaml",
+ content: []byte("key: value\nlist:\n - item1\n - item2\n"),
+ wantText: true,
+ },
+ {
+ name: "yml file",
+ filename: "config.yml",
+ content: []byte("key: value\n"),
+ wantText: true,
+ },
+ {
+ name: "json file",
+ filename: "data.json",
+ content: []byte(`{"key": "value"}`),
+ wantText: true,
+ },
+ {
+ name: "javascript file",
+ filename: "script.js",
+ content: []byte("console.log('hello');\n"),
+ wantText: true,
+ },
+ {
+ name: "typescript file",
+ filename: "script.ts",
+ content: []byte("const x: string = 'hello';\n"),
+ wantText: true,
+ },
+ {
+ name: "markdown file",
+ filename: "README.md",
+ content: []byte("# Title\n\nSome content\n"),
+ wantText: true,
+ },
+ {
+ name: "shell script",
+ filename: "script.sh",
+ content: []byte("#!/bin/bash\necho 'hello'\n"),
+ wantText: true,
+ },
+ {
+ name: "python file",
+ filename: "script.py",
+ content: []byte("print('hello')\n"),
+ wantText: true,
+ },
+ {
+ name: "xml file",
+ filename: "data.xml",
+ content: []byte("<?xml version=\"1.0\"?>\n<root></root>\n"),
+ wantText: true,
+ },
+ {
+ name: "plain text",
+ filename: "file.txt",
+ content: []byte("plain text content\n"),
+ wantText: true,
+ },
+ {
+ name: "css file",
+ filename: "style.css",
+ content: []byte("body { color: red; }\n"),
+ wantText: true,
+ },
+ {
+ name: "scss file",
+ filename: "style.scss",
+ content: []byte("$primary: blue;\nbody { color: $primary; }\n"),
+ wantText: true,
+ },
+ {
+ name: "sass file",
+ filename: "style.sass",
+ content: []byte("$primary: blue\nbody\n color: $primary\n"),
+ wantText: true,
+ },
+ {
+ name: "rust file",
+ filename: "main.rs",
+ content: []byte("fn main() {\n println!(\"Hello, world!\");\n}\n"),
+ wantText: true,
+ },
+ {
+ name: "zig file",
+ filename: "main.zig",
+ content: []byte("const std = @import(\"std\");\npub fn main() void {}\n"),
+ wantText: true,
+ },
+ {
+ name: "java file",
+ filename: "Main.java",
+ content: []byte("public class Main {\n public static void main(String[] args) {}\n}\n"),
+ wantText: true,
+ },
+ {
+ name: "c file",
+ filename: "main.c",
+ content: []byte("#include <stdio.h>\nint main() { return 0; }\n"),
+ wantText: true,
+ },
+ {
+ name: "cpp file",
+ filename: "main.cpp",
+ content: []byte("#include <iostream>\nint main() { return 0; }\n"),
+ wantText: true,
+ },
+ {
+ name: "fish shell",
+ filename: "script.fish",
+ content: []byte("#!/usr/bin/env fish\necho 'hello'\n"),
+ wantText: true,
+ },
+ {
+ name: "powershell file",
+ filename: "script.ps1",
+ content: []byte("Write-Host 'Hello, World!'\n"),
+ wantText: true,
+ },
+ {
+ name: "cmd batch file",
+ filename: "script.bat",
+ content: []byte("@echo off\necho Hello, World!\n"),
+ wantText: true,
+ },
+ {
+ name: "cmd file",
+ filename: "script.cmd",
+ content: []byte("@echo off\necho Hello, World!\n"),
+ wantText: true,
+ },
+ {
+ name: "binary exe",
+ filename: "binary.exe",
+ content: []byte{0x4D, 0x5A, 0x90, 0x00, 0x03, 0x00, 0x00, 0x00},
+ wantText: false,
+ },
+ {
+ name: "png image",
+ filename: "image.png",
+ content: []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A},
+ wantText: false,
+ },
+ {
+ name: "jpeg image",
+ filename: "image.jpg",
+ content: []byte{0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46},
+ wantText: false,
+ },
+ {
+ name: "zip archive",
+ filename: "archive.zip",
+ content: []byte{0x50, 0x4B, 0x03, 0x04, 0x14, 0x00, 0x00, 0x00},
+ wantText: false,
+ },
+ {
+ name: "pdf file",
+ filename: "document.pdf",
+ content: []byte("%PDF-1.4\n%รขรฃรร\n"),
+ wantText: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+ filePath := filepath.Join(tempDir, tt.filename)
+ require.NoError(t, os.WriteFile(filePath, tt.content, 0o644))
+
+ got := isTextFile(filePath)
+ require.Equal(t, tt.wantText, got, "isTextFile(%s) = %v, want %v", tt.filename, got, tt.wantText)
+ })
+ }
+}
@@ -1,6 +1,7 @@
package tools
import (
+ "cmp"
"context"
_ "embed"
"encoding/json"
@@ -9,6 +10,7 @@ import (
"path/filepath"
"strings"
+ "github.com/charmbracelet/crush/internal/config"
"github.com/charmbracelet/crush/internal/fsext"
"github.com/charmbracelet/crush/internal/permission"
)
@@ -16,11 +18,13 @@ import (
type LSParams struct {
Path string `json:"path"`
Ignore []string `json:"ignore"`
+ Depth int `json:"depth"`
}
type LSPermissionsParams struct {
Path string `json:"path"`
Ignore []string `json:"ignore"`
+ Depth int `json:"depth"`
}
type TreeNode struct {
@@ -42,7 +46,7 @@ type lsTool struct {
const (
LSToolName = "ls"
- MaxLSFiles = 1000
+ maxLSFiles = 1000
)
//go:embed ls.md
@@ -68,6 +72,10 @@ func (l *lsTool) Info() ToolInfo {
"type": "string",
"description": "The path to the directory to list (defaults to current working directory)",
},
+ "depth": map[string]any{
+ "type": "integer",
+ "description": "The maximum depth to traverse",
+ },
"ignore": map[string]any{
"type": "array",
"description": "List of glob patterns to ignore",
@@ -86,13 +94,7 @@ func (l *lsTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
return NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil
}
- searchPath := params.Path
- if searchPath == "" {
- searchPath = l.workingDir
- }
-
- var err error
- searchPath, err = fsext.Expand(searchPath)
+ searchPath, err := fsext.Expand(cmp.Or(params.Path, l.workingDir))
if err != nil {
return ToolResponse{}, fmt.Errorf("error expanding path: %w", err)
}
@@ -137,44 +139,49 @@ func (l *lsTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
}
}
- output, err := ListDirectoryTree(searchPath, params.Ignore)
+ output, metadata, err := ListDirectoryTree(searchPath, params)
if err != nil {
return ToolResponse{}, err
}
- // Get file count for metadata
- files, truncated, err := fsext.ListDirectory(searchPath, params.Ignore, MaxLSFiles)
- if err != nil {
- return ToolResponse{}, fmt.Errorf("error listing directory for metadata: %w", err)
- }
-
return WithResponseMetadata(
NewTextResponse(output),
- LSResponseMetadata{
- NumberOfFiles: len(files),
- Truncated: truncated,
- },
+ metadata,
), nil
}
-func ListDirectoryTree(searchPath string, ignore []string) (string, error) {
+func ListDirectoryTree(searchPath string, params LSParams) (string, LSResponseMetadata, error) {
if _, err := os.Stat(searchPath); os.IsNotExist(err) {
- return "", fmt.Errorf("path does not exist: %s", searchPath)
+ return "", LSResponseMetadata{}, fmt.Errorf("path does not exist: %s", searchPath)
}
- files, truncated, err := fsext.ListDirectory(searchPath, ignore, MaxLSFiles)
+ ls := config.Get().Tools.Ls
+ depth, limit := ls.Limits()
+ maxFiles := cmp.Or(limit, maxLSFiles)
+ files, truncated, err := fsext.ListDirectory(
+ searchPath,
+ params.Ignore,
+ cmp.Or(params.Depth, depth),
+ maxFiles,
+ )
if err != nil {
- return "", fmt.Errorf("error listing directory: %w", err)
+ return "", LSResponseMetadata{}, fmt.Errorf("error listing directory: %w", err)
}
+ metadata := LSResponseMetadata{
+ NumberOfFiles: len(files),
+ Truncated: truncated,
+ }
tree := createFileTree(files, searchPath)
- output := printTree(tree, searchPath)
+ var output string
if truncated {
- output = fmt.Sprintf("There are more than %d files in the directory. Use a more specific path or use the Glob tool to find specific files. The first %d files and directories are included below:\n\n%s", MaxLSFiles, MaxLSFiles, output)
+ output = fmt.Sprintf("There are more than %d files in the directory. Use a more specific path or use the Glob tool to find specific files. The first %[1]d files and directories are included below.\n", maxFiles)
}
-
- return output, nil
+ if depth > 0 {
+ output = fmt.Sprintf("The directory tree is shown up to a depth of %d. Use a higher depth and a specific path to see more levels.\n", cmp.Or(params.Depth, depth))
+ }
+ return output + "\n" + printTree(tree, searchPath), metadata, nil
}
func createFileTree(sortedPaths []string, rootPath string) []*TreeNode {
@@ -480,7 +480,9 @@ func (m *editorCmp) SetPosition(x, y int) tea.Cmd {
}
func (m *editorCmp) startCompletions() tea.Msg {
- files, _, _ := fsext.ListDirectory(".", nil, 0)
+ ls := m.app.Config().Options.TUI.Completions
+ depth, limit := ls.Limits()
+ files, _, _ := fsext.ListDirectory(".", nil, depth, limit)
slices.Sort(files)
completionItems := make([]completions.Completion, 0, len(files))
for _, file := range files {
@@ -110,14 +110,17 @@ func Status(opts StatusOpts, width int) string {
extraContentWidth += 1
}
description = ansi.Truncate(description, width-lipgloss.Width(icon)-lipgloss.Width(title)-2-extraContentWidth, "โฆ")
+ description = t.S().Base.Foreground(descriptionColor).Render(description)
}
- description = t.S().Base.Foreground(descriptionColor).Render(description)
content := []string{}
if icon != "" {
content = append(content, icon)
}
- content = append(content, title, description)
+ content = append(content, title)
+ if description != "" {
+ content = append(content, description)
+ }
if opts.ExtraContent != "" {
content = append(content, opts.ExtraContent)
}
@@ -1 +1 @@
-โ [38;2;133;131;146mTitle Only[m [38;2;96;95;107m[m
+โ [38;2;133;131;146mTitle Only[m
@@ -128,12 +128,19 @@ func (c *commandArgumentsDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
c.inputs[c.focusIndex].Blur()
c.focusIndex = (c.focusIndex - 1 + len(c.inputs)) % len(c.inputs)
c.inputs[c.focusIndex].Focus()
-
+ case key.Matches(msg, c.keys.Paste):
+ return c, textinput.Paste
+ case key.Matches(msg, c.keys.Close):
+ return c, util.CmdHandler(dialogs.CloseDialogMsg{})
default:
var cmd tea.Cmd
c.inputs[c.focusIndex], cmd = c.inputs[c.focusIndex].Update(msg)
return c, cmd
}
+ case tea.PasteMsg:
+ var cmd tea.Cmd
+ c.inputs[c.focusIndex], cmd = c.inputs[c.focusIndex].Update(msg)
+ return c, cmd
}
return c, nil
}
@@ -76,6 +76,8 @@ type ArgumentsDialogKeyMap struct {
Confirm key.Binding
Next key.Binding
Previous key.Binding
+ Paste key.Binding
+ Close key.Binding
}
func DefaultArgumentsDialogKeyMap() ArgumentsDialogKeyMap {
@@ -93,6 +95,14 @@ func DefaultArgumentsDialogKeyMap() ArgumentsDialogKeyMap {
key.WithKeys("shift+tab", "up"),
key.WithHelp("shift+tab/โ", "previous"),
),
+ Paste: key.NewBinding(
+ key.WithKeys("ctrl+v"),
+ key.WithHelp("ctrl+v", "paste"),
+ ),
+ Close: key.NewBinding(
+ key.WithKeys("esc", "alt+esc"),
+ key.WithHelp("esc", "cancel"),
+ ),
}
}
@@ -102,6 +112,8 @@ func (k ArgumentsDialogKeyMap) KeyBindings() []key.Binding {
k.Confirm,
k.Next,
k.Previous,
+ k.Paste,
+ k.Close,
}
}
@@ -122,5 +134,7 @@ func (k ArgumentsDialogKeyMap) ShortHelp() []key.Binding {
k.Confirm,
k.Next,
k.Previous,
+ k.Paste,
+ k.Close,
}
}
@@ -56,32 +56,7 @@ func RenderLSPList(lspClients *csync.Map[string, *lsp.Client], opts RenderOption
break
}
- // Determine icon color and description based on state
- icon := t.ItemOfflineIcon
- description := l.LSP.Command
-
- if l.LSP.Disabled {
- description = t.S().Subtle.Render("disabled")
- } else if state, exists := lspStates[l.Name]; exists {
- switch state.State {
- case lsp.StateStarting:
- icon = t.ItemBusyIcon
- description = t.S().Subtle.Render("starting...")
- case lsp.StateReady:
- icon = t.ItemOnlineIcon
- description = l.LSP.Command
- case lsp.StateError:
- icon = t.ItemErrorIcon
- if state.Error != nil {
- description = t.S().Subtle.Render(fmt.Sprintf("error: %s", state.Error.Error()))
- } else {
- description = t.S().Subtle.Render("error")
- }
- case lsp.StateDisabled:
- icon = t.ItemOfflineIcon.Foreground(t.FgMuted)
- description = t.S().Base.Foreground(t.FgMuted).Render("no root markers found")
- }
- }
+ icon, description := iconAndDescription(l, t, lspStates)
// Calculate diagnostic counts if we have LSP clients
var extraContent string
@@ -134,6 +109,30 @@ func RenderLSPList(lspClients *csync.Map[string, *lsp.Client], opts RenderOption
return lspList
}
+func iconAndDescription(l config.LSP, t *styles.Theme, states map[string]app.LSPClientInfo) (lipgloss.Style, string) {
+ if l.LSP.Disabled {
+ return t.ItemOfflineIcon.Foreground(t.FgMuted), t.S().Subtle.Render("disabled")
+ }
+
+ info := states[l.Name]
+ switch info.State {
+ case lsp.StateStarting:
+ return t.ItemBusyIcon, t.S().Subtle.Render("starting...")
+ case lsp.StateReady:
+ return t.ItemOnlineIcon, ""
+ case lsp.StateError:
+ description := t.S().Subtle.Render("error")
+ if info.Error != nil {
+ description = t.S().Subtle.Render(fmt.Sprintf("error: %s", info.Error.Error()))
+ }
+ return t.ItemErrorIcon, description
+ case lsp.StateDisabled:
+ return t.ItemOfflineIcon.Foreground(t.FgMuted), t.S().Subtle.Render("inactive")
+ default:
+ return t.ItemOfflineIcon, ""
+ }
+}
+
// RenderLSPBlock renders a complete LSP block with optional truncation indicator.
func RenderLSPBlock(lspClients *csync.Map[string, *lsp.Client], opts RenderOptions, showTruncationIndicator bool) string {
t := styles.CurrentTheme()
@@ -55,7 +55,7 @@ func RenderMCPList(opts RenderOptions) []string {
// Determine icon and color based on state
icon := t.ItemOfflineIcon
- description := l.MCP.Command
+ description := ""
extraContent := ""
if state, exists := mcpStates[l.Name]; exists {
@@ -3,6 +3,7 @@ package tui
import (
"context"
"fmt"
+ "math/rand"
"strings"
"time"
@@ -612,6 +613,11 @@ func (a *appModel) View() tea.View {
view.Layer = canvas
view.Cursor = cursor
+ if a.app != nil && a.app.CoderAgent != nil && a.app.CoderAgent.IsBusy() {
+ // HACK: use a random percentage to prevent ghostty from hiding it
+ // after a timeout.
+ view.ProgressBar = tea.NewProgressBar(tea.ProgressBarIndeterminate, rand.Intn(100))
+ }
return view
}
@@ -3,23 +3,14 @@ package main
import (
"log/slog"
"net/http"
+ _ "net/http/pprof"
"os"
- _ "net/http/pprof" // profiling
-
- _ "github.com/joho/godotenv/autoload" // automatically load .env files
-
"github.com/charmbracelet/crush/internal/cmd"
- "github.com/charmbracelet/crush/internal/event"
- "github.com/charmbracelet/crush/internal/log"
+ _ "github.com/joho/godotenv/autoload"
)
func main() {
- defer log.RecoverPanic("main", func() {
- event.Flush()
- slog.Error("Application terminated due to unhandled panic")
- })
-
if os.Getenv("CRUSH_PROFILE") != "" {
go func() {
slog.Info("Serving pprof at localhost:6060")
@@ -19,6 +19,28 @@
"additionalProperties": false,
"type": "object"
},
+ "Completions": {
+ "properties": {
+ "max_depth": {
+ "type": "integer",
+ "description": "Maximum depth for the ls tool",
+ "default": 0,
+ "examples": [
+ 10
+ ]
+ },
+ "max_items": {
+ "type": "integer",
+ "description": "Maximum number of items to return for the ls tool",
+ "default": 1000,
+ "examples": [
+ 100
+ ]
+ }
+ },
+ "additionalProperties": false,
+ "type": "object"
+ },
"Config": {
"properties": {
"$schema": {
@@ -53,10 +75,17 @@
"permissions": {
"$ref": "#/$defs/Permissions",
"description": "Permission settings for tool usage"
+ },
+ "tools": {
+ "$ref": "#/$defs/Tools",
+ "description": "Tool configurations"
}
},
"additionalProperties": false,
- "type": "object"
+ "type": "object",
+ "required": [
+ "tools"
+ ]
},
"LSPConfig": {
"properties": {
@@ -484,10 +513,51 @@
"split"
],
"description": "Diff mode for the TUI interface"
+ },
+ "completions": {
+ "$ref": "#/$defs/Completions",
+ "description": "Completions UI options"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "required": [
+ "completions"
+ ]
+ },
+ "ToolLs": {
+ "properties": {
+ "max_depth": {
+ "type": "integer",
+ "description": "Maximum depth for the ls tool",
+ "default": 0,
+ "examples": [
+ 10
+ ]
+ },
+ "max_items": {
+ "type": "integer",
+ "description": "Maximum number of items to return for the ls tool",
+ "default": 1000,
+ "examples": [
+ 100
+ ]
}
},
"additionalProperties": false,
"type": "object"
+ },
+ "Tools": {
+ "properties": {
+ "ls": {
+ "$ref": "#/$defs/ToolLs"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "required": [
+ "ls"
+ ]
}
}
}