v0.50.1 a637010

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). Symlinked directories are
27// followed; see scanUserSkills.
28package skills
29
30import (
31	"errors"
32	"fmt"
33	"io/fs"
34	"os"
35	"path/filepath"
36	"runtime"
37	"sort"
38	"strings"
39	"syscall"
40)
41
42// Skill is one discovered SKILL.md file.
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	// Aliases are alternate identifiers accepted by FindByName. Nested
50	// user skills include their slash-separated path relative to the
51	// search root, so routers can refer to them by file-system path.
52	Aliases []string
53
54	// Description is the one-line summary shown to the model in the
55	// system-prompt manifest.
56	Description string
57
58	// Body is the markdown after the frontmatter. Returned as the
59	// tool result when the model loads this skill.
60	Body string
61
62	// Path is the absolute path to the SKILL.md file.
63	Path string
64
65	// Source is a human-friendly label describing where the skill
66	// came from ("project", "global", "project (claude)", etc.).
67	// Shown in the /skills picker.
68	Source string
69
70	// Builtin marks skills that ship inside the zut binary. They are
71	// fully active for the model (system-prompt manifest + skill
72	// tool) but hidden from user-facing surfaces like the /skills
73	// picker so users only see skills they actually installed or
74	// shipped in their project.
75	Builtin bool
76
77	// DisableModelInvocation hides the skill from the system-prompt
78	// manifest. The user can still load it explicitly with
79	// /skill:<name>.
80	DisableModelInvocation bool
81
82	// AllowedTools and Permissions are parsed for forward-
83	// compatibility but NOT enforced in this version. They appear
84	// in the skill body so the model can self-regulate.
85	AllowedTools []string
86	Permissions  map[string][]string
87}
88
89// BundledSkillDir identifies a skill directory shipped by an extension.
90// The directory contains one subdirectory per skill, each with SKILL.md.
91// Root is optional; when set, every loaded SKILL.md must resolve beneath it.
92// Extension managers set Root to keep symlinked skill files inside the
93// extension directory.
94type BundledSkillDir struct {
95	Dir    string
96	Source string
97	Root   string
98}
99
100// VisibleSkills returns the subset of skills users should see in
101// pickers, /skills, and other interactive surfaces. Built-ins are
102// hidden because they're implementation detail; the model still
103// loads them through the system-prompt manifest + the skill tool.
104func VisibleSkills(in []*Skill) []*Skill {
105	out := make([]*Skill, 0, len(in))
106	for _, s := range in {
107		if s == nil || s.Builtin {
108			continue
109		}
110		out = append(out, s)
111	}
112	return out
113}
114
115// Discover returns the merged skill set. When includeUser is true,
116// user-installed SKILL.md files are loaded before built-ins. Callers
117// normally pass true; --no-skill skips discovery entirely before this
118// function is called.
119//
120// First-match-wins per name; the order matches the priority list
121// in the package doc (project-local before global before claude-
122// compat before agents-compat, all before built-ins). That means a
123// user-installed skill with the same name as a built-in shadows
124// the built-in once includeUser is true.
125//
126// Errors per skill are returned alongside the partial result so a
127// single broken file doesn't suppress the rest.
128func Discover(zutHome, cwd, userHome string, includeUser bool) ([]*Skill, []error) {
129	return DiscoverWithBundled(zutHome, cwd, userHome, includeUser, nil)
130}
131
132// DiscoverWithBundled merges normal user skills with extension-bundled skills
133// and built-ins. Precedence is user/project locations, then bundled skills in
134// declaration order, then built-ins. The first matching name wins.
135func DiscoverWithBundled(zutHome, cwd, userHome string, includeUser bool, bundled []BundledSkillDir) ([]*Skill, []error) {
136	var errs []error
137	seen := map[string]*Skill{}
138	if includeUser {
139		errs = append(errs, scanUserSkills(zutHome, cwd, userHome, seen)...)
140	}
141	for _, dir := range bundled {
142		errs = append(errs, scanBundledSkills(dir, seen)...)
143	}
144	// Built-ins fill in any name the user or an extension didn't provide.
145	for _, s := range loadBuiltins() {
146		registerSkill(seen, s)
147	}
148	out := uniqueSkills(seen)
149	sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
150	return out, errs
151}
152
153// LoadBundled loads only the skills declared by extension manifests. The
154// first declaration with a given name wins; built-in and user locations are
155// intentionally not considered here.
156func LoadBundled(bundled []BundledSkillDir) ([]*Skill, []error) {
157	seen := map[string]*Skill{}
158	var errs []error
159	for _, dir := range bundled {
160		errs = append(errs, scanBundledSkills(dir, seen)...)
161	}
162	out := uniqueSkills(seen)
163	sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
164	return out, errs
165}
166
167func pathWithinRoot(root, path string) (bool, error) {
168	rootResolved, err := filepath.EvalSymlinks(root)
169	if err != nil {
170		return false, err
171	}
172	rootAbs, err := filepath.Abs(rootResolved)
173	if err != nil {
174		return false, err
175	}
176	resolved, err := filepath.EvalSymlinks(path)
177	if err != nil {
178		return false, err
179	}
180	resolvedAbs, err := filepath.Abs(resolved)
181	if err != nil {
182		return false, err
183	}
184	rel, err := filepath.Rel(rootAbs, resolvedAbs)
185	if err != nil {
186		return false, err
187	}
188	return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)), nil
189}
190
191// scanBundledSkills walks one caller-resolved bundled-skill directory and
192// populates `seen` with first-match-wins per name.
193func scanBundledSkills(dir BundledSkillDir, seen map[string]*Skill) []error {
194	if strings.TrimSpace(dir.Dir) == "" {
195		return nil
196	}
197	label := strings.TrimSpace(dir.Source)
198	if label == "" {
199		label = "extension (unnamed)"
200	}
201	var errs []error
202	loadOne := func(path, fallbackName string) {
203		if dir.Root != "" {
204			safe, err := pathWithinRoot(dir.Root, path)
205			if err != nil {
206				if !os.IsNotExist(err) {
207					errs = append(errs, fmt.Errorf("%s: validate path: %w", path, err))
208				}
209				return
210			}
211			if !safe {
212				errs = append(errs, fmt.Errorf("%s: resolved path escapes bundled extension directory", path))
213				return
214			}
215		}
216		s, err := load(path, label)
217		if err != nil {
218			if !os.IsNotExist(err) {
219				errs = append(errs, fmt.Errorf("%s: %w", path, err))
220			}
221			return
222		}
223		if s.Name == "" {
224			s.Name = fallbackName
225		}
226		registerSkill(seen, s)
227	}
228	skillPath := filepath.Join(dir.Dir, "SKILL.md")
229	if info, err := os.Stat(skillPath); err == nil {
230		if !info.IsDir() {
231			loadOne(skillPath, filepath.Base(dir.Dir))
232			return errs
233		}
234	} else if !os.IsNotExist(err) {
235		errs = append(errs, fmt.Errorf("%s: %w", skillPath, err))
236	}
237	entries, err := os.ReadDir(dir.Dir)
238	if err != nil {
239		if !os.IsNotExist(err) {
240			errs = append(errs, fmt.Errorf("%s: %w", dir.Dir, err))
241		}
242		return errs
243	}
244	// Real directories load before symlinked ones so a link cannot claim a
245	// fallback name ahead of the real directory it points at. Each entry
246	// stays one level deep, and loadOne still confines every resolved
247	// SKILL.md to dir.Root. A directory reachable both directly and through a
248	// link is loaded once, so an unnamed skill is not duplicated under its
249	// alias name.
250	visited := map[string]bool{}
251	visit := func(path string) bool {
252		resolved, err := filepath.EvalSymlinks(path)
253		if err != nil {
254			if !skipLinkError(err) {
255				errs = append(errs, fmt.Errorf("%s: %w", path, err))
256			}
257			return false
258		}
259		if visited[resolved] {
260			return false
261		}
262		visited[resolved] = true
263		return true
264	}
265	var linked []string
266	for _, entry := range entries {
267		if entry.IsDir() {
268			path := filepath.Join(dir.Dir, entry.Name())
269			if visit(path) {
270				loadOne(filepath.Join(path, "SKILL.md"), entry.Name())
271			}
272			continue
273		}
274		if entry.Type()&fs.ModeSymlink == 0 {
275			continue
276		}
277		path := filepath.Join(dir.Dir, entry.Name())
278		info, statErr := os.Stat(path)
279		if statErr != nil {
280			// Dangling and cyclic links are skipped; anything else
281			// (permissions, I/O) is reported.
282			if !skipLinkError(statErr) {
283				errs = append(errs, fmt.Errorf("%s: %w", path, statErr))
284			}
285			continue
286		}
287		if info.IsDir() {
288			linked = append(linked, entry.Name())
289		}
290	}
291	for _, name := range linked {
292		path := filepath.Join(dir.Dir, name)
293		if visit(path) {
294			loadOne(filepath.Join(path, "SKILL.md"), name)
295		}
296	}
297	return errs
298}
299
300// scanUserSkills walks the user-skill search dirs recursively and
301// populates `seen` with first-match-wins per name or alias. Split out
302// so Discover's includeUser=false path doesn't have to skip over a
303// giant block.
304//
305// Symlinked directories are followed (filepath.WalkDir would not). Real
306// directories are scanned before links so a link cannot claim a name or
307// alias ahead of the real tree, every resolved directory is scanned once so
308// link cycles terminate, and dangling or cyclic links are skipped. Skills
309// reached through a link keep the walked (link) path.
310func scanUserSkills(zutHome, cwd, userHome string, seen map[string]*Skill) []error {
311	var errs []error
312	for _, loc := range searchDirs(zutHome, cwd, userHome) {
313		errs = append(errs, scanUserSkillDir(loc, seen)...)
314	}
315	return errs
316}
317
318// Traversal bounds for one user skill location. Real skill trees are shallow
319// and small; the caps stop a pathological or mis-linked tree (for example a
320// link into a huge shared checkout) from stalling startup. Too-deep
321// subtrees are skipped and the directory-count cap ends the scan of that
322// location; either way one nonfatal diagnostic is reported and every skill
323// already found is kept.
324const (
325	maxSkillScanDepth = 64
326	maxSkillScanDirs  = 10000
327)
328
329type linkedDir struct {
330	path  string
331	depth int
332}
333
334func scanUserSkillDir(loc location, seen map[string]*Skill) []error {
335	var errs []error
336	visited := map[string]bool{}
337	var linkedDirs []linkedDir
338	dirCount := 0
339	truncated := false // directory cap reached: stop the whole location
340	reported := false
341	report := func(why string) {
342		if !reported {
343			reported = true
344			errs = append(errs, fmt.Errorf("%s: skill scan stopped early: %s; some directories were skipped", loc.dir, why))
345		}
346	}
347	// A link whose target is the location itself or one of its ancestors
348	// would pull the surrounding filesystem into the scan. Compare against
349	// the resolved location root.
350	rootResolved, rootErr := filepath.EvalSymlinks(loc.dir)
351	loadSkill := func(path string) {
352		s, err := load(path, loc.label)
353		if err != nil {
354			if !os.IsNotExist(err) {
355				errs = append(errs, fmt.Errorf("%s: %w", path, err))
356			}
357			return
358		}
359		if s.Name == "" {
360			s.Name = filepath.Base(filepath.Dir(path))
361		}
362		addRelativeAlias(s, loc.dir, path)
363		registerSkill(seen, s)
364	}
365	var walk func(dir string, depth int)
366	walk = func(dir string, depth int) {
367		if truncated {
368			return
369		}
370		if depth > maxSkillScanDepth {
371			report(fmt.Sprintf("nesting deeper than %d levels", maxSkillScanDepth))
372			return
373		}
374		resolved, err := filepath.EvalSymlinks(dir)
375		if err != nil {
376			if !skipLinkError(err) {
377				errs = append(errs, fmt.Errorf("%s: %w", dir, err))
378			}
379			return
380		}
381		if visited[resolved] {
382			return
383		}
384		if dirCount >= maxSkillScanDirs {
385			truncated = true
386			report(fmt.Sprintf("more than %d directories", maxSkillScanDirs))
387			return
388		}
389		visited[resolved] = true
390		dirCount++
391		entries, err := os.ReadDir(dir)
392		if err != nil {
393			if !os.IsNotExist(err) {
394				errs = append(errs, fmt.Errorf("%s: %w", dir, err))
395			}
396			return
397		}
398		for _, entry := range entries {
399			path := filepath.Join(dir, entry.Name())
400			isDir := entry.IsDir()
401			if entry.Type()&fs.ModeSymlink != 0 {
402				info, statErr := os.Stat(path)
403				if statErr != nil {
404					// Dangling and cyclic links are not skills; SKILL.md
405					// links fall through to load, which ignores ENOENT.
406					if !skipLinkError(statErr) {
407						errs = append(errs, fmt.Errorf("%s: %w", path, statErr))
408					}
409					continue
410				}
411				if info.IsDir() {
412					if target, err := filepath.EvalSymlinks(path); err == nil && rootErr == nil && containsPath(target, rootResolved) {
413						// Link to the root or an ancestor of it: a cycle.
414						continue
415					}
416					linkedDirs = append(linkedDirs, linkedDir{path: path, depth: depth + 1})
417					continue
418				}
419			}
420			if isDir {
421				walk(path, depth+1)
422				continue
423			}
424			if entry.Name() != "SKILL.md" {
425				continue
426			}
427			if dir == loc.dir {
428				// Keep the existing convention that a search root is a
429				// collection of skill directories, not a skill itself.
430				continue
431			}
432			loadSkill(path)
433		}
434	}
435	walk(loc.dir, 0)
436	for i := 0; i < len(linkedDirs); i++ {
437		walk(linkedDirs[i].path, linkedDirs[i].depth)
438	}
439	return errs
440}
441
442// containsPath reports whether child equals parent or lies beneath it. Both
443// paths must already be resolved and absolute.
444func containsPath(parent, child string) bool {
445	rel, err := filepath.Rel(parent, child)
446	if err != nil {
447		return false
448	}
449	return rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)))
450}
451
452// skipLinkError reports whether err means a link points at nothing or loops.
453func skipLinkError(err error) bool {
454	return errors.Is(err, fs.ErrNotExist) || symlinkLoopError(err)
455}
456
457func symlinkLoopError(err error) bool {
458	// Windows reports cyclic links as ERROR_CANT_RESOLVE_FILENAME (1921),
459	// rather than ELOOP. syscall does not export a name for this error.
460	return errors.Is(err, syscall.ELOOP) ||
461		(runtime.GOOS == "windows" && errors.Is(err, syscall.Errno(1921)))
462}
463
464// addRelativeAlias makes a nested skill addressable by the directory path
465// used by common skill routers, for example
466// systems-backend/subskills/golang-patterns.
467func addRelativeAlias(s *Skill, root, path string) {
468	if s == nil {
469		return
470	}
471	rel, err := filepath.Rel(root, filepath.Dir(path))
472	if err != nil || rel == "." || rel == "" {
473		return
474	}
475	alias := filepath.ToSlash(rel)
476	if alias == s.Name {
477		return
478	}
479	for _, existing := range s.Aliases {
480		if existing == alias {
481			return
482		}
483	}
484	s.Aliases = append(s.Aliases, alias)
485}
486
487// registerSkill records a skill under its canonical name and aliases. A
488// collision on any identifier means a higher-priority skill already owns
489// that identifier, so the new skill is skipped as one unit.
490func registerSkill(seen map[string]*Skill, s *Skill) bool {
491	if s == nil || s.Name == "" {
492		return false
493	}
494	keys := append([]string{s.Name}, s.Aliases...)
495	for _, key := range keys {
496		if key == "" {
497			continue
498		}
499		if _, exists := seen[key]; exists {
500			return false
501		}
502	}
503	for _, key := range keys {
504		if key != "" {
505			seen[key] = s
506		}
507	}
508	return true
509}
510
511func uniqueSkills(seen map[string]*Skill) []*Skill {
512	out := make([]*Skill, 0, len(seen))
513	added := make(map[*Skill]struct{}, len(seen))
514	for _, s := range seen {
515		if s == nil {
516			continue
517		}
518		if _, exists := added[s]; exists {
519			continue
520		}
521		added[s] = struct{}{}
522		out = append(out, s)
523	}
524	return out
525}
526
527// SystemPromptAddendum returns the text to append to the system
528// prompt when at least one skill is loaded. Empty string if none.
529//
530// The format is deliberately compact: name, one-line description,
531// and a source pointer telling the model where the full body
532// lives. Built-in skills show "builtin" since their markdown is
533// embedded in the zut binary and not on the filesystem; user
534// skills show their SKILL.md path (shortened with ~ for HOME).
535//
536// Loading still goes through the `skill` tool with just the name.
537// The pointer is there so the model can (a) mention the source
538// honestly in explanations and (b) distinguish between built-ins
539// and user-authored instruction sets when reasoning about trust.
540func SystemPromptAddendum(skills []*Skill) string {
541	home, _ := os.UserHomeDir()
542	var sb strings.Builder
543	for _, s := range skills {
544		if s == nil || s.DisableModelInvocation {
545			continue
546		}
547		if sb.Len() == 0 {
548			sb.WriteString("Available skills (call the `skill` tool with a name from this list to load its full instructions):\n")
549		}
550		desc := strings.TrimSpace(s.Description)
551		if desc == "" {
552			desc = "(no description)"
553		}
554		pointer := skillSourcePointer(s, home)
555		fmt.Fprintf(&sb, "- %s [%s]: %s\n", s.Name, pointer, desc)
556	}
557	return sb.String()
558}
559
560// skillSourcePointer returns a short tag describing where a skill
561// originates. Built-ins are tagged "builtin" because their markdown
562// is embedded in the zut binary and not reachable through the
563// filesystem. User skills are tagged with their SKILL.md path,
564// collapsed to use ~ for the user home when possible.
565func skillSourcePointer(s *Skill, home string) string {
566	if s == nil {
567		return "unknown"
568	}
569	if s.Builtin {
570		return "builtin"
571	}
572	p := s.Path
573	if p == "" {
574		return "unknown"
575	}
576	if home != "" && strings.HasPrefix(p, home+string(filepath.Separator)) {
577		return "~" + p[len(home):]
578	}
579	return p
580}
581
582// FindByName returns the skill with the given name or alias, or nil.
583func FindByName(skills []*Skill, name string) *Skill {
584	for _, s := range skills {
585		if s != nil && s.Name == name {
586			return s
587		}
588	}
589	for _, s := range skills {
590		if s == nil {
591			continue
592		}
593		for _, alias := range s.Aliases {
594			if alias == name {
595				return s
596			}
597		}
598	}
599	return nil
600}
601
602// ---- internals ----
603
604type location struct {
605	dir   string
606	label string
607}
608
609func searchDirs(zutHome, cwd, userHome string) []location {
610	var out []location
611	add := func(dir, label string) {
612		if dir == "" {
613			return
614		}
615		out = append(out, location{dir: dir, label: label})
616	}
617	if extra := os.Getenv("ZUT_AGENT_SKILLS"); extra != "" {
618		for _, dir := range filepath.SplitList(extra) {
619			add(dir, "agent")
620		}
621	}
622	if cwd != "" {
623		add(filepath.Join(cwd, ".zut", "skills"), "project")
624	}
625	if zutHome != "" {
626		add(filepath.Join(zutHome, "skills"), "global")
627	}
628	if cwd != "" {
629		add(filepath.Join(cwd, ".claude", "skills"), "project (claude)")
630	}
631	if userHome != "" {
632		add(filepath.Join(userHome, ".claude", "skills"), "global (claude)")
633	}
634	if cwd != "" {
635		add(filepath.Join(cwd, ".agents", "skills"), "project (agents)")
636	}
637	if userHome != "" {
638		add(filepath.Join(userHome, ".agents", "skills"), "global (agents)")
639	}
640	return out
641}
642
643func load(path, source string) (*Skill, error) {
644	raw, err := os.ReadFile(path)
645	if err != nil {
646		return nil, err
647	}
648	front, body := splitFrontmatter(string(raw))
649	s := &Skill{
650		Path:   path,
651		Source: source,
652		Body:   strings.TrimSpace(body),
653	}
654	parseFrontmatter(front, s)
655	return s, nil
656}
657
658// splitFrontmatter returns (yamlBlock, restOfDocument) for a string
659// whose first non-empty line is "---". If no frontmatter is present,
660// returns ("", entireString).
661func splitFrontmatter(raw string) (string, string) {
662	rest := strings.TrimLeft(raw, " \t\r\n")
663	if !strings.HasPrefix(rest, "---") {
664		return "", raw
665	}
666	rest = strings.TrimPrefix(rest, "---")
667	// Drop the trailing newline after the opening ---.
668	if i := strings.IndexByte(rest, '\n'); i >= 0 {
669		rest = rest[i+1:]
670	}
671	end := strings.Index(rest, "\n---")
672	if end < 0 {
673		return "", raw // malformed; treat as no frontmatter
674	}
675	front := rest[:end]
676	body := rest[end+len("\n---"):]
677	body = strings.TrimLeft(body, " \t\r\n")
678	return front, body
679}
680
681// parseFrontmatter handles the small subset of YAML zut recognizes:
682//   - simple `key: value` lines
683//   - `key: [a, b, c]` flow-style lists
684//   - `key:` followed by indented `- item` block lists
685//   - nested `key:` followed by indented `subkey: [...]` for permissions
686//
687// Anything more elaborate is ignored. We deliberately avoid a yaml
688// dependency to keep zut's binary lean.
689func parseFrontmatter(front string, s *Skill) {
690	lines := strings.Split(front, "\n")
691	i := 0
692	for i < len(lines) {
693		line := lines[i]
694		trimmed := strings.TrimSpace(line)
695		if trimmed == "" || strings.HasPrefix(trimmed, "#") {
696			i++
697			continue
698		}
699		// Top-level key: value or key:
700		if !strings.HasPrefix(line, " ") && !strings.HasPrefix(line, "\t") {
701			colon := strings.IndexByte(trimmed, ':')
702			if colon < 0 {
703				i++
704				continue
705			}
706			key := strings.TrimSpace(trimmed[:colon])
707			value := strings.TrimSpace(trimmed[colon+1:])
708			switch key {
709			case "name":
710				s.Name = unquote(value)
711			case "description":
712				s.Description = unquote(value)
713			case "disable-model-invocation", "disable_model_invocation":
714				s.DisableModelInvocation = strings.EqualFold(unquote(value), "true")
715			case "allowed-tools", "allowed_tools":
716				if value != "" {
717					s.AllowedTools = parseInlineList(value)
718				} else {
719					items, consumed := parseBlockList(lines[i+1:])
720					s.AllowedTools = items
721					i += consumed
722				}
723			case "permissions":
724				if value != "" {
725					// Unusual layout; ignore single-line for now.
726					i++
727					continue
728				}
729				perms, consumed := parsePermissionsBlock(lines[i+1:])
730				s.Permissions = perms
731				i += consumed
732			}
733		}
734		i++
735	}
736}
737
738// unquote trims surrounding " or ' from a value.
739func unquote(v string) string {
740	if len(v) >= 2 {
741		if (v[0] == '"' && v[len(v)-1] == '"') || (v[0] == '\'' && v[len(v)-1] == '\'') {
742			return v[1 : len(v)-1]
743		}
744	}
745	return v
746}
747
748// parseInlineList parses "[a, b, c]" or "[\"a\", \"b\"]".
749func parseInlineList(v string) []string {
750	v = strings.TrimSpace(v)
751	v = strings.TrimPrefix(v, "[")
752	v = strings.TrimSuffix(v, "]")
753	if v == "" {
754		return nil
755	}
756	parts := strings.Split(v, ",")
757	out := make([]string, 0, len(parts))
758	for _, p := range parts {
759		out = append(out, unquote(strings.TrimSpace(p)))
760	}
761	return out
762}
763
764// parseBlockList consumes indented "- item" lines until a less-
765// indented line. Returns the items + how many lines to skip.
766func parseBlockList(lines []string) ([]string, int) {
767	var out []string
768	consumed := 0
769	for _, line := range lines {
770		if strings.HasPrefix(line, "  - ") || strings.HasPrefix(line, "    - ") {
771			out = append(out, unquote(strings.TrimSpace(strings.TrimPrefix(strings.TrimLeft(line, " "), "-"))))
772			consumed++
773			continue
774		}
775		if strings.TrimSpace(line) == "" {
776			consumed++
777			continue
778		}
779		break
780	}
781	return out, consumed
782}
783
784// parsePermissionsBlock parses an indented map of tool->[patterns].
785//
786//	permissions:
787//	  bash: ["git diff*", "git log*"]
788//	  read: ["./*.go"]
789func parsePermissionsBlock(lines []string) (map[string][]string, int) {
790	out := map[string][]string{}
791	consumed := 0
792	for _, line := range lines {
793		if !strings.HasPrefix(line, "  ") {
794			break
795		}
796		trimmed := strings.TrimSpace(line)
797		if trimmed == "" {
798			consumed++
799			continue
800		}
801		colon := strings.IndexByte(trimmed, ':')
802		if colon < 0 {
803			break
804		}
805		key := strings.TrimSpace(trimmed[:colon])
806		val := strings.TrimSpace(trimmed[colon+1:])
807		if val == "" {
808			break // we don't support nested-block lists for permissions
809		}
810		out[key] = parseInlineList(val)
811		consumed++
812	}
813	return out, consumed
814}

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