1package skills
2
3import (
4 "errors"
5 "fmt"
6 "os"
7 "path/filepath"
8 "strings"
9)
10
11// SourceType describes where a visible skill comes from.
12type SourceType string
13
14const (
15 SourceSystem SourceType = "system"
16 SourceUser SourceType = "user"
17 SourceProject SourceType = "project"
18)
19
20// CatalogEntry describes an effective visible skill for frontend display.
21type 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.
31type 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.
40var 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.
46func 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.
63func 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.
74func 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
104func 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
129func escapesParent(rel string) bool {
130 return rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator))
131}
132
133func 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}