on main help sign in
Merge remote-tracking branch 'origin/main' into ui
commit9b26ea23dd498bf439344eaa8c775e752c2f0905 authorKujtim Hoxha <kujtimii.h@gmail.com> dateSat 27 Dec 2025, 21:27 (1 year ago) parent4665ad259e4adc
35 files changed+1286−297
.github/cla-signatures.json+40 .github/workflows/schema-update.yml+1−1 README.md+43 go.mod+6−6 go.sum+12−12 internal/agent/agent.go+30−10 internal/agent/common_test.go+4 internal/agent/coordinator.go+13−8 internal/agent/prompt/prompt.go+31−16 internal/agent/templates/coder.md.tpl+10 internal/agent/tools/view.go+58−13 internal/app/app.go+2−2 internal/app/lsp.go+1−1 internal/cmd/login.go+1−2 internal/config/config.go+6−4 internal/config/copilot.go+6−1 internal/config/load.go+31−7 internal/db/db.go+54−44 internal/db/querier.go+1 internal/db/sessions.sql.go+29 internal/db/sql/sessions.sql+9 internal/message/attachment.go+5 internal/message/content.go+46 internal/message/message.go+9−3 internal/pubsub/broker.go+3−8 internal/session/session.go+13 internal/skills/skills.go+164 internal/skills/skills_test.go+249 internal/tui/components/chat/editor/editor.go+117−52 internal/tui/components/chat/messages/messages.go+22−9 internal/tui/components/chat/splash/splash.go+199−69 internal/tui/components/dialogs/models/models.go+28−22 internal/tui/page/chat/chat.go+30−6 internal/tui/styles/icons.go+2−1 schema.json+11
.github/cla-signatures.json+40
@@ -959,6 +959,46 @@
959959 "created_at": "2025-12-14T09:41:12Z",
960960 "repoId": 987670088,
961961 "pullRequestNo": 1628
962+ },
963+ {
964+ "name": "flatsponge",
965+ "id": 104839509,
966+ "comment_id": 3673002560,
967+ "created_at": "2025-12-19T01:11:45Z",
968+ "repoId": 987670088,
969+ "pullRequestNo": 1668
970+ },
971+ {
972+ "name": "jonhoo",
973+ "id": 176295,
974+ "comment_id": 3674853134,
975+ "created_at": "2025-12-19T12:14:08Z",
976+ "repoId": 987670088,
977+ "pullRequestNo": 1675
978+ },
979+ {
980+ "name": "Mr777x-enf",
981+ "id": 248610315,
982+ "comment_id": 3682737876,
983+ "created_at": "2025-12-22T16:07:47Z",
984+ "repoId": 987670088,
985+ "pullRequestNo": 1694
986+ },
987+ {
988+ "name": "yuguorui",
989+ "id": 6182414,
990+ "comment_id": 3687495909,
991+ "created_at": "2025-12-23T17:59:11Z",
992+ "repoId": 987670088,
993+ "pullRequestNo": 1709
994+ },
995+ {
996+ "name": "aeroxy",
997+ "id": 2761307,
998+ "comment_id": 3693734613,
999+ "created_at": "2025-12-27T06:01:58Z",
1000+ "repoId": 987670088,
1001+ "pullRequestNo": 1723
9621002 }
9631003 ]
9641004}
.github/workflows/schema-update.yml+1−1
@@ -19,7 +19,7 @@ jobs:
1919 go-version-file: go.mod
2020 - run: go run . schema > ./schema.json
2121 - run: go generate ./internal/agent/hyper/...
22− - uses: stefanzweifel/git-auto-commit-action@28e16e81777b558cc906c8750092100bbb34c5e3 # v5
22+ - uses: stefanzweifel/git-auto-commit-action@04702edda442b2e678b25b537cec683a1493fcb9 # v5
2323 with:
2424 commit_message: "chore: auto-update files"
2525 branch: main
README.md+43
@@ -362,6 +362,49 @@ completely hidden from the agent.
362362
363363To disable tools from MCP servers, see the [MCP config section](#mcps).
364364
365+### Agent Skills
366+
367+Crush supports the [Agent Skills](https://agentskills.io) open standard for
368+extending agent capabilities with reusable skill packages. Skills are folders
369+containing a `SKILL.md` file with instructions that Crush can discover and
370+activate on demand.
371+
372+Skills are discovered from:
373+
374+- `~/.config/crush/skills/` on Unix (default, can be overridden with `CRUSH_SKILLS_DIR`)
375+- `%LOCALAPPDATA%\crush\skills\` on Windows (default, can be overridden with `CRUSH_SKILLS_DIR`)
376+- Additional paths configured via `options.skills_paths`
377+
378+```jsonc
379+{
380+ "$schema": "https://charm.land/crush.json",
381+ "options": {
382+ "skills_paths": [
383+ "~/.config/crush/skills", // Windows: "%LOCALAPPDATA%\\crush\\skills",
384+ "./project-skills"
385+ ]
386+ }
387+}
388+```
389+
390+You can get started with example skills from [anthropics/skills](https://github.com/anthropics/skills):
391+
392+```bash
393+# Unix
394+mkdir -p ~/.config/crush/skills
395+cd ~/.config/crush/skills
396+git clone https://github.com/anthropics/skills.git _temp
397+mv _temp/skills/* . && rm -rf _temp
398+```
399+
400+```powershell
401+# Windows (PowerShell)
402+mkdir -Force "$env:LOCALAPPDATA\crush\skills"
403+cd "$env:LOCALAPPDATA\crush\skills"
404+git clone https://github.com/anthropics/skills.git _temp
405+mv _temp/skills/* . ; rm -r -force _temp
406+```
407+
365408### Initialization
366409
367410When you initialize a project, Crush analyzes your codebase and creates
go.mod+6−6
@@ -5,7 +5,7 @@ go 1.25.5
55require (
66 charm.land/bubbles/v2 v2.0.0-rc.1
77 charm.land/bubbletea/v2 v2.0.0-rc.2.0.20251216153312-819e2e89c62e
8− charm.land/fantasy v0.5.3
8+ charm.land/fantasy v0.5.5
99 charm.land/glamour/v2 v2.0.0-20251110203732-69649f93d3b1
1010 charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251205162909-7869489d8971
1111 charm.land/log/v2 v2.0.0-20251110204020-529bb77f35da
@@ -13,12 +13,12 @@ require (
1313 github.com/JohannesKaufmann/html-to-markdown v1.6.0
1414 github.com/MakeNowJust/heredoc v1.0.0
1515 github.com/PuerkitoBio/goquery v1.11.0
16− github.com/alecthomas/chroma/v2 v2.20.0
16+ github.com/alecthomas/chroma/v2 v2.21.1
1717 github.com/atotto/clipboard v0.1.4
1818 github.com/aymanbagabas/go-udiff v0.3.1
1919 github.com/bmatcuk/doublestar/v4 v4.9.1
2020 github.com/charlievieth/fastwalk v1.0.14
21− github.com/charmbracelet/catwalk v0.11.0
21+ github.com/charmbracelet/catwalk v0.11.2
2222 github.com/charmbracelet/colorprofile v0.4.1
2323 github.com/charmbracelet/fang v0.4.4
2424 github.com/charmbracelet/ultraviolet v0.0.0-20251212194010-b927aa605560
@@ -39,7 +39,7 @@ require (
3939 github.com/lucasb-eyer/go-colorful v1.3.0
4040 github.com/modelcontextprotocol/go-sdk v1.1.0
4141 github.com/muesli/termenv v0.16.0
42− github.com/ncruces/go-sqlite3 v0.30.3
42+ github.com/ncruces/go-sqlite3 v0.30.4
4343 github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646
4444 github.com/nxadm/tail v1.4.11
4545 github.com/openai/openai-go/v2 v2.7.1
@@ -62,6 +62,7 @@ require (
6262 golang.org/x/sync v0.19.0
6363 golang.org/x/text v0.32.0
6464 gopkg.in/natefinch/lumberjack.v2 v2.2.1
65+ gopkg.in/yaml.v3 v3.0.1
6566 mvdan.cc/sh/moreinterp v0.0.0-20250902163504-3cf4fd5717a5
6667 mvdan.cc/sh/v3 v3.12.1-0.20250902163504-3cf4fd5717a5
6768)
@@ -145,7 +146,7 @@ require (
145146 github.com/sethvargo/go-retry v0.3.0 // indirect
146147 github.com/sourcegraph/jsonrpc2 v0.2.1 // indirect
147148 github.com/spf13/pflag v1.0.9 // indirect
148− github.com/tetratelabs/wazero v1.10.1 // indirect
149+ github.com/tetratelabs/wazero v1.11.0 // indirect
149150 github.com/tidwall/match v1.1.1 // indirect
150151 github.com/tidwall/pretty v1.2.1 // indirect
151152 github.com/u-root/u-root v0.14.1-0.20250807200646-5e7721023dc7 // indirect
@@ -177,5 +178,4 @@ require (
177178 google.golang.org/protobuf v1.36.10 // indirect
178179 gopkg.in/dnaeon/go-vcr.v4 v4.0.6-0.20251110073552-01de4eb40290 // indirect
179180 gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect
180− gopkg.in/yaml.v3 v3.0.1 // indirect
181181)
go.sum+12−12
@@ -2,8 +2,8 @@ charm.land/bubbles/v2 v2.0.0-rc.1 h1:EiIFVAc3Zi/yY86td+79mPhHR7AqZ1OxF+6ztpOCRaM
22charm.land/bubbles/v2 v2.0.0-rc.1/go.mod h1:5AbN6cEd/47gkEf8TgiQ2O3RZ5QxMS14l9W+7F9fPC4=
33charm.land/bubbletea/v2 v2.0.0-rc.2.0.20251216153312-819e2e89c62e h1:tXwTmgGpwZT7ParKF5xbEQBVjM2e1uKhKi/GpfU3mYQ=
44charm.land/bubbletea/v2 v2.0.0-rc.2.0.20251216153312-819e2e89c62e/go.mod h1:pDM18flq3Z4njKZPA3zCvyVSSIJbMcoqlE82BdGUtL8=
5−charm.land/fantasy v0.5.3 h1:+6meCTaH9lrqrcVTEBgsaSkkY0ctC/6dtIufKZcMdMI=
6−charm.land/fantasy v0.5.3/go.mod h1:WnH5fJJRMGylx1fL1ow9Kfq0+sPMr5fenpHYAnoTlTg=
5+charm.land/fantasy v0.5.5 h1:Dw/NBLH9HLX/ouCz604RXGD7BYzr0lT56/B4ylMGZjg=
6+charm.land/fantasy v0.5.5/go.mod h1:QyJLJGissYdBifvitgAxFcYhNACSr0G1faC75CIESUk=
77charm.land/glamour/v2 v2.0.0-20251110203732-69649f93d3b1 h1:9q4+yyU7105T3OrOx0csMyKnw89yMSijJ+rVld/Z2ek=
88charm.land/glamour/v2 v2.0.0-20251110203732-69649f93d3b1/go.mod h1:J3kVhY6oHXZq5f+8vC3hmDO95fEvbqj3z7xDwxrfzU8=
99charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251205162909-7869489d8971 h1:xZFcNsJMiIDbFtWRyDmkKNk1sjojfaom4Zoe0cyH/8c=
@@ -39,10 +39,10 @@ github.com/RealAlexandreAI/json-repair v0.0.14 h1:4kTqotVonDVTio5n2yweRUELVcNe2x
3939github.com/RealAlexandreAI/json-repair v0.0.14/go.mod h1:GKJi5borR78O8c7HCVbgqjhoiVibZ6hJldxbc6dGrAI=
4040github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
4141github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
42−github.com/alecthomas/chroma/v2 v2.20.0 h1:sfIHpxPyR07/Oylvmcai3X/exDlE8+FA820NTz+9sGw=
43−github.com/alecthomas/chroma/v2 v2.20.0/go.mod h1:e7tViK0xh/Nf4BYHl00ycY6rV7b8iXBksI9E359yNmA=
44−github.com/alecthomas/repr v0.5.1 h1:E3G4t2QbHTSNpPKBgMTln5KLkZHLOcU7r37J4pXBuIg=
45−github.com/alecthomas/repr v0.5.1/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
42+github.com/alecthomas/chroma/v2 v2.21.1 h1:FaSDrp6N+3pphkNKU6HPCiYLgm8dbe5UXIXcoBhZSWA=
43+github.com/alecthomas/chroma/v2 v2.21.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o=
44+github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
45+github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
4646github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU=
4747github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
4848github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
@@ -92,8 +92,8 @@ github.com/charlievieth/fastwalk v1.0.14 h1:3Eh5uaFGwHZd8EGwTjJnSpBkfwfsak9h6ICg
9292github.com/charlievieth/fastwalk v1.0.14/go.mod h1:diVcUreiU1aQ4/Wu3NbxxH4/KYdKpLDojrQ1Bb2KgNY=
9393github.com/charmbracelet/anthropic-sdk-go v0.0.0-20251024181547-21d6f3d9a904 h1:rwLdEpG9wE6kL69KkEKDiWprO8pQOZHZXeod6+9K+mw=
9494github.com/charmbracelet/anthropic-sdk-go v0.0.0-20251024181547-21d6f3d9a904/go.mod h1:8TIYxZxsuCqqeJ0lga/b91tBwrbjoHDC66Sq5t8N2R4=
95−github.com/charmbracelet/catwalk v0.11.0 h1:PU3rkc4h4YVJEn9Iyb/1rQAaF4hEd04fuG4tj3vv4dg=
96−github.com/charmbracelet/catwalk v0.11.0/go.mod h1:qg+Yl9oaZTkTvRscqbxfttzOFQ4v0pOT5XwC7b5O0NQ=
95+github.com/charmbracelet/catwalk v0.11.2 h1:m+eE7yv/uIrKW95FpFeGDMFrAugotylX89XzpkZwlLk=
96+github.com/charmbracelet/catwalk v0.11.2/go.mod h1:qg+Yl9oaZTkTvRscqbxfttzOFQ4v0pOT5XwC7b5O0NQ=
9797github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
9898github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
9999github.com/charmbracelet/fang v0.4.4 h1:G4qKxF6or/eTPgmAolwPuRNyuci3hTUGGX1rj1YkHJY=
@@ -248,8 +248,8 @@ github.com/muesli/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8=
248248github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig=
249249github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
250250github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
251−github.com/ncruces/go-sqlite3 v0.30.3 h1:X/CgWW9GzmIAkEPrifhKqf0cC15DuOVxAJaHFTTAURQ=
252−github.com/ncruces/go-sqlite3 v0.30.3/go.mod h1:AxKu9sRxkludimFocbktlY6LiYSkxiI5gTA8r+os/Nw=
251+github.com/ncruces/go-sqlite3 v0.30.4 h1:j9hEoOL7f9ZoXl8uqXVniaq1VNwlWAXihZbTvhqPPjA=
252+github.com/ncruces/go-sqlite3 v0.30.4/go.mod h1:7WR20VSC5IZusKhUdiR9y1NsUqnZgqIYCmKKoMEYg68=
253253github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
254254github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
255255github.com/ncruces/julianday v1.0.0 h1:fH0OKwa7NWvniGQtxdJRxAgkBMolni2BjDHaWTxqt7M=
@@ -310,8 +310,8 @@ github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81P
310310github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
311311github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
312312github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
313−github.com/tetratelabs/wazero v1.10.1 h1:2DugeJf6VVk58KTPszlNfeeN8AhhpwcZqkJj2wwFuH8=
314−github.com/tetratelabs/wazero v1.10.1/go.mod h1:DRm5twOQ5Gr1AoEdSi0CLjDQF1J9ZAuyqFIjl1KKfQU=
313+github.com/tetratelabs/wazero v1.11.0 h1:+gKemEuKCTevU4d7ZTzlsvgd1uaToIDtlQlmNbwqYhA=
314+github.com/tetratelabs/wazero v1.11.0/go.mod h1:eV28rsN8Q+xwjogd7f4/Pp4xFxO7uOGbLcD/LzB1wiU=
315315github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
316316github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
317317github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
internal/agent/agent.go+30−10
@@ -171,10 +171,9 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy
171171 var wg sync.WaitGroup
172172 // Generate title if first message.
173173 if len(msgs) == 0 {
174+ titleCtx := ctx // Copy to avoid race with ctx reassignment below.
174175 wg.Go(func() {
175− sessionLock.Lock()
176− a.generateTitle(ctx, &currentSession, call.Prompt)
177− sessionLock.Unlock()
176+ a.generateTitle(titleCtx, call.SessionID, call.Prompt)
178177 })
179178 }
180179
@@ -201,7 +200,7 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy
201200 var currentAssistant *message.Message
202201 var shouldSummarize bool
203202 result, err := agent.Stream(genCtx, fantasy.AgentStreamCall{
204− Prompt: call.Prompt,
203+ Prompt: message.PromptWithTextAttachments(call.Prompt, call.Attachments),
205204 Files: files,
206205 Messages: history,
207206 ProviderOptions: call.ProviderOptions,
@@ -650,11 +649,11 @@ func (a *sessionAgent) getCacheControlOptions() fantasy.ProviderOptions {
650649}
651650
652651func (a *sessionAgent) createUserMessage(ctx context.Context, call SessionAgentCall) (message.Message, error) {
652+ parts := []message.ContentPart{message.TextContent{Text: call.Prompt}}
653653 var attachmentParts []message.ContentPart
654654 for _, attachment := range call.Attachments {
655655 attachmentParts = append(attachmentParts, message.BinaryContent{Path: attachment.FilePath, MIMEType: attachment.MimeType, Data: attachment.Content})
656656 }
657− parts := []message.ContentPart{message.TextContent{Text: call.Prompt}}
658657 parts = append(parts, attachmentParts...)
659658 msg, err := a.messages.Create(ctx, call.SessionID, message.CreateMessageParams{
660659 Role: message.User,
@@ -691,6 +690,9 @@ If not, please feel free to ignore. Again do not mention this message to the use
691690
692691 var files []fantasy.FilePart
693692 for _, attachment := range attachments {
693+ if attachment.IsText() {
694+ continue
695+ }
694696 files = append(files, fantasy.FilePart{
695697 Filename: attachment.FileName,
696698 Data: attachment.Content,
@@ -723,7 +725,7 @@ func (a *sessionAgent) getSessionMessages(ctx context.Context, session session.S
723725 return msgs, nil
724726}
725727
726−func (a *sessionAgent) generateTitle(ctx context.Context, session *session.Session, prompt string) {
728+func (a *sessionAgent) generateTitle(ctx context.Context, sessionID string, prompt string) {
727729 if prompt == "" {
728730 return
729731 }
@@ -768,8 +770,7 @@ func (a *sessionAgent) generateTitle(ctx context.Context, session *session.Sessi
768770 return
769771 }
770772
771− session.Title = title
772−
773+ // Calculate usage and cost.
773774 var openrouterCost *float64
774775 for _, step := range resp.Steps {
775776 stepCost := a.openrouterCost(step.ProviderMetadata)
@@ -782,8 +783,27 @@ func (a *sessionAgent) generateTitle(ctx context.Context, session *session.Sessi
782783 }
783784 }
784785
785− a.updateSessionUsage(a.smallModel, session, resp.TotalUsage, openrouterCost)
786− _, saveErr := a.sessions.Save(ctx, *session)
786+ modelConfig := a.smallModel.CatwalkCfg
787+ cost := modelConfig.CostPer1MInCached/1e6*float64(resp.TotalUsage.CacheCreationTokens) +
788+ modelConfig.CostPer1MOutCached/1e6*float64(resp.TotalUsage.CacheReadTokens) +
789+ modelConfig.CostPer1MIn/1e6*float64(resp.TotalUsage.InputTokens) +
790+ modelConfig.CostPer1MOut/1e6*float64(resp.TotalUsage.OutputTokens)
791+
792+ if a.isClaudeCode() {
793+ cost = 0
794+ }
795+
796+ // Use override cost if available (e.g., from OpenRouter).
797+ if openrouterCost != nil {
798+ cost = *openrouterCost
799+ }
800+
801+ promptTokens := resp.TotalUsage.InputTokens + resp.TotalUsage.CacheCreationTokens
802+ completionTokens := resp.TotalUsage.OutputTokens + resp.TotalUsage.CacheReadTokens
803+
804+ // Atomically update only title and usage fields to avoid overriding other
805+ // concurrent session updates.
806+ saveErr := a.sessions.UpdateTitleAndUsage(ctx, sessionID, title, promptTokens, completionTokens, cost)
787807 if saveErr != nil {
788808 slog.Error("failed to save session title & usage", "error", saveErr)
789809 return
internal/agent/common_test.go+4
@@ -178,6 +178,10 @@ func coderAgent(r *vcr.Recorder, env fakeEnv, large, small fantasy.LanguageModel
178178 GeneratedWith: true,
179179 }
180180
181+ // Clear skills paths to ensure test reproducibility - user's skills
182+ // would be included in prompt and break VCR cassette matching.
183+ cfg.Options.SkillsPaths = []string{}
184+
181185 systemPrompt, err := prompt.Build(context.TODO(), large.Provider(), large.Model(), *cfg)
182186 if err != nil {
183187 return nil, err
internal/agent/coordinator.go+13−8
@@ -123,7 +123,14 @@ func (c *coordinator) Run(ctx context.Context, sessionID string, prompt string,
123123 }
124124
125125 if !model.CatwalkCfg.SupportsImages && attachments != nil {
126− attachments = nil
126+ // filter out image attachments
127+ filteredAttachments := make([]message.Attachment, 0, len(attachments))
128+ for _, att := range attachments {
129+ if att.IsText() {
130+ filteredAttachments = append(filteredAttachments, att)
131+ }
132+ }
133+ attachments = filteredAttachments
127134 }
128135
129136 providerCfg, ok := c.cfg.Providers.Get(model.ModelCfg.Provider)
@@ -383,7 +390,7 @@ func (c *coordinator) buildTools(ctx context.Context, agent config.Agent) ([]fan
383390 tools.NewLsTool(c.permissions, c.cfg.WorkingDir(), c.cfg.Tools.Ls),
384391 tools.NewSourcegraphTool(nil),
385392 tools.NewTodosTool(c.sessions),
386− tools.NewViewTool(c.lspClients, c.permissions, c.cfg.WorkingDir()),
393+ tools.NewViewTool(c.lspClients, c.permissions, c.cfg.WorkingDir(), c.cfg.Options.SkillsPaths...),
387394 tools.NewWriteTool(c.lspClients, c.permissions, c.history, c.cfg.WorkingDir()),
388395 )
389396
@@ -516,14 +523,13 @@ func (c *coordinator) buildAgentModels(ctx context.Context) (Model, Model, error
516523 }, nil
517524}
518525
519−func (c *coordinator) buildAnthropicProvider(baseURL, apiKey string, headers map[string]string) (fantasy.Provider, error) {
526+func (c *coordinator) buildAnthropicProvider(baseURL, apiKey string, headers map[string]string, isOauth bool) (fantasy.Provider, error) {
520527 var opts []anthropic.Option
521528
522− if strings.HasPrefix(apiKey, "Bearer ") {
529+ if isOauth {
523530 // NOTE: Prevent the SDK from picking up the API key from env.
524531 os.Setenv("ANTHROPIC_API_KEY", "")
525−
526− headers["Authorization"] = apiKey
532+ headers["Authorization"] = fmt.Sprintf("Bearer %s", apiKey)
527533 } else if apiKey != "" {
528534 // X-Api-Key header
529535 opts = append(opts, anthropic.WithAPIKey(apiKey))
@@ -541,7 +547,6 @@ func (c *coordinator) buildAnthropicProvider(baseURL, apiKey string, headers map
541547 httpClient := log.NewHTTPClient()
542548 opts = append(opts, anthropic.WithHTTPClient(httpClient))
543549 }
544−
545550 return anthropic.New(opts...)
546551}
547552
@@ -722,7 +727,7 @@ func (c *coordinator) buildProvider(providerCfg config.ProviderConfig, model con
722727 case openai.Name:
723728 return c.buildOpenaiProvider(baseURL, apiKey, headers)
724729 case anthropic.Name:
725− return c.buildAnthropicProvider(baseURL, apiKey, headers)
730+ return c.buildAnthropicProvider(baseURL, apiKey, headers, providerCfg.OAuthToken != nil)
726731 case openrouter.Name:
727732 return c.buildOpenrouterProvider(baseURL, apiKey, headers)
728733 case azure.Name:
internal/agent/prompt/prompt.go+31−16
@@ -14,6 +14,7 @@ import (
1414 "github.com/charmbracelet/crush/internal/config"
1515 "github.com/charmbracelet/crush/internal/home"
1616 "github.com/charmbracelet/crush/internal/shell"
17+ "github.com/charmbracelet/crush/internal/skills"
1718)
1819
1920// Prompt represents a template-based prompt generator.
@@ -26,15 +27,16 @@ type Prompt struct {
2627}
2728
2829type PromptDat struct {
29− Provider string
30− Model string
31− Config config.Config
32− WorkingDir string
33− IsGitRepo bool
34− Platform string
35− Date string
36− GitStatus string
37− ContextFiles []ContextFile
30+ Provider string
31+ Model string
32+ Config config.Config
33+ WorkingDir string
34+ IsGitRepo bool
35+ Platform string
36+ Date string
37+ GitStatus string
38+ ContextFiles []ContextFile
39+ AvailSkillXML string
3840}
3941
4042type ContextFile struct {
@@ -162,15 +164,28 @@ func (p *Prompt) promptData(ctx context.Context, provider, model string, cfg con
162164 files[pathKey] = content
163165 }
164166
167+ // Discover and load skills metadata.
168+ var availSkillXML string
169+ if len(cfg.Options.SkillsPaths) > 0 {
170+ expandedPaths := make([]string, 0, len(cfg.Options.SkillsPaths))
171+ for _, pth := range cfg.Options.SkillsPaths {
172+ expandedPaths = append(expandedPaths, expandPath(pth, cfg))
173+ }
174+ if discoveredSkills := skills.Discover(expandedPaths); len(discoveredSkills) > 0 {
175+ availSkillXML = skills.ToPromptXML(discoveredSkills)
176+ }
177+ }
178+
165179 isGit := isGitRepo(cfg.WorkingDir())
166180 data := PromptDat{
167− Provider: provider,
168− Model: model,
169− Config: cfg,
170− WorkingDir: filepath.ToSlash(workingDir),
171− IsGitRepo: isGit,
172− Platform: platform,
173− Date: p.now().Format("1/2/2006"),
181+ Provider: provider,
182+ Model: model,
183+ Config: cfg,
184+ WorkingDir: filepath.ToSlash(workingDir),
185+ IsGitRepo: isGit,
186+ Platform: platform,
187+ Date: p.now().Format("1/2/2006"),
188+ AvailSkillXML: availSkillXML,
174189 }
175190 if isGit {
176191 var err error
internal/agent/templates/coder.md.tpl+10
@@ -360,6 +360,16 @@ Diagnostics (lint/typecheck) included in tool output.
360360- Ignore issues in files you didn't touch (unless user asks)
361361</lsp>
362362{{end}}
363+{{- if .AvailSkillXML}}
364+
365+{{.AvailSkillXML}}
366+
367+<skills_usage>
368+When a user task matches a skill's description, read the skill's SKILL.md file to get full instructions.
369+Skills are activated by reading their location path. Follow the skill's instructions to complete the task.
370+If a skill mentions scripts, references, or assets, they are placed in the same folder as the skill itself (e.g., scripts/, references/, assets/ subdirectories within the skill's folder).
371+</skills_usage>
372+{{end}}
363373
364374{{if .ContextFiles}}
365375<memory>
internal/agent/tools/view.go+58−13
@@ -38,6 +38,7 @@ type viewTool struct {
3838 lspClients *csync.Map[string, *lsp.Client]
3939 workingDir string
4040 permissions permission.Service
41+ skillsPaths []string
4142}
4243
4344type ViewResponseMetadata struct {
@@ -52,7 +53,7 @@ const (
5253 MaxLineLength = 2000
5354)
5455
55−func NewViewTool(lspClients *csync.Map[string, *lsp.Client], permissions permission.Service, workingDir string) fantasy.AgentTool {
56+func NewViewTool(lspClients *csync.Map[string, *lsp.Client], permissions permission.Service, workingDir string, skillsPaths ...string) fantasy.AgentTool {
5657 return fantasy.NewAgentTool(
5758 ViewToolName,
5859 string(viewDescription),
@@ -76,8 +77,11 @@ func NewViewTool(lspClients *csync.Map[string, *lsp.Client], permissions permiss
7677 }
7778
7879 relPath, err := filepath.Rel(absWorkingDir, absFilePath)
79− if err != nil || strings.HasPrefix(relPath, "..") {
80− // File is outside working directory, request permission
80+ isOutsideWorkDir := err != nil || strings.HasPrefix(relPath, "..")
81+ isSkillFile := isInSkillsPath(absFilePath, skillsPaths)
82+
83+ // Request permission for files outside working directory, unless it's a skill file.
84+ if isOutsideWorkDir && !isSkillFile {
8185 sessionID := GetSessionFromContext(ctx)
8286 if sessionID == "" {
8387 return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for accessing files outside working directory")
@@ -137,19 +141,23 @@ func NewViewTool(lspClients *csync.Map[string, *lsp.Client], permissions permiss
137141 return fantasy.NewTextErrorResponse(fmt.Sprintf("Path is a directory, not a file: %s", filePath)), nil
138142 }
139143
140− // Check file size
141− if fileInfo.Size() > MaxReadSize {
144+ // Based on the specifications we should not limit the skills read.
145+ if !isSkillFile && fileInfo.Size() > MaxReadSize {
142146 return fantasy.NewTextErrorResponse(fmt.Sprintf("File is too large (%d bytes). Maximum size is %d bytes",
143147 fileInfo.Size(), MaxReadSize)), nil
144148 }
145149
146− // Set default limit if not provided
150+ // Set default limit if not provided (no limit for SKILL.md files)
147151 if params.Limit <= 0 {
148− params.Limit = DefaultReadLimit
152+ if isSkillFile {
153+ params.Limit = 1000000 // Effectively no limit for skill files
154+ } else {
155+ params.Limit = DefaultReadLimit
156+ }
149157 }
150158
151− isImage, mimeType := getImageMimeType(filePath)
152− if isImage {
159+ isSupportedImage, mimeType := getImageMimeType(filePath)
160+ if isSupportedImage {
153161 if !GetSupportsImagesFromContext(ctx) {
154162 modelName := GetModelNameFromContext(ctx)
155163 return fantasy.NewTextErrorResponse(fmt.Sprintf("This model (%s) does not support image data.", modelName)), nil
@@ -282,10 +290,6 @@ func getImageMimeType(filePath string) (bool, string) {
282290 return true, "image/png"
283291 case ".gif":
284292 return true, "image/gif"
285− case ".bmp":
286− return true, "image/bmp"
287− case ".svg":
288− return true, "image/svg+xml"
289293 case ".webp":
290294 return true, "image/webp"
291295 default:
@@ -319,3 +323,44 @@ func (s *LineScanner) Text() string {
319323func (s *LineScanner) Err() error {
320324 return s.scanner.Err()
321325}
326+
327+// isInSkillsPath checks if filePath is within any of the configured skills
328+// directories. Returns true for files that can be read without permission
329+// prompts and without size limits.
330+//
331+// Note that symlinks are resolved to prevent path traversal attacks via
332+// symbolic links.
333+func isInSkillsPath(filePath string, skillsPaths []string) bool {
334+ if len(skillsPaths) == 0 {
335+ return false
336+ }
337+
338+ absFilePath, err := filepath.Abs(filePath)
339+ if err != nil {
340+ return false
341+ }
342+
343+ evalFilePath, err := filepath.EvalSymlinks(absFilePath)
344+ if err != nil {
345+ return false
346+ }
347+
348+ for _, skillsPath := range skillsPaths {
349+ absSkillsPath, err := filepath.Abs(skillsPath)
350+ if err != nil {
351+ continue
352+ }
353+
354+ evalSkillsPath, err := filepath.EvalSymlinks(absSkillsPath)
355+ if err != nil {
356+ continue
357+ }
358+
359+ relPath, err := filepath.Rel(evalSkillsPath, evalFilePath)
360+ if err == nil && !strings.HasPrefix(relPath, "..") {
361+ return true
362+ }
363+ }
364+
365+ return false
366+}
internal/app/app.go+2−2
@@ -69,7 +69,7 @@ func New(ctx context.Context, conn *sql.DB, cfg *config.Config) (*App, error) {
6969 messages := message.NewService(q)
7070 files := history.NewService(q, conn)
7171 skipPermissionsRequests := cfg.Permissions != nil && cfg.Permissions.SkipRequests
72− allowedTools := []string{}
72+ var allowedTools []string
7373 if cfg.Permissions != nil && cfg.Permissions.AllowedTools != nil {
7474 allowedTools = cfg.Permissions.AllowedTools
7575 }
@@ -415,7 +415,7 @@ func (app *App) Shutdown() {
415415 })
416416 }
417417
418− // Call call cleanup functions.
418+ // Call all cleanup functions.
419419 for _, cleanup := range app.cleanupFuncs {
420420 if cleanup != nil {
421421 wg.Go(func() {
internal/app/lsp.go+1−1
@@ -38,7 +38,7 @@ func (app *App) createAndStartLSPClient(ctx context.Context, name string, config
3838 // Create LSP client.
3939 lspClient, err := lsp.New(ctx, name, config, app.config.Resolver())
4040 if err != nil {
41− slog.Error("Failed to create LSP client for", name, err)
41+ slog.Error("Failed to create LSP client for", "name", name, "error", err)
4242 updateLSPState(name, lsp.StateError, err, nil, 0)
4343 return
4444 }
internal/cmd/login.go+1−2
@@ -75,8 +75,7 @@ func loginHyper() error {
7575 if !hyperp.Enabled() {
7676 return fmt.Errorf("hyper not enabled")
7777 }
78− ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, os.Kill)
79− defer cancel()
78+ ctx := getLoginContext()
8079
8180 resp, err := hyper.InitiateDeviceAuth(ctx)
8281 if err != nil {
internal/config/config.go+6−4
@@ -9,6 +9,7 @@ import (
99 "net/http"
1010 "net/url"
1111 "os"
12+ "path/filepath"
1213 "slices"
1314 "strings"
1415 "time"
@@ -255,6 +256,7 @@ func (Attribution) JSONSchemaExtend(schema *jsonschema.Schema) {
255256
256257type Options struct {
257258 ContextPaths []string `json:"context_paths,omitempty" jsonschema:"description=Paths to files containing context information for the AI,example=.cursorrules,example=CRUSH.md"`
259+ SkillsPaths []string `json:"skills_paths,omitempty" jsonschema:"description=Paths to directories containing Agent Skills (folders with SKILL.md files),example=~/.config/crush/skills,example=./skills"`
258260 TUI *TUIOptions `json:"tui,omitempty" jsonschema:"description=Terminal user interface options"`
259261 Debug bool `json:"debug,omitempty" jsonschema:"description=Enable debug logging,default=false"`
260262 DebugLSP bool `json:"debug_lsp,omitempty" jsonschema:"description=Enable debug logging for LSP servers,default=false"`
@@ -498,7 +500,6 @@ func (c *Config) HasConfigField(key string) bool {
498500}
499501
500502func (c *Config) SetConfigField(key string, value any) error {
501− // read the data
502503 data, err := os.ReadFile(c.dataConfigDir)
503504 if err != nil {
504505 if os.IsNotExist(err) {
@@ -512,6 +513,9 @@ func (c *Config) SetConfigField(key string, value any) error {
512513 if err != nil {
513514 return fmt.Errorf("failed to set config field %s: %w", key, err)
514515 }
516+ if err := os.MkdirAll(filepath.Dir(c.dataConfigDir), 0o755); err != nil {
517+ return fmt.Errorf("failed to create config directory %q: %w", c.dataConfigDir, err)
518+ }
515519 if err := os.WriteFile(c.dataConfigDir, []byte(newValue), 0o600); err != nil {
516520 return fmt.Errorf("failed to write config file: %w", err)
517521 }
@@ -547,13 +551,12 @@ func (c *Config) RefreshOAuthToken(ctx context.Context, providerID string) error
547551
548552 slog.Info("Successfully refreshed OAuth token", "provider", providerID)
549553 providerConfig.OAuthToken = newToken
554+ providerConfig.APIKey = newToken.AccessToken
550555
551556 switch providerID {
552557 case string(catwalk.InferenceProviderAnthropic):
553− providerConfig.APIKey = fmt.Sprintf("Bearer %s", newToken.AccessToken)
554558 providerConfig.SetupClaudeCode()
555559 case string(catwalk.InferenceProviderCopilot):
556− providerConfig.APIKey = newToken.AccessToken
557560 providerConfig.SetupGitHubCopilot()
558561 }
559562
@@ -592,7 +595,6 @@ func (c *Config) SetProviderAPIKey(providerID string, apiKey any) error {
592595 providerConfig.OAuthToken = v
593596 switch providerID {
594597 case string(catwalk.InferenceProviderAnthropic):
595− providerConfig.APIKey = fmt.Sprintf("Bearer %s", v.AccessToken)
596598 providerConfig.SetupClaudeCode()
597599 case string(catwalk.InferenceProviderCopilot):
598600 providerConfig.SetupGitHubCopilot()
internal/config/copilot.go+6−1
@@ -6,11 +6,12 @@ import (
66 "log/slog"
77 "testing"
88
9+ "github.com/charmbracelet/catwalk/pkg/catwalk"
910 "github.com/charmbracelet/crush/internal/oauth"
1011 "github.com/charmbracelet/crush/internal/oauth/copilot"
1112)
1213
13−func (c *Config) importCopilot() (*oauth.Token, bool) {
14+func (c *Config) ImportCopilot() (*oauth.Token, bool) {
1415 if testing.Testing() {
1516 return nil, false
1617 }
@@ -31,6 +32,10 @@ func (c *Config) importCopilot() (*oauth.Token, bool) {
3132 return nil, false
3233 }
3334
35+ if err := c.SetProviderAPIKey(string(catwalk.InferenceProviderCopilot), token); err != nil {
36+ return token, false
37+ }
38+
3439 if err := cmp.Or(
3540 c.SetConfigField("providers.copilot.api_key", token.AccessToken),
3641 c.SetConfigField("providers.copilot.oauth", token),
internal/config/load.go+31−7
@@ -133,8 +133,6 @@ func PushPopCrushEnv() func() {
133133}
134134
135135func (c *Config) configureProviders(env env.Env, resolver VariableResolver, knownProviders []catwalk.Provider) error {
136− c.importCopilot()
137−
138136 knownProviderNames := make(map[string]bool)
139137 restore := PushPopCrushEnv()
140138 defer restore()
@@ -206,11 +204,6 @@ func (c *Config) configureProviders(env env.Env, resolver VariableResolver, know
206204 case p.ID == catwalk.InferenceProviderAnthropic && config.OAuthToken != nil:
207205 prepared.SetupClaudeCode()
208206 case p.ID == catwalk.InferenceProviderCopilot:
209− if config.OAuthToken != nil {
210− if token, ok := c.importCopilot(); ok {
211− prepared.OAuthToken = token
212− }
213− }
214207 if config.OAuthToken != nil {
215208 prepared.SetupGitHubCopilot()
216209 }
@@ -336,6 +329,9 @@ func (c *Config) setDefaults(workingDir, dataDir string) {
336329 if c.Options.ContextPaths == nil {
337330 c.Options.ContextPaths = []string{}
338331 }
332+ if c.Options.SkillsPaths == nil {
333+ c.Options.SkillsPaths = []string{}
334+ }
339335 if dataDir != "" {
340336 c.Options.DataDirectory = dataDir
341337 } else if c.Options.DataDirectory == "" {
@@ -369,6 +365,12 @@ func (c *Config) setDefaults(workingDir, dataDir string) {
369365 slices.Sort(c.Options.ContextPaths)
370366 c.Options.ContextPaths = slices.Compact(c.Options.ContextPaths)
371367
368+ // Add the default skills directory if not already present.
369+ defaultSkillsDir := GlobalSkillsDir()
370+ if !slices.Contains(c.Options.SkillsPaths, defaultSkillsDir) {
371+ c.Options.SkillsPaths = append([]string{defaultSkillsDir}, c.Options.SkillsPaths...)
372+ }
373+
372374 if str, ok := os.LookupEnv("CRUSH_DISABLE_PROVIDER_AUTO_UPDATE"); ok {
373375 c.Options.DisableProviderAutoUpdate, _ = strconv.ParseBool(str)
374376 }
@@ -743,3 +745,25 @@ func isInsideWorktree() bool {
743745 ).CombinedOutput()
744746 return err == nil && strings.TrimSpace(string(bts)) == "true"
745747}
748+
749+// GlobalSkillsDir returns the default directory for Agent Skills.
750+// Skills in this directory are auto-discovered and their files can be read
751+// without permission prompts.
752+func GlobalSkillsDir() string {
753+ if crushSkills := os.Getenv("CRUSH_SKILLS_DIR"); crushSkills != "" {
754+ return crushSkills
755+ }
756+ if xdgConfigHome := os.Getenv("XDG_CONFIG_HOME"); xdgConfigHome != "" {
757+ return filepath.Join(xdgConfigHome, appName, "skills")
758+ }
759+
760+ if runtime.GOOS == "windows" {
761+ localAppData := cmp.Or(
762+ os.Getenv("LOCALAPPDATA"),
763+ filepath.Join(os.Getenv("USERPROFILE"), "AppData", "Local"),
764+ )
765+ return filepath.Join(localAppData, appName, "skills")
766+ }
767+
768+ return filepath.Join(home.Dir(), ".config", appName, "skills")
769+}
internal/db/db.go+54−44
@@ -84,6 +84,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) {
8484 if q.updateSessionStmt, err = db.PrepareContext(ctx, updateSession); err != nil {
8585 return nil, fmt.Errorf("error preparing query UpdateSession: %w", err)
8686 }
87+ if q.updateSessionTitleAndUsageStmt, err = db.PrepareContext(ctx, updateSessionTitleAndUsage); err != nil {
88+ return nil, fmt.Errorf("error preparing query UpdateSessionTitleAndUsage: %w", err)
89+ }
8790 return &q, nil
8891}
8992
@@ -189,6 +192,11 @@ func (q *Queries) Close() error {
189192 err = fmt.Errorf("error closing updateSessionStmt: %w", cerr)
190193 }
191194 }
195+ if q.updateSessionTitleAndUsageStmt != nil {
196+ if cerr := q.updateSessionTitleAndUsageStmt.Close(); cerr != nil {
197+ err = fmt.Errorf("error closing updateSessionTitleAndUsageStmt: %w", cerr)
198+ }
199+ }
192200 return err
193201}
194202
@@ -226,53 +234,55 @@ func (q *Queries) queryRow(ctx context.Context, stmt *sql.Stmt, query string, ar
226234}
227235
228236type Queries struct {
229− db DBTX
230− tx *sql.Tx
231− createFileStmt *sql.Stmt
232− createMessageStmt *sql.Stmt
233− createSessionStmt *sql.Stmt
234− deleteFileStmt *sql.Stmt
235− deleteMessageStmt *sql.Stmt
236− deleteSessionStmt *sql.Stmt
237− deleteSessionFilesStmt *sql.Stmt
238− deleteSessionMessagesStmt *sql.Stmt
239− getFileStmt *sql.Stmt
240− getFileByPathAndSessionStmt *sql.Stmt
241− getMessageStmt *sql.Stmt
242− getSessionByIDStmt *sql.Stmt
243− listFilesByPathStmt *sql.Stmt
244− listFilesBySessionStmt *sql.Stmt
245− listLatestSessionFilesStmt *sql.Stmt
246− listMessagesBySessionStmt *sql.Stmt
247− listNewFilesStmt *sql.Stmt
248− listSessionsStmt *sql.Stmt
249− updateMessageStmt *sql.Stmt
250− updateSessionStmt *sql.Stmt
237+ db DBTX
238+ tx *sql.Tx
239+ createFileStmt *sql.Stmt
240+ createMessageStmt *sql.Stmt
241+ createSessionStmt *sql.Stmt
242+ deleteFileStmt *sql.Stmt
243+ deleteMessageStmt *sql.Stmt
244+ deleteSessionStmt *sql.Stmt
245+ deleteSessionFilesStmt *sql.Stmt
246+ deleteSessionMessagesStmt *sql.Stmt
247+ getFileStmt *sql.Stmt
248+ getFileByPathAndSessionStmt *sql.Stmt
249+ getMessageStmt *sql.Stmt
250+ getSessionByIDStmt *sql.Stmt
251+ listFilesByPathStmt *sql.Stmt
252+ listFilesBySessionStmt *sql.Stmt
253+ listLatestSessionFilesStmt *sql.Stmt
254+ listMessagesBySessionStmt *sql.Stmt
255+ listNewFilesStmt *sql.Stmt
256+ listSessionsStmt *sql.Stmt
257+ updateMessageStmt *sql.Stmt
258+ updateSessionStmt *sql.Stmt
259+ updateSessionTitleAndUsageStmt *sql.Stmt
251260}
252261
253262func (q *Queries) WithTx(tx *sql.Tx) *Queries {
254263 return &Queries{
255− db: tx,
256− tx: tx,
257− createFileStmt: q.createFileStmt,
258− createMessageStmt: q.createMessageStmt,
259− createSessionStmt: q.createSessionStmt,
260− deleteFileStmt: q.deleteFileStmt,
261− deleteMessageStmt: q.deleteMessageStmt,
262− deleteSessionStmt: q.deleteSessionStmt,
263− deleteSessionFilesStmt: q.deleteSessionFilesStmt,
264− deleteSessionMessagesStmt: q.deleteSessionMessagesStmt,
265− getFileStmt: q.getFileStmt,
266− getFileByPathAndSessionStmt: q.getFileByPathAndSessionStmt,
267− getMessageStmt: q.getMessageStmt,
268− getSessionByIDStmt: q.getSessionByIDStmt,
269− listFilesByPathStmt: q.listFilesByPathStmt,
270− listFilesBySessionStmt: q.listFilesBySessionStmt,
271− listLatestSessionFilesStmt: q.listLatestSessionFilesStmt,
272− listMessagesBySessionStmt: q.listMessagesBySessionStmt,
273− listNewFilesStmt: q.listNewFilesStmt,
274− listSessionsStmt: q.listSessionsStmt,
275− updateMessageStmt: q.updateMessageStmt,
276− updateSessionStmt: q.updateSessionStmt,
264+ db: tx,
265+ tx: tx,
266+ createFileStmt: q.createFileStmt,
267+ createMessageStmt: q.createMessageStmt,
268+ createSessionStmt: q.createSessionStmt,
269+ deleteFileStmt: q.deleteFileStmt,
270+ deleteMessageStmt: q.deleteMessageStmt,
271+ deleteSessionStmt: q.deleteSessionStmt,
272+ deleteSessionFilesStmt: q.deleteSessionFilesStmt,
273+ deleteSessionMessagesStmt: q.deleteSessionMessagesStmt,
274+ getFileStmt: q.getFileStmt,
275+ getFileByPathAndSessionStmt: q.getFileByPathAndSessionStmt,
276+ getMessageStmt: q.getMessageStmt,
277+ getSessionByIDStmt: q.getSessionByIDStmt,
278+ listFilesByPathStmt: q.listFilesByPathStmt,
279+ listFilesBySessionStmt: q.listFilesBySessionStmt,
280+ listLatestSessionFilesStmt: q.listLatestSessionFilesStmt,
281+ listMessagesBySessionStmt: q.listMessagesBySessionStmt,
282+ listNewFilesStmt: q.listNewFilesStmt,
283+ listSessionsStmt: q.listSessionsStmt,
284+ updateMessageStmt: q.updateMessageStmt,
285+ updateSessionStmt: q.updateSessionStmt,
286+ updateSessionTitleAndUsageStmt: q.updateSessionTitleAndUsageStmt,
277287 }
278288}
internal/db/querier.go+1
@@ -29,6 +29,7 @@ type Querier interface {
2929 ListSessions(ctx context.Context) ([]Session, error)
3030 UpdateMessage(ctx context.Context, arg UpdateMessageParams) error
3131 UpdateSession(ctx context.Context, arg UpdateSessionParams) (Session, error)
32+ UpdateSessionTitleAndUsage(ctx context.Context, arg UpdateSessionTitleAndUsageParams) error
3233}
3334
3435var _ Querier = (*Queries)(nil)
internal/db/sessions.sql.go+29
@@ -199,3 +199,32 @@ func (q *Queries) UpdateSession(ctx context.Context, arg UpdateSessionParams) (S
199199 )
200200 return i, err
201201}
202+
203+const updateSessionTitleAndUsage = `-- name: UpdateSessionTitleAndUsage :exec
204+UPDATE sessions
205+SET
206+ title = ?,
207+ prompt_tokens = prompt_tokens + ?,
208+ completion_tokens = completion_tokens + ?,
209+ cost = cost + ?
210+WHERE id = ?
211+`
212+
213+type UpdateSessionTitleAndUsageParams struct {
214+ Title string `json:"title"`
215+ PromptTokens int64 `json:"prompt_tokens"`
216+ CompletionTokens int64 `json:"completion_tokens"`
217+ Cost float64 `json:"cost"`
218+ ID string `json:"id"`
219+}
220+
221+func (q *Queries) UpdateSessionTitleAndUsage(ctx context.Context, arg UpdateSessionTitleAndUsageParams) error {
222+ _, err := q.exec(ctx, q.updateSessionTitleAndUsageStmt, updateSessionTitleAndUsage,
223+ arg.Title,
224+ arg.PromptTokens,
225+ arg.CompletionTokens,
226+ arg.Cost,
227+ arg.ID,
228+ )
229+ return err
230+}
internal/db/sql/sessions.sql+9
@@ -46,6 +46,15 @@ SET
4646WHERE id = ?
4747RETURNING *;
4848
49+-- name: UpdateSessionTitleAndUsage :exec
50+UPDATE sessions
51+SET
52+ title = ?,
53+ prompt_tokens = prompt_tokens + ?,
54+ completion_tokens = completion_tokens + ?,
55+ cost = cost + ?
56+WHERE id = ?;
57+
4958
5059-- name: DeleteSession :exec
5160DELETE FROM sessions
internal/message/attachment.go+5
@@ -1,8 +1,13 @@
11package message
22
3+import "strings"
4+
35type Attachment struct {
46 FilePath string
57 FileName string
68 MimeType string
79 Content []byte
810}
11+
12+func (a Attachment) IsText() bool { return strings.HasPrefix(a.MimeType, "text/") }
13+func (a Attachment) IsImage() bool { return strings.HasPrefix(a.MimeType, "image/") }
internal/message/content.go+46
@@ -3,6 +3,7 @@ package message
33import (
44 "encoding/base64"
55 "errors"
6+ "fmt"
67 "slices"
78 "strings"
89 "time"
@@ -407,6 +408,15 @@ func (m *Message) SetToolResults(tr []ToolResult) {
407408 }
408409}
409410
411+// Clone returns a deep copy of the message with an independent Parts slice.
412+// This prevents race conditions when the message is modified concurrently.
413+func (m *Message) Clone() Message {
414+ clone := *m
415+ clone.Parts = make([]ContentPart, len(m.Parts))
416+ copy(clone.Parts, m.Parts)
417+ return clone
418+}
419+
410420func (m *Message) AddFinish(reason FinishReason, message, details string) {
411421 // remove any existing finish part
412422 for i, part := range m.Parts {
@@ -426,16 +436,52 @@ func (m *Message) AddBinary(mimeType string, data []byte) {
426436 m.Parts = append(m.Parts, BinaryContent{MIMEType: mimeType, Data: data})
427437}
428438
439+func PromptWithTextAttachments(prompt string, attachments []Attachment) string {
440+ addedAttachments := false
441+ for _, content := range attachments {
442+ if !content.IsText() {
443+ continue
444+ }
445+ if !addedAttachments {
446+ prompt += "\n<system_info>The files below have been attached by the user, consider them in your response</system_info>\n"
447+ addedAttachments = true
448+ }
449+ tag := `<file>\n`
450+ if content.FilePath != "" {
451+ tag = fmt.Sprintf("<file path='%s'>\n", content.FilePath)
452+ }
453+ prompt += tag
454+ prompt += "\n" + string(content.Content) + "\n</file>\n"
455+ }
456+ return prompt
457+}
458+
429459func (m *Message) ToAIMessage() []fantasy.Message {
430460 var messages []fantasy.Message
431461 switch m.Role {
432462 case User:
433463 var parts []fantasy.MessagePart
434464 text := strings.TrimSpace(m.Content().Text)
465+ var textAttachments []Attachment
466+ for _, content := range m.BinaryContent() {
467+ if !strings.HasPrefix(content.MIMEType, "text/") {
468+ continue
469+ }
470+ textAttachments = append(textAttachments, Attachment{
471+ FilePath: content.Path,
472+ MimeType: content.MIMEType,
473+ Content: content.Data,
474+ })
475+ }
476+ text = PromptWithTextAttachments(text, textAttachments)
435477 if text != "" {
436478 parts = append(parts, fantasy.TextPart{Text: text})
437479 }
438480 for _, content := range m.BinaryContent() {
481+ // skip text attachements
482+ if strings.HasPrefix(content.MIMEType, "text/") {
483+ continue
484+ }
439485 parts = append(parts, fantasy.FilePart{
440486 Filename: content.Path,
441487 Data: content.Data,
internal/message/message.go+9−3
@@ -51,7 +51,9 @@ func (s *service) Delete(ctx context.Context, id string) error {
5151 if err != nil {
5252 return err
5353 }
54− s.Publish(pubsub.DeletedEvent, message)
54+ // Clone the message before publishing to avoid race conditions with
55+ // concurrent modifications to the Parts slice.
56+ s.Publish(pubsub.DeletedEvent, message.Clone())
5557 return nil
5658}
5759
@@ -85,7 +87,9 @@ func (s *service) Create(ctx context.Context, sessionID string, params CreateMes
8587 if err != nil {
8688 return Message{}, err
8789 }
88− s.Publish(pubsub.CreatedEvent, message)
90+ // Clone the message before publishing to avoid race conditions with
91+ // concurrent modifications to the Parts slice.
92+ s.Publish(pubsub.CreatedEvent, message.Clone())
8993 return message, nil
9094}
9195
@@ -124,7 +128,9 @@ func (s *service) Update(ctx context.Context, message Message) error {
124128 return err
125129 }
126130 message.UpdatedAt = time.Now().Unix()
127− s.Publish(pubsub.UpdatedEvent, message)
131+ // Clone the message before publishing to avoid race conditions with
132+ // concurrent modifications to the Parts slice.
133+ s.Publish(pubsub.UpdatedEvent, message.Clone())
128134 return nil
129135}
130136
internal/pubsub/broker.go+3−8
@@ -92,22 +92,17 @@ func (b *Broker[T]) GetSubscriberCount() int {
9292
9393func (b *Broker[T]) Publish(t EventType, payload T) {
9494 b.mu.RLock()
95+ defer b.mu.RUnlock()
96+
9597 select {
9698 case <-b.done:
97− b.mu.RUnlock()
9899 return
99100 default:
100101 }
101102
102− subscribers := make([]chan Event[T], 0, len(b.subs))
103− for sub := range b.subs {
104− subscribers = append(subscribers, sub)
105− }
106− b.mu.RUnlock()
107−
108103 event := Event[T]{Type: t, Payload: payload}
109104
110− for _, sub := range subscribers {
105+ for sub := range b.subs {
111106 select {
112107 case sub <- event:
113108 default:
internal/session/session.go+13
@@ -50,6 +50,7 @@ type Service interface {
5050 Get(ctx context.Context, id string) (Session, error)
5151 List(ctx context.Context) ([]Session, error)
5252 Save(ctx context.Context, session Session) (Session, error)
53+ UpdateTitleAndUsage(ctx context.Context, sessionID, title string, promptTokens, completionTokens int64, cost float64) error
5354 Delete(ctx context.Context, id string) error
5455
5556 // Agent tool session management
@@ -156,6 +157,18 @@ func (s *service) Save(ctx context.Context, session Session) (Session, error) {
156157 return session, nil
157158}
158159
160+// UpdateTitleAndUsage updates only the title and usage fields atomically.
161+// This is safer than fetching, modifying, and saving the entire session.
162+func (s *service) UpdateTitleAndUsage(ctx context.Context, sessionID, title string, promptTokens, completionTokens int64, cost float64) error {
163+ return s.q.UpdateSessionTitleAndUsage(ctx, db.UpdateSessionTitleAndUsageParams{
164+ ID: sessionID,
165+ Title: title,
166+ PromptTokens: promptTokens,
167+ CompletionTokens: completionTokens,
168+ Cost: cost,
169+ })
170+}
171+
159172func (s *service) List(ctx context.Context) ([]Session, error) {
160173 dbSessions, err := s.q.ListSessions(ctx)
161174 if err != nil {
internal/skills/skills.gonew file+164
@@ -0,0 +1,164 @@
1+// Package skills implements the Agent Skills open standard.
2+// See https://agentskills.io for the specification.
3+package skills
4+
5+import (
6+ "errors"
7+ "fmt"
8+ "log/slog"
9+ "os"
10+ "path/filepath"
11+ "regexp"
12+ "strings"
13+
14+ "gopkg.in/yaml.v3"
15+)
16+
17+const (
18+ SkillFileName = "SKILL.md"
19+ MaxNameLength = 64
20+ MaxDescriptionLength = 1024
21+ MaxCompatibilityLength = 500
22+)
23+
24+var namePattern = regexp.MustCompile(`^[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*$`)
25+
26+// Skill represents a parsed SKILL.md file.
27+type Skill struct {
28+ Name string `yaml:"name" json:"name"`
29+ Description string `yaml:"description" json:"description"`
30+ License string `yaml:"license,omitempty" json:"license,omitempty"`
31+ Compatibility string `yaml:"compatibility,omitempty" json:"compatibility,omitempty"`
32+ Metadata map[string]string `yaml:"metadata,omitempty" json:"metadata,omitempty"`
33+ Instructions string `yaml:"-" json:"instructions"`
34+ Path string `yaml:"-" json:"path"`
35+ SkillFilePath string `yaml:"-" json:"skill_file_path"`
36+}
37+
38+// Validate checks if the skill meets spec requirements.
39+func (s *Skill) Validate() error {
40+ var errs []error
41+
42+ if s.Name == "" {
43+ errs = append(errs, errors.New("name is required"))
44+ } else {
45+ if len(s.Name) > MaxNameLength {
46+ errs = append(errs, fmt.Errorf("name exceeds %d characters", MaxNameLength))
47+ }
48+ if !namePattern.MatchString(s.Name) {
49+ errs = append(errs, errors.New("name must be alphanumeric with hyphens, no leading/trailing/consecutive hyphens"))
50+ }
51+ if s.Path != "" && !strings.EqualFold(filepath.Base(s.Path), s.Name) {
52+ errs = append(errs, fmt.Errorf("name %q must match directory %q", s.Name, filepath.Base(s.Path)))
53+ }
54+ }
55+
56+ if s.Description == "" {
57+ errs = append(errs, errors.New("description is required"))
58+ } else if len(s.Description) > MaxDescriptionLength {
59+ errs = append(errs, fmt.Errorf("description exceeds %d characters", MaxDescriptionLength))
60+ }
61+
62+ if len(s.Compatibility) > MaxCompatibilityLength {
63+ errs = append(errs, fmt.Errorf("compatibility exceeds %d characters", MaxCompatibilityLength))
64+ }
65+
66+ return errors.Join(errs...)
67+}
68+
69+// Parse parses a SKILL.md file.
70+func Parse(path string) (*Skill, error) {
71+ content, err := os.ReadFile(path)
72+ if err != nil {
73+ return nil, err
74+ }
75+
76+ frontmatter, body, err := splitFrontmatter(string(content))
77+ if err != nil {
78+ return nil, err
79+ }
80+
81+ var skill Skill
82+ if err := yaml.Unmarshal([]byte(frontmatter), &skill); err != nil {
83+ return nil, fmt.Errorf("parsing frontmatter: %w", err)
84+ }
85+
86+ skill.Instructions = strings.TrimSpace(body)
87+ skill.Path = filepath.Dir(path)
88+ skill.SkillFilePath = path
89+
90+ return &skill, nil
91+}
92+
93+// splitFrontmatter extracts YAML frontmatter and body from markdown content.
94+func splitFrontmatter(content string) (frontmatter, body string, err error) {
95+ // Normalize line endings to \n for consistent parsing.
96+ content = strings.ReplaceAll(content, "\r\n", "\n")
97+ if !strings.HasPrefix(content, "---\n") {
98+ return "", "", errors.New("no YAML frontmatter found")
99+ }
100+
101+ rest := strings.TrimPrefix(content, "---\n")
102+ before, after, ok := strings.Cut(rest, "\n---")
103+ if !ok {
104+ return "", "", errors.New("unclosed frontmatter")
105+ }
106+
107+ return before, after, nil
108+}
109+
110+// Discover finds all valid skills in the given paths.
111+func Discover(paths []string) []*Skill {
112+ var skills []*Skill
113+ seen := make(map[string]bool)
114+
115+ for _, base := range paths {
116+ filepath.WalkDir(base, func(path string, d os.DirEntry, err error) error {
117+ if err != nil {
118+ return nil
119+ }
120+ if d.IsDir() || d.Name() != SkillFileName || seen[path] {
121+ return nil
122+ }
123+ seen[path] = true
124+ skill, err := Parse(path)
125+ if err != nil {
126+ slog.Warn("Failed to parse skill file", "path", path, "error", err)
127+ return nil
128+ }
129+ if err := skill.Validate(); err != nil {
130+ slog.Warn("Skill validation failed", "path", path, "error", err)
131+ return nil
132+ }
133+ slog.Info("Successfully loaded skill", "name", skill.Name, "path", path)
134+ skills = append(skills, skill)
135+ return nil
136+ })
137+ }
138+
139+ return skills
140+}
141+
142+// ToPromptXML generates XML for injection into the system prompt.
143+func ToPromptXML(skills []*Skill) string {
144+ if len(skills) == 0 {
145+ return ""
146+ }
147+
148+ var sb strings.Builder
149+ sb.WriteString("<available_skills>\n")
150+ for _, s := range skills {
151+ sb.WriteString(" <skill>\n")
152+ fmt.Fprintf(&sb, " <name>%s</name>\n", escape(s.Name))
153+ fmt.Fprintf(&sb, " <description>%s</description>\n", escape(s.Description))
154+ fmt.Fprintf(&sb, " <location>%s</location>\n", escape(s.SkillFilePath))
155+ sb.WriteString(" </skill>\n")
156+ }
157+ sb.WriteString("</available_skills>")
158+ return sb.String()
159+}
160+
161+func escape(s string) string {
162+ r := strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;", "\"", "&quot;", "'", "&apos;")
163+ return r.Replace(s)
164+}
internal/skills/skills_test.gonew file+249
@@ -0,0 +1,249 @@
1+package skills
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "strings"
7+ "testing"
8+
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func TestParse(t *testing.T) {
13+ t.Parallel()
14+
15+ tests := []struct {
16+ name string
17+ content string
18+ wantName string
19+ wantDesc string
20+ wantLicense string
21+ wantCompat string
22+ wantMeta map[string]string
23+ wantTools string
24+ wantInstr string
25+ wantErr bool
26+ }{
27+ {
28+ name: "full skill",
29+ content: `---
30+name: pdf-processing
31+description: Extracts text and tables from PDF files, fills PDF forms, and merges multiple PDFs.
32+license: Apache-2.0
33+compatibility: Requires python 3.8+, pdfplumber, pdfrw libraries
34+metadata:
35+ author: example-org
36+ version: "1.0"
37+---
38+
39+# PDF Processing
40+
41+## When to use this skill
42+Use this skill when the user needs to work with PDF files.
43+`,
44+ wantName: "pdf-processing",
45+ wantDesc: "Extracts text and tables from PDF files, fills PDF forms, and merges multiple PDFs.",
46+ wantLicense: "Apache-2.0",
47+ wantCompat: "Requires python 3.8+, pdfplumber, pdfrw libraries",
48+ wantMeta: map[string]string{"author": "example-org", "version": "1.0"},
49+ wantInstr: "# PDF Processing\n\n## When to use this skill\nUse this skill when the user needs to work with PDF files.",
50+ },
51+ {
52+ name: "minimal skill",
53+ content: `---
54+name: my-skill
55+description: A simple skill for testing.
56+---
57+
58+# My Skill
59+
60+Instructions here.
61+`,
62+ wantName: "my-skill",
63+ wantDesc: "A simple skill for testing.",
64+ wantInstr: "# My Skill\n\nInstructions here.",
65+ },
66+ {
67+ name: "no frontmatter",
68+ content: "# Just Markdown\n\nNo frontmatter here.",
69+ wantErr: true,
70+ },
71+ }
72+
73+ for _, tt := range tests {
74+ t.Run(tt.name, func(t *testing.T) {
75+ t.Parallel()
76+
77+ // Write content to temp file.
78+ dir := t.TempDir()
79+ path := filepath.Join(dir, "SKILL.md")
80+ require.NoError(t, os.WriteFile(path, []byte(tt.content), 0o644))
81+
82+ skill, err := Parse(path)
83+ if tt.wantErr {
84+ require.Error(t, err)
85+ return
86+ }
87+ require.NoError(t, err)
88+
89+ require.Equal(t, tt.wantName, skill.Name)
90+ require.Equal(t, tt.wantDesc, skill.Description)
91+ require.Equal(t, tt.wantLicense, skill.License)
92+ require.Equal(t, tt.wantCompat, skill.Compatibility)
93+
94+ if tt.wantMeta != nil {
95+ require.Equal(t, tt.wantMeta, skill.Metadata)
96+ }
97+
98+ require.Equal(t, tt.wantInstr, skill.Instructions)
99+ })
100+ }
101+}
102+
103+func TestSkillValidate(t *testing.T) {
104+ t.Parallel()
105+
106+ tests := []struct {
107+ name string
108+ skill Skill
109+ wantErr bool
110+ errMsg string
111+ }{
112+ {
113+ name: "valid skill",
114+ skill: Skill{
115+ Name: "pdf-processing",
116+ Description: "Processes PDF files.",
117+ Path: "/skills/pdf-processing",
118+ },
119+ },
120+ {
121+ name: "missing name",
122+ skill: Skill{Description: "Some description."},
123+ wantErr: true,
124+ errMsg: "name is required",
125+ },
126+ {
127+ name: "missing description",
128+ skill: Skill{Name: "my-skill", Path: "/skills/my-skill"},
129+ wantErr: true,
130+ errMsg: "description is required",
131+ },
132+ {
133+ name: "name too long",
134+ skill: Skill{Name: strings.Repeat("a", 65), Description: "Some description."},
135+ wantErr: true,
136+ errMsg: "exceeds",
137+ },
138+ {
139+ name: "valid name - mixed case",
140+ skill: Skill{Name: "MySkill", Description: "Some description.", Path: "/skills/MySkill"},
141+ wantErr: false,
142+ },
143+ {
144+ name: "invalid name - starts with hyphen",
145+ skill: Skill{Name: "-my-skill", Description: "Some description."},
146+ wantErr: true,
147+ errMsg: "alphanumeric with hyphens",
148+ },
149+ {
150+ name: "name doesn't match directory",
151+ skill: Skill{Name: "my-skill", Description: "Some description.", Path: "/skills/other-skill"},
152+ wantErr: true,
153+ errMsg: "must match directory",
154+ },
155+ {
156+ name: "description too long",
157+ skill: Skill{Name: "my-skill", Description: strings.Repeat("a", 1025), Path: "/skills/my-skill"},
158+ wantErr: true,
159+ errMsg: "description exceeds",
160+ },
161+ {
162+ name: "compatibility too long",
163+ skill: Skill{Name: "my-skill", Description: "desc", Compatibility: strings.Repeat("a", 501), Path: "/skills/my-skill"},
164+ wantErr: true,
165+ errMsg: "compatibility exceeds",
166+ },
167+ }
168+
169+ for _, tt := range tests {
170+ t.Run(tt.name, func(t *testing.T) {
171+ t.Parallel()
172+
173+ err := tt.skill.Validate()
174+ if tt.wantErr {
175+ require.Error(t, err)
176+ require.Contains(t, err.Error(), tt.errMsg)
177+ } else {
178+ require.NoError(t, err)
179+ }
180+ })
181+ }
182+}
183+
184+func TestDiscover(t *testing.T) {
185+ t.Parallel()
186+
187+ tmpDir := t.TempDir()
188+
189+ // Create valid skill 1.
190+ skill1Dir := filepath.Join(tmpDir, "skill-one")
191+ require.NoError(t, os.MkdirAll(skill1Dir, 0o755))
192+ require.NoError(t, os.WriteFile(filepath.Join(skill1Dir, "SKILL.md"), []byte(`---
193+name: skill-one
194+description: First test skill.
195+---
196+# Skill One
197+`), 0o644))
198+
199+ // Create valid skill 2 in nested directory.
200+ skill2Dir := filepath.Join(tmpDir, "nested", "skill-two")
201+ require.NoError(t, os.MkdirAll(skill2Dir, 0o755))
202+ require.NoError(t, os.WriteFile(filepath.Join(skill2Dir, "SKILL.md"), []byte(`---
203+name: skill-two
204+description: Second test skill.
205+---
206+# Skill Two
207+`), 0o644))
208+
209+ // Create invalid skill (won't be included).
210+ invalidDir := filepath.Join(tmpDir, "invalid-dir")
211+ require.NoError(t, os.MkdirAll(invalidDir, 0o755))
212+ require.NoError(t, os.WriteFile(filepath.Join(invalidDir, "SKILL.md"), []byte(`---
213+name: wrong-name
214+description: Name doesn't match directory.
215+---
216+`), 0o644))
217+
218+ skills := Discover([]string{tmpDir})
219+ require.Len(t, skills, 2)
220+
221+ names := make(map[string]bool)
222+ for _, s := range skills {
223+ names[s.Name] = true
224+ }
225+ require.True(t, names["skill-one"])
226+ require.True(t, names["skill-two"])
227+}
228+
229+func TestToPromptXML(t *testing.T) {
230+ t.Parallel()
231+
232+ skills := []*Skill{
233+ {Name: "pdf-processing", Description: "Extracts text from PDFs.", SkillFilePath: "/skills/pdf-processing/SKILL.md"},
234+ {Name: "data-analysis", Description: "Analyzes datasets & charts.", SkillFilePath: "/skills/data-analysis/SKILL.md"},
235+ }
236+
237+ xml := ToPromptXML(skills)
238+
239+ require.Contains(t, xml, "<available_skills>")
240+ require.Contains(t, xml, "<name>pdf-processing</name>")
241+ require.Contains(t, xml, "<description>Extracts text from PDFs.</description>")
242+ require.Contains(t, xml, "&amp;") // XML escaping
243+}
244+
245+func TestToPromptXMLEmpty(t *testing.T) {
246+ t.Parallel()
247+ require.Empty(t, ToPromptXML(nil))
248+ require.Empty(t, ToPromptXML([]*Skill{}))
249+}
internal/tui/components/chat/editor/editor.go+117−52
@@ -2,6 +2,7 @@ package editor
22
33import (
44 "context"
5+ "errors"
56 "fmt"
67 "math/rand"
78 "net/http"
@@ -29,6 +30,7 @@ import (
2930 "github.com/charmbracelet/crush/internal/tui/components/dialogs/quit"
3031 "github.com/charmbracelet/crush/internal/tui/styles"
3132 "github.com/charmbracelet/crush/internal/tui/util"
33+ "github.com/charmbracelet/x/ansi"
3234)
3335
3436type Editor interface {
@@ -84,10 +86,7 @@ var DeleteKeyMaps = DeleteAttachmentKeyMaps{
8486 ),
8587}
8688
87−const (
88− maxAttachments = 5
89− maxFileResults = 25
90−)
89+const maxFileResults = 25
9190
9291type OpenEditorMsg struct {
9392 Text string
@@ -145,14 +144,14 @@ func (m *editorCmp) send() tea.Cmd {
145144 return util.CmdHandler(dialogs.OpenDialogMsg{Model: quit.NewQuitDialog()})
146145 }
147146
148− m.textarea.Reset()
149147 attachments := m.attachments
150148
151− m.attachments = nil
152149 if value == "" {
153150 return nil
154151 }
155152
153+ m.textarea.Reset()
154+ m.attachments = nil
156155 // Change the placeholder when sending a new message.
157156 m.randomizePlaceholders()
158157
@@ -176,9 +175,6 @@ func (m *editorCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
176175 case tea.WindowSizeMsg:
177176 return m, m.repositionCompletions
178177 case filepicker.FilePickedMsg:
179− if len(m.attachments) >= maxAttachments {
180− return m, util.ReportError(fmt.Errorf("cannot add more than %d images", maxAttachments))
181− }
182178 m.attachments = append(m.attachments, msg.Attachment)
183179 return m, nil
184180 case completions.CompletionsOpenedMsg:
@@ -206,6 +202,17 @@ func (m *editorCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
206202 m.currentQuery = ""
207203 m.completionsStartIndex = 0
208204 }
205+ content, err := os.ReadFile(item.Path)
206+ if err != nil {
207+ // if it fails, let the LLM handle it later.
208+ return m, nil
209+ }
210+ m.attachments = append(m.attachments, message.Attachment{
211+ FilePath: item.Path,
212+ FileName: filepath.Base(item.Path),
213+ MimeType: mimeOf(content),
214+ Content: content,
215+ })
209216 }
210217
211218 case commands.OpenExternalEditorMsg:
@@ -217,39 +224,29 @@ func (m *editorCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
217224 m.textarea.SetValue(msg.Text)
218225 m.textarea.MoveToEnd()
219226 case tea.PasteMsg:
220− path := strings.ReplaceAll(msg.Content, "\\ ", " ")
221− // try to get an image
222− path, err := filepath.Abs(strings.TrimSpace(path))
223− if err != nil {
227+ content, path, err := pasteToFile(msg)
228+ if errors.Is(err, errNotAFile) {
224229 m.textarea, cmd = m.textarea.Update(msg)
225230 return m, cmd
226231 }
227− isAllowedType := false
228− for _, ext := range filepicker.AllowedTypes {
229− if strings.HasSuffix(path, ext) {
230− isAllowedType = true
231− break
232− }
233− }
234− if !isAllowedType {
235− m.textarea, cmd = m.textarea.Update(msg)
236− return m, cmd
232+ if err != nil {
233+ return m, util.ReportError(err)
237234 }
238− tooBig, _ := filepicker.IsFileTooBig(path, filepicker.MaxAttachmentSize)
239− if tooBig {
240− m.textarea, cmd = m.textarea.Update(msg)
241− return m, cmd
235+
236+ if len(content) > maxAttachmentSize {
237+ return m, util.ReportWarn("File is too big (>5mb)")
242238 }
243239
244− content, err := os.ReadFile(path)
245− if err != nil {
246− m.textarea, cmd = m.textarea.Update(msg)
247− return m, cmd
240+ mimeType := mimeOf(content)
241+ attachment := message.Attachment{
242+ FilePath: path,
243+ FileName: filepath.Base(path),
244+ MimeType: mimeType,
245+ Content: content,
246+ }
247+ if !attachment.IsText() && !attachment.IsImage() {
248+ return m, util.ReportWarn("Invalid file content type: " + mimeType)
248249 }
249− mimeBufferSize := min(512, len(content))
250− mimeType := http.DetectContentType(content[:mimeBufferSize])
251− fileName := filepath.Base(path)
252− attachment := message.Attachment{FilePath: path, FileName: fileName, MimeType: mimeType, Content: content}
253250 return m, util.CmdHandler(filepicker.FilePickedMsg{
254251 Attachment: attachment,
255252 })
@@ -427,18 +424,17 @@ func (m *editorCmp) View() string {
427424 m.textarea.Placeholder = "Yolo mode!"
428425 }
429426 if len(m.attachments) == 0 {
430− content := t.S().Base.Padding(1).Render(
427+ return t.S().Base.Padding(1).Render(
431428 m.textarea.View(),
432429 )
433− return content
434430 }
435− content := t.S().Base.Padding(0, 1, 1, 1).Render(
436− lipgloss.JoinVertical(lipgloss.Top,
431+ return t.S().Base.Padding(0, 1, 1, 1).Render(
432+ lipgloss.JoinVertical(
433+ lipgloss.Top,
437434 m.attachmentsContent(),
438435 m.textarea.View(),
439436 ),
440437 )
441− return content
442438}
443439
444440func (m *editorCmp) SetSize(width, height int) tea.Cmd {
@@ -456,24 +452,45 @@ func (m *editorCmp) GetSize() (int, int) {
456452func (m *editorCmp) attachmentsContent() string {
457453 var styledAttachments []string
458454 t := styles.CurrentTheme()
459− attachmentStyles := t.S().Base.
460− MarginLeft(1).
455+ attachmentStyle := t.S().Base.
456+ Padding(0, 1).
457+ MarginRight(1).
461458 Background(t.FgMuted).
462− Foreground(t.FgBase)
459+ Foreground(t.FgBase).
460+ Render
461+ iconStyle := t.S().Base.
462+ Foreground(t.BgSubtle).
463+ Background(t.Green).
464+ Padding(0, 1).
465+ Bold(true).
466+ Render
467+ rmStyle := t.S().Base.
468+ Padding(0, 1).
469+ Bold(true).
470+ Background(t.Red).
471+ Foreground(t.FgBase).
472+ Render
463473 for i, attachment := range m.attachments {
464− var filename string
465− if len(attachment.FileName) > 10 {
466− filename = fmt.Sprintf(" %s %s...", styles.DocumentIcon, attachment.FileName[0:7])
467− } else {
468− filename = fmt.Sprintf(" %s %s", styles.DocumentIcon, attachment.FileName)
474+ filename := ansi.Truncate(filepath.Base(attachment.FileName), 10, "...")
475+ icon := styles.ImageIcon
476+ if attachment.IsText() {
477+ icon = styles.TextIcon
469478 }
470479 if m.deleteMode {
471− filename = fmt.Sprintf("%d%s", i, filename)
480+ styledAttachments = append(
481+ styledAttachments,
482+ rmStyle(fmt.Sprintf("%d", i)),
483+ attachmentStyle(filename),
484+ )
485+ continue
472486 }
473− styledAttachments = append(styledAttachments, attachmentStyles.Render(filename))
487+ styledAttachments = append(
488+ styledAttachments,
489+ iconStyle(icon),
490+ attachmentStyle(filename),
491+ )
474492 }
475− content := lipgloss.JoinHorizontal(lipgloss.Left, styledAttachments...)
476− return content
493+ return lipgloss.JoinHorizontal(lipgloss.Left, styledAttachments...)
477494}
478495
479496func (m *editorCmp) SetPosition(x, y int) tea.Cmd {
@@ -597,3 +614,51 @@ func New(app *app.App) Editor {
597614
598615 return e
599616}
617+
618+var maxAttachmentSize = 5 * 1024 * 1024 // 5MB
619+
620+var errNotAFile = errors.New("not a file")
621+
622+func pasteToFile(msg tea.PasteMsg) ([]byte, string, error) {
623+ content, path, err := filepathToFile(msg.Content)
624+ if err == nil {
625+ return content, path, err
626+ }
627+
628+ if strings.Count(msg.Content, "\n") > 2 {
629+ return contentToFile([]byte(msg.Content))
630+ }
631+
632+ return nil, "", errNotAFile
633+}
634+
635+func contentToFile(content []byte) ([]byte, string, error) {
636+ f, err := os.CreateTemp("", "paste_*.txt")
637+ if err != nil {
638+ return nil, "", err
639+ }
640+ if _, err := f.Write(content); err != nil {
641+ return nil, "", err
642+ }
643+ if err := f.Close(); err != nil {
644+ return nil, "", err
645+ }
646+ return content, f.Name(), nil
647+}
648+
649+func filepathToFile(name string) ([]byte, string, error) {
650+ path, err := filepath.Abs(strings.TrimSpace(strings.ReplaceAll(name, "\\", "")))
651+ if err != nil {
652+ return nil, "", err
653+ }
654+ content, err := os.ReadFile(path)
655+ if err != nil {
656+ return nil, "", err
657+ }
658+ return content, path, nil
659+}
660+
661+func mimeOf(content []byte) string {
662+ mimeBufferSize := min(512, len(content))
663+ return http.DetectContentType(content[:mimeBufferSize])
664+}
internal/tui/components/chat/messages/messages.go+22−9
@@ -227,19 +227,32 @@ func (m *messageCmp) renderUserMessage() string {
227227 m.toMarkdown(m.message.Content().String()),
228228 }
229229
230− attachmentStyles := t.S().Text.
231− MarginLeft(1).
232− Background(t.BgSubtle)
230+ attachmentStyle := t.S().Base.
231+ Padding(0, 1).
232+ MarginRight(1).
233+ Background(t.FgMuted).
234+ Foreground(t.FgBase).
235+ Render
236+ iconStyle := t.S().Base.
237+ Foreground(t.BgSubtle).
238+ Background(t.Green).
239+ Padding(0, 1).
240+ Bold(true).
241+ Render
233242
234243 attachments := make([]string, len(m.message.BinaryContent()))
235244 for i, attachment := range m.message.BinaryContent() {
236245 const maxFilenameWidth = 10
237− filename := filepath.Base(attachment.Path)
238− attachments[i] = attachmentStyles.Render(fmt.Sprintf(
239− " %s %s ",
240− styles.DocumentIcon,
241− ansi.Truncate(filename, maxFilenameWidth, "..."),
242− ))
246+ filename := ansi.Truncate(filepath.Base(attachment.Path), 10, "...")
247+ icon := styles.ImageIcon
248+ if strings.HasPrefix(attachment.MIMEType, "text/") {
249+ icon = styles.TextIcon
250+ }
251+ attachments[i] = lipgloss.JoinHorizontal(
252+ lipgloss.Left,
253+ iconStyle(icon),
254+ attachmentStyle(filename),
255+ )
243256 }
244257
245258 if len(attachments) > 0 {
internal/tui/components/chat/splash/splash.go+199−69
@@ -12,12 +12,15 @@ import (
1212 "github.com/atotto/clipboard"
1313 "github.com/charmbracelet/catwalk/pkg/catwalk"
1414 "github.com/charmbracelet/crush/internal/agent"
15+ hyperp "github.com/charmbracelet/crush/internal/agent/hyper"
1516 "github.com/charmbracelet/crush/internal/config"
1617 "github.com/charmbracelet/crush/internal/home"
1718 "github.com/charmbracelet/crush/internal/tui/components/chat"
1819 "github.com/charmbracelet/crush/internal/tui/components/core"
1920 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
2021 "github.com/charmbracelet/crush/internal/tui/components/dialogs/claude"
22+ "github.com/charmbracelet/crush/internal/tui/components/dialogs/copilot"
23+ "github.com/charmbracelet/crush/internal/tui/components/dialogs/hyper"
2124 "github.com/charmbracelet/crush/internal/tui/components/dialogs/models"
2225 "github.com/charmbracelet/crush/internal/tui/components/logo"
2326 lspcomponent "github.com/charmbracelet/crush/internal/tui/components/lsp"
@@ -55,6 +58,12 @@ type Splash interface {
5558
5659 // IsClaudeOAuthComplete returns whether Claude OAuth flow is complete
5760 IsClaudeOAuthComplete() bool
61+
62+ // IsShowingClaudeOAuth2 returns whether showing Hyper OAuth2 flow
63+ IsShowingHyperOAuth2() bool
64+
65+ // IsShowingClaudeOAuth2 returns whether showing GitHub Copilot OAuth2 flow
66+ IsShowingCopilotOAuth2() bool
5867}
5968
6069const (
@@ -87,6 +96,14 @@ type splashCmp struct {
8796 isAPIKeyValid bool
8897 apiKeyValue string
8998
99+ // Hyper device flow state
100+ hyperDeviceFlow *hyper.DeviceFlow
101+ showHyperDeviceFlow bool
102+
103+ // Copilot device flow state
104+ copilotDeviceFlow *copilot.DeviceFlow
105+ showCopilotDeviceFlow bool
106+
90107 // Claude state
91108 claudeAuthMethodChooser *claude.AuthMethodChooser
92109 claudeOAuth2 *claude.OAuth2
@@ -186,6 +203,26 @@ func (s *splashCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
186203 }
187204
188205 return s, tea.Batch(cmds...)
206+ case hyper.DeviceFlowCompletedMsg:
207+ s.showHyperDeviceFlow = false
208+ return s, s.saveAPIKeyAndContinue(msg.Token, true)
209+ case hyper.DeviceAuthInitiatedMsg, hyper.DeviceFlowErrorMsg:
210+ if s.hyperDeviceFlow != nil {
211+ u, cmd := s.hyperDeviceFlow.Update(msg)
212+ s.hyperDeviceFlow = u.(*hyper.DeviceFlow)
213+ return s, cmd
214+ }
215+ return s, nil
216+ case copilot.DeviceAuthInitiatedMsg, copilot.DeviceFlowErrorMsg:
217+ if s.copilotDeviceFlow != nil {
218+ u, cmd := s.copilotDeviceFlow.Update(msg)
219+ s.copilotDeviceFlow = u.(*copilot.DeviceFlow)
220+ return s, cmd
221+ }
222+ return s, nil
223+ case copilot.DeviceFlowCompletedMsg:
224+ s.showCopilotDeviceFlow = false
225+ return s, s.saveAPIKeyAndContinue(msg.Token, true)
189226 case claude.AuthenticationCompleteMsg:
190227 s.showClaudeAuthMethodChooser = false
191228 s.showClaudeOAuth2 = false
@@ -205,41 +242,49 @@ func (s *splashCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
205242 }
206243 case tea.KeyPressMsg:
207244 switch {
208− case key.Matches(msg, s.keyMap.Copy):
209− if s.showClaudeOAuth2 && s.claudeOAuth2.State == claude.OAuthStateURL {
210− return s, tea.Sequence(
211− tea.SetClipboard(s.claudeOAuth2.URL),
212− func() tea.Msg {
213− _ = clipboard.WriteAll(s.claudeOAuth2.URL)
214− return nil
215− },
216− util.ReportInfo("URL copied to clipboard"),
217− )
218− } else if s.showClaudeAuthMethodChooser {
219− u, cmd := s.claudeAuthMethodChooser.Update(msg)
220− s.claudeAuthMethodChooser = u.(*claude.AuthMethodChooser)
221− return s, cmd
222− } else if s.showClaudeOAuth2 {
223− u, cmd := s.claudeOAuth2.Update(msg)
224− s.claudeOAuth2 = u.(*claude.OAuth2)
225− return s, cmd
226− }
245+ case key.Matches(msg, s.keyMap.Copy) && s.showHyperDeviceFlow:
246+ return s, s.hyperDeviceFlow.CopyCode()
247+ case key.Matches(msg, s.keyMap.Copy) && s.showCopilotDeviceFlow:
248+ return s, s.copilotDeviceFlow.CopyCode()
249+ case key.Matches(msg, s.keyMap.Copy) && s.showClaudeOAuth2 && s.claudeOAuth2.State == claude.OAuthStateURL:
250+ return s, tea.Sequence(
251+ tea.SetClipboard(s.claudeOAuth2.URL),
252+ func() tea.Msg {
253+ _ = clipboard.WriteAll(s.claudeOAuth2.URL)
254+ return nil
255+ },
256+ util.ReportInfo("URL copied to clipboard"),
257+ )
258+ case key.Matches(msg, s.keyMap.Copy) && s.showClaudeAuthMethodChooser:
259+ u, cmd := s.claudeAuthMethodChooser.Update(msg)
260+ s.claudeAuthMethodChooser = u.(*claude.AuthMethodChooser)
261+ return s, cmd
262+ case key.Matches(msg, s.keyMap.Copy) && s.showClaudeOAuth2:
263+ u, cmd := s.claudeOAuth2.Update(msg)
264+ s.claudeOAuth2 = u.(*claude.OAuth2)
265+ return s, cmd
227266 case key.Matches(msg, s.keyMap.Back):
228− if s.showClaudeAuthMethodChooser {
267+ switch {
268+ case s.showClaudeAuthMethodChooser:
229269 s.claudeAuthMethodChooser.SetDefaults()
230270 s.showClaudeAuthMethodChooser = false
231271 return s, nil
232− }
233− if s.showClaudeOAuth2 {
272+ case s.showClaudeOAuth2:
234273 s.claudeOAuth2.SetDefaults()
235274 s.showClaudeOAuth2 = false
236275 s.showClaudeAuthMethodChooser = true
237276 return s, nil
238− }
239− if s.isAPIKeyValid {
277+ case s.showHyperDeviceFlow:
278+ s.hyperDeviceFlow = nil
279+ s.showHyperDeviceFlow = false
240280 return s, nil
241− }
242− if s.needsAPIKey {
281+ case s.showCopilotDeviceFlow:
282+ s.copilotDeviceFlow = nil
283+ s.showCopilotDeviceFlow = false
284+ return s, nil
285+ case s.isAPIKeyValid:
286+ return s, nil
287+ case s.needsAPIKey:
243288 if s.selectedModel.Provider.ID == catwalk.InferenceProviderAnthropic {
244289 s.showClaudeAuthMethodChooser = true
245290 }
@@ -251,7 +296,8 @@ func (s *splashCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
251296 return s, nil
252297 }
253298 case key.Matches(msg, s.keyMap.Select):
254− if s.showClaudeAuthMethodChooser {
299+ switch {
300+ case s.showClaudeAuthMethodChooser:
255301 selectedItem := s.modelList.SelectedModel()
256302 if selectedItem == nil {
257303 return s, nil
@@ -269,16 +315,17 @@ func (s *splashCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
269315 s.showClaudeOAuth2 = true
270316 }
271317 return s, nil
272− }
273− if s.showClaudeOAuth2 {
318+ case s.showClaudeOAuth2:
274319 m2, cmd2 := s.claudeOAuth2.ValidationConfirm()
275320 s.claudeOAuth2 = m2.(*claude.OAuth2)
276321 return s, cmd2
277− }
278− if s.isAPIKeyValid {
322+ case s.showHyperDeviceFlow:
323+ return s, s.hyperDeviceFlow.CopyCodeAndOpenURL()
324+ case s.showCopilotDeviceFlow:
325+ return s, s.copilotDeviceFlow.CopyCodeAndOpenURL()
326+ case s.isAPIKeyValid:
279327 return s, s.saveAPIKeyAndContinue(s.apiKeyValue, true)
280− }
281− if s.isOnboarding && !s.needsAPIKey {
328+ case s.isOnboarding && !s.needsAPIKey:
282329 selectedItem := s.modelList.SelectedModel()
283330 if selectedItem == nil {
284331 return s, nil
@@ -288,9 +335,26 @@ func (s *splashCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
288335 s.isOnboarding = false
289336 return s, tea.Batch(cmd, util.CmdHandler(OnboardingCompleteMsg{}))
290337 } else {
291− if selectedItem.Provider.ID == catwalk.InferenceProviderAnthropic {
338+ switch selectedItem.Provider.ID {
339+ case catwalk.InferenceProviderAnthropic:
292340 s.showClaudeAuthMethodChooser = true
293341 return s, nil
342+ case hyperp.Name:
343+ s.selectedModel = selectedItem
344+ s.showHyperDeviceFlow = true
345+ s.hyperDeviceFlow = hyper.NewDeviceFlow()
346+ s.hyperDeviceFlow.SetWidth(min(s.width-2, 60))
347+ return s, s.hyperDeviceFlow.Init()
348+ case catwalk.InferenceProviderCopilot:
349+ if token, ok := config.Get().ImportCopilot(); ok {
350+ s.selectedModel = selectedItem
351+ return s, s.saveAPIKeyAndContinue(token, true)
352+ }
353+ s.selectedModel = selectedItem
354+ s.showCopilotDeviceFlow = true
355+ s.copilotDeviceFlow = copilot.NewDeviceFlow()
356+ s.copilotDeviceFlow.SetWidth(min(s.width-2, 60))
357+ return s, s.copilotDeviceFlow.Init()
294358 }
295359 // Provider not configured, show API key input
296360 s.needsAPIKey = true
@@ -298,7 +362,7 @@ func (s *splashCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
298362 s.apiKeyInput.SetProviderName(selectedItem.Provider.Name)
299363 return s, nil
300364 }
301− } else if s.needsAPIKey {
365+ case s.needsAPIKey:
302366 // Handle API key submission
303367 s.apiKeyValue = strings.TrimSpace(s.apiKeyInput.Value())
304368 if s.apiKeyValue == "" {
@@ -339,7 +403,7 @@ func (s *splashCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
339403 }
340404 },
341405 )
342− } else if s.needsProjectInit {
406+ case s.needsProjectInit:
343407 return s, s.initializeProject()
344408 }
345409 case key.Matches(msg, s.keyMap.Tab, s.keyMap.LeftRight):
@@ -387,44 +451,71 @@ func (s *splashCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
387451 return s, s.initializeProject()
388452 }
389453 default:
390− if s.showClaudeAuthMethodChooser {
454+ switch {
455+ case s.showClaudeAuthMethodChooser:
391456 u, cmd := s.claudeAuthMethodChooser.Update(msg)
392457 s.claudeAuthMethodChooser = u.(*claude.AuthMethodChooser)
393458 return s, cmd
394− } else if s.showClaudeOAuth2 {
459+ case s.showClaudeOAuth2:
395460 u, cmd := s.claudeOAuth2.Update(msg)
396461 s.claudeOAuth2 = u.(*claude.OAuth2)
397462 return s, cmd
398− } else if s.needsAPIKey {
463+ case s.showHyperDeviceFlow:
464+ u, cmd := s.hyperDeviceFlow.Update(msg)
465+ s.hyperDeviceFlow = u.(*hyper.DeviceFlow)
466+ return s, cmd
467+ case s.showCopilotDeviceFlow:
468+ u, cmd := s.copilotDeviceFlow.Update(msg)
469+ s.copilotDeviceFlow = u.(*copilot.DeviceFlow)
470+ return s, cmd
471+ case s.needsAPIKey:
399472 u, cmd := s.apiKeyInput.Update(msg)
400473 s.apiKeyInput = u.(*models.APIKeyInput)
401474 return s, cmd
402− } else if s.isOnboarding {
475+ case s.isOnboarding:
403476 u, cmd := s.modelList.Update(msg)
404477 s.modelList = u
405478 return s, cmd
406479 }
407480 }
408481 case tea.PasteMsg:
409− if s.showClaudeOAuth2 {
482+ switch {
483+ case s.showClaudeOAuth2:
410484 u, cmd := s.claudeOAuth2.Update(msg)
411485 s.claudeOAuth2 = u.(*claude.OAuth2)
412486 return s, cmd
413− } else if s.needsAPIKey {
487+ case s.showHyperDeviceFlow:
488+ u, cmd := s.hyperDeviceFlow.Update(msg)
489+ s.hyperDeviceFlow = u.(*hyper.DeviceFlow)
490+ return s, cmd
491+ case s.showCopilotDeviceFlow:
492+ u, cmd := s.copilotDeviceFlow.Update(msg)
493+ s.copilotDeviceFlow = u.(*copilot.DeviceFlow)
494+ return s, cmd
495+ case s.needsAPIKey:
414496 u, cmd := s.apiKeyInput.Update(msg)
415497 s.apiKeyInput = u.(*models.APIKeyInput)
416498 return s, cmd
417− } else if s.isOnboarding {
499+ case s.isOnboarding:
418500 var cmd tea.Cmd
419501 s.modelList, cmd = s.modelList.Update(msg)
420502 return s, cmd
421503 }
422504 case spinner.TickMsg:
423− if s.showClaudeOAuth2 {
505+ switch {
506+ case s.showClaudeOAuth2:
424507 u, cmd := s.claudeOAuth2.Update(msg)
425508 s.claudeOAuth2 = u.(*claude.OAuth2)
426509 return s, cmd
427− } else {
510+ case s.showHyperDeviceFlow:
511+ u, cmd := s.hyperDeviceFlow.Update(msg)
512+ s.hyperDeviceFlow = u.(*hyper.DeviceFlow)
513+ return s, cmd
514+ case s.showCopilotDeviceFlow:
515+ u, cmd := s.copilotDeviceFlow.Update(msg)
516+ s.copilotDeviceFlow = u.(*copilot.DeviceFlow)
517+ return s, cmd
518+ default:
428519 u, cmd := s.apiKeyInput.Update(msg)
429520 s.apiKeyInput = u.(*models.APIKeyInput)
430521 return s, cmd
@@ -562,8 +653,10 @@ func (s *splashCmp) isProviderConfigured(providerID string) bool {
562653func (s *splashCmp) View() string {
563654 t := styles.CurrentTheme()
564655 var content string
565− if s.showClaudeAuthMethodChooser {
566− remainingHeight := s.height - lipgloss.Height(s.logoRendered) - (SplashScreenPaddingY * 2)
656+
657+ switch {
658+ case s.showClaudeAuthMethodChooser:
659+ remainingHeight := s.height - lipgloss.Height(s.logoRendered) - SplashScreenPaddingY
567660 chooserView := s.claudeAuthMethodChooser.View()
568661 authMethodSelector := t.S().Base.AlignVertical(lipgloss.Bottom).Height(remainingHeight).Render(
569662 lipgloss.JoinVertical(
@@ -578,8 +671,8 @@ func (s *splashCmp) View() string {
578671 s.logoRendered,
579672 authMethodSelector,
580673 )
581− } else if s.showClaudeOAuth2 {
582− remainingHeight := s.height - lipgloss.Height(s.logoRendered) - (SplashScreenPaddingY * 2)
674+ case s.showClaudeOAuth2:
675+ remainingHeight := s.height - lipgloss.Height(s.logoRendered) - SplashScreenPaddingY
583676 oauth2View := s.claudeOAuth2.View()
584677 oauthSelector := t.S().Base.AlignVertical(lipgloss.Bottom).Height(remainingHeight).Render(
585678 lipgloss.JoinVertical(
@@ -594,8 +687,38 @@ func (s *splashCmp) View() string {
594687 s.logoRendered,
595688 oauthSelector,
596689 )
597− } else if s.needsAPIKey {
598− remainingHeight := s.height - lipgloss.Height(s.logoRendered) - (SplashScreenPaddingY * 2)
690+ case s.showHyperDeviceFlow:
691+ remainingHeight := s.height - lipgloss.Height(s.logoRendered) - SplashScreenPaddingY
692+ hyperView := s.hyperDeviceFlow.View()
693+ hyperSelector := t.S().Base.AlignVertical(lipgloss.Bottom).Height(remainingHeight).Render(
694+ lipgloss.JoinVertical(
695+ lipgloss.Left,
696+ t.S().Base.PaddingLeft(1).Foreground(t.Primary).Render("Let's Auth Hyper"),
697+ hyperView,
698+ ),
699+ )
700+ content = lipgloss.JoinVertical(
701+ lipgloss.Left,
702+ s.logoRendered,
703+ hyperSelector,
704+ )
705+ case s.showCopilotDeviceFlow:
706+ remainingHeight := s.height - lipgloss.Height(s.logoRendered) - SplashScreenPaddingY
707+ copilotView := s.copilotDeviceFlow.View()
708+ copilotSelector := t.S().Base.AlignVertical(lipgloss.Bottom).Height(remainingHeight).Render(
709+ lipgloss.JoinVertical(
710+ lipgloss.Left,
711+ t.S().Base.PaddingLeft(1).Foreground(t.Primary).Render("Let's Auth GitHub Copilot"),
712+ copilotView,
713+ ),
714+ )
715+ content = lipgloss.JoinVertical(
716+ lipgloss.Left,
717+ s.logoRendered,
718+ copilotSelector,
719+ )
720+ case s.needsAPIKey:
721+ remainingHeight := s.height - lipgloss.Height(s.logoRendered) - SplashScreenPaddingY
599722 apiKeyView := t.S().Base.PaddingLeft(1).Render(s.apiKeyInput.View())
600723 apiKeySelector := t.S().Base.AlignVertical(lipgloss.Bottom).Height(remainingHeight).Render(
601724 lipgloss.JoinVertical(
@@ -608,9 +731,9 @@ func (s *splashCmp) View() string {
608731 s.logoRendered,
609732 apiKeySelector,
610733 )
611− } else if s.isOnboarding {
734+ case s.isOnboarding:
612735 modelListView := s.modelList.View()
613− remainingHeight := s.height - lipgloss.Height(s.logoRendered) - (SplashScreenPaddingY * 2)
736+ remainingHeight := s.height - lipgloss.Height(s.logoRendered) - SplashScreenPaddingY
614737 modelSelector := t.S().Base.AlignVertical(lipgloss.Bottom).Height(remainingHeight).Render(
615738 lipgloss.JoinVertical(
616739 lipgloss.Left,
@@ -624,7 +747,7 @@ func (s *splashCmp) View() string {
624747 s.logoRendered,
625748 modelSelector,
626749 )
627− } else if s.needsProjectInit {
750+ case s.needsProjectInit:
628751 titleStyle := t.S().Base.Foreground(t.FgBase)
629752 pathStyle := t.S().Base.Foreground(t.Success).PaddingLeft(2)
630753 bodyStyle := t.S().Base.Foreground(t.FgMuted)
@@ -675,7 +798,7 @@ func (s *splashCmp) View() string {
675798 "",
676799 initContent,
677800 )
678− } else {
801+ default:
679802 parts := []string{
680803 s.logoRendered,
681804 s.infoSection(),
@@ -692,28 +815,25 @@ func (s *splashCmp) View() string {
692815}
693816
694817func (s *splashCmp) Cursor() *tea.Cursor {
695− if s.showClaudeAuthMethodChooser {
818+ switch {
819+ case s.showClaudeAuthMethodChooser:
696820 return nil
697− }
698− if s.showClaudeOAuth2 {
821+ case s.showClaudeOAuth2:
699822 if cursor := s.claudeOAuth2.CodeInput.Cursor(); cursor != nil {
700823 cursor.Y += 2 // FIXME(@andreynering): Why do we need this?
701824 return s.moveCursor(cursor)
702825 }
703826 return nil
704− }
705− if s.needsAPIKey {
827+ case s.needsAPIKey:
706828 cursor := s.apiKeyInput.Cursor()
707829 if cursor != nil {
708830 return s.moveCursor(cursor)
709831 }
710− } else if s.isOnboarding {
832+ case s.isOnboarding:
711833 cursor := s.modelList.Cursor()
712834 if cursor != nil {
713835 return s.moveCursor(cursor)
714836 }
715− } else {
716− return nil
717837 }
718838 return nil
719839}
@@ -805,13 +925,14 @@ func (s *splashCmp) logoGap() int {
805925
806926// Bindings implements SplashPage.
807927func (s *splashCmp) Bindings() []key.Binding {
808− if s.showClaudeAuthMethodChooser {
928+ switch {
929+ case s.showClaudeAuthMethodChooser:
809930 return []key.Binding{
810931 s.keyMap.Select,
811932 s.keyMap.Tab,
812933 s.keyMap.Back,
813934 }
814− } else if s.showClaudeOAuth2 {
935+ case s.showClaudeOAuth2:
815936 bindings := []key.Binding{
816937 s.keyMap.Select,
817938 }
@@ -819,18 +940,18 @@ func (s *splashCmp) Bindings() []key.Binding {
819940 bindings = append(bindings, s.keyMap.Copy)
820941 }
821942 return bindings
822− } else if s.needsAPIKey {
943+ case s.needsAPIKey:
823944 return []key.Binding{
824945 s.keyMap.Select,
825946 s.keyMap.Back,
826947 }
827− } else if s.isOnboarding {
948+ case s.isOnboarding:
828949 return []key.Binding{
829950 s.keyMap.Select,
830951 s.keyMap.Next,
831952 s.keyMap.Previous,
832953 }
833− } else if s.needsProjectInit {
954+ case s.needsProjectInit:
834955 return []key.Binding{
835956 s.keyMap.Select,
836957 s.keyMap.Yes,
@@ -838,8 +959,9 @@ func (s *splashCmp) Bindings() []key.Binding {
838959 s.keyMap.Tab,
839960 s.keyMap.LeftRight,
840961 }
962+ default:
963+ return []key.Binding{}
841964 }
842− return []key.Binding{}
843965}
844966
845967func (s *splashCmp) getMaxInfoWidth() int {
@@ -940,3 +1062,11 @@ func (s *splashCmp) IsClaudeOAuthURLState() bool {
9401062func (s *splashCmp) IsClaudeOAuthComplete() bool {
9411063 return s.showClaudeOAuth2 && s.claudeOAuth2.State == claude.OAuthStateCode && s.claudeOAuth2.ValidationState == claude.OAuthValidationStateValid
9421064}
1065+
1066+func (s *splashCmp) IsShowingHyperOAuth2() bool {
1067+ return s.showHyperDeviceFlow
1068+}
1069+
1070+func (s *splashCmp) IsShowingCopilotOAuth2() bool {
1071+ return s.showCopilotDeviceFlow
1072+}
internal/tui/components/dialogs/models/models.go+28−22
@@ -174,13 +174,10 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
174174 case tea.KeyPressMsg:
175175 switch {
176176 // Handle Hyper device flow keys
177− case key.Matches(msg, key.NewBinding(key.WithKeys("c", "C"))) && (m.showHyperDeviceFlow || m.showCopilotDeviceFlow):
178− if m.hyperDeviceFlow != nil {
179− return m, m.hyperDeviceFlow.CopyCode()
180− }
181− if m.copilotDeviceFlow != nil {
182− return m, m.copilotDeviceFlow.CopyCode()
183− }
177+ case key.Matches(msg, key.NewBinding(key.WithKeys("c", "C"))) && m.showHyperDeviceFlow:
178+ return m, m.hyperDeviceFlow.CopyCode()
179+ case key.Matches(msg, key.NewBinding(key.WithKeys("c", "C"))) && m.showCopilotDeviceFlow:
180+ return m, m.copilotDeviceFlow.CopyCode()
184181 case key.Matches(msg, key.NewBinding(key.WithKeys("c", "C"))) && m.showClaudeOAuth2 && m.claudeOAuth2.State == claude.OAuthStateURL:
185182 return m, tea.Sequence(
186183 tea.SetClipboard(m.claudeOAuth2.URL),
@@ -202,6 +199,9 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
202199 return m, m.copilotDeviceFlow.CopyCodeAndOpenURL()
203200 }
204201 selectedItem := m.modelList.SelectedModel()
202+ if selectedItem == nil {
203+ return m, nil
204+ }
205205
206206 modelType := config.SelectedModelTypeLarge
207207 if m.modelList.GetModelType() == SmallModelType {
@@ -310,6 +310,11 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
310310 m.hyperDeviceFlow.SetWidth(m.width - 2)
311311 return m, m.hyperDeviceFlow.Init()
312312 case catwalk.InferenceProviderCopilot:
313+ if token, ok := config.Get().ImportCopilot(); ok {
314+ m.selectedModel = selectedItem
315+ m.selectedModelType = modelType
316+ return m, m.saveOauthTokenAndContinue(token, true)
317+ }
313318 m.showCopilotDeviceFlow = true
314319 m.selectedModel = selectedItem
315320 m.selectedModelType = modelType
@@ -337,28 +342,26 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
337342 return m, m.modelList.SetModelType(LargeModelType)
338343 }
339344 case key.Matches(msg, m.keyMap.Close):
340− if m.showHyperDeviceFlow {
345+ switch {
346+ case m.showHyperDeviceFlow:
341347 if m.hyperDeviceFlow != nil {
342348 m.hyperDeviceFlow.Cancel()
343349 }
344350 m.showHyperDeviceFlow = false
345351 m.selectedModel = nil
346− }
347− if m.showCopilotDeviceFlow {
352+ case m.showCopilotDeviceFlow:
348353 if m.copilotDeviceFlow != nil {
349354 m.copilotDeviceFlow.Cancel()
350355 }
351356 m.showCopilotDeviceFlow = false
352357 m.selectedModel = nil
353− }
354− if m.showClaudeAuthMethodChooser {
358+ case m.showClaudeAuthMethodChooser:
355359 m.claudeAuthMethodChooser.SetDefaults()
356360 m.showClaudeAuthMethodChooser = false
357361 m.keyMap.isClaudeAuthChoiceHelp = false
358362 m.keyMap.isClaudeOAuthHelp = false
359363 return m, nil
360− }
361− if m.needsAPIKey {
364+ case m.needsAPIKey:
362365 if m.isAPIKeyValid {
363366 return m, nil
364367 }
@@ -369,37 +372,40 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
369372 m.apiKeyValue = ""
370373 m.apiKeyInput.Reset()
371374 return m, nil
375+ default:
376+ return m, util.CmdHandler(dialogs.CloseDialogMsg{})
372377 }
373− return m, util.CmdHandler(dialogs.CloseDialogMsg{})
374378 default:
375− if m.showClaudeAuthMethodChooser {
379+ switch {
380+ case m.showClaudeAuthMethodChooser:
376381 u, cmd := m.claudeAuthMethodChooser.Update(msg)
377382 m.claudeAuthMethodChooser = u.(*claude.AuthMethodChooser)
378383 return m, cmd
379− } else if m.showClaudeOAuth2 {
384+ case m.showClaudeOAuth2:
380385 u, cmd := m.claudeOAuth2.Update(msg)
381386 m.claudeOAuth2 = u.(*claude.OAuth2)
382387 return m, cmd
383− } else if m.needsAPIKey {
388+ case m.needsAPIKey:
384389 u, cmd := m.apiKeyInput.Update(msg)
385390 m.apiKeyInput = u.(*APIKeyInput)
386391 return m, cmd
387− } else {
392+ default:
388393 u, cmd := m.modelList.Update(msg)
389394 m.modelList = u
390395 return m, cmd
391396 }
392397 }
393398 case tea.PasteMsg:
394− if m.showClaudeOAuth2 {
399+ switch {
400+ case m.showClaudeOAuth2:
395401 u, cmd := m.claudeOAuth2.Update(msg)
396402 m.claudeOAuth2 = u.(*claude.OAuth2)
397403 return m, cmd
398− } else if m.needsAPIKey {
404+ case m.needsAPIKey:
399405 u, cmd := m.apiKeyInput.Update(msg)
400406 m.apiKeyInput = u.(*APIKeyInput)
401407 return m, cmd
402− } else {
408+ default:
403409 var cmd tea.Cmd
404410 m.modelList, cmd = m.modelList.Update(msg)
405411 return m, cmd
internal/tui/page/chat/chat.go+30−6
@@ -31,7 +31,9 @@ import (
3131 "github.com/charmbracelet/crush/internal/tui/components/dialogs"
3232 "github.com/charmbracelet/crush/internal/tui/components/dialogs/claude"
3333 "github.com/charmbracelet/crush/internal/tui/components/dialogs/commands"
34+ "github.com/charmbracelet/crush/internal/tui/components/dialogs/copilot"
3435 "github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker"
36+ "github.com/charmbracelet/crush/internal/tui/components/dialogs/hyper"
3537 "github.com/charmbracelet/crush/internal/tui/components/dialogs/models"
3638 "github.com/charmbracelet/crush/internal/tui/components/dialogs/reasoning"
3739 "github.com/charmbracelet/crush/internal/tui/page"
@@ -335,7 +337,14 @@ func (p *chatPage) Update(msg tea.Msg) (util.Model, tea.Cmd) {
335337 cmds = append(cmds, cmd)
336338 return p, tea.Batch(cmds...)
337339
338− case claude.ValidationCompletedMsg, claude.AuthenticationCompleteMsg:
340+ case claude.ValidationCompletedMsg,
341+ claude.AuthenticationCompleteMsg,
342+ hyper.DeviceFlowCompletedMsg,
343+ hyper.DeviceAuthInitiatedMsg,
344+ hyper.DeviceFlowErrorMsg,
345+ copilot.DeviceAuthInitiatedMsg,
346+ copilot.DeviceFlowErrorMsg,
347+ copilot.DeviceFlowCompletedMsg:
339348 if p.focusedPane == PanelTypeSplash {
340349 u, cmd := p.splash.Update(msg)
341350 p.splash = u.(splash.Splash)
@@ -604,8 +613,11 @@ func (p *chatPage) View() string {
604613 pillsArea = pillsRow
605614 }
606615
607− style := t.S().Base.MarginTop(1).PaddingLeft(3)
608− pillsArea = style.Render(pillsArea)
616+ pillsArea = t.S().Base.
617+ MaxWidth(p.width).
618+ MarginTop(1).
619+ PaddingLeft(3).
620+ Render(pillsArea)
609621 }
610622
611623 if p.compact {
@@ -1050,7 +1062,8 @@ func (p *chatPage) Help() help.KeyMap {
10501062 fullList = append(fullList, []key.Binding{v})
10511063 }
10521064 case p.isOnboarding && p.splash.IsShowingClaudeOAuth2():
1053− if p.splash.IsClaudeOAuthURLState() {
1065+ switch {
1066+ case p.splash.IsClaudeOAuthURLState():
10541067 shortList = append(shortList,
10551068 key.NewBinding(
10561069 key.WithKeys("enter"),
@@ -1061,14 +1074,25 @@ func (p *chatPage) Help() help.KeyMap {
10611074 key.WithHelp("c", "copy url"),
10621075 ),
10631076 )
1064− } else if p.splash.IsClaudeOAuthComplete() {
1077+ case p.splash.IsClaudeOAuthComplete():
10651078 shortList = append(shortList,
10661079 key.NewBinding(
10671080 key.WithKeys("enter"),
10681081 key.WithHelp("enter", "continue"),
10691082 ),
10701083 )
1071− } else {
1084+ case p.splash.IsShowingHyperOAuth2() || p.splash.IsShowingCopilotOAuth2():
1085+ shortList = append(shortList,
1086+ key.NewBinding(
1087+ key.WithKeys("enter"),
1088+ key.WithHelp("enter", "copy url & open signup"),
1089+ ),
1090+ key.NewBinding(
1091+ key.WithKeys("c"),
1092+ key.WithHelp("c", "copy url"),
1093+ ),
1094+ )
1095+ default:
10721096 shortList = append(shortList,
10731097 key.NewBinding(
10741098 key.WithKeys("enter"),
internal/tui/styles/icons.go+2−1
@@ -10,7 +10,8 @@ const (
1010 ArrowRightIcon string = "→"
1111 CenterSpinnerIcon string = "⋯"
1212 LoadingIcon string = "⟳"
13− DocumentIcon string = "🖼"
13+ ImageIcon string = "■"
14+ TextIcon string = "☰"
1415 ModelIcon string = "◇"
1516
1617 // Tool call icons
schema.json+11
@@ -367,6 +367,17 @@
367367 "type": "array",
368368 "description": "Paths to files containing context information for the AI"
369369 },
370+ "skills_paths": {
371+ "items": {
372+ "type": "string",
373+ "examples": [
374+ "~/.config/crush/skills",
375+ "./skills"
376+ ]
377+ },
378+ "type": "array",
379+ "description": "Paths to directories containing Agent Skills (folders with SKILL.md files)"
380+ },
370381 "tui": {
371382 "$ref": "#/$defs/TUIOptions",
372383 "description": "Terminal user interface options"