bf0b67f9af
New additive, nil-safe battery for subscribing to skill packages in the Anthropic agent-skills format (SKILL.md manifest + bundled files): - Manifest/ParseManifest: SKILL.md frontmatter+body parse & validation (name/description required, allowed-tools passthrough, kebab/length limits) - Tree/Pack/LoadPack: self-contained file set, order-independent content digest (the pin identity + change signal), bundled-file listing, traversal- safe staging - Source (DirSource, GitSource): Fetch returns tree + resolved ref; git clones to temp, reads subpath into memory, cleans up (self-contained tree) - Subscription + Store + content-addressed PackCache, with Memory defaults - Syncer: Subscribe pins; Check records a PENDING update but never moves the pin; Apply is the only re-pin (supply-chain guard — upstream can't silently change what an agent runs) - Activate: resolved packs -> majordomo agent.Skill (catalog instructions + one skill_use tool) for progressive disclosure; Stage materializes files Third distinct 'skill' concept, deliberately separate from executus/skill (saved-agent noun) and majordomo/skill (eager capability bundle). Mort-side wiring (convars, .skillpack commands, Agent.SkillPacks, allowed-tools shim) is a later, separate step. Full unit + hermetic local-git tests; gofmt/vet clean; race-tested. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
193 lines
6.2 KiB
Go
193 lines
6.2 KiB
Go
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) {
|
|
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
|
|
}
|
|
|
|
func isKebab(s string) bool {
|
|
for _, r := range s {
|
|
switch {
|
|
case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '-':
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
return s != ""
|
|
}
|