package skillpack import ( "bufio" "bytes" "fmt" "strings" "gopkg.in/yaml.v3" ) // ManifestName is the required filename at a pack's root. const ManifestName = "SKILL.md" // Limits on manifest fields, matching the Anthropic agent-skills constraints so // packs authored against that ecosystem validate here unchanged. const ( maxNameLen = 64 maxDescriptionLen = 1024 maxBodyBytes = 1 << 20 // 1 MiB of instruction text is already excessive ) // Manifest is a parsed SKILL.md: YAML frontmatter plus the markdown body. Only // Name and Description are required; everything else is optional and passes // through so a host can honor it (or ignore it) without this package growing a // policy opinion. type Manifest struct { // Name is the pack's stable identifier (kebab-case, unique within a host's // subscriptions). It is what the model passes to skill_use. Name string // Description is the one-liner shown in the catalog — the ONLY text loaded // into the prompt up front, so it must convey when to reach for the skill. Description string // License is an optional SPDX-ish tag, informational only. License string // AllowedTools is the pack author's declared tool allow-list. It is advisory // here: a host MAY intersect it with the agent's real toolset, but it can // only ever NARROW, never grant (see the host wiring, not this package). AllowedTools []string // Metadata is arbitrary passthrough frontmatter (e.g. version) the host may // use; this package does not interpret it. Metadata map[string]string // Body is the markdown instruction text after the frontmatter — the payload // skill_use returns on demand. Body string } // ParseManifest parses a SKILL.md byte slice into a validated Manifest. The // input must begin with a `---` YAML frontmatter block; the remainder is the // body. It returns a descriptive error on malformed frontmatter or a field that // violates the limits, so a bad pack fails loudly at subscribe/sync time rather // than silently activating. func ParseManifest(raw []byte) (*Manifest, error) { front, body, err := splitFrontmatter(raw) if err != nil { return nil, err } // Decode into a permissive intermediate: SKILL.md uses hyphenated keys // (allowed-tools) and lets metadata values be scalars of any type. var fm struct { Name string `yaml:"name"` Description string `yaml:"description"` License string `yaml:"license"` AllowedTools stringList `yaml:"allowed-tools"` Metadata map[string]any `yaml:"metadata"` } if err := yaml.Unmarshal(front, &fm); err != nil { return nil, fmt.Errorf("skillpack: invalid SKILL.md frontmatter: %w", err) } m := &Manifest{ Name: strings.TrimSpace(fm.Name), Description: strings.TrimSpace(fm.Description), License: strings.TrimSpace(fm.License), AllowedTools: []string(fm.AllowedTools), Body: strings.TrimSpace(string(body)), } if len(fm.Metadata) > 0 { m.Metadata = make(map[string]string, len(fm.Metadata)) for k, v := range fm.Metadata { m.Metadata[k] = fmt.Sprintf("%v", v) } } if err := m.Validate(); err != nil { return nil, err } return m, nil } // Validate reports the first field that violates the manifest contract. func (m *Manifest) Validate() error { switch { case m.Name == "": return fmt.Errorf("skillpack: SKILL.md missing required 'name'") case len(m.Name) > maxNameLen: return fmt.Errorf("skillpack: name %q exceeds %d chars", m.Name, maxNameLen) case !isKebab(m.Name): return fmt.Errorf("skillpack: name %q must be lowercase kebab-case (a-z, 0-9, -)", m.Name) case m.Description == "": return fmt.Errorf("skillpack: SKILL.md missing required 'description'") case len(m.Description) > maxDescriptionLen: return fmt.Errorf("skillpack: description exceeds %d chars", maxDescriptionLen) case len(m.Body) > maxBodyBytes: return fmt.Errorf("skillpack: body exceeds %d bytes", maxBodyBytes) } return nil } // splitFrontmatter separates a leading `---`-delimited YAML block from the body. // Leading blank lines/BOM are tolerated. A missing or unterminated block is an // error — a SKILL.md without frontmatter has no name/description to catalog. func splitFrontmatter(raw []byte) (front, body []byte, err error) { // Strip a leading UTF-8 BOM: editors on some platforms prepend one, and // bytes.TrimSpace (used below) does not remove it, so a BOM would otherwise // make the first "---" fence unrecognizable. raw = bytes.TrimPrefix(raw, []byte{0xEF, 0xBB, 0xBF}) s := bufio.NewScanner(bytes.NewReader(raw)) s.Buffer(make([]byte, 0, 64*1024), maxBodyBytes+64*1024) var frontLines [][]byte var bodyLines [][]byte state := 0 // 0=before open fence, 1=in frontmatter, 2=in body sawOpen := false for s.Scan() { line := s.Bytes() trimmed := bytes.TrimRight(line, "\r") switch state { case 0: if len(bytes.TrimSpace(trimmed)) == 0 { continue // skip leading blanks } if string(bytes.TrimSpace(trimmed)) != "---" { return nil, nil, fmt.Errorf("skillpack: SKILL.md must start with a '---' frontmatter block") } sawOpen = true state = 1 case 1: if string(bytes.TrimSpace(trimmed)) == "---" { state = 2 continue } frontLines = append(frontLines, append([]byte(nil), trimmed...)) case 2: bodyLines = append(bodyLines, append([]byte(nil), trimmed...)) } } if err := s.Err(); err != nil { return nil, nil, fmt.Errorf("skillpack: reading SKILL.md: %w", err) } if !sawOpen || state != 2 { return nil, nil, fmt.Errorf("skillpack: SKILL.md frontmatter block is not terminated by a closing '---'") } return bytes.Join(frontLines, []byte("\n")), bytes.Join(bodyLines, []byte("\n")), nil } // stringList decodes either a YAML sequence or a comma-separated scalar into a // []string, so `allowed-tools: [Read, Bash]` and `allowed-tools: "Read, Bash"` // both work. type stringList []string func (l *stringList) UnmarshalYAML(node *yaml.Node) error { var seq []string if err := node.Decode(&seq); err == nil { *l = trimAll(seq) return nil } var scalar string if err := node.Decode(&scalar); err != nil { return err } *l = trimAll(strings.Split(scalar, ",")) return nil } func trimAll(in []string) []string { out := in[:0] for _, s := range in { if t := strings.TrimSpace(s); t != "" { out = append(out, t) } } return out } // isKebab reports whether s is strict lowercase kebab-case: [a-z0-9] segments // joined by single hyphens, with no leading, trailing, or consecutive hyphens. func isKebab(s string) bool { if s == "" || s[0] == '-' || s[len(s)-1] == '-' { return false } prevHyphen := false for _, r := range s { switch { case r >= 'a' && r <= 'z', r >= '0' && r <= '9': prevHyphen = false case r == '-': if prevHyphen { return false } prevHyphen = true default: return false } } return true }