v0.0.27 e9b466a

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

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