From 0c779bc970e61f7823157ea84d1b15e6b9cfd035 Mon Sep 17 00:00:00 2001 From: m3ta-chiron Date: Fri, 2 Oct 2026 12:35:39 +0200 Subject: [PATCH] feat(vendor): --exclude plus content-type selection (--types/--exclude-types) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit vendor add gains --exclude a,b,c (all mode minus those ids) as the mirror of --include, for taking the majority of a source while dropping a few items. Mutual exclusion with --include is a hard error, unknown ids fail naming the available ones (generalized checkIDEntries). vendor add and vendor select gain --types (allow-list) and --exclude-types (deny-list) over the four content types. The filter is stored as the optional, v2-compatible Selection.exclude_types field and survives vendor update: new upstream items of an excluded type never enter the work repository (unlike id expansion, which would silently re-admit them). --types/--exclude-types replace the filter on select, absent flags keep it, and --mode all clears it along with include/exclude. Also fixes materializeTree to create the staging directory for an empty selection — previously select-to-zero failed at the staging rename. --- README.md | 22 +++- internal/cli/vendor.go | 44 +++++--- internal/e2e/vendor_exclude_test.go | 75 +++++++++++++ internal/e2e/vendor_types_test.go | 162 ++++++++++++++++++++++++++++ internal/lockfile/lockfile.go | 13 ++- internal/vendor/add.go | 129 +++++++++++++++++++--- internal/vendor/select.go | 53 ++++++--- internal/vendor/update.go | 7 +- scripts/e2e-acceptance.sh | 78 ++++++++++++++ 9 files changed, 533 insertions(+), 50 deletions(-) create mode 100644 internal/e2e/vendor_exclude_test.go create mode 100644 internal/e2e/vendor_types_test.go diff --git a/README.md b/README.md index e9a7bcd..bcb1b9f 100644 --- a/README.md +++ b/README.md @@ -89,11 +89,23 @@ executed. - Default: vendor **everything** (`mode: all`). - `--include a,b,c` switches the source to **include mode**: only those ids arrive, and new upstream items do **not** arrive automatically. +- `--exclude a,b,c` keeps **all mode** minus those ids — for taking the + majority of a source while dropping a few items. +- `--types skills,commands` (**allow-list**) and `--exclude-types agents,mcp` + (**deny-list**) filter whole content types. The filter is stored as + `exclude_types` in the lockfile and **survives updates**: new upstream items + of an excluded type never enter the work repository. `--types` covering all + four types normalizes to no filter; excluding all four fails at the + zero-selection guard. - Hard errors, enforced by `vendor add`, `vendor update`, `vendor select` and `validate`: - `all` combined with an include list → error - an exclude list without `all` → error (not supported in v2 selections) - - an include entry that no longer exists upstream → error naming the item + - an include or exclude entry that no longer exists upstream → error naming the item + - `--include` combined with `--exclude` → error + - `--types` combined with `--exclude-types` → error + - unknown type names → error listing `skills, commands, agents, mcp` + - an include entry belonging to a type the type filter excludes → error (`vendor select` changes selections later — see [Changing your selection later](#changing-your-selection-later) below.) @@ -107,6 +119,7 @@ advancing the pinned revision (that is `vendor update`'s job): agent-lib vendor select superpowers --add tdd,brainstorm # include mode: grow agent-lib vendor select superpowers --remove review,debug # include mode: shrink agent-lib vendor select superpowers --remove review # all mode: move to exclude +agent-lib vendor select superpowers --types skills # replace the type filter agent-lib vendor select superpowers --mode all # reset: take everything again ``` @@ -116,8 +129,11 @@ agent-lib vendor select superpowers --mode all # reset: take everyth - **all mode**: `--remove` moves ids onto the exclude list (lockfile v2 supports exclude in all mode) and deletes their folders; `--add` is a hard error — everything is already selected. -- `--mode all` clears include/exclude and re-materializes the full pinned - upstream, reporting what appeared. +- `--types` / `--exclude-types` **replace** the content-type filter + (same semantics as on `vendor add`); when neither flag is passed, the + stored filter stays as-is. +- `--mode all` clears include/exclude **and the type filter** and + re-materializes the full pinned upstream, reporting what appeared. Every select re-runs the collision checks and honors the lockfile rename map — you can hand-add a rename for a not-yet-selected item, then `--add` it diff --git a/internal/cli/vendor.go b/internal/cli/vendor.go index e266eb2..e60e55b 100644 --- a/internal/cli/vendor.go +++ b/internal/cli/vendor.go @@ -26,7 +26,7 @@ func newVendorCmd() *cobra.Command { func newVendorAddCmd() *cobra.Command { var ref string - var include []string + var include, exclude, onlyTypes, excludeTypes []string var renames []string cmd := &cobra.Command{ Use: "add ", @@ -34,7 +34,10 @@ func newVendorAddCmd() *cobra.Command { Long: "Clones the source (in-process, via go-git), discovers skills, commands,\n" + "agents and MCP fragments, materializes the selection under external//\n" + "and pins URL, ref, revision, selection and renames in the lockfile.\n" + - "Default selection is everything; --include switches to an explicit list.\n" + + "Default selection is everything; --include switches to an explicit list,\n" + + "--exclude keeps everything except the listed ids. --types (allow-list) and\n" + + "--exclude-types (deny-list) filter whole content types and survive updates:\n" + + "new upstream items of an excluded type never enter the work repository.\n" + "Collisions abort with a hard error; resolve them with --rename upstream=deployed.", Args: cobra.ExactArgs(2), RunE: func(cmd *cobra.Command, args []string) error { @@ -43,11 +46,14 @@ func newVendorAddCmd() *cobra.Command { return err } opts := vendor.AddOptions{ - Name: args[0], - URL: args[1], - Ref: ref, - Include: splitList(include), - Renames: renameMap, + Name: args[0], + URL: args[1], + Ref: ref, + Include: splitList(include), + Exclude: splitList(exclude), + OnlyTypes: splitList(onlyTypes), + ExcludeTypes: splitList(excludeTypes), + Renames: renameMap, } if err := vendor.Add(".", opts, cmd.OutOrStdout()); err != nil { return fmt.Errorf("vendor add: %w", err) @@ -57,6 +63,9 @@ func newVendorAddCmd() *cobra.Command { } 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)") + cmd.Flags().StringSliceVar(&exclude, "exclude", nil, "comma-separated item ids to skip (keeps all mode, drops these ids)") + cmd.Flags().StringSliceVar(&onlyTypes, "types", nil, "comma-separated content types to take: skills, commands, agents, mcp (allow-list)") + cmd.Flags().StringSliceVar(&excludeTypes, "exclude-types", nil, "comma-separated content types to skip: skills, commands, agents, mcp (deny-list)") cmd.Flags().StringSliceVar(&renames, "rename", nil, "deployed-name override upstream-id=deployed-name (repeatable)") return cmd } @@ -75,6 +84,7 @@ func splitList(in []string) []string { func newVendorSelectCmd() *cobra.Command { var add, remove []string + var onlyTypes, excludeTypes []string var mode string cmd := &cobra.Command{ Use: "select ", @@ -83,9 +93,11 @@ func newVendorSelectCmd() *cobra.Command { "Include mode: --add appends ids (validated against the pinned upstream),\n" + "--remove drops ids and deletes their folders. All mode: --remove moves ids\n" + "onto the exclude list; --add is an error (everything is selected already).\n" + - "--mode all resets to everything: include/exclude lists are cleared and the\n" + - "full pinned upstream is re-materialized. Collisions re-run on every change;\n" + - "renames from the lockfile are honored. The revision never moves — that is\n" + + "--types/--exclude-types replace the content-type filter (skills, commands,\n" + + "agents, mcp); absent flags keep it as-is. --mode all resets to everything:\n" + + "include/exclude lists and the type filter are cleared and the full pinned\n" + + "upstream is re-materialized. Collisions re-run on every change; renames\n" + + "from the lockfile are honored. The revision never moves — that is\n" + "vendor update's job.", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { @@ -93,9 +105,11 @@ func newVendorSelectCmd() *cobra.Command { return fmt.Errorf("--mode accepts only \"all\" (got %q)", mode) } opts := vendor.SelectOptions{ - Add: splitList(add), - Remove: splitList(remove), - ModeAll: mode == "all", + Add: splitList(add), + Remove: splitList(remove), + ModeAll: mode == "all", + OnlyTypes: splitList(onlyTypes), + ExcludeTypes: splitList(excludeTypes), } if _, err := vendor.Select(".", args[0], opts, cmd.OutOrStdout()); err != nil { return fmt.Errorf("vendor select: %w", err) @@ -105,6 +119,8 @@ func newVendorSelectCmd() *cobra.Command { } cmd.Flags().StringSliceVar(&add, "add", nil, "comma-separated upstream ids to add to the selection") cmd.Flags().StringSliceVar(&remove, "remove", nil, "comma-separated upstream ids to remove from the selection") - cmd.Flags().StringVar(&mode, "mode", "", "switch selection mode (only \"all\": clear include/exclude, take everything)") + cmd.Flags().StringSliceVar(&onlyTypes, "types", nil, "content types to take: skills, commands, agents, mcp (allow-list; replaces the type filter)") + cmd.Flags().StringSliceVar(&excludeTypes, "exclude-types", nil, "content types to skip: skills, commands, agents, mcp (deny-list; replaces the type filter)") + cmd.Flags().StringVar(&mode, "mode", "", "switch selection mode (only \"all\": clear include/exclude and the type filter, take everything)") return cmd } diff --git a/internal/e2e/vendor_exclude_test.go b/internal/e2e/vendor_exclude_test.go new file mode 100644 index 0000000..be135c1 --- /dev/null +++ b/internal/e2e/vendor_exclude_test.go @@ -0,0 +1,75 @@ +package e2e + +import ( + "os" + "path/filepath" + "testing" +) + +// TestVendorAddExcludeMode pins --exclude: all mode minus the listed ids, +// materialized and recorded in the lockfile as mode "all" with an exclude +// list. +func TestVendorAddExcludeMode(t *testing.T) { + h := newHarness(t) + standardFixture(h, t) + + h.mustRun(t, "vendor", "add", "curated", h.upstreamDir, "--exclude", "broken-skill,scout") + + for _, p := range []string{ + "external/curated/skills/good-skill/SKILL.md", + "external/curated/commands/review.md", + "external/curated/mcp/search.yaml", + } { + if _, err := os.Stat(filepath.Join(h.workDir, p)); err != nil { + t.Errorf("non-excluded item must be materialized: %s", p) + } + } + for _, p := range []string{ + "external/curated/skills/broken-skill/SKILL.md", + "external/curated/agents/scout.md", + } { + if _, err := os.Stat(filepath.Join(h.workDir, p)); err == nil { + t.Errorf("excluded item must NOT be materialized: %s", p) + } + } + + lock := readFile(t, filepath.Join(h.workDir, "agent-lib.lock.json")) + if !contains(lock, `"mode": "all"`) { + t.Errorf("exclude keeps all mode:\n%s", lock) + } + if !contains(lock, `"exclude": [`) { + t.Errorf("exclude list must be recorded:\n%s", lock) + } +} + +// TestVendorAddExcludeTypoFailsClean pins the typo protection for --exclude. +func TestVendorAddExcludeTypoFailsClean(t *testing.T) { + h := newHarness(t) + standardFixture(h, t) + + out, err := h.run(t, "vendor", "add", "curated", h.upstreamDir, "--exclude", "good-skill,nope") + if err == nil { + t.Fatalf("typo in exclude list must fail:\n%s", out) + } + if !contains(out, "exclude entry") && !contains(out, "not found upstream") { + t.Errorf("error must name the unknown exclude entry:\n%s", out) + } + if _, statErr := os.Stat(filepath.Join(h.workDir, "external/curated")); statErr == nil { + t.Error("failed add must not leave an external area behind") + } +} + +// TestVendorAddIncludeAndExcludeConflict pins the mutual exclusion. +func TestVendorAddIncludeAndExcludeConflict(t *testing.T) { + h := newHarness(t) + standardFixture(h, t) + + out, err := h.run(t, "vendor", "add", "curated", h.upstreamDir, + "--include", "good-skill", "--exclude", "scout") + if err == nil { + t.Fatalf("--include and --exclude together must fail:\n%s", out) + } + if !contains(out, "--include and --exclude cannot be combined") { + t.Errorf("error must explain the conflict:\n%s", out) + } +} diff --git a/internal/e2e/vendor_types_test.go b/internal/e2e/vendor_types_test.go new file mode 100644 index 0000000..952dd95 --- /dev/null +++ b/internal/e2e/vendor_types_test.go @@ -0,0 +1,162 @@ +package e2e + +import ( + "os" + "path/filepath" + "testing" +) + +// TestVendorAddTypesAllowList pins --types: only the listed content types +// enter the work repository, recorded as the canonical exclude_types +// complement. +func TestVendorAddTypesAllowList(t *testing.T) { + h := newHarness(t) + standardFixture(h, t) + + h.mustRun(t, "vendor", "add", "curated", h.upstreamDir, "--types", "skills,commands") + + if _, err := os.Stat(filepath.Join(h.workDir, "external/curated/skills/good-skill/SKILL.md")); err != nil { + t.Error("allowed skill must be materialized") + } + if _, err := os.Stat(filepath.Join(h.workDir, "external/curated/commands/review.md")); err != nil { + t.Error("allowed command must be materialized") + } + for _, p := range []string{ + "external/curated/agents/scout.md", + "external/curated/mcp/search.yaml", + } { + if _, err := os.Stat(filepath.Join(h.workDir, p)); err == nil { + t.Errorf("item of a non-allowed type must NOT be materialized: %s", p) + } + } + + lock := readFile(t, filepath.Join(h.workDir, "agent-lib.lock.json")) + if !contains(lock, `"exclude_types": [`) { + t.Errorf("type filter must be recorded as exclude_types:\n%s", lock) + } + if !contains(lock, `"agents"`) || !contains(lock, `"mcp"`) { + t.Errorf("exclude_types must hold the complement (agents, mcp):\n%s", lock) + } +} + +// TestVendorAddExcludeTypesDenyList pins --exclude-types and its unknown-type +// validation. +func TestVendorAddExcludeTypesDenyList(t *testing.T) { + h := newHarness(t) + standardFixture(h, t) + + h.mustRun(t, "vendor", "add", "curated", h.upstreamDir, "--exclude-types", "agents,mcp") + + if _, err := os.Stat(filepath.Join(h.workDir, "external/curated/skills/good-skill/SKILL.md")); err != nil { + t.Error("skill must be materialized") + } + if _, err := os.Stat(filepath.Join(h.workDir, "external/curated/agents/scout.md")); err == nil { + t.Error("agent of an excluded type must NOT be materialized") + } + + out, err := h.run(t, "vendor", "add", "other", h.upstreamDir, "--exclude-types", "skillz") + if err == nil { + t.Fatalf("unknown type must fail:\n%s", out) + } + if !contains(out, "unknown content type") || !contains(out, "skills, commands, agents, mcp") { + t.Errorf("error must list the valid type names:\n%s", out) + } + + out, err = h.run(t, "vendor", "add", "other", h.upstreamDir, "--types", "skills", "--exclude-types", "mcp") + if err == nil { + t.Fatalf("--types and --exclude-types together must fail:\n%s", out) + } +} + +// TestVendorTypesSurviveUpdate pins the whole point of exclude_types: items +// of an excluded type that appear upstream later must not slip back in on +// vendor update. +func TestVendorTypesSurviveUpdate(t *testing.T) { + h := newHarness(t) + standardFixture(h, t) + h.mustRun(t, "vendor", "add", "curated", h.upstreamDir, "--types", "skills") + + // upstream gains a new agent and a new skill + h.write(t, "agents/latecomer.md", "---\nname: Latecomer\n---\nbody\n") + h.write(t, "skills/new-skill/SKILL.md", "---\nname: New Skill\ndescription: fresh\n---\n# New\n") + h.commitUpstream(t, "add latecomer agent and new skill") + + h.mustRun(t, "vendor", "update", "curated") + + if _, err := os.Stat(filepath.Join(h.workDir, "external/curated/skills/new-skill/SKILL.md")); err != nil { + t.Error("new skill of an allowed type must be vendored by update") + } + if _, err := os.Stat(filepath.Join(h.workDir, "external/curated/agents/latecomer.md")); err == nil { + t.Error("new agent of an excluded type must NOT re-enter on update") + } + lock := readFile(t, filepath.Join(h.workDir, "agent-lib.lock.json")) + if !contains(lock, `"exclude_types": [`) { + t.Errorf("type filter must survive the update:\n%s", lock) + } +} + +// TestVendorSelectTypeFilter pins vendor select parity: --exclude-types / +// --types replace the filter, absent flags keep it, --mode all clears it. +func TestVendorSelectTypeFilter(t *testing.T) { + h := newHarness(t) + standardFixture(h, t) + h.mustRun(t, "vendor", "add", "curated", h.upstreamDir, "--types", "skills") + + // replacing the filter via --types + h.mustRun(t, "vendor", "select", "curated", "--types", "commands") + if _, err := os.Stat(filepath.Join(h.workDir, "external/curated/skills/good-skill/SKILL.md")); err == nil { + t.Error("skill must be dropped after narrowing the type filter") + } + if _, err := os.Stat(filepath.Join(h.workDir, "external/curated/commands/review.md")); err != nil { + t.Error("command must be materialized after widening the type filter") + } + + // an id-only change keeps the type filter + h.mustRun(t, "vendor", "select", "curated", "--remove", "review") + if _, err := os.Stat(filepath.Join(h.workDir, "external/curated/commands/review.md")); err == nil { + t.Error("excluded command must be gone") + } + lock := readFile(t, filepath.Join(h.workDir, "agent-lib.lock.json")) + if !contains(lock, `"exclude_types": [`) || !contains(lock, `"skills"`) { + t.Errorf("type filter must survive an id-only select:\n%s", lock) + } + + // --mode all clears everything, including the type filter + h.mustRun(t, "vendor", "select", "curated", "--mode", "all") + for _, p := range []string{ + "external/curated/skills/good-skill/SKILL.md", + "external/curated/commands/review.md", + "external/curated/agents/scout.md", + "external/curated/mcp/search.yaml", + } { + if _, err := os.Stat(filepath.Join(h.workDir, p)); err != nil { + t.Errorf("--mode all must re-materialize everything: %s", p) + } + } + lock = readFile(t, filepath.Join(h.workDir, "agent-lib.lock.json")) + if contains(lock, `"exclude_types": [`) { + t.Errorf("--mode all must clear the type filter:\n%s", lock) + } + + // --mode all combined with type flags stays an error + out, err := h.run(t, "vendor", "select", "curated", "--mode", "all", "--types", "skills") + if err == nil { + t.Fatalf("--mode all with --types must fail:\n%s", out) + } +} + +// TestVendorAddIncludeVsTypeFilter pins the explicit conflict error when an +// include id belongs to a type the filter excludes. +func TestVendorAddIncludeVsTypeFilter(t *testing.T) { + h := newHarness(t) + standardFixture(h, t) + + out, err := h.run(t, "vendor", "add", "curated", h.upstreamDir, + "--include", "scout", "--exclude-types", "agents") + if err == nil { + t.Fatalf("include id of an excluded type must fail:\n%s", out) + } + if !contains(out, "type filter excludes") { + t.Errorf("error must explain the conflict:\n%s", out) + } +} diff --git a/internal/lockfile/lockfile.go b/internal/lockfile/lockfile.go index 0a377af..f9dcd82 100644 --- a/internal/lockfile/lockfile.go +++ b/internal/lockfile/lockfile.go @@ -68,11 +68,15 @@ const ( ) // Selection expresses what enters the work repository: everything minus -// Exclude (mode all), or exactly Include (mode include). +// Exclude (mode all), or exactly Include (mode include). ExcludeTypes +// filters whole content types (skills, commands, agents, mcp) on top of +// either mode and survives updates: new upstream items of an excluded +// type never enter the work repository. type Selection struct { - Mode SelectionMode `json:"mode"` - Exclude []string `json:"exclude,omitempty"` - Include []string `json:"include,omitempty"` + Mode SelectionMode `json:"mode"` + Exclude []string `json:"exclude,omitempty"` + Include []string `json:"include,omitempty"` + ExcludeTypes []string `json:"exclude_types,omitempty"` } // Source pins one vendored git source. @@ -140,6 +144,7 @@ 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.Selection.ExcludeTypes) sort.Strings(src.Warnings) for _, items := range src.Inventory { sort.Strings(items) diff --git a/internal/vendor/add.go b/internal/vendor/add.go index 00a3a14..68cbb85 100644 --- a/internal/vendor/add.go +++ b/internal/vendor/add.go @@ -21,11 +21,14 @@ const externalDir = "external" // AddOptions parameterize vendor add. type AddOptions struct { - Name string - URL string - Ref string - Include []string - Renames map[string]string + Name string + URL string + Ref string + Include []string + Exclude []string + OnlyTypes []string + ExcludeTypes []string + Renames map[string]string } // Add vendors a new source into the work repository at workDir: it clones, @@ -68,12 +71,29 @@ func Add(workDir string, opts AddOptions, stdout io.Writer) error { 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 { + if len(opts.Include) > 0 && len(opts.Exclude) > 0 { + return fmt.Errorf("--include and --exclude cannot be combined (a v2 selection is either include mode or all mode minus excludes)") + } + excludeTypes, err := TypeFilter(opts.OnlyTypes, opts.ExcludeTypes) + if err != nil { + return err + } + + sel := lockfile.Selection{Mode: lockfile.ModeAll, ExcludeTypes: excludeTypes} + switch { + case len(opts.Include) > 0: + sel = lockfile.Selection{Mode: lockfile.ModeInclude, Include: opts.Include, ExcludeTypes: excludeTypes} + if err := checkIDEntries(items, opts.Include, opts.Name, "include"); err != nil { return err } + case len(opts.Exclude) > 0: + sel.Exclude = opts.Exclude + if err := checkIDEntries(items, opts.Exclude, opts.Name, "exclude"); err != nil { + return err + } + } + if err := checkIncludeVsTypes(items, sel, opts.Name); err != nil { + return err } selected := applySelection(items, sel) @@ -147,21 +167,96 @@ func discoveryFromTreeRoot(root string) lockfile.Discovery { return d } -func checkIncludeEntries(items []discovery.Item, include []string, name string) error { +// checkIDEntries fails when any id is not available upstream (not discovered +// or skipped) — typo protection for include and exclude lists alike. +func checkIDEntries(items []discovery.Item, ids []string, name, flag string) error { available := map[string]bool{} for _, it := range items { if !it.Skipped { available[it.UpstreamID] = true } } - for _, want := range include { + for _, want := range ids { if !available[want] { - return fmt.Errorf("source %q: include entry %q not found upstream (available: %s)", name, want, strings.Join(sortedKeys(available), ", ")) + return fmt.Errorf("source %q: %s entry %q not found upstream (available: %s)", name, flag, want, strings.Join(sortedKeys(available), ", ")) } } return nil } +// checkIncludeVsTypes fails when an include id belongs to a type excluded by +// the type filter — silently dropping it would betray the explicit include. +func checkIncludeVsTypes(items []discovery.Item, sel lockfile.Selection, name string) error { + if sel.Mode != lockfile.ModeInclude || len(sel.ExcludeTypes) == 0 { + return nil + } + excluded := map[string]bool{} + for _, t := range sel.ExcludeTypes { + excluded[t] = true + } + for _, it := range items { + if it.Skipped || !excluded[it.Type] { + continue + } + for _, id := range sel.Include { + if id == it.UpstreamID { + return fmt.Errorf("source %q: include entry %q belongs to type %s, which the type filter excludes", name, id, it.Type) + } + } + } + return nil +} + +// TypeFilter translates --types (allow-list) and --exclude-types (deny-list) +// into the canonical exclude_types list: the complement of the allow-list, +// or the deny-list verbatim. Both empty means no filter (nil). A filter that +// would exclude nothing normalizes to nil; a filter excluding everything is +// returned as-is and fails later at the zero-selection guard. +func TypeFilter(only, exclude []string) ([]string, error) { + if len(only) > 0 && len(exclude) > 0 { + return nil, fmt.Errorf("--types and --exclude-types cannot be combined") + } + if len(only) == 0 && len(exclude) == 0 { + return nil, nil + } + names := only + if len(names) == 0 { + names = exclude + } + valid := map[string]bool{} + for _, t := range lockfile.Types { + valid[t] = true + } + var unknown []string + for _, t := range names { + if !valid[t] { + unknown = append(unknown, t) + } + } + if len(unknown) > 0 { + return nil, fmt.Errorf("unknown content type(s) %s (valid: %s)", strings.Join(unknown, ", "), strings.Join(lockfile.Types, ", ")) + } + if len(exclude) > 0 { + out := append([]string{}, exclude...) + sort.Strings(out) + return out, nil + } + allowed := map[string]bool{} + for _, t := range only { + allowed[t] = true + } + var out []string + for _, t := range lockfile.Types { + if !allowed[t] { + out = append(out, t) + } + } + if len(out) == 0 { + return nil, nil + } + return out, nil +} + func applySelection(items []discovery.Item, sel lockfile.Selection) []discovery.Item { exclude := map[string]bool{} for _, e := range sel.Exclude { @@ -171,11 +266,18 @@ func applySelection(items []discovery.Item, sel lockfile.Selection) []discovery. for _, i := range sel.Include { include[i] = true } + excludedTypes := map[string]bool{} + for _, t := range sel.ExcludeTypes { + excludedTypes[t] = true + } var out []discovery.Item for _, it := range items { if it.Skipped { continue } + if excludedTypes[it.Type] { + continue + } switch sel.Mode { case lockfile.ModeInclude: if include[it.UpstreamID] { @@ -251,6 +353,9 @@ func reportAdd(w io.Writer, name string, src *lockfile.Source, discovered, selec 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)) + if len(src.Selection.ExcludeTypes) > 0 { + fmt.Fprintf(w, "type filter: excluding %s\n", strings.Join(src.Selection.ExcludeTypes, ", ")) + } fmt.Fprintf(w, "materialized under %s/\n", filepath.Join(externalDir, name)) if len(src.Warnings) > 0 { fmt.Fprintf(w, "warnings:\n") diff --git a/internal/vendor/select.go b/internal/vendor/select.go index 780c34b..7cd6ca5 100644 --- a/internal/vendor/select.go +++ b/internal/vendor/select.go @@ -15,9 +15,11 @@ import ( // SelectOptions parameterize vendor select. type SelectOptions struct { - Add []string - Remove []string - ModeAll bool + Add []string + Remove []string + ModeAll bool + OnlyTypes []string + ExcludeTypes []string } // Select changes what a vendored source contributes — without advancing the @@ -60,6 +62,9 @@ func Select(workDir, name string, opts SelectOptions, stdout io.Writer) (*Change if err := validateSelectIDs(items, src, opts, name); err != nil { return nil, err } + if err := checkIncludeVsTypes(items, newSel, name); err != nil { + return nil, err + } selected := applySelection(items, newSel) if err := checkCollisions(workDir, lf, name, selected, src.Renames); err != nil { @@ -99,6 +104,9 @@ func Select(workDir, name string, opts SelectOptions, stdout io.Writer) (*Change } fmt.Fprintf(stdout, "source %s: selection updated at pinned rev %s (mode %s)\n", name, short(src.Rev), newSel.Mode) + if len(newSel.ExcludeTypes) > 0 { + fmt.Fprintf(stdout, " type filter: excluding %s\n", strings.Join(newSel.ExcludeTypes, ", ")) + } printChangeReport(stdout, report) return report, nil } @@ -106,30 +114,43 @@ func Select(workDir, name string, opts SelectOptions, stdout io.Writer) (*Change // planSelection derives the new selection from the flags — pure, before any // upstream contact: --mode all resets to everything; include mode grows and // shrinks the include list; all mode --remove moves ids onto the exclude -// list. +// list. --types/--exclude-types replace the type filter (absent flags keep +// it; --mode all clears it along with include/exclude). func planSelection(src *lockfile.Source, opts SelectOptions, name string) (lockfile.Selection, error) { + typeFilter, err := TypeFilter(opts.OnlyTypes, opts.ExcludeTypes) + if err != nil { + return lockfile.Selection{}, err + } if opts.ModeAll { - if len(opts.Add) > 0 || len(opts.Remove) > 0 { - return lockfile.Selection{}, fmt.Errorf("--mode all cannot be combined with --add or --remove") + if len(opts.Add) > 0 || len(opts.Remove) > 0 || typeFilter != nil { + return lockfile.Selection{}, fmt.Errorf("--mode all cannot be combined with --add, --remove, --types or --exclude-types") } return lockfile.Selection{Mode: lockfile.ModeAll}, nil } - if len(opts.Add) == 0 && len(opts.Remove) == 0 { - return lockfile.Selection{}, fmt.Errorf("nothing to do: pass --add , --remove or --mode all") + if len(opts.Add) == 0 && len(opts.Remove) == 0 && typeFilter == nil { + return lockfile.Selection{}, fmt.Errorf("nothing to do: pass --add , --remove , --types/--exclude-types or --mode all") } + var newSel lockfile.Selection if src.Selection.Mode == lockfile.ModeAll { if len(opts.Add) > 0 { return lockfile.Selection{}, fmt.Errorf("source %q: everything is already selected in all mode; use --remove to exclude items", name) } - return lockfile.Selection{ + newSel = lockfile.Selection{ Mode: lockfile.ModeAll, Exclude: unionIDs(src.Selection.Exclude, opts.Remove), - }, nil + } + } else { + newSel = lockfile.Selection{ + Mode: lockfile.ModeInclude, + Include: unionIDs(minusIDs(src.Selection.Include, opts.Remove), opts.Add), + } } - return lockfile.Selection{ - Mode: lockfile.ModeInclude, - Include: unionIDs(minusIDs(src.Selection.Include, opts.Remove), opts.Add), - }, nil + if typeFilter != nil { + newSel.ExcludeTypes = typeFilter + } else { + newSel.ExcludeTypes = src.Selection.ExcludeTypes + } + return newSel, nil } // validateSelectIDs checks flag ids against the pinned upstream (and, for @@ -137,7 +158,7 @@ func planSelection(src *lockfile.Source, opts SelectOptions, name string) (lockf // before anything is mutated. func validateSelectIDs(items []discovery.Item, src *lockfile.Source, opts SelectOptions, name string) error { if len(opts.Add) > 0 { - if err := checkIncludeEntries(items, opts.Add, name); err != nil { + if err := checkIDEntries(items, opts.Add, name, "include"); err != nil { return fmt.Errorf("--add: %w", err) } } @@ -153,7 +174,7 @@ func validateSelectIDs(items []discovery.Item, src *lockfile.Source, opts Select } } if len(opts.Remove) > 0 && src.Selection.Mode == lockfile.ModeAll { - if err := checkIncludeEntries(items, opts.Remove, name); err != nil { + if err := checkIDEntries(items, opts.Remove, name, "exclude"); err != nil { return fmt.Errorf("--remove: %w", err) } } diff --git a/internal/vendor/update.go b/internal/vendor/update.go index 5fbf0e9..dc3aafe 100644 --- a/internal/vendor/update.go +++ b/internal/vendor/update.go @@ -197,7 +197,7 @@ func computeUpdate(workDir string, lf *lockfile.Lockfile, name string, withColli } if src.Selection.Mode == lockfile.ModeInclude { - if err := checkIncludeEntries(items, src.Selection.Include, name); err != nil { + if err := checkIDEntries(items, src.Selection.Include, name, "include"); err != nil { return nil, err } } @@ -250,6 +250,11 @@ func cleanupStaleStaging(workDir string) { } func materializeTree(tree discovery.Tree, selected []discovery.Item, renames map[string]string, dst string) error { + // Create dst even for an empty selection: the caller swaps it in with a + // rename, which fails when nothing materialized the directory. + if err := os.MkdirAll(dst, 0o755); err != nil { + return err + } for _, it := range selected { dep := deployedName(it.UpstreamID, renames) base := filepath.Join(dst, it.Type) diff --git a/scripts/e2e-acceptance.sh b/scripts/e2e-acceptance.sh index 5bed8ac..9f5015c 100755 --- a/scripts/e2e-acceptance.sh +++ b/scripts/e2e-acceptance.sh @@ -81,6 +81,26 @@ assert_no_file "$WR3/external" "no partial external area" assert_file "$WR3/external/sp/skills/sp-good/SKILL.md" "renamed folder carries deployed name" assert_no_file "$WR3/external/sp/skills/good-skill" "upstream name absent after rename" +step "vendor add selection flags (--exclude, --types)" +WR4="$WORK/work-repo-4" +mkdir -p "$WR4" +(cd "$WR4" && "$BIN" vendor add cur "$UP" --exclude broken-skill) > "$WORK/exclude.log" 2>&1 +assert_exit_zero $? "add --exclude succeeds" +assert_no_file "$WR4/external/cur/skills/broken-skill" "excluded skill absent" +assert_file "$WR4/external/cur/skills/good-skill/SKILL.md" "non-excluded skill present" +assert_contains "$WR4/agent-lib.lock.json" '"exclude": [' "exclude list pinned" +WR5="$WORK/work-repo-5" +mkdir -p "$WR5" +(cd "$WR5" && "$BIN" vendor add typed "$UP" --types skills,commands) > "$WORK/types.log" 2>&1 +assert_exit_zero $? "add --types succeeds" +assert_no_file "$WR5/external/typed/agents" "agent type filtered out" +assert_no_file "$WR5/external/typed/mcp" "mcp type filtered out" +assert_contains "$WR5/agent-lib.lock.json" '"exclude_types": [' "type filter pinned" +set +e +(cd "$WR5" && "$BIN" vendor add bad "$UP" --include good-skill --exclude broken-skill) > "$WORK/flagconflict.log" 2>&1; RCX=$? +set -e +assert_exit_nonzero "$RCX" "--include + --exclude rejected" + step "validate / list / inspect" (cd "$WR" && "$BIN" validate) > "$WORK/validate.log" 2>&1 assert_exit_zero $? "healthy state validates" @@ -114,6 +134,15 @@ assert_no_file "$WR/external/superpowers" "remove leaves no residue" (cd "$WR" && "$BIN" vendor add superpowers "$UP") >/dev/null 2>&1 git -C "$WR" add -A && git -C "$WR" commit -qm "vendor superpowers (publication = commit && push)" +step "vendor select type filter parity" +(cd "$WR5" && "$BIN" vendor select typed --exclude-types skills) > "$WORK/types-select.log" 2>&1 +assert_exit_zero $? "select --exclude-types succeeds" +assert_no_file "$WR5/external/typed/skills" "skills dropped after narrowing the filter" +assert_contains "$WORK/types-select.log" "type filter" "select reports the type filter" +(cd "$WR5" && "$BIN" vendor select typed --mode all) > "$WORK/types-reset.log" 2>&1 +assert_exit_zero $? "select --mode all clears the filter" +assert_file "$WR5/external/typed/skills/good-skill/SKILL.md" "skills restored after --mode all" + step "employee sync" HOME_DIR="$WORK/employee-home" STATE="$WORK/employee-state" @@ -142,6 +171,31 @@ assert_exit_zero "$RC2" "second sync succeeds" printf '%s' "$OUT2" > "$WORK/sync2.log" assert_contains "$WORK/sync2.log" "kept:" "second run keeps everything" +step "default branch stays fresh without ref" +DB="$WORK/db-repo" +mkdir -p "$DB/skills/db-skill" +git -C "$DB" init -q -b main +printf -- '---\nname: DB\n---\n# v1\n' > "$DB/skills/db-skill/SKILL.md" +git -C "$DB" add -A && git -C "$DB" commit -qm "db v1" +DBCFG="$WORK/db-config.json" +printf '{"repo_url":"%s"}\n' "$DB" > "$DBCFG" +DBHOME="$WORK/db-home"; DBSTATE="$WORK/db-state" +mkdir -p "$DBHOME" "$DBSTATE" +set +e +HOME="$DBHOME" USERPROFILE="$DBHOME" AGENT_LIB_STATE_DIR="$DBSTATE" \ + "$BIN" sync --config "$DBCFG" >/dev/null 2>&1; RCDB=$? +set -e +assert_exit_zero "$RCDB" "first default-branch sync (clone)" +printf -- '---\nname: DB\n---\n# v2\n' > "$DB/skills/db-skill/SKILL.md" +git -C "$DB" add -A && git -C "$DB" commit -qm "db v2" +set +e +HOME="$DBHOME" USERPROFILE="$DBHOME" AGENT_LIB_STATE_DIR="$DBSTATE" \ + "$BIN" sync --config "$DBCFG" > "$WORK/db2.log" 2>&1; RCDB2=$? +set -e +assert_exit_zero "$RCDB2" "second default-branch sync (fetch)" +assert_contains "$WORK/db2.log" "updated: 1" "default branch advanced past the clone-time revision" +grep -q "# v2" "$DBHOME/.agents/skills/db-skill/SKILL.md" && ok "fresh content deployed" || fail "stale clone-time content served" + step "protected sync: local modification wins" printf -- '---\nname: Own Skill\n---\n# MY EDITS\n' > "$HOME_DIR/.agents/skills/own-skill/SKILL.md" printf -- '---\nname: Own Skill\ndescription: v2\n---\n# Upstream v2\n' > "$WR/skills/own-skill/SKILL.md" @@ -153,6 +207,18 @@ assert_contains "$WORK/sync3.log" "skipped (local modifications kept): 1" "modif grep -q "MY EDITS" "$HOME_DIR/.agents/skills/own-skill/SKILL.md" && ok "local version kept" || fail "local version overwritten" assert_contains "$STATE/sync.log" "SKIP skills/own-skill" "warning logged with item and reason" +step "forced sync: repository version wins" +set +e +OUTF="$(HOME="$HOME_DIR" USERPROFILE="$HOME_DIR" AGENT_LIB_STATE_DIR="$STATE" \ + "$BIN" sync --config "$CFG" --force 2>&1)"; RCF=$? +set -e +assert_exit_zero "$RCF" "sync --force succeeds" +printf '%s' "$OUTF" > "$WORK/syncf.log" +assert_contains "$WORK/syncf.log" "updated:" "forced overwrite reported as update" +grep -q "Upstream v2" "$HOME_DIR/.agents/skills/own-skill/SKILL.md" && ok "repository version restored" || fail "local modification survived --force" +# re-introduce drift so the upstream-deletion walk below has a modified copy +printf -- '---\nname: Own Skill\n---\n# MY EDITS\n' > "$HOME_DIR/.agents/skills/own-skill/SKILL.md" + step "self-created item survives" MINE="$HOME_DIR/.agents/skills/my-private-skill" mkdir -p "$MINE" && printf -- '---\nname: Mine\n---\n' > "$MINE/SKILL.md" @@ -189,6 +255,18 @@ set +e; OUTSJ="$(HOME="$HOME_DIR" USERPROFILE="$HOME_DIR" AGENT_LIB_STATE_DIR="$ assert_exit_nonzero "$RCSJ" "status --json keeps drift exit code" printf '%s' "$OUTSJ" | grep -q '"problematic_drift": true' && ok "json drift flag set" || fail "json drift flag missing" +step "forced sync after upstream deletion removes modified copies" +set +e +OUTR="$(HOME="$HOME_DIR" USERPROFILE="$HOME_DIR" AGENT_LIB_STATE_DIR="$STATE" \ + "$BIN" sync --config "$CFG" --force 2>&1)"; RCR=$? +set -e +assert_exit_zero "$RCR" "sync --force after deletion succeeds" +printf '%s' "$OUTR" > "$WORK/syncr.log" +assert_contains "$WORK/syncr.log" "removed:" "forced removals reported" +assert_no_file "$HOME_DIR/.agents/skills/own-skill" "modified skill removed by force" +assert_no_file "$HOME_DIR/.config/opencode/agents/scout.md" "modified agent removed by force" +assert_file "$MINE/SKILL.md" "unmanaged item survives --force untouched" + step "unreachable repository keeps state" BEFORE="$(cat "$HOME_DIR/.agents/skills/fresh-skill/SKILL.md")" printf '{"repo_url":"%s","ref":"main"}\n' "$WORK/nonexistent-repo" > "$CFG"