返回 DeepSeek-Reasonix
skill.go
根目录 / internal / skill / skill.go
1 // Package skill loads invokable playbooks ("skills") from Markdown files. A skill
2 // is a named, described prompt body the model can invoke via the run_skill tool
3 // (or the user via a slash name): an "inline" skill folds its body into the turn as
4 // a tool result, a "subagent" skill runs in an isolated child loop and returns
5 // only its final answer. Project scope wins over global; only names+descriptions
6 // enter the cache-stable system-prompt index (see index.go) — bodies load on
7 // demand. Discovery scans several conventions (.reasonix / .agents / .agent /
8 // .claude under the project root and the home dir — see config.ConventionDirs) so
9 // skills authored for other agent tools migrate in unchanged. Directory skills
10 // use <name>/SKILL.md; flat <name>.md files from Claude roots are loaded only
11 // when they carry skill frontmatter. Discovery follows symlinks, so linked
12 // skills are picked up like real ones.
13 package skill
14
15 import (
16 "context"
17 "errors"
18 "fmt"
19 "io"
20 "os"
21 "path"
22 "path/filepath"
23 "slices"
24 "sort"
25 "strings"
26 "sync"
27
28 "github.com/fsnotify/fsnotify"
29
30 "reasonix/internal/config"
31 fileencoding "reasonix/internal/fileutil/encoding"
32 "reasonix/internal/frontmatter"
33 "reasonix/internal/tool"
34 )
35
36 // ErrInvocationUnavailable marks a profile/dependency gate that can become
37 // runnable after switching profile or connecting the required capability.
38 var ErrInvocationUnavailable = errors.New("skill invocation unavailable")
39
40 // Scope records where a skill was loaded from. Higher-priority scopes win on a
41 // name collision: project > custom > global > builtin.
42 type Scope string
43
44 const (
45 ScopeProject Scope = "project"
46 ScopeCustom Scope = "custom"
47 ScopeGlobal Scope = "global"
48 ScopeBuiltin Scope = "builtin"
49 )
50
51 // RunAs selects how an invoked skill executes. Inline folds the body into the
52 // parent turn; subagent spawns an isolated child loop and returns only the final
53 // answer (its tool calls and reasoning never enter the parent context).
54 type RunAs string
55
56 const (
57 RunInline RunAs = "inline"
58 RunSubagent RunAs = "subagent"
59 )
60
61 const (
62 // SkillsDirname is the directory under each root that holds skills.
63 SkillsDirname = "skills"
64 // SkillFile is the canonical filename inside a directory-layout skill.
65 SkillFile = "SKILL.md"
66 )
67
68 // Skill is a loaded playbook.
69 type Skill struct {
70 Name string // canonical identifier; matches the directory / filename stem
71 Description string // one-liner shown in the session-context catalog
72 Body string // full markdown body (post-frontmatter), loaded eagerly
73 Scope Scope // where it came from
74 Path string // absolute path to the SKILL.md / <name>.md, or "(builtin)"
75 Plugin string // installed plugin package name; empty for non-plugin skills
76 // runtimeBindingsPrepared is session-local invocation state. It must not be
77 // inferred from untrusted Markdown content or persisted skill metadata.
78 runtimeBindingsPrepared bool
79 // SlashPrefix overrides Plugin only for the user-facing invocation name.
80 // Imported Claude agents use <plugin>:agent so an agent and skill may safely
81 // share the same upstream name.
82 SlashPrefix string
83 // AllowedTools, when non-empty, scopes a subagent skill's tool registry to
84 // these literal tool names (from the `allowed-tools` frontmatter).
85 AllowedTools []string
86 RunAs RunAs // inline | subagent
87 Model string // optional model override for runAs=subagent (frontmatter `model:`)
88 Effort string // optional effort for runAs=subagent (frontmatter `effort:`)
89 // ReadOnly, when true, runs a subagent skill against the read-only tool
90 // registry: writer tools are stripped and bash enforces the read-only
91 // command policy at execution time (frontmatter `read-only:`). This is a
92 // tool-boundary contract, not a prompt promise.
93 ReadOnly bool
94 Color string // optional display tag for UI surfaces (frontmatter `color:`); no runtime effect
95 // Invocation gates whether this skill enters the Skills catalog the
96 // model reads every turn. "auto" (default) behaves like every skill always
97 // has. "manual" keeps the skill invocable by name (/<name>, run_skill) but
98 // invisible to model-initiated discovery — for user-authored subagent
99 // profiles meant to be triggered deliberately, not autonomously.
100 Invocation string // auto | manual (frontmatter `invocation:`)
101 // Routing metadata is intentionally kept out of the session-context Skills
102 // catalog; it feeds per-turn capability hints only.
103 Triggers []string
104 NegativeTriggers []string
105 AutoUse string // off | suggest | prefer | require
106 NeedsFreshData bool
107 Cost string // low | medium | high (advisory)
108 // Requires lists capability IDs this skill depends on (e.g. mcp-server:github).
109 // Optional; empty keeps full backward compatibility with older skills.
110 Requires []string
111 // Profiles restricts availability to economy|balanced|delivery. Empty means
112 // the skill is eligible in every profile.
113 Profiles []string
114 // InvalidProfiles preserves rejected profiles frontmatter values so doctor
115 // can warn about typos; the parser drops them from Profiles silently.
116 InvalidProfiles []string
117 }
118
119 // SlashName returns the user-facing slash identifier. Plugin skills use a
120 // package-qualified name while the internal Name remains stable for run_skill.
121 func (s Skill) SlashName() string {
122 prefix := strings.TrimSpace(s.SlashPrefix)
123 if prefix == "" {
124 prefix = strings.TrimSpace(s.Plugin)
125 }
126 if prefix == "" {
127 return s.Name
128 }
129 return prefix + ":" + s.Name
130 }
131
132 // IsValidName reports whether name is a usable skill identifier.
133 func IsValidName(name string) bool { return config.IsValidSkillName(name) }
134
135 // Options configure a Store. ProjectRoot "" reads only the global + custom
136 // scopes. HomeDir "" resolves to the OS home dir (tests point it at a tmpdir).
137 // ReasonixHomeDir overrides the canonical Reasonix home; empty uses
138 // config.ReasonixHomeDir(), or HomeDir/.reasonix when HomeDir is explicitly set.
139 type Options struct {
140 HomeDir string
141 ReasonixHomeDir string
142 ProjectRoot string
143 CustomPaths []string
144 PluginPaths map[string][]string // canonical custom root -> installed plugin package names
145 PluginAgentPaths map[string][]string // plugin roots whose flat Markdown files are Claude agents
146 ExcludedPaths []string
147 DisabledNames []string
148 MaxDepth int
149 DisableBuiltins bool // suppress shipped built-ins (test-only knob)
150 // Watch keeps long-lived catalogs current through filesystem events. Hosts
151 // that own the Store lifecycle set this and call Close during teardown.
152 Watch bool
153 // WatchService shares physical watches across stores when Watch is enabled.
154 WatchService *WatchService
155 // DisableDiscovery returns an empty store without probing project, custom,
156 // global, plugin, or built-in skill sources. It is a test-only isolation knob.
157 DisableDiscovery bool
158 // Stderr is the writer for diagnostic warnings. When nil, defaults to
159 // os.Stderr. Set to io.Discard to suppress output (e.g. during model
160 // switch inside a bubbletea session).
161 Stderr io.Writer
162 }
163
164 // Store resolves skills across the configured roots.
165 type Store struct {
166 homeDir string
167 reasonixHomeDir string
168 projectRoot string
169 customPaths []string
170 pluginPaths map[string][]string
171 pluginAgentPaths map[string][]string
172 excludedPaths map[string]bool
173 disabled map[string]bool
174 maxDepth int
175 disableBuiltins bool
176 disableDiscovery bool
177 autoWatch bool
178 stderr io.Writer
179 runtimeProfile string
180 requiresReady func([]string) []string
181 toolBindings func(Skill) []tool.MCPBinding
182 catalogMu sync.Mutex
183 catalogGen uint64
184 catalog *catalogSnapshot
185 catalogFlight *catalogFlight
186 discoveryScans uint64
187 hostWatch hostWatchState
188 watcherMu sync.Mutex
189 watcher *fsnotify.Watcher
190 watcherDone chan struct{}
191 watcherLifecycle watcherLifecycle
192 watcherGeneration uint64
193 closed bool
194 }
195
196 // CatalogSnapshot is an immutable, stable-order view of one discovery
197 // generation. Complete is false when cancellation or sustained invalidation
198 // forces the caller to receive the last complete snapshot instead.
199 type CatalogSnapshot struct {
200 Version uint64
201 Complete bool
202 Stale bool
203 Candidates []Skill
204 }
205
206 type catalogSnapshot struct {
207 version uint64
208 rootSig string
209 discovered []Skill
210 enabled []Skill
211 byName map[string]Skill
212 slash []Skill
213 builtins map[string]Skill
214 }
215
216 type catalogFlight struct {
217 generation uint64
218 done chan struct{}
219 cancel context.CancelFunc
220 }
221
222 // New builds a Store. Relative custom paths and a relative project root are made
223 // absolute; "~" in a custom path expands to the home dir.
224 func New(opts Options) *Store {
225 home := opts.HomeDir
226 if home == "" {
227 if h, err := os.UserHomeDir(); err == nil {
228 home = h
229 }
230 }
231 reasonixHome := opts.ReasonixHomeDir
232 if reasonixHome == "" {
233 if opts.HomeDir != "" {
234 reasonixHome = filepath.Join(home, ".reasonix")
235 } else {
236 reasonixHome = config.ReasonixHomeDir()
237 }
238 }
239 root := opts.ProjectRoot
240 if root != "" {
241 if abs, err := filepath.Abs(root); err == nil {
242 root = abs
243 }
244 }
245 base := root
246 if base == "" {
247 if wd, err := os.Getwd(); err == nil {
248 base = wd
249 }
250 }
251 custom := dedupePaths(resolveCustomPaths(opts.CustomPaths, base, home))
252 pluginPaths := normalizePluginPaths(opts.PluginPaths)
253 pluginAgentPaths := normalizePluginPaths(opts.PluginAgentPaths)
254 excluded := map[string]bool{}
255 for _, p := range dedupePaths(resolveCustomPaths(opts.ExcludedPaths, base, home)) {
256 excluded[config.CanonicalSkillPath(p)] = true
257 }
258 stderr := opts.Stderr
259 if stderr == nil {
260 stderr = os.Stderr
261 }
262 return &Store{
263 homeDir: home,
264 reasonixHomeDir: reasonixHome,
265 projectRoot: root,
266 customPaths: custom,
267 pluginPaths: pluginPaths,
268 pluginAgentPaths: pluginAgentPaths,
269 excludedPaths: excluded,
270 disabled: disabledNameSet(opts.DisabledNames),
271 maxDepth: normalizeMaxDepth(opts.MaxDepth),
272 disableBuiltins: opts.DisableBuiltins,
273 disableDiscovery: opts.DisableDiscovery,
274 autoWatch: opts.Watch,
275 hostWatch: hostWatchState{service: opts.WatchService},
276 stderr: stderr,
277 catalogGen: 1,
278 }
279 }
280
281 // ConfigureInvocationPolicy installs session-local runtime constraints for
282 // skill calls. It does not alter discovery or the provider-visible tool schema;
283 // callers validate the selected skill immediately before execution.
284 func (s *Store) ConfigureInvocationPolicy(profile string, requiresReady func([]string) []string) {
285 if s == nil {
286 return
287 }
288 s.runtimeProfile = normalizeRuntimeProfile(profile)
289 s.requiresReady = requiresReady
290 }
291
292 // ConfigureToolBindings installs a session-local resolver for plugin-owned MCP
293 // tools. It affects only an invoked skill body and never the cache-stable index.
294 func (s *Store) ConfigureToolBindings(resolve func(Skill) []tool.MCPBinding) {
295 if s == nil {
296 return
297 }
298 s.toolBindings = resolve
299 }
300
301 // Prepare binds a plugin skill's portable MCP references to this session's
302 // exact callable names. Non-plugin skills and sessions without bindings are
303 // returned byte-for-byte unchanged.
304 func (s *Store) Prepare(sk Skill) Skill {
305 if s == nil || s.toolBindings == nil || strings.TrimSpace(sk.Plugin) == "" || sk.runtimeBindingsPrepared {
306 return sk
307 }
308 bindings := append([]tool.MCPBinding(nil), s.toolBindings(sk)...)
309 if len(bindings) == 0 {
310 return sk
311 }
312 sort.Slice(bindings, func(i, j int) bool { return bindings[i].CallableName < bindings[j].CallableName })
313 seen := map[string]bool{}
314 unique := bindings[:0]
315 for _, binding := range bindings {
316 if binding.CallableName == "" || seen[binding.CallableName] {
317 continue
318 }
319 seen[binding.CallableName] = true
320 unique = append(unique, binding)
321 }
322 bindings = unique
323 if len(bindings) == 0 {
324 return sk
325 }
326 sk.AllowedTools = bindAllowedTools(sk.AllowedTools, bindings)
327 sk.runtimeBindingsPrepared = true
328
329 var b strings.Builder
330 b.WriteString(strings.TrimRight(sk.Body, " \t\r\n"))
331 b.WriteString("\n\n## Runtime MCP tool bindings\n\n")
332 b.WriteString("These host-generated bindings are authoritative for this invocation. Use the exact direct name below; if only `use_capability` is available, use the stable capability ID. Short or Claude-style MCP names in this skill refer to these bindings.\n")
333 for _, binding := range bindings {
334 fmt.Fprintf(&b, "\n- `%s/%s` → `%s` (capability `%s`)", binding.Server, binding.RawName, binding.CallableName, binding.CapabilityID)
335 }
336 sk.Body = b.String()
337 return sk
338 }
339
340 // Render prepares and renders a skill for a direct slash invocation.
341 func (s *Store) Render(sk Skill, args string) string { return Render(s.Prepare(sk), args) }
342
343 func bindAllowedTools(refs []string, bindings []tool.MCPBinding) []string {
344 if len(refs) == 0 {
345 return refs
346 }
347 out := make([]string, 0, len(refs))
348 seen := map[string]bool{}
349 appendOne := func(name string) {
350 if name != "" && !seen[name] {
351 seen[name] = true
352 out = append(out, name)
353 }
354 }
355 for _, ref := range refs {
356 matches := map[string]tool.MCPBinding{}
357 isPattern := strings.ContainsAny(ref, "*?[")
358 for _, binding := range bindings {
359 aliases := append(tool.MCPBindingAliases(binding), binding.CallableName)
360 for _, alias := range aliases {
361 matched := ref == alias
362 if isPattern {
363 matched, _ = path.Match(ref, alias)
364 }
365 if matched {
366 matches[binding.CallableName] = binding
367 break
368 }
369 }
370 }
371 if isPattern {
372 // Preserve the original pattern so an existing broad allowlist such as
373 // "*" keeps all of its prior tools. Add only canonical MCP names the
374 // upstream/Claude pattern itself cannot match in Reasonix.
375 appendOne(ref)
376 names := make([]string, 0, len(matches))
377 for name := range matches {
378 names = append(names, name)
379 }
380 sort.Strings(names)
381 for _, name := range names {
382 if matched, err := path.Match(ref, name); err != nil || !matched {
383 appendOne(name)
384 }
385 // Capability IDs are host-only allowlist entries consumed when the
386 // session exposes this MCP tool solely through use_capability. Do not
387 // add one when the authored pattern already grants the proxy itself.
388 proxyMatched, _ := path.Match(ref, "use_capability")
389 if !proxyMatched {
390 appendOne(matches[name].CapabilityID)
391 }
392 }
393 continue
394 }
395 if len(matches) == 1 {
396 for name, binding := range matches {
397 appendOne(name)
398 appendOne(binding.CapabilityID)
399 }
400 continue
401 }
402 // Preserve unresolved or ambiguous literals. The child registry will not
403 // gain any broader permission from them.
404 appendOne(ref)
405 }
406 return out
407 }
408
409 // ValidateInvocation enforces profiles/requires frontmatter at the host tool
410 // boundary, including direct run_skill calls that bypass capability routing.
411 // Skill profiles frontmatter is diagnostic-only: it never blocks invocation.
412 // Required capabilities still gate execution.
413 func (s *Store) ValidateInvocation(sk Skill) error {
414 if s == nil {
415 return nil
416 }
417 if len(sk.Requires) > 0 && s.requiresReady != nil {
418 if missing := s.requiresReady(sk.Requires); len(missing) > 0 {
419 return fmt.Errorf("%w: skill %q requires unavailable capabilities: %s", ErrInvocationUnavailable, sk.Name, strings.Join(missing, ", "))
420 }
421 }
422 return nil
423 }
424
425 // AllowedInProfile reports whether a skill lists profile among its frontmatter
426 // profiles. Empty profiles mean "all". Role settings no longer filter the
427 // model-visible skill index or block run_skill; this helper remains for doctor
428 // diagnostics and capability inventory reports.
429 func AllowedInProfile(sk Skill, profile string) bool {
430 if len(sk.Profiles) == 0 {
431 return true
432 }
433 want := normalizeRuntimeProfile(profile)
434 if want == "" {
435 return true
436 }
437 for _, candidate := range sk.Profiles {
438 if normalizeRuntimeProfile(candidate) == want {
439 return true
440 }
441 }
442 return false
443 }
444
445 // FilterForProfile returns skills that declare eligibility for profile.
446 // Host boot no longer uses this to hide skills from the model; doctor and
447 // inventory tooling may still call it for recommended-profile diagnostics.
448 func FilterForProfile(skills []Skill, profile string) []Skill {
449 out := make([]Skill, 0, len(skills))
450 for _, sk := range skills {
451 if AllowedInProfile(sk, profile) {
452 out = append(out, sk)
453 }
454 }
455 return out
456 }
457
458 func normalizeRuntimeProfile(profile string) string {
459 switch strings.ToLower(strings.TrimSpace(profile)) {
460 case "economy":
461 return "economy"
462 case "delivery":
463 return "delivery"
464 case "balanced", "full":
465 return "balanced"
466 default:
467 return ""
468 }
469 }
470
471 // HasProjectScope reports whether the store was configured with a project root.
472 func (s *Store) HasProjectScope() bool { return s.projectRoot != "" }
473
474 // PathStatus describes a root directory's readability, surfaced by `/skill paths`.
475 type PathStatus string
476
477 const (
478 StatusOK PathStatus = "ok"
479 StatusMissing PathStatus = "missing"
480 StatusNotDirectory PathStatus = "not-directory"
481 StatusUnreadable PathStatus = "unreadable"
482 )
483
484 // Root is one discovery directory with its scope, priority, and status.
485 type Root struct {
486 Dir string
487 Scope Scope
488 Priority int
489 Status PathStatus
490 }
491
492 type discoveryRoot struct {
493 Root
494 requireFlatMarker bool
495 plugins []string
496 forceSubagent bool
497 }
498
499 // roots returns the discovery directories, highest priority first: the
500 // convention dirs (config.ConventionDirs: .reasonix / .agents / .agent / .claude)
501 // under the project root → custom paths → the Reasonix home skills dir → other
502 // home-dir convention dirs. A later root never overrides an earlier one.
503 func (s *Store) roots() []discoveryRoot {
504 if s == nil || s.disableDiscovery {
505 return nil
506 }
507 type de struct {
508 dir string
509 scope Scope
510 requireFlatMarker bool
511 }
512 var dirs []de
513 if s.projectRoot != "" {
514 for _, c := range config.ConventionDirs {
515 dirs = append(dirs, de{filepath.Join(s.projectRoot, c, SkillsDirname), ScopeProject, c == ".claude"})
516 }
517 }
518 for _, d := range s.customPaths {
519 dirs = append(dirs, de{d, ScopeCustom, false})
520 }
521 if s.reasonixHomeDir != "" {
522 dirs = append(dirs, de{filepath.Join(s.reasonixHomeDir, SkillsDirname), ScopeGlobal, false})
523 }
524 if config.IsolatedHomeDir() == "" {
525 for _, c := range config.ConventionDirs {
526 dir := filepath.Join(s.homeDir, c, SkillsDirname)
527 if s.reasonixHomeDir != "" && config.CanonicalSkillPath(filepath.Dir(dir)) == config.CanonicalSkillPath(s.reasonixHomeDir) {
528 continue
529 }
530 dirs = append(dirs, de{dir, ScopeGlobal, c == ".claude"})
531 }
532 }
533 out := make([]discoveryRoot, 0, len(dirs))
534 for _, d := range dirs {
535 if s.excludedPaths[config.CanonicalSkillPath(d.dir)] {
536 continue
537 }
538 key := config.CanonicalSkillPath(d.dir)
539 out = append(out, discoveryRoot{
540 Root: Root{Dir: d.dir, Scope: d.scope, Priority: len(out), Status: pathStatus(d.dir)},
541 requireFlatMarker: d.requireFlatMarker,
542 plugins: append([]string(nil), s.pluginPaths[key]...),
543 forceSubagent: len(s.pluginAgentPaths[key]) > 0,
544 })
545 }
546 return out
547 }
548
549 func normalizePluginPaths(paths map[string][]string) map[string][]string {
550 out := map[string][]string{}
551 for path, plugins := range paths {
552 key := config.CanonicalSkillPath(path)
553 if key == "" {
554 continue
555 }
556 for _, plugin := range plugins {
557 plugin = strings.TrimSpace(plugin)
558 if plugin == "" || stringSliceContains(out[key], plugin) {
559 continue
560 }
561 out[key] = append(out[key], plugin)
562 }
563 sort.Strings(out[key])
564 }
565 return out
566 }
567
568 func stringSliceContains(items []string, want string) bool {
569 return slices.Contains(items, want)
570 }
571
572 // Roots exposes the discovery directories with their status for `/skill paths`.
573 func (s *Store) Roots() []Root {
574 roots := s.roots()
575 out := make([]Root, 0, len(roots))
576 for _, r := range roots {
577 out = append(out, r.Root)
578 }
579 return out
580 }
581
582 func disabledNameSet(names []string) map[string]bool {
583 out := map[string]bool{}
584 for _, name := range names {
585 if key := config.SkillNameKey(name); key != "" {
586 out[key] = true
587 }
588 }
589 return out
590 }
591
592 func (s *Store) disabledName(name string) bool {
593 return s.disabled[config.SkillNameKey(name)]
594 }
595
596 func normalizeMaxDepth(depth int) int {
597 const (
598 defaultDepth = 3
599 maxDepth = 5
600 )
601 if depth == 0 {
602 return defaultDepth
603 }
604 if depth < 1 {
605 return 1
606 }
607 if depth > maxDepth {
608 return maxDepth
609 }
610 return depth
611 }
612
613 // pathStatus classifies a root directory without failing on the common case of
614 // "not created yet".
615 func pathStatus(dir string) PathStatus {
616 info, err := os.Stat(dir)
617 if err != nil {
618 if os.IsNotExist(err) {
619 return StatusMissing
620 }
621 return StatusUnreadable
622 }
623 if !info.IsDir() {
624 return StatusNotDirectory
625 }
626 if f, err := os.Open(dir); err != nil {
627 return StatusUnreadable
628 } else {
629 _ = f.Close()
630 }
631 return StatusOK
632 }
633
634 func (s *Store) List() []Skill {
635 return s.enabledSkills()
636 }
637
638 // Candidate resolves metadata from the immutable catalog without reading the
639 // selected SKILL.md body or its references/scripts.
640 func (s *Store) Candidate(name string) (Skill, bool) {
641 if !IsValidName(name) || s == nil || s.disabledName(name) {
642 return Skill{}, false
643 }
644 snapshot := s.catalogSnapshot()
645 if snapshot == nil {
646 return Skill{}, false
647 }
648 candidate, ok := snapshot.byName[name]
649 return cloneSkill(candidate), ok
650 }
651
652 // SlashList returns the visible user-facing skill directory. Plugin skills are
653 // retained per package under /<plugin>:<name>, even when their bare names
654 // collide; non-plugin skills keep their existing short names.
655 func (s *Store) SlashList() []Skill {
656 s.invalidateChangedRoots()
657 snapshot := s.catalogSnapshot()
658 if snapshot == nil {
659 return nil
660 }
661 return cloneSkills(snapshot.slash)
662 }
663
664 // VisibleSlashSkills deduplicates skills by their user-facing slash name and
665 // returns them in deterministic display order.
666 func VisibleSlashSkills(skills []Skill) []Skill {
667 byName := map[string]Skill{}
668 for _, sk := range skills {
669 name := sk.SlashName()
670 if name == "" {
671 continue
672 }
673 if _, dup := byName[name]; !dup {
674 byName[name] = sk
675 }
676 }
677 out := make([]Skill, 0, len(byName))
678 for _, sk := range byName {
679 out = append(out, sk)
680 }
681 sort.Slice(out, func(i, j int) bool { return out[i].SlashName() < out[j].SlashName() })
682 return out
683 }
684
685 // ResolveSlashSkill resolves a visible qualified plugin name or a compatible
686 // short name. A short plugin name is rejected when multiple plugin packages
687 // contribute it; a higher-priority non-plugin winner keeps its short name.
688 func ResolveSlashSkill(skills []Skill, name string) (Skill, bool) {
689 name = strings.TrimPrefix(strings.TrimSpace(name), "/")
690 if name == "" {
691 return Skill{}, false
692 }
693 for _, sk := range skills {
694 if sk.SlashName() == name {
695 return sk, true
696 }
697 }
698 if strings.Contains(name, ":") || !IsValidName(name) {
699 return Skill{}, false
700 }
701 var winner Skill
702 var found bool
703 plugins := map[string]bool{}
704 for _, sk := range skills {
705 if sk.Name != name {
706 continue
707 }
708 if !found {
709 winner, found = sk, true
710 }
711 if sk.Plugin != "" {
712 plugins[sk.Plugin] = true
713 }
714 }
715 if !found || winner.Plugin != "" && len(plugins) > 1 {
716 return Skill{}, false
717 }
718 return winner, true
719 }
720
721 // Read resolves one skill by name from the current catalog index and reads only
722 // that selected body. ok is false when no such skill exists or the file is
723 // unreadable.
724 func (s *Store) Read(name string) (Skill, bool) {
725 return s.Load(context.Background(), name)
726 }
727
728 // Load resolves one selected candidate using a caller-owned cancellation
729 // context. Discovery wait and the one allowed stale-target refresh both stop
730 // when the owning turn is cancelled.
731 func (s *Store) Load(ctx context.Context, name string) (Skill, bool) {
732 if !IsValidName(name) {
733 return Skill{}, false
734 }
735 if s.disabledName(name) {
736 return Skill{}, false
737 }
738 for range 2 {
739 snapshot, err := s.Snapshot(ctx)
740 if err != nil {
741 return Skill{}, false
742 }
743 candidate, internal, versionMatched, ok := s.candidateAtVersion(name, snapshot.Version)
744 if !versionMatched {
745 // Invalidation raced the public snapshot copy. Resolve once more from a
746 // single generation rather than walking a possibly obsolete slice.
747 continue
748 }
749 if !ok {
750 return Skill{}, false
751 }
752 return s.loadCandidateContext(ctx, candidate, internal)
753 }
754 return Skill{}, false
755 }
756
757 // candidateAtVersion resolves an exact identity in O(1) from the same immutable
758 // generation returned to the caller. The internal catalog remains immutable
759 // after publication, so it is safe to retain its pointer after releasing the
760 // catalog lock.
761 func (s *Store) candidateAtVersion(name string, version uint64) (Skill, *catalogSnapshot, bool, bool) {
762 s.catalogMu.Lock()
763 defer s.catalogMu.Unlock()
764 snapshot := s.catalog
765 if snapshot == nil || snapshot.version != version {
766 return Skill{}, nil, false, false
767 }
768 candidate, ok := snapshot.byName[name]
769 return cloneSkill(candidate), snapshot, true, ok
770 }
771
772 // ReadSlash resolves a user-entered slash identifier without changing the
773 // bare identifiers accepted by Read/run_skill.
774 func (s *Store) ReadSlash(name string) (Skill, bool) {
775 candidate, ok := ResolveSlashSkill(s.discoveredSkills(), name)
776 if !ok {
777 return Skill{}, false
778 }
779 snapshot := s.catalogSnapshot()
780 return s.loadCandidate(candidate, snapshot)
781 }
782
783 func (s *Store) loadCandidate(candidate Skill, snapshot *catalogSnapshot) (Skill, bool) {
784 return s.loadCandidateContext(context.Background(), candidate, snapshot)
785 }
786
787 func (s *Store) loadCandidateContext(ctx context.Context, candidate Skill, snapshot *catalogSnapshot) (Skill, bool) {
788 if ctx == nil {
789 ctx = context.Background()
790 }
791 if ctx.Err() != nil {
792 return Skill{}, false
793 }
794 if strings.HasPrefix(candidate.Path, "(builtin") {
795 if snapshot != nil {
796 if builtin, ok := snapshot.builtins[candidate.Name]; ok {
797 return cloneSkill(builtin), true
798 }
799 }
800 return Skill{}, false
801 }
802 loaded, ok := s.parseSkill(candidate.Path, candidate.Name, candidate.Scope, false, true)
803 if ctx.Err() != nil {
804 return Skill{}, false
805 }
806 if ok && loaded.Name == candidate.Name && loaded.Path == candidate.Path {
807 // The catalog candidate deliberately carries metadata only. Use the
808 // freshly parsed selected file as the source of truth so an edit cannot
809 // return a stale description/model/tool policy merely because discovery
810 // was already warm. Source attribution is assigned by discovery rather
811 // than frontmatter and therefore remains attached to the candidate.
812 loaded.Scope = candidate.Scope
813 loaded.Plugin = candidate.Plugin
814 loaded.SlashPrefix = candidate.SlashPrefix
815 if candidate.RunAs == RunSubagent && candidate.SlashPrefix != "" {
816 loaded.RunAs = RunSubagent
817 loaded.Invocation = "manual"
818 loaded.AllowedTools = mapClaudeAgentTools(loaded.AllowedTools)
819 if isClaudeModelAlias(loaded.Model) {
820 loaded.Model = ""
821 }
822 }
823 return loaded, true
824 }
825 // The selected identity changed after the snapshot. Refresh once and resolve
826 // the name again instead of executing the stale target.
827 s.Invalidate("selected skill changed")
828 refreshed, err := s.Snapshot(ctx)
829 if err != nil {
830 return Skill{}, false
831 }
832 next, internal, versionMatched, found := s.candidateAtVersion(candidate.Name, refreshed.Version)
833 if versionMatched && found && next.Path != candidate.Path {
834 return s.loadCandidateContext(ctx, next, internal)
835 }
836 return Skill{}, false
837 }
838
839 func (s *Store) discoverRoot(ctx context.Context, r discoveryRoot) []Skill {
840 var out []Skill
841 s.scanDir(ctx, r.Dir, r.Scope, r.requireFlatMarker, 1, map[string]bool{}, &out)
842 if r.forceSubagent {
843 for i := range out {
844 out[i].RunAs = RunSubagent
845 out[i].Invocation = "manual"
846 out[i].AllowedTools = mapClaudeAgentTools(out[i].AllowedTools)
847 if isClaudeModelAlias(out[i].Model) {
848 out[i].Model = ""
849 }
850 }
851 }
852 return out
853 }
854
855 func (s *Store) scanDir(ctx context.Context, dir string, scope Scope, requireFlatMarker bool, depth int, seen map[string]bool, out *[]Skill) {
856 if ctx.Err() != nil {
857 return
858 }
859 key := filepath.Clean(dir)
860 if resolved, err := filepath.EvalSymlinks(dir); err == nil {
861 key = filepath.Clean(resolved)
862 }
863 if seen[key] {
864 return
865 }
866 seen[key] = true
867
868 entries, err := os.ReadDir(dir)
869 if err != nil {
870 return
871 }
872 for _, e := range entries {
873 if ctx.Err() != nil {
874 return
875 }
876 sk, ok := s.readEntry(dir, scope, requireFlatMarker, e)
877 if ok {
878 if depth == 1 || strings.TrimSpace(sk.Description) != "" {
879 *out = append(*out, sk)
880 }
881 continue
882 }
883 if depth >= s.maxDepth || !s.canScanChildDir(dir, e) {
884 continue
885 }
886 s.scanDir(ctx, filepath.Join(dir, e.Name()), scope, requireFlatMarker, depth+1, seen, out)
887 }
888 }
889
890 func (s *Store) canScanChildDir(dir string, e os.DirEntry) bool {
891 name := e.Name()
892 if shouldSkipScanDir(name) {
893 return false
894 }
895 if e.IsDir() {
896 return true
897 }
898 if !shouldStatEntryTarget(e.Type()) {
899 return false
900 }
901 info, err := os.Stat(filepath.Join(dir, name))
902 return err == nil && info.IsDir()
903 }
904
905 func shouldStatEntryTarget(mode os.FileMode) bool {
906 return mode&os.ModeSymlink != 0 || mode&os.ModeIrregular != 0
907 }
908
909 func shouldSkipScanDir(name string) bool {
910 if strings.HasPrefix(name, ".") {
911 return true
912 }
913 switch strings.ToLower(name) {
914 case "assets", "node_modules", "references", "scripts":
915 return true
916 default:
917 return false
918 }
919 }
920
921 // readEntry turns one directory entry into a skill. It resolves symlink and
922 // Windows reparse-style entries via os.Stat (os.ReadDir can report the link's
923 // own type, not its target's), so a linked skill directory or flat <name>.md is
924 // discovered like a real one; a broken link fails Stat and is skipped.
925 func (s *Store) readEntry(dir string, scope Scope, requireFlatMarker bool, e os.DirEntry) (Skill, bool) {
926 name := e.Name()
927 full := filepath.Join(dir, name)
928
929 isDir := e.IsDir()
930 isFile := e.Type().IsRegular()
931 if !isDir && !isFile && shouldStatEntryTarget(e.Type()) {
932 info, err := os.Stat(full) // follows the link
933 if err != nil {
934 return Skill{}, false // broken link
935 }
936 isDir = info.IsDir()
937 isFile = info.Mode().IsRegular()
938 }
939
940 if isDir {
941 if !IsValidName(name) {
942 return Skill{}, false
943 }
944 file := filepath.Join(full, SkillFile)
945 if _, err := os.Stat(file); err != nil {
946 return Skill{}, false // a directory without a SKILL.md is not a skill
947 }
948 return s.parse(file, name, scope)
949 }
950 if isFile && strings.EqualFold(filepath.Ext(name), ".md") {
951 stem := strings.TrimSuffix(name, filepath.Ext(name))
952 if !IsValidName(stem) {
953 return Skill{}, false
954 }
955 return s.parseFlat(full, stem, scope, requireFlatMarker)
956 }
957 return Skill{}, false
958 }
959
960 // parse reads and decodes one skill file. The frontmatter `name:` overrides the
961 // filename stem when valid; a missing `description:` is a warning, not a failure
962 // (the skill loads but won't appear in the model's index).
963 func (s *Store) parse(path, stem string, scope Scope) (Skill, bool) {
964 return s.parseSkill(path, stem, scope, false, false)
965 }
966
967 // parseFlat reads a flat <name>.md skill candidate. Claude skill roots can also
968 // contain ordinary documentation, so those flat files need explicit skill
969 // frontmatter before they are treated as skills.
970 func (s *Store) parseFlat(path, stem string, scope Scope, requireSkillMarker bool) (Skill, bool) {
971 return s.parseSkill(path, stem, scope, requireSkillMarker, false)
972 }
973
974 func (s *Store) parseSkill(path, stem string, scope Scope, requireSkillMarker, loadBody bool) (Skill, bool) {
975 b, err := fileencoding.ReadFileUTF8(path)
976 if err != nil {
977 return Skill{}, false
978 }
979 content := strings.TrimPrefix(strings.ReplaceAll(string(b), "\r\n", "\n"), "\uFEFF")
980 fm, body := splitFrontmatter(content)
981 if requireSkillMarker && !hasSkillMarker(content, fm) {
982 return Skill{}, false
983 }
984
985 name := stem
986 if v := fm[skillFrontmatterName]; v != "" && IsValidName(v) {
987 name = v
988 }
989 desc := strings.TrimSpace(fm[skillFrontmatterDescription])
990 if desc == "" && !loadBody {
991 fmt.Fprintf(s.stderr, "warning: skill %q at %s has no description: — it will load but won't appear in the skills index\n", name, path)
992 }
993 bodyText := ""
994 if loadBody {
995 bodyText = loadBodyWithScripts(path, loadBodyWithReferences(path, strings.TrimSpace(body)))
996 }
997 sk := Skill{
998 Name: name,
999 Description: desc,
1000 Body: bodyText,
1001 Scope: scope,
1002 Path: path,
1003 AllowedTools: parseAllowedTools(firstNonEmptySkillValue(fm[skillFrontmatterAllowedTools], fm["tools"])),
1004 RunAs: parseRunAs(fm[skillFrontmatterRunAs], fm[skillFrontmatterContext], fm[skillFrontmatterAgent]),
1005 Model: strings.TrimSpace(fm[skillFrontmatterModel]),
1006 Effort: strings.TrimSpace(fm[skillFrontmatterEffort]),
1007 ReadOnly: parseBoolFrontmatter(fm[skillFrontmatterReadOnly]),
1008 Triggers: parseCSVFrontmatter(fm[skillFrontmatterTriggers]),
1009 NegativeTriggers: parseCSVFrontmatter(
1010 fm[skillFrontmatterNegativeTriggers],
1011 ),
1012 AutoUse: parseAutoUse(fm[skillFrontmatterAutoUse]),
1013 NeedsFreshData: parseBoolFrontmatter(fm[skillFrontmatterNeedsFreshData]),
1014 Cost: parseCost(fm[skillFrontmatterCost]),
1015 Color: strings.TrimSpace(fm[skillFrontmatterColor]),
1016 Invocation: parseInvocation(fm[skillFrontmatterInvocation]),
1017 Requires: parseCSVFrontmatter(fm[skillFrontmatterRequires]),
1018 }
1019 sk.Profiles, sk.InvalidProfiles = parseProfilesFrontmatter(fm[skillFrontmatterProfiles])
1020 return sk, true
1021 }
1022
1023 func firstNonEmptySkillValue(values ...string) string {
1024 for _, value := range values {
1025 if strings.TrimSpace(value) != "" {
1026 return value
1027 }
1028 }
1029 return ""
1030 }
1031
1032 func isClaudeModelAlias(model string) bool {
1033 switch strings.ToLower(strings.TrimSpace(model)) {
1034 case "sonnet", "opus", "haiku", "inherit":
1035 return true
1036 default:
1037 return false
1038 }
1039 }
1040
1041 func mapClaudeAgentTools(in []string) []string {
1042 mapping := map[string]string{
1043 "read": "read_file", "write": "write_file", "edit": "edit_file",
1044 "bash": "bash", "grep": "grep", "glob": "glob", "ls": "ls",
1045 "webfetch": "web_fetch", "websearch": "web_search",
1046 }
1047 out := make([]string, 0, len(in))
1048 seen := map[string]bool{}
1049 for _, name := range in {
1050 mapped := strings.TrimSpace(name)
1051 if replacement := mapping[strings.ToLower(mapped)]; replacement != "" {
1052 mapped = replacement
1053 }
1054 if mapped != "" && !seen[mapped] {
1055 seen[mapped] = true
1056 out = append(out, mapped)
1057 }
1058 }
1059 return out
1060 }
1061
1062 const (
1063 skillFrontmatterDescription = "description"
1064 skillFrontmatterName = "name"
1065 skillFrontmatterRunAs = "runas"
1066 skillFrontmatterContext = "context"
1067 skillFrontmatterAgent = "agent"
1068 skillFrontmatterAllowedTools = "allowed-tools"
1069 skillFrontmatterModel = "model"
1070 skillFrontmatterEffort = "effort"
1071 skillFrontmatterReadOnly = "read-only"
1072 skillFrontmatterTriggers = "triggers"
1073 skillFrontmatterNegativeTriggers = "negative-triggers"
1074 skillFrontmatterAutoUse = "auto-use"
1075 skillFrontmatterNeedsFreshData = "needs-fresh-data"
1076 skillFrontmatterCost = "cost"
1077 skillFrontmatterColor = "color"
1078 skillFrontmatterInvocation = "invocation"
1079 skillFrontmatterRequires = "requires"
1080 skillFrontmatterProfiles = "profiles"
1081 )
1082
1083 var skillMarkerFrontmatterKeys = []string{
1084 skillFrontmatterDescription,
1085 skillFrontmatterName,
1086 skillFrontmatterRunAs,
1087 skillFrontmatterContext,
1088 skillFrontmatterAgent,
1089 skillFrontmatterAllowedTools,
1090 skillFrontmatterModel,
1091 skillFrontmatterEffort,
1092 skillFrontmatterReadOnly,
1093 skillFrontmatterTriggers,
1094 skillFrontmatterNegativeTriggers,
1095 skillFrontmatterAutoUse,
1096 skillFrontmatterNeedsFreshData,
1097 skillFrontmatterCost,
1098 skillFrontmatterColor,
1099 skillFrontmatterInvocation,
1100 skillFrontmatterRequires,
1101 skillFrontmatterProfiles,
1102 }
1103
1104 func hasSkillMarker(content string, fm map[string]string) bool {
1105 for _, key := range skillMarkerFrontmatterKeys {
1106 if strings.TrimSpace(fm[key]) != "" {
1107 return true
1108 }
1109 }
1110 return frontmatterHasSkillMarkerKey(content)
1111 }
1112
1113 func frontmatterHasSkillMarkerKey(content string) bool {
1114 lines := strings.Split(content, "\n")
1115 if len(lines) == 0 || strings.TrimSpace(lines[0]) != "---" {
1116 return false
1117 }
1118 end := -1
1119 for i := 1; i < len(lines); i++ {
1120 if strings.TrimSpace(lines[i]) == "---" {
1121 end = i
1122 break
1123 }
1124 }
1125 if end < 0 {
1126 return false
1127 }
1128 for _, line := range lines[1:end] {
1129 key, _, ok := strings.Cut(line, ":")
1130 if ok && isSkillMarkerFrontmatterKey(strings.ToLower(strings.TrimSpace(key))) {
1131 return true
1132 }
1133 }
1134 return false
1135 }
1136
1137 func isSkillMarkerFrontmatterKey(key string) bool {
1138 return slices.Contains(skillMarkerFrontmatterKeys, key)
1139 }
1140
1141 // Create scaffolds a new skill stub at the chosen scope. Refuses to overwrite.
1142 func loadBodyWithReferences(skillPath, body string) string {
1143 if filepath.Base(skillPath) != SkillFile {
1144 return body
1145 }
1146 refsDir := filepath.Join(filepath.Dir(skillPath), "references")
1147 entries, err := os.ReadDir(refsDir)
1148 if err != nil {
1149 return body
1150 }
1151 var names []string
1152 for _, e := range entries {
1153 if !e.IsDir() && strings.EqualFold(filepath.Ext(e.Name()), ".md") {
1154 names = append(names, e.Name())
1155 }
1156 }
1157 if len(names) == 0 {
1158 return body
1159 }
1160 sort.Strings(names)
1161 var b strings.Builder
1162 b.WriteString(body)
1163 for _, n := range names {
1164 content, err := fileencoding.ReadFileUTF8(filepath.Join(refsDir, n))
1165 if err != nil {
1166 continue
1167 }
1168 trimmed := strings.TrimSpace(string(content))
1169 if trimmed == "" {
1170 continue
1171 }
1172 slug := strings.TrimSuffix(n, filepath.Ext(n))
1173 b.WriteString("\n\n## Reference: " + slug + "\n\n" + trimmed)
1174 }
1175 return b.String()
1176 }
1177
1178 // loadBodyWithScripts appends a directory-layout skill's sibling scripts/
1179 // directory listing to the body, so the model knows what scripts are
1180 // available and can run them via bash (inheriting sandbox, gate, hooks).
1181 func loadBodyWithScripts(skillPath, body string) string {
1182 if filepath.Base(skillPath) != SkillFile {
1183 return body
1184 }
1185 scriptsDir := filepath.Join(filepath.Dir(skillPath), "scripts")
1186 entries, err := os.ReadDir(scriptsDir)
1187 if err != nil {
1188 return body
1189 }
1190 var names []string
1191 for _, e := range entries {
1192 // Filter hidden files — bash should not see config dotfiles in scripts/.
1193 if e.IsDir() || strings.HasPrefix(e.Name(), ".") {
1194 continue
1195 }
1196 if !isScriptExt(filepath.Ext(e.Name())) {
1197 continue
1198 }
1199 names = append(names, e.Name())
1200 }
1201 if len(names) == 0 {
1202 return body
1203 }
1204 sort.Strings(names)
1205 var b strings.Builder
1206 b.WriteString(body)
1207 b.WriteString("\n\n## Scripts\n\nRun a listed script with bash using the exact path shown below; quote the path if it contains spaces.\n\n")
1208 for _, n := range names {
1209 b.WriteString("- `" + filepath.Join(scriptsDir, n) + "`\n")
1210 }
1211 return b.String()
1212 }
1213
1214 func isScriptExt(ext string) bool {
1215 switch strings.ToLower(ext) {
1216 case "", ".sh", ".py", ".js", ".ts", ".rb", ".pl", ".php", ".ps1":
1217 return true
1218 default:
1219 return false
1220 }
1221 }
1222
1223 // parseAllowedTools splits a comma-separated `allowed-tools` value into trimmed,
1224 // non-empty tool names; nil when absent.
1225 func parseAllowedTools(raw string) []string {
1226 return parseCSVFrontmatter(raw)
1227 }
1228
1229 // parseCSVFrontmatter splits simple comma-separated frontmatter values. Full
1230 // YAML lists are intentionally out of scope for the existing frontmatter parser.
1231 func parseCSVFrontmatter(raw string) []string {
1232 raw = strings.TrimSpace(raw)
1233 if raw == "" {
1234 return nil
1235 }
1236 if strings.HasPrefix(raw, "[") && strings.HasSuffix(raw, "]") {
1237 raw = strings.TrimSpace(raw[1 : len(raw)-1])
1238 }
1239 var out []string
1240 for p := range strings.SplitSeq(raw, ",") {
1241 if t := strings.Trim(strings.TrimSpace(p), `"'`); t != "" {
1242 out = append(out, t)
1243 }
1244 }
1245 return out
1246 }
1247
1248 func parseAutoUse(raw string) string {
1249 switch strings.ToLower(strings.TrimSpace(raw)) {
1250 case "off", "suggest", "prefer", "require":
1251 return strings.ToLower(strings.TrimSpace(raw))
1252 default:
1253 return ""
1254 }
1255 }
1256
1257 // parseProfilesFrontmatter keeps only economy|balanced|delivery values and
1258 // returns the rejected ones separately so doctor can surface typos instead of
1259 // the parser hiding them.
1260 func parseProfilesFrontmatter(raw string) (valid, invalid []string) {
1261 seen := map[string]bool{}
1262 for _, p := range parseCSVFrontmatter(raw) {
1263 p = strings.ToLower(strings.TrimSpace(p))
1264 switch p {
1265 case "economy", "balanced", "delivery":
1266 if !seen[p] {
1267 seen[p] = true
1268 valid = append(valid, p)
1269 }
1270 case "":
1271 default:
1272 if !seen[p] {
1273 seen[p] = true
1274 invalid = append(invalid, p)
1275 }
1276 }
1277 }
1278 return valid, invalid
1279 }
1280
1281 func parseBoolFrontmatter(raw string) bool {
1282 switch strings.ToLower(strings.TrimSpace(raw)) {
1283 case "true", "yes", "1", "on":
1284 return true
1285 default:
1286 return false
1287 }
1288 }
1289
1290 func parseCost(raw string) string {
1291 switch strings.ToLower(strings.TrimSpace(raw)) {
1292 case "low", "medium", "high":
1293 return strings.ToLower(strings.TrimSpace(raw))
1294 default:
1295 return ""
1296 }
1297 }
1298
1299 // parseInvocation maps frontmatter to an invocation mode. Anything other than
1300 // "manual" (including absent) is "auto" — the existing, universal behavior.
1301 func parseInvocation(raw string) string {
1302 if strings.EqualFold(strings.TrimSpace(raw), "manual") {
1303 return "manual"
1304 }
1305 return "auto"
1306 }
1307
1308 // parseRunAs maps frontmatter to a run mode. An unknown value defaults to the
1309 // safe (non-spawning) inline mode; a `context: fork` or a non-empty `agent:`
1310 // field (cross-tool conventions) signals subagent isolation.
1311 func parseRunAs(runAs, context, agent string) RunAs {
1312 if strings.TrimSpace(runAs) == "subagent" {
1313 return RunSubagent
1314 }
1315 if strings.EqualFold(strings.TrimSpace(context), "fork") {
1316 return RunSubagent
1317 }
1318 if strings.TrimSpace(agent) != "" {
1319 return RunSubagent
1320 }
1321 return RunInline
1322 }
1323
1324 // stubBody is the scaffold written by `/skill new` — minimal frontmatter plus
1325 // guidance the author fills in.
1326 func stubBody(name string) string {
1327 return "---\nname: " + name + "\ndescription: One-liner — what does this skill do?\n---\n\n# " + name + `
1328
1329 Replace this body with the playbook the model should follow when this skill is invoked.
1330
1331 Tips:
1332 - Reference tools by name (bash, edit_file, grep, read_file, ...)
1333 - Add ` + "`runAs: subagent`" + ` to frontmatter to spawn an isolated subagent loop
1334 - Add ` + "`allowed-tools: read_file, grep`" + ` to scope a subagent's tools
1335 `
1336 }
1337
1338 // resolveCustomPaths expands "~" and makes each custom path absolute relative to
1339 // baseDir.
1340 func resolveCustomPaths(paths []string, baseDir, homeDir string) []string {
1341 out := make([]string, 0, len(paths))
1342 for _, p := range paths {
1343 trimmed := strings.TrimSpace(p)
1344 if trimmed == "" {
1345 continue
1346 }
1347 switch {
1348 case trimmed == "~":
1349 trimmed = homeDir
1350 case strings.HasPrefix(trimmed, "~/") || strings.HasPrefix(trimmed, `~\`):
1351 trimmed = filepath.Join(homeDir, trimmed[2:])
1352 }
1353 if !filepath.IsAbs(trimmed) {
1354 trimmed = filepath.Join(baseDir, trimmed)
1355 }
1356 out = append(out, filepath.Clean(trimmed))
1357 }
1358 return out
1359 }
1360
1361 // dedupePaths drops duplicate custom roots, preserving order.
1362 func dedupePaths(paths []string) []string {
1363 seen := map[string]bool{}
1364 out := paths[:0]
1365 for _, p := range paths {
1366 if seen[p] {
1367 continue
1368 }
1369 seen[p] = true
1370 out = append(out, p)
1371 }
1372 return out
1373 }
1374
1375 // splitFrontmatter is a thin wrapper kept for internal use; the real parser
1376 // lives in internal/frontmatter.
1377 func splitFrontmatter(s string) (map[string]string, string) {
1378 return frontmatter.Split(s)
1379 }
1380
1380 lines GO