on main help sign in
refactor(commands): centralise skill discovery

The UI for invoking skills duplicated skill discovery, but missed some of the nuances like following symlinks. This removes some of that duplicative code and wires other bits together so the UI shares the same discovery logic as the backend, fixing at least the symlink behaviour.

commit0e8b879720d219e74970525e4c9d2615ca414519 authorAmolith <amolith@secluded.site> dateTue 2 Jun 2026, 07:57 (4 months ago) parent123087a
7 files changed+112−83
internal/backend/config.go+6−5 internal/commands/commands.go+15−54 internal/commands/commands_test.go+61 internal/proto/proto.go+6−5 internal/skills/catalog.go+12−10 internal/ui/model/ui.go+6−4 internal/workspace/client_workspace.go+6−5
internal/backend/config.go+6−5
@@ -196,11 +196,12 @@ func (b *Backend) ListSkills(workspaceID string) ([]proto.SkillInfo, error) {
196196 result := make([]proto.SkillInfo, len(entries))
197197 for i, entry := range entries {
198198 result[i] = proto.SkillInfo{
199− ID: entry.ID,
200− Name: entry.Name,
201− Description: entry.Description,
202− Label: entry.Label,
203− Source: string(entry.Source),
199+ ID: entry.ID,
200+ Name: entry.Name,
201+ Description: entry.Description,
202+ Label: entry.Label,
203+ Source: string(entry.Source),
204+ UserInvocable: entry.UserInvocable,
204205 }
205206 }
206207 return result, nil
internal/commands/commands.go+15−54
@@ -61,67 +61,28 @@ func LoadCustomCommands(cfg *config.Config) ([]CustomCommand, error) {
6161 return loadAll(buildCommandSources(cfg))
6262}
6363
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−
64+// FromSkillCatalog converts user-invocable catalog entries into custom
65+// command entries for the command palette.
66+func FromSkillCatalog(entries []skills.CatalogEntry) []CustomCommand {
67+ commands := make([]CustomCommand, 0, len(entries))
10068 for _, entry := range entries {
101− if !entry.IsDir() {
69+ if !entry.UserInvocable {
10270 continue
10371 }
104−
105− skillPath := filepath.Join(dir, entry.Name(), skills.SkillFileName)
106− skill, err := skills.Parse(skillPath)
107− if err != nil {
108− continue
72+ name := entry.Label
73+ if name == "" {
74+ name = userCommandPrefix + entry.Name
10975 }
110−
111− if !skill.UserInvocable {
112− continue
113− }
114−
115− name := prefix + skill.Name
11676 commands = append(commands, CustomCommand{
117− ID: name,
118− Name: name,
119− Content: skill.Instructions,
120− Arguments: nil,
121− Skill: skill,
77+ ID: name,
78+ Name: name,
79+ Skill: &skills.Skill{
80+ Name: entry.Name,
81+ Description: entry.Description,
82+ SkillFilePath: entry.ID,
83+ },
12284 })
12385 }
124−
12586 return commands
12687}
12788
internal/commands/commands_test.go+61
@@ -3,8 +3,10 @@ package commands
33import (
44 "os"
55 "path/filepath"
6+ "runtime"
67 "testing"
78
9+ "github.com/charmbracelet/crush/internal/skills"
810 "github.com/stretchr/testify/require"
911)
1012
@@ -51,3 +53,62 @@ func TestLoadAll_MixedSources(t *testing.T) {
5153 require.Len(t, cmds, 1)
5254 require.Equal(t, "user:cmd", cmds[0].ID)
5355}
56+
57+func TestFromSkillCatalog_UserInvocableOnly(t *testing.T) {
58+ t.Parallel()
59+
60+ cmds := FromSkillCatalog([]skills.CatalogEntry{
61+ {
62+ ID: "/skills/on/SKILL.md",
63+ Name: "on",
64+ Description: "Enabled.",
65+ Label: "user:on",
66+ UserInvocable: true,
67+ },
68+ {
69+ ID: "/skills/off/SKILL.md",
70+ Name: "off",
71+ Description: "Not invocable.",
72+ Label: "user:off",
73+ UserInvocable: false,
74+ },
75+ })
76+
77+ require.Len(t, cmds, 1)
78+ require.Equal(t, "user:on", cmds[0].ID)
79+ require.Equal(t, "user:on", cmds[0].Name)
80+ require.Equal(t, "on", cmds[0].Skill.Name)
81+ require.Equal(t, "Enabled.", cmds[0].Skill.Description)
82+ require.Equal(t, "/skills/on/SKILL.md", cmds[0].Skill.SkillFilePath)
83+}
84+
85+func TestFromSkillCatalog_UsesDiscoveredSymlinkedSkills(t *testing.T) {
86+ if runtime.GOOS == "windows" {
87+ t.Skip("symlink creation requires special privileges on Windows")
88+ }
89+ t.Parallel()
90+
91+ root := t.TempDir()
92+ targetParent := t.TempDir()
93+ targetSkillDir := filepath.Join(targetParent, "linked-skill")
94+ require.NoError(t, os.MkdirAll(targetSkillDir, 0o755))
95+ require.NoError(t, os.WriteFile(
96+ filepath.Join(targetSkillDir, skills.SkillFileName),
97+ []byte("---\nname: linked-skill\ndescription: Symlinked.\nuser-invocable: true\n---\nUse me.\n"),
98+ 0o644,
99+ ))
100+
101+ link := filepath.Join(root, "linked-skill")
102+ require.NoError(t, os.Symlink(targetSkillDir, link))
103+
104+ _, activeSkills, _ := skills.DiscoverFromConfig(skills.DiscoveryConfig{
105+ SkillsPaths: []string{root},
106+ })
107+ entries := skills.Catalog(activeSkills, []string{root}, "")
108+ cmds := FromSkillCatalog(entries)
109+
110+ require.Len(t, cmds, 1)
111+ require.Equal(t, "user:linked-skill", cmds[0].ID)
112+ require.Equal(t, "linked-skill", cmds[0].Skill.Name)
113+ require.Equal(t, filepath.Join(link, skills.SkillFileName), cmds[0].Skill.SkillFilePath)
114+}
internal/proto/proto.go+6−5
@@ -73,11 +73,12 @@ type RunComplete struct {
7373
7474// SkillInfo describes a visible skill exposed to a frontend.
7575type SkillInfo struct {
76− ID string `json:"id"`
77− Name string `json:"name"`
78− Description string `json:"description"`
79− Label string `json:"label"`
80− Source string `json:"source"`
76+ ID string `json:"id"`
77+ Name string `json:"name"`
78+ Description string `json:"description"`
79+ Label string `json:"label"`
80+ Source string `json:"source"`
81+ UserInvocable bool `json:"user_invocable"`
8182}
8283
8384// ReadSkillRequest is the request body for reading a skill's content.
internal/skills/catalog.go+12−10
@@ -19,11 +19,12 @@ const (
1919
2020// CatalogEntry describes an effective visible skill for frontend display.
2121type 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"`
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+ UserInvocable bool `json:"user_invocable"`
2728}
2829
2930// SkillReadResult holds metadata about a skill returned alongside its
@@ -48,11 +49,12 @@ func Catalog(active []*Skill, skillPaths []string, workingDir string) []CatalogE
4849 for _, skill := range active {
4950 label, source := skillLabel(skillPaths, workingDir, skill)
5051 entries = append(entries, CatalogEntry{
51− ID: skill.SkillFilePath,
52− Name: skill.Name,
53− Description: skill.Description,
54− Label: label,
55− Source: source,
52+ ID: skill.SkillFilePath,
53+ Name: skill.Name,
54+ Description: skill.Description,
55+ Label: label,
56+ Source: source,
57+ UserInvocable: skill.UserInvocable,
5658 })
5759 }
5860 return entries
internal/ui/model/ui.go+6−4
@@ -527,10 +527,12 @@ func (m *UI) loadCustomCommands() tea.Cmd {
527527 if err != nil {
528528 slog.Error("Failed to load custom commands", "error", err)
529529 }
530− // Append user-invocable skills as commands
531− skillCommands := commands.LoadSkillCommands()
532− skillCommands = append(skillCommands, commands.LoadProjectSkillCommands(m.com.Workspace.WorkingDir())...)
533− customCommands = append(customCommands, skillCommands...)
530+ // Append user-invocable skills as commands.
531+ skillEntries, err := m.com.Workspace.ListSkills(context.Background())
532+ if err != nil {
533+ slog.Error("Failed to load skill commands", "error", err)
534+ }
535+ customCommands = append(customCommands, commands.FromSkillCatalog(skillEntries)...)
534536 return userCommandsLoadedMsg{Commands: customCommands}
535537 }
536538}
internal/workspace/client_workspace.go+6−5
@@ -506,11 +506,12 @@ func (w *ClientWorkspace) ListSkills(ctx context.Context) ([]skills.CatalogEntry
506506 result := make([]skills.CatalogEntry, len(entries))
507507 for i, entry := range entries {
508508 result[i] = skills.CatalogEntry{
509− ID: entry.ID,
510− Name: entry.Name,
511− Description: entry.Description,
512− Label: entry.Label,
513− Source: skills.SourceType(entry.Source),
509+ ID: entry.ID,
510+ Name: entry.Name,
511+ Description: entry.Description,
512+ Label: entry.Label,
513+ Source: skills.SourceType(entry.Source),
514+ UserInvocable: entry.UserInvocable,
514515 }
515516 }
516517 return result, nil