v0.27.1 131edba

skills.go RawHistoryFolder

1// Package skills implements zut'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; zut 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//	./.zut/skills/<name>/SKILL.md            — project (native)
16//	$ZUT_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 zut unchanged. User skill roots are
24// walked recursively, and nested files can also be addressed by their
25// slash-separated path relative to the root (for example,
26// systems-backend/subskills/golang-patterns).
27package skills
28
29import (
30	"fmt"
31	"io/fs"
32	"os"
33	"path/filepath"
34	"sort"
35	"strings"
36)
37
38// Skill is one discovered SKILL.md file.
39type Skill struct {
40	// Name is the skill identifier — what the model uses when it
41	// invokes the `skill` tool. Taken from the frontmatter `name`
42	// field; falls back to the directory basename.
43	Name string
44
45	// Aliases are alternate identifiers accepted by FindByName. Nested
46	// user skills include their slash-separated path relative to the
47	// search root, so routers can refer to them by file-system path.
48	Aliases []string
49
50	// Description is the one-line summary shown to the model in the
51	// system-prompt manifest.
52	Description string
53
54	// Body is the markdown after the frontmatter. Returned as the
55	// tool result when the model loads this skill.
56	Body string
57
58	// Path is the absolute path to the SKILL.md file.
59	Path string
60
61	// Source is a human-friendly label describing where the skill
62	// came from ("project", "global", "project (claude)", etc.).
63	// Shown in the /skills picker.
64	Source string
65
66	// Builtin marks skills that ship inside the zut binary. They are
67	// fully active for the model (system-prompt manifest + skill
68	// tool) but hidden from user-facing surfaces like the /skills
69	// picker so users only see skills they actually installed or
70	// shipped in their project.
71	Builtin bool
72
73	// DisableModelInvocation hides the skill from the system-prompt
74	// manifest. The user can still load it explicitly with
75	// /skill:<name>.
76	DisableModelInvocation bool
77
78	// AllowedTools and Permissions are parsed for forward-
79	// compatibility but NOT enforced in this version. They appear
80	// in the skill body so the model can self-regulate.
81	AllowedTools []string
82	Permissions  map[string][]string
83}
84
85// BundledSkillDir identifies a skill directory shipped by an extension.
86// The directory contains one subdirectory per skill, each with SKILL.md.
87// Root is optional; when set, every loaded SKILL.md must resolve beneath it.
88// Extension managers set Root to keep symlinked skill files inside the
89// extension directory.
90type BundledSkillDir struct {
91	Dir    string
92	Source string
93	Root   string
94}
95
96// VisibleSkills returns the subset of skills users should see in
97// pickers, /skills, and other interactive surfaces. Built-ins are
98// hidden because they're implementation detail; the model still
99// loads them through the system-prompt manifest + the skill tool.
100func VisibleSkills(in []*Skill) []*Skill {
101	out := make([]*Skill, 0, len(in))
102	for _, s := range in {
103		if s == nil || s.Builtin {
104			continue
105		}
106		out = append(out, s)
107	}
108	return out
109}
110
111// Discover returns the merged skill set. When includeUser is true,
112// user-installed SKILL.md files are loaded before built-ins. Callers
113// normally pass true; --no-skill skips discovery entirely before this
114// function is called.
115//
116// First-match-wins per name; the order matches the priority list
117// in the package doc (project-local before global before claude-
118// compat before agents-compat, all before built-ins). That means a
119// user-installed skill with the same name as a built-in shadows
120// the built-in once includeUser is true.
121//
122// Errors per skill are returned alongside the partial result so a
123// single broken file doesn't suppress the rest.
124func Discover(zutHome, cwd, userHome string, includeUser bool) ([]*Skill, []error) {
125	return DiscoverWithBundled(zutHome, cwd, userHome, includeUser, nil)
126}
127
128// DiscoverWithBundled merges normal user skills with extension-bundled skills
129// and built-ins. Precedence is user/project locations, then bundled skills in
130// declaration order, then built-ins. The first matching name wins.
131func DiscoverWithBundled(zutHome, cwd, userHome string, includeUser bool, bundled []BundledSkillDir) ([]*Skill, []error) {
132	var errs []error
133	seen := map[string]*Skill{}
134	if includeUser {
135		errs = append(errs, scanUserSkills(zutHome, cwd, userHome, seen)...)
136	}
137	for _, dir := range bundled {
138		errs = append(errs, scanBundledSkills(dir, seen)...)
139	}
140	// Built-ins fill in any name the user or an extension didn't provide.
141	for _, s := range loadBuiltins() {
142		registerSkill(seen, s)
143	}
144	out := uniqueSkills(seen)
145	sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
146	return out, errs
147}
148
149// LoadBundled loads only the skills declared by extension manifests. The
150// first declaration with a given name wins; built-in and user locations are
151// intentionally not considered here.
152func LoadBundled(bundled []BundledSkillDir) ([]*Skill, []error) {
153	seen := map[string]*Skill{}
154	var errs []error
155	for _, dir := range bundled {
156		errs = append(errs, scanBundledSkills(dir, seen)...)
157	}
158	out := uniqueSkills(seen)
159	sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
160	return out, errs
161}
162
163func pathWithinRoot(root, path string) (bool, error) {
164	rootResolved, err := filepath.EvalSymlinks(root)
165	if err != nil {
166		return false, err
167	}
168	rootAbs, err := filepath.Abs(rootResolved)
169	if err != nil {
170		return false, err
171	}
172	resolved, err := filepath.EvalSymlinks(path)
173	if err != nil {
174		return false, err
175	}
176	resolvedAbs, err := filepath.Abs(resolved)
177	if err != nil {
178		return false, err
179	}
180	rel, err := filepath.Rel(rootAbs, resolvedAbs)
181	if err != nil {
182		return false, err
183	}
184	return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)), nil
185}
186
187// scanBundledSkills walks one caller-resolved bundled-skill directory and
188// populates `seen` with first-match-wins per name.
189func scanBundledSkills(dir BundledSkillDir, seen map[string]*Skill) []error {
190	if strings.TrimSpace(dir.Dir) == "" {
191		return nil
192	}
193	label := strings.TrimSpace(dir.Source)
194	if label == "" {
195		label = "extension (unnamed)"
196	}
197	var errs []error
198	loadOne := func(path, fallbackName string) {
199		if dir.Root != "" {
200			safe, err := pathWithinRoot(dir.Root, path)
201			if err != nil {
202				if !os.IsNotExist(err) {
203					errs = append(errs, fmt.Errorf("%s: validate path: %w", path, err))
204				}
205				return
206			}
207			if !safe {
208				errs = append(errs, fmt.Errorf("%s: resolved path escapes bundled extension directory", path))
209				return
210			}
211		}
212		s, err := load(path, label)
213		if err != nil {
214			if !os.IsNotExist(err) {
215				errs = append(errs, fmt.Errorf("%s: %w", path, err))
216			}
217			return
218		}
219		if s.Name == "" {
220			s.Name = fallbackName
221		}
222		registerSkill(seen, s)
223	}
224	skillPath := filepath.Join(dir.Dir, "SKILL.md")
225	if info, err := os.Stat(skillPath); err == nil {
226		if !info.IsDir() {
227			loadOne(skillPath, filepath.Base(dir.Dir))
228			return errs
229		}
230	} else if !os.IsNotExist(err) {
231		errs = append(errs, fmt.Errorf("%s: %w", skillPath, err))
232	}
233	entries, err := os.ReadDir(dir.Dir)
234	if err != nil {
235		if !os.IsNotExist(err) {
236			errs = append(errs, fmt.Errorf("%s: %w", dir.Dir, err))
237		}
238		return errs
239	}
240	for _, entry := range entries {
241		if !entry.IsDir() {
242			continue
243		}
244		loadOne(filepath.Join(dir.Dir, entry.Name(), "SKILL.md"), entry.Name())
245	}
246	return errs
247}
248
249// scanUserSkills walks the user-skill search dirs recursively and
250// populates `seen` with first-match-wins per name or alias. Split out
251// so Discover's includeUser=false path doesn't have to skip over a
252// giant block.
253func scanUserSkills(zutHome, cwd, userHome string, seen map[string]*Skill) []error {
254	var errs []error
255	for _, loc := range searchDirs(zutHome, cwd, userHome) {
256		_ = filepath.WalkDir(loc.dir, func(path string, entry fs.DirEntry, walkErr error) error {
257			if walkErr != nil {
258				if !os.IsNotExist(walkErr) {
259					errs = append(errs, fmt.Errorf("%s: %w", path, walkErr))
260				}
261				return nil
262			}
263			if entry.IsDir() || entry.Name() != "SKILL.md" {
264				return nil
265			}
266
267			rel, err := filepath.Rel(loc.dir, filepath.Dir(path))
268			if err != nil {
269				errs = append(errs, fmt.Errorf("%s: determine skill name: %w", path, err))
270				return nil
271			}
272			if rel == "." {
273				// Keep the existing convention that a search root is a
274				// collection of skill directories, not a skill itself.
275				return nil
276			}
277
278			s, err := load(path, loc.label)
279			if err != nil {
280				if !os.IsNotExist(err) {
281					errs = append(errs, fmt.Errorf("%s: %w", path, err))
282				}
283				return nil
284			}
285			if s.Name == "" {
286				s.Name = filepath.Base(filepath.Dir(path))
287			}
288			addRelativeAlias(s, loc.dir, path)
289			registerSkill(seen, s)
290			return nil
291		})
292	}
293	return errs
294}
295
296// addRelativeAlias makes a nested skill addressable by the directory path
297// used by common skill routers, for example
298// systems-backend/subskills/golang-patterns.
299func addRelativeAlias(s *Skill, root, path string) {
300	if s == nil {
301		return
302	}
303	rel, err := filepath.Rel(root, filepath.Dir(path))
304	if err != nil || rel == "." || rel == "" {
305		return
306	}
307	alias := filepath.ToSlash(rel)
308	if alias == s.Name {
309		return
310	}
311	for _, existing := range s.Aliases {
312		if existing == alias {
313			return
314		}
315	}
316	s.Aliases = append(s.Aliases, alias)
317}
318
319// registerSkill records a skill under its canonical name and aliases. A
320// collision on any identifier means a higher-priority skill already owns
321// that identifier, so the new skill is skipped as one unit.
322func registerSkill(seen map[string]*Skill, s *Skill) bool {
323	if s == nil || s.Name == "" {
324		return false
325	}
326	keys := append([]string{s.Name}, s.Aliases...)
327	for _, key := range keys {
328		if key == "" {
329			continue
330		}
331		if _, exists := seen[key]; exists {
332			return false
333		}
334	}
335	for _, key := range keys {
336		if key != "" {
337			seen[key] = s
338		}
339	}
340	return true
341}
342
343func uniqueSkills(seen map[string]*Skill) []*Skill {
344	out := make([]*Skill, 0, len(seen))
345	added := make(map[*Skill]struct{}, len(seen))
346	for _, s := range seen {
347		if s == nil {
348			continue
349		}
350		if _, exists := added[s]; exists {
351			continue
352		}
353		added[s] = struct{}{}
354		out = append(out, s)
355	}
356	return out
357}
358
359// SystemPromptAddendum returns the text to append to the system
360// prompt when at least one skill is loaded. Empty string if none.
361//
362// The format is deliberately compact: name, one-line description,
363// and a source pointer telling the model where the full body
364// lives. Built-in skills show "builtin" since their markdown is
365// embedded in the zut binary and not on the filesystem; user
366// skills show their SKILL.md path (shortened with ~ for HOME).
367//
368// Loading still goes through the `skill` tool with just the name.
369// The pointer is there so the model can (a) mention the source
370// honestly in explanations and (b) distinguish between built-ins
371// and user-authored instruction sets when reasoning about trust.
372func SystemPromptAddendum(skills []*Skill) string {
373	home, _ := os.UserHomeDir()
374	var sb strings.Builder
375	for _, s := range skills {
376		if s == nil || s.DisableModelInvocation {
377			continue
378		}
379		if sb.Len() == 0 {
380			sb.WriteString("Available skills (call the `skill` tool with a name from this list to load its full instructions):\n")
381		}
382		desc := strings.TrimSpace(s.Description)
383		if desc == "" {
384			desc = "(no description)"
385		}
386		pointer := skillSourcePointer(s, home)
387		fmt.Fprintf(&sb, "- %s [%s]: %s\n", s.Name, pointer, desc)
388	}
389	return sb.String()
390}
391
392// skillSourcePointer returns a short tag describing where a skill
393// originates. Built-ins are tagged "builtin" because their markdown
394// is embedded in the zut binary and not reachable through the
395// filesystem. User skills are tagged with their SKILL.md path,
396// collapsed to use ~ for the user home when possible.
397func skillSourcePointer(s *Skill, home string) string {
398	if s == nil {
399		return "unknown"
400	}
401	if s.Builtin {
402		return "builtin"
403	}
404	p := s.Path
405	if p == "" {
406		return "unknown"
407	}
408	if home != "" && strings.HasPrefix(p, home+string(filepath.Separator)) {
409		return "~" + p[len(home):]
410	}
411	return p
412}
413
414// FindByName returns the skill with the given name or alias, or nil.
415func FindByName(skills []*Skill, name string) *Skill {
416	for _, s := range skills {
417		if s != nil && s.Name == name {
418			return s
419		}
420	}
421	for _, s := range skills {
422		if s == nil {
423			continue
424		}
425		for _, alias := range s.Aliases {
426			if alias == name {
427				return s
428			}
429		}
430	}
431	return nil
432}
433
434// ---- internals ----
435
436type location struct {
437	dir   string
438	label string
439}
440
441func searchDirs(zutHome, cwd, userHome string) []location {
442	var out []location
443	add := func(dir, label string) {
444		if dir == "" {
445			return
446		}
447		out = append(out, location{dir: dir, label: label})
448	}
449	if extra := os.Getenv("ZUT_AGENT_SKILLS"); extra != "" {
450		for _, dir := range filepath.SplitList(extra) {
451			add(dir, "agent")
452		}
453	}
454	if cwd != "" {
455		add(filepath.Join(cwd, ".zut", "skills"), "project")
456	}
457	if zutHome != "" {
458		add(filepath.Join(zutHome, "skills"), "global")
459	}
460	if cwd != "" {
461		add(filepath.Join(cwd, ".claude", "skills"), "project (claude)")
462	}
463	if userHome != "" {
464		add(filepath.Join(userHome, ".claude", "skills"), "global (claude)")
465	}
466	if cwd != "" {
467		add(filepath.Join(cwd, ".agents", "skills"), "project (agents)")
468	}
469	if userHome != "" {
470		add(filepath.Join(userHome, ".agents", "skills"), "global (agents)")
471	}
472	return out
473}
474
475func load(path, source string) (*Skill, error) {
476	raw, err := os.ReadFile(path)
477	if err != nil {
478		return nil, err
479	}
480	front, body := splitFrontmatter(string(raw))
481	s := &Skill{
482		Path:   path,
483		Source: source,
484		Body:   strings.TrimSpace(body),
485	}
486	parseFrontmatter(front, s)
487	return s, nil
488}
489
490// splitFrontmatter returns (yamlBlock, restOfDocument) for a string
491// whose first non-empty line is "---". If no frontmatter is present,
492// returns ("", entireString).
493func splitFrontmatter(raw string) (string, string) {
494	rest := strings.TrimLeft(raw, " \t\r\n")
495	if !strings.HasPrefix(rest, "---") {
496		return "", raw
497	}
498	rest = strings.TrimPrefix(rest, "---")
499	// Drop the trailing newline after the opening ---.
500	if i := strings.IndexByte(rest, '\n'); i >= 0 {
501		rest = rest[i+1:]
502	}
503	end := strings.Index(rest, "\n---")
504	if end < 0 {
505		return "", raw // malformed; treat as no frontmatter
506	}
507	front := rest[:end]
508	body := rest[end+len("\n---"):]
509	body = strings.TrimLeft(body, " \t\r\n")
510	return front, body
511}
512
513// parseFrontmatter handles the small subset of YAML zut recognizes:
514//   - simple `key: value` lines
515//   - `key: [a, b, c]` flow-style lists
516//   - `key:` followed by indented `- item` block lists
517//   - nested `key:` followed by indented `subkey: [...]` for permissions
518//
519// Anything more elaborate is ignored. We deliberately avoid a yaml
520// dependency to keep zut's binary lean.
521func parseFrontmatter(front string, s *Skill) {
522	lines := strings.Split(front, "\n")
523	i := 0
524	for i < len(lines) {
525		line := lines[i]
526		trimmed := strings.TrimSpace(line)
527		if trimmed == "" || strings.HasPrefix(trimmed, "#") {
528			i++
529			continue
530		}
531		// Top-level key: value or key:
532		if !strings.HasPrefix(line, " ") && !strings.HasPrefix(line, "\t") {
533			colon := strings.IndexByte(trimmed, ':')
534			if colon < 0 {
535				i++
536				continue
537			}
538			key := strings.TrimSpace(trimmed[:colon])
539			value := strings.TrimSpace(trimmed[colon+1:])
540			switch key {
541			case "name":
542				s.Name = unquote(value)
543			case "description":
544				s.Description = unquote(value)
545			case "disable-model-invocation", "disable_model_invocation":
546				s.DisableModelInvocation = strings.EqualFold(unquote(value), "true")
547			case "allowed-tools", "allowed_tools":
548				if value != "" {
549					s.AllowedTools = parseInlineList(value)
550				} else {
551					items, consumed := parseBlockList(lines[i+1:])
552					s.AllowedTools = items
553					i += consumed
554				}
555			case "permissions":
556				if value != "" {
557					// Unusual layout; ignore single-line for now.
558					i++
559					continue
560				}
561				perms, consumed := parsePermissionsBlock(lines[i+1:])
562				s.Permissions = perms
563				i += consumed
564			}
565		}
566		i++
567	}
568}
569
570// unquote trims surrounding " or ' from a value.
571func unquote(v string) string {
572	if len(v) >= 2 {
573		if (v[0] == '"' && v[len(v)-1] == '"') || (v[0] == '\'' && v[len(v)-1] == '\'') {
574			return v[1 : len(v)-1]
575		}
576	}
577	return v
578}
579
580// parseInlineList parses "[a, b, c]" or "[\"a\", \"b\"]".
581func parseInlineList(v string) []string {
582	v = strings.TrimSpace(v)
583	v = strings.TrimPrefix(v, "[")
584	v = strings.TrimSuffix(v, "]")
585	if v == "" {
586		return nil
587	}
588	parts := strings.Split(v, ",")
589	out := make([]string, 0, len(parts))
590	for _, p := range parts {
591		out = append(out, unquote(strings.TrimSpace(p)))
592	}
593	return out
594}
595
596// parseBlockList consumes indented "- item" lines until a less-
597// indented line. Returns the items + how many lines to skip.
598func parseBlockList(lines []string) ([]string, int) {
599	var out []string
600	consumed := 0
601	for _, line := range lines {
602		if strings.HasPrefix(line, "  - ") || strings.HasPrefix(line, "    - ") {
603			out = append(out, unquote(strings.TrimSpace(strings.TrimPrefix(strings.TrimLeft(line, " "), "-"))))
604			consumed++
605			continue
606		}
607		if strings.TrimSpace(line) == "" {
608			consumed++
609			continue
610		}
611		break
612	}
613	return out, consumed
614}
615
616// parsePermissionsBlock parses an indented map of tool->[patterns].
617//
618//	permissions:
619//	  bash: ["git diff*", "git log*"]
620//	  read: ["./*.go"]
621func parsePermissionsBlock(lines []string) (map[string][]string, int) {
622	out := map[string][]string{}
623	consumed := 0
624	for _, line := range lines {
625		if !strings.HasPrefix(line, "  ") {
626			break
627		}
628		trimmed := strings.TrimSpace(line)
629		if trimmed == "" {
630			consumed++
631			continue
632		}
633		colon := strings.IndexByte(trimmed, ':')
634		if colon < 0 {
635			break
636		}
637		key := strings.TrimSpace(trimmed[:colon])
638		val := strings.TrimSpace(trimmed[colon+1:])
639		if val == "" {
640			break // we don't support nested-block lists for permissions
641		}
642		out[key] = parseInlineList(val)
643		consumed++
644	}
645	return out, consumed
646}

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 476

↑↓ moveenter openesc close