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 // Builtin marks skills that ship inside the zot binary. They are
58 // fully active for the model (system-prompt manifest + skill
59 // tool) but hidden from user-facing surfaces like the /skills
60 // picker so users only see skills they actually installed or
61 // shipped in their project.
62 Builtin bool
63
64 // AllowedTools and Permissions are parsed for forward-
65 // compatibility but NOT enforced in this version. They appear
66 // in the skill body so the model can self-regulate.
67 AllowedTools []string
68 Permissions map[string][]string
69}
70
71// VisibleSkills returns the subset of skills users should see in
72// pickers, /skills, and other interactive surfaces. Built-ins are
73// hidden because they're implementation detail; the model still
74// loads them through the system-prompt manifest + the skill tool.
75func VisibleSkills(in []*Skill) []*Skill {
76 out := make([]*Skill, 0, len(in))
77 for _, s := range in {
78 if s == nil || s.Builtin {
79 continue
80 }
81 out = append(out, s)
82 }
83 return out
84}
85
86// Discover walks every supported location, parses each SKILL.md, and
87// returns the merged skill set. First-match-wins per name; the order
88// matches the priority list in the package doc. Errors per skill are
89// returned alongside the partial result so a single broken file
90// doesn't suppress the rest.
91//
92// Built-in skills (compiled into the zot binary) are added LAST so
93// any user-installed skill with the same name shadows the built-in.
94// That lets users customise the help text by dropping their own
95// SKILL.md with the same name into $ZOT_HOME/skills/<name>/.
96func Discover(zotHome, cwd, userHome string) ([]*Skill, []error) {
97 var errs []error
98 seen := map[string]*Skill{}
99 for _, loc := range searchDirs(zotHome, cwd, userHome) {
100 entries, err := os.ReadDir(loc.dir)
101 if err != nil {
102 continue // missing dir is fine
103 }
104 for _, e := range entries {
105 if !e.IsDir() {
106 continue
107 }
108 path := filepath.Join(loc.dir, e.Name(), "SKILL.md")
109 s, err := load(path, loc.label)
110 if err != nil {
111 if !os.IsNotExist(err) {
112 errs = append(errs, fmt.Errorf("%s: %w", path, err))
113 }
114 continue
115 }
116 if s.Name == "" {
117 s.Name = e.Name()
118 }
119 if _, dup := seen[s.Name]; dup {
120 continue // higher-priority location already won
121 }
122 seen[s.Name] = s
123 }
124 }
125 // Built-ins fill in any name the user didn't already provide.
126 for _, s := range loadBuiltins() {
127 if _, dup := seen[s.Name]; dup {
128 continue
129 }
130 seen[s.Name] = s
131 }
132
133 out := make([]*Skill, 0, len(seen))
134 for _, s := range seen {
135 out = append(out, s)
136 }
137 sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
138 return out, errs
139}
140
141// SystemPromptAddendum returns the text to append to the system
142// prompt when at least one skill is loaded. Empty string if none.
143//
144// Format kept short and explicit so the model reliably calls the
145// `skill` tool with a name from the list rather than guessing.
146func SystemPromptAddendum(skills []*Skill) string {
147 if len(skills) == 0 {
148 return ""
149 }
150 var sb strings.Builder
151 sb.WriteString("Available skills (call the `skill` tool with one of these names to load full instructions):\n")
152 for _, s := range skills {
153 desc := strings.TrimSpace(s.Description)
154 if desc == "" {
155 desc = "(no description)"
156 }
157 fmt.Fprintf(&sb, "- %s — %s\n", s.Name, desc)
158 }
159 return sb.String()
160}
161
162// FindByName returns the skill with the given name, or nil.
163func FindByName(skills []*Skill, name string) *Skill {
164 for _, s := range skills {
165 if s.Name == name {
166 return s
167 }
168 }
169 return nil
170}
171
172// ---- internals ----
173
174type location struct {
175 dir string
176 label string
177}
178
179func searchDirs(zotHome, cwd, userHome string) []location {
180 var out []location
181 add := func(dir, label string) {
182 if dir == "" {
183 return
184 }
185 out = append(out, location{dir: dir, label: label})
186 }
187 if cwd != "" {
188 add(filepath.Join(cwd, ".zot", "skills"), "project")
189 }
190 if zotHome != "" {
191 add(filepath.Join(zotHome, "skills"), "global")
192 }
193 if cwd != "" {
194 add(filepath.Join(cwd, ".claude", "skills"), "project (claude)")
195 }
196 if userHome != "" {
197 add(filepath.Join(userHome, ".claude", "skills"), "global (claude)")
198 }
199 if cwd != "" {
200 add(filepath.Join(cwd, ".agents", "skills"), "project (agents)")
201 }
202 if userHome != "" {
203 add(filepath.Join(userHome, ".agents", "skills"), "global (agents)")
204 }
205 return out
206}
207
208func load(path, source string) (*Skill, error) {
209 raw, err := os.ReadFile(path)
210 if err != nil {
211 return nil, err
212 }
213 front, body := splitFrontmatter(string(raw))
214 s := &Skill{
215 Path: path,
216 Source: source,
217 Body: strings.TrimSpace(body),
218 }
219 parseFrontmatter(front, s)
220 return s, nil
221}
222
223// splitFrontmatter returns (yamlBlock, restOfDocument) for a string
224// whose first non-empty line is "---". If no frontmatter is present,
225// returns ("", entireString).
226func splitFrontmatter(raw string) (string, string) {
227 rest := strings.TrimLeft(raw, " \t\r\n")
228 if !strings.HasPrefix(rest, "---") {
229 return "", raw
230 }
231 rest = strings.TrimPrefix(rest, "---")
232 // Drop the trailing newline after the opening ---.
233 if i := strings.IndexByte(rest, '\n'); i >= 0 {
234 rest = rest[i+1:]
235 }
236 end := strings.Index(rest, "\n---")
237 if end < 0 {
238 return "", raw // malformed; treat as no frontmatter
239 }
240 front := rest[:end]
241 body := rest[end+len("\n---"):]
242 body = strings.TrimLeft(body, " \t\r\n")
243 return front, body
244}
245
246// parseFrontmatter handles the small subset of YAML zot recognizes:
247// - simple `key: value` lines
248// - `key: [a, b, c]` flow-style lists
249// - `key:` followed by indented `- item` block lists
250// - nested `key:` followed by indented `subkey: [...]` for permissions
251//
252// Anything more elaborate is ignored. We deliberately avoid a yaml
253// dependency to keep zot's binary lean.
254func parseFrontmatter(front string, s *Skill) {
255 lines := strings.Split(front, "\n")
256 i := 0
257 for i < len(lines) {
258 line := lines[i]
259 trimmed := strings.TrimSpace(line)
260 if trimmed == "" || strings.HasPrefix(trimmed, "#") {
261 i++
262 continue
263 }
264 // Top-level key: value or key:
265 if !strings.HasPrefix(line, " ") && !strings.HasPrefix(line, "\t") {
266 colon := strings.IndexByte(trimmed, ':')
267 if colon < 0 {
268 i++
269 continue
270 }
271 key := strings.TrimSpace(trimmed[:colon])
272 value := strings.TrimSpace(trimmed[colon+1:])
273 switch key {
274 case "name":
275 s.Name = unquote(value)
276 case "description":
277 s.Description = unquote(value)
278 case "allowed-tools", "allowed_tools":
279 if value != "" {
280 s.AllowedTools = parseInlineList(value)
281 } else {
282 items, consumed := parseBlockList(lines[i+1:])
283 s.AllowedTools = items
284 i += consumed
285 }
286 case "permissions":
287 if value != "" {
288 // Unusual layout; ignore single-line for now.
289 i++
290 continue
291 }
292 perms, consumed := parsePermissionsBlock(lines[i+1:])
293 s.Permissions = perms
294 i += consumed
295 }
296 }
297 i++
298 }
299}
300
301// unquote trims surrounding " or ' from a value.
302func unquote(v string) string {
303 if len(v) >= 2 {
304 if (v[0] == '"' && v[len(v)-1] == '"') || (v[0] == '\'' && v[len(v)-1] == '\'') {
305 return v[1 : len(v)-1]
306 }
307 }
308 return v
309}
310
311// parseInlineList parses "[a, b, c]" or "[\"a\", \"b\"]".
312func parseInlineList(v string) []string {
313 v = strings.TrimSpace(v)
314 v = strings.TrimPrefix(v, "[")
315 v = strings.TrimSuffix(v, "]")
316 if v == "" {
317 return nil
318 }
319 parts := strings.Split(v, ",")
320 out := make([]string, 0, len(parts))
321 for _, p := range parts {
322 out = append(out, unquote(strings.TrimSpace(p)))
323 }
324 return out
325}
326
327// parseBlockList consumes indented "- item" lines until a less-
328// indented line. Returns the items + how many lines to skip.
329func parseBlockList(lines []string) ([]string, int) {
330 var out []string
331 consumed := 0
332 for _, line := range lines {
333 if strings.HasPrefix(line, " - ") || strings.HasPrefix(line, " - ") {
334 out = append(out, unquote(strings.TrimSpace(strings.TrimPrefix(strings.TrimLeft(line, " "), "-"))))
335 consumed++
336 continue
337 }
338 if strings.TrimSpace(line) == "" {
339 consumed++
340 continue
341 }
342 break
343 }
344 return out, consumed
345}
346
347// parsePermissionsBlock parses an indented map of tool->[patterns].
348//
349// permissions:
350// bash: ["git diff*", "git log*"]
351// read: ["./*.go"]
352func parsePermissionsBlock(lines []string) (map[string][]string, int) {
353 out := map[string][]string{}
354 consumed := 0
355 for _, line := range lines {
356 if !strings.HasPrefix(line, " ") {
357 break
358 }
359 trimmed := strings.TrimSpace(line)
360 if trimmed == "" {
361 consumed++
362 continue
363 }
364 colon := strings.IndexByte(trimmed, ':')
365 if colon < 0 {
366 break
367 }
368 key := strings.TrimSpace(trimmed[:colon])
369 val := strings.TrimSpace(trimmed[colon+1:])
370 if val == "" {
371 break // we don't support nested-block lists for permissions
372 }
373 out[key] = parseInlineList(val)
374 consumed++
375 }
376 return out, consumed
377}