on main help sign in
Merge branch 'main' into multi-client
commit6151e7c22c30a173165ec78c9705f7e4cae54711 authorChristian Rocha <christian@rocha.is> dateFri 22 May 2026, 10:08 (5 months ago) parentc986a356b312be
30 files changed+1415−110
.github/cla-signatures.json+8 README.md+31 internal/agent/coordinator.go+49−64 internal/app/app.go+12−3 internal/backend/agent.go+1−1 internal/backend/backend.go+61−6 internal/backend/backend_skills_test.go+167 internal/client/proto.go+5−10 internal/cmd/root.go+29−1 internal/commands/commands.go+67 internal/permission/permission.go+8−6 internal/permission/permission_test.go+15 internal/proto/message.go+42 internal/proto/proto.go+4 internal/proto/skills.go+28 internal/pubsub/events.go+1 internal/server/events.go+27 internal/server/events_test.go+39 internal/skills/builtin/crush-config/SKILL.md+31 internal/skills/manager.go+180 internal/skills/manager_test.go+249 internal/skills/skills.go+30−9 internal/skills/skills_test.go+14 internal/ui/chat/user.go+35−4 internal/ui/dialog/actions.go+2 internal/ui/dialog/commands.go+1 internal/ui/model/session_test.go+142 internal/ui/model/ui.go+8 internal/workspace/client_workspace.go+50−6 internal/workspace/client_workspace_test.go+79
.github/cla-signatures.json+8
@@ -1807,6 +1807,14 @@
18071807 "created_at": "2026-05-19T03:37:32Z",
18081808 "repoId": 987670088,
18091809 "pullRequestNo": 2954
1810+ },
1811+ {
1812+ "name": "mei2jun1",
1813+ "id": 285962643,
1814+ "comment_id": 4495440988,
1815+ "created_at": "2026-05-20T06:53:43Z",
1816+ "repoId": 987670088,
1817+ "pullRequestNo": 2964
18101818 }
18111819 ]
18121820}
README.md+31
@@ -525,6 +525,37 @@ git clone https://github.com/anthropics/skills.git _temp
525525mv _temp/skills/* . ; rm -r -force _temp
526526```
527527
528+#### User-Invocable Skills
529+
530+Skills can be made invocable as commands from the commands palette (Ctrl+P). Add `user-invocable: true` to the skill's YAML frontmatter:
531+
532+```yaml
533+---
534+name: my-skill
535+description: A skill that can be invoked as a command.
536+user-invocable: true
537+---
538+```
539+
540+User-invocable skills appear in the commands palette with a `user:` or `project:` prefix:
541+- Skills from global directories show as `user:skill-name`
542+- Skills from project directories show as `project:skill-name`
543+
544+When invoked, the skill's instructions are loaded into the conversation context.
545+
546+To prevent the model from auto-triggering a skill (while still allowing user invocation), add `disable-model-invocation: true`:
547+
548+```yaml
549+---
550+name: my-skill
551+description: Only invocable by users, not the model.
552+user-invocable: true
553+disable-model-invocation: true
554+---
555+```
556+
557+Skills with `disable-model-invocation` won't appear in the model's available skills list but can still be invoked manually by users.
558+
528559### Desktop notifications
529560
530561Crush sends desktop notifications when a tool call requires permission and when
internal/agent/coordinator.go+49−64
@@ -26,7 +26,6 @@ import (
2626 "github.com/charmbracelet/crush/internal/event"
2727 "github.com/charmbracelet/crush/internal/filetracker"
2828 "github.com/charmbracelet/crush/internal/history"
29− "github.com/charmbracelet/crush/internal/home"
3029 "github.com/charmbracelet/crush/internal/hooks"
3130 "github.com/charmbracelet/crush/internal/log"
3231 "github.com/charmbracelet/crush/internal/lsp"
@@ -118,9 +117,19 @@ func NewCoordinator(
118117 filetracker filetracker.Service,
119118 lspManager *lsp.Manager,
120119 notify pubsub.Publisher[notify.Notification],
120+ skillsMgr *skills.Manager,
121121) (Coordinator, error) {
122− // Discover skills once at session start.
123− allSkills, activeSkills := discoverSkills(cfg)
122+ // Skills are pre-discovered by the caller (see app.New /
123+ // backend.CreateWorkspace) and passed in via the manager. If no
124+ // manager was provided (legacy callers), fall back to an in-line
125+ // discovery so the coordinator still works.
126+ var allSkills, activeSkills []*skills.Skill
127+ if skillsMgr != nil {
128+ allSkills = skillsMgr.AllSkills()
129+ activeSkills = skillsMgr.ActiveSkills()
130+ } else {
131+ allSkills, activeSkills = discoverSkills(cfg)
132+ }
124133 skillTracker := skills.NewTracker(activeSkills)
125134
126135 c := &coordinator{
@@ -1138,53 +1147,31 @@ func (c *coordinator) updateParentSessionCost(ctx context.Context, childSessionI
11381147 return nil
11391148}
11401149
1141−// discoverSkills runs the skill discovery pipeline and returns both the
1142−// pre-filter (all discovered, after dedup) and post-filter (active) lists.
1143−// It also emits a single diagnostic log line summarising the outcome to
1144−// help track skill-loading health over time.
1150+// discoverSkills is a thin fallback wrapper used only when no
1151+// skills.Manager has been threaded through to the coordinator. All
1152+// production call sites (backend.CreateWorkspace, setupLocalWorkspace)
1153+// run discovery in advance and pass the results via the manager;
1154+// reaching this path means a caller bypassed both. It deliberately does
1155+// NOT publish to the package-level broker — there are no subscribers in
1156+// that case, so doing so would be misleading without delivering the
1157+// snapshot anywhere useful.
11451158func discoverSkills(cfg *config.ConfigStore) (allSkills, activeSkills []*skills.Skill) {
1146− builtin, builtinStates := skills.DiscoverBuiltinWithStates()
1147− discovered := append([]*skills.Skill(nil), builtin...)
1148−
1149− var userStates []*skills.SkillState
1150− var userPaths []string
1151−
11521159 opts := cfg.Config().Options
1153− if opts != nil && len(opts.SkillsPaths) > 0 {
1154− userPaths = make([]string, 0, len(opts.SkillsPaths))
1155− for _, pth := range opts.SkillsPaths {
1156− expanded := home.Long(pth)
1157− if strings.HasPrefix(expanded, "$") {
1158− if resolved, err := cfg.Resolver().ResolveValue(expanded); err == nil {
1159− expanded = resolved
1160− }
1161− }
1162− userPaths = append(userPaths, expanded)
1163− }
1164− var userSkills []*skills.Skill
1165− userSkills, userStates = skills.DiscoverWithStates(userPaths)
1166− discovered = append(discovered, userSkills...)
1167− }
1168−
1169− allSkills = skills.Deduplicate(discovered)
1170− var disabledSkills []string
1160+ var paths, disabled []string
11711161 if opts != nil {
1172− disabledSkills = opts.DisabledSkills
1162+ paths = opts.SkillsPaths
1163+ disabled = opts.DisabledSkills
11731164 }
1174− activeSkills = skills.Filter(allSkills, disabledSkills)
1175−
1176− allStates := append([]*skills.SkillState(nil), builtinStates...)
1177− allStates = append(allStates, userStates...)
1178−
1179− allStates = skills.DeduplicateStates(allStates)
1180−
1181− slices.SortStableFunc(allStates, func(a, b *skills.SkillState) int {
1182− return strings.Compare(strings.ToLower(a.Path), strings.ToLower(b.Path))
1165+ var resolver func(string) (string, error)
1166+ if r := cfg.Resolver(); r != nil {
1167+ resolver = r.ResolveValue
1168+ }
1169+ allSkills, activeSkills, states := skills.DiscoverFromConfig(skills.DiscoveryConfig{
1170+ SkillsPaths: paths,
1171+ DisabledSkills: disabled,
1172+ Resolver: resolver,
11831173 })
1184− skills.SetLatestStates(allStates)
1185− skills.PublishStates(allStates)
1186−
1187− logDiscoveryStats(builtin, builtinStates, userStates, userPaths, allSkills, activeSkills, disabledSkills)
1174+ logDiscoveryStats(states, paths, allSkills, activeSkills, disabled)
11881175 return allSkills, activeSkills
11891176}
11901177
@@ -1232,28 +1219,26 @@ func logTurnSkillUsage(
12321219
12331220// logDiscoveryStats emits a single structured log line summarising skill
12341221// discovery for the current session. It is intentionally low-volume: one
1235−// line per session start.
1222+// line per session start. Builtin vs user counts are derived from the
1223+// SkillState.Path — builtin states use the "builtin/" embed prefix.
12361224func logDiscoveryStats(
1237− builtin []*skills.Skill,
1238− builtinStates, userStates []*skills.SkillState,
1225+ states []*skills.SkillState,
12391226 userPaths []string,
12401227 allSkills, activeSkills []*skills.Skill,
12411228 disabled []string,
12421229) {
1243− countErrors := func(states []*skills.SkillState) int {
1244− n := 0
1245− for _, s := range states {
1246− if s.State == skills.StateError {
1247− n++
1248− }
1249− }
1250− return n
1251− }
1252−
1253− userOK := 0
1254− for _, s := range userStates {
1255− if s.State == skills.StateNormal {
1230+ var builtinOK, builtinErr, userOK, userErr int
1231+ for _, s := range states {
1232+ isBuiltin := strings.HasPrefix(s.Path, "builtin/")
1233+ switch {
1234+ case isBuiltin && s.State == skills.StateNormal:
1235+ builtinOK++
1236+ case isBuiltin && s.State == skills.StateError:
1237+ builtinErr++
1238+ case !isBuiltin && s.State == skills.StateNormal:
12561239 userOK++
1240+ case !isBuiltin && s.State == skills.StateError:
1241+ userErr++
12571242 }
12581243 }
12591244
@@ -1267,10 +1252,10 @@ func logDiscoveryStats(
12671252 slog.Info(
12681253 "Skill discovery complete",
12691254 "component", "skills",
1270− "builtin_ok", len(builtin),
1271− "builtin_errors", countErrors(builtinStates),
1255+ "builtin_ok", builtinOK,
1256+ "builtin_errors", builtinErr,
12721257 "user_ok", userOK,
1273− "user_errors", countErrors(userStates),
1258+ "user_errors", userErr,
12741259 "user_paths", len(userPaths),
12751260 "deduped_total", len(allSkills),
12761261 "active", len(activeSkills),
internal/app/app.go+12−3
@@ -62,6 +62,8 @@ type App struct {
6262
6363 LSPManager *lsp.Manager
6464
65+ Skills *skills.Manager
66+
6567 config *config.ConfigStore
6668
6769 serviceEventsWG *sync.WaitGroup
@@ -75,8 +77,11 @@ type App struct {
7577 agentNotifications *pubsub.Broker[notify.Notification]
7678}
7779
78−// New initializes a new application instance.
79−func New(ctx context.Context, conn *sql.DB, store *config.ConfigStore) (*App, error) {
80+// New initializes a new application instance. skillsMgr carries the
81+// per-workspace skill discovery results computed by the caller; the
82+// caller is responsible for constructing it (typically via
83+// skills.NewManager + skills.DiscoverFromConfig).
84+func New(ctx context.Context, conn *sql.DB, store *config.ConfigStore, skillsMgr *skills.Manager) (*App, error) {
8085 q := db.New(conn)
8186 sessions := session.NewService(q, conn)
8287 messages := message.NewService(q)
@@ -95,6 +100,7 @@ func New(ctx context.Context, conn *sql.DB, store *config.ConfigStore) (*App, er
95100 Permissions: permission.NewPermissionService(store.WorkingDir(), skipPermissionsRequests, allowedTools),
96101 FileTracker: filetracker.NewService(q),
97102 LSPManager: lsp.NewManager(store),
103+ Skills: skillsMgr,
98104
99105 globalCtx: ctx,
100106
@@ -481,7 +487,9 @@ func (app *App) setupEvents() {
481487 setupSubscriber(ctx, app.serviceEventsWG, "agent-notifications", app.agentNotifications.Subscribe, app.events)
482488 setupSubscriber(ctx, app.serviceEventsWG, "mcp", mcp.SubscribeEvents, app.events)
483489 setupSubscriber(ctx, app.serviceEventsWG, "lsp", SubscribeLSPEvents, app.events)
484− setupSubscriber(ctx, app.serviceEventsWG, "skills", skills.SubscribeEvents, app.events)
490+ if app.Skills != nil {
491+ setupSubscriber(ctx, app.serviceEventsWG, "skills", app.Skills.SubscribeEvents, app.events)
492+ }
485493 cleanupFunc := func(context.Context) error {
486494 cancel()
487495 app.serviceEventsWG.Wait()
@@ -532,6 +540,7 @@ func (app *App) InitCoderAgent(ctx context.Context) error {
532540 app.FileTracker,
533541 app.LSPManager,
534542 app.agentNotifications,
543+ app.Skills,
535544 )
536545 if err != nil {
537546 slog.Error("Failed to create coder agent", "err", err)
internal/backend/agent.go+1−1
@@ -19,7 +19,7 @@ func (b *Backend) SendMessage(ctx context.Context, workspaceID string, msg proto
1919 return ErrAgentNotInitialized
2020 }
2121
22− _, err = ws.AgentCoordinator.Run(ctx, msg.SessionID, msg.Prompt)
22+ _, err = ws.AgentCoordinator.Run(ctx, msg.SessionID, msg.Prompt, proto.AttachmentsToMessage(msg.Attachments)...)
2323 return err
2424}
2525
internal/backend/backend.go+61−6
@@ -18,6 +18,7 @@ import (
1818 "github.com/charmbracelet/crush/internal/csync"
1919 "github.com/charmbracelet/crush/internal/db"
2020 "github.com/charmbracelet/crush/internal/proto"
21+ "github.com/charmbracelet/crush/internal/skills"
2122 "github.com/charmbracelet/crush/internal/ui/util"
2223 "github.com/charmbracelet/crush/internal/version"
2324 "github.com/google/uuid"
@@ -96,10 +97,11 @@ type clientState struct {
9697// associated resources and state.
9798type Workspace struct {
9899 *app.App
99− ID string
100− Path string
101− Cfg *config.ConfigStore
102− Env []string
100+ ID string
101+ Path string
102+ Cfg *config.ConfigStore
103+ Env []string
104+ Skills *skills.Manager
103105
104106 // resolvedPath is the path used as the dedup key in
105107 // Backend.pathIndex. It is filepath.EvalSymlinks(filepath.Abs(Path))
@@ -228,7 +230,14 @@ func (b *Backend) CreateWorkspace(args proto.Workspace) (*Workspace, proto.Works
228230 return nil, proto.Workspace{}, fmt.Errorf("failed to connect to database: %w", err)
229231 }
230232
231− appWorkspace, err := app.New(b.ctx, conn, cfg)
233+ // Discover skills once per workspace, before app.New. The backend
234+ // hosts multiple workspaces concurrently, so the manager is
235+ // constructed WITHOUT WithGlobalMirror to prevent last-writer-wins
236+ // cross-talk between workspaces.
237+ allSkills, activeSkills, skillStates := skills.DiscoverFromConfig(skillsDiscoveryConfig(cfg))
238+ skillsMgr := skills.NewManager(allSkills, activeSkills, skillStates)
239+
240+ appWorkspace, err := app.New(b.ctx, conn, cfg, skillsMgr)
232241 if err != nil {
233242 return nil, proto.Workspace{}, fmt.Errorf("failed to create app workspace: %w", err)
234243 }
@@ -239,6 +248,7 @@ func (b *Backend) CreateWorkspace(args proto.Workspace) (*Workspace, proto.Works
239248 Path: args.Path,
240249 Cfg: cfg,
241250 Env: args.Env,
251+ Skills: skillsMgr,
242252 resolvedPath: key,
243253 clients: make(map[string]*clientState),
244254 }
@@ -282,6 +292,47 @@ func (b *Backend) CreateWorkspace(args proto.Workspace) (*Workspace, proto.Works
282292 return ws, workspaceToProto(ws), nil
283293}
284294
295+// skillsDiscoveryConfig adapts a *config.ConfigStore to the
296+// skills.DiscoveryConfig that DiscoverFromConfig consumes.
297+func skillsDiscoveryConfig(cfg *config.ConfigStore) skills.DiscoveryConfig {
298+ opts := cfg.Config().Options
299+ var paths, disabled []string
300+ if opts != nil {
301+ paths = opts.SkillsPaths
302+ disabled = opts.DisabledSkills
303+ }
304+ var resolver func(string) (string, error)
305+ if r := cfg.Resolver(); r != nil {
306+ resolver = r.ResolveValue
307+ }
308+ return skills.DiscoveryConfig{
309+ SkillsPaths: paths,
310+ DisabledSkills: disabled,
311+ Resolver: resolver,
312+ }
313+}
314+
315+// skillStatesToProto converts internal skill discovery states into the
316+// wire format.
317+func skillStatesToProto(states []*skills.SkillState) []proto.SkillState {
318+ if len(states) == 0 {
319+ return nil
320+ }
321+ out := make([]proto.SkillState, len(states))
322+ for i, s := range states {
323+ entry := proto.SkillState{
324+ Name: s.Name,
325+ Path: s.Path,
326+ State: proto.SkillDiscoveryState(s.State),
327+ }
328+ if s.Err != nil {
329+ entry.Error = s.Err.Error()
330+ }
331+ out[i] = entry
332+ }
333+ return out
334+}
335+
285336// AttachClient registers a new SSE stream for the given client on the
286337// workspace. The stream's deferred cleanup must call DetachClient with
287338// the same arguments to release the claim.
@@ -598,7 +649,7 @@ func validateClientID(id string) (string, error) {
598649
599650func workspaceToProto(ws *Workspace) proto.Workspace {
600651 cfg := ws.Cfg.Config()
601− return proto.Workspace{
652+ out := proto.Workspace{
602653 ID: ws.ID,
603654 Path: ws.Path,
604655 YOLO: ws.Cfg.Overrides().SkipPermissionRequests,
@@ -608,6 +659,10 @@ func workspaceToProto(ws *Workspace) proto.Workspace {
608659 Env: ws.Env,
609660 Version: version.Version,
610661 }
662+ if ws.Skills != nil {
663+ out.Skills = skillStatesToProto(ws.Skills.States())
664+ }
665+ return out
611666}
612667
613668// logFirstWinsMismatch emits a debug line whenever a second
internal/backend/backend_skills_test.gonew file+167
@@ -0,0 +1,167 @@
1+package backend_test
2+
3+import (
4+ "context"
5+ "fmt"
6+ "os"
7+ "path/filepath"
8+ "testing"
9+ "time"
10+
11+ tea "charm.land/bubbletea/v2"
12+ "github.com/charmbracelet/crush/internal/backend"
13+ "github.com/charmbracelet/crush/internal/config"
14+ "github.com/charmbracelet/crush/internal/proto"
15+ "github.com/charmbracelet/crush/internal/pubsub"
16+ "github.com/charmbracelet/crush/internal/skills"
17+ "github.com/google/uuid"
18+ "github.com/stretchr/testify/require"
19+)
20+
21+// TestBackend_WorkspaceSkillsIsolation verifies that skill discovery
22+// state and SSE events are per-workspace, not process-global. Two
23+// workspaces in the same backend process must not see each other's
24+// discoveries (either in their initial snapshot or in subsequent
25+// PublishStates events).
26+func TestBackend_WorkspaceSkillsIsolation(t *testing.T) {
27+ // Isolate all of config.Init's filesystem reads from the host. The
28+ // project-local .agents/skills/<name>/SKILL.md per working dir is
29+ // what we actually want each workspace to see; everything else
30+ // (global skills, XDG dirs, etc.) must be empty/deterministic.
31+ hostHome := t.TempDir()
32+ t.Setenv("HOME", hostHome)
33+ t.Setenv("XDG_CONFIG_HOME", filepath.Join(hostHome, ".config"))
34+ t.Setenv("XDG_DATA_HOME", filepath.Join(hostHome, ".local", "share"))
35+ t.Setenv("XDG_CACHE_HOME", filepath.Join(hostHome, ".cache"))
36+ t.Setenv("CRUSH_SKILLS_DIR", t.TempDir())
37+
38+ // Each workspace gets its own working directory containing a
39+ // distinct project-local skill so the discovery output differs.
40+ wdA := t.TempDir()
41+ wdB := t.TempDir()
42+ writeSkill(t, wdA, "wsa-only-skill", "Workspace A only skill.")
43+ writeSkill(t, wdB, "wsb-only-skill", "Workspace B only skill.")
44+
45+ srvCfg, err := config.Init(wdA, "", false)
46+ require.NoError(t, err)
47+ b := backend.New(t.Context(), srvCfg, nil)
48+
49+ cidA := uuid.New().String()
50+ cidB := uuid.New().String()
51+
52+ wsA, _, err := b.CreateWorkspace(proto.Workspace{
53+ ClientID: cidA,
54+ Path: wdA,
55+ DataDir: filepath.Join(wdA, ".crush"),
56+ })
57+ require.NoError(t, err)
58+ t.Cleanup(func() { _ = b.DeleteWorkspace(wsA.ID, cidA) })
59+
60+ wsB, _, err := b.CreateWorkspace(proto.Workspace{
61+ ClientID: cidB,
62+ Path: wdB,
63+ DataDir: filepath.Join(wdB, ".crush"),
64+ })
65+ require.NoError(t, err)
66+ t.Cleanup(func() { _ = b.DeleteWorkspace(wsB.ID, cidB) })
67+
68+ require.NotNil(t, wsA.Skills, "workspace A must have its own skills.Manager")
69+ require.NotNil(t, wsB.Skills, "workspace B must have its own skills.Manager")
70+ require.NotSame(t, wsA.Skills, wsB.Skills, "managers must be distinct instances per workspace")
71+
72+ // Initial snapshots see each workspace's own filesystem skill, and
73+ // neither sees the other's.
74+ statesA := wsA.Skills.States()
75+ statesB := wsB.Skills.States()
76+ require.True(t, containsSkillName(statesA, "wsa-only-skill"),
77+ "workspace A snapshot missing its own skill")
78+ require.False(t, containsSkillName(statesA, "wsb-only-skill"),
79+ "workspace A snapshot leaked workspace B's skill")
80+ require.True(t, containsSkillName(statesB, "wsb-only-skill"),
81+ "workspace B snapshot missing its own skill")
82+ require.False(t, containsSkillName(statesB, "wsa-only-skill"),
83+ "workspace B snapshot leaked workspace A's skill")
84+
85+ // Subscribe to each workspace's SSE event stream.
86+ ctxA, cancelA := context.WithCancel(t.Context())
87+ t.Cleanup(cancelA)
88+ chA, err := b.SubscribeEvents(ctxA, wsA.ID)
89+ require.NoError(t, err)
90+
91+ ctxB, cancelB := context.WithCancel(t.Context())
92+ t.Cleanup(cancelB)
93+ chB, err := b.SubscribeEvents(ctxB, wsB.ID)
94+ require.NoError(t, err)
95+
96+ // Trigger a republish on workspace A only. The marker name lets us
97+ // distinguish this event from any incidental backend activity.
98+ const marker = "wsa-republish-marker"
99+ wsA.Skills.PublishStates([]*skills.SkillState{
100+ {Name: marker, State: skills.StateNormal},
101+ })
102+
103+ // Workspace A must receive its own event.
104+ require.True(t,
105+ waitForSkillsEvent(t, chA, marker, 2*time.Second),
106+ "workspace A never received its own skills event")
107+
108+ // Workspace B must NOT receive workspace A's event.
109+ require.False(t,
110+ waitForSkillsEvent(t, chB, marker, 250*time.Millisecond),
111+ "workspace B leaked workspace A's skills event")
112+
113+ // And A's published states are now visible on its manager's
114+ // snapshot (verifies PublishStates updates the cache, not just the
115+ // broker).
116+ updatedA := wsA.Skills.States()
117+ require.True(t, containsSkillName(updatedA, marker),
118+ "PublishStates must update Manager.States()")
119+
120+ // B's manager snapshot is untouched.
121+ require.False(t, containsSkillName(wsB.Skills.States(), marker),
122+ "workspace B's Manager.States() leaked workspace A's republish")
123+}
124+
125+func writeSkill(t *testing.T, workingDir, name, desc string) {
126+ t.Helper()
127+ skillDir := filepath.Join(workingDir, ".agents", "skills", name)
128+ require.NoError(t, os.MkdirAll(skillDir, 0o755))
129+ content := fmt.Sprintf("---\nname: %s\ndescription: %s\n---\n%s\n", name, desc, desc)
130+ require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(content), 0o644))
131+}
132+
133+func containsSkillName(states []*skills.SkillState, name string) bool {
134+ for _, s := range states {
135+ if s.Name == name {
136+ return true
137+ }
138+ }
139+ return false
140+}
141+
142+// waitForSkillsEvent drains the given event channel until either a
143+// pubsub.Event[skills.Event] containing a state named marker arrives or
144+// the timeout elapses. Non-skills events on the channel are silently
145+// skipped — the backend fans in many event types and we only care
146+// about skills here.
147+func waitForSkillsEvent(t *testing.T, ch <-chan pubsub.Event[tea.Msg], marker string, timeout time.Duration) bool {
148+ t.Helper()
149+ deadline := time.After(timeout)
150+ for {
151+ select {
152+ case ev, ok := <-ch:
153+ if !ok {
154+ return false
155+ }
156+ se, ok := ev.Payload.(pubsub.Event[skills.Event])
157+ if !ok {
158+ continue
159+ }
160+ if containsSkillName(se.Payload.States, marker) {
161+ return true
162+ }
163+ case <-deadline:
164+ return false
165+ }
166+ }
167+}
internal/client/proto.go+5−10
@@ -199,6 +199,10 @@ func (c *Client) SubscribeEvents(ctx context.Context, id string) (<-chan any, er
199199 var e pubsub.Event[proto.ConfigChanged]
200200 _ = json.Unmarshal(p.Payload, &e)
201201 sendEvent(ctx, events, e)
202+ case pubsub.PayloadTypeSkillsEvent:
203+ var e pubsub.Event[proto.SkillsEvent]
204+ _ = json.Unmarshal(p.Payload, &e)
205+ sendEvent(ctx, events, e)
202206 default:
203207 slog.Warn("Unknown event type", "type", p.Type)
204208 continue
@@ -366,19 +370,10 @@ func (c *Client) UpdateAgent(ctx context.Context, id string) error {
366370
367371// SendMessage sends a message to the agent for a workspace.
368372func (c *Client) SendMessage(ctx context.Context, id string, sessionID, prompt string, attachments ...message.Attachment) error {
369− protoAttachments := make([]proto.Attachment, len(attachments))
370− for i, a := range attachments {
371− protoAttachments[i] = proto.Attachment{
372− FilePath: a.FilePath,
373− FileName: a.FileName,
374− MimeType: a.MimeType,
375− Content: a.Content,
376− }
377− }
378373 rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/agent", id), nil, jsonBody(proto.AgentMessage{
379374 SessionID: sessionID,
380375 Prompt: prompt,
381− Attachments: protoAttachments,
376+ Attachments: proto.AttachmentsFromMessage(attachments),
382377 }), http.Header{"Content-Type": []string{"application/json"}})
383378 if err != nil {
384379 return fmt.Errorf("failed to send message to agent: %w", err)
internal/cmd/root.go+29−1
@@ -34,6 +34,7 @@ import (
3434 "github.com/charmbracelet/crush/internal/proto"
3535 "github.com/charmbracelet/crush/internal/server"
3636 "github.com/charmbracelet/crush/internal/session"
37+ "github.com/charmbracelet/crush/internal/skills"
3738 "github.com/charmbracelet/crush/internal/ui/common"
3839 ui "github.com/charmbracelet/crush/internal/ui/model"
3940 "github.com/charmbracelet/crush/internal/version"
@@ -286,7 +287,14 @@ func setupLocalWorkspace(cmd *cobra.Command) (workspace.Workspace, func(), error
286287 logFile := filepath.Join(cfg.Options.DataDirectory, "logs", "crush.log")
287288 crushlog.Setup(logFile, debug)
288289
289− appInstance, err := app.New(ctx, conn, store)
290+ // Discover skills once before app.New. Local mode hosts a single
291+ // workspace per process, so WithGlobalMirror keeps the package
292+ // globals (which the TUI reads via skills.GetLatestStates) in sync
293+ // with the manager.
294+ allSkills, activeSkills, skillStates := skills.DiscoverFromConfig(localSkillsDiscoveryConfig(store))
295+ skillsMgr := skills.NewManager(allSkills, activeSkills, skillStates, skills.WithGlobalMirror())
296+
297+ appInstance, err := app.New(ctx, conn, store, skillsMgr)
290298 if err != nil {
291299 _ = conn.Close()
292300 slog.Error("Failed to create app instance", "error", err)
@@ -302,6 +310,26 @@ func setupLocalWorkspace(cmd *cobra.Command) (workspace.Workspace, func(), error
302310 return ws, cleanup, nil
303311}
304312
313+// localSkillsDiscoveryConfig adapts a *config.ConfigStore to the inputs
314+// skills.DiscoverFromConfig expects.
315+func localSkillsDiscoveryConfig(store *config.ConfigStore) skills.DiscoveryConfig {
316+ opts := store.Config().Options
317+ var paths, disabled []string
318+ if opts != nil {
319+ paths = opts.SkillsPaths
320+ disabled = opts.DisabledSkills
321+ }
322+ var resolver func(string) (string, error)
323+ if r := store.Resolver(); r != nil {
324+ resolver = r.ResolveValue
325+ }
326+ return skills.DiscoveryConfig{
327+ SkillsPaths: paths,
328+ DisabledSkills: disabled,
329+ Resolver: resolver,
330+ }
331+}
332+
305333// setupClientServerWorkspace connects to a server process and wraps the
306334// result in a ClientWorkspace.
307335func setupClientServerWorkspace(cmd *cobra.Command) (workspace.Workspace, func(), error) {
internal/commands/commands.go+67
@@ -12,6 +12,7 @@ import (
1212 "github.com/charmbracelet/crush/internal/agent/tools/mcp"
1313 "github.com/charmbracelet/crush/internal/config"
1414 "github.com/charmbracelet/crush/internal/home"
15+ "github.com/charmbracelet/crush/internal/skills"
1516)
1617
1718var namedArgPattern = regexp.MustCompile(`\$([A-Z][A-Z0-9_]*)`)
@@ -45,6 +46,8 @@ type CustomCommand struct {
4546 Name string
4647 Content string
4748 Arguments []Argument
49+ // Skill is set when this command represents a user-invocable skill
50+ Skill *skills.Skill
4851}
4952
5053type commandSource struct {
@@ -58,6 +61,70 @@ func LoadCustomCommands(cfg *config.Config) ([]CustomCommand, error) {
5861 return loadAll(buildCommandSources(cfg))
5962}
6063
64+// LoadSkillCommands loads user-invocable skills as custom commands.
65+func LoadSkillCommands() []CustomCommand {
66+ var commands []CustomCommand
67+
68+ // Load from global skills directories with "user:" prefix
69+ for _, dir := range config.GlobalSkillsDirs() {
70+ commands = append(commands, loadInvocableSkillsFromDir(dir, userCommandPrefix)...)
71+ }
72+
73+ return commands
74+}
75+
76+// LoadProjectSkillCommands loads user-invocable skills from project directories as custom commands.
77+func LoadProjectSkillCommands(workingDir string) []CustomCommand {
78+ var commands []CustomCommand
79+
80+ // Load from project skills directories with "project:" prefix
81+ for _, dir := range config.ProjectSkillsDir(workingDir) {
82+ commands = append(commands, loadInvocableSkillsFromDir(dir, projectCommandPrefix)...)
83+ }
84+
85+ return commands
86+}
87+
88+func loadInvocableSkillsFromDir(dir, prefix string) []CustomCommand {
89+ if _, err := os.Stat(dir); os.IsNotExist(err) {
90+ return nil
91+ }
92+
93+ var commands []CustomCommand
94+
95+ entries, err := os.ReadDir(dir)
96+ if err != nil {
97+ return nil
98+ }
99+
100+ for _, entry := range entries {
101+ if !entry.IsDir() {
102+ continue
103+ }
104+
105+ skillPath := filepath.Join(dir, entry.Name(), skills.SkillFileName)
106+ skill, err := skills.Parse(skillPath)
107+ if err != nil {
108+ continue
109+ }
110+
111+ if !skill.UserInvocable {
112+ continue
113+ }
114+
115+ name := prefix + skill.Name
116+ commands = append(commands, CustomCommand{
117+ ID: name,
118+ Name: name,
119+ Content: skill.Instructions,
120+ Arguments: nil,
121+ Skill: skill,
122+ })
123+ }
124+
125+ return commands
126+}
127+
61128// LoadMCPPrompts loads custom commands from available MCP servers.
62129func LoadMCPPrompts() ([]MCPPrompt, error) {
63130 var commands []MCPPrompt
internal/permission/permission.go+8−6
@@ -6,6 +6,7 @@ import (
66 "path/filepath"
77 "slices"
88 "sync"
9+ "sync/atomic"
910
1011 "github.com/charmbracelet/crush/internal/csync"
1112 "github.com/charmbracelet/crush/internal/pubsub"
@@ -100,7 +101,7 @@ type permissionService struct {
100101 pendingRequests *csync.Map[string, chan bool]
101102 autoApproveSessions map[string]bool
102103 autoApproveSessionsMu sync.RWMutex
103− skip bool
104+ skip atomic.Bool
104105 allowedTools []string
105106
106107 // used to make sure we only process one request at a time
@@ -178,7 +179,7 @@ func (s *permissionService) Deny(permission PermissionRequest) bool {
178179}
179180
180181func (s *permissionService) Request(ctx context.Context, opts CreatePermissionRequest) (bool, error) {
181− if s.skip {
182+ if s.skip.Load() {
182183 return true, nil
183184 }
184185
@@ -287,22 +288,23 @@ func (s *permissionService) SubscribeNotifications(ctx context.Context) <-chan p
287288}
288289
289290func (s *permissionService) SetSkipRequests(skip bool) {
290− s.skip = skip
291+ s.skip.Store(skip)
291292}
292293
293294func (s *permissionService) SkipRequests() bool {
294− return s.skip
295+ return s.skip.Load()
295296}
296297
297298func NewPermissionService(workingDir string, skip bool, allowedTools []string) Service {
298− return &permissionService{
299+ svc := &permissionService{
299300 Broker: pubsub.NewBroker[PermissionRequest](),
300301 notificationBroker: pubsub.NewBroker[PermissionNotification](),
301302 workingDir: workingDir,
302303 sessionPermissions: csync.NewMap[PermissionKey, bool](),
303304 autoApproveSessions: make(map[string]bool),
304− skip: skip,
305305 allowedTools: allowedTools,
306306 pendingRequests: csync.NewMap[string, chan bool](),
307307 }
308+ svc.skip.Store(skip)
309+ return svc
308310}
internal/permission/permission_test.go+15
@@ -81,6 +81,21 @@ func TestPermissionService_AllowedCommands(t *testing.T) {
8181 }
8282}
8383
84+func TestSkipRace(t *testing.T) {
85+ svc := NewPermissionService("/tmp", false, nil)
86+ var wg sync.WaitGroup
87+ wg.Add(2)
88+ go func() {
89+ defer wg.Done()
90+ svc.SetSkipRequests(true)
91+ }()
92+ go func() {
93+ defer wg.Done()
94+ svc.SkipRequests()
95+ }()
96+ wg.Wait()
97+}
98+
8499func TestPermissionService_SkipMode(t *testing.T) {
85100 service := NewPermissionService("/tmp", true, []string{})
86101
internal/proto/message.go+42
@@ -8,6 +8,7 @@ import (
88 "time"
99
1010 "charm.land/catwalk/pkg/catwalk"
11+ "github.com/charmbracelet/crush/internal/message"
1112)
1213
1314// CreateMessageParams represents parameters for creating a message.
@@ -621,6 +622,47 @@ type Attachment struct {
621622 Content []byte `json:"content"`
622623}
623624
625+// ToMessage converts a proto Attachment to a [message.Attachment].
626+func (a Attachment) ToMessage() message.Attachment {
627+ return message.Attachment{
628+ FilePath: a.FilePath,
629+ FileName: a.FileName,
630+ MimeType: a.MimeType,
631+ Content: a.Content,
632+ }
633+}
634+
635+// AttachmentFromMessage converts a [message.Attachment] to a proto
636+// Attachment.
637+func AttachmentFromMessage(a message.Attachment) Attachment {
638+ return Attachment{
639+ FilePath: a.FilePath,
640+ FileName: a.FileName,
641+ MimeType: a.MimeType,
642+ Content: a.Content,
643+ }
644+}
645+
646+// AttachmentsToMessage converts a slice of proto Attachments to a slice
647+// of [message.Attachment].
648+func AttachmentsToMessage(as []Attachment) []message.Attachment {
649+ out := make([]message.Attachment, len(as))
650+ for i, a := range as {
651+ out[i] = a.ToMessage()
652+ }
653+ return out
654+}
655+
656+// AttachmentsFromMessage converts a slice of [message.Attachment] to a
657+// slice of proto Attachments.
658+func AttachmentsFromMessage(as []message.Attachment) []Attachment {
659+ out := make([]Attachment, len(as))
660+ for i, a := range as {
661+ out[i] = AttachmentFromMessage(a)
662+ }
663+ return out
664+}
665+
624666// MarshalJSON implements the [json.Marshaler] interface.
625667func (a Attachment) MarshalJSON() ([]byte, error) {
626668 type Alias Attachment
internal/proto/proto.go+4
@@ -22,6 +22,10 @@ type Workspace struct {
2222 ClientID string `json:"client_id,omitempty"`
2323 Config *config.Config `json:"config,omitempty"`
2424 Env []string `json:"env,omitempty"`
25+ // Skills carries the snapshot of skill discovery state at workspace
26+ // creation time. Subsequent updates flow through the SSE event
27+ // stream.
28+ Skills []SkillState `json:"skills,omitempty"`
2529}
2630
2731// Error represents an error response.
internal/proto/skills.gonew file+28
@@ -0,0 +1,28 @@
1+package proto
2+
3+// SkillDiscoveryState mirrors skills.DiscoveryState across the wire.
4+// Values must stay in sync with internal/skills.DiscoveryState; do not
5+// reorder without a coordinated server/client bump.
6+type SkillDiscoveryState int
7+
8+const (
9+ // SkillStateNormal indicates the skill was parsed and validated
10+ // successfully.
11+ SkillStateNormal SkillDiscoveryState = iota
12+ // SkillStateError indicates discovery encountered a scan/parse/validate
13+ // error.
14+ SkillStateError
15+)
16+
17+// SkillState is the wire representation of skills.SkillState.
18+type SkillState struct {
19+ Name string `json:"name"`
20+ Path string `json:"path"`
21+ State SkillDiscoveryState `json:"state"`
22+ Error string `json:"error,omitempty"`
23+}
24+
25+// SkillsEvent is the wire representation of skills.Event.
26+type SkillsEvent struct {
27+ States []SkillState `json:"states"`
28+}
internal/pubsub/events.go+1
@@ -25,6 +25,7 @@ const (
2525 PayloadTypeFile PayloadType = "file"
2626 PayloadTypeAgentEvent PayloadType = "agent_event"
2727 PayloadTypeConfigChanged PayloadType = "config_changed"
28+ PayloadTypeSkillsEvent PayloadType = "skills_event"
2829)
2930
3031// Payload wraps a discriminated JSON payload with a type tag.
internal/server/events.go+27
@@ -15,6 +15,7 @@ import (
1515 "github.com/charmbracelet/crush/internal/proto"
1616 "github.com/charmbracelet/crush/internal/pubsub"
1717 "github.com/charmbracelet/crush/internal/session"
18+ "github.com/charmbracelet/crush/internal/skills"
1819)
1920
2021// wrapEvent converts a raw tea.Msg (a pubsub.Event[T] from the app
@@ -94,6 +95,11 @@ func wrapEvent(ev any) *pubsub.Payload {
9495 })
9596 case pubsub.Event[proto.ConfigChanged]:
9697 return envelope(pubsub.PayloadTypeConfigChanged, e)
98+ case pubsub.Event[skills.Event]:
99+ return envelope(pubsub.PayloadTypeSkillsEvent, pubsub.Event[proto.SkillsEvent]{
100+ Type: e.Type,
101+ Payload: skillsEventToProto(e.Payload),
102+ })
97103 default:
98104 slog.Warn("Unrecognized event type for SSE wrapping", "type", fmt.Sprintf("%T", ev))
99105 return nil
@@ -250,6 +256,27 @@ func messageToProto(m message.Message) proto.Message {
250256 return msg
251257}
252258
259+// skillsEventToProto converts a skills.Event into its wire form. Errors
260+// are flattened to strings because error does not round-trip over JSON.
261+func skillsEventToProto(e skills.Event) proto.SkillsEvent {
262+ if len(e.States) == 0 {
263+ return proto.SkillsEvent{}
264+ }
265+ out := proto.SkillsEvent{States: make([]proto.SkillState, len(e.States))}
266+ for i, s := range e.States {
267+ entry := proto.SkillState{
268+ Name: s.Name,
269+ Path: s.Path,
270+ State: proto.SkillDiscoveryState(s.State),
271+ }
272+ if s.Err != nil {
273+ entry.Error = s.Err.Error()
274+ }
275+ out.States[i] = entry
276+ }
277+ return out
278+}
279+
253280func messagesToProto(msgs []message.Message) []proto.Message {
254281 out := make([]proto.Message, len(msgs))
255282 for i, m := range msgs {
internal/server/events_test.go+39
@@ -1,10 +1,14 @@
11package server
22
33import (
4+ "encoding/json"
5+ "errors"
46 "testing"
57
68 "github.com/charmbracelet/crush/internal/message"
79 "github.com/charmbracelet/crush/internal/proto"
10+ "github.com/charmbracelet/crush/internal/pubsub"
11+ "github.com/charmbracelet/crush/internal/skills"
812 "github.com/stretchr/testify/require"
913)
1014
@@ -44,3 +48,38 @@ func TestMessageToProtoToolResult(t *testing.T) {
4448 require.Equal(t, `{"file_path":"/tmp/x","content":"hi"}`, tr.Metadata)
4549 require.False(t, tr.IsError)
4650}
51+
52+// TestSkillsEventToProto_RoundTrip verifies that a pubsub.Event[skills.Event]
53+// can be wrapped, marshaled, and unmarshaled back through the SSE
54+// envelope without losing state values or error messages.
55+func TestSkillsEventToProto_RoundTrip(t *testing.T) {
56+ t.Parallel()
57+
58+ src := pubsub.Event[skills.Event]{
59+ Type: pubsub.UpdatedEvent,
60+ Payload: skills.Event{
61+ States: []*skills.SkillState{
62+ {Name: "ok", Path: "/p/ok", State: skills.StateNormal},
63+ {Name: "broken", Path: "/p/broken", State: skills.StateError, Err: errors.New("bad frontmatter")},
64+ },
65+ },
66+ }
67+
68+ env := wrapEvent(src)
69+ require.NotNil(t, env)
70+ require.Equal(t, pubsub.PayloadTypeSkillsEvent, env.Type)
71+
72+ var decoded pubsub.Event[proto.SkillsEvent]
73+ require.NoError(t, json.Unmarshal(env.Payload, &decoded))
74+ require.Equal(t, pubsub.UpdatedEvent, decoded.Type)
75+ require.Len(t, decoded.Payload.States, 2)
76+
77+ require.Equal(t, "ok", decoded.Payload.States[0].Name)
78+ require.Equal(t, "/p/ok", decoded.Payload.States[0].Path)
79+ require.Equal(t, proto.SkillStateNormal, decoded.Payload.States[0].State)
80+ require.Empty(t, decoded.Payload.States[0].Error)
81+
82+ require.Equal(t, "broken", decoded.Payload.States[1].Name)
83+ require.Equal(t, proto.SkillStateError, decoded.Payload.States[1].State)
84+ require.Equal(t, "bad frontmatter", decoded.Payload.States[1].Error)
85+}
internal/skills/builtin/crush-config/SKILL.md+31
@@ -211,6 +211,37 @@ reviewed.
211211
212212Other options: `context_paths`, `progress`, `disable_notifications`, `disable_auto_summarize`, `disable_metrics`, `disable_provider_auto_update`, `disable_default_providers`, `data_directory`, `initialize_as`.
213213
214+## User-Invocable Skills
215+
216+Skills can be made invocable as commands from the commands palette. Add `user-invocable: true` to the skill's YAML frontmatter:
217+
218+```yaml
219+---
220+name: my-skill
221+description: A skill that can be invoked as a command.
222+user-invocable: true
223+---
224+```
225+
226+User-invocable skills appear in the commands palette with a prefix:
227+- Skills from global directories: `user:skill-name`
228+- Skills from project directories: `project:skill-name`
229+
230+When invoked, the skill's instructions are loaded into the conversation context.
231+
232+To prevent the model from auto-triggering a skill (while still allowing user invocation), add `disable-model-invocation: true`:
233+
234+```yaml
235+---
236+name: my-skill
237+description: Only invocable by users, not the model.
238+user-invocable: true
239+disable-model-invocation: true
240+---
241+```
242+
243+Skills with `disable-model-invocation` won't appear in the model's available skills list but can still be invoked manually by users.
244+
214245## Hooks
215246
216247Hooks are user-defined shell commands that fire on agent events. Currently only `PreToolUse` is supported, which runs before a tool is executed.
internal/skills/manager.gonew file+180
@@ -0,0 +1,180 @@
1+package skills
2+
3+import (
4+ "context"
5+ "slices"
6+ "strings"
7+ "sync"
8+
9+ "github.com/charmbracelet/crush/internal/home"
10+ "github.com/charmbracelet/crush/internal/pubsub"
11+)
12+
13+// Manager owns per-workspace skill discovery state: the latest discovery
14+// snapshot, the full skill metadata (with Instructions) for the
15+// coordinator, and a pubsub broker for change events. There is exactly
16+// one Manager per workspace.
17+//
18+// Package-level helpers (GetLatestStates, SetLatestStates,
19+// PublishStates, SubscribeEvents) are preserved for callers that share a
20+// process with the TUI. To bridge a Manager to those globals, construct
21+// it with WithGlobalMirror. Only do this when the process hosts a single
22+// workspace (local mode or a client process); the backend server hosts
23+// multiple workspaces concurrently and must not enable mirroring.
24+type Manager struct {
25+ mu sync.RWMutex
26+ allSkills []*Skill
27+ activeSkills []*Skill
28+ states []*SkillState
29+
30+ broker *pubsub.Broker[Event]
31+ globalMirror bool
32+}
33+
34+// ManagerOption configures a Manager at construction time.
35+type ManagerOption func(*Manager)
36+
37+// WithGlobalMirror causes the manager to forward SetLatestStates and
38+// PublishStates calls to the package-level cache and broker. Only safe
39+// when the process hosts at most one Manager (e.g. local mode or the
40+// client process).
41+func WithGlobalMirror() ManagerOption {
42+ return func(m *Manager) {
43+ m.globalMirror = true
44+ }
45+}
46+
47+// NewManager constructs a workspace-scoped Manager with the given
48+// pre-computed discovery results. The slices are stored as-is; callers
49+// should not mutate them afterwards.
50+func NewManager(allSkills, activeSkills []*Skill, states []*SkillState, opts ...ManagerOption) *Manager {
51+ m := &Manager{
52+ allSkills: allSkills,
53+ activeSkills: activeSkills,
54+ states: states,
55+ broker: pubsub.NewBroker[Event](),
56+ }
57+ for _, opt := range opts {
58+ opt(m)
59+ }
60+ if m.globalMirror {
61+ SetLatestStates(states)
62+ }
63+ return m
64+}
65+
66+// AllSkills returns the deduplicated list of all discovered skills.
67+func (m *Manager) AllSkills() []*Skill {
68+ m.mu.RLock()
69+ defer m.mu.RUnlock()
70+ return m.allSkills
71+}
72+
73+// ActiveSkills returns the post-filter list of active skills (after
74+// removing disabled entries).
75+func (m *Manager) ActiveSkills() []*Skill {
76+ m.mu.RLock()
77+ defer m.mu.RUnlock()
78+ return m.activeSkills
79+}
80+
81+// States returns a clone of the latest discovery state snapshot.
82+func (m *Manager) States() []*SkillState {
83+ m.mu.RLock()
84+ defer m.mu.RUnlock()
85+ return cloneStates(m.states)
86+}
87+
88+// SetLatestStates updates the manager's cached discovery snapshot.
89+func (m *Manager) SetLatestStates(states []*SkillState) {
90+ m.mu.Lock()
91+ m.states = cloneStates(states)
92+ m.mu.Unlock()
93+ if m.globalMirror {
94+ SetLatestStates(states)
95+ }
96+}
97+
98+// PublishStates updates the manager's cached snapshot and publishes a
99+// discovery event to subscribers. Callers should not call
100+// SetLatestStates separately — PublishStates is the single mutation
101+// point, keeping Manager.States(), workspaceToProto, and (when
102+// WithGlobalMirror is set) skills.GetLatestStates consistent with what
103+// subscribers observe.
104+func (m *Manager) PublishStates(states []*SkillState) {
105+ m.mu.Lock()
106+ m.states = cloneStates(states)
107+ m.mu.Unlock()
108+ if m.globalMirror {
109+ SetLatestStates(states)
110+ }
111+ m.broker.Publish(pubsub.UpdatedEvent, Event{States: cloneStates(states)})
112+ if m.globalMirror {
113+ PublishStates(states)
114+ }
115+}
116+
117+// SubscribeEvents returns a channel of discovery events for the
118+// manager's workspace.
119+func (m *Manager) SubscribeEvents(ctx context.Context) <-chan pubsub.Event[Event] {
120+ return m.broker.Subscribe(ctx)
121+}
122+
123+// Shutdown releases broker resources.
124+func (m *Manager) Shutdown() {
125+ if m.broker != nil {
126+ m.broker.Shutdown()
127+ }
128+}
129+
130+// DiscoverFromConfig walks the embedded builtin FS and every path in
131+// cfg.Options.SkillsPaths (after home / env expansion), then dedups and
132+// filters by cfg.Options.DisabledSkills. It returns the three slices the
133+// rest of the system needs:
134+//
135+// - allSkills: deduplicated, pre-filter (includes disabled).
136+// - activeSkills: post-filter (DisabledSkills removed).
137+// - states: per-file discovery outcome for diagnostics/UI.
138+func DiscoverFromConfig(cfg DiscoveryConfig) (allSkills, activeSkills []*Skill, states []*SkillState) {
139+ builtin, builtinStates := DiscoverBuiltinWithStates()
140+ discovered := append([]*Skill(nil), builtin...)
141+
142+ var userStates []*SkillState
143+ var userPaths []string
144+ if len(cfg.SkillsPaths) > 0 {
145+ userPaths = make([]string, 0, len(cfg.SkillsPaths))
146+ for _, pth := range cfg.SkillsPaths {
147+ expanded := home.Long(pth)
148+ if strings.HasPrefix(expanded, "$") && cfg.Resolver != nil {
149+ if resolved, err := cfg.Resolver(expanded); err == nil {
150+ expanded = resolved
151+ }
152+ }
153+ userPaths = append(userPaths, expanded)
154+ }
155+ var userSkills []*Skill
156+ userSkills, userStates = DiscoverWithStates(userPaths)
157+ discovered = append(discovered, userSkills...)
158+ }
159+
160+ allSkills = Deduplicate(discovered)
161+ activeSkills = Filter(allSkills, cfg.DisabledSkills)
162+
163+ allStates := append([]*SkillState(nil), builtinStates...)
164+ allStates = append(allStates, userStates...)
165+ allStates = DeduplicateStates(allStates)
166+ slices.SortStableFunc(allStates, func(a, b *SkillState) int {
167+ return strings.Compare(strings.ToLower(a.Path), strings.ToLower(b.Path))
168+ })
169+ return allSkills, activeSkills, allStates
170+}
171+
172+// DiscoveryConfig contains the inputs DiscoverFromConfig needs. Using a
173+// dedicated struct (rather than importing internal/config) keeps the
174+// skills package's dependency graph small.
175+type DiscoveryConfig struct {
176+ SkillsPaths []string
177+ DisabledSkills []string
178+ // Resolver expands $VAR-style references in paths. May be nil.
179+ Resolver func(string) (string, error)
180+}
internal/skills/manager_test.gonew file+249
@@ -0,0 +1,249 @@
1+package skills
2+
3+import (
4+ "context"
5+ "errors"
6+ "os"
7+ "path/filepath"
8+ "testing"
9+ "time"
10+
11+ "github.com/stretchr/testify/require"
12+)
13+
14+func TestManager_NoGlobalMirrorByDefault(t *testing.T) {
15+ // Not parallel - touches package-level cache.
16+ prev := GetLatestStates()
17+ t.Cleanup(func() { SetLatestStates(prev) })
18+
19+ SetLatestStates(nil)
20+
21+ mgrA := NewManager(nil, nil, []*SkillState{{Name: "a", State: StateNormal}})
22+ mgrB := NewManager(nil, nil, []*SkillState{{Name: "b", State: StateNormal}})
23+
24+ mgrA.PublishStates(mgrA.States())
25+ mgrB.PublishStates(mgrB.States())
26+
27+ // Without WithGlobalMirror, the package-level cache must not be
28+ // touched by manager construction or PublishStates calls.
29+ require.Nil(t, GetLatestStates(), "package global must remain untouched")
30+ require.Equal(t, "a", mgrA.States()[0].Name)
31+ require.Equal(t, "b", mgrB.States()[0].Name)
32+}
33+
34+func TestManager_GlobalMirror(t *testing.T) {
35+ // Not parallel - touches package-level cache.
36+ prev := GetLatestStates()
37+ t.Cleanup(func() { SetLatestStates(prev) })
38+
39+ SetLatestStates(nil)
40+
41+ mgr := NewManager(nil, nil, []*SkillState{{Name: "x", State: StateNormal}}, WithGlobalMirror())
42+
43+ got := GetLatestStates()
44+ require.Len(t, got, 1)
45+ require.Equal(t, "x", got[0].Name)
46+
47+ // PublishStates with mirror enabled forwards to the global cache.
48+ mgr.SetLatestStates([]*SkillState{{Name: "y", State: StateNormal}})
49+ got = GetLatestStates()
50+ require.Len(t, got, 1)
51+ require.Equal(t, "y", got[0].Name)
52+}
53+
54+func TestManager_PublishStatesUpdatesCache(t *testing.T) {
55+ // Not parallel - exercises WithGlobalMirror, which touches the
56+ // package-level cache.
57+ prev := GetLatestStates()
58+ t.Cleanup(func() { SetLatestStates(prev) })
59+
60+ SetLatestStates(nil)
61+
62+ mgr := NewManager(nil, nil, []*SkillState{{Name: "old"}}, WithGlobalMirror())
63+ t.Cleanup(mgr.Shutdown)
64+
65+ // PublishStates must update every observable snapshot, not just the
66+ // SSE subscribers: Manager.States() (used by workspaceToProto on
67+ // the backend) and skills.GetLatestStates() (read by the TUI on the
68+ // client process and in local mode) must reflect the new value.
69+ mgr.PublishStates([]*SkillState{{Name: "new"}})
70+
71+ got := mgr.States()
72+ require.Len(t, got, 1)
73+ require.Equal(t, "new", got[0].Name)
74+
75+ cached := GetLatestStates()
76+ require.Len(t, cached, 1)
77+ require.Equal(t, "new", cached[0].Name)
78+}
79+
80+func TestManager_SubscribeReceivesPublishedStates(t *testing.T) {
81+ t.Parallel()
82+
83+ mgr := NewManager(nil, nil, nil)
84+ t.Cleanup(mgr.Shutdown)
85+
86+ ctx, cancel := context.WithCancel(context.Background())
87+ t.Cleanup(cancel)
88+ ch := mgr.SubscribeEvents(ctx)
89+
90+ want := []*SkillState{{Name: "k", State: StateNormal}}
91+ go mgr.PublishStates(want)
92+
93+ select {
94+ case ev := <-ch:
95+ require.Equal(t, "k", ev.Payload.States[0].Name)
96+ case <-time.After(2 * time.Second):
97+ t.Fatal("timed out waiting for manager event")
98+ }
99+}
100+
101+func TestManager_ConcurrentWorkspacesAreIsolated(t *testing.T) {
102+ t.Parallel()
103+
104+ // Two managers without WithGlobalMirror should not see each other's
105+ // events; this models the multi-workspace backend.
106+ mgrA := NewManager(nil, nil, nil)
107+ mgrB := NewManager(nil, nil, nil)
108+ t.Cleanup(mgrA.Shutdown)
109+ t.Cleanup(mgrB.Shutdown)
110+
111+ ctx, cancel := context.WithCancel(context.Background())
112+ t.Cleanup(cancel)
113+ chA := mgrA.SubscribeEvents(ctx)
114+ chB := mgrB.SubscribeEvents(ctx)
115+
116+ go mgrA.PublishStates([]*SkillState{{Name: "from-a"}})
117+
118+ select {
119+ case ev := <-chA:
120+ require.Equal(t, "from-a", ev.Payload.States[0].Name)
121+ case <-time.After(2 * time.Second):
122+ t.Fatal("workspace A never received its own event")
123+ }
124+
125+ select {
126+ case ev := <-chB:
127+ t.Fatalf("workspace B received workspace A's event: %v", ev)
128+ case <-time.After(100 * time.Millisecond):
129+ // Expected — B's stream is isolated.
130+ }
131+}
132+
133+func TestDiscoverFromConfig(t *testing.T) {
134+ t.Parallel()
135+
136+ tmp := t.TempDir()
137+ skillDir := filepath.Join(tmp, "custom-skill")
138+ require.NoError(t, os.MkdirAll(skillDir, 0o755))
139+ require.NoError(t, os.WriteFile(
140+ filepath.Join(skillDir, SkillFileName),
141+ []byte("---\nname: custom-skill\ndescription: A custom skill for tests.\n---\nDo a thing.\n"),
142+ 0o644,
143+ ))
144+
145+ allSkills, activeSkills, states := DiscoverFromConfig(DiscoveryConfig{
146+ SkillsPaths: []string{tmp},
147+ DisabledSkills: nil,
148+ })
149+
150+ // Builtins plus our one custom skill.
151+ require.NotEmpty(t, allSkills)
152+ require.NotEmpty(t, activeSkills)
153+ require.GreaterOrEqual(t, len(allSkills), 2)
154+ require.GreaterOrEqual(t, len(activeSkills), 2)
155+
156+ // The custom skill is present with full Instructions populated, so
157+ // the coordinator can render system prompts without re-walking the
158+ // filesystem.
159+ var custom *Skill
160+ for _, s := range allSkills {
161+ if s.Name == "custom-skill" {
162+ custom = s
163+ break
164+ }
165+ }
166+ require.NotNil(t, custom)
167+ require.NotEmpty(t, custom.Instructions, "DiscoverFromConfig must return Skill.Instructions")
168+
169+ // State snapshot includes the custom skill too.
170+ foundCustom := false
171+ for _, s := range states {
172+ if s.Name == "custom-skill" {
173+ foundCustom = true
174+ require.Equal(t, StateNormal, s.State)
175+ }
176+ }
177+ require.True(t, foundCustom, "states slice should include the custom skill")
178+}
179+
180+func TestDiscoverFromConfig_DisabledFiltered(t *testing.T) {
181+ t.Parallel()
182+
183+ tmp := t.TempDir()
184+ skillDir := filepath.Join(tmp, "off-skill")
185+ require.NoError(t, os.MkdirAll(skillDir, 0o755))
186+ require.NoError(t, os.WriteFile(
187+ filepath.Join(skillDir, SkillFileName),
188+ []byte("---\nname: off-skill\ndescription: Should be filtered.\n---\nx\n"),
189+ 0o644,
190+ ))
191+
192+ allSkills, activeSkills, states := DiscoverFromConfig(DiscoveryConfig{
193+ SkillsPaths: []string{tmp},
194+ DisabledSkills: []string{"off-skill"},
195+ })
196+
197+ // All discovered: yes; active: no.
198+ hasInAll := false
199+ for _, s := range allSkills {
200+ if s.Name == "off-skill" {
201+ hasInAll = true
202+ }
203+ }
204+ require.True(t, hasInAll, "DisabledSkills must not be removed from allSkills")
205+
206+ for _, s := range activeSkills {
207+ require.NotEqual(t, "off-skill", s.Name, "DisabledSkills must be removed from activeSkills")
208+ }
209+
210+ // State snapshot still carries discovered entries (UI re-applies filter).
211+ hasInStates := false
212+ for _, s := range states {
213+ if s.Name == "off-skill" {
214+ hasInStates = true
215+ }
216+ }
217+ require.True(t, hasInStates)
218+}
219+
220+func TestDiscoverFromConfig_Resolver(t *testing.T) {
221+ t.Parallel()
222+
223+ tmp := t.TempDir()
224+ skillDir := filepath.Join(tmp, "envvar-skill")
225+ require.NoError(t, os.MkdirAll(skillDir, 0o755))
226+ require.NoError(t, os.WriteFile(
227+ filepath.Join(skillDir, SkillFileName),
228+ []byte("---\nname: envvar-skill\ndescription: Env-resolved.\n---\nx\n"),
229+ 0o644,
230+ ))
231+
232+ allSkills, _, _ := DiscoverFromConfig(DiscoveryConfig{
233+ SkillsPaths: []string{"$CUSTOM_SKILLS_DIR"},
234+ Resolver: func(s string) (string, error) {
235+ if s == "$CUSTOM_SKILLS_DIR" {
236+ return tmp, nil
237+ }
238+ return s, errors.New("unknown")
239+ },
240+ })
241+
242+ found := false
243+ for _, s := range allSkills {
244+ if s.Name == "envvar-skill" {
245+ found = true
246+ }
247+ }
248+ require.True(t, found, "DiscoverFromConfig must expand $VAR via Resolver")
249+}
internal/skills/skills.go+30−9
@@ -36,15 +36,17 @@ var (
3636
3737// Skill represents a parsed SKILL.md file.
3838type Skill struct {
39− Name string `yaml:"name" json:"name"`
40− Description string `yaml:"description" json:"description"`
41− License string `yaml:"license,omitempty" json:"license,omitempty"`
42− Compatibility string `yaml:"compatibility,omitempty" json:"compatibility,omitempty"`
43− Metadata map[string]string `yaml:"metadata,omitempty" json:"metadata,omitempty"`
44− Instructions string `yaml:"-" json:"instructions"`
45− Path string `yaml:"-" json:"path"`
46− SkillFilePath string `yaml:"-" json:"skill_file_path"`
47− Builtin bool `yaml:"-" json:"builtin"`
39+ Name string `yaml:"name" json:"name"`
40+ Description string `yaml:"description" json:"description"`
41+ UserInvocable bool `yaml:"user-invocable" json:"user_invocable"`
42+ DisableModelInvocation bool `yaml:"disable-model-invocation" json:"disable_model_invocation"`
43+ License string `yaml:"license,omitempty" json:"license,omitempty"`
44+ Compatibility string `yaml:"compatibility,omitempty" json:"compatibility,omitempty"`
45+ Metadata map[string]string `yaml:"metadata,omitempty" json:"metadata,omitempty"`
46+ Instructions string `yaml:"-" json:"instructions"`
47+ Path string `yaml:"-" json:"path"`
48+ SkillFilePath string `yaml:"-" json:"skill_file_path"`
49+ Builtin bool `yaml:"-" json:"builtin"`
4850}
4951
5052// DiscoveryState represents the outcome of discovering a single skill file.
@@ -293,6 +295,7 @@ func DiscoverWithStates(paths []string) ([]*Skill, []*SkillState) {
293295}
294296
295297// ToPromptXML generates XML for injection into the system prompt.
298+// Skills with DisableModelInvocation set to true are excluded.
296299func ToPromptXML(skills []*Skill) string {
297300 if len(skills) == 0 {
298301 return ""
@@ -301,6 +304,10 @@ func ToPromptXML(skills []*Skill) string {
301304 var sb strings.Builder
302305 sb.WriteString("<available_skills>\n")
303306 for _, s := range skills {
307+ // Skip skills that have disable-model-invocation set
308+ if s.DisableModelInvocation {
309+ continue
310+ }
304311 sb.WriteString(" <skill>\n")
305312 fmt.Fprintf(&sb, " <name>%s</name>\n", escape(s.Name))
306313 fmt.Fprintf(&sb, " <description>%s</description>\n", escape(s.Description))
@@ -314,6 +321,20 @@ func ToPromptXML(skills []*Skill) string {
314321 return sb.String()
315322}
316323
324+// FormatInvocation generates XML for a skill when invoked as a user command.
325+func (s *Skill) FormatInvocation() string {
326+ var sb strings.Builder
327+ sb.WriteString("<loaded_skill>\n")
328+ fmt.Fprintf(&sb, " <name>%s</name>\n", escape(s.Name))
329+ fmt.Fprintf(&sb, " <description>%s</description>\n", escape(s.Description))
330+ fmt.Fprintf(&sb, " <location>%s</location>\n", escape(s.SkillFilePath))
331+ sb.WriteString(" <instructions>\n")
332+ sb.WriteString(escape(s.Instructions))
333+ sb.WriteString("\n </instructions>\n")
334+ sb.WriteString("</loaded_skill>")
335+ return sb.String()
336+}
337+
317338func escape(s string) string {
318339 return promptReplacer.Replace(s)
319340}
internal/skills/skills_test.go+14
@@ -312,6 +312,20 @@ func TestToPromptXML(t *testing.T) {
312312 require.Contains(t, xml, "&amp;") // XML escaping
313313}
314314
315+func TestToPromptXMLDisableModelInvocation(t *testing.T) {
316+ t.Parallel()
317+
318+ skills := []*Skill{
319+ {Name: "visible-skill", Description: "This one appears.", SkillFilePath: "/skills/visible/SKILL.md"},
320+ {Name: "hidden-skill", Description: "This one is hidden.", SkillFilePath: "/skills/hidden/SKILL.md", DisableModelInvocation: true},
321+ }
322+
323+ xml := ToPromptXML(skills)
324+
325+ require.Contains(t, xml, "<name>visible-skill</name>")
326+ require.NotContains(t, xml, "<name>hidden-skill</name>")
327+}
328+
315329func TestToPromptXMLEmpty(t *testing.T) {
316330 t.Parallel()
317331 require.Empty(t, ToPromptXML(nil))
internal/ui/chat/user.go+35−4
@@ -1,6 +1,7 @@
11package chat
22
33import (
4+ "encoding/xml"
45 "strings"
56
67 tea "charm.land/bubbletea/v2"
@@ -12,6 +13,14 @@ import (
1213 "github.com/charmbracelet/crush/internal/ui/styles"
1314)
1415
16+// skillInvocation represents the XML structure for a loaded skill.
17+type skillInvocation struct {
18+ Name string `xml:"name"`
19+ Description string `xml:"description"`
20+ Location string `xml:"location"`
21+ Instructions string `xml:"instructions"`
22+}
23+
1524// UserMessageItem represents a user message in the chat UI.
1625type UserMessageItem struct {
1726 *list.Versioned
@@ -54,13 +63,19 @@ func (m *UserMessageItem) RawRender(width int) string {
5463 return m.renderHighlighted(content, cappedWidth, height)
5564 }
5665
66+ msgContent := strings.TrimSpace(m.message.Content().Text)
67+
68+ // Check if this is a skill invocation (loaded_skill XML)
69+ if strings.HasPrefix(msgContent, "<loaded_skill>") {
70+ content = m.renderSkillInvocation(msgContent, cappedWidth)
71+ height = lipgloss.Height(content)
72+ m.setCachedRender(content, cappedWidth, height)
73+ return m.renderHighlighted(content, cappedWidth, height)
74+ }
75+
5776 renderer := common.MarkdownRenderer(m.sty, cappedWidth)
5877
59− msgContent := strings.TrimSpace(m.message.Content().Text)
60− mu := common.LockMarkdownRenderer(renderer)
61− mu.Lock()
6278 result, err := renderer.Render(msgContent)
63− mu.Unlock()
6479 if err != nil {
6580 content = msgContent
6681 } else {
@@ -81,6 +96,22 @@ func (m *UserMessageItem) RawRender(width int) string {
8196 return m.renderHighlighted(content, cappedWidth, height)
8297}
8398
99+// renderSkillInvocation renders a loaded_skill XML as a special UI element.
100+func (m *UserMessageItem) renderSkillInvocation(content string, width int) string {
101+ var skill skillInvocation
102+ if err := xml.Unmarshal([]byte(content), &skill); err != nil {
103+ // If parsing fails, just render as markdown
104+ renderer := common.MarkdownRenderer(m.sty, width)
105+ result, err := renderer.Render(content)
106+ if err != nil {
107+ return content
108+ }
109+ return strings.TrimSuffix(result, "\n")
110+ }
111+
112+ return toolOutputSkillContent(m.sty, skill.Name, skill.Description)
113+}
114+
84115// Render implements MessageItem.
85116func (m *UserMessageItem) Render(width int) string {
86117 // Bypass the prefix cache while a highlight range is active so
internal/ui/dialog/actions.go+2
@@ -14,6 +14,7 @@ import (
1414 "github.com/charmbracelet/crush/internal/oauth"
1515 "github.com/charmbracelet/crush/internal/permission"
1616 "github.com/charmbracelet/crush/internal/session"
17+ "github.com/charmbracelet/crush/internal/skills"
1718 "github.com/charmbracelet/crush/internal/ui/common"
1819 "github.com/charmbracelet/crush/internal/ui/util"
1920)
@@ -71,6 +72,7 @@ type (
7172 Content string
7273 Arguments []commands.Argument
7374 Args map[string]string // Actual argument values
75+ Skill *skills.Skill // Set when this is a skill command
7476 }
7577 // ActionRunMCPPrompt is a message to run a custom command.
7678 ActionRunMCPPrompt struct {
internal/ui/dialog/commands.go+1
@@ -393,6 +393,7 @@ func (c *Commands) setCommandItems(commandType CommandType) {
393393 action := ActionRunCustomCommand{
394394 Content: cmd.Content,
395395 Arguments: cmd.Arguments,
396+ Skill: cmd.Skill,
396397 }
397398 commandItems = append(commandItems, NewCommandItem(c.com.Styles, "custom_"+cmd.ID, cmd.Name, "", action))
398399 }
internal/ui/model/session_test.gonew file+142
@@ -0,0 +1,142 @@
1+package model
2+
3+import (
4+ "strings"
5+ "testing"
6+
7+ "charm.land/lipgloss/v2"
8+ "github.com/charmbracelet/crush/internal/history"
9+ "github.com/charmbracelet/crush/internal/ui/styles"
10+ "github.com/stretchr/testify/require"
11+)
12+
13+func TestFileList(t *testing.T) {
14+ t.Parallel()
15+
16+ t.Run("empty stats no truncation needed", func(t *testing.T) {
17+ t.Parallel()
18+
19+ st := minimalFileStyles()
20+ files := []SessionFile{
21+ {FirstVersion: history.File{Path: "main.go"}, Additions: 0, Deletions: 0},
22+ }
23+ got := fileList(st, "/", files, 30, 10)
24+ require.Contains(t, stripANSI(got), "main.go")
25+ })
26+
27+ t.Run("empty stats path truncates to width", func(t *testing.T) {
28+ t.Parallel()
29+
30+ st := minimalFileStyles()
31+ files := []SessionFile{
32+ {FirstVersion: history.File{Path: "/very/long/path/to/some/deeply/nested/file.go"}, Additions: 0, Deletions: 0},
33+ }
34+ got := fileList(st, "/", files, 10, 10)
35+ plain := stripANSI(got)
36+ for _, line := range strings.Split(plain, "\n") {
37+ require.LessOrEqual(t, lipgloss.Width(line), 10, "line exceeds sidebar width: %q", line)
38+ }
39+ })
40+
41+ t.Run("with additions and deletions fits within width", func(t *testing.T) {
42+ t.Parallel()
43+
44+ st := minimalFileStyles()
45+ files := []SessionFile{
46+ {FirstVersion: history.File{Path: "main.go"}, Additions: 5, Deletions: 3},
47+ }
48+ got := fileList(st, "/", files, 20, 10)
49+ plain := stripANSI(got)
50+ require.Contains(t, plain, "+5")
51+ require.Contains(t, plain, "-3")
52+ for _, line := range strings.Split(plain, "\n") {
53+ require.LessOrEqual(t, lipgloss.Width(line), 20, "line exceeds sidebar width: %q", line)
54+ }
55+ })
56+
57+ t.Run("narrow width with stats clamps path to zero", func(t *testing.T) {
58+ t.Parallel()
59+
60+ st := minimalFileStyles()
61+ files := []SessionFile{
62+ {FirstVersion: history.File{Path: "main.go"}, Additions: 100, Deletions: 200},
63+ }
64+ got := fileList(st, "/", files, 5, 10)
65+ plain := stripANSI(got)
66+ require.NotContains(t, plain, "main.go")
67+ require.Equal(t, "+100 -200", strings.TrimSpace(plain))
68+ })
69+
70+ t.Run("single addition only", func(t *testing.T) {
71+ t.Parallel()
72+
73+ st := minimalFileStyles()
74+ files := []SessionFile{
75+ {FirstVersion: history.File{Path: "main.go"}, Additions: 3, Deletions: 0},
76+ }
77+ got := fileList(st, "/", files, 20, 10)
78+ plain := stripANSI(got)
79+ require.Contains(t, plain, "+3")
80+ require.NotContains(t, plain, "-0")
81+ for _, line := range strings.Split(plain, "\n") {
82+ require.LessOrEqual(t, lipgloss.Width(line), 20, "line exceeds sidebar width: %q", line)
83+ }
84+ })
85+
86+ t.Run("single deletion only", func(t *testing.T) {
87+ t.Parallel()
88+
89+ st := minimalFileStyles()
90+ files := []SessionFile{
91+ {FirstVersion: history.File{Path: "main.go"}, Additions: 0, Deletions: 7},
92+ }
93+ got := fileList(st, "/", files, 20, 10)
94+ plain := stripANSI(got)
95+ require.NotContains(t, plain, "+0")
96+ require.Contains(t, plain, "-7")
97+ for _, line := range strings.Split(plain, "\n") {
98+ require.LessOrEqual(t, lipgloss.Width(line), 20, "line exceeds sidebar width: %q", line)
99+ }
100+ })
101+
102+ t.Run("max items zero returns empty", func(t *testing.T) {
103+ t.Parallel()
104+
105+ st := minimalFileStyles()
106+ files := []SessionFile{
107+ {FirstVersion: history.File{Path: "main.go"}, Additions: 1, Deletions: 1},
108+ }
109+ got := fileList(st, "/", files, 20, 0)
110+ require.Empty(t, got)
111+ })
112+}
113+
114+func minimalFileStyles() *styles.Styles {
115+ st := styles.CharmtonePantera()
116+ st.Files.Path = lipgloss.NewStyle()
117+ st.Files.Additions = lipgloss.NewStyle()
118+ st.Files.Deletions = lipgloss.NewStyle()
119+ st.Files.SectionTitle = lipgloss.NewStyle()
120+ st.Files.EmptyMessage = lipgloss.NewStyle()
121+ st.Files.TruncationHint = lipgloss.NewStyle()
122+ return &st
123+}
124+
125+func stripANSI(s string) string {
126+ var b strings.Builder
127+ esc := false
128+ for i := 0; i < len(s); i++ {
129+ if s[i] == '\x1b' {
130+ esc = true
131+ continue
132+ }
133+ if esc {
134+ if s[i] >= 'a' && s[i] <= 'z' || s[i] >= 'A' && s[i] <= 'Z' {
135+ esc = false
136+ }
137+ continue
138+ }
139+ b.WriteByte(s[i])
140+ }
141+ return b.String()
142+}
internal/ui/model/ui.go+8
@@ -468,6 +468,10 @@ func (m *UI) loadCustomCommands() tea.Cmd {
468468 if err != nil {
469469 slog.Error("Failed to load custom commands", "error", err)
470470 }
471+ // Append user-invocable skills as commands
472+ skillCommands := commands.LoadSkillCommands()
473+ skillCommands = append(skillCommands, commands.LoadProjectSkillCommands(m.com.Workspace.WorkingDir())...)
474+ customCommands = append(customCommands, skillCommands...)
471475 return userCommandsLoadedMsg{Commands: customCommands}
472476 }
473477}
@@ -1541,6 +1545,10 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
15411545 if msg.Args != nil {
15421546 content = substituteArgs(content, msg.Args)
15431547 }
1548+ // If this is a skill command, format it using the skill's FormatInvocation method
1549+ if msg.Skill != nil {
1550+ content = msg.Skill.FormatInvocation()
1551+ }
15441552 cmds = append(cmds, m.sendMessage(content))
15451553 m.dialog.CloseFrontDialog()
15461554 case dialog.ActionRunMCPPrompt:
internal/workspace/client_workspace.go+50−6
@@ -2,6 +2,7 @@ package workspace
22
33import (
44 "context"
5+ "errors"
56 "fmt"
67 "log/slog"
78 "strings"
@@ -22,6 +23,7 @@ import (
2223 "github.com/charmbracelet/crush/internal/proto"
2324 "github.com/charmbracelet/crush/internal/pubsub"
2425 "github.com/charmbracelet/crush/internal/session"
26+ "github.com/charmbracelet/crush/internal/skills"
2527 "github.com/charmbracelet/x/powernap/pkg/lsp/protocol"
2628)
2729
@@ -32,20 +34,29 @@ import (
3234type ClientWorkspace struct {
3335 client *client.Client
3436
35− mu sync.RWMutex
36− ws proto.Workspace
37+ mu sync.RWMutex
38+ ws proto.Workspace
39+ skills *skills.Manager
3740}
3841
3942// NewClientWorkspace creates a new ClientWorkspace that proxies all
4043// operations through the given client SDK. The ws parameter is the
41−// proto.Workspace snapshot returned by the server at creation time.
44+// proto.Workspace snapshot returned by the server at creation time. The
45+// snapshot's Skills field seeds a process-local skills.Manager so the
46+// TUI sees discovery state before the first SSE event arrives. The
47+// manager is constructed with WithGlobalMirror because the client
48+// process represents exactly one workspace and the TUI reads
49+// skills.GetLatestStates directly at construction time.
4250func NewClientWorkspace(c *client.Client, ws proto.Workspace) *ClientWorkspace {
4351 if ws.Config != nil {
4452 ws.Config.SetupAgents()
4553 }
54+ states := protoToSkillStates(ws.Skills)
55+ mgr := skills.NewManager(nil, nil, states, skills.WithGlobalMirror())
4656 return &ClientWorkspace{
4757 client: c,
4858 ws: ws,
59+ skills: mgr,
4960 }
5061}
5162
@@ -575,7 +586,7 @@ func (w *ClientWorkspace) consumeEvents(evc <-chan any, send func(tea.Msg)) {
575586 w.refreshWorkspace()
576587 continue
577588 }
578− translated := translateEvent(ev)
589+ translated := w.translateEvent(ev)
579590 if translated != nil && send != nil {
580591 send(translated)
581592 }
@@ -587,8 +598,10 @@ func (w *ClientWorkspace) Shutdown() {
587598}
588599
589600// translateEvent converts proto-typed SSE events into the domain types
590−// that the TUI's Update() method expects.
591−func translateEvent(ev any) tea.Msg {
601+// that the TUI's Update() method expects. Skills events also update the
602+// process-local skills.Manager so callers reading
603+// skills.GetLatestStates see fresh data.
604+func (w *ClientWorkspace) translateEvent(ev any) tea.Msg {
592605 switch e := ev.(type) {
593606 case pubsub.Event[proto.LSPEvent]:
594607 return pubsub.Event[LSPEvent]{
@@ -663,6 +676,15 @@ func translateEvent(ev any) tea.Msg {
663676 Type: notify.Type(e.Payload.Type),
664677 },
665678 }
679+ case pubsub.Event[proto.SkillsEvent]:
680+ states := protoToSkillStates(e.Payload.States)
681+ if w.skills != nil {
682+ w.skills.SetLatestStates(states)
683+ }
684+ return pubsub.Event[skills.Event]{
685+ Type: e.Type,
686+ Payload: skills.Event{States: states},
687+ }
666688 default:
667689 slog.Warn("Unknown event type in translateEvent", "type", fmt.Sprintf("%T", ev))
668690 return nil
@@ -822,6 +844,28 @@ func sessionToProto(s session.Session) proto.Session {
822844 }
823845}
824846
847+// protoToSkillStates reconstructs internal skill state slices from
848+// their wire representation. Non-empty Error strings are turned into
849+// synthetic error values; the TUI never type-asserts on Err.
850+func protoToSkillStates(in []proto.SkillState) []*skills.SkillState {
851+ if len(in) == 0 {
852+ return nil
853+ }
854+ out := make([]*skills.SkillState, len(in))
855+ for i, s := range in {
856+ state := &skills.SkillState{
857+ Name: s.Name,
858+ Path: s.Path,
859+ State: skills.DiscoveryState(s.State),
860+ }
861+ if s.Error != "" {
862+ state.Err = errors.New(s.Error)
863+ }
864+ out[i] = state
865+ }
866+ return out
867+}
868+
825869func todosToProto(todos []session.Todo) []proto.Todo {
826870 if len(todos) == 0 {
827871 return nil
internal/workspace/client_workspace_test.go+79
@@ -12,6 +12,8 @@ import (
1212 "github.com/charmbracelet/crush/internal/message"
1313 "github.com/charmbracelet/crush/internal/permission"
1414 "github.com/charmbracelet/crush/internal/proto"
15+ "github.com/charmbracelet/crush/internal/pubsub"
16+ "github.com/charmbracelet/crush/internal/skills"
1517 "github.com/stretchr/testify/require"
1618)
1719
@@ -132,3 +134,80 @@ func TestClientWorkspace_PermissionGrantMapping(t *testing.T) {
132134 })
133135 }
134136}
137+
138+// TestProtoToSkillStates verifies that the wire representation of skill
139+// discovery states reconstructs identical values on the client,
140+// including synthetic errors derived from Error strings.
141+func TestProtoToSkillStates(t *testing.T) {
142+ t.Parallel()
143+
144+ in := []proto.SkillState{
145+ {Name: "ok", Path: "/p/ok", State: proto.SkillStateNormal},
146+ {Name: "broken", Path: "/p/broken", State: proto.SkillStateError, Error: "bad frontmatter"},
147+ }
148+
149+ got := protoToSkillStates(in)
150+ require.Len(t, got, 2)
151+ require.Equal(t, "ok", got[0].Name)
152+ require.Equal(t, skills.StateNormal, got[0].State)
153+ require.NoError(t, got[0].Err)
154+ require.Equal(t, "broken", got[1].Name)
155+ require.Equal(t, skills.StateError, got[1].State)
156+ require.EqualError(t, got[1].Err, "bad frontmatter")
157+}
158+
159+// TestTranslateEvent_Skills verifies that an incoming proto.SkillsEvent
160+// is converted into pubsub.Event[skills.Event] and that the
161+// client-process skill cache is updated as a side effect, so callers
162+// reading skills.GetLatestStates see fresh data after each delta.
163+func TestTranslateEvent_Skills(t *testing.T) {
164+ // Not parallel - touches the package-level skills cache via the
165+ // manager constructed with WithGlobalMirror.
166+ prev := skills.GetLatestStates()
167+ t.Cleanup(func() { skills.SetLatestStates(prev) })
168+
169+ skills.SetLatestStates(nil)
170+
171+ w := NewClientWorkspace(nil, proto.Workspace{})
172+ ev := pubsub.Event[proto.SkillsEvent]{
173+ Type: pubsub.UpdatedEvent,
174+ Payload: proto.SkillsEvent{
175+ States: []proto.SkillState{
176+ {Name: "from-server", Path: "/p", State: proto.SkillStateNormal},
177+ },
178+ },
179+ }
180+
181+ out := w.translateEvent(ev)
182+ got, ok := out.(pubsub.Event[skills.Event])
183+ require.True(t, ok, "expected pubsub.Event[skills.Event], got %T", out)
184+ require.Len(t, got.Payload.States, 1)
185+ require.Equal(t, "from-server", got.Payload.States[0].Name)
186+
187+ // Manager (with WithGlobalMirror) propagated to the package cache.
188+ cached := skills.GetLatestStates()
189+ require.Len(t, cached, 1)
190+ require.Equal(t, "from-server", cached[0].Name)
191+}
192+
193+// TestNewClientWorkspace_SeedsSkillsCache verifies that the snapshot in
194+// proto.Workspace.Skills populates the package-level cache the TUI
195+// reads at construction time, eliminating the race between TUI startup
196+// and the first SSE event.
197+func TestNewClientWorkspace_SeedsSkillsCache(t *testing.T) {
198+ // Not parallel - touches the package-level skills cache.
199+ prev := skills.GetLatestStates()
200+ t.Cleanup(func() { skills.SetLatestStates(prev) })
201+
202+ skills.SetLatestStates(nil)
203+
204+ _ = NewClientWorkspace(nil, proto.Workspace{
205+ Skills: []proto.SkillState{
206+ {Name: "seeded", Path: "/p", State: proto.SkillStateNormal},
207+ },
208+ })
209+
210+ got := skills.GetLatestStates()
211+ require.Len(t, got, 1)
212+ require.Equal(t, "seeded", got[0].Name)
213+}