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>
134 lines
4.1 KiB
Go
134 lines
4.1 KiB
Go
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
|
|
}
|