1229 lines
38 KiB
Go
1229 lines
38 KiB
Go
// Package skill loads invokable playbooks ("skills") from Markdown files. A skill
|
|
// is a named, described prompt body the model can invoke via the run_skill tool
|
|
// (or the user via a slash name): an "inline" skill folds its body into the turn as
|
|
// a tool result, a "subagent" skill runs in an isolated child loop and returns
|
|
// only its final answer. Project scope wins over global; only names+descriptions
|
|
// enter the cache-stable system-prompt index (see index.go) — bodies load on
|
|
// demand. Discovery scans several conventions (.reasonix / .agents / .agent /
|
|
// .claude under the project root and the home dir — see config.ConventionDirs) so
|
|
// skills authored for other agent tools migrate in unchanged. Directory skills
|
|
// use <name>/SKILL.md; flat <name>.md files from Claude roots are loaded only
|
|
// when they carry skill frontmatter. Discovery follows symlinks, so linked
|
|
// skills are picked up like real ones.
|
|
package skill
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
|
|
"reasonix/internal/config"
|
|
"reasonix/internal/fileutil"
|
|
fileencoding "reasonix/internal/fileutil/encoding"
|
|
"reasonix/internal/frontmatter"
|
|
)
|
|
|
|
// ErrInvocationUnavailable marks a profile/dependency gate that can become
|
|
// runnable after switching profile or connecting the required capability.
|
|
var ErrInvocationUnavailable = errors.New("skill invocation unavailable")
|
|
|
|
// Scope records where a skill was loaded from. Higher-priority scopes win on a
|
|
// name collision: project > custom > global > builtin.
|
|
type Scope string
|
|
|
|
const (
|
|
ScopeProject Scope = "project"
|
|
ScopeCustom Scope = "custom"
|
|
ScopeGlobal Scope = "global"
|
|
ScopeBuiltin Scope = "builtin"
|
|
)
|
|
|
|
// RunAs selects how an invoked skill executes. Inline folds the body into the
|
|
// parent turn; subagent spawns an isolated child loop and returns only the final
|
|
// answer (its tool calls and reasoning never enter the parent context).
|
|
type RunAs string
|
|
|
|
const (
|
|
RunInline RunAs = "inline"
|
|
RunSubagent RunAs = "subagent"
|
|
)
|
|
|
|
const (
|
|
// SkillsDirname is the directory under each root that holds skills.
|
|
SkillsDirname = "skills"
|
|
// SkillFile is the canonical filename inside a directory-layout skill.
|
|
SkillFile = "SKILL.md"
|
|
)
|
|
|
|
// Skill is a loaded playbook.
|
|
type Skill struct {
|
|
Name string // canonical identifier; matches the directory / filename stem
|
|
Description string // one-liner shown in the pinned index
|
|
Body string // full markdown body (post-frontmatter), loaded eagerly
|
|
Scope Scope // where it came from
|
|
Path string // absolute path to the SKILL.md / <name>.md, or "(builtin)"
|
|
Plugin string // installed plugin package name; empty for non-plugin skills
|
|
// AllowedTools, when non-empty, scopes a subagent skill's tool registry to
|
|
// these literal tool names (from the `allowed-tools` frontmatter).
|
|
AllowedTools []string
|
|
RunAs RunAs // inline | subagent
|
|
Model string // optional model override for runAs=subagent (frontmatter `model:`)
|
|
Effort string // optional effort for runAs=subagent (frontmatter `effort:`)
|
|
// ReadOnly, when true, runs a subagent skill against the read-only tool
|
|
// registry: writer tools are stripped and bash enforces the plan-mode safe
|
|
// command policy at execution time (frontmatter `read-only:`). This is a
|
|
// tool-boundary contract, not a prompt promise.
|
|
ReadOnly bool
|
|
Color string // optional display tag for UI surfaces (frontmatter `color:`); no runtime effect
|
|
// Invocation gates whether this skill enters the pinned Skills index the
|
|
// model reads every turn. "auto" (default) behaves like every skill always
|
|
// has. "manual" keeps the skill invocable by name (/<name>, run_skill) but
|
|
// invisible to model-initiated discovery — for user-authored subagent
|
|
// profiles meant to be triggered deliberately, not autonomously.
|
|
Invocation string // auto | manual (frontmatter `invocation:`)
|
|
// Routing metadata is intentionally kept out of the cache-stable Skills
|
|
// index; it feeds per-turn capability hints only.
|
|
Triggers []string
|
|
NegativeTriggers []string
|
|
AutoUse string // off | suggest | prefer | require
|
|
NeedsFreshData bool
|
|
Cost string // low | medium | high (advisory)
|
|
// Requires lists capability IDs this skill depends on (e.g. mcp-server:github).
|
|
// Optional; empty keeps full backward compatibility with older skills.
|
|
Requires []string
|
|
// Profiles restricts availability to economy|balanced|delivery. Empty means
|
|
// the skill is eligible in every profile.
|
|
Profiles []string
|
|
// InvalidProfiles preserves rejected profiles frontmatter values so doctor
|
|
// can warn about typos; the parser drops them from Profiles silently.
|
|
InvalidProfiles []string
|
|
}
|
|
|
|
// SlashName returns the user-facing slash identifier. Plugin skills use a
|
|
// package-qualified name while the internal Name remains stable for run_skill.
|
|
func (s Skill) SlashName() string {
|
|
if strings.TrimSpace(s.Plugin) == "" {
|
|
return s.Name
|
|
}
|
|
return strings.TrimSpace(s.Plugin) + ":" + s.Name
|
|
}
|
|
|
|
// IsValidName reports whether name is a usable skill identifier.
|
|
func IsValidName(name string) bool { return config.IsValidSkillName(name) }
|
|
|
|
// Options configure a Store. ProjectRoot "" reads only the global + custom
|
|
// scopes. HomeDir "" resolves to the OS home dir (tests point it at a tmpdir).
|
|
// ReasonixHomeDir overrides the canonical Reasonix home; empty uses
|
|
// config.ReasonixHomeDir(), or HomeDir/.reasonix when HomeDir is explicitly set.
|
|
type Options struct {
|
|
HomeDir string
|
|
ReasonixHomeDir string
|
|
ProjectRoot string
|
|
CustomPaths []string
|
|
PluginPaths map[string][]string // canonical custom root -> installed plugin package names
|
|
ExcludedPaths []string
|
|
DisabledNames []string
|
|
MaxDepth int
|
|
DisableBuiltins bool // suppress shipped built-ins (test-only knob)
|
|
// Stderr is the writer for diagnostic warnings. When nil, defaults to
|
|
// os.Stderr. Set to io.Discard to suppress output (e.g. during model
|
|
// switch inside a bubbletea session).
|
|
Stderr io.Writer
|
|
}
|
|
|
|
// Store resolves skills across the configured roots.
|
|
type Store struct {
|
|
homeDir string
|
|
reasonixHomeDir string
|
|
projectRoot string
|
|
customPaths []string
|
|
pluginPaths map[string][]string
|
|
excludedPaths map[string]bool
|
|
disabled map[string]bool
|
|
maxDepth int
|
|
disableBuiltins bool
|
|
stderr io.Writer
|
|
runtimeProfile string
|
|
requiresReady func([]string) []string
|
|
}
|
|
|
|
// New builds a Store. Relative custom paths and a relative project root are made
|
|
// absolute; "~" in a custom path expands to the home dir.
|
|
func New(opts Options) *Store {
|
|
home := opts.HomeDir
|
|
if home == "" {
|
|
if h, err := os.UserHomeDir(); err == nil {
|
|
home = h
|
|
}
|
|
}
|
|
reasonixHome := opts.ReasonixHomeDir
|
|
if reasonixHome == "" {
|
|
if opts.HomeDir != "" {
|
|
reasonixHome = filepath.Join(home, ".reasonix")
|
|
} else {
|
|
reasonixHome = config.ReasonixHomeDir()
|
|
}
|
|
}
|
|
root := opts.ProjectRoot
|
|
if root != "" {
|
|
if abs, err := filepath.Abs(root); err == nil {
|
|
root = abs
|
|
}
|
|
}
|
|
base := root
|
|
if base == "" {
|
|
if wd, err := os.Getwd(); err == nil {
|
|
base = wd
|
|
}
|
|
}
|
|
custom := dedupePaths(resolveCustomPaths(opts.CustomPaths, base, home))
|
|
pluginPaths := normalizePluginPaths(opts.PluginPaths)
|
|
excluded := map[string]bool{}
|
|
for _, p := range dedupePaths(resolveCustomPaths(opts.ExcludedPaths, base, home)) {
|
|
excluded[config.CanonicalSkillPath(p)] = true
|
|
}
|
|
stderr := opts.Stderr
|
|
if stderr == nil {
|
|
stderr = os.Stderr
|
|
}
|
|
return &Store{
|
|
homeDir: home,
|
|
reasonixHomeDir: reasonixHome,
|
|
projectRoot: root,
|
|
customPaths: custom,
|
|
pluginPaths: pluginPaths,
|
|
excludedPaths: excluded,
|
|
disabled: disabledNameSet(opts.DisabledNames),
|
|
maxDepth: normalizeMaxDepth(opts.MaxDepth),
|
|
disableBuiltins: opts.DisableBuiltins,
|
|
stderr: stderr,
|
|
}
|
|
}
|
|
|
|
// ConfigureInvocationPolicy installs session-local runtime constraints for
|
|
// skill calls. It does not alter discovery or the provider-visible tool schema;
|
|
// callers validate the selected skill immediately before execution.
|
|
func (s *Store) ConfigureInvocationPolicy(profile string, requiresReady func([]string) []string) {
|
|
if s == nil {
|
|
return
|
|
}
|
|
s.runtimeProfile = normalizeRuntimeProfile(profile)
|
|
s.requiresReady = requiresReady
|
|
}
|
|
|
|
// ValidateInvocation enforces profiles/requires frontmatter at the host tool
|
|
// boundary, including direct run_skill calls that bypass capability routing.
|
|
func (s *Store) ValidateInvocation(sk Skill) error {
|
|
if s == nil {
|
|
return nil
|
|
}
|
|
if s.runtimeProfile != "" && !AllowedInProfile(sk, s.runtimeProfile) {
|
|
return fmt.Errorf("%w: skill %q is unavailable in the %s profile (allowed profiles: %s)", ErrInvocationUnavailable, sk.Name, s.runtimeProfile, strings.Join(sk.Profiles, ", "))
|
|
}
|
|
if len(sk.Requires) > 0 && s.requiresReady != nil {
|
|
if missing := s.requiresReady(sk.Requires); len(missing) > 0 {
|
|
return fmt.Errorf("%w: skill %q requires unavailable capabilities: %s", ErrInvocationUnavailable, sk.Name, strings.Join(missing, ", "))
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// AllowedInProfile reports whether a skill is eligible for a runtime profile.
|
|
// Empty profiles preserve backward compatibility and allow every profile.
|
|
func AllowedInProfile(sk Skill, profile string) bool {
|
|
if len(sk.Profiles) == 0 {
|
|
return true
|
|
}
|
|
want := normalizeRuntimeProfile(profile)
|
|
if want == "" {
|
|
return true
|
|
}
|
|
for _, candidate := range sk.Profiles {
|
|
if normalizeRuntimeProfile(candidate) == want {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// FilterForProfile returns the skills eligible for the provider-visible index
|
|
// and capability router while leaving the underlying store intact for doctor
|
|
// diagnostics and explicit host errors.
|
|
func FilterForProfile(skills []Skill, profile string) []Skill {
|
|
out := make([]Skill, 0, len(skills))
|
|
for _, sk := range skills {
|
|
if AllowedInProfile(sk, profile) {
|
|
out = append(out, sk)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func normalizeRuntimeProfile(profile string) string {
|
|
switch strings.ToLower(strings.TrimSpace(profile)) {
|
|
case "economy":
|
|
return "economy"
|
|
case "delivery":
|
|
return "delivery"
|
|
case "balanced", "full":
|
|
return "balanced"
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
// HasProjectScope reports whether the store was configured with a project root.
|
|
func (s *Store) HasProjectScope() bool { return s.projectRoot != "" }
|
|
|
|
// PathStatus describes a root directory's readability, surfaced by `/skill paths`.
|
|
type PathStatus string
|
|
|
|
const (
|
|
StatusOK PathStatus = "ok"
|
|
StatusMissing PathStatus = "missing"
|
|
StatusNotDirectory PathStatus = "not-directory"
|
|
StatusUnreadable PathStatus = "unreadable"
|
|
)
|
|
|
|
// Root is one discovery directory with its scope, priority, and status.
|
|
type Root struct {
|
|
Dir string
|
|
Scope Scope
|
|
Priority int
|
|
Status PathStatus
|
|
}
|
|
|
|
type discoveryRoot struct {
|
|
Root
|
|
requireFlatMarker bool
|
|
plugins []string
|
|
}
|
|
|
|
// roots returns the discovery directories, highest priority first: the
|
|
// convention dirs (config.ConventionDirs: .reasonix / .agents / .agent / .claude)
|
|
// under the project root → custom paths → the Reasonix home skills dir → other
|
|
// home-dir convention dirs. A later root never overrides an earlier one.
|
|
func (s *Store) roots() []discoveryRoot {
|
|
type de struct {
|
|
dir string
|
|
scope Scope
|
|
requireFlatMarker bool
|
|
}
|
|
var dirs []de
|
|
if s.projectRoot != "" {
|
|
for _, c := range config.ConventionDirs {
|
|
dirs = append(dirs, de{filepath.Join(s.projectRoot, c, SkillsDirname), ScopeProject, c == ".claude"})
|
|
}
|
|
}
|
|
for _, d := range s.customPaths {
|
|
dirs = append(dirs, de{d, ScopeCustom, false})
|
|
}
|
|
if s.reasonixHomeDir != "" {
|
|
dirs = append(dirs, de{filepath.Join(s.reasonixHomeDir, SkillsDirname), ScopeGlobal, false})
|
|
}
|
|
if config.IsolatedHomeDir() == "" {
|
|
for _, c := range config.ConventionDirs {
|
|
dir := filepath.Join(s.homeDir, c, SkillsDirname)
|
|
if s.reasonixHomeDir != "" && config.CanonicalSkillPath(filepath.Dir(dir)) == config.CanonicalSkillPath(s.reasonixHomeDir) {
|
|
continue
|
|
}
|
|
dirs = append(dirs, de{dir, ScopeGlobal, c == ".claude"})
|
|
}
|
|
}
|
|
out := make([]discoveryRoot, 0, len(dirs))
|
|
for _, d := range dirs {
|
|
if s.excludedPaths[config.CanonicalSkillPath(d.dir)] {
|
|
continue
|
|
}
|
|
out = append(out, discoveryRoot{
|
|
Root: Root{Dir: d.dir, Scope: d.scope, Priority: len(out), Status: pathStatus(d.dir)},
|
|
requireFlatMarker: d.requireFlatMarker,
|
|
plugins: append([]string(nil), s.pluginPaths[config.CanonicalSkillPath(d.dir)]...),
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
func normalizePluginPaths(paths map[string][]string) map[string][]string {
|
|
out := map[string][]string{}
|
|
for path, plugins := range paths {
|
|
key := config.CanonicalSkillPath(path)
|
|
if key == "" {
|
|
continue
|
|
}
|
|
for _, plugin := range plugins {
|
|
plugin = strings.TrimSpace(plugin)
|
|
if plugin == "" || stringSliceContains(out[key], plugin) {
|
|
continue
|
|
}
|
|
out[key] = append(out[key], plugin)
|
|
}
|
|
sort.Strings(out[key])
|
|
}
|
|
return out
|
|
}
|
|
|
|
func stringSliceContains(items []string, want string) bool {
|
|
for _, item := range items {
|
|
if item == want {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// Roots exposes the discovery directories with their status for `/skill paths`.
|
|
func (s *Store) Roots() []Root {
|
|
roots := s.roots()
|
|
out := make([]Root, 0, len(roots))
|
|
for _, r := range roots {
|
|
out = append(out, r.Root)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func disabledNameSet(names []string) map[string]bool {
|
|
out := map[string]bool{}
|
|
for _, name := range names {
|
|
if key := config.SkillNameKey(name); key != "" {
|
|
out[key] = true
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (s *Store) disabledName(name string) bool {
|
|
return s.disabled[config.SkillNameKey(name)]
|
|
}
|
|
|
|
func normalizeMaxDepth(depth int) int {
|
|
const (
|
|
defaultDepth = 3
|
|
maxDepth = 5
|
|
)
|
|
if depth == 0 {
|
|
return defaultDepth
|
|
}
|
|
if depth < 1 {
|
|
return 1
|
|
}
|
|
if depth > maxDepth {
|
|
return maxDepth
|
|
}
|
|
return depth
|
|
}
|
|
|
|
// pathStatus classifies a root directory without failing on the common case of
|
|
// "not created yet".
|
|
func pathStatus(dir string) PathStatus {
|
|
info, err := os.Stat(dir)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return StatusMissing
|
|
}
|
|
return StatusUnreadable
|
|
}
|
|
if !info.IsDir() {
|
|
return StatusNotDirectory
|
|
}
|
|
if f, err := os.Open(dir); err != nil {
|
|
return StatusUnreadable
|
|
} else {
|
|
_ = f.Close()
|
|
}
|
|
return StatusOK
|
|
}
|
|
|
|
func (s *Store) discoveredSkills() []Skill {
|
|
var out []Skill
|
|
for _, r := range s.roots() {
|
|
if r.Status != StatusOK {
|
|
continue
|
|
}
|
|
for _, sk := range s.discoverRoot(r) {
|
|
if s.disabledName(sk.Name) {
|
|
continue
|
|
}
|
|
if len(r.plugins) == 0 {
|
|
out = append(out, sk)
|
|
continue
|
|
}
|
|
for _, plugin := range r.plugins {
|
|
owned := sk
|
|
owned.Plugin = plugin
|
|
out = append(out, owned)
|
|
}
|
|
}
|
|
}
|
|
if !s.disableBuiltins {
|
|
for _, sk := range builtinSkills() {
|
|
if !s.disabledName(sk.Name) {
|
|
out = append(out, sk)
|
|
}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (s *Store) enabledSkills() []Skill {
|
|
byName := map[string]Skill{}
|
|
for _, sk := range s.discoveredSkills() {
|
|
if _, dup := byName[sk.Name]; !dup {
|
|
byName[sk.Name] = sk
|
|
}
|
|
}
|
|
out := make([]Skill, 0, len(byName))
|
|
for _, sk := range byName {
|
|
out = append(out, sk)
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
|
return out
|
|
}
|
|
|
|
// List returns every model-visible skill, deduped by its bare internal name
|
|
// (first/highest-priority root wins), filtered for this session's runtime
|
|
// profile, and sorted for a cache-stable index.
|
|
func (s *Store) List() []Skill {
|
|
return FilterForProfile(s.enabledSkills(), s.runtimeProfile)
|
|
}
|
|
|
|
// SlashList returns the visible user-facing skill directory. Plugin skills are
|
|
// retained per package under /<plugin>:<name>, even when their bare names
|
|
// collide; non-plugin skills keep their existing short names.
|
|
func (s *Store) SlashList() []Skill {
|
|
return VisibleSlashSkills(FilterForProfile(s.discoveredSkills(), s.runtimeProfile))
|
|
}
|
|
|
|
// VisibleSlashSkills deduplicates skills by their user-facing slash name and
|
|
// returns them in deterministic display order.
|
|
func VisibleSlashSkills(skills []Skill) []Skill {
|
|
byName := map[string]Skill{}
|
|
for _, sk := range skills {
|
|
name := sk.SlashName()
|
|
if name == "" {
|
|
continue
|
|
}
|
|
if _, dup := byName[name]; !dup {
|
|
byName[name] = sk
|
|
}
|
|
}
|
|
out := make([]Skill, 0, len(byName))
|
|
for _, sk := range byName {
|
|
out = append(out, sk)
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return out[i].SlashName() < out[j].SlashName() })
|
|
return out
|
|
}
|
|
|
|
// ResolveSlashSkill resolves a visible qualified plugin name or a compatible
|
|
// short name. A short plugin name is rejected when multiple plugin packages
|
|
// contribute it; a higher-priority non-plugin winner keeps its short name.
|
|
func ResolveSlashSkill(skills []Skill, name string) (Skill, bool) {
|
|
name = strings.TrimPrefix(strings.TrimSpace(name), "/")
|
|
if name == "" {
|
|
return Skill{}, false
|
|
}
|
|
for _, sk := range skills {
|
|
if sk.SlashName() == name {
|
|
return sk, true
|
|
}
|
|
}
|
|
if strings.Contains(name, ":") || !IsValidName(name) {
|
|
return Skill{}, false
|
|
}
|
|
var winner Skill
|
|
var found bool
|
|
plugins := map[string]bool{}
|
|
for _, sk := range skills {
|
|
if sk.Name != name {
|
|
continue
|
|
}
|
|
if !found {
|
|
winner, found = sk, true
|
|
}
|
|
if sk.Plugin != "" {
|
|
plugins[sk.Plugin] = true
|
|
}
|
|
}
|
|
if !found || winner.Plugin != "" && len(plugins) > 1 {
|
|
return Skill{}, false
|
|
}
|
|
return winner, true
|
|
}
|
|
|
|
// Read resolves one skill by name, scanning the roots in priority order then the
|
|
// built-ins. ok is false when no such skill exists or the file is unreadable.
|
|
func (s *Store) Read(name string) (Skill, bool) {
|
|
if !IsValidName(name) {
|
|
return Skill{}, false
|
|
}
|
|
if s.disabledName(name) {
|
|
return Skill{}, false
|
|
}
|
|
for _, sk := range s.enabledSkills() {
|
|
if sk.Name == name {
|
|
return sk, true
|
|
}
|
|
}
|
|
return Skill{}, false
|
|
}
|
|
|
|
// ReadSlash resolves a user-entered slash identifier without changing the
|
|
// bare identifiers accepted by Read/run_skill.
|
|
func (s *Store) ReadSlash(name string) (Skill, bool) {
|
|
return ResolveSlashSkill(FilterForProfile(s.discoveredSkills(), s.runtimeProfile), name)
|
|
}
|
|
|
|
func (s *Store) discoverRoot(r discoveryRoot) []Skill {
|
|
var out []Skill
|
|
s.scanDir(r.Dir, r.Scope, r.requireFlatMarker, 1, map[string]bool{}, &out)
|
|
return out
|
|
}
|
|
|
|
func (s *Store) scanDir(dir string, scope Scope, requireFlatMarker bool, depth int, seen map[string]bool, out *[]Skill) {
|
|
key := filepath.Clean(dir)
|
|
if resolved, err := filepath.EvalSymlinks(dir); err == nil {
|
|
key = filepath.Clean(resolved)
|
|
}
|
|
if seen[key] {
|
|
return
|
|
}
|
|
seen[key] = true
|
|
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
return
|
|
}
|
|
for _, e := range entries {
|
|
sk, ok := s.readEntry(dir, scope, requireFlatMarker, e)
|
|
if ok {
|
|
if depth == 1 || strings.TrimSpace(sk.Description) != "" {
|
|
*out = append(*out, sk)
|
|
}
|
|
continue
|
|
}
|
|
if depth >= s.maxDepth || !s.canScanChildDir(dir, e) {
|
|
continue
|
|
}
|
|
s.scanDir(filepath.Join(dir, e.Name()), scope, requireFlatMarker, depth+1, seen, out)
|
|
}
|
|
}
|
|
|
|
func (s *Store) canScanChildDir(dir string, e os.DirEntry) bool {
|
|
name := e.Name()
|
|
if shouldSkipScanDir(name) {
|
|
return false
|
|
}
|
|
if e.IsDir() {
|
|
return true
|
|
}
|
|
if !shouldStatEntryTarget(e.Type()) {
|
|
return false
|
|
}
|
|
info, err := os.Stat(filepath.Join(dir, name))
|
|
return err == nil && info.IsDir()
|
|
}
|
|
|
|
func shouldStatEntryTarget(mode os.FileMode) bool {
|
|
return mode&os.ModeSymlink != 0 || mode&os.ModeIrregular != 0
|
|
}
|
|
|
|
func shouldSkipScanDir(name string) bool {
|
|
if strings.HasPrefix(name, ".") {
|
|
return true
|
|
}
|
|
switch strings.ToLower(name) {
|
|
case "assets", "node_modules", "references", "scripts":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// readEntry turns one directory entry into a skill. It resolves symlink and
|
|
// Windows reparse-style entries via os.Stat (os.ReadDir can report the link's
|
|
// own type, not its target's), so a linked skill directory or flat <name>.md is
|
|
// discovered like a real one; a broken link fails Stat and is skipped.
|
|
func (s *Store) readEntry(dir string, scope Scope, requireFlatMarker bool, e os.DirEntry) (Skill, bool) {
|
|
name := e.Name()
|
|
full := filepath.Join(dir, name)
|
|
|
|
isDir := e.IsDir()
|
|
isFile := e.Type().IsRegular()
|
|
if !isDir && !isFile && shouldStatEntryTarget(e.Type()) {
|
|
info, err := os.Stat(full) // follows the link
|
|
if err != nil {
|
|
return Skill{}, false // broken link
|
|
}
|
|
isDir = info.IsDir()
|
|
isFile = info.Mode().IsRegular()
|
|
}
|
|
|
|
if isDir {
|
|
if !IsValidName(name) {
|
|
return Skill{}, false
|
|
}
|
|
file := filepath.Join(full, SkillFile)
|
|
if _, err := os.Stat(file); err != nil {
|
|
return Skill{}, false // a directory without a SKILL.md is not a skill
|
|
}
|
|
return s.parse(file, name, scope)
|
|
}
|
|
if isFile && strings.EqualFold(filepath.Ext(name), ".md") {
|
|
stem := strings.TrimSuffix(name, filepath.Ext(name))
|
|
if !IsValidName(stem) {
|
|
return Skill{}, false
|
|
}
|
|
return s.parseFlat(full, stem, scope, requireFlatMarker)
|
|
}
|
|
return Skill{}, false
|
|
}
|
|
|
|
// parse reads and decodes one skill file. The frontmatter `name:` overrides the
|
|
// filename stem when valid; a missing `description:` is a warning, not a failure
|
|
// (the skill loads but won't appear in the model's index).
|
|
func (s *Store) parse(path, stem string, scope Scope) (Skill, bool) {
|
|
return s.parseSkill(path, stem, scope, false)
|
|
}
|
|
|
|
// parseFlat reads a flat <name>.md skill candidate. Claude skill roots can also
|
|
// contain ordinary documentation, so those flat files need explicit skill
|
|
// frontmatter before they are treated as skills.
|
|
func (s *Store) parseFlat(path, stem string, scope Scope, requireSkillMarker bool) (Skill, bool) {
|
|
return s.parseSkill(path, stem, scope, requireSkillMarker)
|
|
}
|
|
|
|
func (s *Store) parseSkill(path, stem string, scope Scope, requireSkillMarker bool) (Skill, bool) {
|
|
b, err := fileencoding.ReadFileUTF8(path)
|
|
if err != nil {
|
|
return Skill{}, false
|
|
}
|
|
content := strings.TrimPrefix(strings.ReplaceAll(string(b), "\r\n", "\n"), "\uFEFF")
|
|
fm, body := splitFrontmatter(content)
|
|
if requireSkillMarker && !hasSkillMarker(content, fm) {
|
|
return Skill{}, false
|
|
}
|
|
|
|
name := stem
|
|
if v := fm[skillFrontmatterName]; v != "" && IsValidName(v) {
|
|
name = v
|
|
}
|
|
desc := strings.TrimSpace(fm[skillFrontmatterDescription])
|
|
if desc == "" {
|
|
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)
|
|
}
|
|
sk := Skill{
|
|
Name: name,
|
|
Description: desc,
|
|
Body: loadBodyWithScripts(path, loadBodyWithReferences(path, strings.TrimSpace(body))),
|
|
Scope: scope,
|
|
Path: path,
|
|
AllowedTools: parseAllowedTools(fm[skillFrontmatterAllowedTools]),
|
|
RunAs: parseRunAs(fm[skillFrontmatterRunAs], fm[skillFrontmatterContext], fm[skillFrontmatterAgent]),
|
|
Model: strings.TrimSpace(fm[skillFrontmatterModel]),
|
|
Effort: strings.TrimSpace(fm[skillFrontmatterEffort]),
|
|
ReadOnly: parseBoolFrontmatter(fm[skillFrontmatterReadOnly]),
|
|
Triggers: parseCSVFrontmatter(fm[skillFrontmatterTriggers]),
|
|
NegativeTriggers: parseCSVFrontmatter(
|
|
fm[skillFrontmatterNegativeTriggers],
|
|
),
|
|
AutoUse: parseAutoUse(fm[skillFrontmatterAutoUse]),
|
|
NeedsFreshData: parseBoolFrontmatter(fm[skillFrontmatterNeedsFreshData]),
|
|
Cost: parseCost(fm[skillFrontmatterCost]),
|
|
Color: strings.TrimSpace(fm[skillFrontmatterColor]),
|
|
Invocation: parseInvocation(fm[skillFrontmatterInvocation]),
|
|
Requires: parseCSVFrontmatter(fm[skillFrontmatterRequires]),
|
|
}
|
|
sk.Profiles, sk.InvalidProfiles = parseProfilesFrontmatter(fm[skillFrontmatterProfiles])
|
|
return sk, true
|
|
}
|
|
|
|
const (
|
|
skillFrontmatterDescription = "description"
|
|
skillFrontmatterName = "name"
|
|
skillFrontmatterRunAs = "runas"
|
|
skillFrontmatterContext = "context"
|
|
skillFrontmatterAgent = "agent"
|
|
skillFrontmatterAllowedTools = "allowed-tools"
|
|
skillFrontmatterModel = "model"
|
|
skillFrontmatterEffort = "effort"
|
|
skillFrontmatterReadOnly = "read-only"
|
|
skillFrontmatterTriggers = "triggers"
|
|
skillFrontmatterNegativeTriggers = "negative-triggers"
|
|
skillFrontmatterAutoUse = "auto-use"
|
|
skillFrontmatterNeedsFreshData = "needs-fresh-data"
|
|
skillFrontmatterCost = "cost"
|
|
skillFrontmatterColor = "color"
|
|
skillFrontmatterInvocation = "invocation"
|
|
skillFrontmatterRequires = "requires"
|
|
skillFrontmatterProfiles = "profiles"
|
|
)
|
|
|
|
var skillMarkerFrontmatterKeys = []string{
|
|
skillFrontmatterDescription,
|
|
skillFrontmatterName,
|
|
skillFrontmatterRunAs,
|
|
skillFrontmatterContext,
|
|
skillFrontmatterAgent,
|
|
skillFrontmatterAllowedTools,
|
|
skillFrontmatterModel,
|
|
skillFrontmatterEffort,
|
|
skillFrontmatterReadOnly,
|
|
skillFrontmatterTriggers,
|
|
skillFrontmatterNegativeTriggers,
|
|
skillFrontmatterAutoUse,
|
|
skillFrontmatterNeedsFreshData,
|
|
skillFrontmatterCost,
|
|
skillFrontmatterColor,
|
|
skillFrontmatterInvocation,
|
|
skillFrontmatterRequires,
|
|
skillFrontmatterProfiles,
|
|
}
|
|
|
|
func hasSkillMarker(content string, fm map[string]string) bool {
|
|
for _, key := range skillMarkerFrontmatterKeys {
|
|
if strings.TrimSpace(fm[key]) != "" {
|
|
return true
|
|
}
|
|
}
|
|
return frontmatterHasSkillMarkerKey(content)
|
|
}
|
|
|
|
func frontmatterHasSkillMarkerKey(content string) bool {
|
|
lines := strings.Split(content, "\n")
|
|
if len(lines) == 0 || strings.TrimSpace(lines[0]) != "---" {
|
|
return false
|
|
}
|
|
end := -1
|
|
for i := 1; i < len(lines); i++ {
|
|
if strings.TrimSpace(lines[i]) == "---" {
|
|
end = i
|
|
break
|
|
}
|
|
}
|
|
if end < 0 {
|
|
return false
|
|
}
|
|
for _, line := range lines[1:end] {
|
|
key, _, ok := strings.Cut(line, ":")
|
|
if ok && isSkillMarkerFrontmatterKey(strings.ToLower(strings.TrimSpace(key))) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func isSkillMarkerFrontmatterKey(key string) bool {
|
|
for _, marker := range skillMarkerFrontmatterKeys {
|
|
if key == marker {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// Create scaffolds a new skill stub at the chosen scope. Refuses to overwrite.
|
|
func (s *Store) Create(name string, scope Scope) (string, error) {
|
|
return s.CreateWithContent(name, scope, stubBody(name))
|
|
}
|
|
|
|
// CreateWithContent writes caller-supplied file contents as a canonical
|
|
// <name>/SKILL.md skill, refusing to clobber an existing directory-layout or
|
|
// legacy flat skill of the same name. Returns the written path.
|
|
func (s *Store) CreateWithContent(name string, scope Scope, content string) (string, error) {
|
|
if !IsValidName(name) {
|
|
return "", fmt.Errorf("invalid skill name %q — use letters, digits, '_', '-', '.'", name)
|
|
}
|
|
var root string
|
|
switch scope {
|
|
case ScopeProject:
|
|
if s.projectRoot == "" {
|
|
return "", fmt.Errorf("project scope requires a workspace — run from a project directory, or use global scope")
|
|
}
|
|
root = filepath.Join(s.projectRoot, ".reasonix", SkillsDirname)
|
|
default:
|
|
root = s.globalSkillsRoot()
|
|
}
|
|
flat := filepath.Join(root, name+".md")
|
|
folder := filepath.Join(root, name, SkillFile)
|
|
if _, err := os.Stat(flat); err == nil {
|
|
return "", fmt.Errorf("skill %q already exists at %s", name, flat)
|
|
}
|
|
if _, err := os.Stat(folder); err == nil {
|
|
return "", fmt.Errorf("skill %q already exists at %s", name, folder)
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(folder), 0o755); err != nil {
|
|
return "", err
|
|
}
|
|
// O_EXCL so a concurrent create (or an existing file) is reported, not clobbered.
|
|
f, err := os.OpenFile(folder, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644)
|
|
if err != nil {
|
|
if os.IsExist(err) {
|
|
return "", fmt.Errorf("skill %q already exists at %s", name, folder)
|
|
}
|
|
return "", err
|
|
}
|
|
defer f.Close()
|
|
if _, err := f.WriteString(content); err != nil {
|
|
return "", err
|
|
}
|
|
return folder, nil
|
|
}
|
|
|
|
// UpdateContent overwrites an existing user-authored skill's file contents in
|
|
// place. Refuses built-ins and a scope mismatch, mirroring Delete's rules —
|
|
// see Delete for why a mismatch must refuse rather than silently target the
|
|
// wrong file.
|
|
func (s *Store) UpdateContent(name string, scope Scope, content string) error {
|
|
if scope == ScopeBuiltin {
|
|
return fmt.Errorf("skill %q is built in and cannot be edited", name)
|
|
}
|
|
sk, ok := s.Read(name)
|
|
if !ok {
|
|
return fmt.Errorf("skill %q not found", name)
|
|
}
|
|
if sk.Scope != scope {
|
|
return fmt.Errorf("skill %q resolves at scope %q, not %q — refusing to edit a different scope's file", name, sk.Scope, scope)
|
|
}
|
|
if sk.Path == "" || sk.Path == "(builtin)" {
|
|
return fmt.Errorf("skill %q has no file to update", name)
|
|
}
|
|
if err := s.validateMutablePath(sk.Path, scope); err != nil {
|
|
return fmt.Errorf("skill %q cannot be edited: %w", name, err)
|
|
}
|
|
info, err := os.Stat(sk.Path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return fileutil.AtomicWriteFile(sk.Path, []byte(content), info.Mode().Perm())
|
|
}
|
|
|
|
// validateMutablePath rejects writes through linked files or directories. Skill
|
|
// discovery intentionally follows symlinks for read compatibility, but editing
|
|
// one must never replace content outside the configured scope root.
|
|
func (s *Store) validateMutablePath(path string, scope Scope) error {
|
|
absPath, err := filepath.Abs(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, root := range s.roots() {
|
|
if root.Scope != scope {
|
|
continue
|
|
}
|
|
absRoot, err := filepath.Abs(root.Dir)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
rel, err := filepath.Rel(absRoot, absPath)
|
|
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
|
continue
|
|
}
|
|
current := absRoot
|
|
parts := []string{"."}
|
|
if rel != "." {
|
|
parts = strings.Split(rel, string(filepath.Separator))
|
|
}
|
|
for _, part := range parts {
|
|
if part != "." {
|
|
current = filepath.Join(current, part)
|
|
}
|
|
info, err := os.Lstat(current)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if info.Mode()&os.ModeSymlink != 0 {
|
|
return fmt.Errorf("path uses symbolic link %s", current)
|
|
}
|
|
}
|
|
realRoot, err := filepath.EvalSymlinks(absRoot)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
realPath, err := filepath.EvalSymlinks(absPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
realRel, err := filepath.Rel(realRoot, realPath)
|
|
if err != nil || realRel == ".." || strings.HasPrefix(realRel, ".."+string(filepath.Separator)) {
|
|
return fmt.Errorf("resolved path is outside scope root %s", absRoot)
|
|
}
|
|
return nil
|
|
}
|
|
return fmt.Errorf("path is outside configured %s skill roots", scope)
|
|
}
|
|
|
|
// Delete removes a user-authored skill. Refuses built-ins (no file backs
|
|
// them) and refuses when the resolved skill's actual scope doesn't match the
|
|
// requested one — e.g. a project-scope delete for a name that only resolves
|
|
// at global scope, which would otherwise silently no-op against the wrong
|
|
// file while a same-named project-scope shadow kept showing up in List().
|
|
func (s *Store) Delete(name string, scope Scope) error {
|
|
if scope == ScopeBuiltin {
|
|
return fmt.Errorf("skill %q is built in and cannot be deleted", name)
|
|
}
|
|
sk, ok := s.Read(name)
|
|
if !ok {
|
|
return fmt.Errorf("skill %q not found", name)
|
|
}
|
|
if sk.Scope != scope {
|
|
return fmt.Errorf("skill %q resolves at scope %q, not %q — refusing to delete a different scope's file", name, sk.Scope, scope)
|
|
}
|
|
if sk.Path == "" || sk.Path == "(builtin)" {
|
|
return fmt.Errorf("skill %q has no file to delete", name)
|
|
}
|
|
if filepath.Base(sk.Path) == SkillFile {
|
|
return os.RemoveAll(filepath.Dir(sk.Path)) // directory-layout skill: <name>/SKILL.md + siblings
|
|
}
|
|
return os.Remove(sk.Path) // legacy flat <name>.md skill
|
|
}
|
|
|
|
func (s *Store) globalSkillsRoot() string {
|
|
if s.reasonixHomeDir != "" {
|
|
return filepath.Join(s.reasonixHomeDir, SkillsDirname)
|
|
}
|
|
return filepath.Join(s.homeDir, ".reasonix", SkillsDirname)
|
|
}
|
|
|
|
// loadBodyWithReferences appends a directory-layout skill's sibling
|
|
// references/*.md files to its body (Anthropic Skills compatibility), so depth
|
|
// material is available without on-demand resolution. Flat skills have no
|
|
// references dir and are returned unchanged.
|
|
func loadBodyWithReferences(skillPath, body string) string {
|
|
if filepath.Base(skillPath) != SkillFile {
|
|
return body
|
|
}
|
|
refsDir := filepath.Join(filepath.Dir(skillPath), "references")
|
|
entries, err := os.ReadDir(refsDir)
|
|
if err != nil {
|
|
return body
|
|
}
|
|
var names []string
|
|
for _, e := range entries {
|
|
if !e.IsDir() && strings.EqualFold(filepath.Ext(e.Name()), ".md") {
|
|
names = append(names, e.Name())
|
|
}
|
|
}
|
|
if len(names) == 0 {
|
|
return body
|
|
}
|
|
sort.Strings(names)
|
|
var b strings.Builder
|
|
b.WriteString(body)
|
|
for _, n := range names {
|
|
content, err := fileencoding.ReadFileUTF8(filepath.Join(refsDir, n))
|
|
if err != nil {
|
|
continue
|
|
}
|
|
trimmed := strings.TrimSpace(string(content))
|
|
if trimmed == "" {
|
|
continue
|
|
}
|
|
slug := strings.TrimSuffix(n, filepath.Ext(n))
|
|
b.WriteString("\n\n## Reference: " + slug + "\n\n" + trimmed)
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// loadBodyWithScripts appends a directory-layout skill's sibling scripts/
|
|
// directory listing to the body, so the model knows what scripts are
|
|
// available and can run them via bash (inheriting sandbox, gate, hooks).
|
|
func loadBodyWithScripts(skillPath, body string) string {
|
|
if filepath.Base(skillPath) != SkillFile {
|
|
return body
|
|
}
|
|
scriptsDir := filepath.Join(filepath.Dir(skillPath), "scripts")
|
|
entries, err := os.ReadDir(scriptsDir)
|
|
if err != nil {
|
|
return body
|
|
}
|
|
var names []string
|
|
for _, e := range entries {
|
|
// Filter hidden files — bash should not see config dotfiles in scripts/.
|
|
if e.IsDir() || strings.HasPrefix(e.Name(), ".") {
|
|
continue
|
|
}
|
|
if !isScriptExt(filepath.Ext(e.Name())) {
|
|
continue
|
|
}
|
|
names = append(names, e.Name())
|
|
}
|
|
if len(names) == 0 {
|
|
return body
|
|
}
|
|
sort.Strings(names)
|
|
var b strings.Builder
|
|
b.WriteString(body)
|
|
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")
|
|
for _, n := range names {
|
|
b.WriteString("- `" + filepath.Join(scriptsDir, n) + "`\n")
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
func isScriptExt(ext string) bool {
|
|
switch strings.ToLower(ext) {
|
|
case "", ".sh", ".py", ".js", ".ts", ".rb", ".pl", ".php", ".ps1":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// parseAllowedTools splits a comma-separated `allowed-tools` value into trimmed,
|
|
// non-empty tool names; nil when absent.
|
|
func parseAllowedTools(raw string) []string {
|
|
return parseCSVFrontmatter(raw)
|
|
}
|
|
|
|
// parseCSVFrontmatter splits simple comma-separated frontmatter values. Full
|
|
// YAML lists are intentionally out of scope for the existing frontmatter parser.
|
|
func parseCSVFrontmatter(raw string) []string {
|
|
if strings.TrimSpace(raw) == "" {
|
|
return nil
|
|
}
|
|
var out []string
|
|
for _, p := range strings.Split(raw, ",") {
|
|
if t := strings.TrimSpace(p); t != "" {
|
|
out = append(out, t)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func parseAutoUse(raw string) string {
|
|
switch strings.ToLower(strings.TrimSpace(raw)) {
|
|
case "off", "suggest", "prefer", "require":
|
|
return strings.ToLower(strings.TrimSpace(raw))
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
// parseProfilesFrontmatter keeps only economy|balanced|delivery values and
|
|
// returns the rejected ones separately so doctor can surface typos instead of
|
|
// the parser hiding them.
|
|
func parseProfilesFrontmatter(raw string) (valid, invalid []string) {
|
|
seen := map[string]bool{}
|
|
for _, p := range parseCSVFrontmatter(raw) {
|
|
p = strings.ToLower(strings.TrimSpace(p))
|
|
switch p {
|
|
case "economy", "balanced", "delivery":
|
|
if !seen[p] {
|
|
seen[p] = true
|
|
valid = append(valid, p)
|
|
}
|
|
case "":
|
|
default:
|
|
if !seen[p] {
|
|
seen[p] = true
|
|
invalid = append(invalid, p)
|
|
}
|
|
}
|
|
}
|
|
return valid, invalid
|
|
}
|
|
|
|
func parseBoolFrontmatter(raw string) bool {
|
|
switch strings.ToLower(strings.TrimSpace(raw)) {
|
|
case "true", "yes", "1", "on":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func parseCost(raw string) string {
|
|
switch strings.ToLower(strings.TrimSpace(raw)) {
|
|
case "low", "medium", "high":
|
|
return strings.ToLower(strings.TrimSpace(raw))
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
// parseInvocation maps frontmatter to an invocation mode. Anything other than
|
|
// "manual" (including absent) is "auto" — the existing, universal behavior.
|
|
func parseInvocation(raw string) string {
|
|
if strings.EqualFold(strings.TrimSpace(raw), "manual") {
|
|
return "manual"
|
|
}
|
|
return "auto"
|
|
}
|
|
|
|
// parseRunAs maps frontmatter to a run mode. An unknown value defaults to the
|
|
// safe (non-spawning) inline mode; a `context: fork` or a non-empty `agent:`
|
|
// field (cross-tool conventions) signals subagent isolation.
|
|
func parseRunAs(runAs, context, agent string) RunAs {
|
|
if strings.TrimSpace(runAs) == "subagent" {
|
|
return RunSubagent
|
|
}
|
|
if strings.EqualFold(strings.TrimSpace(context), "fork") {
|
|
return RunSubagent
|
|
}
|
|
if strings.TrimSpace(agent) != "" {
|
|
return RunSubagent
|
|
}
|
|
return RunInline
|
|
}
|
|
|
|
// stubBody is the scaffold written by `/skill new` — minimal frontmatter plus
|
|
// guidance the author fills in.
|
|
func stubBody(name string) string {
|
|
return "---\nname: " + name + "\ndescription: One-liner — what does this skill do?\n---\n\n# " + name + `
|
|
|
|
Replace this body with the playbook the model should follow when this skill is invoked.
|
|
|
|
Tips:
|
|
- Reference tools by name (bash, edit_file, grep, read_file, ...)
|
|
- Add ` + "`runAs: subagent`" + ` to frontmatter to spawn an isolated subagent loop
|
|
- Add ` + "`allowed-tools: read_file, grep`" + ` to scope a subagent's tools
|
|
`
|
|
}
|
|
|
|
// resolveCustomPaths expands "~" and makes each custom path absolute relative to
|
|
// baseDir.
|
|
func resolveCustomPaths(paths []string, baseDir, homeDir string) []string {
|
|
out := make([]string, 0, len(paths))
|
|
for _, p := range paths {
|
|
trimmed := strings.TrimSpace(p)
|
|
if trimmed == "" {
|
|
continue
|
|
}
|
|
switch {
|
|
case trimmed == "~":
|
|
trimmed = homeDir
|
|
case strings.HasPrefix(trimmed, "~/") || strings.HasPrefix(trimmed, `~\`):
|
|
trimmed = filepath.Join(homeDir, trimmed[2:])
|
|
}
|
|
if !filepath.IsAbs(trimmed) {
|
|
trimmed = filepath.Join(baseDir, trimmed)
|
|
}
|
|
out = append(out, filepath.Clean(trimmed))
|
|
}
|
|
return out
|
|
}
|
|
|
|
// dedupePaths drops duplicate custom roots, preserving order.
|
|
func dedupePaths(paths []string) []string {
|
|
seen := map[string]bool{}
|
|
out := paths[:0]
|
|
for _, p := range paths {
|
|
if seen[p] {
|
|
continue
|
|
}
|
|
seen[p] = true
|
|
out = append(out, p)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// splitFrontmatter is a thin wrapper kept for internal use; the real parser
|
|
// lives in internal/frontmatter.
|
|
func splitFrontmatter(s string) (map[string]string, string) {
|
|
return frontmatter.Split(s)
|
|
}
|