// 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// 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 }