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,124 @@
|
||||
package skillpack
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func mustPack(t *testing.T, name, body string, extra map[string]string) *Pack {
|
||||
t.Helper()
|
||||
tr := packTree(name, body)
|
||||
for k, v := range extra {
|
||||
tr[k] = []byte(v)
|
||||
}
|
||||
p, err := LoadPack(tr)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func TestCatalog(t *testing.T) {
|
||||
packs := []*Pack{
|
||||
mustPack(t, "zebra", "z", nil),
|
||||
mustPack(t, "alpha", "a", nil),
|
||||
}
|
||||
cat := Catalog(packs)
|
||||
if !strings.Contains(cat, "skill_use") {
|
||||
t.Error("catalog should tell the model how to load a skill")
|
||||
}
|
||||
ai := strings.Index(cat, "alpha")
|
||||
zi := strings.Index(cat, "zebra")
|
||||
if ai < 0 || zi < 0 || ai > zi {
|
||||
t.Errorf("catalog should list packs sorted by name:\n%s", cat)
|
||||
}
|
||||
if Catalog(nil) != "" {
|
||||
t.Error("empty catalog should be empty string")
|
||||
}
|
||||
}
|
||||
|
||||
func TestActivate_SkillUseTool(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
packs := []*Pack{
|
||||
mustPack(t, "pdf", "Use pdfplumber.", map[string]string{"scripts/x.py": "print()"}),
|
||||
}
|
||||
sk := Activate(packs, "/stage")
|
||||
if sk == nil {
|
||||
t.Fatal("expected a non-nil skill")
|
||||
}
|
||||
if sk.Instructions() != Catalog(packs) {
|
||||
t.Error("skill instructions should be the catalog")
|
||||
}
|
||||
tb := sk.Tools()
|
||||
tool, ok := tb.Get("skill_use")
|
||||
if !ok {
|
||||
t.Fatal("skill_use tool missing from toolbox")
|
||||
}
|
||||
|
||||
// load an existing pack
|
||||
out, err := tool.Handler(ctx, json.RawMessage(`{"name":"pdf"}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, _ := out.(string)
|
||||
if !strings.Contains(body, "Use pdfplumber.") {
|
||||
t.Errorf("skill_use body missing instructions: %q", body)
|
||||
}
|
||||
if !strings.Contains(body, "scripts/x.py") || !strings.Contains(body, "/stage/pdf") {
|
||||
t.Errorf("skill_use should list bundled files under the staged dir: %q", body)
|
||||
}
|
||||
|
||||
// unknown pack returns guidance, not an error
|
||||
out, err = tool.Handler(ctx, json.RawMessage(`{"name":"nope"}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s, _ := out.(string); !strings.Contains(s, "No skill named") {
|
||||
t.Errorf("unknown skill should return guidance: %q", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActivate_Empty(t *testing.T) {
|
||||
if Activate(nil, "") != nil {
|
||||
t.Error("no packs should activate to a nil skill")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveFromCache(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
cache := NewMemoryPackCache()
|
||||
p := mustPack(t, "alpha", "a", nil)
|
||||
cache.Put(ctx, p.Digest, p.Tree)
|
||||
|
||||
subs := []Subscription{
|
||||
{Name: "alpha", PinnedDigest: p.Digest, Enabled: true},
|
||||
{Name: "disabled", PinnedDigest: p.Digest, Enabled: false},
|
||||
}
|
||||
packs, err := Resolve(ctx, cache, subs)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(packs) != 1 || packs[0].Manifest.Name != "alpha" {
|
||||
t.Fatalf("resolve should skip disabled subs; got %d packs", len(packs))
|
||||
}
|
||||
|
||||
// missing from cache is an error
|
||||
subs = []Subscription{{Name: "ghost", PinnedDigest: "deadbeef", Enabled: true}}
|
||||
if _, err := Resolve(ctx, cache, subs); err == nil {
|
||||
t.Fatal("expected error resolving an uncached pin")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStage(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := mustPack(t, "pdf", "b", map[string]string{"scripts/x.py": "print()"})
|
||||
staged, err := Stage(p, dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.HasSuffix(staged, "/pdf") {
|
||||
t.Errorf("staged dir = %q", staged)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user