Skip to content
Merged
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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- `apm install` and `apm compile` now accept `--root DIR` to redirect every
generated artifact -- `apm_modules/`, `apm.lock.yaml`, `.gitignore`, and
integrated harness files (install) or `AGENTS.md` and per-target files
(compile) -- under `DIR`, while `apm.yml`, `.apm/`, and local-path
dependencies still resolve from the current working directory. This
mirrors `pip install --target` and `npm install --prefix`: useful for
scratch builds, CI artifact staging, and read-only source trees. `DIR` is
created if missing (refused under `--dry-run`); the redirect is reverted
on every exit path so it never leaks across invocations. `--root` is
rejected with `--global` (install) and `--watch` (compile). Thanks to
@srid (juspay) for the feature and original implementation. (closes #888, #1628)
- `apm audit` can now ingest findings from external SARIF 2.1.0 scanners
(e.g. NVIDIA SkillSpector or any SARIF-emitting tool) via `--external
<name>` and `--external-sarif <file>`, merging them into APM's own report
Expand Down
10 changes: 9 additions & 1 deletion docs/src/content/docs/reference/cli/compile.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ use `apm install` or `apm deps update` when you want shared

| Flag | Description |
|------|-------------|
| `--root DIR` | Redirect generated artifacts (AGENTS.md and per-target files) under `DIR` while `apm.yml`, `.apm/`, and `apm_modules/` still resolve from the current working directory. Mirrors `pip install --target`. `DIR` is created if missing. Not valid with `--watch`. |
| `-o, --output PATH` | Output file path. Only applies in single-file mode (`--single-agents`). Default: `AGENTS.md`. |
| `--single-agents` | Force single-file compilation (legacy). Writes one combined file at `--output` instead of a distributed per-directory target-file tree. Applies to every target that uses distributed placement. |
| `--clean` | Remove orphaned AGENTS.md files no longer produced by the current primitive set. |
Expand Down Expand Up @@ -151,6 +152,13 @@ Remove stale AGENTS.md files after deleting primitives:
apm compile --clean
```

Redirect generated files to a scratch directory (sources stay in `$PWD`):

```bash
apm compile --root /tmp/agents-out --target copilot
ls /tmp/agents-out # AGENTS.md / per-target files; the source tree stays clean
```

## Watch mode

`apm compile --watch` monitors the project for source changes and
Expand Down Expand Up @@ -239,7 +247,7 @@ output.
|------|---------|
| 0 | Compilation succeeded (or `--validate` passed). |
| 1 | No `apm.yml`, no primitives to compile, validation failure, hidden-Unicode finding, or compilation error. |
| 2 | Conflicting flags (e.g. `--all` combined with `--target`). |
| 2 | Conflicting flags (e.g. `--all` combined with `--target`, or `--root` combined with `--watch`). |

## Related

Expand Down
19 changes: 18 additions & 1 deletion docs/src/content/docs/reference/cli/install.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,12 @@ With no arguments it installs everything from `apm.yml`. With one or more `PACKA
| `--verbose`, `-v` | off | Show per-file paths and full error context in the diagnostic summary. |
| `--dev` | off | Add new packages to `devDependencies`. Dev deps install locally but are excluded from `apm pack` output. |

### Deploy location

| Flag | Default | Description |
|---|---|---|
| `--root DIR` | `$PWD` | Redirect every write -- `apm_modules/`, `apm.lock.yaml`, `.gitignore`, and integrated harness files -- under `DIR`, while `apm.yml`, `.apm/`, and local-path dependencies still resolve from the current working directory. Mirrors `pip install --target` / `npm install --prefix`. `DIR` is created if missing (except under `--dry-run`, which refuses to create it). Not valid with `--global` (user scope), which exits `2`. |

### Target selection

| Flag | Default | Description |
Expand Down Expand Up @@ -155,6 +161,17 @@ apm install --dry-run
apm install microsoft/apm-sample-package --dry-run
```

### Redirect writes to a scratch directory

```bash
# Resolve apm.yml + local deps from this directory, but write
# apm_modules/, apm.lock.yaml, and harness files under /tmp/apm-out.
apm install --root /tmp/apm-out --target copilot

# The source tree stays clean; the deploy root holds every artifact.
ls /tmp/apm-out # apm_modules/ apm.lock.yaml .github/ .gitignore
```

### Install a local bundle produced by `apm pack`

```bash
Expand All @@ -176,7 +193,7 @@ apm install owner/skill-bundle --skill '*' # reset to all skills
|---|---|
| `0` | Success. All requested dependencies and local content deployed. |
| `1` | Install failure: security scan blocked a critical finding, auth error, manifest write error, dependency resolution error, `--frozen` with a missing lockfile or a direct dependency absent from `apm.lock.yaml`, any reported install error (the diagnostic summary closes with `Installation failed with N error(s)`), or unhandled exception. `--force` does **not** suppress general install errors. The diagnostic summary names the cause. |
| `2` | Usage error: no deployment target detectable (no `--target`, no `targets:` in `apm.yml`, no harness signal in the project), `--ssh` and `--https` both passed, `--frozen` and `--update` both passed, or a Click flag conflict. |
| `2` | Usage error: no deployment target detectable (no `--target`, no `targets:` in `apm.yml`, no harness signal in the project), `--ssh` and `--https` both passed, `--frozen` and `--update` both passed, `--root` combined with `--global`, or a Click flag conflict. |

## Notes

Expand Down
4 changes: 2 additions & 2 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 APM and MCP dependencies (supports APM packages, Claude skills (SKILL.md), and plugin collections (plugin.json)) | `--update` (deprecated; prefer `apm update`) refresh refs, `--refresh` re-fetch all deps from upstream and re-resolve all ref pins, `--force` overwrite (does NOT refresh refs; use `apm update` for that), `--frozen` CI-safe install that fails fast when `apm.lock.yaml` is missing or out of sync with `apm.yml` (mutually exclusive with `--update`; structural presence check only -- use `apm audit` for SHA integrity), `--dry-run`, `--verbose`, `--only [apm\|mcp]`, `--target` (comma-separated, e.g. `--target claude,cursor`; highest-priority entry in the resolution chain `--target` > apm.yml `targets:` > auto-detect; `--target all` deprecated, see `apm compile --all`; use `copilot-cowork` with `--global` after `apm experimental enable copilot-cowork`), `--dev`, `-g` global, `--trust-transitive-mcp`, `--parallel-downloads N`, `--allow-insecure`, `--allow-insecure-host HOSTNAME`, `--skill NAME` install named skill(s) from SKILL_BUNDLE (repeatable; persisted in apm.yml; `'*'` resets to all), `--legacy-skill-paths` restore per-client skill dirs, `--mcp NAME` add MCP entry (NAME goes through the same `--target` > `targets:` > auto-detect resolver as APM packages, so a project whitelisting `targets: [copilot]` will not write `.cursor/mcp.json` even if `.cursor/` exists; `apm install -g --mcp NAME` writes user-scope and bypasses the project-scope gate by design), `--transport`, `--url`, `--env KEY=VAL`, `--header KEY=VAL`, `--mcp-version`, `--registry URL` custom MCP registry |
| `apm install [PKGS...]` | Install APM and MCP dependencies (supports APM packages, Claude skills (SKILL.md), and plugin collections (plugin.json)) | `--update` (deprecated; prefer `apm update`) refresh refs, `--refresh` re-fetch all deps from upstream and re-resolve all ref pins, `--force` overwrite (does NOT refresh refs; use `apm update` for that), `--frozen` CI-safe install that fails fast when `apm.lock.yaml` is missing or out of sync with `apm.yml` (mutually exclusive with `--update`; structural presence check only -- use `apm audit` for SHA integrity), `--dry-run`, `--verbose`, `--only [apm\|mcp]`, `--target` (comma-separated, e.g. `--target claude,cursor`; highest-priority entry in the resolution chain `--target` > apm.yml `targets:` > auto-detect; `--target all` deprecated, see `apm compile --all`; use `copilot-cowork` with `--global` after `apm experimental enable copilot-cowork`), `--dev`, `-g` global, `--trust-transitive-mcp`, `--parallel-downloads N`, `--allow-insecure`, `--allow-insecure-host HOSTNAME`, `--skill NAME` install named skill(s) from SKILL_BUNDLE (repeatable; persisted in apm.yml; `'*'` resets to all), `--legacy-skill-paths` restore per-client skill dirs, `--mcp NAME` add MCP entry (NAME goes through the same `--target` > `targets:` > auto-detect resolver as APM packages, so a project whitelisting `targets: [copilot]` will not write `.cursor/mcp.json` even if `.cursor/` exists; `apm install -g --mcp NAME` writes user-scope and bypasses the project-scope gate by design), `--transport`, `--url`, `--env KEY=VAL`, `--header KEY=VAL`, `--mcp-version`, `--registry URL` custom MCP registry, `--root DIR` redirect writes (`apm_modules/`, lockfile, `.gitignore`, integrated harness files) under DIR while `apm.yml`/`.apm/`/local deps resolve from `$PWD` (mirrors `pip install --target`; created if missing; not valid with `-g`/`--global`, which exits 2) |
| `apm targets` | Show resolved deployment targets for the current project (Click group; reads filesystem signals; works with or without `apm.yml`) | `--all` also include the `agent-skills` meta-target (only meaningful with `--json`), `--json` machine-readable output. No provenance line is printed (the table is the provenance). |
| `apm uninstall PKGS...` | Remove packages (accepts `owner/repo` or `name@marketplace`) | `--dry-run`, `-g` global |
| `apm prune` | Remove orphaned packages | `--dry-run` |
Expand Down Expand Up @@ -51,7 +51,7 @@ If no `--target`, no `targets:` in `apm.yml`, and no harness signal is present,

| Command | Purpose | Key flags |
|---------|---------|-----------|
| `apm compile` | Compile agent context | `-o` output, `-t` target (comma-separated; resolution chain `--target` > apm.yml `targets:` > auto-detect), `--all` compile for every canonical target (preferred over deprecated `--target all`), `--chatmode`, `--dry-run`, `--no-links`, `--watch`, `--validate`, `--single-agents`, `-v` verbose, `--local-only`, `--clean`, `--with-constitution/--no-constitution`, `--no-dedup` / `--force-instructions` (opt out of Claude deduplication) |
| `apm compile` | Compile agent context | `-o` output, `-t` target (comma-separated; resolution chain `--target` > apm.yml `targets:` > auto-detect), `--all` compile for every canonical target (preferred over deprecated `--target all`), `--chatmode`, `--dry-run`, `--no-links`, `--watch`, `--validate`, `--single-agents`, `-v` verbose, `--local-only`, `--clean`, `--with-constitution/--no-constitution`, `--no-dedup` / `--force-instructions` (opt out of Claude deduplication), `--root DIR` redirect generated artifacts under DIR while sources resolve from `$PWD` (mirrors `pip install --target`; not valid with `--watch`) |

`apm compile --watch` live-reloads `apm.yml`: editing `target:` / `targets:` mid-session takes effect on the next file event without restarting the watcher. The CLI `--target` flag, when passed to `apm compile --watch`, still outranks `apm.yml`. Re-resolution is gated on the changed file's basename being `apm.yml`, so `.instructions.md` edits do not pay an extra resolver round-trip and a stray `backup_apm.yml` cannot trigger a reload. `--clean` is ignored in watch mode and the watcher prints an explicit `[!]` warning at startup (`--clean is ignored in watch mode; run 'apm compile --clean' separately to remove orphaned outputs.`); run `apm compile --clean` separately between watch sessions to remove orphans.

Expand Down
70 changes: 57 additions & 13 deletions src/apm_cli/commands/compile/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -409,6 +409,19 @@ def _resolve_effective_target(
help="Alias for --no-dedup.",
hidden=True,
)
@click.option(
"--root",
"root",
type=click.Path(file_okay=False, resolve_path=True),
default=None,
metavar="DIR",
help=(
"Write AGENTS.md / CLAUDE.md outputs under DIR instead of $PWD; "
"sources (apm.yml, .apm/, project tree for placement scoring) "
"continue resolving from $PWD. Pairs with 'apm install --root' "
"for scratch-dir verification. Cannot be combined with --watch."
),
)
@click.pass_context
def compile(
ctx,
Expand All @@ -427,6 +440,7 @@ def compile(
legacy_skill_paths,
compile_all,
no_dedup,
root,
):
"""Compile APM context into distributed AGENTS.md files.

Expand Down Expand Up @@ -464,11 +478,37 @@ def compile(
# consumers running with -W default, which we have none of.
logger.warning("'--target all' is deprecated; use '--all' instead.")

# --root + --watch is rejected: ``_watch_mode`` uses bare-relative
# paths (``Path(APM_DIR)``, ``AgentsCompiler(".")``) and the watch
# loop would scan the deploy root rather than the source tree. The
# flag combination has no real use case -- watch is interactive
# development; --root is for CI scratch-dir verification.
if root and watch:
raise click.UsageError("--root is not valid with --watch")

# --root: see apm_cli.install.root_redirect.compile_root_redirect.
# Bracket the handler so writes land under *root* while sources keep
# resolving from the captured original $PWD via the source-root
# override. ``--dry-run`` is threaded through so the context manager
# skips the ``mkdir`` side-effect on previews. The manager is entered
# manually (rather than via ``with``) so the existing top-level
# try/except below does not need a 300-line re-indent; the matching
# ``finally`` at the end of the handler restores cwd + clears the
# override on every exit path (return, sys.exit, exception).
from ...core.scope import InstallScope, get_source_root
from ...install.root_redirect import compile_root_redirect

_root_redirect = compile_root_redirect(root, dry_run=dry_run)
_root_redirect.__enter__()
try:
# Source root: where apm.yml, .apm/, and the project tree are read
# from. Equals $PWD unless --root redirects writes elsewhere.
source_root = get_source_root(InstallScope.PROJECT)

# Check if this is an APM project first
from pathlib import Path

if not Path(APM_YML_FILENAME).exists():
if not (source_root / APM_YML_FILENAME).exists():
logger.error("Not an APM project - no apm.yml found")
logger.progress(" To initialize an APM project, run:")
logger.progress(" apm init")
Expand All @@ -477,11 +517,11 @@ def compile(
# Check if there are any instruction files to compile
from ...compilation.constitution import find_constitution

apm_modules_exists = Path(APM_MODULES_DIR).exists()
constitution_exists = find_constitution(Path(".")).exists()
apm_modules_exists = (source_root / APM_MODULES_DIR).exists()
constitution_exists = find_constitution(source_root).exists()

# Check if .apm directory has actual content
apm_dir = Path(APM_DIR)
apm_dir = source_root / APM_DIR
local_apm_has_content = apm_dir.exists() and (
any(apm_dir.rglob("*.instructions.md")) or any(apm_dir.rglob("*.chatmode.md"))
)
Expand Down Expand Up @@ -515,9 +555,9 @@ def compile(
logger.start("Validating APM context...", symbol="gear")
clear_discovery_cache()
perf_stats.reset()
compiler = AgentsCompiler(".")
compiler = AgentsCompiler(".", source_dir=str(source_root))
try:
primitives = discover_primitives(".")
primitives = discover_primitives(str(source_root))
except Exception as e:
logger.error(f"Failed to discover primitives: {e}")
logger.progress(f" Error details: {type(e).__name__}")
Expand All @@ -536,13 +576,13 @@ def compile(
try:
from ...models.apm_package import APMPackage

apm_pkg = APMPackage.from_apm_yml(Path(APM_YML_FILENAME))
apm_pkg = APMPackage.from_apm_yml(source_root / APM_YML_FILENAME)
mcp_count = len(apm_pkg.get_mcp_dependencies())
if mcp_count > 0:
logger.progress(f" * {mcp_count} MCP dependencies")
except Exception:
pass
perf_stats.render_summary(logger, project_root=".")
perf_stats.render_summary(logger, project_root=str(source_root))
return

# Watch mode
Expand Down Expand Up @@ -593,7 +633,7 @@ def compile(
from ...models.apm_package import APMPackage

config_target = None
apm_yml_path = Path(APM_YML_FILENAME)
apm_yml_path = source_root / APM_YML_FILENAME
if apm_yml_path.exists():
apm_pkg = APMPackage.from_apm_yml(apm_yml_path)
config_target = apm_pkg.target
Expand Down Expand Up @@ -637,7 +677,7 @@ def compile(
# typed for Optional[str], and a frozenset config_target is already
# handled by the branch above.
detected_target, detection_reason = detect_target(
project_root=Path("."),
project_root=source_root,
explicit_target=compile_target,
config_target=compile_config_target
if isinstance(compile_config_target, str)
Expand Down Expand Up @@ -769,7 +809,7 @@ def _coerce_provenance_targets(value):
# same process (tests, REPL). Mirrors run_install_pipeline.
clear_discovery_cache()
perf_stats.reset()
compiler = AgentsCompiler(".")
compiler = AgentsCompiler(".", source_dir=str(source_root))
result = compiler.compile(config, logger=logger)
compile_has_critical = result.has_critical_security

Expand Down Expand Up @@ -927,10 +967,10 @@ def _coerce_provenance_targets(value):
"Compiled output contains critical hidden characters"
" -- run 'apm audit' to inspect, 'apm audit --strip' to clean"
)
perf_stats.render_summary(logger, project_root=".")
perf_stats.render_summary(logger, project_root=str(source_root))
sys.exit(1)

perf_stats.render_summary(logger, project_root=".")
perf_stats.render_summary(logger, project_root=str(source_root))

except ImportError as e:
logger.error(f"Compilation module not available: {e}")
Expand All @@ -939,3 +979,7 @@ def _coerce_provenance_targets(value):
except Exception as e:
logger.error(f"Error during compilation: {e}")
sys.exit(1)
finally:
# Restore cwd + clear the source-root override regardless of how
# the handler exits (return, sys.exit -> SystemExit, exception).
_root_redirect.__exit__(None, None, None)
Loading
Loading