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}