@@ -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+}