feat: vendor add end-to-end with deterministic lockfile v2

- go-git in-process clone, default-branch/tag resolution, optional token auth
- web-tree URL normalization (github/gitlab) with implied discovery root
- discovery of skills/commands/agents/mcp, lenient frontmatter with warnings
- selection: all by default, --include with missing-entry hard errors
- external/<source>/<type>/ materialization preserving exec bits
- lockfile v2: deterministic JSON, pinned url/ref/rev, inventory, warnings
- offline black-box e2e suite against local fixture git repositories
This commit is contained in:
2026-08-22 21:52:30 +02:00
parent 2a6498dae1
commit 2c7200380b
16 changed files with 1648 additions and 2 deletions
+7 -1
View File
@@ -5,6 +5,7 @@ package cli
import (
"fmt"
"io"
"os"
"runtime"
"github.com/spf13/cobra"
@@ -20,6 +21,7 @@ func NewRootCmd() *cobra.Command {
SilenceErrors: true,
}
root.AddCommand(newVersionCmd())
root.AddCommand(newVendorCmd())
return root
}
@@ -40,5 +42,9 @@ func printVersion(w io.Writer) error {
}
func Execute() error {
return NewRootCmd().Execute()
if err := NewRootCmd().Execute(); err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return err
}
return nil
}
+60
View File
@@ -0,0 +1,60 @@
package cli
import (
"fmt"
"strings"
"github.com/spf13/cobra"
"github.com/m3tam3re/agent-lib/internal/vendor"
)
func newVendorCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "vendor",
Short: "Curate external sources into this work repository",
}
cmd.AddCommand(newVendorAddCmd())
return cmd
}
func newVendorAddCmd() *cobra.Command {
var ref string
var include []string
cmd := &cobra.Command{
Use: "add <name> <url>",
Short: "Vendor a new external git source",
Long: "Clones the source (in-process, via go-git), discovers skills, commands,\n" +
"agents and MCP fragments, materializes the selection under external/<name>/\n" +
"and pins URL, ref, revision, selection and renames in the lockfile.\n" +
"Default selection is everything; --include switches to an explicit list.",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
opts := vendor.AddOptions{
Name: args[0],
URL: args[1],
Ref: ref,
Include: splitList(include),
}
if err := vendor.Add(".", opts, cmd.OutOrStdout()); err != nil {
return fmt.Errorf("vendor add: %w", err)
}
return nil
},
}
cmd.Flags().StringVar(&ref, "ref", "", "branch or tag to track (default: the source's default branch)")
cmd.Flags().StringSliceVar(&include, "include", nil, "comma-separated item ids to vendor (switches selection to include mode)")
return cmd
}
func splitList(in []string) []string {
var out []string
for _, s := range in {
for _, part := range strings.Split(s, ",") {
if t := strings.TrimSpace(part); t != "" {
out = append(out, t)
}
}
}
return out
}
+171
View File
@@ -0,0 +1,171 @@
package discovery
import (
"fmt"
"sort"
"strings"
"github.com/m3tam3re/agent-lib/internal/lockfile"
)
// Tree abstracts a readable content tree: a git commit tree or a filesystem
// root. Paths are slash-separated and relative to the tree root.
type Tree interface {
Walk(fn func(rel string, isDir bool) error) error
ReadFile(rel string) ([]byte, error)
}
// Config directs Scan; zero values select the standard layout.
type Config struct {
Root string
SkillsDir string
CommandsDir string
AgentsDir string
McpDir string
}
func FromLockfileDiscovery(d lockfile.Discovery) Config {
r := d.Resolve()
return Config{Root: r.Root, SkillsDir: r.SkillsDir, CommandsDir: r.CommandsDir, AgentsDir: r.AgentsDir, McpDir: r.McpDir}
}
// Item is one discovered deployable artifact.
type Item struct {
Type string
UpstreamID string
RelPath string
Frontmatter *Frontmatter
Warnings []string
}
// Scan discovers items of all four content types under cfg. Discovery is a
// read-only scan; it never evaluates content beyond reading files.
func Scan(tree Tree, cfg Config) ([]Item, error) {
dirs := lockfile.Discovery{
Root: cfg.Root, SkillsDir: cfg.SkillsDir, CommandsDir: cfg.CommandsDir,
AgentsDir: cfg.AgentsDir, McpDir: cfg.McpDir,
}.Resolve()
skillRoot := join(dirs.Root, dirs.SkillsDir)
commandRoot := join(dirs.Root, dirs.CommandsDir)
agentRoot := join(dirs.Root, dirs.AgentsDir)
mcpRoot := join(dirs.Root, dirs.McpDir)
dirEntries := map[string]map[string]bool{}
fileEntries := map[string]map[string]bool{}
for _, d := range []string{skillRoot, commandRoot, agentRoot, mcpRoot} {
dirEntries[d] = map[string]bool{}
fileEntries[d] = map[string]bool{}
}
err := tree.Walk(func(rel string, isDir bool) error {
parent, base := splitPath(rel)
if isDir {
if set, ok := dirEntries[parent]; ok {
set[base] = true
}
return nil
}
if set, ok := fileEntries[parent]; ok {
set[base] = true
}
return nil
})
if err != nil {
return nil, fmt.Errorf("walking source tree: %w", err)
}
var items []Item
for name := range dirEntries[skillRoot] {
skillMd := join(skillRoot, name, "SKILL.md")
data, err := tree.ReadFile(skillMd)
if err != nil {
continue
}
it := Item{Type: lockfile.TypeSkill, UpstreamID: name, RelPath: join(skillRoot, name)}
fm, warnings := ParseFrontmatter(data)
it.Frontmatter = &fm
for _, w := range warnings {
it.Warnings = append(it.Warnings, fmt.Sprintf("skills/%s: %s", name, w))
}
items = append(items, it)
}
items = append(items, scanFlatFiles(tree, fileEntries, commandRoot, lockfile.TypeCommand)...)
items = append(items, scanFlatFiles(tree, fileEntries, agentRoot, lockfile.TypeAgent)...)
items = append(items, scanMcpFiles(tree, fileEntries, mcpRoot)...)
sort.Slice(items, func(i, j int) bool {
if items[i].Type != items[j].Type {
return typeRank(items[i].Type) < typeRank(items[j].Type)
}
return items[i].UpstreamID < items[j].UpstreamID
})
return items, nil
}
func scanFlatFiles(tree Tree, fileEntries map[string]map[string]bool, root, typ string) []Item {
var items []Item
for base := range fileEntries[root] {
if !strings.HasSuffix(base, ".md") {
continue
}
id := strings.TrimSuffix(base, ".md")
rel := join(root, base)
data, err := tree.ReadFile(rel)
if err != nil {
continue
}
it := Item{Type: typ, UpstreamID: id, RelPath: rel}
fm, warnings := ParseFrontmatter(data)
it.Frontmatter = &fm
for _, w := range warnings {
it.Warnings = append(it.Warnings, fmt.Sprintf("%s/%s: %s", typ, id, w))
}
items = append(items, it)
}
return items
}
func scanMcpFiles(tree Tree, fileEntries map[string]map[string]bool, root string) []Item {
var items []Item
for base := range fileEntries[root] {
var id string
switch {
case strings.HasSuffix(base, ".yaml"):
id = strings.TrimSuffix(base, ".yaml")
case strings.HasSuffix(base, ".yml"):
id = strings.TrimSuffix(base, ".yml")
default:
continue
}
items = append(items, Item{Type: lockfile.TypeMcp, UpstreamID: id, RelPath: join(root, base)})
}
return items
}
func typeRank(t string) int {
for i, tt := range lockfile.Types {
if tt == t {
return i
}
}
return len(lockfile.Types)
}
func join(parts ...string) string {
var nonEmpty []string
for _, p := range parts {
if p != "" {
nonEmpty = append(nonEmpty, p)
}
}
return strings.Join(nonEmpty, "/")
}
func splitPath(rel string) (parent, base string) {
i := strings.LastIndex(rel, "/")
if i < 0 {
return "", rel
}
return rel[:i], rel[i+1:]
}
+73
View File
@@ -0,0 +1,73 @@
package discovery
import (
"strings"
)
// Frontmatter holds the leniently parsed leading YAML block of an item.
type Frontmatter struct {
Name string
Description string
Tags []string
Requires []string
}
// ParseFrontmatter extracts name, description, tags and requires from a
// leading `---` fenced block. Parsing is deliberately lenient: a block that
// opens but never closes, or contains lines without a colon, yields warnings
// instead of failures; the parseable prefix still wins. Files without a
// leading fence have no frontmatter and no warning.
func ParseFrontmatter(data []byte) (Frontmatter, []string) {
var fm Frontmatter
var warnings []string
lines := strings.Split(strings.ReplaceAll(string(data), "\r\n", "\n"), "\n")
if len(lines) == 0 || strings.TrimSpace(lines[0]) != "---" {
return fm, nil
}
closed := false
for _, line := range lines[1:] {
if strings.TrimSpace(line) == "---" {
closed = true
break
}
trimmed := strings.TrimSpace(line)
if trimmed == "" {
continue
}
key, value, ok := strings.Cut(trimmed, ":")
if !ok {
warnings = append(warnings, "malformed frontmatter line (missing colon): "+trimmed)
continue
}
key = strings.TrimSpace(key)
value = strings.TrimSpace(value)
switch strings.ToLower(key) {
case "name":
fm.Name = value
case "description":
fm.Description = value
case "tags":
fm.Tags = splitList(value)
case "requires":
fm.Requires = splitList(value)
}
}
if !closed {
warnings = append(warnings, "frontmatter block not closed")
}
return fm, warnings
}
func splitList(v string) []string {
if v == "" {
return nil
}
parts := strings.Split(v, ",")
var out []string
for _, p := range parts {
if t := strings.TrimSpace(p); t != "" {
out = append(out, t)
}
}
return out
}
+54
View File
@@ -0,0 +1,54 @@
package discovery
import "testing"
func TestParseFrontmatter(t *testing.T) {
cases := []struct {
name string
in string
want Frontmatter
warnLen int
}{
{
name: "complete block",
in: "---\nname: Test Skill\ndescription: does things\ntags: a, b\nrequires: c,d\n---\nbody\n",
want: Frontmatter{Name: "Test Skill", Description: "does things", Tags: []string{"a", "b"}, Requires: []string{"c", "d"}},
},
{
name: "no frontmatter",
in: "# Just a file\nbody\n",
want: Frontmatter{},
},
{
name: "unclosed block",
in: "---\nname: Broken\ndescription: never closed\n",
want: Frontmatter{Name: "Broken", Description: "never closed"},
warnLen: 1,
},
{
name: "line missing colon",
in: "---\nname: X\nbroken line without colon\n---\n",
want: Frontmatter{Name: "X"},
warnLen: 1,
},
{
name: "crlf line endings",
in: "---\r\nname: Win\r\ndescription: windows author\r\n---\r\n",
want: Frontmatter{Name: "Win", Description: "windows author"},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
fm, warnings := ParseFrontmatter([]byte(tc.in))
if fm.Name != tc.want.Name || fm.Description != tc.want.Description {
t.Errorf("got %+v, want %+v", fm, tc.want)
}
if len(fm.Tags) != len(tc.want.Tags) || len(fm.Requires) != len(tc.want.Requires) {
t.Errorf("tags/requires mismatch: got %+v want %+v", fm, tc.want)
}
if len(warnings) != tc.warnLen {
t.Errorf("warnings = %v (len %d), want len %d", warnings, len(warnings), tc.warnLen)
}
})
}
}
+55
View File
@@ -0,0 +1,55 @@
package discovery
import (
"io/fs"
"os"
"path/filepath"
"sort"
"strings"
)
// FsTree adapts a filesystem root to the Tree interface (used for scanning
// the work repository's own items).
type FsTree struct {
Root string
}
func (f *FsTree) Walk(fn func(rel string, isDir bool) error) error {
var paths []string
err := filepath.WalkDir(f.Root, func(p string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if p == f.Root {
return nil
}
paths = append(paths, p)
return nil
})
if err != nil {
return err
}
sort.Strings(paths)
for _, p := range paths {
rel, err := filepath.Rel(f.Root, p)
if err != nil {
return err
}
info, err := os.Stat(p)
if err != nil {
return err
}
if err := fn(filepath.ToSlash(rel), info.IsDir()); err != nil {
return err
}
}
return nil
}
func (f *FsTree) ReadFile(rel string) ([]byte, error) {
clean := filepath.FromSlash(rel)
if strings.Contains(clean, "..") {
return nil, os.ErrNotExist
}
return os.ReadFile(filepath.Join(f.Root, clean))
}
+282
View File
@@ -0,0 +1,282 @@
// Package e2e runs the compiled agent-lib binary as a black box against
// local fixture git repositories — fully offline.
package e2e
import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
var binPath string
func TestMain(m *testing.M) {
dir, err := os.MkdirTemp("", "agent-lib-e2e-bin")
if err != nil {
os.Exit(1)
}
binPath = filepath.Join(dir, "agent-lib")
build := exec.Command("go", "build", "-o", binPath, ".")
build.Dir = "../.."
if out, err := build.CombinedOutput(); err != nil {
os.Stderr.Write(out)
os.Exit(1)
}
code := m.Run()
os.RemoveAll(dir)
os.Exit(code)
}
type harness struct {
t *testing.T
workDir string
upstreamDir string
}
func newHarness(t *testing.T) *harness {
t.Helper()
base := t.TempDir()
h := &harness{
t: t,
workDir: filepath.Join(base, "work"),
upstreamDir: filepath.Join(base, "upstream"),
}
for _, d := range []string{h.workDir, h.upstreamDir} {
if err := os.MkdirAll(d, 0o755); err != nil {
t.Fatal(err)
}
}
h.git(t, h.upstreamDir, "init", "-b", "main")
return h
}
func (h *harness) git(t *testing.T, dir string, args ...string) string {
t.Helper()
cmd := exec.Command("git", args...)
cmd.Dir = dir
cmd.Env = append(os.Environ(),
"GIT_AUTHOR_NAME=test", "GIT_AUTHOR_EMAIL=test@test",
"GIT_COMMITTER_NAME=test", "GIT_COMMITTER_EMAIL=test@test",
"GIT_AUTHOR_DATE=1700000000 +0000", "GIT_COMMITTER_DATE=1700000000 +0000",
)
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git %v in %s: %v\n%s", args, dir, err, out)
}
return string(out)
}
func (h *harness) write(t *testing.T, rel, content string) {
t.Helper()
path := filepath.Join(h.upstreamDir, rel)
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
func (h *harness) commitUpstream(t *testing.T, msg string) {
t.Helper()
h.git(t, h.upstreamDir, "add", "-A")
h.git(t, h.upstreamDir, "commit", "-m", msg)
}
func (h *harness) run(t *testing.T, args ...string) (string, error) {
t.Helper()
cmd := exec.Command(binPath, args...)
cmd.Dir = h.workDir
out, err := cmd.CombinedOutput()
return string(out), err
}
func (h *harness) mustRun(t *testing.T, args ...string) string {
t.Helper()
out, err := h.run(t, args...)
if err != nil {
t.Fatalf("agent-lib %v: %v\n%s", args, err, out)
}
return out
}
func standardFixture(h *harness, t *testing.T) {
h.write(t, "skills/good-skill/SKILL.md", "---\nname: Good Skill\ndescription: works\ntags: a, b\n---\n# Good\n")
h.write(t, "skills/good-skill/helper.py", "print('hi')\n")
h.write(t, "skills/broken-skill/SKILL.md", "---\nname: Broken\nthis line has no colon\n")
h.write(t, "commands/review.md", "---\nname: Review\ndescription: review things\n---\nbody\n")
h.write(t, "agents/scout.md", "---\nname: Scout\n---\nbody\n")
h.write(t, "mcp/search.yaml", "servers:\n search:\n command: search\n")
h.write(t, "README.md", "upstream readme\n")
h.commitUpstream(t, "fixture: initial content")
}
func TestVendorAddAllMode(t *testing.T) {
h := newHarness(t)
standardFixture(h, t)
out := h.mustRun(t, "vendor", "add", "superpowers", h.upstreamDir)
if !contains(out, "ref: main") {
t.Errorf("report missing resolved ref:\n%s", out)
}
if !contains(out, "4 skills") && !contains(out, "2 skills") {
t.Errorf("report should list skill count:\n%s", out)
}
if !contains(out, "warnings") {
t.Errorf("malformed frontmatter must produce a warning in the report:\n%s", out)
}
for _, p := range []string{
"external/superpowers/skills/good-skill/SKILL.md",
"external/superpowers/skills/good-skill/helper.py",
"external/superpowers/skills/broken-skill/SKILL.md",
"external/superpowers/commands/review.md",
"external/superpowers/agents/scout.md",
"external/superpowers/mcp/search.yaml",
} {
if _, err := os.Stat(filepath.Join(h.workDir, p)); err != nil {
t.Errorf("missing materialized file %s", p)
}
}
if _, err := os.Stat(filepath.Join(h.workDir, "external/superpowers/README.md")); err == nil {
t.Error("README must not be vendored")
}
lockBytes, err := os.ReadFile(filepath.Join(h.workDir, "agent-lib.lock.json"))
if err != nil {
t.Fatal(err)
}
lock := string(lockBytes)
if !contains(lock, `"version": 2`) {
t.Errorf("lockfile must be schema v2:\n%s", lock)
}
if !contains(lock, `"mode": "all"`) {
t.Errorf("default selection must be all:\n%s", lock)
}
if !contains(lock, h.upstreamDir) {
t.Errorf("lockfile must pin the url:\n%s", lock)
}
if !contains(lock, "frontmatter") {
t.Errorf("recorded warnings missing from lockfile:\n%s", lock)
}
}
func TestVendorAddDeterministicRerun(t *testing.T) {
h := newHarness(t)
standardFixture(h, t)
h.mustRun(t, "vendor", "add", "superpowers", h.upstreamDir)
secondWork := filepath.Join(t.TempDir(), "work2")
if err := os.MkdirAll(secondWork, 0o755); err != nil {
t.Fatal(err)
}
cmd := exec.Command(binPath, "vendor", "add", "superpowers", h.upstreamDir)
cmd.Dir = secondWork
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("second add: %v\n%s", err, out)
}
b1 := readFile(t, filepath.Join(h.workDir, "agent-lib.lock.json"))
b2 := readFile(t, filepath.Join(secondWork, "agent-lib.lock.json"))
if b1 != b2 {
t.Fatalf("same upstream commit must produce identical lockfiles:\n%s\n---\n%s", b1, b2)
}
}
func TestVendorAddIncludeMode(t *testing.T) {
h := newHarness(t)
standardFixture(h, t)
h.mustRun(t, "vendor", "add", "curated", h.upstreamDir, "--include", "good-skill,review")
if _, err := os.Stat(filepath.Join(h.workDir, "external/curated/skills/good-skill/SKILL.md")); err != nil {
t.Error("included skill must be materialized")
}
if _, err := os.Stat(filepath.Join(h.workDir, "external/curated/commands/review.md")); err != nil {
t.Error("included command must be materialized")
}
if _, err := os.Stat(filepath.Join(h.workDir, "external/curated/agents/scout.md")); err == nil {
t.Error("non-included agent must NOT be materialized")
}
lock := readFile(t, filepath.Join(h.workDir, "agent-lib.lock.json"))
if !contains(lock, `"mode": "include"`) {
t.Errorf("selection mode must be include:\n%s", lock)
}
if !contains(lock, `"include": [`) {
t.Errorf("include list missing:\n%s", lock)
}
}
func TestVendorAddIncludeTypoFailsClean(t *testing.T) {
h := newHarness(t)
standardFixture(h, t)
out, err := h.run(t, "vendor", "add", "curated", h.upstreamDir, "--include", "good-skill,nope")
if err == nil {
t.Fatalf("typo in include list must fail:\n%s", out)
}
if !contains(out, "nope") {
t.Errorf("error must name the missing entry:\n%s", out)
}
if _, err := os.Stat(filepath.Join(h.workDir, "agent-lib.lock.json")); err == nil {
t.Error("failed add must not write a lockfile")
}
if _, err := os.Stat(filepath.Join(h.workDir, "external")); err == nil {
t.Error("failed add must not create the external area")
}
}
func TestVendorAddDuplicateName(t *testing.T) {
h := newHarness(t)
standardFixture(h, t)
h.mustRun(t, "vendor", "add", "superpowers", h.upstreamDir)
out, err := h.run(t, "vendor", "add", "superpowers", h.upstreamDir)
if err == nil {
t.Fatalf("duplicate source name must fail:\n%s", out)
}
if !contains(out, "already exists") {
t.Errorf("error must be explicit about the duplicate:\n%s", out)
}
}
func TestVendorAddOrphanExternalDir(t *testing.T) {
h := newHarness(t)
standardFixture(h, t)
os.MkdirAll(filepath.Join(h.workDir, "external", "ghost"), 0o755)
out, err := h.run(t, "vendor", "add", "ghost", h.upstreamDir)
if err == nil {
t.Fatalf("orphan external dir must fail:\n%s", out)
}
if !contains(out, "not in the lockfile") {
t.Errorf("error must explain the orphan:\n%s", out)
}
}
func TestVendorAddAtRef(t *testing.T) {
h := newHarness(t)
standardFixture(h, t)
h.git(t, h.upstreamDir, "tag", "v1.0")
out := h.mustRun(t, "vendor", "add", "pinned", h.upstreamDir, "--ref", "v1.0")
if !contains(out, "ref: v1.0") {
t.Errorf("tag ref must be pinned:\n%s", out)
}
}
func contains(haystack, needle string) bool {
return strings.Contains(haystack, needle)
}
func readFile(t *testing.T, path string) string {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
return string(data)
}
+185
View File
@@ -0,0 +1,185 @@
// Package gitsource clones external git sources in-process via go-git and
// exposes their commit trees for discovery — no git subprocess at runtime.
package gitsource
import (
"fmt"
"io"
"os"
"sort"
"strings"
"github.com/go-git/go-billy/v5/memfs"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/filemode"
"github.com/go-git/go-git/v5/plumbing/object"
"github.com/go-git/go-git/v5/plumbing/transport/http"
"github.com/go-git/go-git/v5/storage/memory"
"github.com/m3tam3re/agent-lib/internal/discovery"
)
// Env names for optional basic auth on HTTPS sources (deploy/personal token).
const (
TokenEnv = "AGENT_LIB_GIT_TOKEN"
UserEnv = "AGENT_LIB_GIT_USER"
)
// Repo is a cloned source pinned to one resolved revision.
type Repo struct {
Repository *git.Repository
Rev string
Ref string
}
// Clone fetches url — at ref (branch or tag), otherwise the default branch —
// fully in-process and pins the resolved revision.
func Clone(url, ref string) (*Repo, error) {
repo, err := git.Clone(memory.NewStorage(), memfs.New(), cloneOptions(url, ref))
if err != nil && ref != "" {
repo, err = git.Clone(memory.NewStorage(), memfs.New(), tagOptions(url, ref))
}
if err != nil {
return nil, fmt.Errorf("cloning %s: %w", url, err)
}
return resolveHead(repo, ref)
}
func cloneOptions(url, ref string) *git.CloneOptions {
opts := &git.CloneOptions{URL: url}
if ref != "" {
opts.ReferenceName = plumbing.NewBranchReferenceName(ref)
}
applyAuth(url, opts)
return opts
}
func tagOptions(url, ref string) *git.CloneOptions {
opts := &git.CloneOptions{URL: url, ReferenceName: plumbing.NewTagReferenceName(ref)}
applyAuth(url, opts)
return opts
}
func applyAuth(url string, opts *git.CloneOptions) {
if !strings.HasPrefix(url, "https://") {
return
}
token := os.Getenv(TokenEnv)
if token == "" {
return
}
user := os.Getenv(UserEnv)
if user == "" {
user = "agent-lib"
}
opts.Auth = &http.BasicAuth{Username: user, Password: token}
}
func resolveHead(repo *git.Repository, requestedRef string) (*Repo, error) {
head, err := repo.Head()
if err != nil {
return nil, fmt.Errorf("resolving HEAD: %w", err)
}
r := &Repo{Repository: repo, Rev: head.Hash().String()}
switch {
case head.Name().IsBranch() || head.Name().IsTag():
r.Ref = head.Name().Short()
case requestedRef != "":
r.Ref = requestedRef
default:
r.Ref = head.Name().String()
}
return r, nil
}
// Tree returns the commit tree of the pinned revision as a discovery.Tree.
func (r *Repo) Tree() (discovery.Tree, error) {
commit, err := r.Repository.CommitObject(plumbing.NewHash(r.Rev))
if err != nil {
return nil, fmt.Errorf("resolving commit %s: %w", r.Rev, err)
}
tree, err := commit.Tree()
if err != nil {
return nil, fmt.Errorf("reading tree of %s: %w", r.Rev, err)
}
return &GitTree{tree: tree}, nil
}
// GitTree adapts a git commit tree to the discovery.Tree interface.
type GitTree struct {
tree *object.Tree
}
func (g *GitTree) Walk(fn func(rel string, isDir bool) error) error {
walker := object.NewTreeWalker(g.tree, true, nil)
defer walker.Close()
for {
name, entry, err := walker.Next()
if err == io.EOF {
return nil
}
if err != nil {
return err
}
if name == "." {
continue
}
if err := fn(name, entry.Mode == filemode.Dir); err != nil {
return err
}
}
}
func (g *GitTree) ReadFile(rel string) ([]byte, error) {
f, err := g.tree.File(rel)
if err != nil {
return nil, err
}
r, err := f.Blob.Reader()
if err != nil {
return nil, err
}
defer r.Close()
return io.ReadAll(r)
}
// FilesUnder lists every file (recursively) below prefix, sorted. A prefix of
// "" lists the whole tree.
func (g *GitTree) FilesUnder(prefix string) ([]string, error) {
var out []string
walker := object.NewTreeWalker(g.tree, true, nil)
defer walker.Close()
for {
name, entry, err := walker.Next()
if err == io.EOF {
break
}
if err != nil {
return nil, err
}
if name == "." || entry.Mode == filemode.Dir {
continue
}
if prefix == "" || strings.HasPrefix(name, strings.TrimSuffix(prefix, "/")+"/") {
out = append(out, name)
}
}
sort.Strings(out)
return out, nil
}
// MaterializeFile writes the blob at rel to dst, preserving the executable
// bit from the tree entry.
func (g *GitTree) MaterializeFile(rel, dst string) error {
data, err := g.ReadFile(rel)
if err != nil {
return fmt.Errorf("reading %s: %w", rel, err)
}
mode := os.FileMode(0o644)
entry, err := g.tree.FindEntry(rel)
if err == nil && entry.Mode == filemode.Executable {
mode = 0o755
}
return os.WriteFile(dst, data, mode)
}
+41
View File
@@ -0,0 +1,41 @@
package gitsource
import (
"fmt"
"regexp"
"strings"
)
var (
githubTreeRe = regexp.MustCompile(`^https://([^/]*github[^/]*)/([^/]+)/([^/]+)/tree/([^/]+)(/.*)?$`)
gitlabTreeRe = regexp.MustCompile(`^https://([^/]*gitlab[^/]*)/([^/]+)/([^/]+)/-/tree/([^/]+)(/.*)?$`)
)
// NormalizeURL converts pasted GitHub/GitLab web-tree URLs into plain git
// clone URLs and returns the implied discovery root ("" for the repo root).
// URLs that are already cloneable pass through unchanged.
func NormalizeURL(raw string) (url, root string, err error) {
s := strings.TrimSpace(raw)
if s == "" {
return "", "", fmt.Errorf("empty source URL")
}
if m := githubTreeRe.FindStringSubmatch(s); m != nil {
return fmt.Sprintf("https://%s/%s/%s.git", m[1], m[2], m[3]), strings.TrimPrefix(m[5], "/"), nil
}
if m := gitlabTreeRe.FindStringSubmatch(s); m != nil {
return fmt.Sprintf("https://%s/%s/%s.git", m[1], m[2], m[3]), strings.TrimPrefix(m[5], "/"), nil
}
if strings.HasSuffix(s, ".git") {
return s, "", nil
}
if m := regexp.MustCompile(`^https://([^/]*github[^/]*)/([^/]+)/([^/]+)$`).FindStringSubmatch(s); m != nil {
return fmt.Sprintf("https://%s/%s/%s.git", m[1], m[2], m[3]), "", nil
}
if m := regexp.MustCompile(`^https://([^/]*gitlab[^/]*)/([^/]+)/([^/]+)$`).FindStringSubmatch(s); m != nil {
return fmt.Sprintf("https://%s/%s/%s.git", m[1], m[2], m[3]), "", nil
}
if strings.HasPrefix(s, "file://") {
return strings.TrimPrefix(s, "file://"), "", nil
}
return s, "", nil
}
+35
View File
@@ -0,0 +1,35 @@
package gitsource
import "testing"
func TestNormalizeURL(t *testing.T) {
cases := []struct {
name, in, wantURL, wantRoot string
}{
{"github repo", "https://github.com/org/repo", "https://github.com/org/repo.git", ""},
{"github tree with subpath", "https://github.com/org/repo/tree/main/skills", "https://github.com/org/repo.git", "skills"},
{"github tree nested subpath", "https://github.com/org/repo/tree/main/content/skills", "https://github.com/org/repo.git", "content/skills"},
{"github tree no subpath", "https://github.com/org/repo/tree/main", "https://github.com/org/repo.git", ""},
{"gitlab tree with subpath", "https://gitlab.com/org/repo/-/tree/main/agents", "https://gitlab.com/org/repo.git", "agents"},
{"gitlab plain repo", "https://gitlab.com/org/repo", "https://gitlab.com/org/repo.git", ""},
{"already normalized github", "https://github.com/org/repo.git", "https://github.com/org/repo.git", ""},
{"ssh url untouched", "git@github.com:org/repo.git", "git@github.com:org/repo.git", ""},
{"https url untouched", "https://example.com/repo.git", "https://example.com/repo.git", ""},
{"local path untouched", "/tmp/fixture-upstream", "/tmp/fixture-upstream", ""},
{"file url stripped", "file:///tmp/fixture-upstream", "/tmp/fixture-upstream", ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
url, root, err := NormalizeURL(tc.in)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if url != tc.wantURL {
t.Errorf("url = %q, want %q", url, tc.wantURL)
}
if root != tc.wantRoot {
t.Errorf("root = %q, want %q", root, tc.wantRoot)
}
})
}
}
+148
View File
@@ -0,0 +1,148 @@
// Package lockfile defines agent-lib lockfile schema v2: the deterministic
// JSON document pinning vendored sources, their selection and renames.
package lockfile
import (
"encoding/json"
"errors"
"fmt"
"os"
"sort"
)
const (
// Version is the schema version of lockfile v2.
Version = 2
// FileName is the lockfile name inside the work repository root.
FileName = "agent-lib.lock.json"
)
// ErrNotExist reports that no lockfile exists yet.
var ErrNotExist = errors.New("no lockfile found")
// Content type names used across discovery, lockfile and the external area.
const (
TypeSkill = "skills"
TypeCommand = "commands"
TypeAgent = "agents"
TypeMcp = "mcp"
)
// Types lists all content types in canonical order.
var Types = []string{TypeSkill, TypeCommand, TypeAgent, TypeMcp}
// Discovery configures where items are found inside a source repository.
// Empty values select the standard layouts (skills/, commands/, agents/, mcp/
// relative to Root).
type Discovery struct {
Root string `json:"root,omitempty"`
SkillsDir string `json:"skills_dir,omitempty"`
CommandsDir string `json:"commands_dir,omitempty"`
AgentsDir string `json:"agents_dir,omitempty"`
McpDir string `json:"mcp_dir,omitempty"`
}
func (d Discovery) Resolve() Discovery {
r := d
if r.SkillsDir == "" {
r.SkillsDir = TypeSkill
}
if r.CommandsDir == "" {
r.CommandsDir = TypeCommand
}
if r.AgentsDir == "" {
r.AgentsDir = TypeAgent
}
if r.McpDir == "" {
r.McpDir = TypeMcp
}
return r
}
// SelectionMode is either "all" or "include".
type SelectionMode string
const (
ModeAll SelectionMode = "all"
ModeInclude SelectionMode = "include"
)
// Selection expresses what enters the work repository: everything minus
// Exclude (mode all), or exactly Include (mode include).
type Selection struct {
Mode SelectionMode `json:"mode"`
Exclude []string `json:"exclude,omitempty"`
Include []string `json:"include,omitempty"`
}
// Source pins one vendored git source.
type Source struct {
URL string `json:"url"`
Ref string `json:"ref"`
Rev string `json:"rev"`
Discovery *Discovery `json:"discovery,omitempty"`
Selection Selection `json:"selection"`
Renames map[string]string `json:"renames,omitempty"`
Inventory map[string][]string `json:"inventory,omitempty"`
Warnings []string `json:"warnings,omitempty"`
}
// Lockfile is the root document of schema v2.
type Lockfile struct {
Version int `json:"version"`
Sources map[string]*Source `json:"sources"`
}
// New returns an empty lockfile v2.
func New() *Lockfile {
return &Lockfile{Version: Version, Sources: map[string]*Source{}}
}
// Load reads the lockfile at path. A missing file yields ErrNotExist.
func Load(path string) (*Lockfile, error) {
data, err := os.ReadFile(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, ErrNotExist
}
return nil, err
}
var l Lockfile
if err := json.Unmarshal(data, &l); err != nil {
return nil, fmt.Errorf("lockfile %s is not valid JSON: %w", path, err)
}
if l.Version != Version {
return nil, fmt.Errorf("lockfile %s has schema version %d, want %d", path, l.Version, Version)
}
if l.Sources == nil {
l.Sources = map[string]*Source{}
}
return &l, nil
}
// Save canonically orders all slices and maps, then writes deterministic JSON.
func (l *Lockfile) Save(path string) error {
for _, src := range l.Sources {
sort.Strings(src.Selection.Exclude)
sort.Strings(src.Selection.Include)
sort.Strings(src.Warnings)
for _, items := range src.Inventory {
sort.Strings(items)
}
}
data, err := Marshal(l)
if err != nil {
return err
}
return os.WriteFile(path, data, 0o644)
}
// Marshal renders the lockfile as deterministic bytes: fixed field order via
// struct layout, sorted map keys, two-space indent, trailing newline.
func Marshal(l *Lockfile) ([]byte, error) {
data, err := json.MarshalIndent(l, "", " ")
if err != nil {
return nil, err
}
return append(data, '\n'), nil
}
+129
View File
@@ -0,0 +1,129 @@
package lockfile
import (
"os"
"path/filepath"
"testing"
)
func readFile(t *testing.T, path string) string {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
return string(data)
}
func TestMarshalDeterministic(t *testing.T) {
l1 := New()
l1.Sources["beta"] = &Source{
URL: "https://example.com/beta.git", Ref: "main", Rev: "cafe123",
Selection: Selection{Mode: ModeAll},
Renames: map[string]string{"zeta": "beta-zeta"},
Inventory: map[string][]string{TypeSkill: {"a", "zeta"}},
Warnings: []string{"a-warning", "b-warning"},
}
l1.Sources["alpha"] = &Source{
URL: "https://example.com/alpha.git", Ref: "v1.0", Rev: "abcd00",
Selection: Selection{Mode: ModeInclude, Include: []string{"a", "x"}},
}
l2 := New()
l2.Sources["alpha"] = l1.Sources["alpha"]
l2.Sources["beta"] = l1.Sources["beta"]
b1, err := Marshal(l1)
if err != nil {
t.Fatal(err)
}
b2, err := Marshal(l2)
if err != nil {
t.Fatal(err)
}
if string(b1) != string(b2) {
t.Fatalf("marshal not deterministic:\n%s\n--- vs ---\n%s", b1, b2)
}
if b1[len(b1)-1] != '\n' {
t.Error("missing trailing newline")
}
l1.Save(filepath.Join(t.TempDir(), FileName))
b3, err := Marshal(l1)
if err != nil {
t.Fatal(err)
}
if string(b1) != string(b3) {
t.Fatalf("save mutated already-canonical bytes:\n%s\n--- vs ---\n%s", b1, b3)
}
}
func TestSaveCanonicalizesUnsortedInput(t *testing.T) {
l := New()
l.Sources["s"] = &Source{
URL: "u", Ref: "r", Rev: "rev",
Selection: Selection{Mode: ModeInclude, Include: []string{"z", "a"}},
Warnings: []string{"w2", "w1"},
Inventory: map[string][]string{TypeSkill: {"z", "a"}},
}
path := filepath.Join(t.TempDir(), FileName)
if err := l.Save(path); err != nil {
t.Fatal(err)
}
got, err := Load(path)
if err != nil {
t.Fatal(err)
}
src := got.Sources["s"]
wantInclude := []string{"a", "z"}
if !slicesEqual(src.Selection.Include, wantInclude) {
t.Errorf("include = %v, want %v", src.Selection.Include, wantInclude)
}
wantWarnings := []string{"w1", "w2"}
if !slicesEqual(src.Warnings, wantWarnings) {
t.Errorf("warnings = %v, want %v", src.Warnings, wantWarnings)
}
wantInv := []string{"a", "z"}
if !slicesEqual(src.Inventory[TypeSkill], wantInv) {
t.Errorf("inventory = %v, want %v", src.Inventory[TypeSkill], wantInv)
}
}
func slicesEqual(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
func TestLoadRoundTrip(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, FileName)
l := New()
l.Sources["s"] = &Source{URL: "u", Ref: "r", Rev: "rev1", Selection: Selection{Mode: ModeAll}}
if err := l.Save(path); err != nil {
t.Fatal(err)
}
got, err := Load(path)
if err != nil {
t.Fatal(err)
}
if got.Version != Version || got.Sources["s"].Rev != "rev1" {
t.Fatalf("round trip lost data: %+v", got)
}
if _, err := Load(filepath.Join(dir, "missing.json")); err != ErrNotExist {
t.Fatalf("want ErrNotExist, got %v", err)
}
os.WriteFile(path, []byte(`{"version": 1, "sources": {}}`), 0o644)
if _, err := Load(path); err == nil {
t.Fatal("version 1 lockfile must be rejected")
}
}
+280
View File
@@ -0,0 +1,280 @@
// Package vendor implements the curator-side commands: add, update, diff,
// remove, list and inspect over the external area and lockfile v2.
package vendor
import (
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strings"
"github.com/m3tam3re/agent-lib/internal/discovery"
"github.com/m3tam3re/agent-lib/internal/gitsource"
"github.com/m3tam3re/agent-lib/internal/lockfile"
)
const externalDir = "external"
// AddOptions parameterize vendor add.
type AddOptions struct {
Name string
URL string
Ref string
Include []string
}
// Add vendors a new source into the work repository at workDir: it clones,
// discovers, applies the selection and materializes external/<name>/ plus the
// lockfile entry. Nothing is written before every check has passed.
func Add(workDir string, opts AddOptions, stdout io.Writer) error {
lockPath := filepath.Join(workDir, lockfile.FileName)
lf, err := lockfile.Load(lockPath)
if err != nil {
if err != lockfile.ErrNotExist {
return err
}
lf = lockfile.New()
}
if _, exists := lf.Sources[opts.Name]; exists {
return fmt.Errorf("source %q already exists in %s", opts.Name, lockfile.FileName)
}
extRoot := filepath.Join(workDir, externalDir, opts.Name)
if _, err := os.Stat(extRoot); err == nil {
return fmt.Errorf("%s exists but %q is not in the lockfile; remove the directory or pick another name", extRoot, opts.Name)
}
normalized, root, err := gitsource.NormalizeURL(opts.URL)
if err != nil {
return err
}
repo, err := gitsource.Clone(normalized, opts.Ref)
if err != nil {
return err
}
tree, err := repo.Tree()
if err != nil {
return err
}
cfg := lockfile.Discovery{Root: root}
items, err := discovery.Scan(tree, discovery.FromLockfileDiscovery(cfg))
if err != nil {
return err
}
sel := lockfile.Selection{Mode: lockfile.ModeAll}
if len(opts.Include) > 0 {
sel = lockfile.Selection{Mode: lockfile.ModeInclude, Include: opts.Include}
if err := checkIncludeEntries(items, opts.Include, opts.Name); err != nil {
return err
}
}
selected := applySelection(items, sel)
if len(selected) == 0 {
return fmt.Errorf("source %q selected zero items; refusing to add an empty source", opts.Name)
}
src := &lockfile.Source{
URL: normalized,
Ref: repo.Ref,
Rev: repo.Rev,
Selection: sel,
Renames: map[string]string{},
}
if cfg != (lockfile.Discovery{}) {
src.Discovery = &cfg
}
for _, it := range items {
src.Warnings = append(src.Warnings, it.Warnings...)
}
src.Warnings = dedupe(src.Warnings)
inventory, err := materialize(workDir, opts.Name, tree, selected, src.Renames)
if err != nil {
return err
}
src.Inventory = inventory
lf.Sources[opts.Name] = src
if err := lf.Save(lockPath); err != nil {
return err
}
reportAdd(stdout, opts.Name, src, items, selected)
return nil
}
func checkIncludeEntries(items []discovery.Item, include []string, name string) error {
available := map[string]bool{}
for _, it := range items {
available[it.UpstreamID] = true
}
for _, want := range include {
if !available[want] {
return fmt.Errorf("source %q: include entry %q not found upstream (available: %s)", name, want, strings.Join(sortedKeys(available), ", "))
}
}
return nil
}
func applySelection(items []discovery.Item, sel lockfile.Selection) []discovery.Item {
exclude := map[string]bool{}
for _, e := range sel.Exclude {
exclude[e] = true
}
include := map[string]bool{}
for _, i := range sel.Include {
include[i] = true
}
var out []discovery.Item
for _, it := range items {
switch sel.Mode {
case lockfile.ModeInclude:
if include[it.UpstreamID] {
out = append(out, it)
}
default:
if !exclude[it.UpstreamID] {
out = append(out, it)
}
}
}
return out
}
func deployedName(upstreamID string, renames map[string]string) string {
if d, ok := renames[upstreamID]; ok && d != "" {
return d
}
return upstreamID
}
func materialize(workDir, name string, tree discovery.Tree, selected []discovery.Item, renames map[string]string) (map[string][]string, error) {
inventory := map[string][]string{}
for _, it := range selected {
dep := deployedName(it.UpstreamID, renames)
base := filepath.Join(workDir, externalDir, name, it.Type)
var dst string
var err error
switch it.Type {
case lockfile.TypeSkill:
dst = filepath.Join(base, dep)
err = copyTreeDir(tree, it.RelPath, dst)
default:
dst = filepath.Join(base, dep+filepath.Ext(it.RelPath))
err = copyTreeFile(tree, it.RelPath, dst)
}
if err != nil {
return nil, fmt.Errorf("materializing %s/%s: %w", it.Type, it.UpstreamID, err)
}
inventory[it.Type] = append(inventory[it.Type], it.UpstreamID)
}
return inventory, nil
}
func copyTreeDir(tree discovery.Tree, srcRel, dst string) error {
lister, ok := tree.(interface {
FilesUnder(prefix string) ([]string, error)
})
if !ok {
return fmt.Errorf("tree does not support directory copy")
}
files, err := lister.FilesUnder(srcRel)
if err != nil {
return err
}
if len(files) == 0 {
return fmt.Errorf("no files under %s", srcRel)
}
prefix := strings.TrimSuffix(srcRel, "/") + "/"
for _, f := range files {
relInside := strings.TrimPrefix(f, prefix)
target := filepath.Join(dst, filepath.FromSlash(relInside))
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return err
}
if err := writeFileFromTree(tree, f, target); err != nil {
return err
}
}
return nil
}
func copyTreeFile(tree discovery.Tree, srcRel, dst string) error {
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return err
}
return writeFileFromTree(tree, srcRel, dst)
}
func writeFileFromTree(tree discovery.Tree, rel, dst string) error {
if mt, ok := tree.(interface {
MaterializeFile(rel, dst string) error
}); ok {
return mt.MaterializeFile(rel, dst)
}
data, err := tree.ReadFile(rel)
if err != nil {
return err
}
return os.WriteFile(dst, data, 0o644)
}
func reportAdd(w io.Writer, name string, src *lockfile.Source, discovered, selected []discovery.Item) {
counts := func(items []discovery.Item) string {
per := map[string]int{}
for _, it := range items {
per[it.Type]++
}
var parts []string
for _, t := range lockfile.Types {
if per[t] > 0 {
parts = append(parts, fmt.Sprintf("%d %s", per[t], t))
}
}
if len(parts) == 0 {
return "0 items"
}
return strings.Join(parts, ", ")
}
fmt.Fprintf(w, "source %s pinned\n", name)
fmt.Fprintf(w, " url: %s\n", src.URL)
fmt.Fprintf(w, " ref: %s\n", src.Ref)
fmt.Fprintf(w, " rev: %s\n", src.Rev)
fmt.Fprintf(w, "discovered %d items (%s)\n", len(discovered), counts(discovered))
fmt.Fprintf(w, "selected %d items (%s)\n", len(selected), counts(selected))
fmt.Fprintf(w, "materialized under %s/\n", filepath.Join(externalDir, name))
if len(src.Warnings) > 0 {
fmt.Fprintf(w, "warnings:\n")
for _, warn := range src.Warnings {
fmt.Fprintf(w, " - %s\n", warn)
}
}
fmt.Fprintf(w, "lockfile %s written\n", lockfile.FileName)
}
func dedupe(in []string) []string {
seen := map[string]bool{}
var out []string
for _, s := range in {
if !seen[s] {
seen[s] = true
out = append(out, s)
}
}
sort.Strings(out)
return out
}
func sortedKeys(m map[string]bool) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Strings(out)
return out
}