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,133 @@
|
||||
package skillpack
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Source is where a pack's files come from. Fetch retrieves the tree at ref and
|
||||
// returns it together with the source's own resolved ref (a git commit SHA, or
|
||||
// the content digest for a plain directory) — provenance a host can show and
|
||||
// pin against. ref semantics are source-specific and may be empty ("the
|
||||
// default": a dir's current contents, a repo's default branch).
|
||||
type Source interface {
|
||||
Fetch(ctx context.Context, ref string) (Tree, string, error)
|
||||
// Kind is a short stable tag ("dir", "git") for persistence + display.
|
||||
Kind() string
|
||||
// String is a human-readable identifier (path or URL[/subpath]).
|
||||
String() string
|
||||
}
|
||||
|
||||
// DirSource reads a pack from a local directory. ref is ignored (a directory
|
||||
// has no versions); the resolved ref is the content digest. Useful for
|
||||
// first-party/builtin packs shipped on disk and for tests.
|
||||
type DirSource struct {
|
||||
Path string
|
||||
}
|
||||
|
||||
func (d DirSource) Kind() string { return "dir" }
|
||||
func (d DirSource) String() string { return d.Path }
|
||||
|
||||
func (d DirSource) Fetch(_ context.Context, _ string) (Tree, string, error) {
|
||||
info, err := os.Stat(d.Path)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("skillpack: dir source %q: %w", d.Path, err)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return nil, "", fmt.Errorf("skillpack: dir source %q is not a directory", d.Path)
|
||||
}
|
||||
t, err := readTree(os.DirFS(d.Path))
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return t, t.Digest(), nil
|
||||
}
|
||||
|
||||
// GitSource fetches a pack from a git repository, optionally from a Subpath
|
||||
// within it (for repos that publish several packs). ref is any git commit-ish
|
||||
// (branch, tag, or SHA); empty means the default branch. The resolved ref is
|
||||
// the checked-out commit SHA.
|
||||
//
|
||||
// Fetch clones into a temp dir, reads the subpath tree into memory, and removes
|
||||
// the clone before returning — the returned Tree is self-contained, so there is
|
||||
// no clone lifetime to manage and nothing left on disk. Git runs via the system
|
||||
// `git`; GitRunner is overridable for tests.
|
||||
type GitSource struct {
|
||||
URL string
|
||||
Subpath string
|
||||
// GitRunner runs a git command in dir and returns combined output. Nil uses
|
||||
// the system git.
|
||||
GitRunner func(ctx context.Context, dir string, args ...string) ([]byte, error)
|
||||
}
|
||||
|
||||
func (g GitSource) Kind() string { return "git" }
|
||||
|
||||
func (g GitSource) String() string {
|
||||
if g.Subpath != "" {
|
||||
return g.URL + "//" + g.Subpath
|
||||
}
|
||||
return g.URL
|
||||
}
|
||||
|
||||
func (g GitSource) run(ctx context.Context, dir string, args ...string) ([]byte, error) {
|
||||
if g.GitRunner != nil {
|
||||
return g.GitRunner(ctx, dir, args...)
|
||||
}
|
||||
cmd := exec.CommandContext(ctx, "git", args...)
|
||||
cmd.Dir = dir
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return out, fmt.Errorf("skillpack: git %s: %w: %s", strings.Join(args, " "), err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (g GitSource) Fetch(ctx context.Context, ref string) (Tree, string, error) {
|
||||
tmp, err := os.MkdirTemp("", "skillpack-git-*")
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
defer os.RemoveAll(tmp)
|
||||
|
||||
if _, err := g.run(ctx, "", "clone", "--quiet", g.URL, tmp); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if ref != "" {
|
||||
if _, err := g.run(ctx, tmp, "checkout", "--quiet", ref); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
}
|
||||
shaOut, err := g.run(ctx, tmp, "rev-parse", "HEAD")
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
sha := strings.TrimSpace(string(shaOut))
|
||||
|
||||
root := tmp
|
||||
if g.Subpath != "" {
|
||||
clean := path.Clean("/" + g.Subpath) // normalize, strip leading ../
|
||||
root = filepath.Join(tmp, filepath.FromSlash(strings.TrimPrefix(clean, "/")))
|
||||
if !within(tmp, root) {
|
||||
return nil, "", fmt.Errorf("skillpack: subpath %q escapes the repo", g.Subpath)
|
||||
}
|
||||
if info, err := os.Stat(root); err != nil || !info.IsDir() {
|
||||
return nil, "", fmt.Errorf("skillpack: subpath %q not found in %s", g.Subpath, g.URL)
|
||||
}
|
||||
}
|
||||
t, err := readTree(os.DirFS(root))
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
// Drop a nested .git if the subpath was the repo root.
|
||||
for p := range t {
|
||||
if p == ".git" || strings.HasPrefix(p, ".git/") {
|
||||
delete(t, p)
|
||||
}
|
||||
}
|
||||
return t, sha, nil
|
||||
}
|
||||
Reference in New Issue
Block a user