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 errs = append(errs, fmt.Errorf("skill %q shadowed: selected %s; ignored %s", s.Name, prior.Path, s.Path))
144 continue
145 }
146 seen[s.Name] = s
147 }
148 }
149 if includeBuiltins {
150 for _, s := range loadBuiltins() {
151 if prior, dup := seen[s.Name]; dup {
152 errs = append(errs, fmt.Errorf("skill %q shadowed: selected %s; ignored %s", s.Name, prior.Path, s.Path))
153 continue
154 }
155 seen[s.Name] = s
156 }
157 }
158 out := make([]*Skill, 0, len(seen))
159 for _, s := range seen {
160 out = append(out, s)
161 }
162 sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
163 return out, errs
164}
165
166func scanSource(source Source) ([]*Skill, []error) {
167 var out []*Skill
168 var errs []error
169 info, err := os.Stat(source.Root)
170 if err != nil {
171 if !os.IsNotExist(err) {
172 errs = append(errs, fmt.Errorf("%s: %w", source.Root, err))
173 }
174 return out, errs
175 }
176 if !info.IsDir() {
177 if filepath.Base(source.Root) != "SKILL.md" {
178 return out, errs
179 }
180 s, e := load(source.Root, source.Label)
181 if e != nil {
182 return out, []error{fmt.Errorf("%s: %w", source.Root, e)}
183 }
184 if s.Name == "" {
185 s.Name = kebabCase(strings.TrimSuffix(filepath.Base(filepath.Dir(source.Root)), ""))
186 }
187 if source.Prefix != "" {
188 s.Name = source.Prefix + s.Name
189 }
190 return []*Skill{s}, nil
191 }
192 root, err := filepath.EvalSymlinks(source.Root)
193 if err != nil {
194 return out, []error{fmt.Errorf("resolve %s: %w", source.Root, err)}
195 }
196 err = filepath.WalkDir(root, func(path string, d os.DirEntry, walkErr error) error {
197 if walkErr != nil {
198 errs = append(errs, fmt.Errorf("%s: %w", path, walkErr))
199 return nil
200 }
201 if d.IsDir() || d.Name() != "SKILL.md" {
202 return nil
203 }
204 s, e := load(path, source.Label)
205 if e != nil {
206 errs = append(errs, fmt.Errorf("%s: %w", path, e))
207 return nil
208 }
209 if s.Name == "" {
210 rel, e := filepath.Rel(root, filepath.Dir(path))
211 if e != nil {
212 errs = append(errs, e)
213 return nil
214 }
215 parts := []string{}
216 if rel != "." {
217 for _, p := range strings.Split(filepath.ToSlash(rel), "/") {
218 if k := kebabCase(p); k != "" {
219 parts = append(parts, k)
220 }
221 }
222 }
223 if len(parts) == 0 {
224 parts = append(parts, kebabCase(filepath.Base(filepath.Dir(path))))
225 }
226 s.Name = strings.Join(parts, "-")
227 }
228 if source.Prefix != "" {
229 s.Name = source.Prefix + s.Name
230 }
231 out = append(out, s)
232 return nil
233 })
234 if err != nil {
235 errs = append(errs, fmt.Errorf("scan %s: %w", root, err))
236 }
237 return out, errs
238}
239
240// KebabCase converts a path component or display name to a stable skill name.
241func KebabCase(s string) string { return kebabCase(s) }
242
243func kebabCase(s string) string {
244 var b strings.Builder
245 sep := false
246 for _, r := range strings.ToLower(s) {
247 if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
248 if sep && b.Len() > 0 {
249 b.WriteByte('-')
250 }
251 b.WriteRune(r)
252 sep = false
253 } else {
254 sep = true
255 }
256 }
257 return strings.Trim(b.String(), "-")
258}
259
260// scanUserSkills walks the user-skill search dirs and populates
261// `seen` with first-match-wins per name. Split out so Discover's
262// includeUser=false path doesn't have to skip over a giant block.
263func scanUserSkills(zotHome, cwd, userHome string, seen map[string]*Skill) []error {
264 var errs []error
265 for _, loc := range searchDirs(zotHome, cwd, userHome) {
266 entries, err := os.ReadDir(loc.dir)
267 if err != nil {
268 continue // missing dir is fine
269 }
270 for _, e := range entries {
271 if !e.IsDir() {
272 continue
273 }
274 path := filepath.Join(loc.dir, e.Name(), "SKILL.md")
275 s, err := load(path, loc.label)
276 if err != nil {
277 if !os.IsNotExist(err) {
278 errs = append(errs, fmt.Errorf("%s: %w", path, err))
279 }
280 continue
281 }
282 if s.Name == "" {
283 s.Name = e.Name()
284 }
285 if _, dup := seen[s.Name]; dup {
286 continue // higher-priority location already won
287 }
288 seen[s.Name] = s
289 }
290 }
291 return errs
292}
293
294// SystemPromptAddendum returns the text to append to the system
295// prompt when at least one skill is loaded. Empty string if none.
296//
297// The format is deliberately compact: name, one-line description,
298// and a source pointer telling the model where the full body
299// lives. Built-in skills show "builtin" since their markdown is
300// embedded in the zot binary and not on the filesystem; user
301// skills show their SKILL.md path (shortened with ~ for HOME).
302//
303// Loading still goes through the `skill` tool with just the name.
304// The pointer is there so the model can (a) mention the source
305// honestly in explanations and (b) distinguish between built-ins
306// and user-authored instruction sets when reasoning about trust.
307func SystemPromptAddendum(skills []*Skill) string {
308 home, _ := os.UserHomeDir()
309 var sb strings.Builder
310 for _, s := range skills {
311 if s == nil || s.DisableModelInvocation {
312 continue
313 }
314 if sb.Len() == 0 {
315 sb.WriteString("Available skills (call the `skill` tool with a name from this list to load its full instructions):\n")
316 }
317 desc := strings.TrimSpace(s.Description)
318 if desc == "" {
319 desc = "(no description)"
320 }
321 pointer := skillSourcePointer(s, home)
322 fmt.Fprintf(&sb, "- %s [%s]: %s\n", s.Name, pointer, desc)
323 }
324 return sb.String()
325}
326
327// skillSourcePointer returns a short tag describing where a skill
328// originates. Built-ins are tagged "builtin" because their markdown
329// is embedded in the zot binary and not reachable through the
330// filesystem. User skills are tagged with their SKILL.md path,
331// collapsed to use ~ for the user home when possible.
332func skillSourcePointer(s *Skill, home string) string {
333 if s == nil {
334 return "unknown"
335 }
336 if s.Builtin {
337 return "builtin"
338 }
339 p := s.Path
340 if p == "" {
341 return "unknown"
342 }
343 if home != "" && strings.HasPrefix(p, home+string(filepath.Separator)) {
344 return "~" + p[len(home):]
345 }
346 return p
347}
348
349// FindByName returns the skill with the given name, or nil.
350func FindByName(skills []*Skill, name string) *Skill {
351 for _, s := range skills {
352 if s.Name == name {
353 return s
354 }
355 }
356 return nil
357}
358
359// ---- internals ----
360
361type location struct {
362 dir string
363 label string
364}
365
366// SearchSources returns the ordinary skill roots in precedence order.
367func SearchSources(zotHome, cwd, userHome string) []Source {
368 out := make([]Source, 0)
369 for _, loc := range searchDirs(zotHome, cwd, userHome) {
370 out = append(out, Source{Root: loc.dir, Label: loc.label})
371 }
372 return out
373}
374
375func searchDirs(zotHome, cwd, userHome string) []location {
376 var out []location
377 add := func(dir, label string) {
378 if dir == "" {
379 return
380 }
381 out = append(out, location{dir: dir, label: label})
382 }
383 if extra := os.Getenv("ZOT_AGENT_SKILLS"); extra != "" {
384 for _, dir := range filepath.SplitList(extra) {
385 add(dir, "agent")
386 }
387 }
388 if cwd != "" {
389 add(filepath.Join(cwd, ".zot", "skills"), "project")
390 }
391 if zotHome != "" {
392 add(filepath.Join(zotHome, "skills"), "global")
393 }
394 if cwd != "" {
395 add(filepath.Join(cwd, ".claude", "skills"), "project (claude)")
396 }
397 if userHome != "" {
398 add(filepath.Join(userHome, ".claude", "skills"), "global (claude)")
399 }
400 if cwd != "" {
401 add(filepath.Join(cwd, ".agents", "skills"), "project (agents)")
402 }
403 if userHome != "" {
404 add(filepath.Join(userHome, ".agents", "skills"), "global (agents)")
405 }
406 return out
407}
408
409func load(path, source string) (*Skill, error) {
410 raw, err := os.ReadFile(path)
411 if err != nil {
412 return nil, err
413 }
414 front, body := splitFrontmatter(string(raw))
415 s := &Skill{
416 Path: path,
417 Source: source,
418 Body: strings.TrimSpace(body),
419 }
420 parseFrontmatter(front, s)
421 return s, nil
422}
423
424// splitFrontmatter returns (yamlBlock, restOfDocument) for a string
425// whose first non-empty line is "---". If no frontmatter is present,
426// returns ("", entireString).
427func splitFrontmatter(raw string) (string, string) {
428 rest := strings.TrimLeft(raw, " \t\r\n")
429 if !strings.HasPrefix(rest, "---") {
430 return "", raw
431 }
432 rest = strings.TrimPrefix(rest, "---")
433 // Drop the trailing newline after the opening ---.
434 if i := strings.IndexByte(rest, '\n'); i >= 0 {
435 rest = rest[i+1:]
436 }
437 end := strings.Index(rest, "\n---")
438 if end < 0 {
439 return "", raw // malformed; treat as no frontmatter
440 }
441 front := rest[:end]
442 body := rest[end+len("\n---"):]
443 body = strings.TrimLeft(body, " \t\r\n")
444 return front, body
445}
446
447// parseFrontmatter handles the small subset of YAML zot recognizes:
448// - simple `key: value` lines
449// - `key: [a, b, c]` flow-style lists
450// - `key:` followed by indented `- item` block lists
451// - nested `key:` followed by indented `subkey: [...]` for permissions
452//
453// Anything more elaborate is ignored. We deliberately avoid a yaml
454// dependency to keep zot's binary lean.
455func parseFrontmatter(front string, s *Skill) {
456 lines := strings.Split(front, "\n")
457 i := 0
458 for i < len(lines) {
459 line := lines[i]
460 trimmed := strings.TrimSpace(line)
461 if trimmed == "" || strings.HasPrefix(trimmed, "#") {
462 i++
463 continue
464 }
465 // Top-level key: value or key:
466 if !strings.HasPrefix(line, " ") && !strings.HasPrefix(line, "\t") {
467 colon := strings.IndexByte(trimmed, ':')
468 if colon < 0 {
469 i++
470 continue
471 }
472 key := strings.TrimSpace(trimmed[:colon])
473 value := strings.TrimSpace(trimmed[colon+1:])
474 switch key {
475 case "name":
476 s.Name = unquote(value)
477 case "description":
478 s.Description = unquote(value)
479 case "disable-model-invocation", "disable_model_invocation":
480 s.DisableModelInvocation = strings.EqualFold(unquote(value), "true")
481 case "allowed-tools", "allowed_tools":
482 if value != "" {
483 s.AllowedTools = parseInlineList(value)
484 } else {
485 items, consumed := parseBlockList(lines[i+1:])
486 s.AllowedTools = items
487 i += consumed
488 }
489 case "permissions":
490 if value != "" {
491 // Unusual layout; ignore single-line for now.
492 i++
493 continue
494 }
495 perms, consumed := parsePermissionsBlock(lines[i+1:])
496 s.Permissions = perms
497 i += consumed
498 }
499 }
500 i++
501 }
502}
503
504// unquote trims surrounding " or ' from a value.
505func unquote(v string) string {
506 if len(v) >= 2 {
507 if (v[0] == '"' && v[len(v)-1] == '"') || (v[0] == '\'' && v[len(v)-1] == '\'') {
508 return v[1 : len(v)-1]
509 }
510 }
511 return v
512}
513
514// parseInlineList parses "[a, b, c]" or "[\"a\", \"b\"]".
515func parseInlineList(v string) []string {
516 v = strings.TrimSpace(v)
517 v = strings.TrimPrefix(v, "[")
518 v = strings.TrimSuffix(v, "]")
519 if v == "" {
520 return nil
521 }
522 parts := strings.Split(v, ",")
523 out := make([]string, 0, len(parts))
524 for _, p := range parts {
525 out = append(out, unquote(strings.TrimSpace(p)))
526 }
527 return out
528}
529
530// parseBlockList consumes indented "- item" lines until a less-
531// indented line. Returns the items + how many lines to skip.
532func parseBlockList(lines []string) ([]string, int) {
533 var out []string
534 consumed := 0
535 for _, line := range lines {
536 if strings.HasPrefix(line, " - ") || strings.HasPrefix(line, " - ") {
537 out = append(out, unquote(strings.TrimSpace(strings.TrimPrefix(strings.TrimLeft(line, " "), "-"))))
538 consumed++
539 continue
540 }
541 if strings.TrimSpace(line) == "" {
542 consumed++
543 continue
544 }
545 break
546 }
547 return out, consumed
548}
549
550// parsePermissionsBlock parses an indented map of tool->[patterns].
551//
552// permissions:
553// bash: ["git diff*", "git log*"]
554// read: ["./*.go"]
555func parsePermissionsBlock(lines []string) (map[string][]string, int) {
556 out := map[string][]string{}
557 consumed := 0
558 for _, line := range lines {
559 if !strings.HasPrefix(line, " ") {
560 break
561 }
562 trimmed := strings.TrimSpace(line)
563 if trimmed == "" {
564 consumed++
565 continue
566 }
567 colon := strings.IndexByte(trimmed, ':')
568 if colon < 0 {
569 break
570 }
571 key := strings.TrimSpace(trimmed[:colon])
572 val := strings.TrimSpace(trimmed[colon+1:])
573 if val == "" {
574 break // we don't support nested-block lists for permissions
575 }
576 out[key] = parseInlineList(val)
577 consumed++
578 }
579 return out, consumed
580}