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 UserInvocable bool `json:"user_invocable"`
28}
29
30// SkillReadResult holds metadata about a skill returned alongside its
31// content.
32type SkillReadResult struct {
33 Name string `json:"name"`
34 Description string `json:"description"`
35 Source SourceType `json:"source"`
36 Builtin bool `json:"builtin"`
37}
38
39// ErrSkillNotFound is returned when a skill ID is not part of the
40// effective visible skill set.
41var ErrSkillNotFound = errors.New("skill not found")
42
43// Catalog builds a slice of CatalogEntry values from pre-discovered
44// skills. The skillPaths and workingDir parameters are used only for
45// labelling (system / user / project); pass nil/empty when labels are
46// not needed.
47func Catalog(active []*Skill, skillPaths []string, workingDir string) []CatalogEntry {
48 entries := make([]CatalogEntry, 0, len(active))
49 for _, skill := range active {
50 label, source := skillLabel(skillPaths, workingDir, skill)
51 entries = append(entries, CatalogEntry{
52 ID: skill.SkillFilePath,
53 Name: skill.Name,
54 Description: skill.Description,
55 Label: label,
56 Source: source,
57 UserInvocable: skill.UserInvocable,
58 })
59 }
60 return entries
61}
62
63// FindEffective returns the named skill from the given active skill
64// set.
65func FindEffective(active []*Skill, skillID string) (*Skill, error) {
66 for _, skill := range active {
67 if skill.SkillFilePath == skillID {
68 return skill, nil
69 }
70 }
71 return nil, fmt.Errorf("%w: %s", ErrSkillNotFound, skillID)
72}
73
74// ReadContent reads the contents of a visible skill by ID and returns
75// the raw bytes along with metadata about the skill.
76func ReadContent(active []*Skill, skillPaths []string, workingDir string, skillID string) ([]byte, SkillReadResult, error) {
77 skill, err := FindEffective(active, skillID)
78 if err != nil {
79 return nil, SkillReadResult{}, err
80 }
81
82 _, source := skillLabel(skillPaths, workingDir, skill)
83 result := SkillReadResult{
84 Name: skill.Name,
85 Description: skill.Description,
86 Source: source,
87 Builtin: skill.Builtin,
88 }
89
90 if skill.Builtin {
91 embeddedPath := "builtin/" + strings.TrimPrefix(skill.SkillFilePath, BuiltinPrefix)
92 content, err := BuiltinFS().ReadFile(embeddedPath)
93 if err != nil {
94 return nil, SkillReadResult{}, fmt.Errorf("read builtin skill %q: %w", skillID, err)
95 }
96 return content, result, nil
97 }
98
99 content, err := os.ReadFile(skill.SkillFilePath)
100 if err != nil {
101 return nil, SkillReadResult{}, fmt.Errorf("read skill %q: %w", skillID, err)
102 }
103 return content, result, nil
104}
105
106func skillLabel(skillPaths []string, workingDir string, skill *Skill) (string, SourceType) {
107 if skill.Builtin {
108 return string(SourceSystem) + ":" + skill.Name, SourceSystem
109 }
110
111 cleanFile := filepath.Clean(skill.SkillFilePath)
112 for _, base := range skillPaths {
113 cleanBase := filepath.Clean(base)
114 rel, err := filepath.Rel(cleanBase, cleanFile)
115 if err != nil || escapesParent(rel) {
116 continue
117 }
118
119 source := SourceUser
120 prefix := string(SourceUser) + ":"
121 if isProjectSkillPath(cleanBase, workingDir) {
122 source = SourceProject
123 prefix = string(SourceProject) + ":"
124 }
125 return prefix + filepath.Base(filepath.Dir(cleanFile)), source
126 }
127
128 return string(SourceUser) + ":" + filepath.Base(filepath.Dir(cleanFile)), SourceUser
129}
130
131func escapesParent(rel string) bool {
132 return rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator))
133}
134
135func isProjectSkillPath(basePath, workingDir string) bool {
136 if workingDir == "" {
137 return false
138 }
139 absBase, err := filepath.Abs(basePath)
140 if err != nil {
141 return false
142 }
143 absWD, err := filepath.Abs(workingDir)
144 if err != nil {
145 return false
146 }
147 cleanBase := filepath.Clean(absBase)
148 cleanWD := filepath.Clean(absWD)
149 rel, err := filepath.Rel(cleanWD, cleanBase)
150 if err != nil {
151 return false
152 }
153 return !escapesParent(rel)
154}