Detailed changes
@@ -112,8 +112,12 @@ func (b *Backend) CreateWorkspace(args proto.Workspace) (*Workspace, proto.Works
// hosts multiple workspaces concurrently, so the manager is
// constructed WITHOUT WithGlobalMirror to prevent last-writer-wins
// cross-talk between workspaces.
- allSkills, activeSkills, skillStates := skills.DiscoverFromConfig(skillsDiscoveryConfig(cfg))
- skillsMgr := skills.NewManager(allSkills, activeSkills, skillStates)
+ discoveryCfg := skillsDiscoveryConfig(cfg)
+ allSkills, activeSkills, skillStates := skills.DiscoverFromConfig(discoveryCfg)
+ skillsMgr := skills.NewManager(allSkills, activeSkills, skillStates,
+ skills.WithResolvedPaths(discoveryCfg.ResolvePaths()),
+ skills.WithWorkingDir(discoveryCfg.WorkingDir),
+ )
appWorkspace, err := app.New(b.ctx, conn, cfg, skillsMgr)
if err != nil {
@@ -173,6 +177,7 @@ func skillsDiscoveryConfig(cfg *config.ConfigStore) skills.DiscoveryConfig {
return skills.DiscoveryConfig{
SkillsPaths: paths,
DisabledSkills: disabled,
+ WorkingDir: cfg.WorkingDir(),
Resolver: resolver,
}
}
@@ -10,6 +10,8 @@ import (
"github.com/charmbracelet/crush/internal/commands"
"github.com/charmbracelet/crush/internal/config"
"github.com/charmbracelet/crush/internal/oauth"
+ "github.com/charmbracelet/crush/internal/proto"
+ "github.com/charmbracelet/crush/internal/skills"
)
// MCPResourceContents holds the contents of an MCP resource returned
@@ -116,6 +118,49 @@ func (b *Backend) InitializePrompt(workspaceID string) (string, error) {
return agent.InitializePrompt(ws.Cfg)
}
+// ReadSkill reads a skill's content by ID.
+func (b *Backend) ReadSkill(ctx context.Context, workspaceID, skillID string) ([]byte, proto.SkillReadResult, error) {
+ ws, err := b.GetWorkspace(workspaceID)
+ if err != nil {
+ return nil, proto.SkillReadResult{}, err
+ }
+
+ mgr := ws.Skills
+ content, result, err := skills.ReadContent(
+ mgr.ActiveSkills(), mgr.ResolvedPaths(), mgr.WorkingDir(), skillID,
+ )
+ if err != nil {
+ return nil, proto.SkillReadResult{}, err
+ }
+ return content, proto.SkillReadResult{
+ Name: result.Name,
+ Description: result.Description,
+ Source: string(result.Source),
+ Builtin: result.Builtin,
+ }, nil
+}
+
+// ListSkills returns the effective visible skills for a workspace.
+func (b *Backend) ListSkills(workspaceID string) ([]proto.SkillInfo, error) {
+ ws, err := b.GetWorkspace(workspaceID)
+ if err != nil {
+ return nil, err
+ }
+ mgr := ws.Skills
+ entries := skills.Catalog(mgr.ActiveSkills(), mgr.ResolvedPaths(), mgr.WorkingDir())
+ result := make([]proto.SkillInfo, len(entries))
+ for i, entry := range entries {
+ result[i] = proto.SkillInfo{
+ ID: entry.ID,
+ Name: entry.Name,
+ Description: entry.Description,
+ Label: entry.Label,
+ Source: string(entry.Source),
+ }
+ }
+ return result, nil
+}
+
// EnableDockerMCP validates Docker MCP availability, stages the
// configuration, starts the MCP client, and persists the config.
func (b *Backend) EnableDockerMCP(ctx context.Context, workspaceID string) error {
@@ -216,6 +216,42 @@ func (c *Client) GetInitializePrompt(ctx context.Context, id string) (string, er
return result.Prompt, nil
}
+// ListSkills retrieves the visible skills for a workspace.
+func (c *Client) ListSkills(ctx context.Context, id string) ([]proto.SkillInfo, error) {
+ rsp, err := c.get(ctx, fmt.Sprintf("/workspaces/%s/skills", id), nil, nil)
+ if err != nil {
+ return nil, fmt.Errorf("failed to list skills: %w", err)
+ }
+ defer rsp.Body.Close()
+ if rsp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("failed to list skills: status code %d", rsp.StatusCode)
+ }
+ var skills []proto.SkillInfo
+ if err := json.NewDecoder(rsp.Body).Decode(&skills); err != nil {
+ return nil, fmt.Errorf("failed to decode skills: %w", err)
+ }
+ return skills, nil
+}
+
+// ReadSkill reads a skill's content by ID from the server.
+func (c *Client) ReadSkill(ctx context.Context, id, skillID string) (*proto.ReadSkillResponse, error) {
+ rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/skills/read", id), nil, jsonBody(proto.ReadSkillRequest{
+ SkillID: skillID,
+ }), http.Header{"Content-Type": []string{"application/json"}})
+ if err != nil {
+ return nil, fmt.Errorf("failed to read skill: %w", err)
+ }
+ defer rsp.Body.Close()
+ if rsp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("failed to read skill: status code %d", rsp.StatusCode)
+ }
+ var result proto.ReadSkillResponse
+ if err := json.NewDecoder(rsp.Body).Decode(&result); err != nil {
+ return nil, fmt.Errorf("failed to decode skill response: %w", err)
+ }
+ return &result, nil
+}
+
// MCPResourceContents holds the contents of an MCP resource.
type MCPResourceContents struct {
URI string `json:"uri"`
@@ -291,8 +291,13 @@ func setupLocalWorkspace(cmd *cobra.Command) (workspace.Workspace, func(), error
// workspace per process, so WithGlobalMirror keeps the package
// globals (which the TUI reads via skills.GetLatestStates) in sync
// with the manager.
- allSkills, activeSkills, skillStates := skills.DiscoverFromConfig(localSkillsDiscoveryConfig(store))
- skillsMgr := skills.NewManager(allSkills, activeSkills, skillStates, skills.WithGlobalMirror())
+ discoveryCfg := localSkillsDiscoveryConfig(store)
+ allSkills, activeSkills, skillStates := skills.DiscoverFromConfig(discoveryCfg)
+ skillsMgr := skills.NewManager(allSkills, activeSkills, skillStates,
+ skills.WithGlobalMirror(),
+ skills.WithResolvedPaths(discoveryCfg.ResolvePaths()),
+ skills.WithWorkingDir(discoveryCfg.WorkingDir),
+ )
appInstance, err := app.New(ctx, conn, store, skillsMgr)
if err != nil {
@@ -326,6 +331,7 @@ func localSkillsDiscoveryConfig(store *config.ConfigStore) skills.DiscoveryConfi
return skills.DiscoveryConfig{
SkillsPaths: paths,
DisabledSkills: disabled,
+ WorkingDir: store.WorkingDir(),
Resolver: resolver,
}
}
@@ -12,8 +12,9 @@ type Attachment struct {
Content []byte
}
-func (a Attachment) IsText() bool { return strings.HasPrefix(a.MimeType, "text/") }
-func (a Attachment) IsImage() bool { return strings.HasPrefix(a.MimeType, "image/") }
+func (a Attachment) IsText() bool { return strings.HasPrefix(a.MimeType, "text/") }
+func (a Attachment) IsImage() bool { return strings.HasPrefix(a.MimeType, "image/") }
+func (a Attachment) IsMarkdown() bool { return a.MimeType == "text/markdown" }
// ContainsTextAttachment returns true if any of the attachments is a text attachment.
func ContainsTextAttachment(attachments []Attachment) bool {
@@ -32,6 +32,35 @@ type Error struct {
Message string `json:"message"`
}
+// SkillInfo describes a visible skill exposed to a frontend.
+type SkillInfo struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Description string `json:"description"`
+ Label string `json:"label"`
+ Source string `json:"source"`
+}
+
+// ReadSkillRequest is the request body for reading a skill's content.
+type ReadSkillRequest struct {
+ SkillID string `json:"skill_id"`
+}
+
+// ReadSkillResponse is the response for reading a skill's content.
+type ReadSkillResponse struct {
+ Content []byte `json:"content"`
+ Result SkillReadResult `json:"result"`
+}
+
+// SkillReadResult holds metadata about a skill returned alongside its
+// content.
+type SkillReadResult struct {
+ Name string `json:"name"`
+ Description string `json:"description"`
+ Source string `json:"source"`
+ Builtin bool `json:"builtin"`
+}
+
// AgentInfo represents information about the agent.
type AgentInfo struct {
IsBusy bool `json:"is_busy"`
@@ -266,6 +266,57 @@ func (c *controllerV1) handleGetWorkspaceProjectInitPrompt(w http.ResponseWriter
jsonEncode(w, proto.ProjectInitPromptResponse{Prompt: prompt})
}
+// handleGetWorkspaceSkills returns the effective visible skills for a workspace.
+//
+// @Summary List visible skills
+// @Tags skills
+// @Produce json
+// @Param id path string true "Workspace ID"
+// @Success 200 {array} proto.SkillInfo
+// @Failure 404 {object} proto.Error
+// @Failure 500 {object} proto.Error
+// @Router /workspaces/{id}/skills [get]
+func (c *controllerV1) handleGetWorkspaceSkills(w http.ResponseWriter, r *http.Request) {
+ id := r.PathValue("id")
+ skills, err := c.backend.ListSkills(id)
+ if err != nil {
+ c.handleError(w, r, err)
+ return
+ }
+ jsonEncode(w, skills)
+}
+
+// handlePostWorkspaceSkillRead reads a skill's content by ID.
+//
+// @Summary Read skill content
+// @Tags skills
+// @Accept json
+// @Produce json
+// @Param id path string true "Workspace ID"
+// @Param request body proto.ReadSkillRequest true "Read skill request"
+// @Success 200 {object} proto.ReadSkillResponse
+// @Failure 400 {object} proto.Error
+// @Failure 404 {object} proto.Error
+// @Failure 500 {object} proto.Error
+// @Router /workspaces/{id}/skills/read [post]
+func (c *controllerV1) handlePostWorkspaceSkillRead(w http.ResponseWriter, r *http.Request) {
+ id := r.PathValue("id")
+
+ var req proto.ReadSkillRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ c.server.logError(r, "Failed to decode request", "error", err)
+ jsonError(w, http.StatusBadRequest, "failed to decode request")
+ return
+ }
+
+ content, result, err := c.backend.ReadSkill(r.Context(), id, req.SkillID)
+ if err != nil {
+ c.handleError(w, r, err)
+ return
+ }
+ jsonEncode(w, proto.ReadSkillResponse{Content: content, Result: result})
+}
+
// handlePostWorkspaceMCPEnableDocker enables the Docker MCP server.
//
// @Summary Enable Docker MCP
@@ -157,6 +157,8 @@ func NewServer(cfg *config.ConfigStore, network, address string) *Server {
mux.HandleFunc("GET /v1/workspaces/{id}/project/needs-init", c.handleGetWorkspaceProjectNeedsInit)
mux.HandleFunc("POST /v1/workspaces/{id}/project/init", c.handlePostWorkspaceProjectInit)
mux.HandleFunc("GET /v1/workspaces/{id}/project/init-prompt", c.handleGetWorkspaceProjectInitPrompt)
+ mux.HandleFunc("GET /v1/workspaces/{id}/skills", c.handleGetWorkspaceSkills)
+ mux.HandleFunc("POST /v1/workspaces/{id}/skills/read", c.handlePostWorkspaceSkillRead)
mux.HandleFunc("POST /v1/workspaces/{id}/mcp/refresh-tools", c.handlePostWorkspaceMCPRefreshTools)
mux.HandleFunc("POST /v1/workspaces/{id}/mcp/read-resource", c.handlePostWorkspaceMCPReadResource)
mux.HandleFunc("POST /v1/workspaces/{id}/mcp/get-prompt", c.handlePostWorkspaceMCPGetPrompt)
@@ -0,0 +1,152 @@
+package skills
+
+import (
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+)
+
+// SourceType describes where a visible skill comes from.
+type SourceType string
+
+const (
+ SourceSystem SourceType = "system"
+ SourceUser SourceType = "user"
+ SourceProject SourceType = "project"
+)
+
+// CatalogEntry describes an effective visible skill for frontend display.
+type CatalogEntry struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Description string `json:"description"`
+ Label string `json:"label"`
+ Source SourceType `json:"source"`
+}
+
+// SkillReadResult holds metadata about a skill returned alongside its
+// content.
+type SkillReadResult struct {
+ Name string `json:"name"`
+ Description string `json:"description"`
+ Source SourceType `json:"source"`
+ Builtin bool `json:"builtin"`
+}
+
+// ErrSkillNotFound is returned when a skill ID is not part of the
+// effective visible skill set.
+var ErrSkillNotFound = errors.New("skill not found")
+
+// Catalog builds a slice of CatalogEntry values from pre-discovered
+// skills. The skillPaths and workingDir parameters are used only for
+// labelling (system / user / project); pass nil/empty when labels are
+// not needed.
+func Catalog(active []*Skill, skillPaths []string, workingDir string) []CatalogEntry {
+ entries := make([]CatalogEntry, 0, len(active))
+ for _, skill := range active {
+ label, source := skillLabel(skillPaths, workingDir, skill)
+ entries = append(entries, CatalogEntry{
+ ID: skill.SkillFilePath,
+ Name: skill.Name,
+ Description: skill.Description,
+ Label: label,
+ Source: source,
+ })
+ }
+ return entries
+}
+
+// FindEffective returns the named skill from the given active skill
+// set.
+func FindEffective(active []*Skill, skillID string) (*Skill, error) {
+ for _, skill := range active {
+ if skill.SkillFilePath == skillID {
+ return skill, nil
+ }
+ }
+ return nil, fmt.Errorf("%w: %s", ErrSkillNotFound, skillID)
+}
+
+// ReadContent reads the contents of a visible skill by ID and returns
+// the raw bytes along with metadata about the skill.
+func ReadContent(active []*Skill, skillPaths []string, workingDir string, skillID string) ([]byte, SkillReadResult, error) {
+ skill, err := FindEffective(active, skillID)
+ if err != nil {
+ return nil, SkillReadResult{}, err
+ }
+
+ _, source := skillLabel(skillPaths, workingDir, skill)
+ result := SkillReadResult{
+ Name: skill.Name,
+ Description: skill.Description,
+ Source: source,
+ Builtin: skill.Builtin,
+ }
+
+ if skill.Builtin {
+ embeddedPath := "builtin/" + strings.TrimPrefix(skill.SkillFilePath, BuiltinPrefix)
+ content, err := BuiltinFS().ReadFile(embeddedPath)
+ if err != nil {
+ return nil, SkillReadResult{}, fmt.Errorf("read builtin skill %q: %w", skillID, err)
+ }
+ return content, result, nil
+ }
+
+ content, err := os.ReadFile(skill.SkillFilePath)
+ if err != nil {
+ return nil, SkillReadResult{}, fmt.Errorf("read skill %q: %w", skillID, err)
+ }
+ return content, result, nil
+}
+
+func skillLabel(skillPaths []string, workingDir string, skill *Skill) (string, SourceType) {
+ if skill.Builtin {
+ return string(SourceSystem) + ":" + skill.Name, SourceSystem
+ }
+
+ cleanFile := filepath.Clean(skill.SkillFilePath)
+ for _, base := range skillPaths {
+ cleanBase := filepath.Clean(base)
+ rel, err := filepath.Rel(cleanBase, cleanFile)
+ if err != nil || escapesParent(rel) {
+ continue
+ }
+
+ source := SourceUser
+ prefix := string(SourceUser) + ":"
+ if isProjectSkillPath(cleanBase, workingDir) {
+ source = SourceProject
+ prefix = string(SourceProject) + ":"
+ }
+ return prefix + filepath.Base(filepath.Dir(cleanFile)), source
+ }
+
+ return string(SourceUser) + ":" + filepath.Base(filepath.Dir(cleanFile)), SourceUser
+}
+
+func escapesParent(rel string) bool {
+ return rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator))
+}
+
+func isProjectSkillPath(basePath, workingDir string) bool {
+ if workingDir == "" {
+ return false
+ }
+ absBase, err := filepath.Abs(basePath)
+ if err != nil {
+ return false
+ }
+ absWD, err := filepath.Abs(workingDir)
+ if err != nil {
+ return false
+ }
+ cleanBase := filepath.Clean(absBase)
+ cleanWD := filepath.Clean(absWD)
+ rel, err := filepath.Rel(cleanWD, cleanBase)
+ if err != nil {
+ return false
+ }
+ return !escapesParent(rel)
+}
@@ -27,6 +27,12 @@ type Manager struct {
activeSkills []*Skill
states []*SkillState
+ // resolvedPaths are the expanded SkillsPaths used during discovery.
+ // Stored so Catalog/ReadContent can label skills without
+ // re-resolving.
+ resolvedPaths []string
+ workingDir string
+
broker *pubsub.Broker[Event]
globalMirror bool
}
@@ -44,6 +50,23 @@ func WithGlobalMirror() ManagerOption {
}
}
+// WithResolvedPaths stores the expanded skills directory paths that
+// were used during discovery. Catalog and ReadContent use these for
+// source labelling.
+func WithResolvedPaths(paths []string) ManagerOption {
+ return func(m *Manager) {
+ m.resolvedPaths = paths
+ }
+}
+
+// WithWorkingDir stores the workspace working directory. Catalog and
+// ReadContent use it to distinguish project skills from user skills.
+func WithWorkingDir(dir string) ManagerOption {
+ return func(m *Manager) {
+ m.workingDir = dir
+ }
+}
+
// NewManager constructs a workspace-scoped Manager with the given
// pre-computed discovery results. The slices are stored as-is; callers
// should not mutate them afterwards.
@@ -78,6 +101,18 @@ func (m *Manager) ActiveSkills() []*Skill {
return m.activeSkills
}
+// ResolvedPaths returns the expanded skills directory paths stored at
+// construction time.
+func (m *Manager) ResolvedPaths() []string {
+ return m.resolvedPaths
+}
+
+// WorkingDir returns the workspace working directory stored at
+// construction time.
+func (m *Manager) WorkingDir() string {
+ return m.workingDir
+}
+
// States returns a clone of the latest discovery state snapshot.
func (m *Manager) States() []*SkillState {
m.mu.RLock()
@@ -140,18 +175,8 @@ func DiscoverFromConfig(cfg DiscoveryConfig) (allSkills, activeSkills []*Skill,
discovered := append([]*Skill(nil), builtin...)
var userStates []*SkillState
- var userPaths []string
- if len(cfg.SkillsPaths) > 0 {
- userPaths = make([]string, 0, len(cfg.SkillsPaths))
- for _, pth := range cfg.SkillsPaths {
- expanded := home.Long(pth)
- if strings.HasPrefix(expanded, "$") && cfg.Resolver != nil {
- if resolved, err := cfg.Resolver(expanded); err == nil {
- expanded = resolved
- }
- }
- userPaths = append(userPaths, expanded)
- }
+ userPaths := cfg.ResolvePaths()
+ if len(userPaths) > 0 {
var userSkills []*Skill
userSkills, userStates = DiscoverWithStates(userPaths)
discovered = append(discovered, userSkills...)
@@ -175,6 +200,28 @@ func DiscoverFromConfig(cfg DiscoveryConfig) (allSkills, activeSkills []*Skill,
type DiscoveryConfig struct {
SkillsPaths []string
DisabledSkills []string
+ WorkingDir string
// Resolver expands $VAR-style references in paths. May be nil.
Resolver func(string) (string, error)
}
+
+// ResolvePaths expands home-directory and $VAR references in
+// SkillsPaths. This is the canonical path-resolution logic used by
+// DiscoverFromConfig; callers that need the resolved list (e.g. for
+// Catalog labels) can call this directly.
+func (c DiscoveryConfig) ResolvePaths() []string {
+ if len(c.SkillsPaths) == 0 {
+ return nil
+ }
+ out := make([]string, 0, len(c.SkillsPaths))
+ for _, pth := range c.SkillsPaths {
+ expanded := home.Long(pth)
+ if strings.HasPrefix(expanded, "$") && c.Resolver != nil {
+ if resolved, err := c.Resolver(expanded); err == nil {
+ expanded = resolved
+ }
+ }
+ out = append(out, expanded)
+ }
+ return out
+}
@@ -82,25 +82,27 @@ func (m *Attachments) Render(width int) string {
// styles in place.
func (m *Attachments) Renderer() *Renderer { return m.renderer }
-func NewRenderer(normalStyle, deletingStyle, imageStyle, textStyle lipgloss.Style) *Renderer {
+func NewRenderer(normalStyle, deletingStyle, imageStyle, textStyle, skillStyle lipgloss.Style) *Renderer {
return &Renderer{
normalStyle: normalStyle,
textStyle: textStyle,
imageStyle: imageStyle,
+ skillStyle: skillStyle,
deletingStyle: deletingStyle,
}
}
// SetStyles updates the renderer styles in place.
-func (r *Renderer) SetStyles(normalStyle, deletingStyle, imageStyle, textStyle lipgloss.Style) {
+func (r *Renderer) SetStyles(normalStyle, deletingStyle, imageStyle, textStyle, skillStyle lipgloss.Style) {
r.normalStyle = normalStyle
r.textStyle = textStyle
r.imageStyle = imageStyle
+ r.skillStyle = skillStyle
r.deletingStyle = deletingStyle
}
type Renderer struct {
- normalStyle, textStyle, imageStyle, deletingStyle lipgloss.Style
+ normalStyle, textStyle, imageStyle, skillStyle, deletingStyle lipgloss.Style
}
func (r *Renderer) Render(attachments []message.Attachment, deleting bool, width int) string {
@@ -143,5 +145,8 @@ func (r *Renderer) icon(a message.Attachment) lipgloss.Style {
if a.IsImage() {
return r.imageStyle
}
+ if a.IsMarkdown() {
+ return r.skillStyle
+ }
return r.textStyle
}
@@ -372,6 +372,7 @@ func ExtractMessageItems(sty *styles.Styles, msg *message.Message, toolResults m
sty.Attachments.Deleting,
sty.Attachments.Image,
sty.Attachments.Text,
+ sty.Attachments.Skill,
)
return []MessageItem{NewUserMessageItem(sty, msg, r)}
case message.Assistant:
@@ -116,6 +116,7 @@ func TestUserMessageItemRender_PrefixCacheFocusBlur(t *testing.T) {
sty.Attachments.Deleting,
sty.Attachments.Image,
sty.Attachments.Text,
+ sty.Attachments.Skill,
)
item := NewUserMessageItem(&sty, msg, r).(*UserMessageItem)
@@ -83,6 +83,7 @@ func TestUserMessageItem_MutatorsBumpVersion(t *testing.T) {
sty.Attachments.Deleting,
sty.Attachments.Image,
sty.Attachments.Text,
+ sty.Attachments.Skill,
)
msg := &message.Message{
ID: "u-mut",
@@ -253,6 +254,7 @@ func TestUserMessageItem_FinishedAlwaysTrue(t *testing.T) {
sty.Attachments.Deleting,
sty.Attachments.Image,
sty.Attachments.Text,
+ sty.Attachments.Skill,
)
msg := &message.Message{
ID: "u-fin",
@@ -74,6 +74,12 @@ type (
Args map[string]string // Actual argument values
Skill *skills.Skill // Set when this is a skill command
}
+ // ActionAttachSkill is sent when a skill is selected from the commands
+ // dialog to be attached to the conversation as a markdown attachment.
+ ActionAttachSkill struct {
+ ID string
+ Name string
+ }
// ActionRunMCPPrompt is a message to run a custom command.
ActionRunMCPPrompt struct {
Title string
@@ -390,12 +390,21 @@ func (c *Commands) setCommandItems(commandType CommandType) {
}
case UserCommands:
for _, cmd := range c.customCommands {
- action := ActionRunCustomCommand{
- Content: cmd.Content,
- Arguments: cmd.Arguments,
- Skill: cmd.Skill,
+ var action Action
+ if cmd.Skill != nil {
+ action = ActionAttachSkill{ID: cmd.Skill.SkillFilePath, Name: cmd.Skill.Name}
+ } else {
+ action = ActionRunCustomCommand{
+ Content: cmd.Content,
+ Arguments: cmd.Arguments,
+ Skill: cmd.Skill,
+ }
+ }
+ item := NewCommandItem(c.com.Styles, "custom_"+cmd.ID, cmd.Name, "", action)
+ if cmd.Skill != nil {
+ item = item.WithDescription(cmd.Skill.Description)
}
- commandItems = append(commandItems, NewCommandItem(c.com.Styles, "custom_"+cmd.ID, cmd.Name, "", action))
+ commandItems = append(commandItems, item)
}
case MCPPrompts:
for _, cmd := range c.mcpPrompts {
@@ -3,23 +3,26 @@ package dialog
import (
"strings"
+ "charm.land/lipgloss/v2"
"github.com/charmbracelet/crush/internal/ui/list"
"github.com/charmbracelet/crush/internal/ui/styles"
+ "github.com/charmbracelet/x/ansi"
"github.com/sahilm/fuzzy"
)
// CommandItem wraps a uicmd.Command to implement the ListItem interface.
type CommandItem struct {
*list.Versioned
- id string
- title string
- shortcut string
- action Action
- aliases []string
- t *styles.Styles
- m fuzzy.Match
- cache map[int]string
- focused bool
+ id string
+ title string
+ shortcut string
+ description string
+ action Action
+ aliases []string
+ t *styles.Styles
+ m fuzzy.Match
+ cache map[int]string
+ focused bool
}
var _ ListItem = &CommandItem{Versioned: list.NewVersioned()}
@@ -48,12 +51,23 @@ func (c *CommandItem) WithAliases(aliases ...string) *CommandItem {
return c
}
+// WithDescription returns the CommandItem with a description displayed below
+// the title.
+func (c *CommandItem) WithDescription(desc string) *CommandItem {
+ c.description = desc
+ return c
+}
+
// Filter implements ListItem.
func (c *CommandItem) Filter() string {
- if len(c.aliases) == 0 {
- return c.title
+ base := c.title
+ if len(c.aliases) > 0 {
+ base = c.title + " " + strings.Join(c.aliases, " ")
}
- return c.title + " " + strings.Join(c.aliases, " ")
+ if c.description != "" {
+ base = base + " " + c.description
+ }
+ return base
}
// ID implements ListItem.
@@ -103,5 +117,20 @@ func (c *CommandItem) Render(width int) string {
InfoTextBlurred: c.t.Dialog.ListItem.InfoBlurred,
InfoTextFocused: c.t.Dialog.ListItem.InfoFocused,
}
- return renderItem(styles, c.title, c.shortcut, c.focused, width, c.cache, &c.m)
+ rendered := renderItem(styles, c.title, c.shortcut, c.focused, width, c.cache, &c.m)
+ if c.description != "" {
+ descStyle := c.t.Dialog.SecondaryText
+ if c.focused {
+ descStyle = c.t.Dialog.SelectedItem
+ }
+ contentWidth := max(0, width-descStyle.GetHorizontalFrameSize()+1)
+ description := ansi.Truncate(strings.TrimSpace(c.description), contentWidth, "...")
+ descVisWidth := lipgloss.Width(description)
+ gap := strings.Repeat(" ", max(0, contentWidth-descVisWidth))
+ if description == "" {
+ description = " "
+ }
+ rendered = lipgloss.JoinVertical(lipgloss.Left, rendered, descStyle.Render(description+gap))
+ }
+ return rendered
}
@@ -317,6 +317,7 @@ func New(com *common.Common, initialSessionID string, continueLast bool) *UI {
com.Styles.Attachments.Deleting,
com.Styles.Attachments.Image,
com.Styles.Attachments.Text,
+ com.Styles.Attachments.Skill,
),
attachments.Keymap{
DeleteMode: keyMap.Editor.AttachmentDeleteMode,
@@ -1551,6 +1552,9 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
}
cmds = append(cmds, m.sendMessage(content))
m.dialog.CloseFrontDialog()
+ case dialog.ActionAttachSkill:
+ m.dialog.CloseFrontDialog()
+ cmds = append(cmds, m.attachSkill(msg.ID, msg.Name))
case dialog.ActionRunMCPPrompt:
if len(msg.Arguments) > 0 && msg.Args == nil {
m.dialog.CloseFrontDialog()
@@ -3136,12 +3140,37 @@ func (m *UI) refreshStyles() {
t.Attachments.Deleting,
t.Attachments.Image,
t.Attachments.Text,
+ t.Attachments.Skill,
)
m.todoSpinner.Style = t.Pills.TodoSpinner
m.status.help.Styles = t.Help
m.chat.InvalidateRenderCaches()
}
+// attachSkill reads a skill's content by ID and returns it as a markdown
+// attachment to be added to the attachment toolbar. The user can then
+// compose a message and send it with the skill attached.
+// The name parameter is used as a fallback when the server does not
+// return one.
+func (m *UI) attachSkill(skillID, name string) tea.Cmd {
+ return func() tea.Msg {
+ content, result, err := m.com.Workspace.ReadSkill(context.Background(), skillID)
+ if err != nil {
+ return util.NewErrorMsg(err)
+ }
+ fileName := result.Name
+ if fileName == "" {
+ fileName = name
+ }
+ return message.Attachment{
+ FilePath: fileName,
+ FileName: fileName,
+ MimeType: "text/markdown",
+ Content: content,
+ }
+ }
+}
+
// sendMessage sends a message with the given content and attachments.
func (m *UI) sendMessage(content string, attachments ...message.Attachment) tea.Cmd {
if !m.com.Workspace.AgentIsReady() {
@@ -923,6 +923,7 @@ func quickStyle(o quickStyleOpts) Styles {
attachmentIconStyle := base.Foreground(o.bgLessVisible).Background(o.success).Padding(0, 1)
s.Attachments.Image = attachmentIconStyle.SetString(ImageIcon)
s.Attachments.Text = attachmentIconStyle.SetString(TextIcon)
+ s.Attachments.Skill = attachmentIconStyle.SetString(SkillIcon)
s.Attachments.Normal = base.Padding(0, 1).MarginRight(1).Background(o.fgMoreSubtle).Foreground(o.fgBase)
s.Attachments.Deleting = base.Padding(0, 1).Bold(true).Background(o.destructive).Foreground(o.fgBase)
@@ -43,6 +43,7 @@ const (
ImageIcon string = "■"
TextIcon string = "≡"
+ SkillIcon string = "▲"
ScrollbarThumb string = "┃"
ScrollbarTrack string = "│"
@@ -502,6 +503,7 @@ type Styles struct {
Normal lipgloss.Style
Image lipgloss.Style
Text lipgloss.Style
+ Skill lipgloss.Style
Deleting lipgloss.Style
}
@@ -18,6 +18,7 @@ import (
"github.com/charmbracelet/crush/internal/oauth"
"github.com/charmbracelet/crush/internal/permission"
"github.com/charmbracelet/crush/internal/session"
+ "github.com/charmbracelet/crush/internal/skills"
)
// AppWorkspace implements the Workspace interface by delegating
@@ -304,6 +305,16 @@ func (w *AppWorkspace) InitializePrompt() (string, error) {
return agent.InitializePrompt(w.store)
}
+func (w *AppWorkspace) ListSkills(_ context.Context) ([]skills.CatalogEntry, error) {
+ mgr := w.app.Skills
+ return skills.Catalog(mgr.ActiveSkills(), mgr.ResolvedPaths(), mgr.WorkingDir()), nil
+}
+
+func (w *AppWorkspace) ReadSkill(_ context.Context, skillID string) ([]byte, skills.SkillReadResult, error) {
+ mgr := w.app.Skills
+ return skills.ReadContent(mgr.ActiveSkills(), mgr.ResolvedPaths(), mgr.WorkingDir(), skillID)
+}
+
// -- MCP operations --
func (w *AppWorkspace) MCPGetStates() map[string]mcptools.ClientInfo {
@@ -483,6 +483,37 @@ func (w *ClientWorkspace) InitializePrompt() (string, error) {
return w.client.GetInitializePrompt(context.Background(), w.workspaceID())
}
+func (w *ClientWorkspace) ListSkills(ctx context.Context) ([]skills.CatalogEntry, error) {
+ entries, err := w.client.ListSkills(ctx, w.workspaceID())
+ if err != nil {
+ return nil, err
+ }
+ result := make([]skills.CatalogEntry, len(entries))
+ for i, entry := range entries {
+ result[i] = skills.CatalogEntry{
+ ID: entry.ID,
+ Name: entry.Name,
+ Description: entry.Description,
+ Label: entry.Label,
+ Source: skills.SourceType(entry.Source),
+ }
+ }
+ return result, nil
+}
+
+func (w *ClientWorkspace) ReadSkill(ctx context.Context, skillID string) ([]byte, skills.SkillReadResult, error) {
+ resp, err := w.client.ReadSkill(ctx, w.workspaceID(), skillID)
+ if err != nil {
+ return nil, skills.SkillReadResult{}, err
+ }
+ return resp.Content, skills.SkillReadResult{
+ Name: resp.Result.Name,
+ Description: resp.Result.Description,
+ Source: skills.SourceType(resp.Result.Source),
+ Builtin: resp.Result.Builtin,
+ }, nil
+}
+
// -- MCP operations --
func (w *ClientWorkspace) MCPGetStates() map[string]mcp.ClientInfo {
@@ -18,6 +18,7 @@ import (
"github.com/charmbracelet/crush/internal/oauth"
"github.com/charmbracelet/crush/internal/permission"
"github.com/charmbracelet/crush/internal/session"
+ "github.com/charmbracelet/crush/internal/skills"
)
// LSPClientInfo holds information about an LSP client's state. This is
@@ -127,6 +128,8 @@ type Workspace interface {
ProjectNeedsInitialization() (bool, error)
MarkProjectInitialized() error
InitializePrompt() (string, error)
+ ListSkills(ctx context.Context) ([]skills.CatalogEntry, error)
+ ReadSkill(ctx context.Context, skillID string) ([]byte, skills.SkillReadResult, error)
// MCP operations (server-side in client mode)
MCPGetStates() map[string]mcptools.ClientInfo