v0.4.3 2a19df1

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.
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}

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