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.
35// Source describes one directory of skills. Sources are evaluated in order;
36// the first skill with a canonical name wins.
37type Source struct {
38 Root string
39 Label string
40 Prefix string
41}
42
43type Skill struct {
44 // Name is the skill identifier — what the model uses when it
45 // invokes the `skill` tool. Taken from the frontmatter `name`
46 // field; falls back to the directory basename.
47 Name string
48
49 // Description is the one-line summary shown to the model in the
50 // system-prompt manifest.
51 Description string
52
53 // Body is the markdown after the frontmatter. Returned as the
54 // tool result when the model loads this skill.
55 Body string
56
57 // Path is the absolute path to the SKILL.md file.
58 Path string
59
60 // Source is a human-friendly label describing where the skill
61 // came from ("project", "global", "project (claude)", etc.).
62 // Shown in the /skills picker.
63 Source string
64
65 // Builtin marks skills that ship inside the zot binary. They are
66 // fully active for the model (system-prompt manifest + skill
67 // tool) but hidden from user-facing surfaces like the /skills
68 // picker so users only see skills they actually installed or
69 // shipped in their project.
70 Builtin bool
71
72 // DisableModelInvocation hides the skill from the system-prompt
73 // manifest. The user can still load it explicitly with
74 // /skill:<name>.
75 DisableModelInvocation bool
76
77 // AllowedTools and Permissions are parsed for forward-
78 // compatibility but NOT enforced in this version. They appear
79 // in the skill body so the model can self-regulate.
80 AllowedTools []string
81 Permissions map[string][]string
82}
83
84// VisibleSkills returns the subset of skills users should see in
85// pickers, /skills, and other interactive surfaces. Built-ins are
86// hidden because they're implementation detail; the model still
87// loads them through the system-prompt manifest + the skill tool.
88func VisibleSkills(in []*Skill) []*Skill {
89 out := make([]*Skill, 0, len(in))
90 for _, s := range in {
91 if s == nil || s.Builtin {
92 continue
93 }
94 out = append(out, s)
95 }
96 return out
97}
98
99// Discover returns the merged skill set. When includeUser is true,
100// user-installed SKILL.md files are loaded before built-ins. Callers
101// normally pass true; --no-skill skips discovery entirely before this
102// function is called.
103//
104// First-match-wins per name; the order matches the priority list
105// in the package doc (project-local before global before claude-
106// compat before agents-compat, all before built-ins). That means a
107// user-installed skill with the same name as a built-in shadows
108// the built-in once includeUser is true.
109//
110// Errors per skill are returned alongside the partial result so a
111// single broken file doesn't suppress the rest.
112func Discover(zotHome, cwd, userHome string, includeUser bool) ([]*Skill, []error) {
113 var sources []Source
114 if includeUser {
115 for _, loc := range searchDirs(zotHome, cwd, userHome) {
116 sources = append(sources, Source{Root: loc.dir, Label: loc.label})
117 }
118 }
119 discovered, errs := DiscoverSources(sources, true)
120 // Preserve the historical Discover contract: duplicate ordinary skills
121 // were silently ignored. New callers using DiscoverSources receive the
122 // richer shadowing diagnostics.
123 filtered := errs[:0]
124 for _, err := range errs {
125 if !strings.Contains(err.Error(), " shadowed: ") {
126 filtered = append(filtered, err)
127 }
128 }
129 return discovered, filtered
130}
131
132// DiscoverSources recursively discovers SKILL.md files from sources in
133// precedence order, then appends built-ins when includeUser is true or false.
134// It returns non-fatal diagnostics for malformed files and shadowed names.
135func DiscoverSources(sources []Source, includeBuiltins bool) ([]*Skill, []error) {
136 var errs []error
137 seen := map[string]*Skill{}
138 for _, source := range sources {
139 found, scanErrs := scanSource(source)
140 errs = append(errs, scanErrs...)
141 for _, s := range found {
142 if prior, dup := seen[s.Name]; dup {
143 if sameSkillFile(prior.Path, s.Path) {
144 continue
145 }
146 errs = append(errs, fmt.Errorf("skill %q shadowed: selected %s; ignored %s", s.Name, prior.Path, s.Path))
147 continue
148 }
149 seen[s.Name] = s
150 }
151 }
152 if includeBuiltins {
153 for _, s := range loadBuiltins() {
154 if prior, dup := seen[s.Name]; dup {
155 errs = append(errs, fmt.Errorf("skill %q shadowed: selected %s; ignored %s", s.Name, prior.Path, s.Path))
156 continue
157 }
158 seen[s.Name] = s
159 }
160 }
161 out := make([]*Skill, 0, len(seen))
162 for _, s := range seen {
163 out = append(out, s)
164 }
165 sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
166 return out, errs
167}
168
169// sameSkillFile distinguishes repeated discovery from a genuine name conflict.
170// Stat follows symlinks, and SameFile also handles filesystem path aliases.
171func sameSkillFile(a, b string) bool {
172 if a == b {
173 return true
174 }
175 aInfo, aErr := os.Stat(a)
176 bInfo, bErr := os.Stat(b)
177 return aErr == nil && bErr == nil && os.SameFile(aInfo, bInfo)
178}
179
180func scanSource(source Source) ([]*Skill, []error) {
181 var out []*Skill
182 var errs []error
183 info, err := os.Stat(source.Root)
184 if err != nil {
185 if !os.IsNotExist(err) {
186 errs = append(errs, fmt.Errorf("%s: %w", source.Root, err))
187 }
188 return out, errs
189 }
190 if !info.IsDir() {
191 if filepath.Base(source.Root) != "SKILL.md" {
192 return out, errs
193 }
194 s, e := load(source.Root, source.Label)
195 if e != nil {
196 return out, []error{fmt.Errorf("%s: %w", source.Root, e)}
197 }
198 if s.Name == "" {
199 s.Name = kebabCase(strings.TrimSuffix(filepath.Base(filepath.Dir(source.Root)), ""))
200 }
201 if source.Prefix != "" {
202 s.Name = source.Prefix + s.Name
203 }
204 return []*Skill{s}, nil
205 }
206 root, err := filepath.EvalSymlinks(source.Root)
207 if err != nil {
208 return out, []error{fmt.Errorf("resolve %s: %w", source.Root, err)}
209 }
210 err = filepath.WalkDir(root, func(path string, d os.DirEntry, walkErr error) error {
211 if walkErr != nil {
212 errs = append(errs, fmt.Errorf("%s: %w", path, walkErr))
213 return nil
214 }
215 if d.IsDir() || d.Name() != "SKILL.md" {
216 return nil
217 }
218 s, e := load(path, source.Label)
219 if e != nil {
220 errs = append(errs, fmt.Errorf("%s: %w", path, e))
221 return nil
222 }
223 if s.Name == "" {
224 rel, e := filepath.Rel(root, filepath.Dir(path))
225 if e != nil {
226 errs = append(errs, e)
227 return nil
228 }
229 parts := []string{}
230 if rel != "." {
231 for _, p := range strings.Split(filepath.ToSlash(rel), "/") {
232 if k := kebabCase(p); k != "" {
233 parts = append(parts, k)
234 }
235 }
236 }
237 if len(parts) == 0 {
238 parts = append(parts, kebabCase(filepath.Base(filepath.Dir(path))))
239 }
240 s.Name = strings.Join(parts, "-")
241 }
242 if source.Prefix != "" {
243 s.Name = source.Prefix + s.Name
244 }
245 out = append(out, s)
246 return nil
247 })
248 if err != nil {
249 errs = append(errs, fmt.Errorf("scan %s: %w", root, err))
250 }
251 return out, errs
252}
253
254// KebabCase converts a path component or display name to a stable skill name.
255func KebabCase(s string) string { return kebabCase(s) }
256
257func kebabCase(s string) string {
258 var b strings.Builder
259 sep := false
260 for _, r := range strings.ToLower(s) {
261 if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
262 if sep && b.Len() > 0 {
263 b.WriteByte('-')
264 }
265 b.WriteRune(r)
266 sep = false
267 } else {
268 sep = true
269 }
270 }
271 return strings.Trim(b.String(), "-")
272}
273
274// scanUserSkills walks the user-skill search dirs and populates
275// `seen` with first-match-wins per name. Split out so Discover's
276// includeUser=false path doesn't have to skip over a giant block.
277func scanUserSkills(zotHome, cwd, userHome string, seen map[string]*Skill) []error {
278 var errs []error
279 for _, loc := range searchDirs(zotHome, cwd, userHome) {
280 entries, err := os.ReadDir(loc.dir)
281 if err != nil {
282 continue // missing dir is fine
283 }
284 for _, e := range entries {
285 if !e.IsDir() {
286 continue
287 }
288 path := filepath.Join(loc.dir, e.Name(), "SKILL.md")
289 s, err := load(path, loc.label)
290 if err != nil {
291 if !os.IsNotExist(err) {
292 errs = append(errs, fmt.Errorf("%s: %w", path, err))
293 }
294 continue
295 }
296 if s.Name == "" {
297 s.Name = e.Name()
298 }
299 if _, dup := seen[s.Name]; dup {
300 continue // higher-priority location already won
301 }
302 seen[s.Name] = s
303 }
304 }
305 return errs
306}
307
308// SystemPromptAddendum returns the text to append to the system
309// prompt when at least one skill is loaded. Empty string if none.
310//
311// The format is deliberately compact: name, one-line description,
312// and a source pointer telling the model where the full body
313// lives. Built-in skills show "builtin" since their markdown is
314// embedded in the zot binary and not on the filesystem; user
315// skills show their SKILL.md path (shortened with ~ for HOME).
316//
317// Loading still goes through the `skill` tool with just the name.
318// The pointer is there so the model can (a) mention the source
319// honestly in explanations and (b) distinguish between built-ins
320// and user-authored instruction sets when reasoning about trust.
321func SystemPromptAddendum(skills []*Skill) string {
322 home, _ := os.UserHomeDir()
323 var sb strings.Builder
324 for _, s := range skills {
325 if s == nil || s.DisableModelInvocation {
326 continue
327 }
328 if sb.Len() == 0 {
329 sb.WriteString("Available skills (call the `skill` tool with a name from this list to load its full instructions):\n")
330 }
331 desc := strings.TrimSpace(s.Description)
332 if desc == "" {
333 desc = "(no description)"
334 }
335 pointer := skillSourcePointer(s, home)
336 fmt.Fprintf(&sb, "- %s [%s]: %s\n", s.Name, pointer, desc)
337 }
338 return sb.String()
339}
340
341// skillSourcePointer returns a short tag describing where a skill
342// originates. Built-ins are tagged "builtin" because their markdown
343// is embedded in the zot binary and not reachable through the
344// filesystem. User skills are tagged with their SKILL.md path,
345// collapsed to use ~ for the user home when possible.
346func skillSourcePointer(s *Skill, home string) string {
347 if s == nil {
348 return "unknown"
349 }
350 if s.Builtin {
351 return "builtin"
352 }
353 p := s.Path
354 if p == "" {
355 return "unknown"
356 }
357 if home != "" && strings.HasPrefix(p, home+string(filepath.Separator)) {
358 return "~" + p[len(home):]
359 }
360 return p
361}
362
363// FindByName returns the skill with the given name, or nil.
364func FindByName(skills []*Skill, name string) *Skill {
365 for _, s := range skills {
366 if s.Name == name {
367 return s
368 }
369 }
370 return nil
371}
372
373// ---- internals ----
374
375type location struct {
376 dir string
377 label string
378}
379
380// SearchSources returns the ordinary skill roots in precedence order.
381func SearchSources(zotHome, cwd, userHome string) []Source {
382 out := make([]Source, 0)
383 for _, loc := range searchDirs(zotHome, cwd, userHome) {
384 out = append(out, Source{Root: loc.dir, Label: loc.label})
385 }
386 return out
387}
388
389func searchDirs(zotHome, cwd, userHome string) []location {
390 var out []location
391 add := func(dir, label string) {
392 if dir == "" {
393 return
394 }
395 out = append(out, location{dir: dir, label: label})
396 }
397 if extra := os.Getenv("ZOT_AGENT_SKILLS"); extra != "" {
398 for _, dir := range filepath.SplitList(extra) {
399 add(dir, "agent")
400 }
401 }
402 if cwd != "" {
403 add(filepath.Join(cwd, ".zot", "skills"), "project")
404 }
405 if zotHome != "" {
406 add(filepath.Join(zotHome, "skills"), "global")
407 }
408 if cwd != "" {
409 add(filepath.Join(cwd, ".claude", "skills"), "project (claude)")
410 }
411 if userHome != "" {
412 add(filepath.Join(userHome, ".claude", "skills"), "global (claude)")
413 }
414 if cwd != "" {
415 add(filepath.Join(cwd, ".agents", "skills"), "project (agents)")
416 }
417 if userHome != "" {
418 add(filepath.Join(userHome, ".agents", "skills"), "global (agents)")
419 }
420 return out
421}
422
423func load(path, source string) (*Skill, error) {
424 raw, err := os.ReadFile(path)
425 if err != nil {
426 return nil, err
427 }
428 front, body := splitFrontmatter(string(raw))
429 s := &Skill{
430 Path: path,
431 Source: source,
432 Body: strings.TrimSpace(body),
433 }
434 parseFrontmatter(front, s)
435 return s, nil
436}
437
438// splitFrontmatter returns (yamlBlock, restOfDocument) for a string
439// whose first non-empty line is "---". If no frontmatter is present,
440// returns ("", entireString).
441func splitFrontmatter(raw string) (string, string) {
442 rest := strings.TrimLeft(raw, " \t\r\n")
443 if !strings.HasPrefix(rest, "---") {
444 return "", raw
445 }
446 rest = strings.TrimPrefix(rest, "---")
447 // Drop the trailing newline after the opening ---.
448 if i := strings.IndexByte(rest, '\n'); i >= 0 {
449 rest = rest[i+1:]
450 }
451 end := strings.Index(rest, "\n---")
452 if end < 0 {
453 return "", raw // malformed; treat as no frontmatter
454 }
455 front := rest[:end]
456 body := rest[end+len("\n---"):]
457 body = strings.TrimLeft(body, " \t\r\n")
458 return front, body
459}
460
461// parseFrontmatter handles the small subset of YAML zot recognizes:
462// - simple `key: value` lines
463// - `key: [a, b, c]` flow-style lists
464// - `key:` followed by indented `- item` block lists
465// - nested `key:` followed by indented `subkey: [...]` for permissions
466//
467// Anything more elaborate is ignored. We deliberately avoid a yaml
468// dependency to keep zot's binary lean.
469func parseFrontmatter(front string, s *Skill) {
470 lines := strings.Split(front, "\n")
471 i := 0
472 for i < len(lines) {
473 line := lines[i]
474 trimmed := strings.TrimSpace(line)
475 if trimmed == "" || strings.HasPrefix(trimmed, "#") {
476 i++
477 continue
478 }
479 // Top-level key: value or key:
480 if !strings.HasPrefix(line, " ") && !strings.HasPrefix(line, "\t") {
481 colon := strings.IndexByte(trimmed, ':')
482 if colon < 0 {
483 i++
484 continue
485 }
486 key := strings.TrimSpace(trimmed[:colon])
487 value := strings.TrimSpace(trimmed[colon+1:])
488 switch key {
489 case "name":
490 s.Name = unquote(value)
491 case "description":
492 s.Description = unquote(value)
493 case "disable-model-invocation", "disable_model_invocation":
494 s.DisableModelInvocation = strings.EqualFold(unquote(value), "true")
495 case "allowed-tools", "allowed_tools":
496 if value != "" {
497 s.AllowedTools = parseInlineList(value)
498 } else {
499 items, consumed := parseBlockList(lines[i+1:])
500 s.AllowedTools = items
501 i += consumed
502 }
503 case "permissions":
504 if value != "" {
505 // Unusual layout; ignore single-line for now.
506 i++
507 continue
508 }
509 perms, consumed := parsePermissionsBlock(lines[i+1:])
510 s.Permissions = perms
511 i += consumed
512 }
513 }
514 i++
515 }
516}
517
518// unquote trims surrounding " or ' from a value.
519func unquote(v string) string {
520 if len(v) >= 2 {
521 if (v[0] == '"' && v[len(v)-1] == '"') || (v[0] == '\'' && v[len(v)-1] == '\'') {
522 return v[1 : len(v)-1]
523 }
524 }
525 return v
526}
527
528// parseInlineList parses "[a, b, c]" or "[\"a\", \"b\"]".
529func parseInlineList(v string) []string {
530 v = strings.TrimSpace(v)
531 v = strings.TrimPrefix(v, "[")
532 v = strings.TrimSuffix(v, "]")
533 if v == "" {
534 return nil
535 }
536 parts := strings.Split(v, ",")
537 out := make([]string, 0, len(parts))
538 for _, p := range parts {
539 out = append(out, unquote(strings.TrimSpace(p)))
540 }
541 return out
542}
543
544// parseBlockList consumes indented "- item" lines until a less-
545// indented line. Returns the items + how many lines to skip.
546func parseBlockList(lines []string) ([]string, int) {
547 var out []string
548 consumed := 0
549 for _, line := range lines {
550 if strings.HasPrefix(line, " - ") || strings.HasPrefix(line, " - ") {
551 out = append(out, unquote(strings.TrimSpace(strings.TrimPrefix(strings.TrimLeft(line, " "), "-"))))
552 consumed++
553 continue
554 }
555 if strings.TrimSpace(line) == "" {
556 consumed++
557 continue
558 }
559 break
560 }
561 return out, consumed
562}
563
564// parsePermissionsBlock parses an indented map of tool->[patterns].
565//
566// permissions:
567// bash: ["git diff*", "git log*"]
568// read: ["./*.go"]
569func parsePermissionsBlock(lines []string) (map[string][]string, int) {
570 out := map[string][]string{}
571 consumed := 0
572 for _, line := range lines {
573 if !strings.HasPrefix(line, " ") {
574 break
575 }
576 trimmed := strings.TrimSpace(line)
577 if trimmed == "" {
578 consumed++
579 continue
580 }
581 colon := strings.IndexByte(trimmed, ':')
582 if colon < 0 {
583 break
584 }
585 key := strings.TrimSpace(trimmed[:colon])
586 val := strings.TrimSpace(trimmed[colon+1:])
587 if val == "" {
588 break // we don't support nested-block lists for permissions
589 }
590 out[key] = parseInlineList(val)
591 consumed++
592 }
593 return out, consumed
594}