返回 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 and plugin-package
11 // roots are loaded only when they carry skill frontmatter. Discovery follows
12 // symlinks, so linked 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 func bindAllowedTools(refs []string, bindings []tool.MCPBinding) []string {
341 if len(refs) == 0 {
342 return refs
343 }
344 out := make([]string, 0, len(refs))
345 seen := map[string]bool{}
346 appendOne := func(name string) {
347 if name != "" && !seen[name] {
348 seen[name] = true
349 out = append(out, name)
350 }
351 }
352 for _, ref := range refs {
353 matches := map[string]tool.MCPBinding{}
354 isPattern := strings.ContainsAny(ref, "*?[")
355 for _, binding := range bindings {
356 aliases := append(tool.MCPBindingAliases(binding), binding.CallableName)
357 for _, alias := range aliases {
358 matched := ref == alias
359 if isPattern {
360 matched, _ = path.Match(ref, alias)
361 }
362 if matched {
363 matches[binding.CallableName] = binding
364 break
365 }
366 }
367 }
368 if isPattern {
369 // Preserve the original pattern so an existing broad allowlist such as
370 // "*" keeps all of its prior tools. Add only canonical MCP names the
371 // upstream/Claude pattern itself cannot match in Reasonix.
372 appendOne(ref)
373 names := make([]string, 0, len(matches))
374 for name := range matches {
375 names = append(names, name)
376 }
377 sort.Strings(names)
378 for _, name := range names {
379 if matched, err := path.Match(ref, name); err != nil || !matched {
380 appendOne(name)
381 }
382 // Capability IDs are host-only allowlist entries consumed when the
383 // session exposes this MCP tool solely through use_capability. Do not
384 // add one when the authored pattern already grants the proxy itself.
385 proxyMatched, _ := path.Match(ref, "use_capability")
386 if !proxyMatched {
387 appendOne(matches[name].CapabilityID)
388 }
389 }
390 continue
391 }
392 if len(matches) == 1 {
393 for name, binding := range matches {
394 appendOne(name)
395 appendOne(binding.CapabilityID)
396 }
397 continue
398 }
399 // Preserve unresolved or ambiguous literals. The child registry will not
400 // gain any broader permission from them.
401 appendOne(ref)
402 }
403 return out
404 }
405
406 // ValidateInvocation enforces profiles/requires frontmatter at the host tool
407 // boundary, including direct run_skill calls that bypass capability routing.
408 // Skill profiles frontmatter is diagnostic-only: it never blocks invocation.
409 // Required capabilities still gate execution.
410 func (s *Store) ValidateInvocation(sk Skill) error {
411 if s == nil {
412 return nil
413 }
414 if len(sk.Requires) > 0 && s.requiresReady != nil {
415 if missing := s.requiresReady(sk.Requires); len(missing) > 0 {
416 return fmt.Errorf("%w: skill %q requires unavailable capabilities: %s", ErrInvocationUnavailable, sk.Name, strings.Join(missing, ", "))
417 }
418 }
419 return nil
420 }
421
422 // AllowedInProfile reports whether a skill lists profile among its frontmatter
423 // profiles. Empty profiles mean "all". Role settings no longer filter the
424 // model-visible skill index or block run_skill; this helper remains for doctor
425 // diagnostics and capability inventory reports.
426 func AllowedInProfile(sk Skill, profile string) bool {
427 if len(sk.Profiles) == 0 {
428 return true
429 }
430 want := normalizeRuntimeProfile(profile)
431 if want == "" {
432 return true
433 }
434 for _, candidate := range sk.Profiles {
435 if normalizeRuntimeProfile(candidate) == want {
436 return true
437 }
438 }
439 return false
440 }
441
442 // FilterForProfile returns skills that declare eligibility for profile.
443 // Host boot no longer uses this to hide skills from the model; doctor and
444 // inventory tooling may still call it for recommended-profile diagnostics.
445 func FilterForProfile(skills []Skill, profile string) []Skill {
446 out := make([]Skill, 0, len(skills))
447 for _, sk := range skills {
448 if AllowedInProfile(sk, profile) {
449 out = append(out, sk)
450 }
451 }
452 return out
453 }
454
455 func normalizeRuntimeProfile(profile string) string {
456 switch strings.ToLower(strings.TrimSpace(profile)) {
457 case "economy":
458 return "economy"
459 case "delivery":
460 return "delivery"
461 case "balanced", "full":
462 return "balanced"
463 default:
464 return ""
465 }
466 }
467
468 // HasProjectScope reports whether the store was configured with a project root.
469 func (s *Store) HasProjectScope() bool { return s.projectRoot != "" }
470
471 // PathStatus describes a root directory's readability, surfaced by `/skill paths`.
472 type PathStatus string
473
474 const (
475 StatusOK PathStatus = "ok"
476 StatusMissing PathStatus = "missing"
477 StatusNotDirectory PathStatus = "not-directory"
478 StatusUnreadable PathStatus = "unreadable"
479 )
480
481 // Root is one discovery directory with its scope, priority, and status.
482 type Root struct {
483 Dir string
484 Scope Scope
485 Priority int
486 Status PathStatus
487 }
488
489 type discoveryRoot struct {
490 Root
491 requireFlatMarker bool
492 plugins []string
493 forceSubagent bool
494 }
495
496 // roots returns the discovery directories, highest priority first: the
497 // convention dirs (config.ConventionDirs: .reasonix / .agents / .agent / .claude)
498 // under the project root → custom paths → the Reasonix home skills dir → other
499 // home-dir convention dirs. A later root never overrides an earlier one.
500 func (s *Store) roots() []discoveryRoot {
501 if s == nil || s.disableDiscovery {
502 return nil
503 }
504 type de struct {
505 dir string
506 scope Scope
507 requireFlatMarker bool
508 }
509 var dirs []de
510 if s.projectRoot != "" {
511 for _, c := range config.ConventionDirs {
512 dirs = append(dirs, de{filepath.Join(s.projectRoot, c, SkillsDirname), ScopeProject, c == ".claude"})
513 }
514 }
515 for _, d := range s.customPaths {
516 dirs = append(dirs, de{d, ScopeCustom, false})
517 }
518 if s.reasonixHomeDir != "" {
519 dirs = append(dirs, de{filepath.Join(s.reasonixHomeDir, SkillsDirname), ScopeGlobal, false})
520 }
521 if config.IsolatedHomeDir() == "" {
522 for _, c := range config.ConventionDirs {
523 dir := filepath.Join(s.homeDir, c, SkillsDirname)
524 if s.reasonixHomeDir != "" && config.CanonicalSkillPath(filepath.Dir(dir)) == config.CanonicalSkillPath(s.reasonixHomeDir) {
525 continue
526 }
527 dirs = append(dirs, de{dir, ScopeGlobal, c == ".claude"})
528 }
529 }
530 out := make([]discoveryRoot, 0, len(dirs))
531 for _, d := range dirs {
532 if s.excludedPaths[config.CanonicalSkillPath(d.dir)] {
533 continue
534 }
535 key := config.CanonicalSkillPath(d.dir)
536 out = append(out, discoveryRoot{
537 Root: Root{Dir: d.dir, Scope: d.scope, Priority: len(out), Status: pathStatus(d.dir)},
538 requireFlatMarker: d.requireFlatMarker || len(s.pluginPaths[key]) > 0 || len(s.pluginAgentPaths[key]) > 0,
539 plugins: append([]string(nil), s.pluginPaths[key]...),
540 forceSubagent: len(s.pluginAgentPaths[key]) > 0,
541 })
542 }
543 return out
544 }
545
546 func normalizePluginPaths(paths map[string][]string) map[string][]string {
547 out := map[string][]string{}
548 for path, plugins := range paths {
549 key := config.CanonicalSkillPath(path)
550 if key == "" {
551 continue
552 }
553 for _, plugin := range plugins {
554 plugin = strings.TrimSpace(plugin)
555 if plugin == "" || stringSliceContains(out[key], plugin) {
556 continue
557 }
558 out[key] = append(out[key], plugin)
559 }
560 sort.Strings(out[key])
561 }
562 return out
563 }
564
565 func stringSliceContains(items []string, want string) bool {
566 return slices.Contains(items, want)
567 }
568
569 // Roots exposes the discovery directories with their status for `/skill paths`.
570 func (s *Store) Roots() []Root {
571 roots := s.roots()
572 out := make([]Root, 0, len(roots))
573 for _, r := range roots {
574 out = append(out, r.Root)
575 }
576 return out
577 }
578
579 func disabledNameSet(names []string) map[string]bool {
580 out := map[string]bool{}
581 for _, name := range names {
582 if key := config.SkillNameKey(name); key != "" {
583 out[key] = true
584 }
585 }
586 return out
587 }
588
589 func (s *Store) disabledName(name string) bool {
590 return s.disabled[config.SkillNameKey(name)]
591 }
592
593 func normalizeMaxDepth(depth int) int {
594 const (
595 defaultDepth = 3
596 maxDepth = 5
597 )
598 if depth == 0 {
599 return defaultDepth
600 }
601 if depth < 1 {
602 return 1
603 }
604 if depth > maxDepth {
605 return maxDepth
606 }
607 return depth
608 }
609
610 // pathStatus classifies a root directory without failing on the common case of
611 // "not created yet".
612 func pathStatus(dir string) PathStatus {
613 info, err := os.Stat(dir)
614 if err != nil {
615 if os.IsNotExist(err) {
616 return StatusMissing
617 }
618 return StatusUnreadable
619 }
620 if !info.IsDir() {
621 return StatusNotDirectory
622 }
623 if f, err := os.Open(dir); err != nil {
624 return StatusUnreadable
625 } else {
626 _ = f.Close()
627 }
628 return StatusOK
629 }
630
631 func (s *Store) List() []Skill {
632 return s.enabledSkills()
633 }
634
635 // Candidate resolves metadata from the immutable catalog without reading the
636 // selected SKILL.md body or its references/scripts.
637 func (s *Store) Candidate(name string) (Skill, bool) {
638 if !IsValidName(name) || s == nil || s.disabledName(name) {
639 return Skill{}, false
640 }
641 snapshot := s.catalogSnapshot()
642 if snapshot == nil {
643 return Skill{}, false
644 }
645 candidate, ok := snapshot.byName[name]
646 return cloneSkill(candidate), ok
647 }
648
649 // SlashList returns the visible user-facing skill directory. Plugin skills are
650 // retained per package under /<plugin>:<name>, even when their bare names
651 // collide; non-plugin skills keep their existing short names.
652 func (s *Store) SlashList() []Skill {
653 s.invalidateChangedRoots()
654 snapshot := s.catalogSnapshot()
655 if snapshot == nil {
656 return nil
657 }
658 return cloneSkills(snapshot.slash)
659 }
660
661 // VisibleSlashSkills deduplicates skills by their user-facing slash name and
662 // returns them in deterministic display order.
663 func VisibleSlashSkills(skills []Skill) []Skill {
664 byName := map[string]Skill{}
665 for _, sk := range skills {
666 name := sk.SlashName()
667 if name == "" {
668 continue
669 }
670 if _, dup := byName[name]; !dup {
671 byName[name] = sk
672 }
673 }
674 out := make([]Skill, 0, len(byName))
675 for _, sk := range byName {
676 out = append(out, sk)
677 }
678 sort.Slice(out, func(i, j int) bool { return out[i].SlashName() < out[j].SlashName() })
679 return out
680 }
681
682 // ResolveSlashSkill resolves a visible qualified plugin name or a compatible
683 // short name. A short plugin name is rejected when multiple plugin packages
684 // contribute it; a higher-priority non-plugin winner keeps its short name.
685 func ResolveSlashSkill(skills []Skill, name string) (Skill, bool) {
686 name = strings.TrimPrefix(strings.TrimSpace(name), "/")
687 if name == "" {
688 return Skill{}, false
689 }
690 for _, sk := range skills {
691 if sk.SlashName() == name {
692 return sk, true
693 }
694 }
695 if strings.Contains(name, ":") || !IsValidName(name) {
696 return Skill{}, false
697 }
698 var winner Skill
699 var found bool
700 plugins := map[string]bool{}
701 for _, sk := range skills {
702 if sk.Name != name {
703 continue
704 }
705 if !found {
706 winner, found = sk, true
707 }
708 if sk.Plugin != "" {
709 plugins[sk.Plugin] = true
710 }
711 }
712 if !found || winner.Plugin != "" && len(plugins) > 1 {
713 return Skill{}, false
714 }
715 return winner, true
716 }
717
718 // Read resolves one skill by name from the current catalog index and reads only
719 // that selected body. ok is false when no such skill exists or the file is
720 // unreadable.
721 func (s *Store) Read(name string) (Skill, bool) {
722 return s.Load(context.Background(), name)
723 }
724
725 // Load resolves one selected candidate using a caller-owned cancellation
726 // context. Discovery wait and the one allowed stale-target refresh both stop
727 // when the owning turn is cancelled.
728 func (s *Store) Load(ctx context.Context, name string) (Skill, bool) {
729 if !IsValidName(name) {
730 return Skill{}, false
731 }
732 if s.disabledName(name) {
733 return Skill{}, false
734 }
735 for range 2 {
736 snapshot, err := s.Snapshot(ctx)
737 if err != nil {
738 return Skill{}, false
739 }
740 candidate, internal, versionMatched, ok := s.candidateAtVersion(name, snapshot.Version)
741 if !versionMatched {
742 // Invalidation raced the public snapshot copy. Resolve once more from a
743 // single generation rather than walking a possibly obsolete slice.
744 continue
745 }
746 if !ok {
747 return Skill{}, false
748 }
749 return s.loadCandidateContext(ctx, candidate, internal)
750 }
751 return Skill{}, false
752 }
753
754 // candidateAtVersion resolves an exact identity in O(1) from the same immutable
755 // generation returned to the caller. The internal catalog remains immutable
756 // after publication, so it is safe to retain its pointer after releasing the
757 // catalog lock.
758 func (s *Store) candidateAtVersion(name string, version uint64) (Skill, *catalogSnapshot, bool, bool) {
759 s.catalogMu.Lock()
760 defer s.catalogMu.Unlock()
761 snapshot := s.catalog
762 if snapshot == nil || snapshot.version != version {
763 return Skill{}, nil, false, false
764 }
765 candidate, ok := snapshot.byName[name]
766 return cloneSkill(candidate), snapshot, true, ok
767 }
768
769 // ReadSlash resolves a user-entered slash identifier without changing the
770 // bare identifiers accepted by Read/run_skill.
771 func (s *Store) ReadSlash(name string) (Skill, bool) {
772 candidate, ok := ResolveSlashSkill(s.discoveredSkills(), name)
773 if !ok {
774 return Skill{}, false
775 }
776 snapshot := s.catalogSnapshot()
777 return s.loadCandidate(candidate, snapshot)
778 }
779
780 func (s *Store) loadCandidate(candidate Skill, snapshot *catalogSnapshot) (Skill, bool) {
781 return s.loadCandidateContext(context.Background(), candidate, snapshot)
782 }
783
784 func (s *Store) loadCandidateContext(ctx context.Context, candidate Skill, snapshot *catalogSnapshot) (Skill, bool) {
785 if ctx == nil {
786 ctx = context.Background()
787 }
788 if ctx.Err() != nil {
789 return Skill{}, false
790 }
791 if strings.HasPrefix(candidate.Path, "(builtin") {
792 if snapshot != nil {
793 if builtin, ok := snapshot.builtins[candidate.Name]; ok {
794 return cloneSkill(builtin), true
795 }
796 }
797 return Skill{}, false
798 }
799 loaded, ok := s.parseSkill(candidate.Path, candidate.Name, candidate.Scope, false, true)
800 if ctx.Err() != nil {
801 return Skill{}, false
802 }
803 if ok && loaded.Name == candidate.Name && loaded.Path == candidate.Path {
804 // The catalog candidate deliberately carries metadata only. Use the
805 // freshly parsed selected file as the source of truth so an edit cannot
806 // return a stale description/model/tool policy merely because discovery
807 // was already warm. Source attribution is assigned by discovery rather
808 // than frontmatter and therefore remains attached to the candidate.
809 loaded.Scope = candidate.Scope
810 loaded.Plugin = candidate.Plugin
811 loaded.SlashPrefix = candidate.SlashPrefix
812 if candidate.RunAs == RunSubagent && candidate.SlashPrefix != "" {
813 loaded.RunAs = RunSubagent
814 loaded.Invocation = "manual"
815 loaded.AllowedTools = mapClaudeAgentTools(loaded.AllowedTools)
816 if isClaudeModelAlias(loaded.Model) {
817 loaded.Model = ""
818 }
819 }
820 return loaded, true
821 }
822 // The selected identity changed after the snapshot. Refresh once and resolve
823 // the name again instead of executing the stale target.
824 s.Invalidate("selected skill changed")
825 refreshed, err := s.Snapshot(ctx)
826 if err != nil {
827 return Skill{}, false
828 }
829 next, internal, versionMatched, found := s.candidateAtVersion(candidate.Name, refreshed.Version)
830 if versionMatched && found && next.Path != candidate.Path {
831 return s.loadCandidateContext(ctx, next, internal)
832 }
833 return Skill{}, false
834 }
835
836 func (s *Store) discoverRoot(ctx context.Context, r discoveryRoot) []Skill {
837 var out []Skill
838 s.scanDir(ctx, r.Dir, r.Scope, r.requireFlatMarker, 1, map[string]bool{}, &out)
839 if r.forceSubagent {
840 for i := range out {
841 out[i].RunAs = RunSubagent
842 out[i].Invocation = "manual"
843 out[i].AllowedTools = mapClaudeAgentTools(out[i].AllowedTools)
844 if isClaudeModelAlias(out[i].Model) {
845 out[i].Model = ""
846 }
847 }
848 }
849 return out
850 }
851
852 func (s *Store) scanDir(ctx context.Context, dir string, scope Scope, requireFlatMarker bool, depth int, seen map[string]bool, out *[]Skill) {
853 if ctx.Err() != nil {
854 return
855 }
856 key := filepath.Clean(dir)
857 if resolved, err := filepath.EvalSymlinks(dir); err == nil {
858 key = filepath.Clean(resolved)
859 }
860 if seen[key] {
861 return
862 }
863 seen[key] = true
864
865 entries, err := os.ReadDir(dir)
866 if err != nil {
867 return
868 }
869 for _, e := range entries {
870 if ctx.Err() != nil {
871 return
872 }
873 sk, ok := s.readEntry(dir, scope, requireFlatMarker, e)
874 if ok {
875 if depth == 1 || strings.TrimSpace(sk.Description) != "" {
876 *out = append(*out, sk)
877 }
878 continue
879 }
880 if depth >= s.maxDepth || !s.canScanChildDir(dir, e) {
881 continue
882 }
883 s.scanDir(ctx, filepath.Join(dir, e.Name()), scope, requireFlatMarker, depth+1, seen, out)
884 }
885 }
886
887 func (s *Store) canScanChildDir(dir string, e os.DirEntry) bool {
888 name := e.Name()
889 if shouldSkipScanDir(name) {
890 return false
891 }
892 if e.IsDir() {
893 return true
894 }
895 if !shouldStatEntryTarget(e.Type()) {
896 return false
897 }
898 info, err := os.Stat(filepath.Join(dir, name))
899 return err == nil && info.IsDir()
900 }
901
902 func shouldStatEntryTarget(mode os.FileMode) bool {
903 return mode&os.ModeSymlink != 0 || mode&os.ModeIrregular != 0
904 }
905
906 func shouldSkipScanDir(name string) bool {
907 if strings.HasPrefix(name, ".") {
908 return true
909 }
910 switch strings.ToLower(name) {
911 case "assets", "node_modules", "references", "scripts":
912 return true
913 default:
914 return false
915 }
916 }
917
918 // readEntry turns one directory entry into a skill. It resolves symlink and
919 // Windows reparse-style entries via os.Stat (os.ReadDir can report the link's
920 // own type, not its target's), so a linked skill directory or flat <name>.md is
921 // discovered like a real one; a broken link fails Stat and is skipped.
922 func (s *Store) readEntry(dir string, scope Scope, requireFlatMarker bool, e os.DirEntry) (Skill, bool) {
923 name := e.Name()
924 full := filepath.Join(dir, name)
925
926 isDir := e.IsDir()
927 isFile := e.Type().IsRegular()
928 if !isDir && !isFile && shouldStatEntryTarget(e.Type()) {
929 info, err := os.Stat(full) // follows the link
930 if err != nil {
931 return Skill{}, false // broken link
932 }
933 isDir = info.IsDir()
934 isFile = info.Mode().IsRegular()
935 }
936
937 if isDir {
938 if !IsValidName(name) {
939 return Skill{}, false
940 }
941 file := filepath.Join(full, SkillFile)
942 if _, err := os.Stat(file); err != nil {
943 return Skill{}, false // a directory without a SKILL.md is not a skill
944 }
945 return s.parse(file, name, scope)
946 }
947 if isFile && strings.EqualFold(filepath.Ext(name), ".md") {
948 stem := strings.TrimSuffix(name, filepath.Ext(name))
949 if !IsValidName(stem) {
950 return Skill{}, false
951 }
952 return s.parseFlat(full, stem, scope, requireFlatMarker)
953 }
954 return Skill{}, false
955 }
956
957 // parse reads and decodes one skill file. The frontmatter `name:` overrides the
958 // filename stem when valid; a missing `description:` is a warning, not a failure
959 // (the skill loads but won't appear in the model's index).
960 func (s *Store) parse(path, stem string, scope Scope) (Skill, bool) {
961 return s.parseSkill(path, stem, scope, false, false)
962 }
963
964 // parseFlat reads a flat <name>.md skill candidate. Claude skill roots can also
965 // contain ordinary documentation, so those flat files need explicit skill
966 // frontmatter before they are treated as skills.
967 func (s *Store) parseFlat(path, stem string, scope Scope, requireSkillMarker bool) (Skill, bool) {
968 return s.parseSkill(path, stem, scope, requireSkillMarker, false)
969 }
970
971 func (s *Store) parseSkill(path, stem string, scope Scope, requireSkillMarker, loadBody bool) (Skill, bool) {
972 b, err := fileencoding.ReadFileUTF8(path)
973 if err != nil {
974 return Skill{}, false
975 }
976 content := strings.TrimPrefix(strings.ReplaceAll(string(b), "\r\n", "\n"), "\uFEFF")
977 fm, body := splitFrontmatter(content)
978 if requireSkillMarker && !hasSkillMarker(content, fm) {
979 return Skill{}, false
980 }
981
982 name := stem
983 if v := fm[skillFrontmatterName]; v != "" && IsValidName(v) {
984 name = v
985 }
986 desc := strings.TrimSpace(fm[skillFrontmatterDescription])
987 if desc == "" && !loadBody {
988 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)
989 }
990 bodyText := ""
991 if loadBody {
992 bodyText = loadBodyWithScripts(path, loadBodyWithReferences(path, strings.TrimSpace(body)))
993 }
994 sk := Skill{
995 Name: name,
996 Description: desc,
997 Body: bodyText,
998 Scope: scope,
999 Path: path,
1000 AllowedTools: parseAllowedTools(firstNonEmptySkillValue(fm[skillFrontmatterAllowedTools], fm["tools"])),
1001 RunAs: parseRunAs(fm[skillFrontmatterRunAs], fm[skillFrontmatterContext], fm[skillFrontmatterAgent]),
1002 Model: strings.TrimSpace(fm[skillFrontmatterModel]),
1003 Effort: strings.TrimSpace(fm[skillFrontmatterEffort]),
1004 ReadOnly: parseBoolFrontmatter(fm[skillFrontmatterReadOnly]),
1005 Triggers: parseCSVFrontmatter(fm[skillFrontmatterTriggers]),
1006 NegativeTriggers: parseCSVFrontmatter(
1007 fm[skillFrontmatterNegativeTriggers],
1008 ),
1009 AutoUse: parseAutoUse(fm[skillFrontmatterAutoUse]),
1010 NeedsFreshData: parseBoolFrontmatter(fm[skillFrontmatterNeedsFreshData]),
1011 Cost: parseCost(fm[skillFrontmatterCost]),
1012 Color: strings.TrimSpace(fm[skillFrontmatterColor]),
1013 Invocation: parseInvocation(fm[skillFrontmatterInvocation]),
1014 Requires: parseCSVFrontmatter(fm[skillFrontmatterRequires]),
1015 }
1016 sk.Profiles, sk.InvalidProfiles = parseProfilesFrontmatter(fm[skillFrontmatterProfiles])
1017 return sk, true
1018 }
1019
1020 func firstNonEmptySkillValue(values ...string) string {
1021 for _, value := range values {
1022 if strings.TrimSpace(value) != "" {
1023 return value
1024 }
1025 }
1026 return ""
1027 }
1028
1029 func isClaudeModelAlias(model string) bool {
1030 switch strings.ToLower(strings.TrimSpace(model)) {
1031 case "sonnet", "opus", "haiku", "inherit":
1032 return true
1033 default:
1034 return false
1035 }
1036 }
1037
1038 func mapClaudeAgentTools(in []string) []string {
1039 mapping := map[string]string{
1040 "read": "read_file", "write": "write_file", "edit": "edit_file",
1041 "bash": "bash", "grep": "grep", "glob": "glob", "ls": "ls",
1042 "webfetch": "web_fetch", "websearch": "web_search",
1043 }
1044 out := make([]string, 0, len(in))
1045 seen := map[string]bool{}
1046 for _, name := range in {
1047 mapped := strings.TrimSpace(name)
1048 if replacement := mapping[strings.ToLower(mapped)]; replacement != "" {
1049 mapped = replacement
1050 }
1051 if mapped != "" && !seen[mapped] {
1052 seen[mapped] = true
1053 out = append(out, mapped)
1054 }
1055 }
1056 return out
1057 }
1058
1059 const (
1060 skillFrontmatterDescription = "description"
1061 skillFrontmatterName = "name"
1062 skillFrontmatterRunAs = "runas"
1063 skillFrontmatterContext = "context"
1064 skillFrontmatterAgent = "agent"
1065 skillFrontmatterAllowedTools = "allowed-tools"
1066 skillFrontmatterModel = "model"
1067 skillFrontmatterEffort = "effort"
1068 skillFrontmatterReadOnly = "read-only"
1069 skillFrontmatterTriggers = "triggers"
1070 skillFrontmatterNegativeTriggers = "negative-triggers"
1071 skillFrontmatterAutoUse = "auto-use"
1072 skillFrontmatterNeedsFreshData = "needs-fresh-data"
1073 skillFrontmatterCost = "cost"
1074 skillFrontmatterColor = "color"
1075 skillFrontmatterInvocation = "invocation"
1076 skillFrontmatterRequires = "requires"
1077 skillFrontmatterProfiles = "profiles"
1078 )
1079
1080 var skillMarkerFrontmatterKeys = []string{
1081 skillFrontmatterDescription,
1082 skillFrontmatterName,
1083 skillFrontmatterRunAs,
1084 skillFrontmatterContext,
1085 skillFrontmatterAgent,
1086 skillFrontmatterAllowedTools,
1087 skillFrontmatterModel,
1088 skillFrontmatterEffort,
1089 skillFrontmatterReadOnly,
1090 skillFrontmatterTriggers,
1091 skillFrontmatterNegativeTriggers,
1092 skillFrontmatterAutoUse,
1093 skillFrontmatterNeedsFreshData,
1094 skillFrontmatterCost,
1095 skillFrontmatterColor,
1096 skillFrontmatterInvocation,
1097 skillFrontmatterRequires,
1098 skillFrontmatterProfiles,
1099 }
1100
1101 func hasSkillMarker(content string, fm map[string]string) bool {
1102 for _, key := range skillMarkerFrontmatterKeys {
1103 if strings.TrimSpace(fm[key]) != "" {
1104 return true
1105 }
1106 }
1107 return frontmatterHasSkillMarkerKey(content)
1108 }
1109
1110 func frontmatterHasSkillMarkerKey(content string) bool {
1111 lines := strings.Split(content, "\n")
1112 if len(lines) == 0 || strings.TrimSpace(lines[0]) != "---" {
1113 return false
1114 }
1115 end := -1
1116 for i := 1; i < len(lines); i++ {
1117 if strings.TrimSpace(lines[i]) == "---" {
1118 end = i
1119 break
1120 }
1121 }
1122 if end < 0 {
1123 return false
1124 }
1125 for _, line := range lines[1:end] {
1126 key, _, ok := strings.Cut(line, ":")
1127 if ok && isSkillMarkerFrontmatterKey(strings.ToLower(strings.TrimSpace(key))) {
1128 return true
1129 }
1130 }
1131 return false
1132 }
1133
1134 func isSkillMarkerFrontmatterKey(key string) bool {
1135 return slices.Contains(skillMarkerFrontmatterKeys, key)
1136 }
1137
1138 // Create scaffolds a new skill stub at the chosen scope. Refuses to overwrite.
1139 func loadBodyWithReferences(skillPath, body string) string {
1140 if filepath.Base(skillPath) != SkillFile {
1141 return body
1142 }
1143 refsDir := filepath.Join(filepath.Dir(skillPath), "references")
1144 entries, err := os.ReadDir(refsDir)
1145 if err != nil {
1146 return body
1147 }
1148 var names []string
1149 for _, e := range entries {
1150 if !e.IsDir() && strings.EqualFold(filepath.Ext(e.Name()), ".md") {
1151 names = append(names, e.Name())
1152 }
1153 }
1154 if len(names) == 0 {
1155 return body
1156 }
1157 sort.Strings(names)
1158 var b strings.Builder
1159 b.WriteString(body)
1160 for _, n := range names {
1161 content, err := fileencoding.ReadFileUTF8(filepath.Join(refsDir, n))
1162 if err != nil {
1163 continue
1164 }
1165 trimmed := strings.TrimSpace(string(content))
1166 if trimmed == "" {
1167 continue
1168 }
1169 slug := strings.TrimSuffix(n, filepath.Ext(n))
1170 b.WriteString("\n\n## Reference: " + slug + "\n\n" + trimmed)
1171 }
1172 return b.String()
1173 }
1174
1175 // loadBodyWithScripts appends a directory-layout skill's sibling scripts/
1176 // directory listing to the body, so the model knows what scripts are
1177 // available and can run them via bash (inheriting sandbox, gate, hooks).
1178 func loadBodyWithScripts(skillPath, body string) string {
1179 if filepath.Base(skillPath) != SkillFile {
1180 return body
1181 }
1182 scriptsDir := filepath.Join(filepath.Dir(skillPath), "scripts")
1183 entries, err := os.ReadDir(scriptsDir)
1184 if err != nil {
1185 return body
1186 }
1187 var names []string
1188 for _, e := range entries {
1189 // Filter hidden files — bash should not see config dotfiles in scripts/.
1190 if e.IsDir() || strings.HasPrefix(e.Name(), ".") {
1191 continue
1192 }
1193 if !isScriptExt(filepath.Ext(e.Name())) {
1194 continue
1195 }
1196 names = append(names, e.Name())
1197 }
1198 if len(names) == 0 {
1199 return body
1200 }
1201 sort.Strings(names)
1202 var b strings.Builder
1203 b.WriteString(body)
1204 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")
1205 for _, n := range names {
1206 b.WriteString("- `" + filepath.Join(scriptsDir, n) + "`\n")
1207 }
1208 return b.String()
1209 }
1210
1211 func isScriptExt(ext string) bool {
1212 switch strings.ToLower(ext) {
1213 case "", ".sh", ".py", ".js", ".ts", ".rb", ".pl", ".php", ".ps1":
1214 return true
1215 default:
1216 return false
1217 }
1218 }
1219
1220 // parseAllowedTools splits a comma-separated `allowed-tools` value into trimmed,
1221 // non-empty tool names; nil when absent.
1222 func parseAllowedTools(raw string) []string {
1223 return parseCSVFrontmatter(raw)
1224 }
1225
1226 // parseCSVFrontmatter splits simple comma-separated frontmatter values. Full
1227 // YAML lists are intentionally out of scope for the existing frontmatter parser.
1228 func parseCSVFrontmatter(raw string) []string {
1229 raw = strings.TrimSpace(raw)
1230 if raw == "" {
1231 return nil
1232 }
1233 if strings.HasPrefix(raw, "[") && strings.HasSuffix(raw, "]") {
1234 raw = strings.TrimSpace(raw[1 : len(raw)-1])
1235 }
1236 var out []string
1237 for p := range strings.SplitSeq(raw, ",") {
1238 if t := strings.Trim(strings.TrimSpace(p), `"'`); t != "" {
1239 out = append(out, t)
1240 }
1241 }
1242 return out
1243 }
1244
1245 func parseAutoUse(raw string) string {
1246 switch strings.ToLower(strings.TrimSpace(raw)) {
1247 case "off", "suggest", "prefer", "require":
1248 return strings.ToLower(strings.TrimSpace(raw))
1249 default:
1250 return ""
1251 }
1252 }
1253
1254 // parseProfilesFrontmatter keeps only economy|balanced|delivery values and
1255 // returns the rejected ones separately so doctor can surface typos instead of
1256 // the parser hiding them.
1257 func parseProfilesFrontmatter(raw string) (valid, invalid []string) {
1258 seen := map[string]bool{}
1259 for _, p := range parseCSVFrontmatter(raw) {
1260 p = strings.ToLower(strings.TrimSpace(p))
1261 switch p {
1262 case "economy", "balanced", "delivery":
1263 if !seen[p] {
1264 seen[p] = true
1265 valid = append(valid, p)
1266 }
1267 case "":
1268 default:
1269 if !seen[p] {
1270 seen[p] = true
1271 invalid = append(invalid, p)
1272 }
1273 }
1274 }
1275 return valid, invalid
1276 }
1277
1278 func parseBoolFrontmatter(raw string) bool {
1279 switch strings.ToLower(strings.TrimSpace(raw)) {
1280 case "true", "yes", "1", "on":
1281 return true
1282 default:
1283 return false
1284 }
1285 }
1286
1287 func parseCost(raw string) string {
1288 switch strings.ToLower(strings.TrimSpace(raw)) {
1289 case "low", "medium", "high":
1290 return strings.ToLower(strings.TrimSpace(raw))
1291 default:
1292 return ""
1293 }
1294 }
1295
1296 // parseInvocation maps frontmatter to an invocation mode. Anything other than
1297 // "manual" (including absent) is "auto" — the existing, universal behavior.
1298 func parseInvocation(raw string) string {
1299 if strings.EqualFold(strings.TrimSpace(raw), "manual") {
1300 return "manual"
1301 }
1302 return "auto"
1303 }
1304
1305 // parseRunAs maps frontmatter to a run mode. An unknown value defaults to the
1306 // safe (non-spawning) inline mode; a `context: fork` or a non-empty `agent:`
1307 // field (cross-tool conventions) signals subagent isolation.
1308 func parseRunAs(runAs, context, agent string) RunAs {
1309 if strings.TrimSpace(runAs) == "subagent" {
1310 return RunSubagent
1311 }
1312 if strings.EqualFold(strings.TrimSpace(context), "fork") {
1313 return RunSubagent
1314 }
1315 if strings.TrimSpace(agent) != "" {
1316 return RunSubagent
1317 }
1318 return RunInline
1319 }
1320
1321 // stubBody is the scaffold written by `/skill new` — minimal frontmatter plus
1322 // guidance the author fills in.
1323 func stubBody(name string) string {
1324 return "---\nname: " + name + "\ndescription: One-liner — what does this skill do?\n---\n\n# " + name + `
1325
1326 Replace this body with the playbook the model should follow when this skill is invoked.
1327
1328 Tips:
1329 - Reference tools by name (bash, edit_file, grep, read_file, ...)
1330 - Add ` + "`runAs: subagent`" + ` to frontmatter to spawn an isolated subagent loop
1331 - Add ` + "`allowed-tools: read_file, grep`" + ` to scope a subagent's tools
1332 `
1333 }
1334
1335 // resolveCustomPaths expands "~" and makes each custom path absolute relative to
1336 // baseDir.
1337 func resolveCustomPaths(paths []string, baseDir, homeDir string) []string {
1338 out := make([]string, 0, len(paths))
1339 for _, p := range paths {
1340 trimmed := strings.TrimSpace(p)
1341 if trimmed == "" {
1342 continue
1343 }
1344 switch {
1345 case trimmed == "~":
1346 trimmed = homeDir
1347 case strings.HasPrefix(trimmed, "~/") || strings.HasPrefix(trimmed, `~\`):
1348 trimmed = filepath.Join(homeDir, trimmed[2:])
1349 }
1350 if !filepath.IsAbs(trimmed) {
1351 trimmed = filepath.Join(baseDir, trimmed)
1352 }
1353 out = append(out, filepath.Clean(trimmed))
1354 }
1355 return out
1356 }
1357
1358 // dedupePaths drops duplicate custom roots, preserving order.
1359 func dedupePaths(paths []string) []string {
1360 seen := map[string]bool{}
1361 out := paths[:0]
1362 for _, p := range paths {
1363 if seen[p] {
1364 continue
1365 }
1366 seen[p] = true
1367 out = append(out, p)
1368 }
1369 return out
1370 }
1371
1372 // splitFrontmatter is a thin wrapper kept for internal use; the real parser
1373 // lives in internal/frontmatter.
1374 func splitFrontmatter(s string) (map[string]string, string) {
1375 return frontmatter.Split(s)
1376 }
1377
1377 lines GO