Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- 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)
- `apm install` now automatically discovers and deploys local `.apm/` primitives (skills, instructions, agents, prompts, hooks, commands) to target directories, with local content taking priority over dependencies on collision (#626, #644)

### Fixed
Expand Down
11 changes: 7 additions & 4 deletions docs/src/content/docs/enterprise/policy-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
9 changes: 8 additions & 1 deletion docs/src/content/docs/guides/compilation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion docs/src/content/docs/guides/pack-distribute.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/src/content/docs/introduction/how-it-works.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
19 changes: 14 additions & 5 deletions docs/src/content/docs/reference/cli-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -461,7 +461,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`
Expand Down Expand Up @@ -833,7 +833,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:**
Expand Down Expand Up @@ -1175,7 +1175,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
Expand Down Expand Up @@ -1203,7 +1203,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):**
Expand Down Expand Up @@ -1245,6 +1251,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

Expand Down
25 changes: 19 additions & 6 deletions docs/src/content/docs/reference/manifest-schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,21 +106,34 @@ compilation: <CompilationConfig>

| | |
|---|---|
| **Type** | `enum<string>` |
| **Type** | `string \| list<string>` |
| **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`

Expand Down
6 changes: 3 additions & 3 deletions packages/apm-guide/.apm/skills/apm-usage/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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

Expand Down
2 changes: 1 addition & 1 deletion packages/apm-guide/.apm/skills/apm-usage/governance.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 15 additions & 3 deletions packages/apm-guide/.apm/skills/apm-usage/workflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ version: <string> # REQUIRED -- semver (e.g. 1.0.0)
description: <string> # optional
author: <string> # optional
license: <string> # optional -- SPDX (e.g. MIT)
target: <enum> # optional -- vscode|claude|codex|opencode|all
target: <string | list> # optional -- vscode|claude|codex|opencode|all (or list: [claude, copilot])
type: <enum> # optional -- instructions|skill|hybrid|prompts
scripts: <map<string, string>> # optional -- named commands
dependencies:
Expand All @@ -39,7 +39,7 @@ devDependencies: # optional -- excluded from bundles
apm: <list<ApmDependency>>
mcp: <list<McpDependency>>
compilation: # optional
target: <enum> # vscode|claude|codex|opencode|all
target: <enum> # vscode|claude|codex|opencode|all (or list)
strategy: <enum> # distributed|single-file
output: <string> # custom output path
chatmode: <string> # chatmode to prepend
Expand All @@ -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
Expand Down
47 changes: 39 additions & 8 deletions src/apm_cli/bundle/lockfile_enrichment.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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.

Expand All @@ -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:
Comment thread
sergio-sisternes-epam marked this conversation as resolved.
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:
Expand All @@ -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.

Expand All @@ -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
Expand All @@ -132,17 +155,25 @@ 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:
# Record the source prefixes that were remapped so consumers know the
# 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:
Expand Down
Loading
Loading