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,114 @@
|
||||
package skillpack
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Memory is a zero-dependency in-process Store — a light host or a test gets
|
||||
// subscription persistence with no DB. Returned values are copies, so callers
|
||||
// can mutate them without corrupting the store.
|
||||
type Memory struct {
|
||||
mu sync.RWMutex
|
||||
subs map[string]*Subscription // by ID
|
||||
}
|
||||
|
||||
// NewMemory returns an empty in-memory Store.
|
||||
func NewMemory() *Memory {
|
||||
return &Memory{subs: map[string]*Subscription{}}
|
||||
}
|
||||
|
||||
var _ Store = (*Memory)(nil)
|
||||
|
||||
func (m *Memory) Initialize(context.Context) error { return nil }
|
||||
|
||||
func (m *Memory) Save(_ context.Context, s *Subscription) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
cp := *s
|
||||
m.subs[s.ID] = &cp
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Memory) Get(_ context.Context, id string) (*Subscription, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
s, ok := m.subs[id]
|
||||
if !ok {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
cp := *s
|
||||
return &cp, nil
|
||||
}
|
||||
|
||||
func (m *Memory) GetByName(_ context.Context, name string) (*Subscription, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
for _, s := range m.subs {
|
||||
if s.Name == name {
|
||||
cp := *s
|
||||
return &cp, nil
|
||||
}
|
||||
}
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
|
||||
func (m *Memory) List(context.Context) ([]Subscription, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
out := make([]Subscription, 0, len(m.subs))
|
||||
for _, s := range m.subs {
|
||||
out = append(out, *s)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (m *Memory) Delete(_ context.Context, id string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
delete(m.subs, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
// MemoryPackCache is a zero-dependency in-process PackCache. Trees are copied on
|
||||
// the way in and out so a cached pin is immutable in practice.
|
||||
type MemoryPackCache struct {
|
||||
mu sync.RWMutex
|
||||
trees map[string]Tree
|
||||
}
|
||||
|
||||
// NewMemoryPackCache returns an empty in-memory PackCache.
|
||||
func NewMemoryPackCache() *MemoryPackCache {
|
||||
return &MemoryPackCache{trees: map[string]Tree{}}
|
||||
}
|
||||
|
||||
var _ PackCache = (*MemoryPackCache)(nil)
|
||||
|
||||
func (c *MemoryPackCache) Put(_ context.Context, digest string, t Tree) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.trees[digest] = cloneTree(t)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *MemoryPackCache) Get(_ context.Context, digest string) (Tree, error) {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
t, ok := c.trees[digest]
|
||||
if !ok {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return cloneTree(t), nil
|
||||
}
|
||||
|
||||
func cloneTree(t Tree) Tree {
|
||||
cp := make(Tree, len(t))
|
||||
for k, v := range t {
|
||||
b := make([]byte, len(v))
|
||||
copy(b, v)
|
||||
cp[k] = b
|
||||
}
|
||||
return cp
|
||||
}
|
||||
Reference in New Issue
Block a user