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