v0.3.48 9b7bb6a

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

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