A flat .md under agents/ or commands/ whose frontmatter contains none of the definition keys (name, description, mode, agent, model, argument-hint, temperature, permission) is documentation, not an item: discovery marks it Skipped with a recorded warning — it never enters a selection, inventory, availability listing or collision index, but the warning lands in the lockfile and add/update/select reports like other discovery warnings. Skills keep their folder+SKILL.md marker, MCP keeps the .yaml marker. README gains the canonical layout table and the migration note for folder-style agent repos. Closes beads: agent-lib-2n7
285 lines
7.2 KiB
Go
285 lines
7.2 KiB
Go
// 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"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
|
|
"github.com/m3tam3re/agent-lib/internal/deploy"
|
|
"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
|
|
Renames map[string]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
|
|
}
|
|
cfg := discoveryFromTreeRoot(root)
|
|
|
|
repo, err := gitsource.Clone(normalized, opts.Ref)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
tree, err := repo.Tree()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
if err := checkCollisions(workDir, lf, opts.Name, selected, opts.Renames); err != nil {
|
|
return err
|
|
}
|
|
|
|
src := &lockfile.Source{
|
|
URL: normalized,
|
|
Ref: repo.Ref,
|
|
Rev: repo.Rev,
|
|
Selection: sel,
|
|
Renames: map[string]string{},
|
|
}
|
|
if len(opts.Renames) > 0 {
|
|
src.Renames = opts.Renames
|
|
}
|
|
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
|
|
}
|
|
|
|
// discoveryFromTreeRoot maps a pasted tree sub-path onto discovery config.
|
|
// A sub-path ending in a standard type directory (e.g. ".../tree/main/skills")
|
|
// selects that directory as the type root; anything else becomes the
|
|
// discovery root under which the standard layout is expected.
|
|
func discoveryFromTreeRoot(root string) lockfile.Discovery {
|
|
if root == "" {
|
|
return lockfile.Discovery{}
|
|
}
|
|
parent, base := path.Split(strings.TrimSuffix(root, "/"))
|
|
parent = strings.TrimSuffix(parent, "/")
|
|
d := lockfile.Discovery{Root: parent}
|
|
switch base {
|
|
case lockfile.TypeSkill:
|
|
d.SkillsDir = base
|
|
case lockfile.TypeCommand:
|
|
d.CommandsDir = base
|
|
case lockfile.TypeAgent:
|
|
d.AgentsDir = base
|
|
case lockfile.TypeMcp:
|
|
d.McpDir = base
|
|
default:
|
|
return lockfile.Discovery{Root: root}
|
|
}
|
|
if d == (lockfile.Discovery{}) {
|
|
return lockfile.Discovery{}
|
|
}
|
|
return d
|
|
}
|
|
|
|
func checkIncludeEntries(items []discovery.Item, include []string, name string) error {
|
|
available := map[string]bool{}
|
|
for _, it := range items {
|
|
if !it.Skipped {
|
|
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 {
|
|
if it.Skipped {
|
|
continue
|
|
}
|
|
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 = deploy.Dir(tree, it.RelPath, dst)
|
|
default:
|
|
dst = filepath.Join(base, dep+filepath.Ext(it.RelPath))
|
|
err = deploy.File(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 reportAdd(w io.Writer, name string, src *lockfile.Source, discovered, selected []discovery.Item) {
|
|
real := 0
|
|
for _, it := range discovered {
|
|
if !it.Skipped {
|
|
real++
|
|
}
|
|
}
|
|
counts := func(items []discovery.Item) string {
|
|
per := map[string]int{}
|
|
for _, it := range items {
|
|
if !it.Skipped {
|
|
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", real, 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
|
|
}
|