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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `apm lock export --format cyclonedx|spdx` emits a standard SBOM inventory of installed packages, and a new declared-license recorder stores each package's manifest-declared license (`apm.yml` `license:` / `plugin.json`) in the lockfile after offline SPDX-id validation. APM records what a package declares -- it does not scan LICENSE text or gate installs on a license. (closes #1777) (#1820)
- `apm install` / `apm pack` can now deploy an experimental Copilot-only `canvas` primitive: a package declaring `.apm/extensions/<name>/` ships verbatim to `.github/extensions/<name>/` (or `~/.copilot/extensions/<name>/` with `--global`), where Copilot CLI discovers it in-session. The surface is gated twice -- `apm experimental enable canvas` plus `--trust-canvas-extensions` for dependency-provided canvases -- and is fail-closed when the flag is off. (#1689)
- `apm install` now blocks dependency-provided executables (hooks and `bin/`) by default, mirroring npm v12's default-deny model. A dependency's hooks or binaries deploy only after explicit approval in an `allowExecutables` block of `apm.yml`, managed via `apm approve` / `apm deny`; root-authored content and text-only primitives are unaffected. (#1723)
- `apm compile --global` / `-g` compiles user-scope root context files such as
`~/.claude/CLAUDE.md`, `~/.codex/AGENTS.md`, and `~/.gemini/GEMINI.md` from
globally installed instructions. Compilation stays explicit; `apm install -g`
prints a one-line hint pointing at `apm compile -g` when global instructions
land on a root-context-only target, but writes no root context file. (#1632)

### Removed

Expand Down
6 changes: 6 additions & 0 deletions docs/src/content/docs/consumer/install-packages.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,12 @@ apm install -g <package> # install to user scope (~/.apm/)
apm install -v # verbose: show resolution and integration
```

Targets with native user-scope instruction files pick up global instructions
during install. Targets whose user-scope instruction surface is a root context
file require explicit
[`apm compile --global`](../../reference/cli/compile/#global-compilation);
`apm install -g` prints a hint and writes no root context file.

For the full flag reference, run `apm install --help` or see
[CLI commands](../../reference/cli/install/).

Expand Down
6 changes: 6 additions & 0 deletions docs/src/content/docs/enterprise/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,12 @@ download → scan source → block or deploy → report
Content scanning extends beyond install:

- **`apm compile`** scans compiled output (AGENTS.md, CLAUDE.md, `.github/copilot-instructions.md`, commands) before writing to disk. Critical findings cause `apm compile` to exit with code 1 after writing — defense-in-depth since source files were already scanned at install, but compilation assembles content from multiple sources. `.github/copilot-instructions.md` is assembled from global instructions in `.apm/instructions/`, including those installed under `apm_modules/`.
- **`apm compile --global`** scans user-scope root context files assembled from
globally installed instructions before writing them. Critical findings stop
the write and exit with code 1. Existing hand-authored root context files are
skipped unless they carry APM's generated marker, so opting into global
compilation does not clobber user-managed `CLAUDE.md`, `AGENTS.md`, or
`GEMINI.md` files.
- **`apm pack`** scans files before bundling. This catches hidden characters before a package is published, preventing authors from accidentally distributing tainted content.
- **`apm unpack`** scans bundle contents before deployment. This is a pre-deployment gate matching `apm install` — critical findings block deployment unless `--force` is used. (Note: `apm unpack` is DEPRECATED; prefer `apm install <bundle-path>` for new pipelines -- it applies the same scan plus lockfile integration. See [Pack and distribute](../producer/pack-a-bundle/).)

Expand Down
37 changes: 37 additions & 0 deletions docs/src/content/docs/producer/compile.md
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,43 @@ you can omit `start_marker` and `end_marker` if you use those verbatim.
- In distributed compile mode, subdirectory `AGENTS.md` files remain fully
APM-owned and are overwritten on each run.

## Global compilation (-g)

Install a package once globally and root-context tools on your machine can pick
up its instructions without per-project setup. For user-scope instructions, use
the `--global` or `-g` flag:

```bash
apm compile --global
apm compile -g --dry-run
```

This reads **global instructions** from `~/.apm/apm_modules/` (instructions
without `applyTo:` frontmatter) and writes user-scope root context files for
root-context targets:

- `~/.claude/CLAUDE.md` (or `$CLAUDE_CONFIG_DIR/CLAUDE.md`)
- `~/.codex/AGENTS.md` and other verified AGENTS.md user roots
- `~/.gemini/GEMINI.md`

### Overwrite protection

When a root file exists but contains no APM marker, it is treated as
hand-authored and never overwritten. Use `--dry-run` to preview what would
be written without modifying files.

### Constraints

- Compilation is explicit. `apm install -g` (see
[Install packages](../consumer/install-packages/)) does not write root context
files; it prints a one-line hint pointing at `apm compile -g` when global
instructions land on a root-context-only target.
- `--global` cannot be combined with project-output flags such as `--target`,
`--all`, `--watch`, `--root`, or `--output`.
- Compiled output is security-scanned before it is written. Critical findings
stop the write and make `apm compile -g` exit non-zero.
- Skills-only packages (no global instructions) do not write root files.

## Pitfalls

- **Confusing compile's scope.** Compile only handles **instructions**
Expand Down
19 changes: 19 additions & 0 deletions docs/src/content/docs/reference/cli/compile.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,25 @@ The dry-run output shows `[dry-run] would remove stale CLAUDE.md -- instructions
| `--dry-run` | Show placement decisions without writing files. |
| `-v, --verbose` | Show source attribution and optimizer analysis. |

### Global compilation

Global compilation keeps every supported AI tool on your machine in sync with
globally installed instruction packages -- one command, no per-tool setup.

| Flag | Description |
|------|-------------|
| `-g, --global` | Compile user-scope root context files from `~/.apm/apm_modules`. Reads globally installed packages and writes one root context file per supported user-scope target (e.g. `~/.claude/CLAUDE.md`, `~/.codex/AGENTS.md`). Not valid with project-output flags such as `--target`, `--all`, `--watch`, `--root`, or `--output`. Exits non-zero if `~/.apm/apm_modules` does not exist. |

`apm compile --global` is explicit. `apm install -g` does not run it; instead,
when global instructions land on a root-context-only target, install prints a
one-line hint pointing at `apm compile -g`. Run it manually after adding or
removing global packages. Hand-authored files (files that do not carry the
APM-generated marker) are never overwritten.

```bash
apm compile -g
```

## Examples

Compile for whatever the project is set up for:
Expand Down
1 change: 1 addition & 0 deletions docs/src/content/docs/reference/cli/install.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ Transport env vars: `APM_GIT_PROTOCOL` (`ssh` or `https`) sets the default initi
- **No-op nudge.** When the lockfile is already satisfied and nothing needs deploying, install prints `[i] Run 'apm update' to check for newer versions.` so you know the silent success was not a missed refresh.
- **Frozen mode.** With `--frozen`, install resolves only what is in `apm.lock.yaml`. A direct dependency missing from the lockfile, or a missing lockfile entirely, exits `1`. Orphan lockfile entries (locked but no longer in `apm.yml`) are tolerated; local-path deps are skipped. This is a structural check, not a content check -- run `apm audit --ci` for hash verification.
- **Local `.apm/` deployment.** After dependencies are integrated, primitives in the project's own `.apm/` directory are deployed to the same targets. Local files win on collision. Skipped at `--global` and with `--only mcp`.
- **User-scope root context hint.** Compilation stays explicit. After `apm install -g`, targets with native user-scope instruction files pick up global instructions during install. Targets whose user-scope instruction surface is a root context file require [`apm compile --global`](../compile/#global-compilation); install prints a one-line `[i]` hint and writes no root context file.
- **Stale-file cleanup.** Files a still-present package previously deployed but no longer produces are removed from the workspace, gated by per-file content hashes recorded in the lockfile (user-edited files are kept with a warning).
- **Enterprise marketplace gate.** When installing from a `*.ghe.com` marketplace, bare cross-repo `repo:` fields (e.g. `repo: owner/repo`) are refused before any network request runs, preventing dependency-confusion attacks. Host-qualify the field to proceed: `repo: corp.ghe.com/owner/repo` for an enterprise dep, or `repo: github.com/owner/repo` for a declared cross-host dep.
- **Security scan.** Source files are scanned for hidden Unicode and other tag-character / bidi-override patterns before deployment. Critical findings block the package; the install exits `1`. Use `--force` to deploy anyway, or run `apm audit --strip` first to remediate.
Expand Down
2 changes: 1 addition & 1 deletion packages/apm-guide/.apm/skills/apm-usage/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,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/Copilot deduplication), `--root DIR` redirect generated artifacts under DIR while sources resolve from `$PWD` (mirrors `pip install --target`; not valid with `--watch`) |
| `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`), `-g`/`--global` (read global instructions from `~/.apm/apm_modules/`, write user-scope root files; cannot combine with project-output flags such as `--target`, `--all`, `--watch`, `--root`, or `--output`; critical hidden-character findings stop the write and exit 1), `--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/Copilot 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
155 changes: 155 additions & 0 deletions src/apm_cli/commands/compile/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,114 @@ def _resolve_effective_target(
return detected_target, detection_reason, config_target


def _handle_global_flag(dry_run: bool, logger: CommandLogger | None = None) -> int:
"""Handle --global compilation of user-scope root context files.

Returns 0 on success, 1 on error (for sys.exit).
"""

from ...compilation import compile_user_root_contexts
from ...core.scope import InstallScope, get_apm_dir
from ...integration.targets import KNOWN_TARGETS
from ...utils.console import _rich_error, _rich_info, _rich_success

if logger is None:

class _RichLogger:
def error(self, message: str, symbol: str = "error") -> None:
_rich_error(message, symbol=symbol)

def info(self, message: str, symbol: str = "info") -> None:
_rich_info(message, symbol=symbol)

def success(self, message: str, symbol: str = "check") -> None:
_rich_success(message, symbol=symbol)

def verbose_detail(self, _message: str) -> None:
return

logger = _RichLogger()
source_root = get_apm_dir(InstallScope.USER)
apm_modules = source_root / "apm_modules"
if not apm_modules.is_dir():
Comment on lines +344 to +367
display_path = _display_user_path(apm_modules)
logger.error(
f"User-scope apm_modules not found: {display_path}. "
"Run 'apm install -g <package>' to install packages globally.",
symbol="error",
)
return 1

results = compile_user_root_contexts(
list(KNOWN_TARGETS.values()),
source_root,
dry_run=dry_run,
logger=None,
)

if not results:
logger.info(
"No user-scope targets produced output -- run 'apm install -g <package>' "
"to add global instructions.",
symbol="info",
)
return 0

has_error = False
written_count = 0
would_write_count = 0
unchanged_count = 0
for entry in results:
status = entry.status
tname = entry.target
path = entry.path
display_path = _display_user_path(path) if path is not None else None
if status == "written":
logger.success(f"{tname}: wrote {display_path}", symbol="check")
written_count += 1
elif status == "would-write":
logger.info(f"{tname}: would write {display_path} (dry-run)", symbol="preview")
would_write_count += 1
elif status == "unchanged":
logger.verbose_detail(f"{tname}: unchanged {display_path}")
unchanged_count += 1
elif status == "skipped-hand-authored":
logger.info(f"{tname}: skipped (hand-authored) {display_path}", symbol="info")
elif status == "skipped-no-instructions":
logger.verbose_detail(f"{tname}: skipped (no global instructions)")
elif status.startswith("error:"):
logger.error(f"{tname}: {status[6:]}", symbol="error")
has_error = True
if getattr(entry, "has_critical_security", False):
has_error = True

if not has_error:
changed_count = written_count + would_write_count
if changed_count:
verb = "Would compile" if dry_run else "Compiled"
message = (
f"{verb} {changed_count} user-scope root context file(s); "
f"{unchanged_count} unchanged."
)
if dry_run:
logger.info(message, symbol="preview")
else:
logger.success(message, symbol="check")
else:
logger.info("No user-scope root context files changed.", symbol="info")

return 1 if has_error else 0


def _display_user_path(path: Path) -> str:
"""Render paths under HOME with a stable tilde prefix for CLI output."""
try:
rel = path.resolve().relative_to(Path.home().resolve())
except ValueError:
return str(path)
return f"~/{rel.as_posix()}"


def _validate_project(logger: CommandLogger, dry_run: bool, source_root: Path) -> None:
"""Check APM project exists and has content.

Expand Down Expand Up @@ -892,6 +1000,19 @@ def _coerce_provenance_targets(value):
"for scratch-dir verification. Cannot be combined with --watch."
),
)
@click.option(
"--global",
"-g",
"global_",
is_flag=True,
default=False,
help=(
"Compile user-scope root context files (~/.claude/CLAUDE.md, etc.) "
"from ~/.apm/apm_modules. Cannot be combined with project-scoped output "
"flags such as --target, --all, --watch, --root, or --output; use with "
"--dry-run to preview changes."
),
)
@click.pass_context
def compile( # noqa: PLR0913 -- Click handler
ctx,
Expand All @@ -911,6 +1032,7 @@ def compile( # noqa: PLR0913 -- Click handler
compile_all,
no_dedup,
root,
global_,
):
"""Compile APM context into distributed AGENTS.md files.

Expand Down Expand Up @@ -953,6 +1075,39 @@ def compile( # noqa: PLR0913 -- Click handler
# consumers running with -W default, which we have none of.
logger.warning("'--target all' is deprecated; use '--all' instead.")

# --global: compile user-scope root context files from ~/.apm/apm_modules.
# Must be checked before --watch / --root guards so we return early.
if global_:
from click.core import ParameterSource

allowed_with_global = {"global_", "dry_run", "verbose"}
flag_names = {
"chatmode": "--chatmode",
"clean": "--clean",
"compile_all": "--all",
"legacy_skill_paths": "--legacy-skill-paths",
"local_only": "--local-only",
"no_dedup": "--no-dedup/--force-instructions",
"no_links": "--no-links",
"output": "--output",
"root": "--root",
"single_agents": "--single-agents",
"target": "--target",
"validate": "--validate",
"verbose": "--verbose",
"watch": "--watch",
"with_constitution": "--with-constitution/--no-constitution",
}
for name in sorted(set(ctx.params) - allowed_with_global):
if ctx.get_parameter_source(name) is ParameterSource.DEFAULT:
continue
flag = flag_names.get(name, f"--{name.replace('_', '-')}")
raise click.UsageError(f"--global is not valid with {flag}")
rc = _handle_global_flag(dry_run=dry_run, logger=logger)
if rc != 0:
ctx.exit(rc)
return

# --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
Expand Down
4 changes: 4 additions & 0 deletions src/apm_cli/compilation/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,17 @@
find_chatmode_by_name,
render_instructions_block,
)
from .user_root_context import UserRootCompileResult, compile_user_root_contexts

__all__ = [ # noqa: RUF022
# Main compilation interface
"AgentsCompiler",
"compile_agents_md",
"CompilationConfig",
"CompilationResult",
# User-scope root context compilation
"compile_user_root_contexts",
"UserRootCompileResult",
# Template building
"build_conditional_sections",
"render_instructions_block",
Expand Down
Loading
Loading