v0.3.86 81f384a

skills.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 477

↑↓ moveenter openesc close