diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a5d7d3ea..00935d38c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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//` ships verbatim to `.github/extensions//` (or `~/.copilot/extensions//` 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 diff --git a/docs/src/content/docs/consumer/install-packages.md b/docs/src/content/docs/consumer/install-packages.md index 9f3e6bf83..a70cf0897 100644 --- a/docs/src/content/docs/consumer/install-packages.md +++ b/docs/src/content/docs/consumer/install-packages.md @@ -178,6 +178,12 @@ apm install -g # 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/). diff --git a/docs/src/content/docs/enterprise/security.md b/docs/src/content/docs/enterprise/security.md index 96441c122..f7237e82a 100644 --- a/docs/src/content/docs/enterprise/security.md +++ b/docs/src/content/docs/enterprise/security.md @@ -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 ` for new pipelines -- it applies the same scan plus lockfile integration. See [Pack and distribute](../producer/pack-a-bundle/).) diff --git a/docs/src/content/docs/producer/compile.md b/docs/src/content/docs/producer/compile.md index ca9fa9e6b..65f27236a 100644 --- a/docs/src/content/docs/producer/compile.md +++ b/docs/src/content/docs/producer/compile.md @@ -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** diff --git a/docs/src/content/docs/reference/cli/compile.md b/docs/src/content/docs/reference/cli/compile.md index 68fb22bd6..63674cc63 100644 --- a/docs/src/content/docs/reference/cli/compile.md +++ b/docs/src/content/docs/reference/cli/compile.md @@ -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: diff --git a/docs/src/content/docs/reference/cli/install.md b/docs/src/content/docs/reference/cli/install.md index c44b428b1..ba1e8e597 100644 --- a/docs/src/content/docs/reference/cli/install.md +++ b/docs/src/content/docs/reference/cli/install.md @@ -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. diff --git a/packages/apm-guide/.apm/skills/apm-usage/commands.md b/packages/apm-guide/.apm/skills/apm-usage/commands.md index 89f895ef7..e943fbc9e 100644 --- a/packages/apm-guide/.apm/skills/apm-usage/commands.md +++ b/packages/apm-guide/.apm/skills/apm-usage/commands.md @@ -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. diff --git a/src/apm_cli/commands/compile/cli.py b/src/apm_cli/commands/compile/cli.py index 6f69d65de..761d2f59f 100644 --- a/src/apm_cli/commands/compile/cli.py +++ b/src/apm_cli/commands/compile/cli.py @@ -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(): + display_path = _display_user_path(apm_modules) + logger.error( + f"User-scope apm_modules not found: {display_path}. " + "Run 'apm install -g ' 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 ' " + "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. @@ -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, @@ -911,6 +1032,7 @@ def compile( # noqa: PLR0913 -- Click handler compile_all, no_dedup, root, + global_, ): """Compile APM context into distributed AGENTS.md files. @@ -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 diff --git a/src/apm_cli/compilation/__init__.py b/src/apm_cli/compilation/__init__.py index 41254e1af..9f1ef00a1 100644 --- a/src/apm_cli/compilation/__init__.py +++ b/src/apm_cli/compilation/__init__.py @@ -8,6 +8,7 @@ find_chatmode_by_name, render_instructions_block, ) +from .user_root_context import UserRootCompileResult, compile_user_root_contexts __all__ = [ # noqa: RUF022 # Main compilation interface @@ -15,6 +16,9 @@ "compile_agents_md", "CompilationConfig", "CompilationResult", + # User-scope root context compilation + "compile_user_root_contexts", + "UserRootCompileResult", # Template building "build_conditional_sections", "render_instructions_block", diff --git a/src/apm_cli/compilation/user_root_context.py b/src/apm_cli/compilation/user_root_context.py new file mode 100644 index 000000000..c2016ab91 --- /dev/null +++ b/src/apm_cli/compilation/user_root_context.py @@ -0,0 +1,303 @@ +"""User-scope root-context compilation engine. + +Reads global (apply_to-less) instructions from ~/.apm/apm_modules and +writes each active target's user-scope root context file: + claude -> ~/.claude/CLAUDE.md (or $CLAUDE_CONFIG_DIR/CLAUDE.md) + codex -> ~/.codex/AGENTS.md + gemini -> ~/.gemini/GEMINI.md + copilot -> ~/.copilot/AGENTS.md + vscode -> ~/.copilot/AGENTS.md (same deploy root at user scope) + cursor -> ~/.cursor/AGENTS.md + opencode -> ~/.config/opencode/AGENTS.md + +Files are ONLY written when: +1. The target supports user scope (for_scope returns non-None) +2. The target has a recognised compile_family with a root-file mapping +3. Global instructions exist in the module tree +4. The existing file either does not exist OR carries the generated marker + +Hand-authored files (no marker) are left untouched. +""" + +from __future__ import annotations + +import hashlib +import logging +from collections.abc import Iterable +from dataclasses import dataclass +from pathlib import Path +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + import logging as _logging_module + + from ..integration.targets import TargetProfile + from ..primitives.models import Instruction + +# Root filename by compile_family. Targets whose compile_family is not in +# this map do not produce a root file (e.g. family=None for agent-skills). +_ROOT_FILENAME: dict[str, str] = { + "claude": "CLAUDE.md", + "agents": "AGENTS.md", + "vscode": "AGENTS.md", + "gemini": "GEMINI.md", +} + + +@dataclass(frozen=True) +class UserRootCompileResult: + """Result for one user-scope root context compilation target.""" + + target: str + path: Path | None + status: str + has_critical_security: bool = False + + +def _resolve_deploy_root(profile: TargetProfile) -> Path: + """Return the absolute deploy root for a user-scoped TargetProfile. + + After for_scope(user_scope=True): + * profile.resolved_deploy_root is set -> use it directly + * otherwise -> Path.home() / profile.root_dir + """ + if profile.resolved_deploy_root is not None: + return profile.resolved_deploy_root + return Path.home() / profile.root_dir + + +def _finalize_build_id(content: str) -> str: + """Replace the BUILD_ID_PLACEHOLDER sentinel with a 12-char content hash. + + The hash is computed over all lines EXCEPT the placeholder line so the + result is deterministic (not self-referential). + """ + from .constants import BUILD_ID_PLACEHOLDER + + lines = content.splitlines() + try: + idx = lines.index(BUILD_ID_PLACEHOLDER) + except ValueError: + return content + + hash_input_lines = [line for i, line in enumerate(lines) if i != idx] + build_id = hashlib.sha256("\n".join(hash_input_lines).encode("utf-8")).hexdigest()[:12] + lines[idx] = f"" + return "\n".join(lines) + "\n" + + +def _generate_content(instructions: list[Instruction]) -> str: + """Generate the root context file content from a list of global instructions. + + Embeds the APM-generated marker and a deterministic Build ID so that + subsequent runs can detect APM-owned files and apply overwrite protection. + + ASCII-only: no Unicode in the generated skeleton; instruction *content* + is passed through as-is (callers are responsible for encoding checks). + """ + from .agents_compiler import _COPILOT_ROOT_GENERATED_MARKER + from .constants import BUILD_ID_PLACEHOLDER + + sections: list[str] = [ + _COPILOT_ROOT_GENERATED_MARKER, + BUILD_ID_PLACEHOLDER, + "", + ] + + for instruction in instructions: + sections.append(instruction.content.strip()) + sections.append("") + + return _finalize_build_id("\n".join(sections)) + + +def discover_global_instructions( + source_root: Path, + *, + logger: _logging_module.Logger | None = None, +) -> list[Instruction]: + """Return global (apply_to-less) instructions under ``source_root/apm_modules``. + + Returns an empty list when the ``apm_modules`` tree is absent or carries no + global instructions. Results are sorted by file path for determinism so + callers (the compile engine and the install-time hint) agree on ordering. + """ + from ..primitives.discovery import discover_primitives + + log = logger or logging.getLogger(__name__) + + apm_modules = source_root / "apm_modules" + if not apm_modules.is_dir(): + log.debug( + "user_root_context: apm_modules dir not found at %s -- no global instructions", + apm_modules, + ) + return [] + + primitives = discover_primitives(str(apm_modules)) + return sorted( + [instr for instr in primitives.instructions if not instr.apply_to], + key=lambda instr: str(instr.file_path), + ) + + +def compile_user_root_contexts( + targets: Iterable[TargetProfile], + source_root: Path, + *, + dry_run: bool = False, + logger: _logging_module.Logger | None = None, +) -> list[UserRootCompileResult]: + """Compile user-scope root context files from global (apply_to-less) instructions. + + Iterates over *targets*, skipping any that: + * do not support user scope (for_scope returns None) + * have no recognised compile_family root-file mapping + + For each remaining target the function discovers global instructions from + ``source_root / "apm_modules"``, generates content, and writes the root + file -- unless the existing file is hand-authored (no marker). + + Args: + targets: Iterable of TargetProfile instances to process. + source_root: Root of the user's APM installation tree, + e.g. ``Path.home() / ".apm"``. + dry_run: When True, no files are written or directories created. + The returned status values reflect what *would* happen. + logger: Optional logger. Falls back to ``logging.getLogger(__name__)``. + + Returns: + A list of UserRootCompileResult entries, one per target that was + evaluated. Each entry contains ``target``, ``path``, and ``status``. + + Status values: + * ``"written"`` -- file was created or updated + * ``"unchanged"`` -- file already matches generated content + * ``"would-write"`` -- dry_run; file would have been written + * ``"skipped-no-instructions"`` -- no global instructions found + * ``"skipped-hand-authored"`` -- existing file has no APM marker + * ``"error:"`` -- OS error during read or write + """ + from ..utils.path_security import PathTraversalError, ensure_path_within + from .agents_compiler import _COPILOT_ROOT_GENERATED_MARKER + + log = logger or logging.getLogger(__name__) + + results: list[UserRootCompileResult] = [] + + apm_modules = source_root / "apm_modules" + if not apm_modules.is_dir(): + log.debug( + "user_root_context: apm_modules dir not found at %s -- no root files written", + apm_modules, + ) + return results + + global_instructions = discover_global_instructions(source_root, logger=log) + + for target in targets: + # Resolve to user scope; None == target does not support user scope + scoped = target.for_scope(user_scope=True) + if scoped is None: + log.debug("user_root_context: %s does not support user scope -- skipping", target.name) + continue + + family = scoped.compile_family + if family not in _ROOT_FILENAME: + log.debug( + "user_root_context: %s compile_family=%r has no root-file mapping -- skipping", + scoped.name, + family, + ) + continue + + if not global_instructions: + log.debug( + "user_root_context: no global instructions found in %s -- skipping %s", + apm_modules, + scoped.name, + ) + results.append(UserRootCompileResult(scoped.name, None, "skipped-no-instructions")) + continue + + deploy_root = _resolve_deploy_root(scoped) + root_filename = _ROOT_FILENAME[family] + try: + output_path = ensure_path_within(deploy_root / root_filename, deploy_root) + except PathTraversalError as exc: + log.warning("user_root_context: unsafe output path for %s: %s", scoped.name, exc) + results.append( + UserRootCompileResult(scoped.name, deploy_root / root_filename, f"error:{exc}") + ) + continue + + content = _generate_content(global_instructions) + + # -- overwrite protection -- + if output_path.exists(): + try: + existing = output_path.read_text(encoding="utf-8") + except OSError as exc: + log.warning("user_root_context: cannot read %s: %s", output_path, exc) + results.append(UserRootCompileResult(scoped.name, output_path, f"error:{exc}")) + continue + + if not existing.lstrip().startswith(_COPILOT_ROOT_GENERATED_MARKER): + log.info( + "user_root_context: %s is hand-authored (no APM marker) -- not overwriting", + output_path, + ) + results.append( + UserRootCompileResult(scoped.name, output_path, "skipped-hand-authored") + ) + continue + + if existing == content: + log.debug("user_root_context: %s is unchanged", output_path) + results.append(UserRootCompileResult(scoped.name, output_path, "unchanged")) + continue + + if dry_run: + log.debug("user_root_context: [dry-run] would write %s", output_path) + results.append(UserRootCompileResult(scoped.name, output_path, "would-write")) + continue + + try: + output_path.parent.mkdir(parents=True, exist_ok=True) + from ..security.gate import BLOCK_POLICY, SecurityGate + + verdict = SecurityGate.scan_text(content, str(output_path), policy=BLOCK_POLICY) + actionable = verdict.critical_count + verdict.warning_count + if actionable: + log.warning( + "user_root_context: %s contains %s hidden character(s) " + "-- run 'apm audit --file %s' to inspect", + output_path, + actionable, + output_path, + ) + if verdict.should_block: + results.append( + UserRootCompileResult( + scoped.name, + output_path, + "error:critical hidden characters in compiled output", + has_critical_security=True, + ) + ) + continue + output_path.write_text(content, encoding="utf-8") + log.debug("user_root_context: wrote %s", output_path) + results.append( + UserRootCompileResult( + scoped.name, + output_path, + "written", + has_critical_security=verdict.has_critical, + ) + ) + except OSError as exc: + log.warning("user_root_context: failed to write %s: %s", output_path, exc) + results.append(UserRootCompileResult(scoped.name, output_path, f"error:{exc}")) + + return results diff --git a/src/apm_cli/install/phases/finalize.py b/src/apm_cli/install/phases/finalize.py index d2707a151..286e06e30 100644 --- a/src/apm_cli/install/phases/finalize.py +++ b/src/apm_cli/install/phases/finalize.py @@ -18,6 +18,65 @@ from apm_cli.install.context import InstallContext from apm_cli.models.results import InstallResult +# compile_family values whose user-scope surface for global instructions can +# require a root context file (AGENTS.md / CLAUDE.md / GEMINI.md). The excluded +# target names deploy user-scope instructions natively or have no verified +# user-scope root-context reader, so they should not receive the hint. +_ROOT_CONTEXT_ONLY_FAMILIES = frozenset({"agents", "claude", "gemini"}) +_ROOT_CONTEXT_HINT_EXCLUDED_TARGETS = frozenset( + {"antigravity", "copilot", "cursor", "kiro", "windsurf"} +) + + +def _hint_global_root_context(ctx: InstallContext) -> None: + """Print a one-line hint pointing at ``apm compile -g`` after ``install -g``. + + The hint is emitted only when BOTH conditions hold: + + 1. At least one global (apply_to-less) instruction was installed under the + user-scope ``apm_modules`` tree. + 2. At least one active target is root-context-only -- its user-scope + ``compile_family`` is in :data:`_ROOT_CONTEXT_ONLY_FAMILIES`. + + No file is written. Compilation stays explicit: the user runs + ``apm compile -g`` to materialise the root context files. Imports are + kept lazy to avoid pulling the compilation package into the hot install + path and to prevent import cycles. + """ + if ctx.dry_run: + return + + from apm_cli.compilation.user_root_context import discover_global_instructions + from apm_cli.core.scope import InstallScope, get_apm_dir + from apm_cli.utils.console import _rich_info + + source_root = get_apm_dir(InstallScope.USER) + if not discover_global_instructions(source_root): + return + + target_names: list[str] = [] + seen: set[str] = set() + for target in ctx.targets: + scoped = target.for_scope(user_scope=True) + if scoped is None: + continue + if scoped.name.lower() in _ROOT_CONTEXT_HINT_EXCLUDED_TARGETS: + continue + if scoped.compile_family not in _ROOT_CONTEXT_ONLY_FAMILIES: + continue + if scoped.name not in seen: + seen.add(scoped.name) + target_names.append(scoped.name) + + if not target_names: + return + + message = f"Global instructions installed. Run 'apm compile -g' for: {', '.join(target_names)}." + if ctx.logger: + ctx.logger.info(message, symbol="info") + else: + _rich_info(message, symbol="info") + def run(ctx: InstallContext) -> InstallResult: """Emit verbose stats, fallback success, unpinned warning, and return final result.""" @@ -83,6 +142,15 @@ def run(ctx: InstallContext) -> InstallResult: f"{ctx.unpinned_count} {noun} unpinned -- add #tag or #sha to prevent drift" ) + # User-scope post-install: when global instructions land on a + # root-context-only target, print a one-line hint pointing at + # ``apm compile -g``. No context file is written on install -- + # compilation stays explicit. + from apm_cli.core.scope import InstallScope + + if ctx.scope is InstallScope.USER: + _hint_global_root_context(ctx) + return InstallResult( ctx.installed_count, ctx.total_prompts_integrated, diff --git a/tests/integration/test_compile_global.py b/tests/integration/test_compile_global.py new file mode 100644 index 000000000..52519a5fe --- /dev/null +++ b/tests/integration/test_compile_global.py @@ -0,0 +1,151 @@ +"""Integration coverage for user-scope global compilation.""" + +from __future__ import annotations + +from click.testing import CliRunner + + +def test_install_global_hints_compile_and_writes_no_root_file(tmp_path, monkeypatch): + """`apm install -g` prints the compile hint but writes NO root context file. + + Compilation stays explicit: install only nudges the user toward + `apm compile -g` when global instructions land on a root-context target. + """ + from apm_cli.commands.install import install as install_cmd + from apm_cli.primitives.discovery import clear_discovery_cache + + home = tmp_path / "home" + package_dir = tmp_path / "global-package" + instruction_dir = package_dir / ".apm" / "instructions" + instruction_dir.mkdir(parents=True) + (package_dir / "apm.yml").write_text( + "name: global-package\nversion: 0.1.0\n", + encoding="utf-8", + ) + (instruction_dir / "global.instructions.md").write_text( + "---\ndescription: Global install instructions\n---\nUse integration tests.\n", + encoding="utf-8", + ) + + claude_config = home / "claude-config" + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(claude_config)) + clear_discovery_cache() + + result = CliRunner().invoke(install_cmd, ["-g", str(package_dir), "--target", "claude"]) + + clear_discovery_cache() + assert result.exit_code == 0, result.output + # No root context file is written on install -- compilation is explicit. + assert not (claude_config / "CLAUDE.md").exists() + # The install nudges the user toward the explicit transform. + assert "apm compile -g" in result.output + + +def test_compile_global_writes_claude_md_from_real_fixtures(tmp_path, monkeypatch): + """Run apm compile --global through real discovery and filesystem writes.""" + from apm_cli.commands.compile.cli import compile as compile_cmd + from apm_cli.primitives.discovery import clear_discovery_cache + + home = tmp_path / "home" + apm_modules = home / ".apm" / "apm_modules" + instruction_dir = apm_modules / "demo" / ".apm" / "instructions" + instruction_dir.mkdir(parents=True) + (instruction_dir / "global.instructions.md").write_text( + "---\ndescription: Global test instructions\n---\nUse type hints in generated code.\n", + encoding="utf-8", + ) + + claude_config = home / "claude-config" + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(claude_config)) + clear_discovery_cache() + + result = CliRunner().invoke(compile_cmd, ["--global"]) + + clear_discovery_cache() + assert result.exit_code == 0, result.output + output_path = claude_config / "CLAUDE.md" + content = output_path.read_text(encoding="utf-8") + assert "