v0.3.75 64117f3

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

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