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