// This file implements declarative skills (US-028, #45): a skill is a markdown // file with a YAML frontmatter block (mirrors SKILL.md) that names a reusable // capability. Loading a skill parses its metadata (name, description, optional // tool allow-list and model) and its markdown body (the skill's system prompt), // then materializes it as a sub-agent tool — so invoking a skill runs its body // as the system prompt of a child agent loop, reusing the SubAgentTool // abstraction rather than introducing a second execution path. Each skill's // description is surfaced in the parent's capability list so the model can // choose to delegate to it. package runtime import ( "errors" "fmt" "os" "path/filepath" "regexp" "sort" "strings" "github.com/smallnest/pigo/internal/agentcore" "gopkg.in/yaml.v3" ) // SkillFrontmatter is the YAML metadata block at the head of a skill file. type SkillFrontmatter struct { // Name is the skill identifier; it becomes the spawnable tool name. When // omitted it defaults to the file's base name (without extension). Name string `yaml:"name"` // Description tells the model what the skill does and when to use it. It is // injected into the capability list, so it should be action-oriented. Description string `yaml:"description"` // AllowedTools optionally restricts the tools the skill's sub-agent may use, // by tool name. Empty means "inherit the provided tool set as-is". Real // Claude Code skills write this either as a YAML list or as a single // scalar string (e.g. "Bash(foo:*), Read"), so it tolerates both forms. AllowedTools stringList `yaml:"allowed-tools"` // Model optionally pins the skill to a specific model; empty inherits. Model string `yaml:"model"` // DisableModelInvocation, when true, keeps the skill out of the system // prompt's list so the model cannot auto-invoke it; it // remains reachable only via its explicit "/name" slash command (mirrors pi's // disable-model-invocation frontmatter key). Defaults to false. DisableModelInvocation bool `yaml:"disable-model-invocation"` } // Agent Skills spec limits (mirrors pi/agentskills.io): a skill name is a short // slug and a description is a single sentence, both bounded so they stay cheap // to inject into the system prompt. const ( maxSkillNameLength = 64 maxSkillDescriptionLength = 1024 ) // skillNamePattern matches a valid skill name per the Agent Skills spec: // lowercase ASCII letters, digits, and hyphens only. var skillNamePattern = regexp.MustCompile(`^[a-z0-9-]+$`) // validateSkillName reports why name violates the Agent Skills spec, or nil // when it is valid: lowercase a-z/0-9/hyphen only, at most maxSkillNameLength // characters, and no leading, trailing, or consecutive hyphens. func validateSkillName(name string) error { if len(name) > maxSkillNameLength { return fmt.Errorf("name exceeds %d characters (%d)", maxSkillNameLength, len(name)) } if !skillNamePattern.MatchString(name) { return fmt.Errorf("name %q contains invalid characters (allowed: lowercase a-z, 0-9, hyphen)", name) } if strings.HasPrefix(name, "-") || strings.HasSuffix(name, "-") { return fmt.Errorf("name %q must not start or end with a hyphen", name) } if strings.Contains(name, "--") { return fmt.Errorf("name %q must not contain consecutive hyphens", name) } return nil } // validateSkillDescription reports why description violates the spec, or nil // when it is valid: non-empty and at most maxSkillDescriptionLength characters. func validateSkillDescription(description string) error { if strings.TrimSpace(description) == "" { return errors.New("frontmatter missing required 'description'") } if len(description) > maxSkillDescriptionLength { return fmt.Errorf("description exceeds %d characters (%d)", maxSkillDescriptionLength, len(description)) } return nil } // stringList is a []string that unmarshals from either a YAML sequence // (- a\n- b) or a single scalar. A scalar is split on commas so the common // Claude Code form `allowed-tools: Bash(foo:*), Read` parses into two entries. // This tolerance matters: a strict []string field rejects the scalar form and, // because LoadSkillsDir aborts on the first parse error, one such skill would // hide every other skill in the directory. type stringList []string // UnmarshalYAML accepts a scalar or a sequence node. func (l *stringList) UnmarshalYAML(node *yaml.Node) error { switch node.Kind { case yaml.ScalarNode: var s string if err := node.Decode(&s); err != nil { return err } parts := strings.Split(s, ",") out := make([]string, 0, len(parts)) for _, p := range parts { if t := strings.TrimSpace(p); t != "" { out = append(out, t) } } *l = out return nil case yaml.SequenceNode: var ss []string if err := node.Decode(&ss); err != nil { return err } *l = ss return nil default: // An empty/null node leaves the list nil (no restriction). return nil } } // Skill is a parsed skill file: its metadata plus the markdown body that serves // as the sub-agent's system prompt. type Skill struct { Frontmatter SkillFrontmatter // Body is the markdown after the frontmatter block — the skill's instructions, // used as the child agent's system prompt. Body string // Path is the source file, retained for diagnostics. Path string } // ParseSkill parses a skill's raw file content into a Skill. The file must open // with a YAML frontmatter block delimited by lines containing only "---"; the // remainder is the markdown body. A missing or malformed frontmatter block is // an error, since name/description drive discovery. func ParseSkill(path string, content []byte) (*Skill, error) { fm, body, err := splitFrontmatter(content) if err != nil { return nil, fmt.Errorf("skill %s: %w", path, err) } var meta SkillFrontmatter if err := yaml.Unmarshal(fm, &meta); err != nil { return nil, fmt.Errorf("skill %s: parse frontmatter: %w", path, err) } if meta.Name == "" { base := filepath.Base(path) meta.Name = strings.TrimSuffix(base, filepath.Ext(base)) } if err := validateSkillName(meta.Name); err != nil { return nil, fmt.Errorf("skill %s: %w", path, err) } if err := validateSkillDescription(meta.Description); err != nil { return nil, fmt.Errorf("skill %s: %w", path, err) } return &Skill{Frontmatter: meta, Body: strings.TrimSpace(string(body)), Path: path}, nil } // splitFrontmatter separates a leading "---"-delimited YAML block from the rest // of the document. It returns the frontmatter bytes (without the fences) and // the remaining body. It errors if the document does not open with a fence or // the closing fence is missing. func splitFrontmatter(content []byte) (frontmatter, body []byte, err error) { text := string(content) // Tolerate a UTF-8 BOM and leading blank lines before the opening fence. text = strings.TrimPrefix(text, "\ufeff") trimmed := strings.TrimLeft(text, "\r\n") if !strings.HasPrefix(trimmed, "---") { return nil, nil, fmt.Errorf("missing YAML frontmatter (file must start with '---')") } lines := strings.Split(trimmed, "\n") // lines[0] is the opening fence. Find the closing fence. var fmLines []string closeIdx := -1 for i := 1; i < len(lines); i++ { if strings.TrimRight(lines[i], "\r") == "---" { closeIdx = i break } fmLines = append(fmLines, lines[i]) } if closeIdx == -1 { return nil, nil, fmt.Errorf("unterminated YAML frontmatter (missing closing '---')") } bodyLines := lines[closeIdx+1:] return []byte(strings.Join(fmLines, "\n")), []byte(strings.Join(bodyLines, "\n")), nil } // LoadSkillsDir loads every "*.md" skill file in dir (non-recursively) plus any // "/SKILL.md" nested layout (mirrors the SKILL.md convention). It returns the // parsed skills sorted by name. A missing directory yields no skills and no // error (skills are optional). // // A malformed skill file does NOT abort the load: the file is skipped and its // error accumulated, so one bad skill cannot hide every other skill in the // directory (a real ~/.agents/skills holds 100+ skills authored to varying // conventions). The successfully parsed skills are always returned; the error, // when non-nil, joins every skip reason for the caller to surface as a // non-fatal warning. func LoadSkillsDir(dir string) ([]*Skill, error) { entries, err := os.ReadDir(dir) if err != nil { if os.IsNotExist(err) { return nil, nil } return nil, fmt.Errorf("read skills dir %s: %w", dir, err) } var skills []*Skill var errs []error for _, e := range entries { var path string switch { case e.IsDir(): // Nested layout: //SKILL.md. candidate := filepath.Join(dir, e.Name(), "SKILL.md") if _, statErr := os.Stat(candidate); statErr != nil { continue } path = candidate case strings.EqualFold(filepath.Ext(e.Name()), ".md"): path = filepath.Join(dir, e.Name()) default: continue } content, readErr := os.ReadFile(path) if readErr != nil { errs = append(errs, fmt.Errorf("read skill %s: %w", path, readErr)) continue } skill, parseErr := ParseSkill(path, content) if parseErr != nil { errs = append(errs, parseErr) continue } skills = append(skills, skill) } sort.Slice(skills, func(i, j int) bool { return skills[i].Frontmatter.Name < skills[j].Frontmatter.Name }) return skills, errors.Join(errs...) } // SubAgentSpec turns a skill into a sub-agent spec: the skill body becomes the // child's system prompt, the description is surfaced to the model, and the tool // set is the provided tools filtered by AllowedTools (when set). newRunConfig // builds each child run's configuration; it receives the resolved tool set so // the caller can wire a matching registry. func (s *Skill) SubAgentSpec(tools []agentcore.AgentTool, newRunConfig func(tools []agentcore.AgentTool) RunConfig) SubAgentSpec { resolved := filterToolsByName(tools, s.Frontmatter.AllowedTools) return SubAgentSpec{ Name: s.Frontmatter.Name, Description: s.Frontmatter.Description, SystemPrompt: s.Body, Tools: resolved, NewRunConfig: func() RunConfig { return newRunConfig(resolved) }, } } // SkillTool materializes a skill as an invocable sub-agent tool. func (s *Skill) SkillTool(tools []agentcore.AgentTool, newRunConfig func(tools []agentcore.AgentTool) RunConfig) *SubAgentTool { return NewSubAgentTool(s.SubAgentSpec(tools, newRunConfig)) } // SlashCommand exposes the skill as a "/name" slash command (mirrors Claude Code's // /skill-name invocation). Invoking it expands to the skill's instructions (its // markdown body) as the prompt, with any arguments appended, so the skill runs // in the current conversation. It is a prompt command (not an action): the // expanded text is fed to the agent loop as the next user turn. func (s *Skill) SlashCommand() SlashCommand { body := s.Body return SlashCommand{ Name: s.Frontmatter.Name, Description: s.Frontmatter.Description, Source: SourceUser, Expand: func(args string) string { if strings.Contains(body, "$ARGUMENTS") { return strings.ReplaceAll(body, "$ARGUMENTS", args) } if strings.TrimSpace(args) == "" { return body } return body + "\n\n" + args }, } } // FormatSkillsForPrompt renders the visible skills as an // XML block for injection into the system prompt (mirrors pi's // formatSkillsForPrompt). It implements progressive disclosure: only each // skill's name, description, and location (the absolute SKILL.md path) are // listed, so the model can read the file on demand rather than carrying every // skill body in context. // // Skills with DisableModelInvocation == true are excluded (they remain // reachable only via their explicit "/name" slash command). When no visible // skill remains, it returns the empty string so callers can append // unconditionally without altering a skill-free prompt. func FormatSkillsForPrompt(skills []*Skill) string { visible := make([]*Skill, 0, len(skills)) for _, s := range skills { if s != nil && !s.Frontmatter.DisableModelInvocation { visible = append(visible, s) } } if len(visible) == 0 { return "" } lines := []string{ "\n\nThe following skills provide specialized instructions for specific tasks.", "Use the read tool to load a skill's file when the task matches its description.", "When a skill file references a relative path, resolve it against the skill directory (parent of SKILL.md / dirname of the path) and use that absolute path in tool commands.", "", "", } for _, s := range visible { lines = append(lines, " ", " "+escapeXML(s.Frontmatter.Name)+"", " "+escapeXML(s.Frontmatter.Description)+"", " "+escapeXML(skillLocation(s.Path))+"", " ", ) } lines = append(lines, "") return strings.Join(lines, "\n") } // skillLocation returns the absolute path to a skill file so the model can load // it with the read tool regardless of the working directory. It falls back to // the original path if resolution fails. func skillLocation(path string) string { if abs, err := filepath.Abs(path); err == nil { return abs } return path } // escapeXML escapes the five XML special characters so a skill's name or // description cannot break the surrounding markup. func escapeXML(s string) string { r := strings.NewReplacer( "&", "&", "<", "<", ">", ">", `"`, """, "'", "'", ) return r.Replace(s) } // filterToolsByName keeps only tools whose Name is in allow. An empty allow // list means "no restriction" and returns the input unchanged. func filterToolsByName(tools []agentcore.AgentTool, allow []string) []agentcore.AgentTool { if len(allow) == 0 { return tools } set := make(map[string]bool, len(allow)) for _, n := range allow { set[n] = true } out := make([]agentcore.AgentTool, 0, len(tools)) for _, t := range tools { if set[t.Name()] { out = append(out, t) } } return out }