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 returns the merged skill set. When includeUser is true,
87// user-installed SKILL.md files are loaded before built-ins. Callers
88// normally pass true; --no-skill skips discovery entirely before this
89// function is called.
90//
91// First-match-wins per name; the order matches the priority list
92// in the package doc (project-local before global before claude-
93// compat before agents-compat, all before built-ins). That means a
94// user-installed skill with the same name as a built-in shadows
95// the built-in once includeUser is true.
96//
97// Errors per skill are returned alongside the partial result so a
98// single broken file doesn't suppress the rest.
99func Discover(zotHome, cwd, userHome string, includeUser bool) ([]*Skill, []error) {
100 var errs []error
101 seen := map[string]*Skill{}
102 if includeUser {
103 errs = append(errs, scanUserSkills(zotHome, cwd, userHome, seen)...)
104 }
105 // Built-ins fill in any name the user didn't already provide
106 // (or every name, when includeUser is false).
107 for _, s := range loadBuiltins() {
108 if _, dup := seen[s.Name]; dup {
109 continue
110 }
111 seen[s.Name] = s
112 }
113 out := make([]*Skill, 0, len(seen))
114 for _, s := range seen {
115 out = append(out, s)
116 }
117 sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
118 return out, errs
119}
120
121// scanUserSkills walks the user-skill search dirs and populates
122// `seen` with first-match-wins per name. Split out so Discover's
123// includeUser=false path doesn't have to skip over a giant block.
124func scanUserSkills(zotHome, cwd, userHome string, seen map[string]*Skill) []error {
125 var errs []error
126 for _, loc := range searchDirs(zotHome, cwd, userHome) {
127 entries, err := os.ReadDir(loc.dir)
128 if err != nil {
129 continue // missing dir is fine
130 }
131 for _, e := range entries {
132 if !e.IsDir() {
133 continue
134 }
135 path := filepath.Join(loc.dir, e.Name(), "SKILL.md")
136 s, err := load(path, loc.label)
137 if err != nil {
138 if !os.IsNotExist(err) {
139 errs = append(errs, fmt.Errorf("%s: %w", path, err))
140 }
141 continue
142 }
143 if s.Name == "" {
144 s.Name = e.Name()
145 }
146 if _, dup := seen[s.Name]; dup {
147 continue // higher-priority location already won
148 }
149 seen[s.Name] = s
150 }
151 }
152 return errs
153}
154
155// SystemPromptAddendum returns the text to append to the system
156// prompt when at least one skill is loaded. Empty string if none.
157//
158// The format is deliberately compact: name, one-line description,
159// and a source pointer telling the model where the full body
160// lives. Built-in skills show "builtin" since their markdown is
161// embedded in the zot binary and not on the filesystem; user
162// skills show their SKILL.md path (shortened with ~ for HOME).
163//
164// Loading still goes through the `skill` tool with just the name.
165// The pointer is there so the model can (a) mention the source
166// honestly in explanations and (b) distinguish between built-ins
167// and user-authored instruction sets when reasoning about trust.
168func SystemPromptAddendum(skills []*Skill) string {
169 if len(skills) == 0 {
170 return ""
171 }
172 home, _ := os.UserHomeDir()
173 var sb strings.Builder
174 sb.WriteString("Available skills (call the `skill` tool with a name from this list to load its full instructions):\n")
175 for _, s := range skills {
176 desc := strings.TrimSpace(s.Description)
177 if desc == "" {
178 desc = "(no description)"
179 }
180 pointer := skillSourcePointer(s, home)
181 fmt.Fprintf(&sb, "- %s [%s]: %s\n", s.Name, pointer, desc)
182 }
183 return sb.String()
184}
185
186// skillSourcePointer returns a short tag describing where a skill
187// originates. Built-ins are tagged "builtin" because their markdown
188// is embedded in the zot binary and not reachable through the
189// filesystem. User skills are tagged with their SKILL.md path,
190// collapsed to use ~ for the user home when possible.
191func skillSourcePointer(s *Skill, home string) string {
192 if s == nil {
193 return "unknown"
194 }
195 if s.Builtin {
196 return "builtin"
197 }
198 p := s.Path
199 if p == "" {
200 return "unknown"
201 }
202 if home != "" && strings.HasPrefix(p, home+string(filepath.Separator)) {
203 return "~" + p[len(home):]
204 }
205 return p
206}
207
208// FindByName returns the skill with the given name, or nil.
209func FindByName(skills []*Skill, name string) *Skill {
210 for _, s := range skills {
211 if s.Name == name {
212 return s
213 }
214 }
215 return nil
216}
217
218// ---- internals ----
219
220type location struct {
221 dir string
222 label string
223}
224
225func searchDirs(zotHome, cwd, userHome string) []location {
226 var out []location
227 add := func(dir, label string) {
228 if dir == "" {
229 return
230 }
231 out = append(out, location{dir: dir, label: label})
232 }
233 if extra := os.Getenv("ZOT_AGENT_SKILLS"); extra != "" {
234 for _, dir := range filepath.SplitList(extra) {
235 add(dir, "agent")
236 }
237 }
238 if cwd != "" {
239 add(filepath.Join(cwd, ".zot", "skills"), "project")
240 }
241 if zotHome != "" {
242 add(filepath.Join(zotHome, "skills"), "global")
243 }
244 if cwd != "" {
245 add(filepath.Join(cwd, ".claude", "skills"), "project (claude)")
246 }
247 if userHome != "" {
248 add(filepath.Join(userHome, ".claude", "skills"), "global (claude)")
249 }
250 if cwd != "" {
251 add(filepath.Join(cwd, ".agents", "skills"), "project (agents)")
252 }
253 if userHome != "" {
254 add(filepath.Join(userHome, ".agents", "skills"), "global (agents)")
255 }
256 return out
257}
258
259func load(path, source string) (*Skill, error) {
260 raw, err := os.ReadFile(path)
261 if err != nil {
262 return nil, err
263 }
264 front, body := splitFrontmatter(string(raw))
265 s := &Skill{
266 Path: path,
267 Source: source,
268 Body: strings.TrimSpace(body),
269 }
270 parseFrontmatter(front, s)
271 return s, nil
272}
273
274// splitFrontmatter returns (yamlBlock, restOfDocument) for a string
275// whose first non-empty line is "---". If no frontmatter is present,
276// returns ("", entireString).
277func splitFrontmatter(raw string) (string, string) {
278 rest := strings.TrimLeft(raw, " \t\r\n")
279 if !strings.HasPrefix(rest, "---") {
280 return "", raw
281 }
282 rest = strings.TrimPrefix(rest, "---")
283 // Drop the trailing newline after the opening ---.
284 if i := strings.IndexByte(rest, '\n'); i >= 0 {
285 rest = rest[i+1:]
286 }
287 end := strings.Index(rest, "\n---")
288 if end < 0 {
289 return "", raw // malformed; treat as no frontmatter
290 }
291 front := rest[:end]
292 body := rest[end+len("\n---"):]
293 body = strings.TrimLeft(body, " \t\r\n")
294 return front, body
295}
296
297// parseFrontmatter handles the small subset of YAML zot recognizes:
298// - simple `key: value` lines
299// - `key: [a, b, c]` flow-style lists
300// - `key:` followed by indented `- item` block lists
301// - nested `key:` followed by indented `subkey: [...]` for permissions
302//
303// Anything more elaborate is ignored. We deliberately avoid a yaml
304// dependency to keep zot's binary lean.
305func parseFrontmatter(front string, s *Skill) {
306 lines := strings.Split(front, "\n")
307 i := 0
308 for i < len(lines) {
309 line := lines[i]
310 trimmed := strings.TrimSpace(line)
311 if trimmed == "" || strings.HasPrefix(trimmed, "#") {
312 i++
313 continue
314 }
315 // Top-level key: value or key:
316 if !strings.HasPrefix(line, " ") && !strings.HasPrefix(line, "\t") {
317 colon := strings.IndexByte(trimmed, ':')
318 if colon < 0 {
319 i++
320 continue
321 }
322 key := strings.TrimSpace(trimmed[:colon])
323 value := strings.TrimSpace(trimmed[colon+1:])
324 switch key {
325 case "name":
326 s.Name = unquote(value)
327 case "description":
328 s.Description = unquote(value)
329 case "allowed-tools", "allowed_tools":
330 if value != "" {
331 s.AllowedTools = parseInlineList(value)
332 } else {
333 items, consumed := parseBlockList(lines[i+1:])
334 s.AllowedTools = items
335 i += consumed
336 }
337 case "permissions":
338 if value != "" {
339 // Unusual layout; ignore single-line for now.
340 i++
341 continue
342 }
343 perms, consumed := parsePermissionsBlock(lines[i+1:])
344 s.Permissions = perms
345 i += consumed
346 }
347 }
348 i++
349 }
350}
351
352// unquote trims surrounding " or ' from a value.
353func unquote(v string) string {
354 if len(v) >= 2 {
355 if (v[0] == '"' && v[len(v)-1] == '"') || (v[0] == '\'' && v[len(v)-1] == '\'') {
356 return v[1 : len(v)-1]
357 }
358 }
359 return v
360}
361
362// parseInlineList parses "[a, b, c]" or "[\"a\", \"b\"]".
363func parseInlineList(v string) []string {
364 v = strings.TrimSpace(v)
365 v = strings.TrimPrefix(v, "[")
366 v = strings.TrimSuffix(v, "]")
367 if v == "" {
368 return nil
369 }
370 parts := strings.Split(v, ",")
371 out := make([]string, 0, len(parts))
372 for _, p := range parts {
373 out = append(out, unquote(strings.TrimSpace(p)))
374 }
375 return out
376}
377
378// parseBlockList consumes indented "- item" lines until a less-
379// indented line. Returns the items + how many lines to skip.
380func parseBlockList(lines []string) ([]string, int) {
381 var out []string
382 consumed := 0
383 for _, line := range lines {
384 if strings.HasPrefix(line, " - ") || strings.HasPrefix(line, " - ") {
385 out = append(out, unquote(strings.TrimSpace(strings.TrimPrefix(strings.TrimLeft(line, " "), "-"))))
386 consumed++
387 continue
388 }
389 if strings.TrimSpace(line) == "" {
390 consumed++
391 continue
392 }
393 break
394 }
395 return out, consumed
396}
397
398// parsePermissionsBlock parses an indented map of tool->[patterns].
399//
400// permissions:
401// bash: ["git diff*", "git log*"]
402// read: ["./*.go"]
403func parsePermissionsBlock(lines []string) (map[string][]string, int) {
404 out := map[string][]string{}
405 consumed := 0
406 for _, line := range lines {
407 if !strings.HasPrefix(line, " ") {
408 break
409 }
410 trimmed := strings.TrimSpace(line)
411 if trimmed == "" {
412 consumed++
413 continue
414 }
415 colon := strings.IndexByte(trimmed, ':')
416 if colon < 0 {
417 break
418 }
419 key := strings.TrimSpace(trimmed[:colon])
420 val := strings.TrimSpace(trimmed[colon+1:])
421 if val == "" {
422 break // we don't support nested-block lists for permissions
423 }
424 out[key] = parseInlineList(val)
425 consumed++
426 }
427 return out, consumed
428}