diff --git a/CHANGELOG.md b/CHANGELOG.md index a360d7742..8ee55edf3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Per-dependency agent subsetting: object-form dependencies accept a non-empty + `agents:` list, and repeatable `apm install --agent NAME` selections persist + to `apm.yml` and `apm.lock.yaml` for deterministic bare installs and audit + replay. `--agent '*'` resets the dependency to all agents. (closes #2491) - `apm install` now accepts `--trust-bin` / `--no-trust-bin` for per-invocation consent over marketplace-plugin `bin/` executable deployment. `--trust-bin` approves deployment silently; `--no-trust-bin` skips `bin/` even when policy diff --git a/CONFORMANCE.json b/CONFORMANCE.json index a453dd73c..ad0279256 100644 --- a/CONFORMANCE.json +++ b/CONFORMANCE.json @@ -571,6 +571,17 @@ "tests/spec_conformance/test_manifest_reqs.py::test_consumer_preserves_registry_identity_on_structured_rewrite" ] }, + { + "conformance_class": "consumer", + "id": "req-mf-025", + "keyword": "MUST", + "section": "4.3.2", + "status": "active", + "test_count": 1, + "tests": [ + "tests/spec_conformance/test_manifest_reqs.py::test_consumer_persists_and_deploys_only_selected_agents" + ] + }, { "conformance_class": "governance", "id": "req-pl-001", @@ -1279,7 +1290,7 @@ "spec_version": "v0.1.1", "summary_by_class": { "consumer": { - "active": 82, + "active": 83, "skipped": 1, "unbound": 0, "xfail": 0 @@ -1303,5 +1314,5 @@ "xfail": 0 } }, - "total_requirements": 112 + "total_requirements": 113 } diff --git a/CONFORMANCE.md b/CONFORMANCE.md index 75e38a53a..d4bbf3321 100644 --- a/CONFORMANCE.md +++ b/CONFORMANCE.md @@ -19,7 +19,7 @@ All four conformance classes (Producer, Consumer, Registry, Governance) carry ac | Class | Active | Skipped | Xfail | Unbound | |-------|-------:|--------:|------:|--------:| | Producer | 12 | 0 | 0 | 0 | -| Consumer | 82 | 1 | 0 | 0 | +| Consumer | 83 | 1 | 0 | 0 | | Registry | 1 | 0 | 0 | 0 | | Governance | 16 | 0 | 0 | 0 | @@ -77,6 +77,7 @@ All four conformance classes (Producer, Consumer, Registry, Governance) carry ac | [req-mf-022](docs/src/content/docs/specs/openapm-v0.1.md#req-mf-022) | MUST | 4.3.2 | consumer | active | 1 | | [req-mf-023](docs/src/content/docs/specs/openapm-v0.1.md#req-mf-023) | MUST | 4.5 | consumer | active | 1 | | [req-mf-024](docs/src/content/docs/specs/openapm-v0.1.md#req-mf-024) | MUST | 4.3.2 | consumer | active | 1 | +| [req-mf-025](docs/src/content/docs/specs/openapm-v0.1.md#req-mf-025) | MUST | 4.3.2 | consumer | active | 1 | | [req-pl-001](docs/src/content/docs/specs/openapm-v0.1.md#req-pl-001) | MUST | 6.1 | governance | active | 1 | | [req-pl-002](docs/src/content/docs/specs/openapm-v0.1.md#req-pl-002) | MUST | 6.2 | governance | active | 1 | | [req-pl-003](docs/src/content/docs/specs/openapm-v0.1.md#req-pl-003) | MUST | 6.4 | governance | active | 1 | diff --git a/docs/public/specs/manifests/openapm-v0.1.requirements.yml b/docs/public/specs/manifests/openapm-v0.1.requirements.yml index 5438f67d2..0502d1fd5 100644 --- a/docs/public/specs/manifests/openapm-v0.1.requirements.yml +++ b/docs/public/specs/manifests/openapm-v0.1.requirements.yml @@ -119,6 +119,10 @@ requirements: keyword: MUST section: "4.3.2" conformance_class: consumer + - id: req-mf-025 + keyword: MUST + section: "4.3.2" + conformance_class: consumer - id: req-ext-001 keyword: MUST section: "4.1" diff --git a/docs/public/specs/schemas/lockfile-v0.1.schema.json b/docs/public/specs/schemas/lockfile-v0.1.schema.json index 420700abc..d5475bae1 100644 --- a/docs/public/specs/schemas/lockfile-v0.1.schema.json +++ b/docs/public/specs/schemas/lockfile-v0.1.schema.json @@ -54,6 +54,7 @@ "depth": { "type": "integer", "minimum": 0 }, "resolved_by": { "type": "string" }, "package_type": { "type": "string" }, + "agent_subset": { "type": "array", "items": { "type": "string", "pattern": "^(?!\\s*$)(?!\\.{1,2}$)[^/\\\\]+$" } }, "skill_subset": { "type": "array", "items": { "type": "string" } }, "deployed_files": { "type": "array", "items": { "type": "string" } }, "deployed_file_hashes": { diff --git a/docs/public/specs/schemas/manifest-v0.1.schema.json b/docs/public/specs/schemas/manifest-v0.1.schema.json index 642db658f..534937afb 100644 --- a/docs/public/specs/schemas/manifest-v0.1.schema.json +++ b/docs/public/specs/schemas/manifest-v0.1.schema.json @@ -106,6 +106,7 @@ "prerelease": { "type": "boolean" }, "path": { "type": "string" }, "alias": { "type": "string" }, + "agents": { "type": "array", "minItems": 1, "items": { "type": "string", "pattern": "^(?!\\s*$)(?!\\.{1,2}$)[^/\\\\]+$" } }, "skills": { "type": "array", "items": { "type": "string" } } }, "patternProperties": { "^x-[a-z][a-z0-9-]*$": {} }, diff --git a/docs/src/content/docs/concepts/lifecycle.md b/docs/src/content/docs/concepts/lifecycle.md index 574d34299..a426453ac 100644 --- a/docs/src/content/docs/concepts/lifecycle.md +++ b/docs/src/content/docs/concepts/lifecycle.md @@ -150,7 +150,7 @@ apm audit --file # standalone: scan an arbitrary file **Local mode** (`apm audit`, optionally with `--strip` or `--file `) scans installed primitives -- or any file you point at -- for hidden Unicode and reports findings as text, JSON, SARIF, or markdown. With `--strip`, it removes hidden characters in place, preserving emoji and whitespace. Use `--dry-run` to preview the strip. -**CI mode** (`apm audit --ci`) runs the nine baseline consistency checks in order: `lockfile-exists`, `ref-consistency`, `deployment-ledger-owners`, `deployed-files-present`, `no-orphaned-packages`, `skill-subset-consistency`, `config-consistency`, `content-integrity`, and `includes-consent`. After those pass, it performs an install-replay drift check. APM rebuilds the deployed context in a scratch directory and diffs it against your working tree, catching hand-edits to `apm_modules/` or generated files before they ship. When `apm_modules/` is absent but the lockfile is present, `--ci` self-hydrates a lock-pinned scratch install first instead of reporting a green drift skip. Pass `--no-drift` to skip the replay in performance-constrained loops; pass `--no-fail-fast` to run all checks even after a failure. With `--policy ` it also evaluates org policy against the lockfile. +**CI mode** (`apm audit --ci`) runs the ten baseline consistency checks in order: `lockfile-exists`, `ref-consistency`, `deployment-ledger-owners`, `deployed-files-present`, `no-orphaned-packages`, `agent-subset-consistency`, `skill-subset-consistency`, `config-consistency`, `content-integrity`, and `includes-consent`. After those pass, it performs an install-replay drift check. APM rebuilds the deployed context in a scratch directory and diffs it against your working tree, catching hand-edits to `apm_modules/` or generated files before they ship. When `apm_modules/` is absent but the lockfile is present, `--ci` self-hydrates a lock-pinned scratch install first instead of reporting a green drift skip. Pass `--no-drift` to skip the replay in performance-constrained loops; pass `--no-fail-fast` to run all checks even after a failure. With `--policy ` it also evaluates org policy against the lockfile. **Common surprises** diff --git a/docs/src/content/docs/concepts/the-three-promises.md b/docs/src/content/docs/concepts/the-three-promises.md index c8526d060..2273b8725 100644 --- a/docs/src/content/docs/concepts/the-three-promises.md +++ b/docs/src/content/docs/concepts/the-three-promises.md @@ -113,10 +113,11 @@ apm install --dry-run `resolve_policy_chain()` implement the tighten-only enterprise -> org -> repo flow with `_escalate()` enforcement. - `src/apm_cli/policy/ci_checks.py` -- `run_baseline_checks()` is - the CI surface used by `apm audit --ci`. It runs 8 baseline - checks: lockfile-exists, ref-consistency, deployed-files-present, - no-orphans, skill-subset-consistency, config-consistency, - content-integrity, and includes-consent. + the CI surface used by `apm audit --ci`. It runs 10 baseline + checks: lockfile-exists, ref-consistency, deployment-ledger-owners, + deployed-files-present, no-orphans, agent-subset-consistency, + skill-subset-consistency, config-consistency, content-integrity, and + includes-consent. ### Read more @@ -147,6 +148,6 @@ right now -- including hand-edits to files inside `apm_modules/`. At install time: dependencies from disallowed sources or scopes, primitives outside the allow-list, and transitive MCP servers that fail any of the configured trust rules -- evaluated before any -download. In CI via `apm audit --ci`: the 8 baseline checks above, +download. In CI via `apm audit --ci`: the 10 baseline checks above, which catch lockfile drift, missing deployed files, orphaned packages, and content-hash mismatches before a PR can merge. diff --git a/docs/src/content/docs/enterprise/drift-detection.md b/docs/src/content/docs/enterprise/drift-detection.md index 88fd37d7a..148e49600 100644 --- a/docs/src/content/docs/enterprise/drift-detection.md +++ b/docs/src/content/docs/enterprise/drift-detection.md @@ -29,14 +29,14 @@ happens; see [security model](../security/). ### `apm audit --ci` -The lockfile-consistency gate. Runs nine baseline checks in order and +The lockfile-consistency gate. Runs ten baseline checks in order and exits non-zero on the first failure (or on any failure with `--no-fail-fast`): ``` lockfile-exists -> ref-consistency -> deployment-ledger-owners -> deployed-files-present -> no-orphaned-packages --> skill-subset-consistency -> config-consistency +-> agent-subset-consistency -> skill-subset-consistency -> config-consistency -> content-integrity -> includes-consent ``` diff --git a/docs/src/content/docs/enterprise/enforce-in-ci.md b/docs/src/content/docs/enterprise/enforce-in-ci.md index bb0e7613f..f933c2cb9 100644 --- a/docs/src/content/docs/enterprise/enforce-in-ci.md +++ b/docs/src/content/docs/enterprise/enforce-in-ci.md @@ -20,9 +20,9 @@ playbook, see [Governance deep-dive](../governance-guide/) and apm audit --ci ``` -One command. It runs the nine baseline lockfile checks +One command. It runs the ten baseline lockfile checks (`lockfile-exists`, `ref-consistency`, `deployment-ledger-owners`, -`deployed-files-present`, `no-orphaned-packages`, `skill-subset-consistency`, +`deployed-files-present`, `no-orphaned-packages`, `agent-subset-consistency`, `skill-subset-consistency`, `config-consistency`, `content-integrity`, `includes-consent`), the install-replay drift check, and -- if an `apm-policy.yml` is discovered -- the org policy checks. Exit code is `0` clean, `1` on any violation. @@ -134,7 +134,7 @@ The two patterns serve different goals: | Full install then audit | Catching developers who skipped `apm install` after editing `apm.yml`; ensuring gitignored deployed files are present on a fresh runner | | Audit-only (`setup-only: true`) | Zero-install CI gate for repos that commit deployed files: compare the checked-out commit against a lock-pinned scratch replay without rewriting the checkout | -Both patterns enforce policy and the nine baseline lockfile checks. The +Both patterns enforce policy and the ten baseline lockfile checks. The difference is only in whether content-integrity can see tampered bytes. ## Recipe: SARIF for GitHub Code Scanning diff --git a/docs/src/content/docs/enterprise/policy-reference.md b/docs/src/content/docs/enterprise/policy-reference.md index 20059c1bf..f3155ad73 100644 --- a/docs/src/content/docs/enterprise/policy-reference.md +++ b/docs/src/content/docs/enterprise/policy-reference.md @@ -408,6 +408,7 @@ Deny patterns are evaluated first. If a reference matches any deny pattern, it f | `ref-consistency` | Every dependency's manifest ref matches the lockfile's resolved ref | | `deployed-files-present` | All files listed in lockfile `deployed_files` exist on disk | | `no-orphaned-packages` | No lockfile packages are absent from the manifest | +| `agent-subset-consistency` | `agents:` selections in `apm.yml` match `agent_subset` in the lockfile | | `skill-subset-consistency` | `skills:` selections in `apm.yml` match `skill_subset` in the lockfile | | `config-consistency` | MCP server configs match lockfile baseline | | `content-integrity` | Deployed files contain no critical hidden Unicode characters and their SHA-256 hashes match the lockfile | diff --git a/docs/src/content/docs/integrations/ci-cd.md b/docs/src/content/docs/integrations/ci-cd.md index f09a11d6e..6cbc07aa5 100644 --- a/docs/src/content/docs/integrations/ci-cd.md +++ b/docs/src/content/docs/integrations/ci-cd.md @@ -68,7 +68,7 @@ This step is not needed if your team only uses GitHub Copilot and Claude, which run: apm audit --ci ``` -This single command runs the nine baseline lockfile checks PLUS integration +This single command runs the ten baseline lockfile checks PLUS integration drift detection (default-on) AND replays the install pipeline into a scratch tree to detect missed `apm install` runs, hand-edited deployed files, and orphaned files. See the diff --git a/docs/src/content/docs/reference/baseline-checks.md b/docs/src/content/docs/reference/baseline-checks.md index 59f9aeb26..8afa8e311 100644 --- a/docs/src/content/docs/reference/baseline-checks.md +++ b/docs/src/content/docs/reference/baseline-checks.md @@ -36,6 +36,7 @@ first failure to skip expensive I/O. | `deployment-ledger-owners` | block | `ci_checks.py` | yes | | `deployed-files-present` | block | `ci_checks.py` | yes | | `no-orphaned-packages` | block | `ci_checks.py` | yes | +| `agent-subset-consistency` | block | `ci_checks.py` | yes | | `skill-subset-consistency` | block | `ci_checks.py` | yes | | `config-consistency` | block | `ci_checks.py` | yes | | `content-integrity` | block | `ci_checks.py` | yes | @@ -116,9 +117,15 @@ the [policy schema](../policy-schema/). - **Fails when.** The lockfile holds a package that the manifest no longer lists. - **Remediation.** Run `apm install` to prune the orphan, then commit `apm.lock.yaml`. +### `agent-subset-consistency` + +- **What it verifies.** That every dependency's `agents:` selection in `apm.yml` matches `agent_subset` in the lockfile. +- **Fails when.** The sorted manifest and lockfile agent selections differ. +- **Remediation.** Run `apm install` to regenerate the lockfile against the current selection. + ### `skill-subset-consistency` -- **What it verifies.** That the `skills:` selection in `apm.yml` for each `skill_bundle` dependency matches the `skill_subset` recorded in the lockfile. +- **What it verifies.** That each skill bundle's `skills:` selection in `apm.yml` matches `skill_subset` in the lockfile. - **Fails when.** The sorted manifest skill list differs from the sorted lockfile `skill_subset` for any skill bundle. - **Remediation.** Run `apm install` to regenerate the lockfile against the current selection. @@ -153,7 +160,7 @@ the [policy schema](../policy-schema/). ## Run order and fail-fast -The aggregate runner in `run_baseline_checks` evaluates checks in this order: `manifest-parse` (only when `apm.yml` is unparseable), `lockfile-exists`, `ref-consistency`, `deployment-ledger-owners`, `deployed-files-present`, `no-orphaned-packages`, `skill-subset-consistency`, `config-consistency`, `content-integrity`, `includes-consent`. Drift is invoked separately by the audit command after the baseline batch, but in `--ci` mode it shares the same cold-cache scratch materialization with `config-consistency`. +The aggregate runner in `run_baseline_checks` evaluates checks in this order: `manifest-parse` (only when `apm.yml` is unparseable), `lockfile-exists`, `ref-consistency`, `deployment-ledger-owners`, `deployed-files-present`, `no-orphaned-packages`, `agent-subset-consistency`, `skill-subset-consistency`, `config-consistency`, `content-integrity`, `includes-consent`. Drift is invoked separately by the audit command after the baseline batch, but in `--ci` mode it shares the same cold-cache scratch materialization with `config-consistency`. With fail-fast on (the default), the runner stops at the first failing check. `apm audit --ci --no-fail-fast` evaluates every check so the report lists every problem at once. diff --git a/docs/src/content/docs/reference/cli/install.md b/docs/src/content/docs/reference/cli/install.md index 58cabad40..5ecac10ff 100644 --- a/docs/src/content/docs/reference/cli/install.md +++ b/docs/src/content/docs/reference/cli/install.md @@ -82,10 +82,11 @@ auto-detection only when `apm.yml` declares no targets. Transport env vars: `APM_GIT_PROTOCOL` (`ssh` or `https`) sets the default initial transport for shorthand deps; `APM_ALLOW_PROTOCOL_FALLBACK=1` mirrors `--allow-protocol-fallback`. -### Skill subset +### Agent and skill subsets | Flag | Default | Description | |---|---|---| +| `--agent NAME` | all | Install only named agents from an explicitly named dependency (at least one package argument is required). Agent names are flat filename stems (for example, `reviewer` selects `reviewer.agent.md`). Repeatable and additive across installs. The sorted selection is persisted as `agents:` in `apm.yml` and `agent_subset` in `apm.lock.yaml`, so a bare reinstall or audit replay deploys the same agents. Use `--agent '*'` to reset to all agents. | | `--skill NAME` | all | Install only named skills from a dependency that exposes selectable skills. Applies to both git-longhand and registry-longhand (`id:`/`registry:`) dependencies. Repeatable. For plugin manifests, `NAME` may be the skill name or manifest path, such as `skills/productivity/grill-me`. A CLI name that matches no declared skill is an install error; the diagnostic lists the available names. If a previously persisted `skills:` pin later matches no available source skill, install stays successful but warns with the package, requested names, and available names instead of silently doing nothing. The selection is persisted to `apm.yml` and `apm.lock.yaml` only after a successful CLI match. `--skill` is additive across separate installs: a later `apm install --skill X` adds `X` to the existing pin (union) rather than replacing it -- previously deployed skills are never silently removed. Use `--skill '*'` to reset to the full bundle; to drop a single skill, edit the `skills:` list in `apm.yml` and re-run `apm install`. | | `--as ALIAS` | bundle id | Override the log/display label for a local-bundle install. Only valid with a single local-bundle `PACKAGE_REF`. | @@ -203,12 +204,15 @@ apm install ./my-bundle.zip --as custom-name apm install ./my-bundle --target opencode ``` -### Install only a subset of skills from a bundle +### Install only a subset of agents or skills ```bash apm install owner/skill-bundle --skill review apm install owner/skill-bundle --skill refactor # adds refactor; review is kept (union) apm install owner/skill-bundle --skill '*' # reset to all skills + +apm install owner/agent-pack --agent planner --agent reviewer +apm install owner/agent-pack --agent '*' # reset to all agents ``` ## Exit codes diff --git a/docs/src/content/docs/reference/lockfile-spec.md b/docs/src/content/docs/reference/lockfile-spec.md index 93615e7ae..0bc05d310 100644 --- a/docs/src/content/docs/reference/lockfile-spec.md +++ b/docs/src/content/docs/reference/lockfile-spec.md @@ -202,6 +202,7 @@ Each item in `dependencies` describes one resolved package. | `depth` | int | no | Position in the dependency tree. `0` is the project itself, `1` is a direct dep, higher is transitive. Defaults to `1`. | | `resolved_by` | string | no | `repo_url` of the parent that pulled this transitive dep. Absent for direct deps. Rewritten by `apm uninstall` when a rescued transitive dependency's original parent is removed, so the entry stays keyed on a genuine surviving parent -- this never changes the entry's identity or lock key, only which parent it points to. | | `package_type` | string | no | Kind of package: `apm_package`, `skill_bundle`, `claude_skill`, `hook_package`, `hybrid`, `marketplace_plugin`. Drives target placement. | +| `agent_subset` | list of strings | no | Sorted flat agent names selected by the manifest's `agents:` field. Empty means "all". | | `skill_subset` | list of strings | no | For dependencies that expose selectable skills: the sorted subset of skill names the manifest selected. Empty means "all". | | `target_subset` | list of strings | no | Sorted target names selected by a dependency's `targets:` subset. Empty means "all active install targets". | | `deployed_files` | list of strings | no | Project-relative paths APM wrote for this dep. Sorted. Powers `prune` and `audit`'s file-presence check. A shared path has one canonical package owner; uninstall transfers ownership to a surviving provider. When the consumer manifest declares targets, reinstall preserves entries for other declared, gated, or dynamic targets and removes entries outside that target universe. On a target contraction, a normal install/prune run removes an obsolete target's file only when its recorded hash still matches; a user-edited file stays on disk and remains tracked for review. `apm lock` is non-destructive: if bytes remain on disk, the lockfile preserves their `deployed_files`, `deployed_file_hashes`, and deployment-ledger rows until the next normal install can prove and perform cleanup. Without a declared target set, reinstall preserves prior other-target entries. | @@ -343,6 +344,7 @@ check maps to specific lockfile fields: | `ref-consistency` | `resolved_ref` per entry vs. `apm.yml` | | `deployed-files-present` | `deployed_files` per entry (and self entry) | | `content-integrity` | `deployed_file_hashes` (and `local_deployed_file_hashes`) | +| `agent-subset-consistency` | `agent_subset` per dependency entry | | `skill-subset-consistency` | `skill_subset` per `skill_bundle` entry | | `config-consistency` | `mcp_configs` and `mcp_config_provenance` | | `no-orphaned-packages` | `dependencies` keys vs. `apm.yml` | diff --git a/docs/src/content/docs/reference/manifest-schema.md b/docs/src/content/docs/reference/manifest-schema.md index 6647cd047..3fde97607 100644 --- a/docs/src/content/docs/reference/manifest-schema.md +++ b/docs/src/content/docs/reference/manifest-schema.md @@ -433,6 +433,7 @@ REQUIRED when the shorthand is ambiguous (e.g. direct nested-group repos with vi | `alias` | `string` | OPTIONAL | `^[a-zA-Z0-9._-]+$` | Local alias. | | `type` | `string` | OPTIONAL (remote Git only) | `gitlab` | Treat a bespoke hostname as self-managed GitLab. | | `allow_insecure` | `boolean` | OPTIONAL (remote Git only) | `true` or `false` | Manifest-side approval for an `http://` dependency; the install command still requires its separate insecure-host opt-in. | +| `agents` | `list` | OPTIONAL | Non-empty flat agent names | Installs only the selected agent primitives from this dependency. Omitted means all agents. | | `skills` | `list` | OPTIONAL | Non-empty skill names or `["*"]` | Installs only the selected skills from a dependency that exposes selectable skills. | | `targets` | `list` | OPTIONAL | Target slugs. Stable: `copilot`, `claude`, `grok-build`, `cursor`, `kiro`, `opencode`, `gemini`, `antigravity`, `codex`, `windsurf`, `agent-skills`. Experimental: `grok-cloud`, `openclaw`, `hermes`, `copilot-cowork`, `copilot-app`. | Restricts which install targets receive this dependency's target-scoped primitives. Omitted = all active install targets. Effective reach = install targets INTERSECT this list. | @@ -543,10 +544,11 @@ Registry dependency (whole package or virtual sub-path): version: 1.4.0 alias: review # OPTIONAL -# Skill and target subset install from a registry package +# Agent, skill, and target subset install from a registry package - id: acme/toolkit registry: jf-skills version: ^2.0.0 + agents: [planner, reviewer] # OPTIONAL - install only named agents skills: [deploy, lint] # OPTIONAL - install only named skills (same as git-longhand) targets: [docker] # OPTIONAL - restrict deployment targets ``` diff --git a/docs/src/content/docs/specs/openapm-v0.1.md b/docs/src/content/docs/specs/openapm-v0.1.md index 48b443121..aa8c85fcb 100644 --- a/docs/src/content/docs/specs/openapm-v0.1.md +++ b/docs/src/content/docs/specs/openapm-v0.1.md @@ -543,6 +543,7 @@ and MUST NOT use both on the same entry. | `ref` | no | Branch, tag, semver range, or commit SHA (git form). | | `path` | no / yes (local form) | Subpath within repo, or local filesystem path. | | `alias` | no | Local alias. | +| `agents` | no | Non-empty list selecting flat agent primitive names from this dependency. | | `skills` | no | Skill-subset selection for dependencies that expose selectable skills (see [Section 8.1](#81-primitive-types)). | @@ -585,6 +586,16 @@ registry-sourced entry with a non-registry-shaped entry, the implementation MUST reject the update with a diagnostic naming the identity, rather than silently converting it. + +**[req-mf-025]** A conforming **consumer** implementation that supports +agent primitives MUST treat an object-form dependency's `agents:` field +as a non-empty inclusion list of flat agent names: only matching agents +from that dependency are deployed to active targets. The consumer MUST +reject an empty list or a name containing a path separator, MUST record +the sorted selection as `agent_subset` in the lockfile, and MUST replay +that recorded selection on a subsequent install. When `agents:` is +absent, all agents in the dependency remain eligible for deployment. + #### 4.3.3 Virtual packages A dependency MAY target a subdirectory or a file within a repository @@ -788,6 +799,7 @@ This section's normative statements are: [req-mf-019](#req-mf-019), [req-mf-020](#req-mf-020), [req-mf-021](#req-mf-021), [req-mf-022](#req-mf-022), [req-mf-023](#req-mf-023), [req-mf-024](#req-mf-024), + [req-mf-025](#req-mf-025), [req-ext-001](#req-ext-001), [req-ext-002](#req-ext-002), [req-tg-004](#req-tg-004), [req-sc-006](#req-sc-006). @@ -3019,6 +3031,7 @@ conformance statement identifying: [req-mf-019](#req-mf-019), [req-mf-020](#req-mf-020), [req-mf-021](#req-mf-021), [req-mf-022](#req-mf-022), [req-mf-023](#req-mf-023), [req-mf-024](#req-mf-024), +[req-mf-025](#req-mf-025), [req-ext-001](#req-ext-001), [req-lk-001](#req-lk-001), [req-lk-002](#req-lk-002), [req-lk-003](#req-lk-003), [req-lk-004](#req-lk-004), @@ -3407,6 +3420,7 @@ renumbering of conformance classes. | [req-mf-022](#req-mf-022) | MUST | 4.3.2 | consumer | | [req-mf-023](#req-mf-023) | MUST | 4.5 | consumer | | [req-mf-024](#req-mf-024) | MUST | 4.3.2 | consumer | +| [req-mf-025](#req-mf-025) | MUST | 4.3.2 | consumer | | [req-ext-001](#req-ext-001) | MUST | 4.1 | consumer | | [req-ext-002](#req-ext-002) | MUST | 4.1 | producer | | [req-lk-001](#req-lk-001) | MUST | 5.1 | consumer | @@ -3496,7 +3510,7 @@ renumbering of conformance classes. | [req-cf-001](#req-cf-001) | MUST | 12.5 | consumer | | [req-cf-002](#req-cf-002) | MUST | 12.3 | consumer | -**Total normative statements: 112** (107 MUST, 5 SHOULD). +**Total normative statements: 113** (108 MUST, 5 SHOULD). --- @@ -3534,6 +3548,7 @@ renumbering of conformance classes. | 0.1.26 | 2026-08-03 | Spec-citation fold for VS Code OCI/Docker MCP runtime argument resolution (closes #2438). Added [req-mf-023] (Section 4.5, consumer MUST): a non-secret runtime variable resolves every `{name}` occurrence across package runtime and package arguments, an unresolved template is never written literally, and package-scoped secret metadata uses VS Code secret-input references instead of generated config bytes. Section 4.9, Section 11.3.2, and Appendix C updated. Statement count: 109 -> 110 (105 MUST, 5 SHOULD). | | 0.1.27 | 2026-08-03 | Spec-citation fold for object-form registry identity preservation on CLI-driven manifest updates (closes the PR #2166 Mode-B silent-extension gate). Added [req-mf-024] (Section 4.3.2, consumer MUST): a consumer MUST NOT silently rewrite an existing `id:`-form (registry-sourced) manifest entry into a `git:`-form entry when persisting a subsequent CLI-driven update (e.g. an additive `--skill` pin) for the same dependency identity; when a CLI-parsed reference is ambiguous about its source but an existing manifest entry for the same identity already resolves to the `registry` source, the existing entry's source MUST be honored, and an update that would otherwise replace a registry-sourced entry with a non-registry-shaped entry MUST be rejected with a diagnostic naming the identity. Section 4.9 and Section 11.3.2 Consumer enumerations and Appendix C updated. Statement count: 110 -> 111 (106 MUST, 5 SHOULD). | | 0.1.28 | 2026-08-06 | Spec-citation fold for per-invocation executable consent in non-interactive contexts (closes #1620 Mode-B silent-extension gate). Added [req-sc-014] (Section 10.15, consumer MUST): a consumer that supports a per-invocation consent flag for bin/ executable deployment MUST deny deployment by default when stdout is not a TTY, unless the operator has explicitly opted in for that invocation; an explicit opt-in overrides the non-interactive default and permits deployment; an explicit opt-out overrides the default and denies deployment even in a terminal; the allowExecutables policy gate [req-sc-009] is evaluated first and always takes precedence. Added row 19 to the Section 10.11 summary table. Section 11.3.2 Consumer enumeration and Appendix C updated. Statement count: 111 -> 112 (107 MUST, 5 SHOULD). | +| 0.1.29 | 2026-08-22 | Spec-citation fold for per-dependency agent subsetting (closes #2491). Added [req-mf-025] (Section 4.3.2, consumer MUST): `agents:` is a non-empty flat-name inclusion list, deploys only matching agent primitives, persists as sorted `agent_subset`, and replays deterministically; absence means all agents. Added the manifest and lockfile schema fields, Section 11.3.2 enumeration, and Appendix C row. Statement count: 112 -> 113 (108 MUST, 5 SHOULD). | Errata (none at publication). diff --git a/packages/apm-guide/.apm/skills/apm-usage/commands.md b/packages/apm-guide/.apm/skills/apm-usage/commands.md index 08c619589..52c4f8685 100644 --- a/packages/apm-guide/.apm/skills/apm-usage/commands.md +++ b/packages/apm-guide/.apm/skills/apm-usage/commands.md @@ -10,7 +10,7 @@ | Command | Purpose | Key flags | |---------|---------|-----------| -| `apm install [PKGS...]` | Install APM, MCP, and LSP dependencies (supports APM packages, Claude skills (SKILL.md), and plugin collections (plugin.json)); one effective target decision drives package, MCP, and LSP phases; plain/frozen installs replay locked refs and cache state, while `--update` and `--refresh` require current upstream mutable refs; a successful non-dry-run install also reconciles deployed artifacts, lockfile ownership, and merge-hook config/sidecar entries for any target dropped from `targets:` | `--update` (deprecated; prefer `apm update`) refresh refs without accepting stale bare-cache answers, `--refresh` re-fetch all deps from upstream and re-resolve all ref pins, `--force` overwrite collisions and permit deployment after critical built-in scan findings (does NOT refresh refs by itself; `apm update --force` still requires upstream truth), `--frozen` CI-safe install that fails before any durable write when `apm.lock.yaml` is missing or out of sync with `apm.yml`, including MCP config state (mutually exclusive with `--update`, package additions, and `--mcp`; use normal install to create or repair lock state, then `apm audit` for SHA integrity), `--dry-run` (no package/deployment writes; a newly bootstrapped `apm.yml` and explicit targets are kept), `--verbose`, `--only [apm\|mcp]`, `--target` (comma-separated, e.g. `--target claude,cursor`; resolution chain `--target` > apm.yml `targets:` > `apm config set target ...` > auto-detect; this decision is reused by package, MCP, and LSP phases; unresolved required service work fails non-zero before manifest or package writes, and native MCP/LSP write failures also fail non-zero; `intellij` is MCP-only and writes JetBrains Copilot's user-scope config; explicit lists are exact, so `intellij,claude` writes those two MCP configs and `all,intellij` adds JetBrains to `all`; on auto-bootstrap when no `apm.yml` exists, recognized manifest target(s) are persisted to the new manifest's `targets:` field so a later bare `apm update` reuses them; `--target all` deprecated, see `apm compile --all`; use `kiro` for Kiro IDE; use `grok-build` for stable Grok Build rules, agents, commands, skills, and `AGENTS.md`; use `copilot-cowork` with `--global` after `apm experimental enable copilot-cowork`; use `grok-cloud` after `apm experimental enable grok-cloud` to deploy skills only to `.grok/skills/`; use `hermes` after `apm experimental enable hermes` to deploy skills + `AGENTS.md` and, at `--global`, MCP servers to `~/.hermes/config.yaml`), `--dev`, `-g` global (MCP deploys only to user-scope runtimes: Copilot CLI, Claude Code, Codex CLI, Gemini CLI, Antigravity CLI, Kiro, Windsurf, JetBrains Copilot, and Hermes when enabled), `--trust-transitive-mcp`, `--parallel-downloads N`, `--trust-bin` / `--no-trust-bin` (per-invocation consent for marketplace-plugin bin/ deployment: `--trust-bin` suppresses the trust-posture warning, `--no-trust-bin` skips bin/ even if policy allows; default deploys with a warning), `--allow-insecure`, `--allow-insecure-host HOSTNAME`, `--skill NAME` install named skills from a dependency that exposes selectable skills (repeatable; plugin manifests accept a leaf name or manifest path; a CLI name that matches no declared skill fails with available names; a stale persisted `skills:` pin that no longer matches an available source skill warns with the package, declared request names, and available names, and directs the user to edit `skills:` in apm.yml; persisted in apm.yml only on a successful CLI match; additive across separate installs -- a later `--skill X` adds to the existing pin (union) rather than replacing it, so previously deployed skills are never silently removed; `'*'` resets to the full bundle; drop a single skill by editing the `skills:` list in apm.yml then re-running install), `--legacy-skill-paths` restore per-client skill dirs, `--mcp NAME` add MCP entry using that same effective target decision (the shared decision applies, so `apm install --mcp NAME --target intellij` writes only JetBrains Copilot's MCP config; compilation target policy applies to every explicitly selected target; `apm install -g --mcp NAME` writes user-scope and bypasses the project-scope gate by design), `--transport`, `--url`, `--env KEY=VAL`, `--header KEY=VAL`, `--mcp-version`, `--registry URL` custom MCP registry, `--root DIR` redirect writes (`apm_modules/`, lockfile, `.gitignore`, integrated harness files) under DIR while `apm.yml`/`.apm/`/local deps resolve from `$PWD` (mirrors `pip install --target`; created if missing; not valid with `-g`/`--global`, which exits 2). Explicit plugin component paths must resolve inside the plugin root; missing declarations fail before deployment and lockfile commit. | +| `apm install [PKGS...]` | Install APM, MCP, and LSP dependencies (supports APM packages, Claude skills (SKILL.md), and plugin collections (plugin.json)); one effective target decision drives package, MCP, and LSP phases; plain/frozen installs replay locked refs and cache state, while `--update` and `--refresh` require current upstream mutable refs; a successful non-dry-run install also reconciles deployed artifacts, lockfile ownership, and merge-hook config/sidecar entries for any target dropped from `targets:` | `--update` (deprecated; prefer `apm update`) refresh refs without accepting stale bare-cache answers, `--refresh` re-fetch all deps from upstream and re-resolve all ref pins, `--force` overwrite collisions and permit deployment after critical built-in scan findings (does NOT refresh refs by itself; `apm update --force` still requires upstream truth), `--frozen` CI-safe install that fails before any durable write when `apm.lock.yaml` is missing or out of sync with `apm.yml`, including MCP config state (mutually exclusive with `--update`, package additions, and `--mcp`; use normal install to create or repair lock state, then `apm audit` for SHA integrity), `--dry-run` (no package/deployment writes; a newly bootstrapped `apm.yml` and explicit targets are kept), `--verbose`, `--only [apm\|mcp]`, `--target` (comma-separated, e.g. `--target claude,cursor`; resolution chain `--target` > apm.yml `targets:` > `apm config set target ...` > auto-detect; this decision is reused by package, MCP, and LSP phases; unresolved required service work fails non-zero before manifest or package writes, and native MCP/LSP write failures also fail non-zero; `intellij` is MCP-only and writes JetBrains Copilot's user-scope config; explicit lists are exact, so `intellij,claude` writes those two MCP configs and `all,intellij` adds JetBrains to `all`; on auto-bootstrap when no `apm.yml` exists, recognized manifest target(s) are persisted to the new manifest's `targets:` field so a later bare `apm update` reuses them; `--target all` deprecated, see `apm compile --all`; use `kiro` for Kiro IDE; use `grok-build` for stable Grok Build rules, agents, commands, skills, and `AGENTS.md`; use `copilot-cowork` with `--global` after `apm experimental enable copilot-cowork`; use `grok-cloud` after `apm experimental enable grok-cloud` to deploy skills only to `.grok/skills/`; use `hermes` after `apm experimental enable hermes` to deploy skills + `AGENTS.md` and, at `--global`, MCP servers to `~/.hermes/config.yaml`), `--dev`, `-g` global (MCP deploys only to user-scope runtimes: Copilot CLI, Claude Code, Codex CLI, Gemini CLI, Antigravity CLI, Kiro, Windsurf, JetBrains Copilot, and Hermes when enabled), `--trust-transitive-mcp`, `--parallel-downloads N`, `--trust-bin` / `--no-trust-bin` (per-invocation consent for marketplace-plugin bin/ deployment: `--trust-bin` suppresses the trust-posture warning, `--no-trust-bin` skips bin/ even if policy allows; default deploys with a warning), `--allow-insecure`, `--allow-insecure-host HOSTNAME`, `--agent NAME` install named flat agent primitives (repeatable, additive, persisted as `agents:`/`agent_subset`; `'*'` resets to all), `--skill NAME` install named skills from a dependency that exposes selectable skills (repeatable; plugin manifests accept a leaf name or manifest path; a CLI name that matches no declared skill fails with available names; a stale persisted `skills:` pin that no longer matches an available source skill warns with the package, declared request names, and available names, and directs the user to edit `skills:` in apm.yml; persisted in apm.yml only on a successful CLI match; additive across separate installs -- a later `--skill X` adds to the existing pin (union) rather than replacing it, so previously deployed skills are never silently removed; `'*'` resets to the full bundle; drop a single skill by editing the `skills:` list in apm.yml then re-running install), `--legacy-skill-paths` restore per-client skill dirs, `--mcp NAME` add MCP entry using that same effective target decision (the shared decision applies, so `apm install --mcp NAME --target intellij` writes only JetBrains Copilot's MCP config; compilation target policy applies to every explicitly selected target; `apm install -g --mcp NAME` writes user-scope and bypasses the project-scope gate by design), `--transport`, `--url`, `--env KEY=VAL`, `--header KEY=VAL`, `--mcp-version`, `--registry URL` custom MCP registry, `--root DIR` redirect writes (`apm_modules/`, lockfile, `.gitignore`, integrated harness files) under DIR while `apm.yml`/`.apm/`/local deps resolve from `$PWD` (mirrors `pip install --target`; created if missing; not valid with `-g`/`--global`, which exits 2). Explicit plugin component paths must resolve inside the plugin root; missing declarations fail before deployment and lockfile commit. | | `apm targets` | Show resolved deployment targets for the current project (Click group; reads filesystem signals; works with or without `apm.yml`) | `--all` also include the `agent-skills` meta-target (only meaningful with `--json`), `--json` machine-readable output. No provenance line is printed (the table is the provenance). | | `apm uninstall PKGS...` | Remove packages; identifier selection is atomic. Accepts `owner/repo`, `name@marketplace`, exact declared local paths, or portable `_local/` keys for direct local declarations with matching lock metadata. A missing or ambiguous identifier exits nonzero before scripts or APM writes. | `--dry-run`, `-g` global | | `apm prune` | Remove installed packages absent from the manifest and lockfile-resolved graph; reconcile stale dependency/deployment ownership after interrupted runs without deleting files based only on ghost metadata or dropping shared URI deployments | `--dry-run` previews package removal and ownership repair without mutation | diff --git a/packages/apm-guide/.apm/skills/apm-usage/dependencies.md b/packages/apm-guide/.apm/skills/apm-usage/dependencies.md index 4ea300c0c..2224a3834 100644 --- a/packages/apm-guide/.apm/skills/apm-usage/dependencies.md +++ b/packages/apm-guide/.apm/skills/apm-usage/dependencies.md @@ -164,6 +164,7 @@ instead so `@` remains reserved for git usernames and version syntax. | `alias` | OPTIONAL | Install under a custom directory name (`^[a-zA-Z0-9._-]+$`). | | `type` | OPTIONAL | Set to `gitlab` for self-managed GitLab on a bespoke hostname. Generic hosts do not receive APM-managed PATs on HTTP file reads. See the [lockfile spec](https://microsoft.github.io/apm/reference/lockfile-spec/#lockfile-identity-keys) for keying rules. | | `allow_insecure` | OPTIONAL | Manifest-side approval for an `http://` dependency; the install command still requires its separate insecure-host opt-in. | +| `agents` | OPTIONAL | Install only named agents from the dependency. Non-empty list of flat agent names. | | `skills` | OPTIONAL | Install only named skills from a skill bundle. | | `targets` | OPTIONAL | Consumer-side harness subset for that dependency's target-scoped primitives. Non-empty list of target names. | @@ -194,6 +195,7 @@ and `alias`. |-------|----------|-------------| | `path` | REQUIRED | Filesystem path (must start with `./`, `../`, `/`, or `~/`). | | `alias` | OPTIONAL | Install under a custom directory name (`^[a-zA-Z0-9._-]+$`). | +| `agents` | OPTIONAL | Consumer-side agent subset for that dependency. Non-empty list of flat names. | | `skills` | OPTIONAL | Consumer-side skill subset for that dependency. Non-empty list of skill names. | | `targets` | OPTIONAL | Consumer-side harness subset for that dependency's target-scoped primitives. Non-empty list of target names. | @@ -205,6 +207,7 @@ package's directory, not the project root. - path: ./packages/local-review-kit alias: local-review-kit + agents: [planner] skills: [reviewer] targets: [claude] ``` diff --git a/packages/apm-guide/.apm/skills/apm-usage/governance.md b/packages/apm-guide/.apm/skills/apm-usage/governance.md index 8e9ddb1cb..44c347d4c 100644 --- a/packages/apm-guide/.apm/skills/apm-usage/governance.md +++ b/packages/apm-guide/.apm/skills/apm-usage/governance.md @@ -373,6 +373,7 @@ These checks run without a policy file: - `deployment-ledger-owners` -- every canonical deployment owner and active owner resolves to a current dependency, `.`, or `local-bundle` - `deployed-files-present` -- all deployed files exist - `no-orphaned-packages` -- no packages in lockfile absent from manifest +- `agent-subset-consistency` -- selected agent subsets match the lockfile - `skill-subset-consistency` -- selected skill subsets match the lockfile - `config-consistency` -- MCP configs match lockfile - `content-integrity` -- no critical Unicode in deployed files, and no SHA-256 drift between on-disk content and the hash recorded at install time (line endings are normalized, so CRLF/LF platform differences never false-positive) diff --git a/src/apm_cli/commands/_apm_yml_writer.py b/src/apm_cli/commands/_apm_yml_writer.py index 819a4cd8d..1c156e10f 100644 --- a/src/apm_cli/commands/_apm_yml_writer.py +++ b/src/apm_cli/commands/_apm_yml_writer.py @@ -7,6 +7,7 @@ from pathlib import Path from ..models.dependency.reference import DependencyReference +from ..models.dependency.subsets import parse_agent_subset from ..utils.yaml_io import dump_yaml, load_yaml @@ -25,6 +26,15 @@ def set_skill_subset_for_entry( return _set_subset_for_entry(manifest_path, repo_url, "skills", subset) +def set_agent_subset_for_entry( + manifest_path: Path, + repo_url: str, + subset: list[str] | None, +) -> bool: + """Promote entry to dict form and set/clear agents: field.""" + return _set_subset_for_entry(manifest_path, repo_url, "agents", subset) + + def set_target_subset_for_entry( manifest_path: Path, repo_url: str, @@ -112,7 +122,9 @@ def _apply_subset(entry, field: str | list[str] | None, subset: list[str] | None return entry # Determine if we should set or clear - if field == "skills": + if field == "agents": + ref.agent_subset = parse_agent_subset(subset) if subset else None + elif field == "skills": ref.skill_subset = sorted(set(subset)) if subset else None elif field == "targets": ref.target_subset = sorted({name.strip().lower() for name in subset}) if subset else None diff --git a/src/apm_cli/commands/install.py b/src/apm_cli/commands/install.py index 69da1da8a..5bc6877b8 100644 --- a/src/apm_cli/commands/install.py +++ b/src/apm_cli/commands/install.py @@ -54,7 +54,9 @@ from apm_cli.install.mcp.writer import _add_mcp_to_apm_yml # noqa: F401 from apm_cli.install.package_resolution import ( GIT_PARENT_USER_SCOPE_ERROR, + apply_cli_agent_pin, apply_cli_skill_pin, + cli_agent_subset, cli_skill_subset, dependency_reference_to_yaml_entry, persist_dependency_list_if_changed, @@ -183,6 +185,8 @@ class InstallContext: legacy_skill_paths: bool = False frozen: bool = False plan_callback: "Callable[[UpdatePlan], bool] | None" = None + agent_subset: "builtins.tuple[str, ...] | None" = None + agent_subset_from_cli: bool = False skill_subset: "builtins.tuple[str, ...] | None" = None skill_subset_from_cli: bool = False audit_override: str | None = None @@ -260,6 +264,8 @@ def _resolve_package_references( logger=None, scope=None, allow_insecure=False, + agent_subset=None, + agent_subset_from_cli=False, skill_subset=None, skill_subset_from_cli=False, default_registry=None, @@ -394,6 +400,15 @@ def warning_handler(msg): dependency_reference_cls=DependencyReference, logger=logger, ) + apply_cli_agent_pin( + dep_ref, + agent_subset, + agent_subset_from_cli, + current_deps, + _apm_yml_entries, + dependency_reference_cls=DependencyReference, + logger=logger, + ) apply_cli_skill_pin( dep_ref, skill_subset, @@ -434,7 +449,7 @@ def warning_handler(msg): logger.validation_fail(package, scope_reject) continue - if skill_subset and canonical not in _apm_yml_entries: + if (agent_subset or skill_subset) and canonical not in _apm_yml_entries: _apm_yml_entries[canonical] = dep_ref.to_apm_yml_entry() _apm_yml_entries.setdefault(canonical, dep_ref.to_apm_yml_entry()) @@ -574,6 +589,8 @@ def _validate_and_add_packages_to_apm_yml( auth_resolver=None, scope=None, allow_insecure=False, + agent_subset=None, + agent_subset_from_cli=False, skill_subset=None, skill_subset_from_cli=False, ): @@ -640,6 +657,8 @@ def _validate_and_add_packages_to_apm_yml( logger=logger, scope=scope, allow_insecure=allow_insecure, + agent_subset=agent_subset, + agent_subset_from_cli=agent_subset_from_cli, skill_subset=skill_subset, skill_subset_from_cli=skill_subset_from_cli, default_registry=_default_registry_for_cli, @@ -837,6 +856,22 @@ def _handle_mcp_install( # noqa: PLR0913 ) +def _validate_cli_agent_subset( + agent_names: builtins.tuple[str, ...], + packages: builtins.tuple[str, ...], +) -> builtins.tuple[str, ...] | None: + """Validate CLI agent names and require dependency-scoped persistence.""" + if agent_names and not packages: + raise click.UsageError( + "--agent requires at least one package argument so the selection " + "can be persisted for that dependency." + ) + try: + return cli_agent_subset(agent_names) + except ValueError as exc: + raise click.BadParameter(str(exc), param_hint="--agent") from exc + + @click.command( help="Install APM, MCP, and LSP dependencies (supports APM packages, Claude skills (SKILL.md), and plugin collections (plugin.json); auto-creates apm.yml; use --allow-insecure for http:// packages)" ) @@ -1011,6 +1046,13 @@ def _handle_mcp_install( # noqa: PLR0913 "or a stdio command (self-defined entries)." ), ) +@click.option( + "--agent", + "agent_names", + multiple=True, + metavar="NAME", + help="Install only named agent(s) from a package. Repeatable and persisted in apm.yml and apm.lock. Additive across installs; use --agent '*' to reset to all agents.", +) @click.option( "--skill", "skill_names", @@ -1126,6 +1168,7 @@ def install( # noqa: PLR0913 header_pairs, mcp_version, registry_url, + agent_names, skill_names, no_policy, audit_mode, @@ -1273,6 +1316,7 @@ def install( # noqa: PLR0913 "--allow-protocol-fallback": allow_protocol_fallback, "--mcp": mcp_name, "--registry": registry_url, + "--agent": bool(agent_names), "--skill": bool(skill_names), "--parallel-downloads": parallel_downloads != 4, "--allow-insecure": allow_insecure, @@ -1359,9 +1403,10 @@ def install( # noqa: PLR0913 any_transport_flag=use_ssh or use_https or allow_protocol_fallback, registry_url=validated_registry_url, ) - # Normalize --skill: '*' means all (same as absent). Reject with --mcp. - if skill_names and mcp_name is not None: - raise click.UsageError("--skill cannot be combined with --mcp.") + # Normalize primitive subsets: '*' means all. Reject with --mcp. + if (agent_names or skill_names) and mcp_name is not None: + raise click.UsageError("--agent/--skill cannot be combined with --mcp.") + _agent_subset = _validate_cli_agent_subset(agent_names, packages) _skill_subset = cli_skill_subset(skill_names) if mcp_name is not None: @@ -1497,6 +1542,8 @@ def install( # noqa: PLR0913 auth_resolver=auth_resolver, scope=scope, allow_insecure=allow_insecure, + agent_subset=_agent_subset, + agent_subset_from_cli=bool(agent_names), skill_subset=_skill_subset, skill_subset_from_cli=bool(skill_names), ) @@ -1540,6 +1587,8 @@ def install( # noqa: PLR0913 legacy_skill_paths=legacy_skill_paths, frozen=frozen, plan_callback=None, + agent_subset=_agent_subset, + agent_subset_from_cli=bool(agent_names), skill_subset=_skill_subset, skill_subset_from_cli=bool(skill_names), trust_bin=trust_bin, @@ -1866,6 +1915,8 @@ def _install_apm_packages(ctx, outcome): trust_transitive_mcp=ctx.trust_transitive_mcp, frozen=ctx.frozen, plan_callback=ctx.plan_callback, + agent_subset=ctx.agent_subset, + agent_subset_from_cli=ctx.agent_subset_from_cli, skill_subset=ctx.skill_subset, skill_subset_from_cli=ctx.skill_subset_from_cli, refresh=ctx.refresh, @@ -2000,96 +2051,11 @@ def _post_install_summary( ) -# --------------------------------------------------------------------------- -# Install engine -# --------------------------------------------------------------------------- - - -# Re-exports for backward compatibility -- the real implementations live -# in apm_cli.install.services (P1 -- DI seam). Tests that -# @patch("apm_cli.commands.install._integrate_package_primitives") still -# work because patching this module-level alias rebinds the name where -# call-sites in this module would look it up. Tests inside this codebase -# now patch the canonical apm_cli.install.services._integrate_package_primitives -# directly to avoid relying on transitive aliasing. - - -# --------------------------------------------------------------------------- -# Pipeline entry point -- thin re-export preserving the patch path -# ``apm_cli.commands.install._install_apm_dependencies`` used by tests. -# -# The real implementation lives in ``apm_cli.install.pipeline`` (F2). -# --------------------------------------------------------------------------- -def _install_apm_dependencies( # noqa: PLR0913 - apm_package: "APMPackage", - update_refs: bool = False, - verbose: bool = False, - only_packages: "builtins.list | None" = None, - force: bool = False, - parallel_downloads: int = 4, - logger: "InstallLogger" = None, - scope=None, - auth_resolver: "AuthResolver" = None, - target: str | None = None, - target_decision: "EffectiveTargetDecision | None" = None, - allow_insecure: bool = False, - allow_insecure_hosts=(), - marketplace_provenance: dict = None, - protocol_pref=None, - allow_protocol_fallback: "bool | None" = None, - no_policy: bool = False, - audit_override: "str | None" = None, - skill_subset: "builtins.tuple | None" = None, - skill_subset_from_cli: bool = False, - legacy_skill_paths: bool = False, - trust_transitive_mcp: bool = False, - frozen: bool = False, - plan_callback=None, - refresh: bool = False, - lockfile_only: bool = False, - transaction: "InstallTransaction | None" = None, -): - """Thin wrapper -- builds an :class:`InstallRequest` and delegates to - :class:`apm_cli.install.service.InstallService`. - - Kept here so that ``@patch("apm_cli.commands.install._install_apm_dependencies")`` - continues to intercept calls from the Click handler. The service - itself is the typed Application Service entry point for any future - programmatic callers. - """ +def _install_apm_dependencies(*args, **kwargs): + """Preserve the historical command-module patch seam.""" if not APM_DEPS_AVAILABLE: raise RuntimeError("APM dependency system not available") - from apm_cli.install.request import InstallRequest - from apm_cli.install.service import InstallService + from apm_cli.install.entrypoint import install_apm_dependencies - request = InstallRequest( - apm_package=apm_package, - update_refs=update_refs, - verbose=verbose, - only_packages=only_packages, - force=force, - parallel_downloads=parallel_downloads, - logger=logger, - scope=scope, - auth_resolver=auth_resolver, - target=target, - target_decision=target_decision, - allow_insecure=allow_insecure, - allow_insecure_hosts=allow_insecure_hosts, - marketplace_provenance=marketplace_provenance, - protocol_pref=protocol_pref, - allow_protocol_fallback=allow_protocol_fallback, - no_policy=no_policy, - audit_override=audit_override, - skill_subset=skill_subset, - skill_subset_from_cli=skill_subset_from_cli, - legacy_skill_paths=legacy_skill_paths, - trust_transitive_mcp=trust_transitive_mcp, - frozen=frozen, - plan_callback=plan_callback, - refresh=refresh, - lockfile_only=lockfile_only, - transaction=transaction, - ) - return InstallService().run(request) + return install_apm_dependencies(*args, **kwargs) diff --git a/src/apm_cli/deps/lockfile.py b/src/apm_cli/deps/lockfile.py index dc939287d..9d4f187a9 100644 --- a/src/apm_cli/deps/lockfile.py +++ b/src/apm_cli/deps/lockfile.py @@ -197,6 +197,7 @@ class LockedDependency: source_digest: str | None = None # sha256 digest of the marketplace manifest is_insecure: bool = False # True when the locked source was http:// allow_insecure: bool = False # True when the manifest explicitly allowed HTTP + agent_subset: list[str] = field(default_factory=list) # Sorted flat agent names skill_subset: list[str] = field(default_factory=list) # Sorted skill names for SKILL_BUNDLE target_subset: list[str] = field(default_factory=list) # Audit-only consumer target subset @@ -357,6 +358,8 @@ def to_dict(self) -> dict[str, Any]: result["is_insecure"] = True if self.allow_insecure: result["allow_insecure"] = True + if self.agent_subset: + result["agent_subset"] = sorted(self.agent_subset) if self.skill_subset: result["skill_subset"] = sorted(self.skill_subset) if self.target_subset: @@ -445,6 +448,7 @@ def from_dict(cls, data: dict[str, Any]) -> LockedDependency: "source_digest", "is_insecure", "allow_insecure", + "agent_subset", "skill_subset", "target_subset", "resolved_url", @@ -493,6 +497,7 @@ def from_dict(cls, data: dict[str, Any]) -> LockedDependency: source_digest=data.get("source_digest"), is_insecure=data.get("is_insecure", False), allow_insecure=data.get("allow_insecure", False), + agent_subset=list(data.get("agent_subset") or []), skill_subset=list(data.get("skill_subset") or []), target_subset=list(data.get("target_subset") or []), resolved_url=data.get("resolved_url"), @@ -633,6 +638,9 @@ def from_dependency_ref( is_dev=is_dev, is_insecure=dep_ref.is_insecure, allow_insecure=dep_ref.allow_insecure, + agent_subset=sorted(dep_ref.agent_subset) + if isinstance(getattr(dep_ref, "agent_subset", None), list) + else [], skill_subset=sorted(dep_ref.skill_subset) if isinstance(getattr(dep_ref, "skill_subset", None), list) else [], @@ -687,6 +695,7 @@ def to_dependency_ref(self) -> DependencyReference: is_insecure=self.is_insecure, allow_insecure=self.allow_insecure, source=self.source, + agent_subset=sorted(self.agent_subset) if self.agent_subset else None, skill_subset=sorted(self.skill_subset) if self.skill_subset else None, target_subset=sorted(self.target_subset) if self.target_subset else None, ).with_derived_provider_coordinates() diff --git a/src/apm_cli/install/context.py b/src/apm_cli/install/context.py index 611323833..f15e0224b 100644 --- a/src/apm_cli/install/context.py +++ b/src/apm_cli/install/context.py @@ -191,6 +191,9 @@ class InstallContext: policy_enforcement_active: bool = False no_policy: bool = False # W2-escape-hatch will wire --no-policy here audit_override: str | None = None # --audit/--no-audit CLI override (off|warn|block) + agent_subset: tuple[str, ...] | None = None # --agent filter for package agents + agent_subset_from_cli: bool = False # True when user passed --agent (even '*') + agent_subset_cli_dep_keys: set[str] = field(default_factory=set) # pipeline setup skill_subset: tuple[str, ...] | None = None # --skill filter for SKILL_BUNDLE packages skill_subset_from_cli: bool = False # True when user passed --skill (even --skill '*') early_lockfile: Any = None # LockFile read before pipeline phases (avoids re-read) @@ -232,3 +235,11 @@ def __post_init__(self) -> None: # test gets source_root == project_root for free. if self.source_root is None: self.source_root = self.project_root + if self.agent_subset_from_cli and not self.agent_subset_cli_dep_keys: + from apm_cli.install.package_selection import cli_agent_subset_dep_keys + + self.agent_subset_cli_dep_keys = cli_agent_subset_dep_keys( + self.all_apm_deps, + self.only_packages, + agent_subset_from_cli=True, + ) diff --git a/src/apm_cli/install/drift.py b/src/apm_cli/install/drift.py index d74cc2c94..0239c3d13 100644 --- a/src/apm_cli/install/drift.py +++ b/src/apm_cli/install/drift.py @@ -647,6 +647,7 @@ def run_replay(config: ReplayConfig, logger: CheckLogger) -> Path: package_name=dep_key, logger=None, scope=None, + agent_subset=tuple(package_info.dependency_ref.agent_subset or ()) or None, skill_subset=tuple(package_info.dependency_ref.skill_subset or ()) or None, ctx=None, scratch_root=scratch_root, diff --git a/src/apm_cli/install/entrypoint.py b/src/apm_cli/install/entrypoint.py new file mode 100644 index 000000000..5573f4d3e --- /dev/null +++ b/src/apm_cli/install/entrypoint.py @@ -0,0 +1,86 @@ +"""Programmatic entry point for dependency installation.""" + +# This compatibility adapter intentionally mirrors the pipeline's request fields. +# pylint: disable=duplicate-code + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Callable # noqa: UP035 + +if TYPE_CHECKING: + from apm_cli.core.auth import AuthResolver + from apm_cli.core.command_logger import InstallLogger + from apm_cli.core.scope import InstallScope + from apm_cli.core.target_detection import EffectiveTargetDecision + from apm_cli.install.plan import UpdatePlan + from apm_cli.install.transaction import InstallTransaction + from apm_cli.models.apm_package import APMPackage + + +def install_apm_dependencies( # noqa: PLR0913 + apm_package: APMPackage, + update_refs: bool = False, + verbose: bool = False, + only_packages: list[str] | None = None, + force: bool = False, + parallel_downloads: int = 4, + logger: InstallLogger | None = None, + scope: InstallScope | None = None, + auth_resolver: AuthResolver | None = None, + target: str | list[str] | None = None, + target_decision: EffectiveTargetDecision | None = None, + allow_insecure: bool = False, + allow_insecure_hosts: tuple[str, ...] = (), + marketplace_provenance: dict[str, Any] | None = None, + protocol_pref: Any = None, + allow_protocol_fallback: bool | None = None, + no_policy: bool = False, + audit_override: str | None = None, + agent_subset: tuple[str, ...] | None = None, + agent_subset_from_cli: bool = False, + skill_subset: tuple[str, ...] | None = None, + skill_subset_from_cli: bool = False, + legacy_skill_paths: bool = False, + trust_transitive_mcp: bool = False, + frozen: bool = False, + plan_callback: Callable[[UpdatePlan], bool] | None = None, + refresh: bool = False, + lockfile_only: bool = False, + transaction: InstallTransaction | None = None, +): + """Build an install request and delegate it to the application service.""" + from apm_cli.install.request import InstallRequest + from apm_cli.install.service import InstallService + + request = InstallRequest( + apm_package=apm_package, + update_refs=update_refs, + verbose=verbose, + only_packages=only_packages, + force=force, + parallel_downloads=parallel_downloads, + logger=logger, + scope=scope, + auth_resolver=auth_resolver, + target=target, + target_decision=target_decision, + allow_insecure=allow_insecure, + allow_insecure_hosts=allow_insecure_hosts, + marketplace_provenance=marketplace_provenance, + protocol_pref=protocol_pref, + allow_protocol_fallback=allow_protocol_fallback, + no_policy=no_policy, + audit_override=audit_override, + agent_subset=agent_subset, + agent_subset_from_cli=agent_subset_from_cli, + skill_subset=skill_subset, + skill_subset_from_cli=skill_subset_from_cli, + legacy_skill_paths=legacy_skill_paths, + trust_transitive_mcp=trust_transitive_mcp, + frozen=frozen, + plan_callback=plan_callback, + refresh=refresh, + lockfile_only=lockfile_only, + transaction=transaction, + ) + return InstallService().run(request) diff --git a/src/apm_cli/install/outcome.py b/src/apm_cli/install/outcome.py index 53004dc06..be25d990b 100644 --- a/src/apm_cli/install/outcome.py +++ b/src/apm_cli/install/outcome.py @@ -36,12 +36,15 @@ def require_requested_components( requested: Iterable[str], available: Collection[str], package: str, + match_leaf: bool = True, ) -> bool: """Record one canonical failure when requested components are unavailable.""" requested_values = tuple(str(value) for value in requested) available_names = frozenset(str(value) for value in available) missing = tuple( - value for value in requested_values if _component_name(value) not in available_names + value + for value in requested_values + if (value if not match_leaf else _component_name(value)) not in available_names ) if not missing: return True diff --git a/src/apm_cli/install/package_resolution.py b/src/apm_cli/install/package_resolution.py index 68ceda985..a18ba2cd0 100644 --- a/src/apm_cli/install/package_resolution.py +++ b/src/apm_cli/install/package_resolution.py @@ -210,6 +210,39 @@ def get_existing_skill_subset( return list(subset) if subset else None +def get_existing_agent_subset( + current_deps: builtins.list, + identity: str, + *, + dependency_reference_cls: Any, +) -> builtins.list[str] | None: + """Return the persisted ``agents:`` list for *identity*, or None.""" + existing_ref = get_existing_dep_ref_for_identity( + current_deps, identity, dependency_reference_cls=dependency_reference_cls + ) + if existing_ref is None: + return None + subset = getattr(existing_ref, "agent_subset", None) + return list(subset) if subset else None + + +def normalize_and_merge_agent_subset( + cli_subset: builtins.tuple[str, ...], + current_deps: builtins.list, + identity: str, + *, + dependency_reference_cls: Any, +) -> builtins.list[str]: + """Normalize CLI ``--agent`` names and merge persisted manifest agents.""" + seen = {name.strip() for name in cli_subset if name.strip()} + existing = get_existing_agent_subset( + current_deps, identity, dependency_reference_cls=dependency_reference_cls + ) + if existing: + seen.update(existing) + return sorted(seen) + + def normalize_and_merge_skill_subset( cli_subset: builtins.tuple[str, ...], current_deps: builtins.list, @@ -267,6 +300,23 @@ def effective_deploy_skill_subset( return builtins.tuple(sorted(merged)) or None +def effective_deploy_agent_subset( + *, + agent_subset_from_cli: bool, + cli_subset: builtins.tuple[str, ...] | builtins.list[str] | None, + persisted_subset: builtins.tuple[str, ...] | builtins.list[str] | None, +) -> builtins.tuple[str, ...] | None: + """Resolve the additive agent subset to deploy, or None for all agents.""" + if agent_subset_from_cli and not cli_subset: + return None + merged: builtins.set[str] = builtins.set() + if persisted_subset: + merged.update(persisted_subset) + if cli_subset: + merged.update(cli_subset) + return builtins.tuple(sorted(merged)) or None + + def cli_skill_subset( skill_names: builtins.tuple[str, ...], ) -> builtins.tuple[str, ...] | None: @@ -281,6 +331,20 @@ def cli_skill_subset( return None +def cli_agent_subset( + agent_names: builtins.tuple[str, ...], +) -> builtins.tuple[str, ...] | None: + """Resolve raw CLI ``--agent`` names to a subset, or None for all agents.""" + if not agent_names: + return None + + from apm_cli.models.dependency.subsets import parse_agent_subset + + named_agents = [name for name in agent_names if name != "*"] + normalized = parse_agent_subset(named_agents) if named_agents else [] + return None if len(named_agents) != len(agent_names) else builtins.tuple(normalized) + + def apply_cli_skill_pin( dep_ref: Any, cli_subset: builtins.tuple[str, ...] | None, @@ -319,6 +383,36 @@ def apply_cli_skill_pin( ) +def apply_cli_agent_pin( + dep_ref: Any, + cli_subset: builtins.tuple[str, ...] | None, + agent_subset_from_cli: bool, + current_deps: builtins.list, + apm_yml_entries: dict, + *, + dependency_reference_cls: Any, + logger: Any | None = None, +) -> None: + """Attach, merge, or reset a CLI ``--agent`` pin on ``dep_ref``.""" + identity = dep_ref.get_identity() + if cli_subset: + dep_ref.agent_subset = normalize_and_merge_agent_subset( + cli_subset, + current_deps, + identity, + dependency_reference_cls=dependency_reference_cls, + ) + return + if agent_subset_from_cli: + dep_ref.agent_subset = None + apm_yml_entries[dep_ref.to_canonical()] = dep_ref.to_apm_yml_entry() + if logger: + logger.verbose_detail( + f" [i] {identity}: agent pin reset to full package " + "(--agent '*'); a later bare 'apm install' deploys all agents" + ) + + def manifest_has_different_entry_for_identity( current_deps: builtins.list, identity: str, diff --git a/src/apm_cli/install/package_selection.py b/src/apm_cli/install/package_selection.py index 460b1b4c4..9aa72ee0f 100644 --- a/src/apm_cli/install/package_selection.py +++ b/src/apm_cli/install/package_selection.py @@ -6,6 +6,7 @@ if TYPE_CHECKING: from apm_cli.core.command_logger import _ValidationOutcome + from apm_cli.models.dependency import DependencyReference def only_packages_from_validation( @@ -24,3 +25,29 @@ def only_packages_from_validation( seen.add(canonical) selected.append(canonical) return selected + + +def cli_agent_subset_dep_keys( + direct_dependencies: list[DependencyReference], + only_packages: list[str] | None, + *, + agent_subset_from_cli: bool, +) -> set[str]: + """Return direct dependency keys targeted by this invocation's ``--agent``.""" + if not agent_subset_from_cli or not only_packages: + return set() + + from apm_cli.models.dependency import DependencyReference + + selected_identities: set[str] = set() + for package in only_packages: + try: + selected_identities.add(DependencyReference.parse(package).get_identity()) + except Exception: + selected_identities.add(package) + + return { + dependency.get_unique_key() + for dependency in direct_dependencies + if dependency.get_identity() in selected_identities + } diff --git a/src/apm_cli/install/phases/lockfile.py b/src/apm_cli/install/phases/lockfile.py index 3ccf9b9d4..69c25871c 100644 --- a/src/apm_cli/install/phases/lockfile.py +++ b/src/apm_cli/install/phases/lockfile.py @@ -20,7 +20,10 @@ from typing import TYPE_CHECKING from apm_cli.core.scope import is_user_scope -from apm_cli.install.package_resolution import effective_deploy_skill_subset +from apm_cli.install.package_resolution import ( + effective_deploy_agent_subset, + effective_deploy_skill_subset, +) from apm_cli.utils.content_hash import compute_file_hash if TYPE_CHECKING: @@ -132,6 +135,8 @@ def build_and_save(self) -> None: self._attach_exec_status(lockfile) # Apply CLI --skill override to lockfile entries (skill_bundle only) self._attach_skill_subset_override(lockfile) + # Apply CLI --agent override only to explicitly selected dependencies. + self._attach_agent_subset_override(lockfile) # Attach content hashes captured at download/verify time self._attach_content_hashes(lockfile) # Attach declared-license provenance captured at acquire time (U6) @@ -362,6 +367,21 @@ def _attach_skill_subset_override(self, lockfile: LockFile) -> None: # subset always survives the union. locked_dep.skill_subset = list(merged) if merged else [] + def _attach_agent_subset_override(self, lockfile: LockFile) -> None: + """Union CLI ``--agent`` values into selected direct dependency entries.""" + if not self.ctx.agent_subset: + return + for dep_key in self.ctx.agent_subset_cli_dep_keys: + locked_dep = lockfile.dependencies.get(dep_key) + if locked_dep is None: + continue + merged = effective_deploy_agent_subset( + agent_subset_from_cli=self.ctx.agent_subset_from_cli, + cli_subset=self.ctx.agent_subset, + persisted_subset=locked_dep.agent_subset, + ) + locked_dep.agent_subset = list(merged) if merged else [] + def _attach_content_hashes(self, lockfile: LockFile) -> None: for dep_key, locked_dep in lockfile.dependencies.items(): if dep_key in self.ctx.package_hashes: diff --git a/src/apm_cli/install/pipeline.py b/src/apm_cli/install/pipeline.py index 5770065c4..69b17eaa0 100644 --- a/src/apm_cli/install/pipeline.py +++ b/src/apm_cli/install/pipeline.py @@ -422,6 +422,8 @@ def run_install_pipeline( # noqa: C901, PLR0913, RUF100 allow_protocol_fallback: bool | None = None, no_policy: bool = False, audit_override: str | None = None, + agent_subset: tuple | None = None, + agent_subset_from_cli: bool = False, skill_subset: tuple | None = None, skill_subset_from_cli: bool = False, legacy_skill_paths: bool = False, @@ -572,6 +574,8 @@ def run_install_pipeline( # noqa: C901, PLR0913, RUF100 old_local_deployed=_old_local_deployed, no_policy=no_policy, audit_override=audit_override, + agent_subset=agent_subset, + agent_subset_from_cli=agent_subset_from_cli, skill_subset=skill_subset, skill_subset_from_cli=skill_subset_from_cli, early_lockfile=_early_lockfile, diff --git a/src/apm_cli/install/request.py b/src/apm_cli/install/request.py index 8049acb18..9456603ac 100644 --- a/src/apm_cli/install/request.py +++ b/src/apm_cli/install/request.py @@ -47,6 +47,8 @@ class InstallRequest: allow_protocol_fallback: bool | None = None # None => read APM_ALLOW_PROTOCOL_FALLBACK env no_policy: bool = False # W2-escape-hatch: skip org policy enforcement audit_override: str | None = None # --audit/--no-audit override (off|warn|block) + agent_subset: tuple[str, ...] | None = None # --agent filter for package agents + agent_subset_from_cli: bool = False # True when user passed --agent (even '*') skill_subset: tuple[str, ...] | None = None # --skill filter for SKILL_BUNDLE packages skill_subset_from_cli: bool = False # True when user passed --skill (even --skill '*') legacy_skill_paths: bool = False # --legacy-skill-paths / APM_LEGACY_SKILL_PATHS diff --git a/src/apm_cli/install/service.py b/src/apm_cli/install/service.py index 17764c71c..3effd8dd4 100644 --- a/src/apm_cli/install/service.py +++ b/src/apm_cli/install/service.py @@ -97,6 +97,8 @@ def run(self, request: InstallRequest) -> InstallResult: allow_protocol_fallback=request.allow_protocol_fallback, no_policy=request.no_policy, audit_override=request.audit_override, + agent_subset=request.agent_subset, + agent_subset_from_cli=request.agent_subset_from_cli, skill_subset=request.skill_subset, skill_subset_from_cli=request.skill_subset_from_cli, legacy_skill_paths=request.legacy_skill_paths, diff --git a/src/apm_cli/install/services.py b/src/apm_cli/install/services.py index b7a825777..f3f68c9b2 100644 --- a/src/apm_cli/install/services.py +++ b/src/apm_cli/install/services.py @@ -250,6 +250,7 @@ def integrate_package_primitives( # noqa: PLR0913 package_name: str = "", logger: InstallLogger | None = None, scope: InstallScope | None = None, + agent_subset: tuple | None = None, skill_subset: tuple | None = None, ctx: InstallContext | None = None, scratch_root: Path | None = None, @@ -479,6 +480,8 @@ def _format_target_collapse(paths: list[str], verbose: bool) -> tuple[str, list[ _call_kwargs["user_scope"] = scope is InstallScope.USER _call_kwargs["dep_targets_active"] = dep_targets_active _call_kwargs["allowed_targets"] = allowed_dep_targets + if _prim_name == "agents": + _call_kwargs["agent_subset"] = agent_subset # Canvas integration: always pass is_first_party. Approval # is enforced by the gate above (canvas already skipped if # not approved and not is_first_party), so here we always diff --git a/src/apm_cli/install/template.py b/src/apm_cli/install/template.py index 47a719bce..2cbc32f25 100644 --- a/src/apm_cli/install/template.py +++ b/src/apm_cli/install/template.py @@ -14,7 +14,10 @@ from __future__ import annotations from apm_cli.install.helpers.security_scan import _pre_deploy_security_scan -from apm_cli.install.package_resolution import effective_deploy_skill_subset +from apm_cli.install.package_resolution import ( + effective_deploy_agent_subset, + effective_deploy_skill_subset, +) from apm_cli.install.services import IntegratorBundle, integrate_package_primitives from apm_cli.install.sources import DependencySource, Materialization @@ -116,6 +119,24 @@ def _integrate_materialization( diagnostics = ctx.diagnostics logger = ctx.logger + agent_subset_from_cli = dep_key in ctx.agent_subset_cli_dep_keys + if agent_subset_from_cli and ctx.agent_subset: + from apm_cli.install.outcome import require_requested_components + from apm_cli.integration.agent_integrator import AgentIntegrator + + available_agents = AgentIntegrator.available_agent_names(m.package_info) + if not require_requested_components( + diagnostics, + option="--agent", + component="agent", + requested=ctx.agent_subset, + available=available_agents, + package=dep_key, + match_leaf=False, + ): + ctx.package_deployed_files[dep_key] = [] + return deltas + if ctx.skill_subset_from_cli and ctx.skill_subset: from apm_cli.install.outcome import require_requested_components from apm_cli.integration.skill_integrator import SkillIntegrator @@ -151,6 +172,12 @@ def _integrate_materialization( ctx.package_deployed_files[dep_key] = [] return deltas + effective_agent_subset = effective_deploy_agent_subset( + agent_subset_from_cli=agent_subset_from_cli, + cli_subset=ctx.agent_subset if agent_subset_from_cli else None, + persisted_subset=dep_ref.agent_subset, + ) + # Per-package effective subset: ``--skill`` is additive (issue # #1786), so deploy the UNION of the persisted apm.yml ``skills:`` # and the current CLI ``--skill`` values -- a targeted ``--skill`` @@ -190,6 +217,7 @@ def _integrate_materialization( package_name=dep_key, logger=logger, scope=ctx.scope, + agent_subset=effective_agent_subset, skill_subset=effective_skill_subset, dep_target_subset=dep_ref.target_subset, ctx=ctx, diff --git a/src/apm_cli/integration/agent_integrator.py b/src/apm_cli/integration/agent_integrator.py index 920fa6709..6269b3928 100644 --- a/src/apm_cli/integration/agent_integrator.py +++ b/src/apm_cli/integration/agent_integrator.py @@ -77,6 +77,20 @@ def find_agent_files(self, package_path: Path) -> list[Path]: files.append(f) return files + @staticmethod + def agent_name(source_file: Path) -> str: + """Return the flat name used by ``agents:`` and ``--agent`` selection.""" + if source_file.name.endswith(".agent.md"): + return source_file.name[: -len(".agent.md")] + return source_file.stem + + @classmethod + def available_agent_names(cls, package_info) -> frozenset[str]: + """Return the selectable flat agent names in one materialized package.""" + return frozenset( + cls.agent_name(path) for path in cls().find_agent_files(package_info.install_path) + ) + # NOTE: find_skill_file(), integrate_skill(), and _generate_skill_agent_content() # have been REMOVED as part of T5 (skill-strategy.md). # @@ -98,7 +112,7 @@ def get_target_filename_for_target( """Generate target filename using the extension from *target*'s agents mapping.""" mapping = target.primitives.get("agents") ext = mapping.extension if mapping else ".agent.md" - stem = source_file.name[:-9] if source_file.name.endswith(".agent.md") else source_file.stem + stem = self.agent_name(source_file) return f"{stem}{ext}" def integrate_agents_for_target( @@ -111,6 +125,7 @@ def integrate_agents_for_target( managed_files: set = None, # noqa: RUF013 diagnostics=None, scope=None, + agent_subset=None, ) -> IntegrationResult: """Integrate agents from a package for a single *target*. @@ -143,7 +158,10 @@ def integrate_agents_for_target( target_paths: list[Path] = [] total_links_resolved = 0 + selected_agents = set(agent_subset) if agent_subset else None for source_file in agent_files: + if selected_agents is not None and self.agent_name(source_file) not in selected_agents: + continue # kiro_agent uses relative path from .apm/agents/ for identity. if mapping.format_id == "kiro_agent": target_relpath = self._kiro_agent_relpath(source_file, package_info.install_path) diff --git a/src/apm_cli/models/dependency/object_fields.py b/src/apm_cli/models/dependency/object_fields.py index 0efa66205..983b721cb 100644 --- a/src/apm_cli/models/dependency/object_fields.py +++ b/src/apm_cli/models/dependency/object_fields.py @@ -6,12 +6,13 @@ from collections.abc import Collection from typing import Any -from .subsets import parse_skill_subset, parse_target_subset +from .subsets import parse_agent_subset, parse_skill_subset, parse_target_subset _ALIAS_PATTERN = re.compile(r"^[a-zA-Z0-9._-]+$") _REMOTE_GIT_DEPENDENCY_FIELDS = frozenset( { "alias", + "agents", "allow_insecure", "git", "path", @@ -64,10 +65,13 @@ def reject_unknown_git_fields(entry: dict, *, parent: bool) -> None: def apply_optional_dependency_fields(dep: Any, entry: dict) -> None: - """Apply common alias, skills, and targets fields to a dependency.""" + """Apply common alias, agent, skill, and target fields to a dependency.""" alias = parse_alias_override(entry.get("alias")) if alias is not None: dep.alias = alias + agents_raw = entry.get("agents") + if agents_raw is not None: + dep.agent_subset = parse_agent_subset(agents_raw) skills_raw = entry.get("skills") if skills_raw is not None: dep.skill_subset = parse_skill_subset(skills_raw) @@ -79,6 +83,7 @@ def apply_optional_dependency_fields(dep: Any, entry: dict) -> None: def local_path_apm_yml_entry( local_path: str, alias: str | None, + agent_subset: list[str] | None, skill_subset: list[str] | None, target_subset: list[str] | None, ) -> dict[str, object]: @@ -86,6 +91,8 @@ def local_path_apm_yml_entry( entry: dict[str, object] = {"path": local_path} if alias: entry["alias"] = alias + if agent_subset: + entry["agents"] = sorted(agent_subset) if skill_subset: entry["skills"] = sorted(skill_subset) if target_subset: diff --git a/src/apm_cli/models/dependency/reference.py b/src/apm_cli/models/dependency/reference.py index f863297bb..7f4217e88 100644 --- a/src/apm_cli/models/dependency/reference.py +++ b/src/apm_cli/models/dependency/reference.py @@ -88,7 +88,8 @@ class DependencyReference(ProviderCoordinateMixin): is_insecure: bool = False # True when the dependency URL uses http:// allow_insecure: bool = False # True if this HTTP dep is explicitly allowed - # SKILL_BUNDLE subset selection (persisted in apm.yml `skills:` field) + # Primitive subset selection persisted in apm.yml. + agent_subset: list[str] | None = None # Sorted flat agent names, or None = all skill_subset: list[str] | None = None # Sorted skill names, or None = all target_subset: list[str] | None = None # Sorted lowercase target names, or None = all @@ -783,7 +784,7 @@ def parse_from_dict(cls, entry: dict) -> "DependencyReference": # Support dict-form local path: { path: ./local/dir } if "path" in entry and "git" not in entry: - reject_unknown_fields(entry, {"path", "alias", "skills", "targets"}, "path") + reject_unknown_fields(entry, {"path", "alias", "agents", "skills", "targets"}, "path") local = entry["path"] if not isinstance(local, str) or not local.strip(): raise ValueError("'path' field must be a non-empty string") @@ -1849,7 +1850,7 @@ def to_apm_yml_entry(self): - Local path deps with optional fields: returns a dict with 'path'. - HTTP (insecure) git deps: returns a dict with 'git' and 'allow_insecure' keys. - - Git deps with skill_subset or target_subset: returns a dict with 'git' plus + - Git deps with primitive or target subsets: returns a dict with 'git' plus the applicable optional keys. - Registry deps (object-form ``id:``/``registry:``): always returns a dict with 'id', 'version', plus the applicable optional keys. @@ -1876,16 +1877,19 @@ def to_apm_yml_entry(self): entry["version"] = self.reference if self.alias: entry["alias"] = self.alias + if self.agent_subset: + entry["agents"] = sorted(self.agent_subset) if self.skill_subset: entry["skills"] = sorted(self.skill_subset) if self.target_subset: entry["targets"] = sorted(self.target_subset) return entry if self.is_local and self.local_path: - if self.skill_subset or self.target_subset or self.alias: + if self.agent_subset or self.skill_subset or self.target_subset or self.alias: return local_path_apm_yml_entry( self.local_path, self.alias, + self.agent_subset, self.skill_subset, self.target_subset, ) @@ -1899,17 +1903,21 @@ def to_apm_yml_entry(self): if self.alias: entry["alias"] = self.alias entry["allow_insecure"] = self.allow_insecure + if self.agent_subset: + entry["agents"] = sorted(self.agent_subset) if self.skill_subset: entry["skills"] = sorted(self.skill_subset) if self.target_subset: entry["targets"] = sorted(self.target_subset) return entry - if self.skill_subset or self.target_subset: + if self.agent_subset or self.skill_subset or self.target_subset: entry = {"git": self._format_reference(self.repo_url).split("#", 1)[0]} if self.reference: entry["ref"] = self.reference if self.alias: entry["alias"] = self.alias + if self.agent_subset: + entry["agents"] = sorted(self.agent_subset) if self.skill_subset: entry["skills"] = sorted(self.skill_subset) if self.target_subset: diff --git a/src/apm_cli/models/dependency/registry_entry.py b/src/apm_cli/models/dependency/registry_entry.py index bc411aeb2..1edc581e6 100644 --- a/src/apm_cli/models/dependency/registry_entry.py +++ b/src/apm_cli/models/dependency/registry_entry.py @@ -13,7 +13,7 @@ class as a parameter (rather than importing it) so this module has no from ...utils.github_host import default_host from ...utils.path_security import validate_path_segments -from .subsets import parse_skill_subset, parse_target_subset +from .subsets import parse_agent_subset, parse_skill_subset, parse_target_subset _ALIAS_PATTERN = re.compile(r"^[a-zA-Z0-9._-]+$") _ID_SEGMENT_PATTERN = re.compile(r"^[a-zA-Z0-9._-]+$") @@ -30,6 +30,7 @@ def parse_registry_object_entry(dependency_reference_cls: Any, entry: dict) -> A registry: # routes to named registry; omit to use default path: prompts/foo.md # virtual sub-path; omit to install the whole package alias: # same meaning as in other object forms + agents: [x, y, z] # same meaning as in other object forms skills: [x, y, z] # same meaning as in other object forms targets: [x, y, z] # same meaning as in other object forms """ @@ -84,6 +85,9 @@ def parse_registry_object_entry(dependency_reference_cls: Any, entry: dict) -> A f"letters, numbers, dots, underscores, and hyphens" ) + agents_raw = entry.get("agents") + agent_subset = parse_agent_subset(agents_raw) if agents_raw is not None else None + skills_raw = entry.get("skills") skill_subset = parse_skill_subset(skills_raw) if skills_raw is not None else None @@ -91,7 +95,7 @@ def parse_registry_object_entry(dependency_reference_cls: Any, entry: dict) -> A target_subset = parse_target_subset(targets_raw) if targets_raw is not None else None # Reject any unknown keys to catch typos early. - known = {"registry", "id", "path", "version", "alias", "skills", "targets"} + known = {"registry", "id", "path", "version", "alias", "agents", "skills", "targets"} unknown = set(entry.keys()) - known if unknown: raise ValueError( @@ -114,6 +118,7 @@ def parse_registry_object_entry(dependency_reference_cls: Any, entry: dict) -> A alias=alias, source="registry", registry_name=registry_name, + agent_subset=agent_subset, skill_subset=skill_subset, target_subset=target_subset, ) diff --git a/src/apm_cli/models/dependency/subsets.py b/src/apm_cli/models/dependency/subsets.py index 1ada5ee9b..154c85ec2 100644 --- a/src/apm_cli/models/dependency/subsets.py +++ b/src/apm_cli/models/dependency/subsets.py @@ -56,6 +56,31 @@ def parse_skill_subset(skills_raw: object) -> list[str]: return sorted(validated) +def parse_agent_subset(agents_raw: object) -> list[str]: + """Validate and normalize object-form dependency ``agents:``.""" + if not isinstance(agents_raw, list): + raise ValueError("'agents' field must be a list of agent names") + if not agents_raw: + raise ValueError( + "agents: must contain at least one name; " + "remove the field to install all agents in the package." + ) + + seen: set[str] = set() + validated: list[str] = [] + for name in agents_raw: + if not isinstance(name, str) or not name.strip(): + raise ValueError("Each entry in 'agents' must be a non-empty string") + name = name.strip() + validate_path_segments(name, context="agents/") + if "/" in name or "\\" in name: + raise ValueError("Each entry in 'agents' must be a flat agent name") + if name not in seen: + seen.add(name) + validated.append(name) + return sorted(validated) + + def parse_target_subset(targets_raw: object) -> list[str]: """Validate and normalize object-form dependency ``targets:``.""" from apm_cli.integration.targets import KNOWN_TARGETS diff --git a/src/apm_cli/policy/ci_checks.py b/src/apm_cli/policy/ci_checks.py index d5e116a13..c2c16e016 100644 --- a/src/apm_cli/policy/ci_checks.py +++ b/src/apm_cli/policy/ci_checks.py @@ -341,6 +341,40 @@ def _check_skill_subset_consistency( ) +def _check_agent_subset_consistency( + manifest: APMPackage, + lock: LockFile, +) -> CheckResult: + """Verify lockfile agent_subset matches manifest agents: for each entry.""" + mismatches: list[str] = [] + for dep_ref in manifest.get_all_apm_dependencies(): + key = dep_ref.get_unique_key() + locked_dep = lock.get_dependency(key) + if locked_dep is None: + continue + manifest_subset = sorted(dep_ref.agent_subset) if dep_ref.agent_subset else [] + lock_subset = sorted(locked_dep.agent_subset) if locked_dep.agent_subset else [] + if manifest_subset != lock_subset: + mismatches.append( + f"{key}: manifest agents {manifest_subset} != lockfile agent_subset {lock_subset}" + ) + + if not mismatches: + return CheckResult( + name="agent-subset-consistency", + passed=True, + message="Agent subset selections match lockfile", + ) + return CheckResult( + name="agent-subset-consistency", + passed=False, + message=( + f"{len(mismatches)} agent subset mismatch(es) -- regenerate lockfile (apm install)" + ), + details=mismatches, + ) + + def _check_config_consistency( manifest: APMPackage, lock: LockFile, @@ -812,11 +846,16 @@ def _run(check: CheckResult) -> bool: if _run(_check_no_orphans(manifest, lock)): return result - # Check 6: Skill subset consistency (manifest vs lockfile) - if _run(_check_skill_subset_consistency(manifest, lock)): + # Check 6: Agent subset consistency (manifest vs lockfile) + agent_subset_failed = _run(_check_agent_subset_consistency(manifest, lock)) + # Check 7: Skill subset consistency (manifest vs lockfile) + skill_subset_failed = False + if not agent_subset_failed: + skill_subset_failed = _run(_check_skill_subset_consistency(manifest, lock)) + if agent_subset_failed or skill_subset_failed: return result - # Check 7: Config consistency (MCP) + # Check 8: Config consistency (MCP) if _run( _check_config_consistency( manifest, @@ -827,11 +866,11 @@ def _run(check: CheckResult) -> bool: ): return result - # Check 8: Content integrity + # Check 9: Content integrity if _run(_check_content_integrity(project_root, lock)): return result - # Check 9: Includes consent (advisory; never hard-fails) + # Check 10: Includes consent (advisory; never hard-fails) _run(_check_includes_consent(manifest, lock)) return result diff --git a/tests/integration/test_architecture_intent_guards.py b/tests/integration/test_architecture_intent_guards.py index 0125af576..8987102c4 100644 --- a/tests/integration/test_architecture_intent_guards.py +++ b/tests/integration/test_architecture_intent_guards.py @@ -69,7 +69,7 @@ def test_locked_dependency_reconstructs_persisted_skill_subset() -> None: that ``self.skill_subset`` is the value threaded into the reconstructed ``DependencyReference(...)`` call. It does not exercise runtime behavior -- ``tests/unit/install/test_drift.py:: - test_run_replay_threads_locked_skill_subset`` owns the runtime symptom + test_run_replay_threads_locked_primitive_subsets`` owns the runtime symptom coverage (an actual replay producing the correctly filtered primitives). The lockfile is the sole persisted record of a consumer's ``--skill`` @@ -97,7 +97,7 @@ def test_audit_replay_forwards_locked_skill_subset_without_interpreting_it() -> Like the guard above, this is a structural *routing* guard operating on the AST, not a runtime behavior test: ``tests/unit/install/ - test_drift.py::test_run_replay_threads_locked_skill_subset`` owns the + test_drift.py::test_run_replay_threads_locked_primitive_subsets`` owns the runtime symptom coverage for an actual replay run. ``integrate_package_primitives`` is the canonical owner of skill-subset diff --git a/tests/spec_conformance/test_lockfile_reqs.py b/tests/spec_conformance/test_lockfile_reqs.py index 876307739..406bce54f 100644 --- a/tests/spec_conformance/test_lockfile_reqs.py +++ b/tests/spec_conformance/test_lockfile_reqs.py @@ -87,6 +87,23 @@ def test_lockfile_v1_remains_parseable_under_v2_reader(): validate_against("lockfile-v0.1.schema.json", load_yaml_fixture(*V1)) +@pytest.mark.req("req-mf-025") +def test_lockfile_agent_subset_uses_flat_non_blank_names(): + valid = { + "lockfile_version": "1", + "dependencies": [{"repo_url": "owner/repo", "agent_subset": ["planner"]}], + } + validate_against("lockfile-v0.1.schema.json", valid) + + for invalid_name in ("", " ", ".", "..", "team/planner", r"team\planner"): + invalid = { + "lockfile_version": "1", + "dependencies": [{"repo_url": "owner/repo", "agent_subset": [invalid_name]}], + } + with pytest.raises(jsonschema.ValidationError): + validate_against("lockfile-v0.1.schema.json", invalid) + + @pytest.mark.req("req-lk-005") def test_lockfile_dependency_carries_resolved_field(): schema = load_schema("lockfile-v0.1.schema.json") diff --git a/tests/spec_conformance/test_manifest_reqs.py b/tests/spec_conformance/test_manifest_reqs.py index 019f0cdba..8ae8da1ff 100644 --- a/tests/spec_conformance/test_manifest_reqs.py +++ b/tests/spec_conformance/test_manifest_reqs.py @@ -1,6 +1,6 @@ """Manifest (apm.yml) + scheme + tag + conformance-class tests. -Covers req-mf-001..023, req-ext-001..002, req-sc-001..010, +Covers req-mf-001..025, req-ext-001..002, req-sc-001..010, req-tg-001..008, req-cf-001..002. Every requirement is exercised either by (a) schema validation @@ -340,6 +340,81 @@ def test_consumer_preserves_registry_identity_on_structured_rewrite(monkeypatch) ) +@pytest.mark.req("req-mf-025") +def test_consumer_persists_and_deploys_only_selected_agents(tmp_path: Path): + """req-mf-025: agents: is a durable flat-name inclusion filter.""" + from apm_cli.deps.lockfile import LockedDependency + from apm_cli.integration.agent_integrator import AgentIntegrator + from apm_cli.integration.targets import KNOWN_TARGETS + from apm_cli.models.apm_package import APMPackage, PackageInfo + from apm_cli.models.dependency.reference import DependencyReference + + ref = DependencyReference.parse_from_dict( + {"git": "acme/agent-pack", "agents": ["reviewer", "planner"]} + ) + locked = LockedDependency.from_dependency_ref( + ref, + resolved_commit="a" * 40, + depth=1, + resolved_by=None, + ) + replay_ref = LockedDependency.from_dict(locked.to_dict()).to_dependency_ref() + assert replay_ref.agent_subset == ["planner", "reviewer"] + + package_root = tmp_path / "package" + agents_dir = package_root / ".apm" / "agents" + agents_dir.mkdir(parents=True) + for name in ("planner", "reviewer", "writer"): + (agents_dir / f"{name}.agent.md").write_text(f"# {name}\n", encoding="utf-8") + + project_root = tmp_path / "project" + (project_root / ".github").mkdir(parents=True) + package_info = PackageInfo( + package=APMPackage(name="agent-pack", version="1.0.0", package_path=package_root), + install_path=package_root, + dependency_ref=replay_ref, + ) + AgentIntegrator().integrate_agents_for_target( + KNOWN_TARGETS["copilot"], + package_info, + project_root, + agent_subset=replay_ref.agent_subset, + ) + + deployed = {path.name for path in (project_root / ".github" / "agents").iterdir()} + assert deployed == {"planner.agent.md", "reviewer.agent.md"} + + with pytest.raises(ValueError, match="at least one"): + DependencyReference.parse_from_dict({"git": "acme/agent-pack", "agents": []}) + with pytest.raises(ValueError, match="flat agent name"): + DependencyReference.parse_from_dict({"git": "acme/agent-pack", "agents": ["team/planner"]}) + + validate_against( + "manifest-v0.1.schema.json", + { + "name": "agent-consumer", + "version": "1.0.0", + "dependencies": {"apm": [{"git": "acme/agent-pack", "agents": ["planner"]}]}, + }, + ) + for invalid_name in ("", " ", ".", "..", "team/planner", r"team\planner"): + with pytest.raises(jsonschema.ValidationError): + validate_against( + "manifest-v0.1.schema.json", + { + "name": "agent-consumer", + "version": "1.0.0", + "dependencies": {"apm": [{"git": "acme/agent-pack", "agents": [invalid_name]}]}, + }, + ) + + assert_spec_contains( + "`agents:` field", + "sorted selection as `agent_subset`", + "absent, all agents in the dependency", + ) + + # --- req-ext-001..002 -------------------------------------------------- diff --git a/tests/unit/deps/test_lockfile_consumer_contract.py b/tests/unit/deps/test_lockfile_consumer_contract.py index 23bb8b1d3..f6f07e28f 100644 --- a/tests/unit/deps/test_lockfile_consumer_contract.py +++ b/tests/unit/deps/test_lockfile_consumer_contract.py @@ -45,6 +45,7 @@ "source_digest": f"sha256:{'d' * 64}", "is_insecure": True, "allow_insecure": True, + "agent_subset": ["planner", "reviewer"], "skill_subset": ["alpha", "beta"], "target_subset": ["copilot"], "resolved_url": "https://registry.example.invalid/consume-contract.tgz", @@ -75,6 +76,7 @@ "anchored_local_path", "is_insecure", "allow_insecure", + "agent_subset", "skill_subset", "target_subset", } diff --git a/tests/unit/deps/test_lockfile_field_properties.py b/tests/unit/deps/test_lockfile_field_properties.py index e6a08e4cd..5aff650c9 100644 --- a/tests/unit/deps/test_lockfile_field_properties.py +++ b/tests/unit/deps/test_lockfile_field_properties.py @@ -89,7 +89,7 @@ def _repo_url(draw: st.DrawFn) -> str: def locked_dependency_kwargs(draw: st.DrawFn) -> dict[str, Any]: """Generate a valid, field-complete kwargs dict for ``LockedDependency``. - Order-sensitive fields (``deployed_files``, ``skill_subset``, + Order-sensitive fields (``deployed_files``, ``agent_subset``, ``skill_subset``, ``target_subset``) are generated already deduped and sorted, matching what ``to_dict()``/``from_dict()`` always converge to -- see the dedicated permutation-invariance property for the order-determinism @@ -149,6 +149,12 @@ def maybe(default: Any, value: st.SearchStrategy[Any]) -> Any: kwargs["source_digest"] = maybe(None, _SHA.map(lambda s: f"sha256:{s}")) kwargs["is_insecure"] = draw(st.booleans()) kwargs["allow_insecure"] = draw(st.booleans()) + kwargs["agent_subset"] = draw( + st.one_of( + st.just([]), + st.permutations(["planner", "reviewer", "writer"]).map(lambda p: sorted(p[:2])), + ) + ) kwargs["skill_subset"] = draw( st.one_of( st.just([]), st.permutations(["alpha", "beta", "gamma"]).map(lambda p: sorted(p[:2])) diff --git a/tests/unit/install/test_agent_subset.py b/tests/unit/install/test_agent_subset.py new file mode 100644 index 000000000..d347c02ea --- /dev/null +++ b/tests/unit/install/test_agent_subset.py @@ -0,0 +1,280 @@ +"""Agent dependency subset persistence and deployment tests (issue #2491).""" + +from __future__ import annotations + +from pathlib import Path +from types import SimpleNamespace + +import pytest +import yaml +from click.testing import CliRunner + +from apm_cli.deps.lockfile import LockedDependency, LockFile +from apm_cli.models.apm_package import APMPackage +from apm_cli.models.dependency.reference import DependencyReference +from apm_cli.policy.ci_checks import _check_agent_subset_consistency + + +def _make_agent_package( + base: Path, + names: tuple[str, ...], + *, + package_name: str = "agent-package", + dependencies: list[dict[str, str]] | None = None, +) -> Path: + package = base / package_name + agents_dir = package / ".apm" / "agents" + agents_dir.mkdir(parents=True) + for name in names: + (agents_dir / f"{name}.agent.md").write_text( + f"---\nname: {name}\ndescription: {name} agent\n---\n# {name}\n", + encoding="utf-8", + ) + manifest: dict[str, object] = {"name": package_name, "version": "1.0.0"} + if dependencies: + manifest["dependencies"] = {"apm": dependencies} + (package / "apm.yml").write_text(yaml.safe_dump(manifest), encoding="utf-8") + return package + + +def _make_project(base: Path) -> Path: + project = base / "project" + project.mkdir(parents=True) + (project / "apm.yml").write_text( + yaml.safe_dump({"name": "consumer", "version": "1.0.0"}), + encoding="utf-8", + ) + return project + + +def _install(project: Path, monkeypatch, *args: str): + from apm_cli.cli import cli + from apm_cli.models.apm_package import clear_apm_yml_cache + + clear_apm_yml_cache() + monkeypatch.chdir(project) + return CliRunner().invoke(cli, ["install", *args], catch_exceptions=False) + + +def _deployed_agents(project: Path) -> set[str]: + agents_dir = project / ".github" / "agents" + if not agents_dir.exists(): + return set() + return {path.name.removesuffix(".agent.md") for path in agents_dir.glob("*.agent.md")} + + +def _locked_agent_subset(project: Path) -> list[str]: + data = yaml.safe_load((project / "apm.lock.yaml").read_text(encoding="utf-8")) + dependencies = data.get("dependencies", []) + entries = dependencies.values() if isinstance(dependencies, dict) else dependencies + for entry in entries: + if isinstance(entry, dict) and entry.get("repo_url"): + return sorted(entry.get("agent_subset") or []) + return [] + + +class TestAgentSubsetModel: + def test_object_form_round_trip_is_sorted_and_deduplicated(self): + ref = DependencyReference.parse_from_dict( + {"git": "owner/repo", "agents": ["reviewer", "planner", "reviewer"]} + ) + + assert ref.agent_subset == ["planner", "reviewer"] + assert ref.to_apm_yml_entry()["agents"] == ["planner", "reviewer"] + + @pytest.mark.parametrize( + "value", + [[], "planner", [""], [" "], ["."], [".."], ["team/planner"], [r"team\planner"]], + ) + def test_invalid_agent_subset_is_rejected(self, value): + with pytest.raises(ValueError, match=r"agents|agent"): + DependencyReference.parse_from_dict({"git": "owner/repo", "agents": value}) + + def test_lockfile_round_trip_preserves_agent_subset(self): + locked = LockedDependency.from_dependency_ref( + DependencyReference(repo_url="owner/repo", agent_subset=["reviewer", "planner"]), + resolved_commit="a" * 40, + depth=1, + resolved_by=None, + ) + + restored = LockedDependency.from_dict(locked.to_dict()).to_dependency_ref() + + assert restored.agent_subset == ["planner", "reviewer"] + + def test_ci_baseline_detects_agent_subset_drift(self): + ref = DependencyReference(repo_url="owner/repo", agent_subset=["planner"]) + manifest = APMPackage( + name="consumer", + version="1.0.0", + dependencies={"apm": [ref]}, + ) + lock = LockFile( + dependencies={ + ref.get_unique_key(): LockedDependency( + repo_url="owner/repo", + agent_subset=["reviewer"], + ) + } + ) + + result = _check_agent_subset_consistency(manifest, lock) + + assert result.passed is False + assert result.name == "agent-subset-consistency" + assert "manifest agents ['planner']" in result.details[0] + + def test_cli_agent_subset_normalizes_with_manifest_parser(self): + from apm_cli.install.package_resolution import cli_agent_subset + + assert cli_agent_subset((" reviewer ", "planner", "planner")) == ( + "planner", + "reviewer", + ) + with pytest.raises(ValueError, match="flat agent name"): + cli_agent_subset(("team/planner",)) + with pytest.raises(ValueError, match="flat agent name"): + cli_agent_subset(("*", "team/planner")) + + def test_manifest_writeback_reuses_agent_subset_parser(self): + from apm_cli.commands._apm_yml_writer import _apply_subset + + with pytest.raises(ValueError, match="flat agent name"): + _apply_subset("owner/repo", "agents", ["team/planner"]) + + def test_resolve_records_only_selected_direct_dependency_keys(self): + from apm_cli.install.package_selection import cli_agent_subset_dep_keys + + direct = DependencyReference(repo_url="owner/direct") + unrelated = DependencyReference(repo_url="owner/unrelated") + selected_keys = cli_agent_subset_dep_keys( + [direct, unrelated], + ["owner/direct"], + agent_subset_from_cli=True, + ) + + assert selected_keys == {direct.get_unique_key()} + + def test_lockfile_override_does_not_touch_unselected_or_transitive_dependencies(self): + from apm_cli.install.phases.lockfile import LockfileBuilder + + selected_key = "owner/direct" + unselected_key = "owner/transitive" + lock = LockFile( + dependencies={ + selected_key: LockedDependency(repo_url=selected_key, agent_subset=["reviewer"]), + unselected_key: LockedDependency(repo_url=unselected_key, agent_subset=["writer"]), + } + ) + ctx = SimpleNamespace( + agent_subset=("planner",), + agent_subset_from_cli=True, + agent_subset_cli_dep_keys={selected_key}, + ) + + LockfileBuilder(ctx)._attach_agent_subset_override(lock) + + assert lock.dependencies[selected_key].agent_subset == ["planner", "reviewer"] + assert lock.dependencies[unselected_key].agent_subset == ["writer"] + + +class TestAgentSubsetInstall: + def test_cli_agent_name_must_be_flat_and_does_not_mutate_manifest(self, tmp_path, monkeypatch): + package = _make_agent_package(tmp_path / "source", ("planner",)) + project = _make_project(tmp_path / "consumer") + original_manifest = (project / "apm.yml").read_text(encoding="utf-8") + + result = _install( + project, + monkeypatch, + str(package), + "--agent", + "team/planner", + "--target", + "copilot", + ) + + assert result.exit_code == 2 + assert "flat agent name" in result.output + assert (project / "apm.yml").read_text(encoding="utf-8") == original_manifest + + def test_cli_agent_requires_an_explicit_dependency(self, tmp_path, monkeypatch): + project = _make_project(tmp_path / "consumer") + + result = _install(project, monkeypatch, "--agent", "planner", "--target", "copilot") + + assert result.exit_code == 2 + assert "requires at least one package argument" in result.output + + def test_cli_subset_does_not_leak_to_transitive_dependency(self, tmp_path, monkeypatch): + source = tmp_path / "source" + _make_agent_package(source, ("writer",), package_name="transitive") + direct = _make_agent_package( + source, + ("planner",), + package_name="direct", + dependencies=[{"path": "../transitive"}], + ) + project = _make_project(tmp_path / "consumer") + + result = _install( + project, + monkeypatch, + str(direct), + "--agent", + "planner", + "--target", + "copilot", + ) + + assert result.exit_code == 0, result.output + assert _deployed_agents(project) == {"planner", "writer"} + + def test_unknown_cli_agent_fails_without_persisting_pin(self, tmp_path, monkeypatch): + package = _make_agent_package(tmp_path / "source", ("planner", "reviewer")) + project = _make_project(tmp_path / "consumer") + original_manifest = (project / "apm.yml").read_text(encoding="utf-8") + + result = _install( + project, + monkeypatch, + str(package), + "--agent", + "missing", + "--target", + "copilot", + ) + + assert result.exit_code == 1 + assert "missing" in result.output + assert "planner" in result.output + assert (project / "apm.yml").read_text(encoding="utf-8") == original_manifest + + def test_cli_subset_is_additive_persisted_and_replayed(self, tmp_path, monkeypatch): + package = _make_agent_package(tmp_path / "source", ("planner", "reviewer", "writer")) + project = _make_project(tmp_path / "consumer") + + first = _install( + project, monkeypatch, str(package), "--agent", "planner", "--target", "copilot" + ) + assert first.exit_code == 0, first.output + assert _deployed_agents(project) == {"planner"} + + second = _install( + project, monkeypatch, str(package), "--agent", "reviewer", "--target", "copilot" + ) + assert second.exit_code == 0, second.output + assert _deployed_agents(project) == {"planner", "reviewer"} + + manifest = yaml.safe_load((project / "apm.yml").read_text(encoding="utf-8")) + assert manifest["dependencies"]["apm"][0]["agents"] == ["planner", "reviewer"] + assert _locked_agent_subset(project) == ["planner", "reviewer"] + + replay = _install(project, monkeypatch, "--target", "copilot") + assert replay.exit_code == 0, replay.output + assert _deployed_agents(project) == {"planner", "reviewer"} + + reset = _install(project, monkeypatch, str(package), "--agent", "*", "--target", "copilot") + assert reset.exit_code == 0, reset.output + assert _deployed_agents(project) == {"planner", "reviewer", "writer"} + assert _locked_agent_subset(project) == [] diff --git a/tests/unit/install/test_drift.py b/tests/unit/install/test_drift.py index 903aef324..39d26d80a 100644 --- a/tests/unit/install/test_drift.py +++ b/tests/unit/install/test_drift.py @@ -697,11 +697,11 @@ def _spy_integrate(*args, **kwargs): ) -def test_run_replay_threads_locked_skill_subset( +def test_run_replay_threads_locked_primitive_subsets( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: - """Replay must preserve the locked skill subset through dependency reconstruction.""" + """Replay must preserve locked agent and skill subsets through reconstruction.""" from apm_cli.install.drift import run_replay project_root = tmp_path / "proj" @@ -718,6 +718,7 @@ def test_run_replay_threads_locked_skill_subset( source="local", local_path="./skill-bundle", resolved_commit=None, + agent_subset=["planner", "reviewer"], skill_subset=[ "productivity/grill-me", "productivity/grilling", @@ -734,6 +735,8 @@ def _spy_integrate(*args: object, **kwargs: object) -> dict[str, list[str]]: package_info = args[0] captured.append( { + "dependency_ref_agent_subset": package_info.dependency_ref.agent_subset, + "agent_subset": kwargs.get("agent_subset"), "dependency_ref_skill_subset": package_info.dependency_ref.skill_subset, "skill_subset": kwargs.get("skill_subset"), } @@ -757,6 +760,8 @@ def _spy_integrate(*args: object, **kwargs: object) -> dict[str, list[str]]: assert captured == [ { + "dependency_ref_agent_subset": ["planner", "reviewer"], + "agent_subset": ("planner", "reviewer"), "dependency_ref_skill_subset": [ "productivity/grill-me", "productivity/grilling", diff --git a/tests/unit/policy/test_ci_checks.py b/tests/unit/policy/test_ci_checks.py index 3ad14770a..ca1f30080 100644 --- a/tests/unit/policy/test_ci_checks.py +++ b/tests/unit/policy/test_ci_checks.py @@ -1015,7 +1015,7 @@ def test_all_pass(self, tmp_path): ) result = run_baseline_checks(tmp_path) assert result.passed - assert len(result.checks) == 9 + assert len(result.checks) == 10 def test_mixed_pass_fail(self, tmp_path): # Ref mismatch (fail) + missing file (fail) + clean otherwise diff --git a/tests/unit/test_audit_policy_command.py b/tests/unit/test_audit_policy_command.py index c8bdde9e0..4a9c869a1 100644 --- a/tests/unit/test_audit_policy_command.py +++ b/tests/unit/test_audit_policy_command.py @@ -286,5 +286,5 @@ def test_baseline_only(self, runner, tmp_path, monkeypatch): # found; JSON is on stdout. Read stdout explicitly so the warning does # not corrupt JSON parsing. data = json.loads(result.stdout) - # Only the nine baseline checks, including deployment ownership. - assert data["summary"]["total"] == 9 + # Only the ten baseline checks, including deployment ownership. + assert data["summary"]["total"] == 10