v0.0.31 e9ffc74

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	// 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 walks every supported location, parses each SKILL.md, and
87// returns the merged skill set. First-match-wins per name; the order
88// matches the priority list in the package doc. Errors per skill are
89// returned alongside the partial result so a single broken file
90// doesn't suppress the rest.
91//
92// Built-in skills (compiled into the zot binary) are added LAST so
93// any user-installed skill with the same name shadows the built-in.
94// That lets users customise the help text by dropping their own
95// SKILL.md with the same name into $ZOT_HOME/skills/<name>/.
96func Discover(zotHome, cwd, userHome string) ([]*Skill, []error) {
97	var errs []error
98	seen := map[string]*Skill{}
99	for _, loc := range searchDirs(zotHome, cwd, userHome) {
100		entries, err := os.ReadDir(loc.dir)
101		if err != nil {
102			continue // missing dir is fine
103		}
104		for _, e := range entries {
105			if !e.IsDir() {
106				continue
107			}
108			path := filepath.Join(loc.dir, e.Name(), "SKILL.md")
109			s, err := load(path, loc.label)
110			if err != nil {
111				if !os.IsNotExist(err) {
112					errs = append(errs, fmt.Errorf("%s: %w", path, err))
113				}
114				continue
115			}
116			if s.Name == "" {
117				s.Name = e.Name()
118			}
119			if _, dup := seen[s.Name]; dup {
120				continue // higher-priority location already won
121			}
122			seen[s.Name] = s
123		}
124	}
125	// Built-ins fill in any name the user didn't already provide.
126	for _, s := range loadBuiltins() {
127		if _, dup := seen[s.Name]; dup {
128			continue
129		}
130		seen[s.Name] = s
131	}
132
133	out := make([]*Skill, 0, len(seen))
134	for _, s := range seen {
135		out = append(out, s)
136	}
137	sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
138	return out, errs
139}
140
141// SystemPromptAddendum returns the text to append to the system
142// prompt when at least one skill is loaded. Empty string if none.
143//
144// Format kept short and explicit so the model reliably calls the
145// `skill` tool with a name from the list rather than guessing.
146func SystemPromptAddendum(skills []*Skill) string {
147	if len(skills) == 0 {
148		return ""
149	}
150	var sb strings.Builder
151	sb.WriteString("Available skills (call the `skill` tool with one of these names to load full instructions):\n")
152	for _, s := range skills {
153		desc := strings.TrimSpace(s.Description)
154		if desc == "" {
155			desc = "(no description)"
156		}
157		fmt.Fprintf(&sb, "- %s — %s\n", s.Name, desc)
158	}
159	return sb.String()
160}
161
162// FindByName returns the skill with the given name, or nil.
163func FindByName(skills []*Skill, name string) *Skill {
164	for _, s := range skills {
165		if s.Name == name {
166			return s
167		}
168	}
169	return nil
170}
171
172// ---- internals ----
173
174type location struct {
175	dir   string
176	label string
177}
178
179func searchDirs(zotHome, cwd, userHome string) []location {
180	var out []location
181	add := func(dir, label string) {
182		if dir == "" {
183			return
184		}
185		out = append(out, location{dir: dir, label: label})
186	}
187	if cwd != "" {
188		add(filepath.Join(cwd, ".zot", "skills"), "project")
189	}
190	if zotHome != "" {
191		add(filepath.Join(zotHome, "skills"), "global")
192	}
193	if cwd != "" {
194		add(filepath.Join(cwd, ".claude", "skills"), "project (claude)")
195	}
196	if userHome != "" {
197		add(filepath.Join(userHome, ".claude", "skills"), "global (claude)")
198	}
199	if cwd != "" {
200		add(filepath.Join(cwd, ".agents", "skills"), "project (agents)")
201	}
202	if userHome != "" {
203		add(filepath.Join(userHome, ".agents", "skills"), "global (agents)")
204	}
205	return out
206}
207
208func load(path, source string) (*Skill, error) {
209	raw, err := os.ReadFile(path)
210	if err != nil {
211		return nil, err
212	}
213	front, body := splitFrontmatter(string(raw))
214	s := &Skill{
215		Path:   path,
216		Source: source,
217		Body:   strings.TrimSpace(body),
218	}
219	parseFrontmatter(front, s)
220	return s, nil
221}
222
223// splitFrontmatter returns (yamlBlock, restOfDocument) for a string
224// whose first non-empty line is "---". If no frontmatter is present,
225// returns ("", entireString).
226func splitFrontmatter(raw string) (string, string) {
227	rest := strings.TrimLeft(raw, " \t\r\n")
228	if !strings.HasPrefix(rest, "---") {
229		return "", raw
230	}
231	rest = strings.TrimPrefix(rest, "---")
232	// Drop the trailing newline after the opening ---.
233	if i := strings.IndexByte(rest, '\n'); i >= 0 {
234		rest = rest[i+1:]
235	}
236	end := strings.Index(rest, "\n---")
237	if end < 0 {
238		return "", raw // malformed; treat as no frontmatter
239	}
240	front := rest[:end]
241	body := rest[end+len("\n---"):]
242	body = strings.TrimLeft(body, " \t\r\n")
243	return front, body
244}
245
246// parseFrontmatter handles the small subset of YAML zot recognizes:
247//   - simple `key: value` lines
248//   - `key: [a, b, c]` flow-style lists
249//   - `key:` followed by indented `- item` block lists
250//   - nested `key:` followed by indented `subkey: [...]` for permissions
251//
252// Anything more elaborate is ignored. We deliberately avoid a yaml
253// dependency to keep zot's binary lean.
254func parseFrontmatter(front string, s *Skill) {
255	lines := strings.Split(front, "\n")
256	i := 0
257	for i < len(lines) {
258		line := lines[i]
259		trimmed := strings.TrimSpace(line)
260		if trimmed == "" || strings.HasPrefix(trimmed, "#") {
261			i++
262			continue
263		}
264		// Top-level key: value or key:
265		if !strings.HasPrefix(line, " ") && !strings.HasPrefix(line, "\t") {
266			colon := strings.IndexByte(trimmed, ':')
267			if colon < 0 {
268				i++
269				continue
270			}
271			key := strings.TrimSpace(trimmed[:colon])
272			value := strings.TrimSpace(trimmed[colon+1:])
273			switch key {
274			case "name":
275				s.Name = unquote(value)
276			case "description":
277				s.Description = unquote(value)
278			case "allowed-tools", "allowed_tools":
279				if value != "" {
280					s.AllowedTools = parseInlineList(value)
281				} else {
282					items, consumed := parseBlockList(lines[i+1:])
283					s.AllowedTools = items
284					i += consumed
285				}
286			case "permissions":
287				if value != "" {
288					// Unusual layout; ignore single-line for now.
289					i++
290					continue
291				}
292				perms, consumed := parsePermissionsBlock(lines[i+1:])
293				s.Permissions = perms
294				i += consumed
295			}
296		}
297		i++
298	}
299}
300
301// unquote trims surrounding " or ' from a value.
302func unquote(v string) string {
303	if len(v) >= 2 {
304		if (v[0] == '"' && v[len(v)-1] == '"') || (v[0] == '\'' && v[len(v)-1] == '\'') {
305			return v[1 : len(v)-1]
306		}
307	}
308	return v
309}
310
311// parseInlineList parses "[a, b, c]" or "[\"a\", \"b\"]".
312func parseInlineList(v string) []string {
313	v = strings.TrimSpace(v)
314	v = strings.TrimPrefix(v, "[")
315	v = strings.TrimSuffix(v, "]")
316	if v == "" {
317		return nil
318	}
319	parts := strings.Split(v, ",")
320	out := make([]string, 0, len(parts))
321	for _, p := range parts {
322		out = append(out, unquote(strings.TrimSpace(p)))
323	}
324	return out
325}
326
327// parseBlockList consumes indented "- item" lines until a less-
328// indented line. Returns the items + how many lines to skip.
329func parseBlockList(lines []string) ([]string, int) {
330	var out []string
331	consumed := 0
332	for _, line := range lines {
333		if strings.HasPrefix(line, "  - ") || strings.HasPrefix(line, "    - ") {
334			out = append(out, unquote(strings.TrimSpace(strings.TrimPrefix(strings.TrimLeft(line, " "), "-"))))
335			consumed++
336			continue
337		}
338		if strings.TrimSpace(line) == "" {
339			consumed++
340			continue
341		}
342		break
343	}
344	return out, consumed
345}
346
347// parsePermissionsBlock parses an indented map of tool->[patterns].
348//
349//	permissions:
350//	  bash: ["git diff*", "git log*"]
351//	  read: ["./*.go"]
352func parsePermissionsBlock(lines []string) (map[string][]string, int) {
353	out := map[string][]string{}
354	consumed := 0
355	for _, line := range lines {
356		if !strings.HasPrefix(line, "  ") {
357			break
358		}
359		trimmed := strings.TrimSpace(line)
360		if trimmed == "" {
361			consumed++
362			continue
363		}
364		colon := strings.IndexByte(trimmed, ':')
365		if colon < 0 {
366			break
367		}
368		key := strings.TrimSpace(trimmed[:colon])
369		val := strings.TrimSpace(trimmed[colon+1:])
370		if val == "" {
371			break // we don't support nested-block lists for permissions
372		}
373		out[key] = parseInlineList(val)
374		consumed++
375	}
376	return out, consumed
377}

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