Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,15 @@ 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

- Pin codex setup to `rust-v0.118.0` for security and reproducibility; update config to `wire_api = "responses"` (#663)
- Propagate headers and environment variables through OpenCode MCP adapter with defensive copies to prevent mutation (#622)
- Fix `apm compile --target claude` silently skipping dependency instructions stored in `.github/instructions/` (#631)

### Changed

- `apm marketplace browse/search/add/update` now route through the registry proxy when `PROXY_REGISTRY_URL` is set; `PROXY_REGISTRY_ONLY=1` blocks direct GitHub API calls (#506)
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:
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