diff --git a/CHANGELOG.md b/CHANGELOG.md index 1307da034..958230d0e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Ref immutability advisory: caches plugin-to-ref pins and warns when a previously pinned plugin's ref changes (#514) - Multi-marketplace shadow detection: warns when the same plugin name appears in multiple registered marketplaces (#514) +- Multi-target support: `apm.yml` `target` field now accepts a list (`target: [claude, copilot]`) and CLI `--target` accepts comma-separated values (`-t claude,copilot`). Only specified targets are compiled, installed, and packed -- no redundant output for unused tools. Single-string syntax is fully backward compatible. (#628) + ### Fixed - `apm install` no longer silently drops skills, agents, and commands when a Claude Code plugin also ships `hooks/*.json`. The package-type detection cascade now classifies plugin-shaped packages as `MARKETPLACE_PLUGIN` (which already maps hooks via the plugin synthesizer) before falling back to the hook-only classification, and emits a default-visibility `[!]` warning when a hook-only classification disagrees with the package's directory contents (#780) diff --git a/docs/src/content/docs/enterprise/policy-reference.md b/docs/src/content/docs/enterprise/policy-reference.md index 3c5b13c55..e769016c2 100644 --- a/docs/src/content/docs/enterprise/policy-reference.md +++ b/docs/src/content/docs/enterprise/policy-reference.md @@ -38,8 +38,8 @@ mcp: compilation: target: - allow: [] # vscode | claude | all - enforce: null # Enforce specific target + allow: [] # vscode | claude | cursor | opencode | codex | all + enforce: null # Enforce specific target (must be present in list) strategy: enforce: null # distributed | single-file source_attribution: false # Require source attribution @@ -205,13 +205,16 @@ Whether to trust MCP servers declared by transitive dependencies. Default: `fals ### `target.allow` / `target.enforce` -Control which compilation targets are permitted: +Control which compilation targets are permitted. With multi-target support, these policies apply to every item in the target list: + +- **`enforce`**: The enforced target must be present in the target list. Fails if missing (e.g., `enforce: vscode` requires `vscode` to appear in `target: [claude, vscode]`). +- **`allow`**: Every target in the list must be in the allowed set. Rejects any target not listed. ```yaml compilation: target: allow: [vscode, claude] # Only these targets allowed - enforce: vscode # Must use this specific target + enforce: vscode # Must be present in the target list ``` `enforce` takes precedence over `allow`. Use one or the other. diff --git a/docs/src/content/docs/guides/compilation.md b/docs/src/content/docs/guides/compilation.md index 23de213bf..201656ff3 100644 --- a/docs/src/content/docs/guides/compilation.md +++ b/docs/src/content/docs/guides/compilation.md @@ -31,13 +31,20 @@ apm compile # Auto-detects target from project structure apm compile --target copilot # Force GitHub Copilot, Cursor, Gemini apm compile --target codex # Force Codex CLI apm compile --target claude # Force Claude Code, Claude Desktop +apm compile -t claude,copilot # Multiple targets (comma-separated) ``` You can set a persistent target in `apm.yml`: ```yaml name: my-project version: 1.0.0 -target: copilot # or vscode, claude, codex, or all +target: copilot # single target +``` + +```yaml +name: my-project +version: 1.0.0 +target: [claude, copilot] # multiple targets -- only these are compiled ``` ### Output Files diff --git a/docs/src/content/docs/guides/pack-distribute.md b/docs/src/content/docs/guides/pack-distribute.md index 9a3ca2d1c..600582ff2 100644 --- a/docs/src/content/docs/guides/pack-distribute.md +++ b/docs/src/content/docs/guides/pack-distribute.md @@ -42,7 +42,8 @@ apm pack # Filter by target apm pack --target copilot # only .github/ files apm pack --target claude # only .claude/ files -apm pack --target all # both targets +apm pack --target all # all targets +apm pack -t claude,copilot # multiple targets (comma-separated) # Bundle format apm pack --format plugin # valid plugin directory structure diff --git a/docs/src/content/docs/introduction/how-it-works.md b/docs/src/content/docs/introduction/how-it-works.md index 060621b03..e0bc3c613 100644 --- a/docs/src/content/docs/introduction/how-it-works.md +++ b/docs/src/content/docs/introduction/how-it-works.md @@ -251,7 +251,7 @@ These tools support the full set of APM primitives. Running `apm install` deploy - **GitHub Copilot** (AGENTS.md + .github/) - instructions, prompts, chat modes, context, hooks, MCP - **Claude Code** (CLAUDE.md + .claude/) - commands, skills, MCP configuration -APM auto-detects targets based on project structure -- deploying to every recognized directory (`.github/`, `.claude/`, `.cursor/`, `.opencode/`) that exists, falling back to `.github/` when none do. +APM auto-detects targets based on project structure -- deploying to every recognized directory (`.github/`, `.claude/`, `.cursor/`, `.opencode/`) that exists, falling back to `.github/` when none do. Set `target` in `apm.yml` to restrict to specific targets (single string or list). ### Compiled instructions diff --git a/docs/src/content/docs/reference/cli-commands.md b/docs/src/content/docs/reference/cli-commands.md index be886e455..5b01c4d8d 100644 --- a/docs/src/content/docs/reference/cli-commands.md +++ b/docs/src/content/docs/reference/cli-commands.md @@ -87,7 +87,7 @@ apm install [PACKAGES...] [OPTIONS] - `--runtime TEXT` - Target specific runtime only (copilot, codex, vscode) - `--exclude TEXT` - Exclude specific runtime from installation - `--only [apm|mcp]` - Install only specific dependency type -- `--target [copilot|claude|cursor|codex|opencode|all]` - Force deployment to a specific target (overrides auto-detection) +- `--target [copilot|claude|cursor|codex|opencode|all]` - Force deployment to specific target(s). Accepts comma-separated values for multiple targets (e.g., `-t claude,copilot`). Overrides auto-detection - `--update` - Update dependencies to latest Git references - `--force` - Overwrite locally-authored files on collision; bypass security scan blocks - `--dry-run` - Show what would be installed without installing @@ -488,7 +488,7 @@ apm pack [OPTIONS] **Options:** - `-o, --output PATH` - Output directory (default: `./build`) -- `-t, --target [copilot|vscode|claude|cursor|codex|opencode|all]` - Filter files by target. Auto-detects from `apm.yml` if not specified. `vscode` is an alias for `copilot` +- `-t, --target [copilot|vscode|claude|cursor|codex|opencode|all]` - Filter files by target. Accepts comma-separated values for multiple targets (e.g., `-t claude,copilot`). Auto-detects from `apm.yml` if not specified. `vscode` is an alias for `copilot` - `--archive` - Produce a `.tar.gz` archive instead of a directory - `--dry-run` - List files that would be packed without writing anything - `--format [apm|plugin]` - Bundle format (default: `apm`). `plugin` produces a standalone plugin directory with `plugin.json` @@ -866,7 +866,7 @@ apm deps update [PACKAGES...] [OPTIONS] - `--verbose, -v` - Show detailed update information - `--force` - Overwrite locally-authored files on collision - `-g, --global` - Update user-scope dependencies (`~/.apm/`) -- `--target, -t` - Force deployment to a specific target (copilot, claude, cursor, opencode, vscode, agents, all) +- `--target, -t` - Force deployment to specific target(s). Accepts comma-separated values (e.g., `-t claude,copilot`). Valid values: copilot, claude, cursor, opencode, vscode, agents, all - `--parallel-downloads` - Max concurrent downloads (default: 4) **Examples:** @@ -1232,7 +1232,7 @@ apm compile [OPTIONS] **Options:** - `-o, --output TEXT` - Output file path (for single-file mode) -- `-t, --target [vscode|agents|claude|codex|opencode|all]` - Target agent format. `agents` is an alias for `vscode`. Auto-detects if not specified. +- `-t, --target [vscode|agents|claude|codex|opencode|all]` - Target agent format. Accepts comma-separated values for multiple targets (e.g., `-t claude,copilot`). `agents` is an alias for `vscode`. Auto-detects if not specified. - `--chatmode TEXT` - Chatmode to prepend to the AGENTS.md file - `--dry-run` - Preview compilation without writing files (shows placement decisions) - `--no-links` - Skip markdown link resolution @@ -1260,7 +1260,13 @@ You can also set a persistent target in `apm.yml`: ```yaml name: my-project version: 1.0.0 -target: vscode # or claude, codex, opencode, or all +target: vscode # single target +``` + +```yaml +name: my-project +version: 1.0.0 +target: [claude, copilot] # multiple targets -- only these are compiled/installed ``` **Target Formats (explicit):** @@ -1302,6 +1308,9 @@ apm compile --target claude # CLAUDE.md + .claude/ only apm compile --target opencode # AGENTS.md + .opencode/ only apm compile --target all # All formats (default) +# Multiple targets (comma-separated) +apm compile -t claude,copilot # Both CLAUDE.md and AGENTS.md + # Compile injecting Spec Kit constitution (auto-detected) apm compile --with-constitution diff --git a/docs/src/content/docs/reference/manifest-schema.md b/docs/src/content/docs/reference/manifest-schema.md index c17852c6e..8eb41f40a 100644 --- a/docs/src/content/docs/reference/manifest-schema.md +++ b/docs/src/content/docs/reference/manifest-schema.md @@ -106,21 +106,34 @@ compilation: | | | |---|---| -| **Type** | `enum` | +| **Type** | `string \| list` | | **Required** | OPTIONAL | -| **Default** | Auto-detect: `vscode` if `.github/` exists, `claude` if `.claude/` exists, `codex` if `.codex/` exists, `all` if both `.github/` and `.claude/`, `minimal` if neither | -| **Allowed values** | `vscode` · `agents` · `claude` · `codex` · `all` | +| **Default** | Auto-detect: `vscode` if `.github/` exists, `claude` if `.claude/` exists, `codex` if `.codex/` exists, `all` if multiple target folders exist, `minimal` if none | +| **Allowed values** | `vscode` · `agents` · `copilot` · `claude` · `cursor` · `opencode` · `codex` · `all` | + +Controls which output targets are generated during compilation and installation. Accepts a single string or a list of strings. When unset, a conforming resolver SHOULD auto-detect based on folder presence. Unknown values MUST be silently ignored (auto-detection takes over). + +```yaml +# Single target +target: copilot + +# Multiple targets +target: [claude, copilot] +``` -Controls which output targets are generated during compilation. When unset, a conforming resolver SHOULD auto-detect based on `.github/`, `.claude/`, and `.codex/` folder presence. Unknown values MUST be silently ignored (auto-detection takes over). +When a list is specified, only those targets are compiled, installed, and packed -- no output is generated for unlisted targets. `all` cannot be combined with other values. | Value | Effect | |---|---| | `vscode` | Emits `AGENTS.md` at the project root (and per-directory files in distributed mode) | | `agents` | Alias for `vscode` | +| `copilot` | Alias for `vscode` | | `claude` | Emits `CLAUDE.md` at the project root | +| `cursor` | Emits to `.cursor/rules/`, `.cursor/agents/`, `.cursor/skills/` | +| `opencode` | Emits to `.opencode/agents/`, `.opencode/commands/`, `.opencode/skills/` | | `codex` | Emits `AGENTS.md` and deploys skills to `.agents/skills/`, agents to `.codex/agents/` | -| `all` | Both `vscode` and `claude` targets | -| `minimal` | AGENTS.md only at project root. **Auto-detected only** — this value MUST NOT be set explicitly in manifests; it is an internal fallback when no `.github/` or `.claude/` folder is detected. | +| `all` | All targets. Cannot be combined with other values in a list. | +| `minimal` | AGENTS.md only at project root. **Auto-detected only** -- this value MUST NOT be set explicitly in manifests; it is an internal fallback when no target folder is detected. | ### 3.7. `type` diff --git a/packages/apm-guide/.apm/skills/apm-usage/commands.md b/packages/apm-guide/.apm/skills/apm-usage/commands.md index 9e71beded..24ef8e61a 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 packages | `--update` refresh refs, `--force` overwrite, `--dry-run`, `--verbose`, `--only [apm\|mcp]`, `--target`, `--dev`, `-g` global, `--trust-transitive-mcp`, `--parallel-downloads N` | +| `apm install [PKGS...]` | Install packages | `--update` refresh refs, `--force` overwrite, `--dry-run`, `--verbose`, `--only [apm\|mcp]`, `--target` (comma-separated), `--dev`, `-g` global, `--trust-transitive-mcp`, `--parallel-downloads N` | | `apm uninstall PKGS...` | Remove packages | `--dry-run`, `-g` global | | `apm prune` | Remove orphaned packages | `--dry-run` | | `apm deps list` | List installed packages | `-g` global, `--all` both scopes | @@ -19,13 +19,13 @@ | `apm outdated` | Check locked deps via SHA/semver comparison | `-g` global, `-v` verbose, `-j N` parallel checks | | `apm deps info PKG` | Alias for `apm view PKG` local metadata | -- | | `apm deps clean` | Clean dependency cache | `--dry-run`, `-y` skip confirm | -| `apm deps update [PKGS...]` | Update specific packages | `--verbose`, `--force`, `--target`, `--parallel-downloads N` | +| `apm deps update [PKGS...]` | Update specific packages | `--verbose`, `--force`, `--target` (comma-separated), `--parallel-downloads N` | ## Compilation | Command | Purpose | Key flags | |---------|---------|-----------| -| `apm compile` | Compile agent context | `-o` output, `-t` target, `--chatmode`, `--dry-run`, `--no-links`, `--watch`, `--validate`, `--single-agents`, `-v` verbose, `--local-only`, `--clean`, `--with-constitution/--no-constitution` | +| `apm compile` | Compile agent context | `-o` output, `-t` target (comma-separated), `--chatmode`, `--dry-run`, `--no-links`, `--watch`, `--validate`, `--single-agents`, `-v` verbose, `--local-only`, `--clean`, `--with-constitution/--no-constitution` | ## Scripts diff --git a/packages/apm-guide/.apm/skills/apm-usage/governance.md b/packages/apm-guide/.apm/skills/apm-usage/governance.md index fadf0e132..466fedcf1 100644 --- a/packages/apm-guide/.apm/skills/apm-usage/governance.md +++ b/packages/apm-guide/.apm/skills/apm-usage/governance.md @@ -39,7 +39,7 @@ mcp: compilation: target: allow: [vscode, claude] # permitted targets - enforce: null # force specific target + enforce: null # force specific target (must be present in target list) strategy: enforce: null # distributed | single-file source_attribution: false # require attribution diff --git a/packages/apm-guide/.apm/skills/apm-usage/workflow.md b/packages/apm-guide/.apm/skills/apm-usage/workflow.md index 04dce6beb..98a41d526 100644 --- a/packages/apm-guide/.apm/skills/apm-usage/workflow.md +++ b/packages/apm-guide/.apm/skills/apm-usage/workflow.md @@ -29,7 +29,7 @@ version: # REQUIRED -- semver (e.g. 1.0.0) description: # optional author: # optional license: # optional -- SPDX (e.g. MIT) -target: # optional -- vscode|claude|codex|opencode|all +target: # optional -- vscode|claude|codex|opencode|all (or list: [claude, copilot]) type: # optional -- instructions|skill|hybrid|prompts scripts: > # optional -- named commands dependencies: @@ -39,7 +39,7 @@ devDependencies: # optional -- excluded from bundles apm: > mcp: > compilation: # optional - target: # vscode|claude|codex|opencode|all + target: # vscode|claude|codex|opencode|all (or list) strategy: # distributed|single-file output: # custom output path chatmode: # chatmode to prepend @@ -58,12 +58,24 @@ compilation: # optional ### Target auto-detection +When no target is specified, APM auto-detects from project structure. The `target` field accepts a single string or a list: + +```yaml +# Single target +target: copilot + +# Multiple targets -- only these are compiled/installed +target: [claude, copilot] +``` + +CLI equivalent: `--target claude,copilot` (comma-separated). + | Condition | Detected target | |-----------|-----------------| | `.github/` exists only | `vscode` | | `.claude/` exists only | `claude` | | `.codex/` exists | `codex` | -| Both `.github/` and `.claude/` | `all` | +| Multiple target folders | `all` | | Neither exists | `minimal` (AGENTS.md only) | ## What to commit diff --git a/src/apm_cli/bundle/lockfile_enrichment.py b/src/apm_cli/bundle/lockfile_enrichment.py index b4a7a6c63..4a8b2d55a 100644 --- a/src/apm_cli/bundle/lockfile_enrichment.py +++ b/src/apm_cli/bundle/lockfile_enrichment.py @@ -1,7 +1,7 @@ """Lockfile enrichment for pack-time metadata.""" from datetime import datetime, timezone -from typing import Dict, List, Tuple +from typing import Dict, List, Tuple, Union from ..deps.lockfile import LockFile @@ -55,7 +55,7 @@ def _filter_files_by_target( - deployed_files: List[str], target: str + deployed_files: List[str], target: Union[str, List[str]] ) -> Tuple[List[str], Dict[str, str]]: """Filter deployed file paths by target prefix, with cross-target mapping. @@ -64,16 +64,38 @@ def _filter_files_by_target( remapped to the equivalent target path. Commands, instructions, and hooks are NOT remapped -- they are target-specific. + *target* may be a single string or a list of strings. For a list, the + union of all relevant prefixes and cross-target maps is used. + Returns: A tuple of ``(filtered_files, path_mappings)`` where *path_mappings* maps ``bundle_path -> disk_path`` for any file that was cross-target remapped. Direct matches have no entry in the dict. """ - prefixes = _TARGET_PREFIXES.get(target, _TARGET_PREFIXES["all"]) + if isinstance(target, list): + # Union all prefixes for the targets in the list + prefixes: List[str] = [] + seen_prefixes: set = set() + for t in target: + for p in _TARGET_PREFIXES.get(t, []): + if p not in seen_prefixes: + seen_prefixes.add(p) + prefixes.append(p) + # Union all cross-target maps + # NOTE: dict.update() means the last target's mapping wins when + # multiple targets map the same source prefix. In practice this + # is benign -- common multi-target combos (e.g. claude+copilot) + # match prefixes directly without needing cross-maps. + cross_map: Dict[str, str] = {} + for t in target: + cross_map.update(_CROSS_TARGET_MAPS.get(t, {})) + else: + prefixes = _TARGET_PREFIXES.get(target, _TARGET_PREFIXES["all"]) + cross_map = _CROSS_TARGET_MAPS.get(target, {}) + direct = [f for f in deployed_files if any(f.startswith(p) for p in prefixes)] path_mappings: Dict[str, str] = {} - cross_map = _CROSS_TARGET_MAPS.get(target, {}) if cross_map: direct_set = set(direct) for f in deployed_files: @@ -94,7 +116,7 @@ def _filter_files_by_target( def enrich_lockfile_for_pack( lockfile: LockFile, fmt: str, - target: str, + target: Union[str, List[str]], ) -> str: """Create an enriched copy of the lockfile YAML with a ``pack:`` section. @@ -109,7 +131,8 @@ def enrich_lockfile_for_pack( lockfile: The resolved lockfile to enrich. fmt: Bundle format (``"apm"`` or ``"plugin"``). target: Effective target used for packing (e.g. ``"copilot"``, ``"claude"``, - ``"all"``). The internal alias ``"vscode"`` is also accepted. + ``"all"``). May also be a list of target strings for multi-target + packing. The internal alias ``"vscode"`` is also accepted. Returns: A YAML string with the ``pack:`` block followed by the original @@ -132,9 +155,12 @@ def enrich_lockfile_for_pack( # Build the pack: metadata section (after filtering so we know if mapping # occurred). + # Serialize target as a comma-joined string for backward compatibility + # with consumers that expect a plain string in pack.target. + target_str = ",".join(target) if isinstance(target, list) else target pack_meta: Dict = { "format": fmt, - "target": target, + "target": target_str, "packed_at": datetime.now(timezone.utc).isoformat(), } if all_mappings: @@ -142,7 +168,12 @@ def enrich_lockfile_for_pack( # bundle paths differ from the original lockfile. Use the canonical # prefix keys from _CROSS_TARGET_MAPS rather than reverse-engineering # them from file paths. - cross_map = _CROSS_TARGET_MAPS.get(target, {}) + if isinstance(target, list): + cross_map: Dict[str, str] = {} + for t in target: + cross_map.update(_CROSS_TARGET_MAPS.get(t, {})) + else: + cross_map = _CROSS_TARGET_MAPS.get(target, {}) used_src_prefixes = set() for original in all_mappings.values(): for src_prefix in cross_map: diff --git a/src/apm_cli/bundle/packer.py b/src/apm_cli/bundle/packer.py index 5fc9c6507..19253dc96 100644 --- a/src/apm_cli/bundle/packer.py +++ b/src/apm_cli/bundle/packer.py @@ -5,7 +5,7 @@ import tarfile from dataclasses import dataclass, field from pathlib import Path -from typing import Dict, List, Optional +from typing import Dict, List, Optional, Union from ..deps.lockfile import LockFile, get_lockfile_path, migrate_lockfile_if_needed from ..models.apm_package import APMPackage @@ -28,7 +28,7 @@ def pack_bundle( project_root: Path, output_dir: Path, fmt: str = "apm", - target: Optional[str] = None, + target: Optional[Union[str, List[str]]] = None, archive: bool = False, dry_run: bool = False, force: bool = False, @@ -40,7 +40,8 @@ def pack_bundle( project_root: Root of the project containing ``apm.lock.yaml`` and ``apm.yml``. output_dir: Directory where the bundle will be created. fmt: Bundle format -- ``"apm"`` (default) or ``"plugin"``. - target: Target filter -- ``"copilot"``, ``"claude"``, ``"all"``, or *None* + target: Target filter -- ``"copilot"``, ``"claude"``, ``"all"``, a list of + target strings (e.g. ``["claude", "vscode"]``), or *None* (auto-detect from apm.yml / project structure). archive: If *True*, produce a ``.tar.gz`` and remove the directory. dry_run: If *True*, resolve the file list but write nothing to disk. @@ -102,14 +103,21 @@ def pack_bundle( config_target = None # 3. Resolve effective target - effective_target, _reason = detect_target( - project_root, - explicit_target=target, - config_target=config_target, - ) - # For packing purposes, "minimal" means nothing to pack -- treat as "all" - if effective_target == "minimal": - effective_target = "all" + if isinstance(target, list): + # List from CLI (e.g. --target claude,copilot) passes through directly + effective_target = target + elif isinstance(config_target, list) and target is None: + # List from apm.yml target: [claude, copilot] + effective_target = config_target + else: + effective_target, _reason = detect_target( + project_root, + explicit_target=target, + config_target=config_target if isinstance(config_target, str) else None, + ) + # For packing purposes, "minimal" means nothing to pack -- treat as "all" + if effective_target == "minimal": + effective_target = "all" # 4. Collect deployed_files from all dependencies, filtered by target all_deployed: List[str] = [] diff --git a/src/apm_cli/commands/compile/cli.py b/src/apm_cli/commands/compile/cli.py index 1432af97a..f18074bec 100644 --- a/src/apm_cli/commands/compile/cli.py +++ b/src/apm_cli/commands/compile/cli.py @@ -8,6 +8,7 @@ from ...constants import AGENTS_MD_FILENAME, APM_DIR, APM_MODULES_DIR, APM_YML_FILENAME from ...compilation import AgentsCompiler, CompilationConfig from ...core.command_logger import CommandLogger +from ...core.target_detection import TargetParamType from ...primitives.discovery import discover_primitives from ...utils.console import ( _rich_error, @@ -162,6 +163,36 @@ def _get_validation_suggestion(error_msg): return "Check primitive structure and frontmatter" +def _resolve_compile_target(target): + """Map CLI target input to compiler-understood target string. + + The compiler only understands ``"vscode"``, ``"claude"``, and ``"all"``. + Multi-target lists are mapped to the narrowest equivalent. + + Args: + target: A single target string, a list of target strings, or ``None``. + + Returns: + A single string (or ``None``) suitable for :func:`detect_target`. + """ + if target is None: + return None # will trigger detect_target() auto-detection + if isinstance(target, list): + target_set = set(target) + # Any target that produces AGENTS.md (copilot/vscode/agents/cursor/opencode/codex) + has_agents_family = bool( + target_set & {"copilot", "vscode", "agents", "cursor", "opencode", "codex"} + ) + has_claude = "claude" in target_set + if has_agents_family and has_claude: + return "all" + elif has_claude: + return "claude" + else: + return "vscode" # agents-family only + return target # single string pass-through + + @click.command(help="Compile APM context into distributed AGENTS.md files") @click.option( "--output", @@ -172,9 +203,9 @@ def _get_validation_suggestion(error_msg): @click.option( "--target", "-t", - type=click.Choice(["copilot", "claude", "cursor", "opencode", "codex", "vscode", "agents", "all"]), + type=TargetParamType(), default=None, - help="Target platform: copilot (AGENTS.md), claude (CLAUDE.md), cursor, opencode, or all. 'vscode' and 'agents' are deprecated aliases for 'copilot'. Auto-detects if not specified.", + help="Target platform (comma-separated for multiple, e.g. claude,copilot). Use 'all' for every target. Auto-detects if not specified.", ) @click.option( "--dry-run", @@ -354,10 +385,14 @@ def compile( # No apm.yml or parsing error - proceed with auto-detection pass + # Resolve list targets to compiler-understood string + compile_target = _resolve_compile_target(target) + # Also handle config_target being a list (from apm.yml target: [claude, copilot]) + compile_config_target = _resolve_compile_target(config_target) detected_target, detection_reason = detect_target( project_root=Path("."), - explicit_target=target, - config_target=config_target, + explicit_target=compile_target, + config_target=compile_config_target, ) # Map 'minimal' to 'vscode' for the compiler (AGENTS.md only, no folder integration) @@ -383,7 +418,22 @@ def compile( # Show target-aware message with detection reason. Use # get_target_description() so any future target added to # target_detection shows up here automatically. - if detected_target == "minimal": + if isinstance(target, list): + # Multi-target list: show what the compiler will produce + _target_label = ",".join(target) + if effective_target == "all": + logger.progress( + f"Compiling for AGENTS.md + CLAUDE.md (--target {_target_label})" + ) + elif effective_target == "claude": + logger.progress( + f"Compiling for CLAUDE.md (--target {_target_label})" + ) + else: + logger.progress( + f"Compiling for AGENTS.md (--target {_target_label})" + ) + elif detected_target == "minimal": logger.progress(f"Compiling for AGENTS.md only ({detection_reason})") logger.progress( " Create .github/, .claude/, .codex/, .opencode/ or .cursor/ folder for full integration", diff --git a/src/apm_cli/commands/deps/cli.py b/src/apm_cli/commands/deps/cli.py index 5d0525707..2decd41cf 100644 --- a/src/apm_cli/commands/deps/cli.py +++ b/src/apm_cli/commands/deps/cli.py @@ -10,6 +10,7 @@ from ...constants import APM_DIR, APM_MODULES_DIR, APM_YML_FILENAME, SKILL_MD_FILENAME from ...models.apm_package import APMPackage, ValidationResult, validate_apm_package from ...core.command_logger import CommandLogger +from ...core.target_detection import TargetParamType from ._utils import ( _is_nested_under_package, @@ -477,12 +478,9 @@ def clean(dry_run: bool, yes: bool): ) @click.option( "--target", "-t", - type=click.Choice( - ["copilot", "claude", "cursor", "opencode", "codex", "vscode", "agents", "all"], - case_sensitive=False, - ), + type=TargetParamType(), default=None, - help="Force deployment to a specific target (overrides auto-detection)", + help="Target platform (comma-separated for multiple, e.g. claude,copilot). Use 'all' for every target. Overrides auto-detection.", ) @click.option( "--parallel-downloads", diff --git a/src/apm_cli/commands/install.py b/src/apm_cli/commands/install.py index 4d6f6808e..66a7e36d7 100644 --- a/src/apm_cli/commands/install.py +++ b/src/apm_cli/commands/install.py @@ -24,6 +24,7 @@ ) from ..models.results import InstallResult from ..core.command_logger import InstallLogger, _ValidationOutcome +from ..core.target_detection import TargetParamType from ..utils.console import _rich_echo, _rich_error, _rich_info, _rich_success from ..utils.diagnostics import DiagnosticCollector @@ -383,12 +384,9 @@ def _validate_and_add_packages_to_apm_yml(packages, dry_run=False, dev=False, lo "--target", "-t", "target", - type=click.Choice( - ["copilot", "claude", "cursor", "opencode", "codex", "vscode", "agents", "all"], - case_sensitive=False, - ), + type=TargetParamType(), default=None, - help="Force deployment to a specific target (overrides auto-detection)", + help="Target platform (comma-separated for multiple, e.g. claude,copilot). Use 'all' for every target. Overrides auto-detection.", ) @click.option( "--global", "-g", "global_", diff --git a/src/apm_cli/commands/pack.py b/src/apm_cli/commands/pack.py index d95504047..d32eb4c08 100644 --- a/src/apm_cli/commands/pack.py +++ b/src/apm_cli/commands/pack.py @@ -8,6 +8,7 @@ from ..bundle.packer import pack_bundle from ..bundle.unpacker import unpack_bundle from ..core.command_logger import CommandLogger +from ..core.target_detection import TargetParamType @click.command(name="pack", help="Create a self-contained bundle from installed dependencies") @@ -21,9 +22,9 @@ @click.option( "--target", "-t", - type=click.Choice(["copilot", "claude", "cursor", "opencode", "codex", "vscode", "agents", "all"]), + type=TargetParamType(), default=None, - help="Filter files by target (default: auto-detect). 'vscode' is a deprecated alias for 'copilot'.", + help="Target platform (comma-separated for multiple, e.g. claude,copilot). Use 'all' for every target. Auto-detects if not specified.", ) @click.option("--archive", is_flag=True, default=False, help="Produce a .tar.gz archive.") @click.option( diff --git a/src/apm_cli/core/target_detection.py b/src/apm_cli/core/target_detection.py index 6c5f3c3d8..85a991bd7 100644 --- a/src/apm_cli/core/target_detection.py +++ b/src/apm_cli/core/target_detection.py @@ -21,7 +21,9 @@ """ from pathlib import Path -from typing import Literal, Optional, Tuple +from typing import List, Literal, Optional, Tuple, Union + +import click # Valid target values (internal canonical form) TargetType = Literal["vscode", "claude", "cursor", "opencode", "codex", "all", "minimal"] @@ -222,3 +224,142 @@ def get_target_description(target: UserTargetType) -> str: "minimal": "AGENTS.md only (create .github/ or .claude/ for full integration)", } return descriptions.get(normalized, "unknown target") + + +# --------------------------------------------------------------------------- +# Multi-target helpers (used by active_targets() in the integration layer) +# --------------------------------------------------------------------------- + +#: The complete set of real (non-pseudo) canonical targets. +#: "minimal" is intentionally excluded -- it is a fallback pseudo-target. +ALL_CANONICAL_TARGETS = frozenset({"vscode", "claude", "cursor", "opencode", "codex"}) + +#: Alias mapping: user-facing name -> canonical internal name. +TARGET_ALIASES: dict[str, str] = { + "copilot": "vscode", + "agents": "vscode", + "vscode": "vscode", +} + + +def normalize_target_list( + value: Union[str, List[str], None], +) -> Optional[List[str]]: + """Normalize a user-provided target value to a list of canonical names. + + Handles: + - ``None`` -> ``None`` (auto-detect) + - ``"claude"`` -> ``["claude"]`` + - ``"copilot"`` -> ``["vscode"]`` (alias resolution) + - ``"all"`` -> ``["claude", "codex", "copilot", "cursor", "opencode"]`` + - ``["claude", "copilot"]`` -> ``["claude", "vscode"]`` + - Deduplicates while preserving first-seen order. + + Args: + value: A single target string, a list of target strings, or ``None``. + + Returns: + A deduplicated list of canonical target names, or ``None`` if the + input was ``None`` (meaning "auto-detect"). + """ + if value is None: + return None + + raw: List[str] = [value] if isinstance(value, str) else list(value) + + # "all" anywhere in the input means "every target" -- expand to the + # full sorted list of canonical targets. + if "all" in raw: + return sorted(ALL_CANONICAL_TARGETS) + + seen: set[str] = set() + result: List[str] = [] + for item in raw: + canonical = TARGET_ALIASES.get(item, item) + if canonical not in seen: + seen.add(canonical) + result.append(canonical) + return result + + +# --------------------------------------------------------------------------- +# Click parameter type for --target (comma-separated multi-target support) +# --------------------------------------------------------------------------- + +#: All values accepted by the ``--target`` CLI option. +#: Derived from canonical targets, alias keys, and the ``"all"`` keyword. +VALID_TARGET_VALUES: frozenset[str] = ( + ALL_CANONICAL_TARGETS | frozenset(TARGET_ALIASES) | frozenset({"all"}) +) + + +class TargetParamType(click.ParamType): + """Click parameter type accepting comma-separated target values. + + Single values and ``"all"`` are returned as plain strings for backward + compatibility with existing command handlers. Multiple comma-separated + targets are returned as a deduplicated ``list[str]`` of canonical names. + + Examples:: + + -t claude -> "claude" + -t claude,copilot -> ["claude", "vscode"] + -t all -> "all" + -t copilot,vscode -> ["vscode"] (deduped aliases) + """ + + name = "target" + + def convert( + self, + value: Union[str, List[str], None], + param: Optional[click.Parameter], + ctx: Optional[click.Context], + ) -> Union[str, List[str], None]: + if value is None: + return None + # If already converted (e.g. from a default), pass through. + if isinstance(value, list): + return value + + # Split on comma, normalize whitespace & case, drop empty parts. + parts = [v.strip().lower() for v in value.split(",") if v.strip()] + if not parts: + self.fail("target value must not be empty", param, ctx) + + # Validate every token. + for p in parts: + if p not in VALID_TARGET_VALUES: + self.fail( + f"'{p}' is not a valid target. " + f"Choose from: {', '.join(sorted(VALID_TARGET_VALUES))}", + param, + ctx, + ) + + # "all" is exclusive -- reject combinations like "all,claude". + if "all" in parts: + if len(parts) > 1: + self.fail( + "'all' cannot be combined with other targets", + param, + ctx, + ) + return "all" + + # Single target -> plain string (backward compat). + if len(parts) == 1: + return parts[0] + + # Multi-target: resolve aliases and deduplicate. + seen: set[str] = set() + result: List[str] = [] + for p in parts: + canonical = TARGET_ALIASES.get(p, p) + if canonical not in seen: + seen.add(canonical) + result.append(canonical) + # If aliases collapsed everything to one target, return a string. + if len(result) == 1: + return result[0] + return result diff --git a/src/apm_cli/integration/targets.py b/src/apm_cli/integration/targets.py index a00aae034..22a8eac74 100644 --- a/src/apm_cli/integration/targets.py +++ b/src/apm_cli/integration/targets.py @@ -8,7 +8,7 @@ from __future__ import annotations from dataclasses import dataclass, field -from typing import Dict, Optional, Tuple, Union +from typing import Dict, List, Optional, Tuple, Union @dataclass(frozen=True) @@ -314,7 +314,7 @@ def get_integration_prefixes(targets=None) -> tuple: def active_targets_user_scope( - explicit_target: "Optional[str]" = None, + explicit_target: "Optional[Union[str, List[str]]]" = None, ) -> list: """Return ``TargetProfile`` instances for user-scope deployment. @@ -325,7 +325,7 @@ def active_targets_user_scope( 1. **Explicit target** (``--target``): returns the matching profile if it supports user scope. ``"all"`` returns every user-capable - target. + target. A list of names returns all matching user-capable profiles. 2. **Directory detection**: profiles whose ``effective_root(user_scope=True)`` directory exists under ``~/``. 3. **Fallback**: ``[copilot]`` -- same default as project scope. @@ -336,6 +336,25 @@ def active_targets_user_scope( # --- explicit target --- if explicit_target: + if isinstance(explicit_target, list): + profiles: list = [] + seen: set = set() + for t in explicit_target: + canonical = t + if canonical in ("copilot", "vscode", "agents"): + canonical = "copilot" + if canonical == "all": + return [ + p for p in KNOWN_TARGETS.values() + if p.user_supported + ] + profile = KNOWN_TARGETS.get(canonical) + if profile and profile.user_supported and profile.name not in seen: + seen.add(profile.name) + profiles.append(profile) + return profiles if profiles else [] + + # single string (existing behavior) canonical = explicit_target if canonical in ("copilot", "vscode", "agents"): canonical = "copilot" @@ -361,7 +380,10 @@ def active_targets_user_scope( return [KNOWN_TARGETS["copilot"]] -def active_targets(project_root, explicit_target: "Optional[str]" = None) -> list: +def active_targets( + project_root, + explicit_target: "Optional[Union[str, List[str]]]" = None, +) -> list: """Return the list of ``TargetProfile`` instances that should be deployed into *project_root*. @@ -369,7 +391,7 @@ def active_targets(project_root, explicit_target: "Optional[str]" = None) -> lis 1. **Explicit target** (``--target`` flag or ``apm.yml target:``): returns only the matching profile(s). ``"all"`` returns every - known target. + known target. A list of names returns all matching profiles. 2. **Directory detection**: profiles whose ``root_dir`` already exists under *project_root*. 3. **Fallback**: when nothing is detected, returns ``[copilot]`` @@ -377,9 +399,8 @@ def active_targets(project_root, explicit_target: "Optional[str]" = None) -> lis Args: project_root: The workspace root ``Path``. - explicit_target: Canonical target name (``"copilot"``, ``"claude"``, - ``"cursor"``, ``"opencode"``, ``"all"``). ``None`` means - auto-detect. + explicit_target: Canonical target name, list of canonical names, + or ``"all"``/``None``. ``None`` means auto-detect. """ from pathlib import Path @@ -387,6 +408,22 @@ def active_targets(project_root, explicit_target: "Optional[str]" = None) -> lis # --- explicit target --- if explicit_target: + if isinstance(explicit_target, list): + profiles: list = [] + seen: set = set() + for t in explicit_target: + canonical = t + if canonical in ("copilot", "vscode", "agents"): + canonical = "copilot" + if canonical == "all": + return list(KNOWN_TARGETS.values()) + profile = KNOWN_TARGETS.get(canonical) + if profile and profile.name not in seen: + seen.add(profile.name) + profiles.append(profile) + return profiles if profiles else [KNOWN_TARGETS["copilot"]] + + # single string (existing behavior) canonical = explicit_target if canonical in ("copilot", "vscode", "agents"): canonical = "copilot" @@ -410,7 +447,7 @@ def active_targets(project_root, explicit_target: "Optional[str]" = None) -> lis def resolve_targets( project_root, user_scope: bool = False, - explicit_target: "Optional[str]" = None, + explicit_target: "Optional[Union[str, List[str]]]" = None, ) -> list: """Return scope-resolved ``TargetProfile`` instances. @@ -424,7 +461,8 @@ def resolve_targets( Args: project_root: Workspace root (``Path.cwd()`` or ``Path.home()``). user_scope: When ``True``, resolve for user-level deployment. - explicit_target: Canonical target name or ``"all"``. + explicit_target: Canonical target name, list of canonical names, + or ``"all"``. ``None`` means auto-detect. """ if user_scope: raw = active_targets_user_scope(explicit_target) diff --git a/src/apm_cli/models/apm_package.py b/src/apm_cli/models/apm_package.py index a691c2e90..7e84c6069 100644 --- a/src/apm_cli/models/apm_package.py +++ b/src/apm_cli/models/apm_package.py @@ -73,7 +73,7 @@ class APMPackage: dev_dependencies: Optional[Dict[str, List[Union[DependencyReference, str, dict]]]] = None scripts: Optional[Dict[str, str]] = None package_path: Optional[Path] = None # Local path to package - target: Optional[str] = None # Target agent: vscode, claude, or all (applies to compile and install) + target: Optional[Union[str, List[str]]] = None # Target agent(s): single string or list (applies to compile and install) type: Optional[PackageContentType] = None # Package content type: instructions, skill, hybrid, or prompts @classmethod diff --git a/src/apm_cli/policy/policy_checks.py b/src/apm_cli/policy/policy_checks.py index 5605a46cc..c776ad868 100644 --- a/src/apm_cli/policy/policy_checks.py +++ b/src/apm_cli/policy/policy_checks.py @@ -439,21 +439,27 @@ def _check_compilation_target( message="No compilation target set in manifest", ) + # Normalize target to a list for uniform checking + target_list = target if isinstance(target, list) else [target] + if enforce: - if target != enforce: + if enforce not in target_list: return CheckResult( name="compilation-target", passed=False, - message=f"Target '{target}' does not match enforced '{enforce}'", + message=f"Enforced target '{enforce}' not present in {target_list}", details=[f"target: {target}, enforced: {enforce}"], ) - elif allow is not None and target not in allow: - return CheckResult( - name="compilation-target", - passed=False, - message=f"Target '{target}' not in allowed list {allow}", - details=[f"target: {target}, allowed: {allow}"], - ) + elif allow is not None: + allow_set = set(allow) if isinstance(allow, list) else {allow} + disallowed = [t for t in target_list if t not in allow_set] + if disallowed: + return CheckResult( + name="compilation-target", + passed=False, + message=f"Target(s) {disallowed} not in allowed list {sorted(allow_set)}", + details=[f"target: {target}, allowed: {sorted(allow_set)}"], + ) return CheckResult( name="compilation-target", diff --git a/tests/unit/compilation/test_compile_target_flag.py b/tests/unit/compilation/test_compile_target_flag.py index 9faac208d..21b9177c0 100644 --- a/tests/unit/compilation/test_compile_target_flag.py +++ b/tests/unit/compilation/test_compile_target_flag.py @@ -964,3 +964,57 @@ def test_cli_warns_missing_apply_to_claude( ) finally: os.chdir(original_dir) + + +class TestResolveCompileTarget: + """Tests for _resolve_compile_target() multi-target list mapping.""" + + def test_none_returns_none(self): + from apm_cli.commands.compile.cli import _resolve_compile_target + + assert _resolve_compile_target(None) is None + + def test_single_string_passthrough(self): + from apm_cli.commands.compile.cli import _resolve_compile_target + + assert _resolve_compile_target("claude") == "claude" + assert _resolve_compile_target("vscode") == "vscode" + assert _resolve_compile_target("all") == "all" + assert _resolve_compile_target("copilot") == "copilot" + + def test_list_claude_and_copilot_returns_all(self): + from apm_cli.commands.compile.cli import _resolve_compile_target + + assert _resolve_compile_target(["claude", "vscode"]) == "all" + assert _resolve_compile_target(["claude", "copilot"]) == "all" + + def test_list_claude_only_returns_claude(self): + from apm_cli.commands.compile.cli import _resolve_compile_target + + assert _resolve_compile_target(["claude"]) == "claude" + + def test_list_copilot_only_returns_vscode(self): + from apm_cli.commands.compile.cli import _resolve_compile_target + + assert _resolve_compile_target(["vscode"]) == "vscode" + assert _resolve_compile_target(["copilot"]) == "vscode" + + def test_list_agents_family_without_claude_returns_vscode(self): + """Targets that produce AGENTS.md but not CLAUDE.md.""" + from apm_cli.commands.compile.cli import _resolve_compile_target + + assert _resolve_compile_target(["cursor"]) == "vscode" + assert _resolve_compile_target(["opencode"]) == "vscode" + assert _resolve_compile_target(["codex"]) == "vscode" + assert _resolve_compile_target(["cursor", "opencode"]) == "vscode" + + def test_list_cursor_and_claude_returns_all(self): + from apm_cli.commands.compile.cli import _resolve_compile_target + + assert _resolve_compile_target(["cursor", "claude"]) == "all" + assert _resolve_compile_target(["codex", "claude"]) == "all" + + def test_list_all_targets_returns_all(self): + from apm_cli.commands.compile.cli import _resolve_compile_target + + assert _resolve_compile_target(["claude", "vscode", "cursor"]) == "all" diff --git a/tests/unit/core/test_target_detection.py b/tests/unit/core/test_target_detection.py index 90487a754..78764857c 100644 --- a/tests/unit/core/test_target_detection.py +++ b/tests/unit/core/test_target_detection.py @@ -9,8 +9,13 @@ should_compile_agents_md, should_compile_claude_md, get_target_description, + TargetParamType, + VALID_TARGET_VALUES, ) +import click +import pytest + class TestDetectTarget: """Tests for detect_target function.""" @@ -425,3 +430,181 @@ def test_opencode_compile_agents_md(self): def test_opencode_no_compile_claude_md(self): """OpenCode target should NOT compile CLAUDE.md.""" assert should_compile_claude_md("opencode") is False + + +# --------------------------------------------------------------------------- +# TargetParamType tests +# --------------------------------------------------------------------------- + +class TestTargetParamType: + """Tests for TargetParamType Click parameter type.""" + + def setup_method(self): + self.tp = TargetParamType() + + # -- Valid target values set ------------------------------------------ + + def test_valid_target_values_includes_canonical(self): + """VALID_TARGET_VALUES contains all canonical targets.""" + for name in ("vscode", "claude", "cursor", "opencode", "codex"): + assert name in VALID_TARGET_VALUES + + def test_valid_target_values_includes_aliases(self): + """VALID_TARGET_VALUES contains user-facing aliases.""" + for name in ("copilot", "agents"): + assert name in VALID_TARGET_VALUES + + def test_valid_target_values_includes_all(self): + """VALID_TARGET_VALUES contains 'all'.""" + assert "all" in VALID_TARGET_VALUES + + # -- None passthrough ------------------------------------------------- + + def test_none_returns_none(self): + """None value passes through unchanged.""" + assert self.tp.convert(None, None, None) is None + + # -- Already-converted list passthrough ------------------------------- + + def test_list_passthrough(self): + """A list value passes through unchanged.""" + lst = ["claude", "vscode"] + assert self.tp.convert(lst, None, None) is lst + + # -- Single target (backward compat: returns string) ------------------ + + def test_single_claude(self): + assert self.tp.convert("claude", None, None) == "claude" + + def test_single_copilot(self): + assert self.tp.convert("copilot", None, None) == "copilot" + + def test_single_vscode(self): + assert self.tp.convert("vscode", None, None) == "vscode" + + def test_single_cursor(self): + assert self.tp.convert("cursor", None, None) == "cursor" + + def test_single_opencode(self): + assert self.tp.convert("opencode", None, None) == "opencode" + + def test_single_codex(self): + assert self.tp.convert("codex", None, None) == "codex" + + def test_single_agents(self): + assert self.tp.convert("agents", None, None) == "agents" + + def test_single_all(self): + """'all' returns string 'all' for backward compat.""" + assert self.tp.convert("all", None, None) == "all" + + def test_single_target_returns_string_type(self): + """Single target must return str, not list.""" + result = self.tp.convert("claude", None, None) + assert isinstance(result, str) + + # -- Case insensitivity ----------------------------------------------- + + def test_uppercase_accepted(self): + assert self.tp.convert("CLAUDE", None, None) == "claude" + + def test_mixed_case_accepted(self): + assert self.tp.convert("Claude", None, None) == "claude" + + def test_mixed_case_multi(self): + result = self.tp.convert("Claude,Copilot", None, None) + assert result == ["claude", "vscode"] + + # -- Multi-target (returns list) -------------------------------------- + + def test_multi_claude_copilot(self): + """claude,copilot → ['claude', 'vscode'] (alias resolved).""" + result = self.tp.convert("claude,copilot", None, None) + assert result == ["claude", "vscode"] + + def test_multi_preserves_order(self): + """Order of user input is preserved.""" + result = self.tp.convert("cursor,claude", None, None) + assert result == ["cursor", "claude"] + + def test_multi_returns_list_type(self): + """Multi-target must return list, not str.""" + result = self.tp.convert("claude,cursor", None, None) + assert isinstance(result, list) + + def test_multi_three_targets(self): + result = self.tp.convert("claude,cursor,codex", None, None) + assert result == ["claude", "cursor", "codex"] + + # -- Alias deduplication ---------------------------------------------- + + def test_copilot_vscode_deduplicates(self): + """copilot,vscode → 'vscode' (both alias to same canonical).""" + result = self.tp.convert("copilot,vscode", None, None) + # Both map to "vscode"; collapses to single string. + assert result == "vscode" + + def test_copilot_agents_deduplicates(self): + """copilot,agents → 'vscode' (both alias to same canonical).""" + result = self.tp.convert("copilot,agents", None, None) + assert result == "vscode" + + def test_copilot_agents_vscode_deduplicates(self): + """copilot,agents,vscode → 'vscode' (all alias to same).""" + result = self.tp.convert("copilot,agents,vscode", None, None) + assert result == "vscode" + + def test_copilot_claude_deduplicates_alias(self): + """copilot,claude → ['vscode', 'claude'] (alias resolved).""" + result = self.tp.convert("copilot,claude", None, None) + assert result == ["vscode", "claude"] + + # -- Whitespace and formatting ---------------------------------------- + + def test_spaces_around_comma(self): + result = self.tp.convert("claude , copilot", None, None) + assert result == ["claude", "vscode"] + + def test_trailing_comma_ignored(self): + result = self.tp.convert("claude,", None, None) + assert result == "claude" + + def test_leading_comma_ignored(self): + result = self.tp.convert(",claude", None, None) + assert result == "claude" + + def test_double_comma_ignored(self): + result = self.tp.convert("claude,,cursor", None, None) + assert result == ["claude", "cursor"] + + # -- Error cases ------------------------------------------------------ + + def test_invalid_single_target(self): + """Invalid target name produces clean error.""" + with pytest.raises(click.exceptions.BadParameter, match="'invalid' is not a valid target"): + self.tp.convert("invalid", None, None) + + def test_invalid_in_multi(self): + """Invalid target in comma list produces clean error.""" + with pytest.raises(click.exceptions.BadParameter, match="'nope' is not a valid target"): + self.tp.convert("claude,nope", None, None) + + def test_all_combined_with_other_rejected(self): + """'all' combined with other targets is rejected.""" + with pytest.raises(click.exceptions.BadParameter, match="cannot be combined"): + self.tp.convert("all,claude", None, None) + + def test_target_combined_with_all_rejected(self): + """Target followed by 'all' is also rejected.""" + with pytest.raises(click.exceptions.BadParameter, match="cannot be combined"): + self.tp.convert("claude,all", None, None) + + def test_empty_string_rejected(self): + """Empty string is rejected.""" + with pytest.raises(click.exceptions.BadParameter, match="must not be empty"): + self.tp.convert("", None, None) + + def test_only_commas_rejected(self): + """Only commas (no actual values) is rejected.""" + with pytest.raises(click.exceptions.BadParameter, match="must not be empty"): + self.tp.convert(",,,", None, None) diff --git a/tests/unit/integration/test_targets.py b/tests/unit/integration/test_targets.py index 3d8739e8c..8951e24bb 100644 --- a/tests/unit/integration/test_targets.py +++ b/tests/unit/integration/test_targets.py @@ -122,3 +122,63 @@ def test_all_five_dirs_returns_all_five(self): (self.root / d).mkdir() targets = active_targets(self.root) assert len(targets) == 5 + + # -- explicit list of targets -- + + def test_explicit_list_single_target(self): + targets = active_targets(self.root, explicit_target=["claude"]) + assert [t.name for t in targets] == ["claude"] + + def test_explicit_list_multiple_targets(self): + targets = active_targets(self.root, explicit_target=["claude", "copilot"]) + assert [t.name for t in targets] == ["claude", "copilot"] + + def test_explicit_list_deduplicates_aliases(self): + """copilot and vscode are aliases -- should return one profile.""" + targets = active_targets(self.root, explicit_target=["copilot", "vscode"]) + assert [t.name for t in targets] == ["copilot"] + + def test_explicit_list_with_all_returns_every_known_target(self): + targets = active_targets(self.root, explicit_target=["all"]) + assert len(targets) == len(KNOWN_TARGETS) + + def test_explicit_list_all_mixed_returns_every_known_target(self): + """'all' anywhere in the list wins.""" + targets = active_targets(self.root, explicit_target=["claude", "all"]) + assert len(targets) == len(KNOWN_TARGETS) + + def test_explicit_list_unknown_targets_falls_back_to_copilot(self): + targets = active_targets(self.root, explicit_target=["nonexistent", "bogus"]) + assert [t.name for t in targets] == ["copilot"] + + def test_explicit_list_mixed_known_unknown(self): + """Known targets are included, unknown ones are silently skipped.""" + targets = active_targets(self.root, explicit_target=["claude", "nonexistent"]) + assert [t.name for t in targets] == ["claude"] + + def test_explicit_list_overrides_detection(self): + """Explicit list wins even if dirs for other targets exist.""" + (self.root / ".github").mkdir() + (self.root / ".claude").mkdir() + targets = active_targets(self.root, explicit_target=["cursor"]) + assert [t.name for t in targets] == ["cursor"] + + def test_explicit_list_agents_alias(self): + targets = active_targets(self.root, explicit_target=["agents", "claude"]) + assert [t.name for t in targets] == ["copilot", "claude"] + + def test_explicit_empty_list_falls_through_to_autodetect(self): + """Empty list is falsy -- should auto-detect (fallback to copilot).""" + targets = active_targets(self.root, explicit_target=[]) + assert [t.name for t in targets] == ["copilot"] # fallback + + def test_explicit_list_preserves_order(self): + """Result order matches input order.""" + targets = active_targets( + self.root, explicit_target=["cursor", "claude", "copilot"] + ) + assert [t.name for t in targets] == ["cursor", "claude", "copilot"] + + def test_explicit_list_codex_at_project_scope(self): + targets = active_targets(self.root, explicit_target=["codex"]) + assert [t.name for t in targets] == ["codex"] diff --git a/tests/unit/policy/test_policy_checks.py b/tests/unit/policy/test_policy_checks.py index 9f7b096ee..34aaf6189 100644 --- a/tests/unit/policy/test_policy_checks.py +++ b/tests/unit/policy/test_policy_checks.py @@ -42,7 +42,7 @@ ) -# ── Helpers ──────────────────────────────────────────────────────── +# -- Helpers -------------------------------------------------------- def _write_apm_yml(project: Path, data: dict) -> None: @@ -89,7 +89,7 @@ def _make_lockfile(deps_data: list[dict]): return lock -# ── Fixtures ─────────────────────────────────────────────────────── +# -- Fixtures ------------------------------------------------------- @pytest.fixture(autouse=True) @@ -100,7 +100,7 @@ def _clear_cache(): clear_apm_yml_cache() -# ── Check 1: dependency-allowlist ────────────────────────────────── +# -- Check 1: dependency-allowlist ---------------------------------- class TestDependencyAllowlist: @@ -130,7 +130,7 @@ def test_empty_deps(self): assert result.passed -# ── Check 2: dependency-denylist ─────────────────────────────────── +# -- Check 2: dependency-denylist ----------------------------------- class TestDependencyDenylist: @@ -154,7 +154,7 @@ def test_fail_when_denied(self): assert "denied by pattern" in result.details[0] -# ── Check 3: required-packages ───────────────────────────────────── +# -- Check 3: required-packages ------------------------------------- class TestRequiredPackages: @@ -192,7 +192,7 @@ def test_no_prefix_collision(self): assert "org/package" in result.details -# ── Check 4: required-packages-deployed ──────────────────────────── +# -- Check 4: required-packages-deployed ---------------------------- class TestRequiredPackagesDeployed: @@ -220,7 +220,7 @@ def test_fail_not_deployed(self): assert "org/pkg" in result.details[0] def test_skip_if_not_in_manifest(self): - """Required package not in manifest — check 3 handles that.""" + """Required package not in manifest -- check 3 handles that.""" deps = _make_dep_refs(["other/pkg"]) lock = _make_lockfile([{"repo_url": "other/pkg", "deployed_files": ["x.md"]}]) policy = DependencyPolicy(require=["org/missing"]) @@ -228,7 +228,7 @@ def test_skip_if_not_in_manifest(self): assert result.passed -# ── Check 5: required-package-version ────────────────────────────── +# -- Check 5: required-package-version ------------------------------ class TestRequiredPackageVersion: @@ -284,7 +284,7 @@ def test_pass_project_wins_mismatch(self): assert len(result.details) > 0 -# ── Check 6: transitive-depth ────────────────────────────────────── +# -- Check 6: transitive-depth -------------------------------------- class TestTransitiveDepth: @@ -315,7 +315,7 @@ def test_fail_exceeds_limit(self): assert "depth 5" in result.details[0] -# ── Check 7: mcp-allowlist ───────────────────────────────────────── +# -- Check 7: mcp-allowlist ----------------------------------------- class TestMcpAllowlist: @@ -338,7 +338,7 @@ def test_fail_not_in_allow_list(self): assert not result.passed -# ── Check 8: mcp-denylist ────────────────────────────────────────── +# -- Check 8: mcp-denylist ------------------------------------------ class TestMcpDenylist: @@ -362,7 +362,7 @@ def test_fail_denied(self): assert "denied by pattern" in result.details[0] -# ── Check 9: mcp-transport ───────────────────────────────────────── +# -- Check 9: mcp-transport ----------------------------------------- class TestMcpTransport: @@ -392,7 +392,7 @@ def test_skip_no_transport_set(self): assert result.passed -# ── Check 10: mcp-self-defined ───────────────────────────────────── +# -- Check 10: mcp-self-defined ------------------------------------- class TestMcpSelfDefined: @@ -428,7 +428,7 @@ def test_pass_no_self_defined_servers(self): assert result.passed -# ── Check 11: compilation-target ─────────────────────────────────── +# -- Check 11: compilation-target ----------------------------------- class TestCompilationTarget: @@ -474,8 +474,72 @@ def test_pass_no_target_in_manifest(self): result = _check_compilation_target({}, policy) assert result.passed + # -- Multi-target (list) tests ---------------------------------- -# ── Check 12: compilation-strategy ───────────────────────────────── + def test_target_list_enforce_present(self): + """List target containing the enforced value passes.""" + policy = CompilationPolicy( + target=CompilationTargetPolicy(enforce="claude") + ) + result = _check_compilation_target( + {"target": ["claude", "copilot"]}, policy + ) + assert result.passed + + def test_target_list_enforce_missing(self): + """List target missing the enforced value fails.""" + policy = CompilationPolicy( + target=CompilationTargetPolicy(enforce="claude") + ) + result = _check_compilation_target( + {"target": ["cursor", "copilot"]}, policy + ) + assert not result.passed + assert "enforced" in result.details[0] + + def test_target_list_allow_all_in(self): + """All items in list target within allow set passes.""" + policy = CompilationPolicy( + target=CompilationTargetPolicy( + allow=["claude", "copilot", "cursor"] + ) + ) + result = _check_compilation_target( + {"target": ["claude", "copilot"]}, policy + ) + assert result.passed + + def test_target_list_allow_some_disallowed(self): + """List target with items outside allow set fails.""" + policy = CompilationPolicy( + target=CompilationTargetPolicy(allow=["claude"]) + ) + result = _check_compilation_target( + {"target": ["claude", "copilot"]}, policy + ) + assert not result.passed + assert "copilot" in result.message + + def test_target_string_still_works(self): + """Backward compat: single string target with enforce.""" + policy = CompilationPolicy( + target=CompilationTargetPolicy(enforce="copilot") + ) + result = _check_compilation_target({"target": "copilot"}, policy) + assert result.passed + + def test_target_list_single_item(self): + """Single-element list target with matching enforce passes.""" + policy = CompilationPolicy( + target=CompilationTargetPolicy(enforce="copilot") + ) + result = _check_compilation_target( + {"target": ["copilot"]}, policy + ) + assert result.passed + + +# -- Check 12: compilation-strategy --------------------------------- class TestCompilationStrategy: @@ -511,7 +575,7 @@ def test_pass_no_strategy_in_manifest(self): assert result.passed -# ── Check 13: source-attribution ─────────────────────────────────── +# -- Check 13: source-attribution ----------------------------------- class TestSourceAttribution: @@ -532,7 +596,7 @@ def test_fail_not_enabled(self): assert not result.passed -# ── Check 14: required-manifest-fields ───────────────────────────── +# -- Check 14: required-manifest-fields ----------------------------- class TestRequiredManifestFields: @@ -563,7 +627,7 @@ def test_fail_field_empty(self): assert not result.passed -# ── Check 15: scripts-policy ─────────────────────────────────────── +# -- Check 15: scripts-policy --------------------------------------- class TestScriptsPolicy: @@ -587,7 +651,7 @@ def test_fail_deny_with_scripts(self): assert "build" in result.details -# ── Check 16: unmanaged-files ────────────────────────────────────── +# -- Check 16: unmanaged-files -------------------------------------- class TestUnmanagedFiles: @@ -685,7 +749,7 @@ def test_rglob_cap_skips_check(self, tmp_path, monkeypatch): assert "capped" in result.message.lower() -# ── Integration: run_policy_checks ───────────────────────────────── +# -- Integration: run_policy_checks --------------------------------- class TestRunPolicyChecks: diff --git a/tests/unit/test_apm_package.py b/tests/unit/test_apm_package.py index 037357e8b..12a55a14c 100644 --- a/tests/unit/test_apm_package.py +++ b/tests/unit/test_apm_package.py @@ -208,6 +208,70 @@ def test_dev_apm_no_mcp_key(self, tmp_path): assert pkg.get_dev_mcp_dependencies() == [] +class TestTargetField: + """Tests for target field supporting both str and list[str].""" + + def test_target_string(self, tmp_path): + """target: copilot → stored as string.""" + yml = _write_apm_yml(tmp_path, { + "name": "test-pkg", + "version": "1.0.0", + "target": "copilot", + }) + + pkg = APMPackage.from_apm_yml(yml) + + assert pkg.target == "copilot" + assert isinstance(pkg.target, str) + + def test_target_list(self, tmp_path): + """target: [claude, copilot] → stored as list.""" + yml = _write_apm_yml(tmp_path, { + "name": "test-pkg", + "version": "1.0.0", + "target": ["claude", "copilot"], + }) + + pkg = APMPackage.from_apm_yml(yml) + + assert pkg.target == ["claude", "copilot"] + assert isinstance(pkg.target, list) + + def test_target_missing(self, tmp_path): + """No target field → None.""" + yml = _write_apm_yml(tmp_path, { + "name": "test-pkg", + "version": "1.0.0", + }) + + pkg = APMPackage.from_apm_yml(yml) + + assert pkg.target is None + + def test_target_single_item_list(self, tmp_path): + """target: [copilot] → stored as single-element list.""" + yml = _write_apm_yml(tmp_path, { + "name": "test-pkg", + "version": "1.0.0", + "target": ["copilot"], + }) + + pkg = APMPackage.from_apm_yml(yml) + + assert pkg.target == ["copilot"] + assert isinstance(pkg.target, list) + + def test_target_direct_construction_string(self): + """APMPackage can be constructed with target as string.""" + pkg = APMPackage(name="t", version="1.0.0", target="claude") + assert pkg.target == "claude" + + def test_target_direct_construction_list(self): + """APMPackage can be constructed with target as list.""" + pkg = APMPackage(name="t", version="1.0.0", target=["claude", "copilot"]) + assert pkg.target == ["claude", "copilot"] + + class TestClearCache: """Tests for clear_apm_yml_cache.""" diff --git a/tests/unit/test_lockfile_enrichment.py b/tests/unit/test_lockfile_enrichment.py index 8066663cd..588b6cdbc 100644 --- a/tests/unit/test_lockfile_enrichment.py +++ b/tests/unit/test_lockfile_enrichment.py @@ -245,3 +245,108 @@ def test_traversal_path_not_escaped(self): assert f.startswith(".claude/skills/") # Either way, the original .github/ path should not sneak through assert ".github/skills/../../etc/passwd" not in filtered + + +class TestFilterFilesByTargetList: + """Tests for _filter_files_by_target with list targets.""" + + def test_list_claude_copilot_includes_both_prefixes(self): + from apm_cli.bundle.lockfile_enrichment import _filter_files_by_target + + files = [".github/agents/a.md", ".claude/commands/b.md", ".cursor/rules/r.md"] + filtered, mappings = _filter_files_by_target(files, ["claude", "vscode"]) + assert ".github/agents/a.md" in filtered + assert ".claude/commands/b.md" in filtered + # .cursor/ is not in ["claude", "vscode"] prefixes + assert ".cursor/rules/r.md" not in filtered + # Both are direct matches under their respective prefixes, no mapping needed + assert mappings == {} + + def test_list_single_element_same_as_string(self): + from apm_cli.bundle.lockfile_enrichment import _filter_files_by_target + + files = [".github/skills/x/SKILL.md", ".claude/commands/b.md"] + filtered_list, maps_list = _filter_files_by_target(files, ["claude"]) + filtered_str, maps_str = _filter_files_by_target(files, "claude") + assert filtered_list == filtered_str + assert maps_list == maps_str + + def test_list_claude_cursor_includes_both(self): + from apm_cli.bundle.lockfile_enrichment import _filter_files_by_target + + files = [".claude/skills/s1/SKILL.md", ".cursor/rules/r.md", ".github/agents/a.md"] + filtered, mappings = _filter_files_by_target(files, ["claude", "cursor"]) + assert ".claude/skills/s1/SKILL.md" in filtered + assert ".cursor/rules/r.md" in filtered + # .github/ is not a direct prefix for either claude or cursor + # but cross-target maps may apply + assert ".github/agents/a.md" not in filtered + + def test_list_deduplicates_prefixes(self): + """copilot and vscode share the same prefix .github/ -- no duplicates.""" + from apm_cli.bundle.lockfile_enrichment import _filter_files_by_target + + files = [".github/agents/a.md"] + filtered, mappings = _filter_files_by_target(files, ["copilot", "vscode"]) + assert filtered == [".github/agents/a.md"] + assert mappings == {} + + def test_list_cross_map_github_to_claude_and_cursor(self): + """When both claude and cursor are targets, cross-mapped files go to one dest.""" + from apm_cli.bundle.lockfile_enrichment import _filter_files_by_target + + files = [".github/skills/x/SKILL.md"] + filtered, mappings = _filter_files_by_target(files, ["claude", "cursor"]) + # Both claude and cursor have cross-maps from .github/skills/ + # Dict.update means cursor map overwrites claude map for same key + # So the result maps to cursor's destination + assert len(filtered) == 1 + assert len(mappings) == 1 + + +class TestEnrichLockfileListTarget: + """Tests for enrich_lockfile_for_pack with list targets.""" + + def test_list_target_serializes_as_comma_string(self): + lf = _make_lockfile() + result = enrich_lockfile_for_pack(lf, fmt="apm", target=["claude", "vscode"]) + parsed = yaml.safe_load(result) + + assert parsed["pack"]["target"] == "claude,vscode" + + def test_list_target_filters_deployed_files(self): + lf = LockFile() + dep = LockedDependency( + repo_url="owner/repo", + resolved_commit="abc123", + version="1.0.0", + deployed_files=[ + ".github/agents/a.md", + ".claude/commands/c.md", + ".cursor/rules/r.md", + ], + ) + lf.add_dependency(dep) + + result = enrich_lockfile_for_pack(lf, fmt="apm", target=["claude", "vscode"]) + parsed = yaml.safe_load(result) + + deployed = parsed["dependencies"][0]["deployed_files"] + assert ".github/agents/a.md" in deployed + assert ".claude/commands/c.md" in deployed + # .cursor/ not in target list + assert ".cursor/rules/r.md" not in deployed + + def test_list_target_single_element_equivalent_to_string(self): + lf = _make_lockfile() + result_list = enrich_lockfile_for_pack(lf, fmt="apm", target=["vscode"]) + result_str = enrich_lockfile_for_pack(lf, fmt="apm", target="vscode") + + parsed_list = yaml.safe_load(result_list) + parsed_str = yaml.safe_load(result_str) + + # Deployed files should be identical + assert ( + parsed_list["dependencies"][0]["deployed_files"] + == parsed_str["dependencies"][0]["deployed_files"] + ) diff --git a/tests/unit/test_packer.py b/tests/unit/test_packer.py index a5eb6e85c..a1cc350f6 100644 --- a/tests/unit/test_packer.py +++ b/tests/unit/test_packer.py @@ -480,3 +480,85 @@ def test_pack_rejects_traversal_deployed_path(self, tmp_path): with pytest.raises(ValueError, match="unsafe path"): pack_bundle(project, tmp_path / "out") + + +class TestFilterFilesByTargetList: + """Tests for _filter_files_by_target with list target input.""" + + def test_list_includes_union_of_prefixes(self): + files = [".github/agents/a.md", ".claude/commands/b.md", ".cursor/rules/r.md"] + result, mappings = _filter_files_by_target(files, ["claude", "vscode"]) + assert ".github/agents/a.md" in result + assert ".claude/commands/b.md" in result + assert ".cursor/rules/r.md" not in result + assert mappings == {} + + def test_list_copilot_vscode_dedup(self): + """copilot and vscode share .github/ prefix -- should not duplicate.""" + files = [".github/agents/a.md"] + result, mappings = _filter_files_by_target(files, ["copilot", "vscode"]) + assert result == [".github/agents/a.md"] + + def test_list_single_element_matches_string(self): + files = [".github/agents/a.md", ".claude/commands/b.md"] + result_list, maps_list = _filter_files_by_target(files, ["vscode"]) + result_str, maps_str = _filter_files_by_target(files, "vscode") + assert result_list == result_str + assert maps_list == maps_str + + +class TestPackBundleMultiTarget: + """Tests for pack_bundle with list targets.""" + + def test_pack_list_target_dry_run(self, tmp_path): + """List target passes through to filtering in dry-run mode.""" + deployed = [".github/agents/a.md", ".claude/commands/b.md", ".cursor/rules/r.md"] + project = _setup_project(tmp_path, deployed) + out = tmp_path / "build" + + result = pack_bundle(project, out, target=["claude", "vscode"], dry_run=True) + + assert ".github/agents/a.md" in result.files + assert ".claude/commands/b.md" in result.files + assert ".cursor/rules/r.md" not in result.files + + def test_pack_list_target_creates_bundle(self, tmp_path): + """List target produces a valid bundle with files from all listed targets.""" + deployed = [".github/agents/a.md", ".claude/commands/b.md"] + project = _setup_project(tmp_path, deployed) + out = tmp_path / "build" + + result = pack_bundle(project, out, target=["claude", "vscode"]) + + assert result.bundle_path.exists() + assert (result.bundle_path / ".github/agents/a.md").exists() + assert (result.bundle_path / ".claude/commands/b.md").exists() + + def test_pack_list_target_enriched_lockfile_target_string(self, tmp_path): + """Enriched lockfile should have comma-joined target string.""" + deployed = [".github/agents/a.md", ".claude/commands/b.md"] + project = _setup_project(tmp_path, deployed) + out = tmp_path / "build" + + result = pack_bundle(project, out, target=["claude", "vscode"]) + + lock_yaml = yaml.safe_load( + (result.bundle_path / "apm.lock.yaml").read_text() + ) + assert lock_yaml["pack"]["target"] == "claude,vscode" + + def test_pack_list_config_target_when_no_explicit(self, tmp_path): + """When apm.yml has target: [claude, copilot] and no explicit --target.""" + deployed = [".github/agents/a.md", ".claude/commands/b.md"] + project = _setup_project(tmp_path, deployed) + out = tmp_path / "build" + + # Rewrite apm.yml with list target + apm_yml = {"name": "test-pkg", "version": "1.0.0", "target": ["claude", "copilot"]} + (project / "apm.yml").write_text(yaml.dump(apm_yml), encoding="utf-8") + + result = pack_bundle(project, out, target=None, dry_run=True) + + # Should include files from both .github/ (copilot) and .claude/ (claude) + assert ".github/agents/a.md" in result.files + assert ".claude/commands/b.md" in result.files