1// Package skills implements zot's reusable-instruction system.
2//
3// A skill is a per-folder SKILL.md file with a YAML frontmatter
4// header. Skills live in well-known directories under the project or
5// the user home; zot discovers them at startup, lists their names +
6// one-line descriptions in the system prompt, and exposes a built-in
7// "skill" tool the model uses to pull the full body on demand.
8//
9// The on-demand-load model keeps token usage cheap: only the
10// short manifest goes into every request; the body is fetched as a
11// tool result the one or two turns the model actually needs it.
12//
13// Discovery layout (priority order — first match wins per name):
14//
15// ./.zot/skills/<name>/SKILL.md — project (native)
16// $ZOT_HOME/skills/<name>/SKILL.md — global (native)
17// ./.claude/skills/<name>/SKILL.md — project (claude-compat)
18// ~/.claude/skills/<name>/SKILL.md — global (claude-compat)
19// ./.agents/skills/<name>/SKILL.md — project (agent-compat)
20// ~/.agents/skills/<name>/SKILL.md — global (agent-compat)
21//
22// The compat paths are deliberate: a SKILL.md written for any of
23// the related ecosystems works in zot unchanged.
24package skills
25
26import (
27 "fmt"
28 "os"
29 "path/filepath"
30 "sort"
31 "strings"
32)
33
34// Skill is one discovered SKILL.md file.
35type Skill struct {
36 // Name is the skill identifier — what the model uses when it
37 // invokes the `skill` tool. Taken from the frontmatter `name`
38 // field; falls back to the directory basename.
39 Name string
40
41 // Description is the one-line summary shown to the model in the
42 // system-prompt manifest.
43 Description string
44
45 // Body is the markdown after the frontmatter. Returned as the
46 // tool result when the model loads this skill.
47 Body string
48
49 // Path is the absolute path to the SKILL.md file.
50 Path string
51
52 // Source is a human-friendly label describing where the skill
53 // came from ("project", "global", "project (claude)", etc.).
54 // Shown in the /skills picker.
55 Source string
56
57 // AllowedTools and Permissions are parsed for forward-
58 // compatibility but NOT enforced in this version. They appear
59 // in the skill body so the model can self-regulate.
60 AllowedTools []string
61 Permissions map[string][]string
62}
63
64// Discover walks every supported location, parses each SKILL.md, and
65// returns the merged skill set. First-match-wins per name; the order
66// matches the priority list in the package doc. Errors per skill are
67// returned alongside the partial result so a single broken file
68// doesn't suppress the rest.
69func Discover(zotHome, cwd, userHome string) ([]*Skill, []error) {
70 var errs []error
71 seen := map[string]*Skill{}
72 for _, loc := range searchDirs(zotHome, cwd, userHome) {
73 entries, err := os.ReadDir(loc.dir)
74 if err != nil {
75 continue // missing dir is fine
76 }
77 for _, e := range entries {
78 if !e.IsDir() {
79 continue
80 }
81 path := filepath.Join(loc.dir, e.Name(), "SKILL.md")
82 s, err := load(path, loc.label)
83 if err != nil {
84 if !os.IsNotExist(err) {
85 errs = append(errs, fmt.Errorf("%s: %w", path, err))
86 }
87 continue
88 }
89 if s.Name == "" {
90 s.Name = e.Name()
91 }
92 if _, dup := seen[s.Name]; dup {
93 continue // higher-priority location already won
94 }
95 seen[s.Name] = s
96 }
97 }
98 out := make([]*Skill, 0, len(seen))
99 for _, s := range seen {
100 out = append(out, s)
101 }
102 sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
103 return out, errs
104}
105
106// SystemPromptAddendum returns the text to append to the system
107// prompt when at least one skill is loaded. Empty string if none.
108//
109// Format kept short and explicit so the model reliably calls the
110// `skill` tool with a name from the list rather than guessing.
111func SystemPromptAddendum(skills []*Skill) string {
112 if len(skills) == 0 {
113 return ""
114 }
115 var sb strings.Builder
116 sb.WriteString("Available skills (call the `skill` tool with one of these names to load full instructions):\n")
117 for _, s := range skills {
118 desc := strings.TrimSpace(s.Description)
119 if desc == "" {
120 desc = "(no description)"
121 }
122 fmt.Fprintf(&sb, "- %s — %s\n", s.Name, desc)
123 }
124 return sb.String()
125}
126
127// FindByName returns the skill with the given name, or nil.
128func FindByName(skills []*Skill, name string) *Skill {
129 for _, s := range skills {
130 if s.Name == name {
131 return s
132 }
133 }
134 return nil
135}
136
137// ---- internals ----
138
139type location struct {
140 dir string
141 label string
142}
143
144func searchDirs(zotHome, cwd, userHome string) []location {
145 var out []location
146 add := func(dir, label string) {
147 if dir == "" {
148 return
149 }
150 out = append(out, location{dir: dir, label: label})
151 }
152 if cwd != "" {
153 add(filepath.Join(cwd, ".zot", "skills"), "project")
154 }
155 if zotHome != "" {
156 add(filepath.Join(zotHome, "skills"), "global")
157 }
158 if cwd != "" {
159 add(filepath.Join(cwd, ".claude", "skills"), "project (claude)")
160 }
161 if userHome != "" {
162 add(filepath.Join(userHome, ".claude", "skills"), "global (claude)")
163 }
164 if cwd != "" {
165 add(filepath.Join(cwd, ".agents", "skills"), "project (agents)")
166 }
167 if userHome != "" {
168 add(filepath.Join(userHome, ".agents", "skills"), "global (agents)")
169 }
170 return out
171}
172
173func load(path, source string) (*Skill, error) {
174 raw, err := os.ReadFile(path)
175 if err != nil {
176 return nil, err
177 }
178 front, body := splitFrontmatter(string(raw))
179 s := &Skill{
180 Path: path,
181 Source: source,
182 Body: strings.TrimSpace(body),
183 }
184 parseFrontmatter(front, s)
185 return s, nil
186}
187
188// splitFrontmatter returns (yamlBlock, restOfDocument) for a string
189// whose first non-empty line is "---". If no frontmatter is present,
190// returns ("", entireString).
191func splitFrontmatter(raw string) (string, string) {
192 rest := strings.TrimLeft(raw, " \t\r\n")
193 if !strings.HasPrefix(rest, "---") {
194 return "", raw
195 }
196 rest = strings.TrimPrefix(rest, "---")
197 // Drop the trailing newline after the opening ---.
198 if i := strings.IndexByte(rest, '\n'); i >= 0 {
199 rest = rest[i+1:]
200 }
201 end := strings.Index(rest, "\n---")
202 if end < 0 {
203 return "", raw // malformed; treat as no frontmatter
204 }
205 front := rest[:end]
206 body := rest[end+len("\n---"):]
207 body = strings.TrimLeft(body, " \t\r\n")
208 return front, body
209}
210
211// parseFrontmatter handles the small subset of YAML zot recognizes:
212// - simple `key: value` lines
213// - `key: [a, b, c]` flow-style lists
214// - `key:` followed by indented `- item` block lists
215// - nested `key:` followed by indented `subkey: [...]` for permissions
216//
217// Anything more elaborate is ignored. We deliberately avoid a yaml
218// dependency to keep zot's binary lean.
219func parseFrontmatter(front string, s *Skill) {
220 lines := strings.Split(front, "\n")
221 i := 0
222 for i < len(lines) {
223 line := lines[i]
224 trimmed := strings.TrimSpace(line)
225 if trimmed == "" || strings.HasPrefix(trimmed, "#") {
226 i++
227 continue
228 }
229 // Top-level key: value or key:
230 if !strings.HasPrefix(line, " ") && !strings.HasPrefix(line, "\t") {
231 colon := strings.IndexByte(trimmed, ':')
232 if colon < 0 {
233 i++
234 continue
235 }
236 key := strings.TrimSpace(trimmed[:colon])
237 value := strings.TrimSpace(trimmed[colon+1:])
238 switch key {
239 case "name":
240 s.Name = unquote(value)
241 case "description":
242 s.Description = unquote(value)
243 case "allowed-tools", "allowed_tools":
244 if value != "" {
245 s.AllowedTools = parseInlineList(value)
246 } else {
247 items, consumed := parseBlockList(lines[i+1:])
248 s.AllowedTools = items
249 i += consumed
250 }
251 case "permissions":
252 if value != "" {
253 // Unusual layout; ignore single-line for now.
254 i++
255 continue
256 }
257 perms, consumed := parsePermissionsBlock(lines[i+1:])
258 s.Permissions = perms
259 i += consumed
260 }
261 }
262 i++
263 }
264}
265
266// unquote trims surrounding " or ' from a value.
267func unquote(v string) string {
268 if len(v) >= 2 {
269 if (v[0] == '"' && v[len(v)-1] == '"') || (v[0] == '\'' && v[len(v)-1] == '\'') {
270 return v[1 : len(v)-1]
271 }
272 }
273 return v
274}
275
276// parseInlineList parses "[a, b, c]" or "[\"a\", \"b\"]".
277func parseInlineList(v string) []string {
278 v = strings.TrimSpace(v)
279 v = strings.TrimPrefix(v, "[")
280 v = strings.TrimSuffix(v, "]")
281 if v == "" {
282 return nil
283 }
284 parts := strings.Split(v, ",")
285 out := make([]string, 0, len(parts))
286 for _, p := range parts {
287 out = append(out, unquote(strings.TrimSpace(p)))
288 }
289 return out
290}
291
292// parseBlockList consumes indented "- item" lines until a less-
293// indented line. Returns the items + how many lines to skip.
294func parseBlockList(lines []string) ([]string, int) {
295 var out []string
296 consumed := 0
297 for _, line := range lines {
298 if strings.HasPrefix(line, " - ") || strings.HasPrefix(line, " - ") {
299 out = append(out, unquote(strings.TrimSpace(strings.TrimPrefix(strings.TrimLeft(line, " "), "-"))))
300 consumed++
301 continue
302 }
303 if strings.TrimSpace(line) == "" {
304 consumed++
305 continue
306 }
307 break
308 }
309 return out, consumed
310}
311
312// parsePermissionsBlock parses an indented map of tool->[patterns].
313//
314// permissions:
315// bash: ["git diff*", "git log*"]
316// read: ["./*.go"]
317func parsePermissionsBlock(lines []string) (map[string][]string, int) {
318 out := map[string][]string{}
319 consumed := 0
320 for _, line := range lines {
321 if !strings.HasPrefix(line, " ") {
322 break
323 }
324 trimmed := strings.TrimSpace(line)
325 if trimmed == "" {
326 consumed++
327 continue
328 }
329 colon := strings.IndexByte(trimmed, ':')
330 if colon < 0 {
331 break
332 }
333 key := strings.TrimSpace(trimmed[:colon])
334 val := strings.TrimSpace(trimmed[colon+1:])
335 if val == "" {
336 break // we don't support nested-block lists for permissions
337 }
338 out[key] = parseInlineList(val)
339 consumed++
340 }
341 return out, consumed
342}