on main help sign in
refactor(commands): centralise skill discovery
0e8b879Amolith
internal/backend/config.go+6−5
@@ -196,11 +196,12 @@ func (b *Backend) ListSkills(workspaceID string) ([]proto.SkillInfo, error) {
196 result := make([]proto.SkillInfo, len(entries))
196 result := make([]proto.SkillInfo, len(entries))
197 for i, entry := range entries {
197 for i, entry := range entries {
198 result[i] = proto.SkillInfo{
198 result[i] = proto.SkillInfo{
199+ ID: entry.ID,
199− ID: entry.ID,
200+ Name: entry.Name,
200− Name: entry.Name,
201+ Description: entry.Description,
201− Description: entry.Description,
202+ Label: entry.Label,
202− Label: entry.Label,
203− Source: string(entry.Source),
203+ Source: string(entry.Source),
204+ UserInvocable: entry.UserInvocable,
204 }
205 }
205 }
206 }
206 return result, nil
207 return result, nil
internal/commands/commands.go+15−54
@@ -61,67 +61,28 @@ func LoadCustomCommands(cfg *config.Config) ([]CustomCommand, error) {
61 return loadAll(buildCommandSources(cfg))
61 return loadAll(buildCommandSources(cfg))
62}
62}
63
63
64−// LoadSkillCommands loads user-invocable skills as custom commands.
64+// FromSkillCatalog converts user-invocable catalog entries into custom
65−func LoadSkillCommands() []CustomCommand {
65+// command entries for the command palette.
66− var commands []CustomCommand
66+func FromSkillCatalog(entries []skills.CatalogEntry) []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
67+ commands := make([]CustomCommand, 0, len(entries))
94−
95− entries, err := os.ReadDir(dir)
96− if err != nil {
97− return nil
98− }
99−
100 for _, entry := range entries {
68 for _, entry := range entries {
101− if !entry.IsDir() {
69+ if !entry.UserInvocable {
102 continue
70 continue
103 }
71 }
104−
72+ name := entry.Label
105− skillPath := filepath.Join(dir, entry.Name(), skills.SkillFileName)
73+ if name == "" {
106− skill, err := skills.Parse(skillPath)
74+ name = userCommandPrefix + entry.Name
107− if err != nil {
108− continue
109 }
75 }
110−
111− if !skill.UserInvocable {
112− continue
113− }
114−
115− name := prefix + skill.Name
116 commands = append(commands, CustomCommand{
76 commands = append(commands, CustomCommand{
117− ID: name,
77+ ID: name,
118− Name: name,
78+ Name: name,
119− Content: skill.Instructions,
79+ Skill: &skills.Skill{
120− Arguments: nil,
80+ Name: entry.Name,
121− Skill: skill,
81+ Description: entry.Description,
82+ SkillFilePath: entry.ID,
83+ },
122 })
84 })
123 }
85 }
124−
125 return commands
86 return commands
126}
87}
127
88
internal/commands/commands_test.go+61
@@ -3,8 +3,10 @@ package commands
3import (
3import (
4 "os"
4 "os"
5 "path/filepath"
5 "path/filepath"
6+ "runtime"
6 "testing"
7 "testing"
7
8
9+ "github.com/charmbracelet/crush/internal/skills"
8 "github.com/stretchr/testify/require"
10 "github.com/stretchr/testify/require"
9)
11)
10
12
@@ -51,3 +53,62 @@ func TestLoadAll_MixedSources(t *testing.T) {
51 require.Len(t, cmds, 1)
53 require.Len(t, cmds, 1)
52 require.Equal(t, "user:cmd", cmds[0].ID)
54 require.Equal(t, "user:cmd", cmds[0].ID)
53}
55}
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 {
73
73
74// SkillInfo describes a visible skill exposed to a frontend.
74// SkillInfo describes a visible skill exposed to a frontend.
75type SkillInfo struct {
75type SkillInfo struct {
76− ID string `json:"id"`
76+ ID string `json:"id"`
77− Name string `json:"name"`
77+ Name string `json:"name"`
78− Description string `json:"description"`
78+ Description string `json:"description"`
79− Label string `json:"label"`
79+ Label string `json:"label"`
80− Source string `json:"source"`
80+ Source string `json:"source"`
81+ UserInvocable bool `json:"user_invocable"`
81}
82}
82
83
83// ReadSkillRequest is the request body for reading a skill's content.
84// ReadSkillRequest is the request body for reading a skill's content.
internal/skills/catalog.go+12−10
@@ -19,11 +19,12 @@ const (
19
19
20// CatalogEntry describes an effective visible skill for frontend display.
20// CatalogEntry describes an effective visible skill for frontend display.
21type CatalogEntry struct {
21type CatalogEntry struct {
22− ID string `json:"id"`
22+ ID string `json:"id"`
23− Name string `json:"name"`
23+ Name string `json:"name"`
24− Description string `json:"description"`
24+ Description string `json:"description"`
25− Label string `json:"label"`
25+ Label string `json:"label"`
26− Source SourceType `json:"source"`
26+ Source SourceType `json:"source"`
27+ UserInvocable bool `json:"user_invocable"`
27}
28}
28
29
29// SkillReadResult holds metadata about a skill returned alongside its
30// SkillReadResult holds metadata about a skill returned alongside its
@@ -48,11 +49,12 @@ func Catalog(active []*Skill, skillPaths []string, workingDir string) []CatalogE
48 for _, skill := range active {
49 for _, skill := range active {
49 label, source := skillLabel(skillPaths, workingDir, skill)
50 label, source := skillLabel(skillPaths, workingDir, skill)
50 entries = append(entries, CatalogEntry{
51 entries = append(entries, CatalogEntry{
51− ID: skill.SkillFilePath,
52+ ID: skill.SkillFilePath,
52− Name: skill.Name,
53+ Name: skill.Name,
53− Description: skill.Description,
54+ Description: skill.Description,
54− Label: label,
55+ Label: label,
55− Source: source,
56+ Source: source,
57+ UserInvocable: skill.UserInvocable,
56 })
58 })
57 }
59 }
58 return entries
60 return entries
internal/ui/model/ui.go+6−4
@@ -527,10 +527,12 @@ func (m *UI) loadCustomCommands() tea.Cmd {
527 if err != nil {
527 if err != nil {
528 slog.Error("Failed to load custom commands", "error", err)
528 slog.Error("Failed to load custom commands", "error", err)
529 }
529 }
530− // Append user-invocable skills as commands
530+ // Append user-invocable skills as commands.
531− skillCommands := commands.LoadSkillCommands()
531+ skillEntries, err := m.com.Workspace.ListSkills(context.Background())
532− skillCommands = append(skillCommands, commands.LoadProjectSkillCommands(m.com.Workspace.WorkingDir())...)
532+ if err != nil {
533+ slog.Error("Failed to load skill commands", "error", err)
534+ }
533− customCommands = append(customCommands, skillCommands...)
535+ customCommands = append(customCommands, commands.FromSkillCatalog(skillEntries)...)
534 return userCommandsLoadedMsg{Commands: customCommands}
536 return userCommandsLoadedMsg{Commands: customCommands}
535 }
537 }
536}
538}
internal/workspace/client_workspace.go+6−5
@@ -506,11 +506,12 @@ func (w *ClientWorkspace) ListSkills(ctx context.Context) ([]skills.CatalogEntry
506 result := make([]skills.CatalogEntry, len(entries))
506 result := make([]skills.CatalogEntry, len(entries))
507 for i, entry := range entries {
507 for i, entry := range entries {
508 result[i] = skills.CatalogEntry{
508 result[i] = skills.CatalogEntry{
509+ ID: entry.ID,
509− ID: entry.ID,
510+ Name: entry.Name,
510− Name: entry.Name,
511+ Description: entry.Description,
511− Description: entry.Description,
512+ Label: entry.Label,
512− Label: entry.Label,
513− Source: skills.SourceType(entry.Source),
513+ Source: skills.SourceType(entry.Source),
514+ UserInvocable: entry.UserInvocable,
514 }
515 }
515 }
516 }
516 return result, nil
517 return result, nil