feat(skillpack): SKILL.md-subscription battery
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>
This commit is contained in:
@@ -0,0 +1,191 @@
|
||||
package skillpack
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Syncer ties a Store, a PackCache, and Sources together into the subscription
|
||||
// lifecycle: subscribe (initial pin), check (record a PENDING update, never move
|
||||
// the pin), and apply (the explicit re-pin). It owns the supply-chain invariant
|
||||
// — the only call that changes the bytes an agent runs is Apply, always with an
|
||||
// actor recorded.
|
||||
type Syncer struct {
|
||||
Store PackCache // content store for pinned trees
|
||||
Subs Store // subscription metadata store
|
||||
|
||||
// SourceFor builds the Source for a stored subscription. A host overrides
|
||||
// this to enforce its allow-list (reject a disallowed URL/kind before any
|
||||
// fetch). Nil uses DefaultSourceFor (dir + git, no allow-list).
|
||||
SourceFor func(*Subscription) (Source, error)
|
||||
|
||||
// Now/NewID are injectable for deterministic tests.
|
||||
Now func() time.Time
|
||||
NewID func() string
|
||||
}
|
||||
|
||||
func (y *Syncer) now() time.Time {
|
||||
if y.Now != nil {
|
||||
return y.Now()
|
||||
}
|
||||
return time.Now()
|
||||
}
|
||||
|
||||
func (y *Syncer) newID() string {
|
||||
if y.NewID != nil {
|
||||
return y.NewID()
|
||||
}
|
||||
return uuid.NewString()
|
||||
}
|
||||
|
||||
func (y *Syncer) sourceFor(s *Subscription) (Source, error) {
|
||||
if y.SourceFor != nil {
|
||||
return y.SourceFor(s)
|
||||
}
|
||||
return DefaultSourceFor(s)
|
||||
}
|
||||
|
||||
// DefaultSourceFor reconstructs a Source from a subscription's stored
|
||||
// coordinates, with no allow-list. A host that cares about which sources are
|
||||
// permitted should set Syncer.SourceFor instead of using this.
|
||||
func DefaultSourceFor(s *Subscription) (Source, error) {
|
||||
switch s.SourceKind {
|
||||
case "dir":
|
||||
return DirSource{Path: s.SourceURL}, nil
|
||||
case "git":
|
||||
return GitSource{URL: s.SourceURL, Subpath: s.Subpath}, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("skillpack: unknown source kind %q", s.SourceKind)
|
||||
}
|
||||
}
|
||||
|
||||
// fetchPack fetches src at ref, caches the resulting tree, and returns the
|
||||
// parsed pack plus the source's resolved ref.
|
||||
func (y *Syncer) fetchPack(ctx context.Context, src Source, ref string) (*Pack, string, error) {
|
||||
tree, sourceRef, err := src.Fetch(ctx, ref)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
pack, err := LoadPack(tree)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if err := y.Store.Put(ctx, pack.Digest, pack.Tree); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return pack, sourceRef, nil
|
||||
}
|
||||
|
||||
// Subscribe fetches a pack from src at trackRef, caches it, and persists a new
|
||||
// Subscription pinned to that exact content, attributed to by. It rejects a
|
||||
// second subscription to the same pack name.
|
||||
func (y *Syncer) Subscribe(ctx context.Context, src Source, trackRef, by string) (*Subscription, error) {
|
||||
pack, sourceRef, err := y.fetchPack(ctx, src, trackRef)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if existing, err := y.Subs.GetByName(ctx, pack.Manifest.Name); err == nil {
|
||||
return nil, fmt.Errorf("skillpack: already subscribed to %q (id %s)", pack.Manifest.Name, existing.ID)
|
||||
}
|
||||
|
||||
sub := &Subscription{
|
||||
ID: y.newID(),
|
||||
SourceKind: src.Kind(),
|
||||
SourceURL: src.String(),
|
||||
TrackRef: trackRef,
|
||||
Enabled: true,
|
||||
}
|
||||
// Store the raw URL/subpath (String() may combine them for display).
|
||||
if gs, ok := src.(GitSource); ok {
|
||||
sub.SourceURL = gs.URL
|
||||
sub.Subpath = gs.Subpath
|
||||
}
|
||||
sub.pinTo(pack, sourceRef, by, y.now())
|
||||
if err := y.Subs.Save(ctx, sub); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return sub, nil
|
||||
}
|
||||
|
||||
// Check fetches the subscription's tracked ref and, if the content digest
|
||||
// differs from the current pin, caches the new tree and records it as PENDING —
|
||||
// it never moves the pin. If the tracked ref matches the pin, any stale pending
|
||||
// state is cleared. The updated subscription is saved and returned.
|
||||
func (y *Syncer) Check(ctx context.Context, id string) (*Subscription, error) {
|
||||
sub, err := y.Subs.Get(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
src, err := y.sourceFor(sub)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pack, sourceRef, err := y.fetchPack(ctx, src, sub.TrackRef)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if pack.Digest == sub.PinnedDigest {
|
||||
// No change upstream; drop any previously-recorded pending update.
|
||||
sub.PendingDigest, sub.PendingSourceRef, sub.PendingAt = "", "", time.Time{}
|
||||
} else {
|
||||
sub.PendingDigest = pack.Digest
|
||||
sub.PendingSourceRef = sourceRef
|
||||
sub.PendingAt = y.now()
|
||||
}
|
||||
if err := y.Subs.Save(ctx, sub); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return sub, nil
|
||||
}
|
||||
|
||||
// CheckAll runs Check on every subscription and returns the ones that now have a
|
||||
// pending update. Errors on individual subscriptions are collected, not fatal —
|
||||
// one unreachable source shouldn't stop the sweep. A host calls this on its own
|
||||
// ticker (skillpack has no cron opinion; the update is never auto-applied so the
|
||||
// cadence only affects how fresh the "pending" signal is).
|
||||
func (y *Syncer) CheckAll(ctx context.Context) (pending []Subscription, errs []error) {
|
||||
subs, err := y.Subs.List(ctx)
|
||||
if err != nil {
|
||||
return nil, []error{err}
|
||||
}
|
||||
for i := range subs {
|
||||
updated, err := y.Check(ctx, subs[i].ID)
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Errorf("skillpack: check %q: %w", subs[i].Name, err))
|
||||
continue
|
||||
}
|
||||
if updated.HasPending() {
|
||||
pending = append(pending, *updated)
|
||||
}
|
||||
}
|
||||
return pending, errs
|
||||
}
|
||||
|
||||
// Apply promotes a subscription's pending update to the active pin, attributed
|
||||
// to by. This is the ONLY call that changes what an agent runs. It errors if
|
||||
// there is no pending update or the pending tree is missing from the cache.
|
||||
func (y *Syncer) Apply(ctx context.Context, id, by string) (*Subscription, error) {
|
||||
sub, err := y.Subs.Get(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !sub.HasPending() {
|
||||
return nil, fmt.Errorf("skillpack: %q has no pending update to apply", sub.Name)
|
||||
}
|
||||
tree, err := y.Store.Get(ctx, sub.PendingDigest)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("skillpack: pending tree for %q missing from cache: %w", sub.Name, err)
|
||||
}
|
||||
pack, err := LoadPack(tree)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sub.pinTo(pack, sub.PendingSourceRef, by, y.now())
|
||||
if err := y.Subs.Save(ctx, sub); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return sub, nil
|
||||
}
|
||||
Reference in New Issue
Block a user