on main help sign in
feat(skills): add descriptions to skill picker and use attachements
5572165Kieran Klukas
internal/backend/backend.go+7−2
@@ -112,8 +112,12 @@ func (b *Backend) CreateWorkspace(args proto.Workspace) (*Workspace, proto.Works
112 // hosts multiple workspaces concurrently, so the manager is
112 // hosts multiple workspaces concurrently, so the manager is
113 // constructed WITHOUT WithGlobalMirror to prevent last-writer-wins
113 // constructed WITHOUT WithGlobalMirror to prevent last-writer-wins
114 // cross-talk between workspaces.
114 // cross-talk between workspaces.
115− allSkills, activeSkills, skillStates := skills.DiscoverFromConfig(skillsDiscoveryConfig(cfg))
115+ discoveryCfg := skillsDiscoveryConfig(cfg)
116+ allSkills, activeSkills, skillStates := skills.DiscoverFromConfig(discoveryCfg)
116− skillsMgr := skills.NewManager(allSkills, activeSkills, skillStates)
117+ skillsMgr := skills.NewManager(allSkills, activeSkills, skillStates,
118+ skills.WithResolvedPaths(discoveryCfg.ResolvePaths()),
119+ skills.WithWorkingDir(discoveryCfg.WorkingDir),
120+ )
117
121
118 appWorkspace, err := app.New(b.ctx, conn, cfg, skillsMgr)
122 appWorkspace, err := app.New(b.ctx, conn, cfg, skillsMgr)
119 if err != nil {
123 if err != nil {
@@ -173,6 +177,7 @@ func skillsDiscoveryConfig(cfg *config.ConfigStore) skills.DiscoveryConfig {
173 return skills.DiscoveryConfig{
177 return skills.DiscoveryConfig{
174 SkillsPaths: paths,
178 SkillsPaths: paths,
175 DisabledSkills: disabled,
179 DisabledSkills: disabled,
180+ WorkingDir: cfg.WorkingDir(),
176 Resolver: resolver,
181 Resolver: resolver,
177 }
182 }
178}
183}
internal/backend/config.go+45
@@ -10,6 +10,8 @@ import (
10 "github.com/charmbracelet/crush/internal/commands"
10 "github.com/charmbracelet/crush/internal/commands"
11 "github.com/charmbracelet/crush/internal/config"
11 "github.com/charmbracelet/crush/internal/config"
12 "github.com/charmbracelet/crush/internal/oauth"
12 "github.com/charmbracelet/crush/internal/oauth"
13+ "github.com/charmbracelet/crush/internal/proto"
14+ "github.com/charmbracelet/crush/internal/skills"
13)
15)
14
16
15// MCPResourceContents holds the contents of an MCP resource returned
17// MCPResourceContents holds the contents of an MCP resource returned
@@ -116,6 +118,49 @@ func (b *Backend) InitializePrompt(workspaceID string) (string, error) {
116 return agent.InitializePrompt(ws.Cfg)
118 return agent.InitializePrompt(ws.Cfg)
117}
119}
118
120
121+// ReadSkill reads a skill's content by ID.
122+func (b *Backend) ReadSkill(ctx context.Context, workspaceID, skillID string) ([]byte, proto.SkillReadResult, error) {
123+ ws, err := b.GetWorkspace(workspaceID)
124+ if err != nil {
125+ return nil, proto.SkillReadResult{}, err
126+ }
127+
128+ mgr := ws.Skills
129+ content, result, err := skills.ReadContent(
130+ mgr.ActiveSkills(), mgr.ResolvedPaths(), mgr.WorkingDir(), skillID,
131+ )
132+ if err != nil {
133+ return nil, proto.SkillReadResult{}, err
134+ }
135+ return content, proto.SkillReadResult{
136+ Name: result.Name,
137+ Description: result.Description,
138+ Source: string(result.Source),
139+ Builtin: result.Builtin,
140+ }, nil
141+}
142+
143+// ListSkills returns the effective visible skills for a workspace.
144+func (b *Backend) ListSkills(workspaceID string) ([]proto.SkillInfo, error) {
145+ ws, err := b.GetWorkspace(workspaceID)
146+ if err != nil {
147+ return nil, err
148+ }
149+ mgr := ws.Skills
150+ entries := skills.Catalog(mgr.ActiveSkills(), mgr.ResolvedPaths(), mgr.WorkingDir())
151+ result := make([]proto.SkillInfo, len(entries))
152+ for i, entry := range entries {
153+ result[i] = proto.SkillInfo{
154+ ID: entry.ID,
155+ Name: entry.Name,
156+ Description: entry.Description,
157+ Label: entry.Label,
158+ Source: string(entry.Source),
159+ }
160+ }
161+ return result, nil
162+}
163+
119// EnableDockerMCP validates Docker MCP availability, stages the
164// EnableDockerMCP validates Docker MCP availability, stages the
120// configuration, starts the MCP client, and persists the config.
165// configuration, starts the MCP client, and persists the config.
121func (b *Backend) EnableDockerMCP(ctx context.Context, workspaceID string) error {
166func (b *Backend) EnableDockerMCP(ctx context.Context, workspaceID string) error {
internal/client/config.go+36
@@ -216,6 +216,42 @@ func (c *Client) GetInitializePrompt(ctx context.Context, id string) (string, er
216 return result.Prompt, nil
216 return result.Prompt, nil
217}
217}
218
218
219+// ListSkills retrieves the visible skills for a workspace.
220+func (c *Client) ListSkills(ctx context.Context, id string) ([]proto.SkillInfo, error) {
221+ rsp, err := c.get(ctx, fmt.Sprintf("/workspaces/%s/skills", id), nil, nil)
222+ if err != nil {
223+ return nil, fmt.Errorf("failed to list skills: %w", err)
224+ }
225+ defer rsp.Body.Close()
226+ if rsp.StatusCode != http.StatusOK {
227+ return nil, fmt.Errorf("failed to list skills: status code %d", rsp.StatusCode)
228+ }
229+ var skills []proto.SkillInfo
230+ if err := json.NewDecoder(rsp.Body).Decode(&skills); err != nil {
231+ return nil, fmt.Errorf("failed to decode skills: %w", err)
232+ }
233+ return skills, nil
234+}
235+
236+// ReadSkill reads a skill's content by ID from the server.
237+func (c *Client) ReadSkill(ctx context.Context, id, skillID string) (*proto.ReadSkillResponse, error) {
238+ rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/skills/read", id), nil, jsonBody(proto.ReadSkillRequest{
239+ SkillID: skillID,
240+ }), http.Header{"Content-Type": []string{"application/json"}})
241+ if err != nil {
242+ return nil, fmt.Errorf("failed to read skill: %w", err)
243+ }
244+ defer rsp.Body.Close()
245+ if rsp.StatusCode != http.StatusOK {
246+ return nil, fmt.Errorf("failed to read skill: status code %d", rsp.StatusCode)
247+ }
248+ var result proto.ReadSkillResponse
249+ if err := json.NewDecoder(rsp.Body).Decode(&result); err != nil {
250+ return nil, fmt.Errorf("failed to decode skill response: %w", err)
251+ }
252+ return &result, nil
253+}
254+
219// MCPResourceContents holds the contents of an MCP resource.
255// MCPResourceContents holds the contents of an MCP resource.
220type MCPResourceContents struct {
256type MCPResourceContents struct {
221 URI string `json:"uri"`
257 URI string `json:"uri"`
internal/cmd/root.go+8−2
@@ -291,8 +291,13 @@ func setupLocalWorkspace(cmd *cobra.Command) (workspace.Workspace, func(), error
291 // workspace per process, so WithGlobalMirror keeps the package
291 // workspace per process, so WithGlobalMirror keeps the package
292 // globals (which the TUI reads via skills.GetLatestStates) in sync
292 // globals (which the TUI reads via skills.GetLatestStates) in sync
293 // with the manager.
293 // with the manager.
294− allSkills, activeSkills, skillStates := skills.DiscoverFromConfig(localSkillsDiscoveryConfig(store))
294+ discoveryCfg := localSkillsDiscoveryConfig(store)
295+ allSkills, activeSkills, skillStates := skills.DiscoverFromConfig(discoveryCfg)
295− skillsMgr := skills.NewManager(allSkills, activeSkills, skillStates, skills.WithGlobalMirror())
296+ skillsMgr := skills.NewManager(allSkills, activeSkills, skillStates,
297+ skills.WithGlobalMirror(),
298+ skills.WithResolvedPaths(discoveryCfg.ResolvePaths()),
299+ skills.WithWorkingDir(discoveryCfg.WorkingDir),
300+ )
296
301
297 appInstance, err := app.New(ctx, conn, store, skillsMgr)
302 appInstance, err := app.New(ctx, conn, store, skillsMgr)
298 if err != nil {
303 if err != nil {
@@ -326,6 +331,7 @@ func localSkillsDiscoveryConfig(store *config.ConfigStore) skills.DiscoveryConfi
326 return skills.DiscoveryConfig{
331 return skills.DiscoveryConfig{
327 SkillsPaths: paths,
332 SkillsPaths: paths,
328 DisabledSkills: disabled,
333 DisabledSkills: disabled,
334+ WorkingDir: store.WorkingDir(),
329 Resolver: resolver,
335 Resolver: resolver,
330 }
336 }
331}
337}
internal/message/attachment.go+3−2
@@ -12,8 +12,9 @@ type Attachment struct {
12 Content []byte
12 Content []byte
13}
13}
14
14
15−func (a Attachment) IsText() bool { return strings.HasPrefix(a.MimeType, "text/") }
15+func (a Attachment) IsText() bool { return strings.HasPrefix(a.MimeType, "text/") }
16−func (a Attachment) IsImage() bool { return strings.HasPrefix(a.MimeType, "image/") }
16+func (a Attachment) IsImage() bool { return strings.HasPrefix(a.MimeType, "image/") }
17+func (a Attachment) IsMarkdown() bool { return a.MimeType == "text/markdown" }
17
18
18// ContainsTextAttachment returns true if any of the attachments is a text attachment.
19// ContainsTextAttachment returns true if any of the attachments is a text attachment.
19func ContainsTextAttachment(attachments []Attachment) bool {
20func ContainsTextAttachment(attachments []Attachment) bool {
internal/proto/proto.go+29
@@ -32,6 +32,35 @@ type Error struct {
32 Message string `json:"message"`
32 Message string `json:"message"`
33}
33}
34
34
35+// SkillInfo describes a visible skill exposed to a frontend.
36+type SkillInfo struct {
37+ ID string `json:"id"`
38+ Name string `json:"name"`
39+ Description string `json:"description"`
40+ Label string `json:"label"`
41+ Source string `json:"source"`
42+}
43+
44+// ReadSkillRequest is the request body for reading a skill's content.
45+type ReadSkillRequest struct {
46+ SkillID string `json:"skill_id"`
47+}
48+
49+// ReadSkillResponse is the response for reading a skill's content.
50+type ReadSkillResponse struct {
51+ Content []byte `json:"content"`
52+ Result SkillReadResult `json:"result"`
53+}
54+
55+// SkillReadResult holds metadata about a skill returned alongside its
56+// content.
57+type SkillReadResult struct {
58+ Name string `json:"name"`
59+ Description string `json:"description"`
60+ Source string `json:"source"`
61+ Builtin bool `json:"builtin"`
62+}
63+
35// AgentInfo represents information about the agent.
64// AgentInfo represents information about the agent.
36type AgentInfo struct {
65type AgentInfo struct {
37 IsBusy bool `json:"is_busy"`
66 IsBusy bool `json:"is_busy"`
internal/server/config.go+51
@@ -266,6 +266,57 @@ func (c *controllerV1) handleGetWorkspaceProjectInitPrompt(w http.ResponseWriter
266 jsonEncode(w, proto.ProjectInitPromptResponse{Prompt: prompt})
266 jsonEncode(w, proto.ProjectInitPromptResponse{Prompt: prompt})
267}
267}
268
268
269+// handleGetWorkspaceSkills returns the effective visible skills for a workspace.
270+//
271+// @Summary List visible skills
272+// @Tags skills
273+// @Produce json
274+// @Param id path string true "Workspace ID"
275+// @Success 200 {array} proto.SkillInfo
276+// @Failure 404 {object} proto.Error
277+// @Failure 500 {object} proto.Error
278+// @Router /workspaces/{id}/skills [get]
279+func (c *controllerV1) handleGetWorkspaceSkills(w http.ResponseWriter, r *http.Request) {
280+ id := r.PathValue("id")
281+ skills, err := c.backend.ListSkills(id)
282+ if err != nil {
283+ c.handleError(w, r, err)
284+ return
285+ }
286+ jsonEncode(w, skills)
287+}
288+
289+// handlePostWorkspaceSkillRead reads a skill's content by ID.
290+//
291+// @Summary Read skill content
292+// @Tags skills
293+// @Accept json
294+// @Produce json
295+// @Param id path string true "Workspace ID"
296+// @Param request body proto.ReadSkillRequest true "Read skill request"
297+// @Success 200 {object} proto.ReadSkillResponse
298+// @Failure 400 {object} proto.Error
299+// @Failure 404 {object} proto.Error
300+// @Failure 500 {object} proto.Error
301+// @Router /workspaces/{id}/skills/read [post]
302+func (c *controllerV1) handlePostWorkspaceSkillRead(w http.ResponseWriter, r *http.Request) {
303+ id := r.PathValue("id")
304+
305+ var req proto.ReadSkillRequest
306+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
307+ c.server.logError(r, "Failed to decode request", "error", err)
308+ jsonError(w, http.StatusBadRequest, "failed to decode request")
309+ return
310+ }
311+
312+ content, result, err := c.backend.ReadSkill(r.Context(), id, req.SkillID)
313+ if err != nil {
314+ c.handleError(w, r, err)
315+ return
316+ }
317+ jsonEncode(w, proto.ReadSkillResponse{Content: content, Result: result})
318+}
319+
269// handlePostWorkspaceMCPEnableDocker enables the Docker MCP server.
320// handlePostWorkspaceMCPEnableDocker enables the Docker MCP server.
270//
321//
271// @Summary Enable Docker MCP
322// @Summary Enable Docker MCP
internal/server/server.go+2
@@ -157,6 +157,8 @@ func NewServer(cfg *config.ConfigStore, network, address string) *Server {
157 mux.HandleFunc("GET /v1/workspaces/{id}/project/needs-init", c.handleGetWorkspaceProjectNeedsInit)
157 mux.HandleFunc("GET /v1/workspaces/{id}/project/needs-init", c.handleGetWorkspaceProjectNeedsInit)
158 mux.HandleFunc("POST /v1/workspaces/{id}/project/init", c.handlePostWorkspaceProjectInit)
158 mux.HandleFunc("POST /v1/workspaces/{id}/project/init", c.handlePostWorkspaceProjectInit)
159 mux.HandleFunc("GET /v1/workspaces/{id}/project/init-prompt", c.handleGetWorkspaceProjectInitPrompt)
159 mux.HandleFunc("GET /v1/workspaces/{id}/project/init-prompt", c.handleGetWorkspaceProjectInitPrompt)
160+ mux.HandleFunc("GET /v1/workspaces/{id}/skills", c.handleGetWorkspaceSkills)
161+ mux.HandleFunc("POST /v1/workspaces/{id}/skills/read", c.handlePostWorkspaceSkillRead)
160 mux.HandleFunc("POST /v1/workspaces/{id}/mcp/refresh-tools", c.handlePostWorkspaceMCPRefreshTools)
162 mux.HandleFunc("POST /v1/workspaces/{id}/mcp/refresh-tools", c.handlePostWorkspaceMCPRefreshTools)
161 mux.HandleFunc("POST /v1/workspaces/{id}/mcp/read-resource", c.handlePostWorkspaceMCPReadResource)
163 mux.HandleFunc("POST /v1/workspaces/{id}/mcp/read-resource", c.handlePostWorkspaceMCPReadResource)
162 mux.HandleFunc("POST /v1/workspaces/{id}/mcp/get-prompt", c.handlePostWorkspaceMCPGetPrompt)
164 mux.HandleFunc("POST /v1/workspaces/{id}/mcp/get-prompt", c.handlePostWorkspaceMCPGetPrompt)
internal/skills/catalog.gonew file+152
@@ -0,0 +1,152 @@
1+package skills
2+
3+import (
4+ "errors"
5+ "fmt"
6+ "os"
7+ "path/filepath"
8+ "strings"
9+)
10+
11+// SourceType describes where a visible skill comes from.
12+type SourceType string
13+
14+const (
15+ SourceSystem SourceType = "system"
16+ SourceUser SourceType = "user"
17+ SourceProject SourceType = "project"
18+)
19+
20+// CatalogEntry describes an effective visible skill for frontend display.
21+type CatalogEntry struct {
22+ ID string `json:"id"`
23+ Name string `json:"name"`
24+ Description string `json:"description"`
25+ Label string `json:"label"`
26+ Source SourceType `json:"source"`
27+}
28+
29+// SkillReadResult holds metadata about a skill returned alongside its
30+// content.
31+type SkillReadResult struct {
32+ Name string `json:"name"`
33+ Description string `json:"description"`
34+ Source SourceType `json:"source"`
35+ Builtin bool `json:"builtin"`
36+}
37+
38+// ErrSkillNotFound is returned when a skill ID is not part of the
39+// effective visible skill set.
40+var ErrSkillNotFound = errors.New("skill not found")
41+
42+// Catalog builds a slice of CatalogEntry values from pre-discovered
43+// skills. The skillPaths and workingDir parameters are used only for
44+// labelling (system / user / project); pass nil/empty when labels are
45+// not needed.
46+func Catalog(active []*Skill, skillPaths []string, workingDir string) []CatalogEntry {
47+ entries := make([]CatalogEntry, 0, len(active))
48+ for _, skill := range active {
49+ label, source := skillLabel(skillPaths, workingDir, skill)
50+ entries = append(entries, CatalogEntry{
51+ ID: skill.SkillFilePath,
52+ Name: skill.Name,
53+ Description: skill.Description,
54+ Label: label,
55+ Source: source,
56+ })
57+ }
58+ return entries
59+}
60+
61+// FindEffective returns the named skill from the given active skill
62+// set.
63+func FindEffective(active []*Skill, skillID string) (*Skill, error) {
64+ for _, skill := range active {
65+ if skill.SkillFilePath == skillID {
66+ return skill, nil
67+ }
68+ }
69+ return nil, fmt.Errorf("%w: %s", ErrSkillNotFound, skillID)
70+}
71+
72+// ReadContent reads the contents of a visible skill by ID and returns
73+// the raw bytes along with metadata about the skill.
74+func ReadContent(active []*Skill, skillPaths []string, workingDir string, skillID string) ([]byte, SkillReadResult, error) {
75+ skill, err := FindEffective(active, skillID)
76+ if err != nil {
77+ return nil, SkillReadResult{}, err
78+ }
79+
80+ _, source := skillLabel(skillPaths, workingDir, skill)
81+ result := SkillReadResult{
82+ Name: skill.Name,
83+ Description: skill.Description,
84+ Source: source,
85+ Builtin: skill.Builtin,
86+ }
87+
88+ if skill.Builtin {
89+ embeddedPath := "builtin/" + strings.TrimPrefix(skill.SkillFilePath, BuiltinPrefix)
90+ content, err := BuiltinFS().ReadFile(embeddedPath)
91+ if err != nil {
92+ return nil, SkillReadResult{}, fmt.Errorf("read builtin skill %q: %w", skillID, err)
93+ }
94+ return content, result, nil
95+ }
96+
97+ content, err := os.ReadFile(skill.SkillFilePath)
98+ if err != nil {
99+ return nil, SkillReadResult{}, fmt.Errorf("read skill %q: %w", skillID, err)
100+ }
101+ return content, result, nil
102+}
103+
104+func skillLabel(skillPaths []string, workingDir string, skill *Skill) (string, SourceType) {
105+ if skill.Builtin {
106+ return string(SourceSystem) + ":" + skill.Name, SourceSystem
107+ }
108+
109+ cleanFile := filepath.Clean(skill.SkillFilePath)
110+ for _, base := range skillPaths {
111+ cleanBase := filepath.Clean(base)
112+ rel, err := filepath.Rel(cleanBase, cleanFile)
113+ if err != nil || escapesParent(rel) {
114+ continue
115+ }
116+
117+ source := SourceUser
118+ prefix := string(SourceUser) + ":"
119+ if isProjectSkillPath(cleanBase, workingDir) {
120+ source = SourceProject
121+ prefix = string(SourceProject) + ":"
122+ }
123+ return prefix + filepath.Base(filepath.Dir(cleanFile)), source
124+ }
125+
126+ return string(SourceUser) + ":" + filepath.Base(filepath.Dir(cleanFile)), SourceUser
127+}
128+
129+func escapesParent(rel string) bool {
130+ return rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator))
131+}
132+
133+func isProjectSkillPath(basePath, workingDir string) bool {
134+ if workingDir == "" {
135+ return false
136+ }
137+ absBase, err := filepath.Abs(basePath)
138+ if err != nil {
139+ return false
140+ }
141+ absWD, err := filepath.Abs(workingDir)
142+ if err != nil {
143+ return false
144+ }
145+ cleanBase := filepath.Clean(absBase)
146+ cleanWD := filepath.Clean(absWD)
147+ rel, err := filepath.Rel(cleanWD, cleanBase)
148+ if err != nil {
149+ return false
150+ }
151+ return !escapesParent(rel)
152+}
internal/skills/manager.go+59−12
@@ -27,6 +27,12 @@ type Manager struct {
27 activeSkills []*Skill
27 activeSkills []*Skill
28 states []*SkillState
28 states []*SkillState
29
29
30+ // resolvedPaths are the expanded SkillsPaths used during discovery.
31+ // Stored so Catalog/ReadContent can label skills without
32+ // re-resolving.
33+ resolvedPaths []string
34+ workingDir string
35+
30 broker *pubsub.Broker[Event]
36 broker *pubsub.Broker[Event]
31 globalMirror bool
37 globalMirror bool
32}
38}
@@ -44,6 +50,23 @@ func WithGlobalMirror() ManagerOption {
44 }
50 }
45}
51}
46
52
53+// WithResolvedPaths stores the expanded skills directory paths that
54+// were used during discovery. Catalog and ReadContent use these for
55+// source labelling.
56+func WithResolvedPaths(paths []string) ManagerOption {
57+ return func(m *Manager) {
58+ m.resolvedPaths = paths
59+ }
60+}
61+
62+// WithWorkingDir stores the workspace working directory. Catalog and
63+// ReadContent use it to distinguish project skills from user skills.
64+func WithWorkingDir(dir string) ManagerOption {
65+ return func(m *Manager) {
66+ m.workingDir = dir
67+ }
68+}
69+
47// NewManager constructs a workspace-scoped Manager with the given
70// NewManager constructs a workspace-scoped Manager with the given
48// pre-computed discovery results. The slices are stored as-is; callers
71// pre-computed discovery results. The slices are stored as-is; callers
49// should not mutate them afterwards.
72// should not mutate them afterwards.
@@ -78,6 +101,18 @@ func (m *Manager) ActiveSkills() []*Skill {
78 return m.activeSkills
101 return m.activeSkills
79}
102}
80
103
104+// ResolvedPaths returns the expanded skills directory paths stored at
105+// construction time.
106+func (m *Manager) ResolvedPaths() []string {
107+ return m.resolvedPaths
108+}
109+
110+// WorkingDir returns the workspace working directory stored at
111+// construction time.
112+func (m *Manager) WorkingDir() string {
113+ return m.workingDir
114+}
115+
81// States returns a clone of the latest discovery state snapshot.
116// States returns a clone of the latest discovery state snapshot.
82func (m *Manager) States() []*SkillState {
117func (m *Manager) States() []*SkillState {
83 m.mu.RLock()
118 m.mu.RLock()
@@ -140,18 +175,8 @@ func DiscoverFromConfig(cfg DiscoveryConfig) (allSkills, activeSkills []*Skill,
140 discovered := append([]*Skill(nil), builtin...)
175 discovered := append([]*Skill(nil), builtin...)
141
176
142 var userStates []*SkillState
177 var userStates []*SkillState
143− var userPaths []string
178+ userPaths := cfg.ResolvePaths()
144− if len(cfg.SkillsPaths) > 0 {
179+ if len(userPaths) > 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
180 var userSkills []*Skill
156 userSkills, userStates = DiscoverWithStates(userPaths)
181 userSkills, userStates = DiscoverWithStates(userPaths)
157 discovered = append(discovered, userSkills...)
182 discovered = append(discovered, userSkills...)
@@ -175,6 +200,28 @@ func DiscoverFromConfig(cfg DiscoveryConfig) (allSkills, activeSkills []*Skill,
175type DiscoveryConfig struct {
200type DiscoveryConfig struct {
176 SkillsPaths []string
201 SkillsPaths []string
177 DisabledSkills []string
202 DisabledSkills []string
203+ WorkingDir string
178 // Resolver expands $VAR-style references in paths. May be nil.
204 // Resolver expands $VAR-style references in paths. May be nil.
179 Resolver func(string) (string, error)
205 Resolver func(string) (string, error)
180}
206}
207+
208+// ResolvePaths expands home-directory and $VAR references in
209+// SkillsPaths. This is the canonical path-resolution logic used by
210+// DiscoverFromConfig; callers that need the resolved list (e.g. for
211+// Catalog labels) can call this directly.
212+func (c DiscoveryConfig) ResolvePaths() []string {
213+ if len(c.SkillsPaths) == 0 {
214+ return nil
215+ }
216+ out := make([]string, 0, len(c.SkillsPaths))
217+ for _, pth := range c.SkillsPaths {
218+ expanded := home.Long(pth)
219+ if strings.HasPrefix(expanded, "$") && c.Resolver != nil {
220+ if resolved, err := c.Resolver(expanded); err == nil {
221+ expanded = resolved
222+ }
223+ }
224+ out = append(out, expanded)
225+ }
226+ return out
227+}
internal/ui/attachments/attachments.go+8−3
@@ -82,25 +82,27 @@ func (m *Attachments) Render(width int) string {
82// styles in place.
82// styles in place.
83func (m *Attachments) Renderer() *Renderer { return m.renderer }
83func (m *Attachments) Renderer() *Renderer { return m.renderer }
84
84
85−func NewRenderer(normalStyle, deletingStyle, imageStyle, textStyle lipgloss.Style) *Renderer {
85+func NewRenderer(normalStyle, deletingStyle, imageStyle, textStyle, skillStyle lipgloss.Style) *Renderer {
86 return &Renderer{
86 return &Renderer{
87 normalStyle: normalStyle,
87 normalStyle: normalStyle,
88 textStyle: textStyle,
88 textStyle: textStyle,
89 imageStyle: imageStyle,
89 imageStyle: imageStyle,
90+ skillStyle: skillStyle,
90 deletingStyle: deletingStyle,
91 deletingStyle: deletingStyle,
91 }
92 }
92}
93}
93
94
94// SetStyles updates the renderer styles in place.
95// SetStyles updates the renderer styles in place.
95−func (r *Renderer) SetStyles(normalStyle, deletingStyle, imageStyle, textStyle lipgloss.Style) {
96+func (r *Renderer) SetStyles(normalStyle, deletingStyle, imageStyle, textStyle, skillStyle lipgloss.Style) {
96 r.normalStyle = normalStyle
97 r.normalStyle = normalStyle
97 r.textStyle = textStyle
98 r.textStyle = textStyle
98 r.imageStyle = imageStyle
99 r.imageStyle = imageStyle
100+ r.skillStyle = skillStyle
99 r.deletingStyle = deletingStyle
101 r.deletingStyle = deletingStyle
100}
102}
101
103
102type Renderer struct {
104type Renderer struct {
103− normalStyle, textStyle, imageStyle, deletingStyle lipgloss.Style
105+ normalStyle, textStyle, imageStyle, skillStyle, deletingStyle lipgloss.Style
104}
106}
105
107
106func (r *Renderer) Render(attachments []message.Attachment, deleting bool, width int) string {
108func (r *Renderer) Render(attachments []message.Attachment, deleting bool, width int) string {
@@ -143,5 +145,8 @@ func (r *Renderer) icon(a message.Attachment) lipgloss.Style {
143 if a.IsImage() {
145 if a.IsImage() {
144 return r.imageStyle
146 return r.imageStyle
145 }
147 }
148+ if a.IsMarkdown() {
149+ return r.skillStyle
150+ }
146 return r.textStyle
151 return r.textStyle
147}
152}
internal/ui/chat/messages.go+1
@@ -372,6 +372,7 @@ func ExtractMessageItems(sty *styles.Styles, msg *message.Message, toolResults m
372 sty.Attachments.Deleting,
372 sty.Attachments.Deleting,
373 sty.Attachments.Image,
373 sty.Attachments.Image,
374 sty.Attachments.Text,
374 sty.Attachments.Text,
375+ sty.Attachments.Skill,
375 )
376 )
376 return []MessageItem{NewUserMessageItem(sty, msg, r)}
377 return []MessageItem{NewUserMessageItem(sty, msg, r)}
377 case message.Assistant:
378 case message.Assistant:
internal/ui/chat/prefix_cache_test.go+1
@@ -116,6 +116,7 @@ func TestUserMessageItemRender_PrefixCacheFocusBlur(t *testing.T) {
116 sty.Attachments.Deleting,
116 sty.Attachments.Deleting,
117 sty.Attachments.Image,
117 sty.Attachments.Image,
118 sty.Attachments.Text,
118 sty.Attachments.Text,
119+ sty.Attachments.Skill,
119 )
120 )
120 item := NewUserMessageItem(&sty, msg, r).(*UserMessageItem)
121 item := NewUserMessageItem(&sty, msg, r).(*UserMessageItem)
121
122
internal/ui/chat/version_bump_test.go+2
@@ -83,6 +83,7 @@ func TestUserMessageItem_MutatorsBumpVersion(t *testing.T) {
83 sty.Attachments.Deleting,
83 sty.Attachments.Deleting,
84 sty.Attachments.Image,
84 sty.Attachments.Image,
85 sty.Attachments.Text,
85 sty.Attachments.Text,
86+ sty.Attachments.Skill,
86 )
87 )
87 msg := &message.Message{
88 msg := &message.Message{
88 ID: "u-mut",
89 ID: "u-mut",
@@ -253,6 +254,7 @@ func TestUserMessageItem_FinishedAlwaysTrue(t *testing.T) {
253 sty.Attachments.Deleting,
254 sty.Attachments.Deleting,
254 sty.Attachments.Image,
255 sty.Attachments.Image,
255 sty.Attachments.Text,
256 sty.Attachments.Text,
257+ sty.Attachments.Skill,
256 )
258 )
257 msg := &message.Message{
259 msg := &message.Message{
258 ID: "u-fin",
260 ID: "u-fin",
internal/ui/dialog/actions.go+6
@@ -74,6 +74,12 @@ type (
74 Args map[string]string // Actual argument values
74 Args map[string]string // Actual argument values
75 Skill *skills.Skill // Set when this is a skill command
75 Skill *skills.Skill // Set when this is a skill command
76 }
76 }
77+ // ActionAttachSkill is sent when a skill is selected from the commands
78+ // dialog to be attached to the conversation as a markdown attachment.
79+ ActionAttachSkill struct {
80+ ID string
81+ Name string
82+ }
77 // ActionRunMCPPrompt is a message to run a custom command.
83 // ActionRunMCPPrompt is a message to run a custom command.
78 ActionRunMCPPrompt struct {
84 ActionRunMCPPrompt struct {
79 Title string
85 Title string
internal/ui/dialog/commands.go+14−5
@@ -390,12 +390,21 @@ func (c *Commands) setCommandItems(commandType CommandType) {
390 }
390 }
391 case UserCommands:
391 case UserCommands:
392 for _, cmd := range c.customCommands {
392 for _, cmd := range c.customCommands {
393+ var action Action
394+ if cmd.Skill != nil {
395+ action = ActionAttachSkill{ID: cmd.Skill.SkillFilePath, Name: cmd.Skill.Name}
396+ } else {
393− action := ActionRunCustomCommand{
397+ action = ActionRunCustomCommand{
394− Content: cmd.Content,
398+ Content: cmd.Content,
395− Arguments: cmd.Arguments,
399+ Arguments: cmd.Arguments,
396− Skill: cmd.Skill,
400+ Skill: cmd.Skill,
401+ }
402+ }
403+ item := NewCommandItem(c.com.Styles, "custom_"+cmd.ID, cmd.Name, "", action)
404+ if cmd.Skill != nil {
405+ item = item.WithDescription(cmd.Skill.Description)
397 }
406 }
398− commandItems = append(commandItems, NewCommandItem(c.com.Styles, "custom_"+cmd.ID, cmd.Name, "", action))
407+ commandItems = append(commandItems, item)
399 }
408 }
400 case MCPPrompts:
409 case MCPPrompts:
401 for _, cmd := range c.mcpPrompts {
410 for _, cmd := range c.mcpPrompts {
internal/ui/dialog/commands_item.go+42−13
@@ -3,23 +3,26 @@ package dialog
3import (
3import (
4 "strings"
4 "strings"
5
5
6+ "charm.land/lipgloss/v2"
6 "github.com/charmbracelet/crush/internal/ui/list"
7 "github.com/charmbracelet/crush/internal/ui/list"
7 "github.com/charmbracelet/crush/internal/ui/styles"
8 "github.com/charmbracelet/crush/internal/ui/styles"
9+ "github.com/charmbracelet/x/ansi"
8 "github.com/sahilm/fuzzy"
10 "github.com/sahilm/fuzzy"
9)
11)
10
12
11// CommandItem wraps a uicmd.Command to implement the ListItem interface.
13// CommandItem wraps a uicmd.Command to implement the ListItem interface.
12type CommandItem struct {
14type CommandItem struct {
13 *list.Versioned
15 *list.Versioned
14− id string
16+ id string
15− title string
17+ title string
16− shortcut string
18+ shortcut string
19+ description string
17− action Action
20+ action Action
18− aliases []string
21+ aliases []string
19− t *styles.Styles
22+ t *styles.Styles
20− m fuzzy.Match
23+ m fuzzy.Match
21− cache map[int]string
24+ cache map[int]string
22− focused bool
25+ focused bool
23}
26}
24
27
25var _ ListItem = &CommandItem{Versioned: list.NewVersioned()}
28var _ ListItem = &CommandItem{Versioned: list.NewVersioned()}
@@ -48,12 +51,23 @@ func (c *CommandItem) WithAliases(aliases ...string) *CommandItem {
48 return c
51 return c
49}
52}
50
53
54+// WithDescription returns the CommandItem with a description displayed below
55+// the title.
56+func (c *CommandItem) WithDescription(desc string) *CommandItem {
57+ c.description = desc
58+ return c
59+}
60+
51// Filter implements ListItem.
61// Filter implements ListItem.
52func (c *CommandItem) Filter() string {
62func (c *CommandItem) Filter() string {
63+ base := c.title
53− if len(c.aliases) == 0 {
64+ if len(c.aliases) > 0 {
54− return c.title
65+ base = c.title + " " + strings.Join(c.aliases, " ")
55 }
66 }
56− return c.title + " " + strings.Join(c.aliases, " ")
67+ if c.description != "" {
68+ base = base + " " + c.description
69+ }
70+ return base
57}
71}
58
72
59// ID implements ListItem.
73// ID implements ListItem.
@@ -103,5 +117,20 @@ func (c *CommandItem) Render(width int) string {
103 InfoTextBlurred: c.t.Dialog.ListItem.InfoBlurred,
117 InfoTextBlurred: c.t.Dialog.ListItem.InfoBlurred,
104 InfoTextFocused: c.t.Dialog.ListItem.InfoFocused,
118 InfoTextFocused: c.t.Dialog.ListItem.InfoFocused,
105 }
119 }
106− return renderItem(styles, c.title, c.shortcut, c.focused, width, c.cache, &c.m)
120+ rendered := renderItem(styles, c.title, c.shortcut, c.focused, width, c.cache, &c.m)
121+ if c.description != "" {
122+ descStyle := c.t.Dialog.SecondaryText
123+ if c.focused {
124+ descStyle = c.t.Dialog.SelectedItem
125+ }
126+ contentWidth := max(0, width-descStyle.GetHorizontalFrameSize()+1)
127+ description := ansi.Truncate(strings.TrimSpace(c.description), contentWidth, "...")
128+ descVisWidth := lipgloss.Width(description)
129+ gap := strings.Repeat(" ", max(0, contentWidth-descVisWidth))
130+ if description == "" {
131+ description = " "
132+ }
133+ rendered = lipgloss.JoinVertical(lipgloss.Left, rendered, descStyle.Render(description+gap))
134+ }
135+ return rendered
107}
136}
internal/ui/model/ui.go+29
@@ -317,6 +317,7 @@ func New(com *common.Common, initialSessionID string, continueLast bool) *UI {
317 com.Styles.Attachments.Deleting,
317 com.Styles.Attachments.Deleting,
318 com.Styles.Attachments.Image,
318 com.Styles.Attachments.Image,
319 com.Styles.Attachments.Text,
319 com.Styles.Attachments.Text,
320+ com.Styles.Attachments.Skill,
320 ),
321 ),
321 attachments.Keymap{
322 attachments.Keymap{
322 DeleteMode: keyMap.Editor.AttachmentDeleteMode,
323 DeleteMode: keyMap.Editor.AttachmentDeleteMode,
@@ -1551,6 +1552,9 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
1551 }
1552 }
1552 cmds = append(cmds, m.sendMessage(content))
1553 cmds = append(cmds, m.sendMessage(content))
1553 m.dialog.CloseFrontDialog()
1554 m.dialog.CloseFrontDialog()
1555+ case dialog.ActionAttachSkill:
1556+ m.dialog.CloseFrontDialog()
1557+ cmds = append(cmds, m.attachSkill(msg.ID, msg.Name))
1554 case dialog.ActionRunMCPPrompt:
1558 case dialog.ActionRunMCPPrompt:
1555 if len(msg.Arguments) > 0 && msg.Args == nil {
1559 if len(msg.Arguments) > 0 && msg.Args == nil {
1556 m.dialog.CloseFrontDialog()
1560 m.dialog.CloseFrontDialog()
@@ -3136,12 +3140,37 @@ func (m *UI) refreshStyles() {
3136 t.Attachments.Deleting,
3140 t.Attachments.Deleting,
3137 t.Attachments.Image,
3141 t.Attachments.Image,
3138 t.Attachments.Text,
3142 t.Attachments.Text,
3143+ t.Attachments.Skill,
3139 )
3144 )
3140 m.todoSpinner.Style = t.Pills.TodoSpinner
3145 m.todoSpinner.Style = t.Pills.TodoSpinner
3141 m.status.help.Styles = t.Help
3146 m.status.help.Styles = t.Help
3142 m.chat.InvalidateRenderCaches()
3147 m.chat.InvalidateRenderCaches()
3143}
3148}
3144
3149
3150+// attachSkill reads a skill's content by ID and returns it as a markdown
3151+// attachment to be added to the attachment toolbar. The user can then
3152+// compose a message and send it with the skill attached.
3153+// The name parameter is used as a fallback when the server does not
3154+// return one.
3155+func (m *UI) attachSkill(skillID, name string) tea.Cmd {
3156+ return func() tea.Msg {
3157+ content, result, err := m.com.Workspace.ReadSkill(context.Background(), skillID)
3158+ if err != nil {
3159+ return util.NewErrorMsg(err)
3160+ }
3161+ fileName := result.Name
3162+ if fileName == "" {
3163+ fileName = name
3164+ }
3165+ return message.Attachment{
3166+ FilePath: fileName,
3167+ FileName: fileName,
3168+ MimeType: "text/markdown",
3169+ Content: content,
3170+ }
3171+ }
3172+}
3173+
3145// sendMessage sends a message with the given content and attachments.
3174// sendMessage sends a message with the given content and attachments.
3146func (m *UI) sendMessage(content string, attachments ...message.Attachment) tea.Cmd {
3175func (m *UI) sendMessage(content string, attachments ...message.Attachment) tea.Cmd {
3147 if !m.com.Workspace.AgentIsReady() {
3176 if !m.com.Workspace.AgentIsReady() {
internal/ui/styles/quickstyle.go+1
@@ -923,6 +923,7 @@ func quickStyle(o quickStyleOpts) Styles {
923 attachmentIconStyle := base.Foreground(o.bgLessVisible).Background(o.success).Padding(0, 1)
923 attachmentIconStyle := base.Foreground(o.bgLessVisible).Background(o.success).Padding(0, 1)
924 s.Attachments.Image = attachmentIconStyle.SetString(ImageIcon)
924 s.Attachments.Image = attachmentIconStyle.SetString(ImageIcon)
925 s.Attachments.Text = attachmentIconStyle.SetString(TextIcon)
925 s.Attachments.Text = attachmentIconStyle.SetString(TextIcon)
926+ s.Attachments.Skill = attachmentIconStyle.SetString(SkillIcon)
926 s.Attachments.Normal = base.Padding(0, 1).MarginRight(1).Background(o.fgMoreSubtle).Foreground(o.fgBase)
927 s.Attachments.Normal = base.Padding(0, 1).MarginRight(1).Background(o.fgMoreSubtle).Foreground(o.fgBase)
927 s.Attachments.Deleting = base.Padding(0, 1).Bold(true).Background(o.destructive).Foreground(o.fgBase)
928 s.Attachments.Deleting = base.Padding(0, 1).Bold(true).Background(o.destructive).Foreground(o.fgBase)
928
929
internal/ui/styles/styles.go+2
@@ -43,6 +43,7 @@ const (
43
43
44 ImageIcon string = "■"
44 ImageIcon string = "■"
45 TextIcon string = "≡"
45 TextIcon string = "≡"
46+ SkillIcon string = "▲"
46
47
47 ScrollbarThumb string = "┃"
48 ScrollbarThumb string = "┃"
48 ScrollbarTrack string = "│"
49 ScrollbarTrack string = "│"
@@ -502,6 +503,7 @@ type Styles struct {
502 Normal lipgloss.Style
503 Normal lipgloss.Style
503 Image lipgloss.Style
504 Image lipgloss.Style
504 Text lipgloss.Style
505 Text lipgloss.Style
506+ Skill lipgloss.Style
505 Deleting lipgloss.Style
507 Deleting lipgloss.Style
506 }
508 }
507
509
internal/workspace/app_workspace.go+11
@@ -18,6 +18,7 @@ import (
18 "github.com/charmbracelet/crush/internal/oauth"
18 "github.com/charmbracelet/crush/internal/oauth"
19 "github.com/charmbracelet/crush/internal/permission"
19 "github.com/charmbracelet/crush/internal/permission"
20 "github.com/charmbracelet/crush/internal/session"
20 "github.com/charmbracelet/crush/internal/session"
21+ "github.com/charmbracelet/crush/internal/skills"
21)
22)
22
23
23// AppWorkspace implements the Workspace interface by delegating
24// AppWorkspace implements the Workspace interface by delegating
@@ -304,6 +305,16 @@ func (w *AppWorkspace) InitializePrompt() (string, error) {
304 return agent.InitializePrompt(w.store)
305 return agent.InitializePrompt(w.store)
305}
306}
306
307
308+func (w *AppWorkspace) ListSkills(_ context.Context) ([]skills.CatalogEntry, error) {
309+ mgr := w.app.Skills
310+ return skills.Catalog(mgr.ActiveSkills(), mgr.ResolvedPaths(), mgr.WorkingDir()), nil
311+}
312+
313+func (w *AppWorkspace) ReadSkill(_ context.Context, skillID string) ([]byte, skills.SkillReadResult, error) {
314+ mgr := w.app.Skills
315+ return skills.ReadContent(mgr.ActiveSkills(), mgr.ResolvedPaths(), mgr.WorkingDir(), skillID)
316+}
317+
307// -- MCP operations --
318// -- MCP operations --
308
319
309func (w *AppWorkspace) MCPGetStates() map[string]mcptools.ClientInfo {
320func (w *AppWorkspace) MCPGetStates() map[string]mcptools.ClientInfo {
internal/workspace/client_workspace.go+31
@@ -483,6 +483,37 @@ func (w *ClientWorkspace) InitializePrompt() (string, error) {
483 return w.client.GetInitializePrompt(context.Background(), w.workspaceID())
483 return w.client.GetInitializePrompt(context.Background(), w.workspaceID())
484}
484}
485
485
486+func (w *ClientWorkspace) ListSkills(ctx context.Context) ([]skills.CatalogEntry, error) {
487+ entries, err := w.client.ListSkills(ctx, w.workspaceID())
488+ if err != nil {
489+ return nil, err
490+ }
491+ result := make([]skills.CatalogEntry, len(entries))
492+ for i, entry := range entries {
493+ result[i] = skills.CatalogEntry{
494+ ID: entry.ID,
495+ Name: entry.Name,
496+ Description: entry.Description,
497+ Label: entry.Label,
498+ Source: skills.SourceType(entry.Source),
499+ }
500+ }
501+ return result, nil
502+}
503+
504+func (w *ClientWorkspace) ReadSkill(ctx context.Context, skillID string) ([]byte, skills.SkillReadResult, error) {
505+ resp, err := w.client.ReadSkill(ctx, w.workspaceID(), skillID)
506+ if err != nil {
507+ return nil, skills.SkillReadResult{}, err
508+ }
509+ return resp.Content, skills.SkillReadResult{
510+ Name: resp.Result.Name,
511+ Description: resp.Result.Description,
512+ Source: skills.SourceType(resp.Result.Source),
513+ Builtin: resp.Result.Builtin,
514+ }, nil
515+}
516+
486// -- MCP operations --
517// -- MCP operations --
487
518
488func (w *ClientWorkspace) MCPGetStates() map[string]mcp.ClientInfo {
519func (w *ClientWorkspace) MCPGetStates() map[string]mcp.ClientInfo {
internal/workspace/workspace.go+3
@@ -18,6 +18,7 @@ import (
18 "github.com/charmbracelet/crush/internal/oauth"
18 "github.com/charmbracelet/crush/internal/oauth"
19 "github.com/charmbracelet/crush/internal/permission"
19 "github.com/charmbracelet/crush/internal/permission"
20 "github.com/charmbracelet/crush/internal/session"
20 "github.com/charmbracelet/crush/internal/session"
21+ "github.com/charmbracelet/crush/internal/skills"
21)
22)
22
23
23// LSPClientInfo holds information about an LSP client's state. This is
24// LSPClientInfo holds information about an LSP client's state. This is
@@ -127,6 +128,8 @@ type Workspace interface {
127 ProjectNeedsInitialization() (bool, error)
128 ProjectNeedsInitialization() (bool, error)
128 MarkProjectInitialized() error
129 MarkProjectInitialized() error
129 InitializePrompt() (string, error)
130 InitializePrompt() (string, error)
131+ ListSkills(ctx context.Context) ([]skills.CatalogEntry, error)
132+ ReadSkill(ctx context.Context, skillID string) ([]byte, skills.SkillReadResult, error)
130
133
131 // MCP operations (server-side in client mode)
134 // MCP operations (server-side in client mode)
132 MCPGetStates() map[string]mcptools.ClientInfo
135 MCPGetStates() map[string]mcptools.ClientInfo