From d0673c7b433ac133c783827c157d25df516bb7b7 Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Wed, 3 Jun 2026 08:23:57 +0200 Subject: [PATCH 01/10] feat(compilation): add compile_user_root_contexts for user-scope root files New shared function compile_user_root_contexts(targets, source_root, *, dry_run, logger) in src/apm_cli/compilation/user_root_context.py: - Discovers global (apply_to-less) instructions from source_root/apm_modules - Resolves TargetProfile.for_scope(user_scope=True) per target; None => skip - Maps compile_family to root filename: claude -> CLAUDE.md agents -> AGENTS.md vscode -> AGENTS.md gemini -> GEMINI.md - Reuses _COPILOT_ROOT_GENERATED_MARKER from agents_compiler.py - Overwrite protection: skips hand-authored files (no marker) - Writes only the root file; no CWD changes - Returns [{target, path, status}] dicts for caller reporting Exported from src/apm_cli/compilation/__init__.py. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/apm_cli/compilation/__init__.py | 3 + src/apm_cli/compilation/user_root_context.py | 227 +++++++++++++++++++ 2 files changed, 230 insertions(+) create mode 100644 src/apm_cli/compilation/user_root_context.py diff --git a/src/apm_cli/compilation/__init__.py b/src/apm_cli/compilation/__init__.py index 41254e1af..f3c212c38 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 compile_user_root_contexts __all__ = [ # noqa: RUF022 # Main compilation interface @@ -15,6 +16,8 @@ "compile_agents_md", "CompilationConfig", "CompilationResult", + # User-scope root context compilation + "compile_user_root_contexts", # 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..769eebfbc --- /dev/null +++ b/src/apm_cli/compilation/user_root_context.py @@ -0,0 +1,227 @@ +"""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 pathlib import Path + +# 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", +} + + +def _resolve_deploy_root(profile) -> 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) -> 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 compile_user_root_contexts( + targets, + source_root: Path, + *, + dry_run: bool = False, + logger=None, +) -> list[dict]: + """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 dicts, one per target that was evaluated, each containing: + ``{"target": , "path": , "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 ..primitives.discovery import discover_primitives + from .agents_compiler import _COPILOT_ROOT_GENERATED_MARKER + + log = logger or logging.getLogger(__name__) + + results: list[dict] = [] + + 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 + + primitives = discover_primitives(str(apm_modules)) + + global_instructions = sorted( + [instr for instr in primitives.instructions if not instr.apply_to], + key=lambda instr: str(instr.file_path), + ) + + 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( + {"target": scoped.name, "path": None, "status": "skipped-no-instructions"} + ) + continue + + deploy_root = _resolve_deploy_root(scoped) + root_filename = _ROOT_FILENAME[family] + output_path = deploy_root / root_filename + + 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( + {"target": scoped.name, "path": output_path, "status": f"error:{exc}"} + ) + continue + + if _COPILOT_ROOT_GENERATED_MARKER not in existing: + log.info( + "user_root_context: %s is hand-authored (no APM marker) -- not overwriting", + output_path, + ) + results.append( + {"target": scoped.name, "path": output_path, "status": "skipped-hand-authored"} + ) + continue + + if existing == content: + log.debug("user_root_context: %s is unchanged", output_path) + results.append({"target": scoped.name, "path": output_path, "status": "unchanged"}) + continue + + if dry_run: + log.debug("user_root_context: [dry-run] would write %s", output_path) + results.append({"target": scoped.name, "path": output_path, "status": "would-write"}) + continue + + try: + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text(content, encoding="utf-8") + log.debug("user_root_context: wrote %s", output_path) + results.append({"target": scoped.name, "path": output_path, "status": "written"}) + except OSError as exc: + log.warning("user_root_context: failed to write %s: %s", output_path, exc) + results.append({"target": scoped.name, "path": output_path, "status": f"error:{exc}"}) + + return results From 342aa52845b4cfde79623e25cee629222040789e Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Wed, 3 Jun 2026 08:27:48 +0200 Subject: [PATCH 02/10] feat(install): trigger user-scope root context compile after install -g Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/apm_cli/install/phases/finalize.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/apm_cli/install/phases/finalize.py b/src/apm_cli/install/phases/finalize.py index d2707a151..ac7297207 100644 --- a/src/apm_cli/install/phases/finalize.py +++ b/src/apm_cli/install/phases/finalize.py @@ -19,6 +19,20 @@ from apm_cli.models.results import InstallResult +def _compile_user_root_contexts_after_install(ctx: InstallContext) -> None: + """Invoke compile_user_root_contexts after a user-scope install.""" + from apm_cli.compilation import compile_user_root_contexts + from apm_cli.core.scope import InstallScope, get_source_root + from apm_cli.integration.targets import KNOWN_TARGETS + + source_root = get_source_root(InstallScope.USER) + targets = list(KNOWN_TARGETS.values()) + results = compile_user_root_contexts(targets, source_root, dry_run=False, logger=ctx.logger) + written = [r for r in results if r.get("status") == "written"] + if written and ctx.logger: + ctx.logger.verbose_detail(f"Compiled {len(written)} user-scope root context file(s)") + + def run(ctx: InstallContext) -> InstallResult: """Emit verbose stats, fallback success, unpinned warning, and return final result.""" from apm_cli.commands import install as _install_mod @@ -83,6 +97,13 @@ def run(ctx: InstallContext) -> InstallResult: f"{ctx.unpinned_count} {noun} unpinned -- add #tag or #sha to prevent drift" ) + # User-scope post-install: compile root context files if we just installed + # a package that brought global instructions. + from apm_cli.core.scope import InstallScope + + if ctx.scope is InstallScope.USER: + _compile_user_root_contexts_after_install(ctx) + return InstallResult( ctx.installed_count, ctx.total_prompts_integrated, From ab644f86a24aa08a4740d709695c2bfae89bcaee Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Wed, 3 Jun 2026 08:30:43 +0200 Subject: [PATCH 03/10] feat(compile): add --global/-g flag for user-scope root context compilation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/apm_cli/commands/compile/cli.py | 81 +++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/src/apm_cli/commands/compile/cli.py b/src/apm_cli/commands/compile/cli.py index 9ebfc397b..c0e752776 100644 --- a/src/apm_cli/commands/compile/cli.py +++ b/src/apm_cli/commands/compile/cli.py @@ -320,6 +320,59 @@ def _resolve_effective_target( return detected_target, detection_reason, config_target +def _handle_global_flag(dry_run: bool) -> 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_source_root + from ...integration.targets import KNOWN_TARGETS + from ...utils.console import _rich_error, _rich_info, _rich_success + + source_root = get_source_root(InstallScope.USER) + apm_modules = source_root / "apm_modules" + if not apm_modules.is_dir(): + _rich_error( + f"User-scope apm_modules not found: {apm_modules}. " + "Run 'apm install -g ' to install packages globally." + ) + return 1 + + results = compile_user_root_contexts( + list(KNOWN_TARGETS.values()), + source_root, + dry_run=dry_run, + logger=None, + ) + + if not results: + _rich_info("No user-scope targets produced output (no global instructions found).") + return 0 + + has_error = False + for entry in results: + status = entry["status"] + tname = entry["target"] + path = entry.get("path") + if status == "written": + _rich_success(f"[+] {tname}: wrote {path}") + elif status == "would-write": + _rich_info(f"[*] {tname}: would write {path} (dry-run)") + elif status == "unchanged": + _rich_info(f"[i] {tname}: unchanged {path}") + elif status == "skipped-hand-authored": + _rich_info(f"[i] {tname}: skipped (hand-authored) {path}") + elif status == "skipped-no-instructions": + _rich_info(f"[i] {tname}: skipped (no global instructions)") + elif status.startswith("error:"): + _rich_error(f"[x] {tname}: {status[6:]}") + has_error = True + + return 1 if has_error else 0 + + @click.command(help="Compile APM context into distributed AGENTS.md files") @click.option( "--output", @@ -422,6 +475,17 @@ def _resolve_effective_target( "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 --watch or --root." + ), +) @click.pass_context def compile( ctx, @@ -441,6 +505,7 @@ def compile( compile_all, no_dedup, root, + global_, ): """Compile APM context into distributed AGENTS.md files. @@ -478,6 +543,22 @@ def compile( # 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 ...utils.console import _rich_error + + if watch: + _rich_error("--global cannot be combined with --watch") + return + if root: + _rich_error("--global cannot be combined with --root") + return + rc = _handle_global_flag(dry_run=dry_run) + if rc != 0: + sys.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 From 3fc83865916c709fa0c56827acc1969a10bad3d1 Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Wed, 3 Jun 2026 08:33:56 +0200 Subject: [PATCH 04/10] docs: document apm compile --global user-scope root context flag Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/src/content/docs/producer/compile.md | 33 +++++++++++++++++++ .../.apm/skills/apm-usage/commands.md | 2 +- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/docs/src/content/docs/producer/compile.md b/docs/src/content/docs/producer/compile.md index 1c076f92f..9dd93cf67 100644 --- a/docs/src/content/docs/producer/compile.md +++ b/docs/src/content/docs/producer/compile.md @@ -218,6 +218,39 @@ you can omit `start_marker` and `end_marker` if you use those verbatim. - Content outside the markers is preserved verbatim across every compile run; only the block between the markers is replaced. +## Global compilation (-g) + +By default, `apm compile` reads instructions from your workspace and +writes root context files to `.github/`, `.claude/`, etc. For distributing +instructions to all AI tools on your user's machine (not scoped to a project), +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 an `apply_to:` field) and writes user-scope root context files: + +- `~/.claude/CLAUDE.md` (or `$CLAUDE_CONFIG_DIR/CLAUDE.md`) +- `~/.codex/AGENTS.md`, `~/.copilot/AGENTS.md`, `~/.cursor/AGENTS.md`, etc. +- `~/.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 + +- `--global` cannot be combined with `--watch` or `--root`. +- Skills-only packages (no global instructions) do not write root files. +- To integrate global compile into your install flow, use + `apm install -g` (see [Install packages](../consumer/install-packages/)), which + automatically runs compile after installing global packages. + ## Pitfalls - **Confusing compile's scope.** Compile only handles **instructions** diff --git a/packages/apm-guide/.apm/skills/apm-usage/commands.md b/packages/apm-guide/.apm/skills/apm-usage/commands.md index 6269c79a1..f8c2d7b90 100644 --- a/packages/apm-guide/.apm/skills/apm-usage/commands.md +++ b/packages/apm-guide/.apm/skills/apm-usage/commands.md @@ -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), `--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 `--watch` or `--root`), `--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. From 8723c27ff39e96b8fd873f2dd74bb7076dc30c1a Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Wed, 3 Jun 2026 08:39:37 +0200 Subject: [PATCH 05/10] Add comprehensive unit tests for user-scope root context compilation feature - test_user_root_context.py: 16 tests covering compile_user_root_contexts() function - Tests for no apm_modules, target filtering, instruction discovery, file I/O - Tests for overwrite protection, dry-run, error handling, and logging - test_compile_global_flag.py: 13 tests for CLI --global flag integration - Tests for _handle_global_flag() with various result statuses - Tests for CLI flag validation and error handling - Tests for rich console output functions - test_finalize_user_compile.py: 10 tests for post-install compile hook - Tests for _compile_user_root_contexts_after_install() hook - Tests for finalize.run() integration with different install scopes - Tests for logger behavior and cross-module integration All 39 tests pass with proper mocking, fixtures, and comprehensive scenario coverage. Fixes #1485 --- .../compilation/test_compile_global_flag.py | 432 ++++++++++++ .../compilation/test_user_root_context.py | 626 ++++++++++++++++++ .../phases/test_finalize_user_compile.py | 298 +++++++++ 3 files changed, 1356 insertions(+) create mode 100644 tests/unit/compilation/test_compile_global_flag.py create mode 100644 tests/unit/compilation/test_user_root_context.py create mode 100644 tests/unit/install/phases/test_finalize_user_compile.py diff --git a/tests/unit/compilation/test_compile_global_flag.py b/tests/unit/compilation/test_compile_global_flag.py new file mode 100644 index 000000000..9ee8c844b --- /dev/null +++ b/tests/unit/compilation/test_compile_global_flag.py @@ -0,0 +1,432 @@ +"""Unit tests for compile --global CLI flag. + +Covers the _handle_global_flag function and --global integration in the compile command: + +* _handle_global_flag: error when apm_modules missing +* _handle_global_flag: success when results present +* _handle_global_flag: result status printing (written, unchanged, would-write, etc.) +* _handle_global_flag: error accumulation and exit code +* compile command: --global with --watch rejected +* compile command: --global with --root rejected +* compile command: --global without errors exits 0 +""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import MagicMock, patch + +from click.testing import CliRunner + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_result(target: str, path: str | None, status: str) -> dict: + """Create a result dict as returned by compile_user_root_contexts.""" + return { + "target": target, + "path": Path(path) if path else None, + "status": status, + } + + +# --------------------------------------------------------------------------- +# _handle_global_flag tests +# --------------------------------------------------------------------------- + + +class TestHandleGlobalFlag: + """Tests for _handle_global_flag().""" + + def test_no_apm_modules_returns_error(self, tmp_path): + """apm_modules missing -> returns 1 and prints error.""" + from apm_cli.commands.compile.cli import _handle_global_flag + + source_root = tmp_path / "source" + source_root.mkdir() + # apm_modules does NOT exist + + mock_rich_error = MagicMock() + + with ( + patch( + "apm_cli.core.scope.get_source_root", + return_value=source_root, + ), + patch( + "apm_cli.utils.console._rich_error", + new=mock_rich_error, + ), + ): + rc = _handle_global_flag(dry_run=False) + + assert rc == 1 + mock_rich_error.assert_called_once() + assert "apm_modules not found" in str(mock_rich_error.call_args).lower() + + def test_success_written_status(self, tmp_path): + """Result with 'written' status -> prints [+] and returns 0.""" + from apm_cli.commands.compile.cli import _handle_global_flag + + source_root = tmp_path / "source" + source_root.mkdir() + apm_modules = source_root / "apm_modules" + apm_modules.mkdir() + + results = [_make_result("claude", str(tmp_path / ".claude/CLAUDE.md"), "written")] + + mock_rich_success = MagicMock() + mock_rich_info = MagicMock() + + with ( + patch( + "apm_cli.core.scope.get_source_root", + return_value=source_root, + ), + patch( + "apm_cli.compilation.compile_user_root_contexts", + return_value=results, + ), + patch( + "apm_cli.utils.console._rich_success", + new=mock_rich_success, + ), + patch( + "apm_cli.utils.console._rich_info", + new=mock_rich_info, + ), + ): + rc = _handle_global_flag(dry_run=False) + + assert rc == 0 + # Should call _rich_success with [+] + calls_str = str(mock_rich_success.call_args_list) + assert "[+]" in calls_str or "claude" in calls_str.lower() + + def test_success_would_write_status(self, tmp_path): + """Result with 'would-write' status -> prints [*] and returns 0.""" + from apm_cli.commands.compile.cli import _handle_global_flag + + source_root = tmp_path / "source" + source_root.mkdir() + apm_modules = source_root / "apm_modules" + apm_modules.mkdir() + + results = [_make_result("claude", str(tmp_path / ".claude/CLAUDE.md"), "would-write")] + + mock_rich_info = MagicMock() + + with ( + patch( + "apm_cli.core.scope.get_source_root", + return_value=source_root, + ), + patch( + "apm_cli.compilation.compile_user_root_contexts", + return_value=results, + ), + patch( + "apm_cli.utils.console._rich_info", + new=mock_rich_info, + ), + ): + rc = _handle_global_flag(dry_run=True) + + assert rc == 0 + # Should call _rich_info with [*] + calls_str = str(mock_rich_info.call_args_list) + assert "[*]" in calls_str or "would" in calls_str.lower() + + def test_success_unchanged_status(self, tmp_path): + """Result with 'unchanged' status -> prints [i] and returns 0.""" + from apm_cli.commands.compile.cli import _handle_global_flag + + source_root = tmp_path / "source" + source_root.mkdir() + apm_modules = source_root / "apm_modules" + apm_modules.mkdir() + + results = [_make_result("claude", str(tmp_path / ".claude/CLAUDE.md"), "unchanged")] + + mock_rich_info = MagicMock() + + with ( + patch( + "apm_cli.core.scope.get_source_root", + return_value=source_root, + ), + patch( + "apm_cli.compilation.compile_user_root_contexts", + return_value=results, + ), + patch( + "apm_cli.utils.console._rich_info", + new=mock_rich_info, + ), + ): + rc = _handle_global_flag(dry_run=False) + + assert rc == 0 + calls_str = str(mock_rich_info.call_args_list) + assert "[i]" in calls_str or "unchanged" in calls_str.lower() + + def test_success_skipped_no_instructions(self, tmp_path): + """Result with 'skipped-no-instructions' -> prints [i] and returns 0.""" + from apm_cli.commands.compile.cli import _handle_global_flag + + source_root = tmp_path / "source" + source_root.mkdir() + apm_modules = source_root / "apm_modules" + apm_modules.mkdir() + + results = [_make_result("claude", None, "skipped-no-instructions")] + + mock_rich_info = MagicMock() + + with ( + patch( + "apm_cli.core.scope.get_source_root", + return_value=source_root, + ), + patch( + "apm_cli.compilation.compile_user_root_contexts", + return_value=results, + ), + patch( + "apm_cli.utils.console._rich_info", + new=mock_rich_info, + ), + ): + rc = _handle_global_flag(dry_run=False) + + assert rc == 0 + calls_str = str(mock_rich_info.call_args_list) + assert "[i]" in calls_str or "skipped" in calls_str.lower() + + def test_success_skipped_hand_authored(self, tmp_path): + """Result with 'skipped-hand-authored' -> prints [i] and returns 0.""" + from apm_cli.commands.compile.cli import _handle_global_flag + + source_root = tmp_path / "source" + source_root.mkdir() + apm_modules = source_root / "apm_modules" + apm_modules.mkdir() + + results = [ + _make_result("claude", str(tmp_path / ".claude/CLAUDE.md"), "skipped-hand-authored") + ] + + mock_rich_info = MagicMock() + + with ( + patch( + "apm_cli.core.scope.get_source_root", + return_value=source_root, + ), + patch( + "apm_cli.compilation.compile_user_root_contexts", + return_value=results, + ), + patch( + "apm_cli.utils.console._rich_info", + new=mock_rich_info, + ), + ): + rc = _handle_global_flag(dry_run=False) + + assert rc == 0 + + def test_error_status_returns_1(self, tmp_path): + """Result with 'error:...' status -> prints [x] and returns 1.""" + from apm_cli.commands.compile.cli import _handle_global_flag + + source_root = tmp_path / "source" + source_root.mkdir() + apm_modules = source_root / "apm_modules" + apm_modules.mkdir() + + results = [_make_result("claude", str(tmp_path / ".claude/CLAUDE.md"), "error:disk full")] + + mock_rich_error = MagicMock() + mock_rich_info = MagicMock() + + with ( + patch( + "apm_cli.core.scope.get_source_root", + return_value=source_root, + ), + patch( + "apm_cli.compilation.compile_user_root_contexts", + return_value=results, + ), + patch( + "apm_cli.utils.console._rich_error", + new=mock_rich_error, + ), + patch( + "apm_cli.utils.console._rich_info", + new=mock_rich_info, + ), + ): + rc = _handle_global_flag(dry_run=False) + + assert rc == 1 + # Should call _rich_error + mock_rich_error.assert_called() + + def test_multiple_results_mixed_status(self, tmp_path): + """Multiple results with different status values.""" + from apm_cli.commands.compile.cli import _handle_global_flag + + source_root = tmp_path / "source" + source_root.mkdir() + apm_modules = source_root / "apm_modules" + apm_modules.mkdir() + + results = [ + _make_result("claude", str(tmp_path / ".claude/CLAUDE.md"), "written"), + _make_result("vscode", str(tmp_path / ".vscode/AGENTS.md"), "unchanged"), + ] + + mock_rich_success = MagicMock() + mock_rich_info = MagicMock() + + with ( + patch( + "apm_cli.core.scope.get_source_root", + return_value=source_root, + ), + patch( + "apm_cli.compilation.compile_user_root_contexts", + return_value=results, + ), + patch( + "apm_cli.utils.console._rich_success", + new=mock_rich_success, + ), + patch( + "apm_cli.utils.console._rich_info", + new=mock_rich_info, + ), + ): + rc = _handle_global_flag(dry_run=False) + + assert rc == 0 + # Success and info should have been called + assert mock_rich_success.called or mock_rich_info.called + + def test_no_results_returns_success(self, tmp_path): + """Empty results list -> returns 0.""" + from apm_cli.commands.compile.cli import _handle_global_flag + + source_root = tmp_path / "source" + source_root.mkdir() + apm_modules = source_root / "apm_modules" + apm_modules.mkdir() + + results = [] + + mock_rich_info = MagicMock() + + with ( + patch( + "apm_cli.core.scope.get_source_root", + return_value=source_root, + ), + patch( + "apm_cli.compilation.compile_user_root_contexts", + return_value=results, + ), + patch( + "apm_cli.utils.console._rich_info", + new=mock_rich_info, + ), + ): + rc = _handle_global_flag(dry_run=False) + + assert rc == 0 + + +# --------------------------------------------------------------------------- +# compile command --global integration tests +# --------------------------------------------------------------------------- + + +class TestCompileGlobalCommand: + """Tests for compile command with --global flag.""" + + def test_global_with_watch_rejected(self): + """--global and --watch together -> error message, no sys.exit.""" + from apm_cli.commands.compile.cli import compile as compile_cmd + + runner = CliRunner() + + with patch("apm_cli.utils.console._rich_error") as mock_error: + # Invoke with both --global and --watch + runner.invoke(compile_cmd, ["--global", "--watch"], standalone_mode=False) + + # Should reject the combination (returns None, not sys.exit) + mock_error.assert_called() + call_str = str(mock_error.call_args) + assert "global" in call_str.lower() and "watch" in call_str.lower() + + def test_global_with_root_rejected(self): + """--global and --root together -> error message, no sys.exit.""" + from apm_cli.commands.compile.cli import compile as compile_cmd + + runner = CliRunner() + + with patch("apm_cli.utils.console._rich_error") as mock_error: + # Invoke with both --global and --root + runner.invoke(compile_cmd, ["--global", "--root", "/tmp"], standalone_mode=False) + + # Should reject the combination (returns None, not sys.exit) + mock_error.assert_called() + call_str = str(mock_error.call_args) + assert "global" in call_str.lower() and "root" in call_str.lower() + + def test_global_success_no_exit(self, tmp_path): + """--global with successful _handle_global_flag -> returns normally.""" + from apm_cli.commands.compile.cli import compile as compile_cmd + + runner = CliRunner() + + source_root = tmp_path / "source" + source_root.mkdir() + + with ( + patch("apm_cli.core.scope.get_source_root", return_value=source_root), + patch( + "apm_cli.commands.compile.cli._handle_global_flag", + return_value=0, + ), + ): + # Invoke with --global; should return 0 (success) + result = runner.invoke(compile_cmd, ["--global"], standalone_mode=False) + + # Runner exit_code should be 0 + assert result.exit_code == 0 + + def test_global_failure_exits_1(self, tmp_path): + """--global with _handle_global_flag returning 1 -> sys.exit(1).""" + from apm_cli.commands.compile.cli import compile as compile_cmd + + runner = CliRunner() + + source_root = tmp_path / "source" + source_root.mkdir() + + with ( + patch("apm_cli.core.scope.get_source_root", return_value=source_root), + patch( + "apm_cli.commands.compile.cli._handle_global_flag", + return_value=1, + ), + ): + # Invoke with --global; _handle_global_flag returns 1 -> sys.exit(1) + result = runner.invoke(compile_cmd, ["--global"]) + + # Runner exit_code should be 1 + assert result.exit_code == 1 diff --git a/tests/unit/compilation/test_user_root_context.py b/tests/unit/compilation/test_user_root_context.py new file mode 100644 index 000000000..e636135c2 --- /dev/null +++ b/tests/unit/compilation/test_user_root_context.py @@ -0,0 +1,626 @@ +"""Unit tests for apm_cli.compilation.user_root_context. + +Covers compile_user_root_contexts() for user-scope root context file generation: + +* apm_modules directory discovery +* Target filtering (for_scope, compile_family) +* Global instructions discovery and filtering +* File generation and placement (deploy roots) +* Overwrite protection (generated marker detection) +* Dry-run behavior +* Content generation with Build ID +* Error handling (OSError on read/write) +""" + +from __future__ import annotations + +import logging +from pathlib import Path +from unittest.mock import MagicMock, patch + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +# Sentinel value to distinguish "not provided" from "explicitly None" +_UNSET = object() + + +def _make_target( + name: str, + compile_family: str, + for_scope_returns=_UNSET, + deploy_root=None, +): + """Create a mock TargetProfile.""" + target = MagicMock() + target.name = name + target.compile_family = compile_family + target.resolved_deploy_root = deploy_root + target.root_dir = f".{name}" + + scoped = MagicMock() + scoped.name = name + scoped.compile_family = compile_family + scoped.resolved_deploy_root = deploy_root + scoped.root_dir = f".{name}" + + if for_scope_returns is _UNSET: + target.for_scope = MagicMock(return_value=scoped) + else: + target.for_scope = MagicMock(return_value=for_scope_returns) + + return target + + +def _make_instruction(name="global", apply_to=None, content="Use type hints"): + """Create a mock Instruction.""" + instr = MagicMock() + instr.name = name + instr.apply_to = apply_to # None = global instruction + instr.content = content + instr.file_path = Path(f"/tmp/{name}.instructions.md") + return instr + + +# --------------------------------------------------------------------------- +# test_no_apm_modules_returns_empty +# --------------------------------------------------------------------------- + + +class TestNoApmModulesReturnsEmpty: + """When apm_modules dir does not exist, return [].""" + + def test_apm_modules_missing(self, tmp_path): + """apm_modules directory does not exist -> return [].""" + from apm_cli.compilation.user_root_context import compile_user_root_contexts + + source_root = tmp_path / "source" + source_root.mkdir() + # apm_modules does NOT exist + targets = [_make_target("claude", "claude")] + + result = compile_user_root_contexts(targets, source_root) + + assert result == [] + + def test_apm_modules_is_file_not_dir(self, tmp_path): + """apm_modules exists as a file (not dir) -> return [].""" + from apm_cli.compilation.user_root_context import compile_user_root_contexts + + source_root = tmp_path / "source" + source_root.mkdir() + apm_modules = source_root / "apm_modules" + apm_modules.touch() # Create as file, not directory + + targets = [_make_target("claude", "claude")] + result = compile_user_root_contexts(targets, source_root) + + assert result == [] + + +# --------------------------------------------------------------------------- +# test_skip_when_for_scope_returns_none +# --------------------------------------------------------------------------- + + +class TestSkipWhenForScopeReturnsNone: + """Target that does not support user scope (for_scope returns None) is skipped.""" + + def test_for_scope_returns_none(self, tmp_path): + """for_scope(user_scope=True) returns None -> skip target.""" + from apm_cli.compilation.user_root_context import compile_user_root_contexts + + source_root = tmp_path / "source" + source_root.mkdir() + apm_modules = source_root / "apm_modules" + apm_modules.mkdir() + + target = _make_target("agents", "agents", for_scope_returns=None) + result = compile_user_root_contexts([target], source_root) + + assert result == [] + target.for_scope.assert_called_once_with(user_scope=True) + + +# --------------------------------------------------------------------------- +# test_skip_when_no_root_filename_family +# --------------------------------------------------------------------------- + + +class TestSkipWhenNoRootFilenameFamily: + """Target with unknown compile_family (not in _ROOT_FILENAME) is skipped.""" + + def test_unknown_compile_family(self, tmp_path): + """compile_family not in _ROOT_FILENAME map -> skip.""" + from apm_cli.compilation.user_root_context import compile_user_root_contexts + + source_root = tmp_path / "source" + source_root.mkdir() + apm_modules = source_root / "apm_modules" + apm_modules.mkdir() + + # Use mock that returns family="unknown" + target = _make_target("mystery", "unknown") + result = compile_user_root_contexts([target], source_root) + + assert result == [] + + +# --------------------------------------------------------------------------- +# test_skipped_no_instructions +# --------------------------------------------------------------------------- + + +class TestSkippedNoInstructions: + """When no global instructions found, return skipped-no-instructions.""" + + def test_no_global_instructions(self, tmp_path): + """No global instructions in apm_modules -> skipped-no-instructions.""" + from apm_cli.compilation.user_root_context import compile_user_root_contexts + + source_root = tmp_path / "source" + source_root.mkdir() + apm_modules = source_root / "apm_modules" + apm_modules.mkdir() + + target = _make_target("claude", "claude") + + primitives = MagicMock() + primitives.instructions = [] # No instructions at all + + with patch( + "apm_cli.primitives.discovery.discover_primitives", + return_value=primitives, + ): + result = compile_user_root_contexts([target], source_root) + + assert len(result) == 1 + assert result[0]["target"] == "claude" + assert result[0]["path"] is None + assert result[0]["status"] == "skipped-no-instructions" + + def test_only_scoped_instructions_no_global(self, tmp_path): + """All instructions are scoped (have apply_to) -> no global -> skipped.""" + from apm_cli.compilation.user_root_context import compile_user_root_contexts + + source_root = tmp_path / "source" + source_root.mkdir() + apm_modules = source_root / "apm_modules" + apm_modules.mkdir() + + target = _make_target("claude", "claude") + + # All have apply_to (not global) + instr = _make_instruction("scoped", apply_to="**/*.py", content="Use hints") + primitives = MagicMock() + primitives.instructions = [instr] + + with patch( + "apm_cli.primitives.discovery.discover_primitives", + return_value=primitives, + ): + result = compile_user_root_contexts([target], source_root) + + assert len(result) == 1 + assert result[0]["status"] == "skipped-no-instructions" + + +# --------------------------------------------------------------------------- +# test_skipped_hand_authored +# --------------------------------------------------------------------------- + + +class TestSkippedHandAuthored: + """Existing file without generated marker is skipped.""" + + def test_hand_authored_no_marker(self, tmp_path): + """Existing file without APM marker -> skipped-hand-authored.""" + from apm_cli.compilation.user_root_context import compile_user_root_contexts + + source_root = tmp_path / "source" + source_root.mkdir() + apm_modules = source_root / "apm_modules" + apm_modules.mkdir() + + # Claude deploy root + deploy_root = tmp_path / ".claude" + deploy_root.mkdir(parents=True, exist_ok=True) + output_path = deploy_root / "CLAUDE.md" + + # Write hand-authored file (no marker) + output_path.write_text("# My custom Claude context\nSome notes here.\n") + + target = _make_target("claude", "claude", deploy_root=deploy_root) + instr = _make_instruction("global", apply_to=None, content="Use type hints") + primitives = MagicMock() + primitives.instructions = [instr] + + with patch( + "apm_cli.primitives.discovery.discover_primitives", + return_value=primitives, + ): + result = compile_user_root_contexts([target], source_root) + + assert len(result) == 1 + assert result[0]["target"] == "claude" + assert result[0]["path"] == output_path + assert result[0]["status"] == "skipped-hand-authored" + + # File should NOT have been modified + existing = output_path.read_text() + assert existing == "# My custom Claude context\nSome notes here.\n" + + +# --------------------------------------------------------------------------- +# test_written_new_file +# --------------------------------------------------------------------------- + + +class TestWrittenNewFile: + """New file (does not exist) is created with status written.""" + + def test_write_new_file(self, tmp_path): + """No existing file -> create and write with status written.""" + from apm_cli.compilation.user_root_context import compile_user_root_contexts + + source_root = tmp_path / "source" + source_root.mkdir() + apm_modules = source_root / "apm_modules" + apm_modules.mkdir() + + deploy_root = tmp_path / ".claude" + # Do NOT create deploy_root; it should be created by the function + output_path = deploy_root / "CLAUDE.md" + + target = _make_target("claude", "claude", deploy_root=deploy_root) + instr = _make_instruction("global", apply_to=None, content="Use type hints") + primitives = MagicMock() + primitives.instructions = [instr] + + with patch( + "apm_cli.primitives.discovery.discover_primitives", + return_value=primitives, + ): + result = compile_user_root_contexts([target], source_root) + + assert len(result) == 1 + assert result[0]["target"] == "claude" + assert result[0]["path"] == output_path + assert result[0]["status"] == "written" + + # File should exist + assert output_path.exists() + content = output_path.read_text() + assert "Generated by APM CLI" in content + assert "Use type hints" in content + + +# --------------------------------------------------------------------------- +# test_unchanged +# --------------------------------------------------------------------------- + + +class TestUnchanged: + """Existing file that matches generated content has status unchanged.""" + + def test_file_unchanged(self, tmp_path): + """Existing file matches generated content -> unchanged.""" + from apm_cli.compilation.user_root_context import compile_user_root_contexts + + source_root = tmp_path / "source" + source_root.mkdir() + apm_modules = source_root / "apm_modules" + apm_modules.mkdir() + + deploy_root = tmp_path / ".claude" + deploy_root.mkdir(parents=True, exist_ok=True) + + target = _make_target("claude", "claude", deploy_root=deploy_root) + instr = _make_instruction("global", apply_to=None, content="Use type hints") + primitives = MagicMock() + primitives.instructions = [instr] + + # Generate content and write it once + with patch( + "apm_cli.primitives.discovery.discover_primitives", + return_value=primitives, + ): + result1 = compile_user_root_contexts([target], source_root) + assert result1[0]["status"] == "written" + + # Now generate again with the same content + with patch( + "apm_cli.primitives.discovery.discover_primitives", + return_value=primitives, + ): + result2 = compile_user_root_contexts([target], source_root) + + assert len(result2) == 1 + assert result2[0]["target"] == "claude" + assert result2[0]["status"] == "unchanged" + + +# --------------------------------------------------------------------------- +# test_dry_run_would_write +# --------------------------------------------------------------------------- + + +class TestDryRunWouldWrite: + """dry_run=True produces status would-write without writing file.""" + + def test_dry_run_no_file_written(self, tmp_path): + """dry_run=True -> would-write, no file created.""" + from apm_cli.compilation.user_root_context import compile_user_root_contexts + + source_root = tmp_path / "source" + source_root.mkdir() + apm_modules = source_root / "apm_modules" + apm_modules.mkdir() + + deploy_root = tmp_path / ".claude" + # Do NOT create deploy_root + output_path = deploy_root / "CLAUDE.md" + + target = _make_target("claude", "claude", deploy_root=deploy_root) + instr = _make_instruction("global", apply_to=None, content="Use type hints") + primitives = MagicMock() + primitives.instructions = [instr] + + with patch( + "apm_cli.primitives.discovery.discover_primitives", + return_value=primitives, + ): + result = compile_user_root_contexts([target], source_root, dry_run=True) + + assert len(result) == 1 + assert result[0]["target"] == "claude" + assert result[0]["path"] == output_path + assert result[0]["status"] == "would-write" + + # File should NOT exist (no write happened) + assert not output_path.exists() + + +# --------------------------------------------------------------------------- +# test_marker_in_written_file +# --------------------------------------------------------------------------- + + +class TestMarkerInWrittenFile: + """Written file contains the _COPILOT_ROOT_GENERATED_MARKER.""" + + def test_marker_present_in_generated_file(self, tmp_path): + """Generated file contains APM marker for overwrite detection.""" + from apm_cli.compilation.user_root_context import compile_user_root_contexts + + source_root = tmp_path / "source" + source_root.mkdir() + apm_modules = source_root / "apm_modules" + apm_modules.mkdir() + + deploy_root = tmp_path / ".claude" + output_path = deploy_root / "CLAUDE.md" + + target = _make_target("claude", "claude", deploy_root=deploy_root) + instr = _make_instruction("global", apply_to=None, content="Use type hints") + primitives = MagicMock() + primitives.instructions = [instr] + + with patch( + "apm_cli.primitives.discovery.discover_primitives", + return_value=primitives, + ): + result = compile_user_root_contexts([target], source_root) + + assert result[0]["status"] == "written" + content = output_path.read_text() + # Check for the marker (from agents_compiler) + assert "Generated by APM CLI from .apm/ primitives" in content + + +# --------------------------------------------------------------------------- +# test_claude_config_dir +# --------------------------------------------------------------------------- + + +class TestClaudeConfigDir: + """Resolved deploy root is honored when set.""" + + def test_resolved_deploy_root_honored(self, tmp_path): + """resolved_deploy_root is used instead of home()/.claude.""" + from apm_cli.compilation.user_root_context import compile_user_root_contexts + + source_root = tmp_path / "source" + source_root.mkdir() + apm_modules = source_root / "apm_modules" + apm_modules.mkdir() + + # Use a non-standard deploy root + custom_root = tmp_path / "custom_deploy" + output_path = custom_root / "CLAUDE.md" + + target = _make_target("claude", "claude", deploy_root=custom_root) + instr = _make_instruction("global", apply_to=None, content="Use type hints") + primitives = MagicMock() + primitives.instructions = [instr] + + with patch( + "apm_cli.primitives.discovery.discover_primitives", + return_value=primitives, + ): + result = compile_user_root_contexts([target], source_root) + + assert len(result) == 1 + assert result[0]["target"] == "claude" + assert result[0]["path"] == output_path + assert result[0]["status"] == "written" + + # File should be at custom_root, not home()/.claude + assert output_path.exists() + + +# --------------------------------------------------------------------------- +# test_multiple_targets +# --------------------------------------------------------------------------- + + +class TestMultipleTargets: + """Multiple targets are processed in sequence.""" + + def test_multiple_targets_all_written(self, tmp_path): + """Multiple targets, all write their files.""" + from apm_cli.compilation.user_root_context import compile_user_root_contexts + + source_root = tmp_path / "source" + source_root.mkdir() + apm_modules = source_root / "apm_modules" + apm_modules.mkdir() + + claude_root = tmp_path / ".claude" + vscode_root = tmp_path / ".vscode" + + targets = [ + _make_target("claude", "claude", deploy_root=claude_root), + _make_target("vscode", "vscode", deploy_root=vscode_root), + ] + + instr = _make_instruction("global", apply_to=None, content="Use type hints") + primitives = MagicMock() + primitives.instructions = [instr] + + with patch( + "apm_cli.primitives.discovery.discover_primitives", + return_value=primitives, + ): + result = compile_user_root_contexts(targets, source_root) + + assert len(result) == 2 + assert result[0]["target"] == "claude" + assert result[0]["status"] == "written" + assert result[1]["target"] == "vscode" + assert result[1]["status"] == "written" + + # Both files exist + assert (claude_root / "CLAUDE.md").exists() + assert (vscode_root / "AGENTS.md").exists() + + +# --------------------------------------------------------------------------- +# test_error_on_read +# --------------------------------------------------------------------------- + + +class TestErrorOnRead: + """OSError during read returns error status.""" + + def test_oserror_on_read_existing_file(self, tmp_path): + """Cannot read existing file -> error:.""" + from apm_cli.compilation.user_root_context import compile_user_root_contexts + + source_root = tmp_path / "source" + source_root.mkdir() + apm_modules = source_root / "apm_modules" + apm_modules.mkdir() + + deploy_root = tmp_path / ".claude" + deploy_root.mkdir(parents=True, exist_ok=True) + output_path = deploy_root / "CLAUDE.md" + output_path.write_text("existing content") + + target = _make_target("claude", "claude", deploy_root=deploy_root) + instr = _make_instruction("global", apply_to=None, content="Use type hints") + primitives = MagicMock() + primitives.instructions = [instr] + + # Patch read_text to raise OSError + with ( + patch( + "apm_cli.primitives.discovery.discover_primitives", + return_value=primitives, + ), + patch.object(Path, "read_text", side_effect=OSError("permission denied")), + ): + result = compile_user_root_contexts([target], source_root) + + assert len(result) == 1 + assert result[0]["target"] == "claude" + assert result[0]["status"].startswith("error:") + assert "permission denied" in result[0]["status"] + + +# --------------------------------------------------------------------------- +# test_error_on_write +# --------------------------------------------------------------------------- + + +class TestErrorOnWrite: + """OSError during write returns error status.""" + + def test_oserror_on_write_new_file(self, tmp_path): + """Cannot write new file -> error:.""" + from apm_cli.compilation.user_root_context import compile_user_root_contexts + + source_root = tmp_path / "source" + source_root.mkdir() + apm_modules = source_root / "apm_modules" + apm_modules.mkdir() + + deploy_root = tmp_path / ".claude" + + target = _make_target("claude", "claude", deploy_root=deploy_root) + instr = _make_instruction("global", apply_to=None, content="Use type hints") + primitives = MagicMock() + primitives.instructions = [instr] + + # Patch write_text to raise OSError + with ( + patch( + "apm_cli.primitives.discovery.discover_primitives", + return_value=primitives, + ), + patch.object(Path, "write_text", side_effect=OSError("disk full")), + ): + result = compile_user_root_contexts([target], source_root) + + assert len(result) == 1 + assert result[0]["target"] == "claude" + assert result[0]["status"].startswith("error:") + assert "disk full" in result[0]["status"] + + +# --------------------------------------------------------------------------- +# test_logger_usage +# --------------------------------------------------------------------------- + + +class TestLoggerUsage: + """Logger is used for debug/info/warning output.""" + + def test_logger_called_on_success(self, tmp_path): + """Logger receives debug calls for various steps.""" + from apm_cli.compilation.user_root_context import compile_user_root_contexts + + source_root = tmp_path / "source" + source_root.mkdir() + apm_modules = source_root / "apm_modules" + apm_modules.mkdir() + + deploy_root = tmp_path / ".claude" + + target = _make_target("claude", "claude", deploy_root=deploy_root) + instr = _make_instruction("global", apply_to=None, content="Use type hints") + primitives = MagicMock() + primitives.instructions = [instr] + + mock_logger = MagicMock(spec=logging.Logger) + + with patch( + "apm_cli.primitives.discovery.discover_primitives", + return_value=primitives, + ): + compile_user_root_contexts([target], source_root, logger=mock_logger) + + # Logger should have received debug/info calls + assert mock_logger.debug.called or mock_logger.info.called diff --git a/tests/unit/install/phases/test_finalize_user_compile.py b/tests/unit/install/phases/test_finalize_user_compile.py new file mode 100644 index 000000000..c08876caf --- /dev/null +++ b/tests/unit/install/phases/test_finalize_user_compile.py @@ -0,0 +1,298 @@ +"""Unit tests for finalize.py post-install compile hook. + +Covers _compile_user_root_contexts_after_install and its integration in run(): + +* _compile_user_root_contexts_after_install: calls compile_user_root_contexts +* _compile_user_root_contexts_after_install: logs when files are written +* run(): does NOT call compile for PROJECT scope +* run(): DOES call compile for USER scope +* run(): passes correct source_root to compile +""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import MagicMock, patch + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_install_context(scope=None, logger=None): + """Create a mock InstallContext.""" + ctx = MagicMock() + ctx.scope = scope + ctx.logger = logger + ctx.total_links_resolved = 0 + ctx.total_commands_integrated = 0 + ctx.total_hooks_integrated = 0 + ctx.total_instructions_integrated = 0 + ctx.installed_count = 1 + ctx.unpinned_count = 0 + ctx.installed_packages = [] + ctx.package_types = {} + ctx.diagnostics = MagicMock() + return ctx + + +# --------------------------------------------------------------------------- +# _compile_user_root_contexts_after_install tests +# --------------------------------------------------------------------------- + + +class TestCompileUserRootContextsAfterInstall: + """Tests for _compile_user_root_contexts_after_install().""" + + def test_calls_compile_user_root_contexts(self): + """Function calls compile_user_root_contexts with correct arguments.""" + from apm_cli.core.scope import InstallScope + from apm_cli.install.phases.finalize import ( + _compile_user_root_contexts_after_install, + ) + + source_root = Path.home() + ctx = _make_install_context(scope=InstallScope.USER) + + mock_compile = MagicMock(return_value=[]) + + with ( + patch( + "apm_cli.core.scope.get_source_root", + return_value=source_root, + ), + patch( + "apm_cli.compilation.compile_user_root_contexts", + side_effect=mock_compile, + ), + ): + _compile_user_root_contexts_after_install(ctx) + + # Should have called compile_user_root_contexts + mock_compile.assert_called_once() + call_args = mock_compile.call_args + # Check that source_root was passed + assert call_args[0][1] == source_root + # Check that dry_run=False + assert call_args[1]["dry_run"] is False + + def test_logs_when_files_written(self): + """When files are written, logger.verbose_detail is called.""" + from apm_cli.core.scope import InstallScope + from apm_cli.install.phases.finalize import ( + _compile_user_root_contexts_after_install, + ) + + source_root = Path.home() + mock_logger = MagicMock() + ctx = _make_install_context(scope=InstallScope.USER, logger=mock_logger) + + # Two written files + results = [ + {"target": "claude", "path": Path(".claude/CLAUDE.md"), "status": "written"}, + {"target": "vscode", "path": Path(".vscode/AGENTS.md"), "status": "written"}, + ] + + with ( + patch( + "apm_cli.core.scope.get_source_root", + return_value=source_root, + ), + patch( + "apm_cli.compilation.compile_user_root_contexts", + return_value=results, + ), + ): + _compile_user_root_contexts_after_install(ctx) + + # Logger should have been called with verbose_detail + mock_logger.verbose_detail.assert_called_once() + call_str = str(mock_logger.verbose_detail.call_args) + assert "2" in call_str # Should mention 2 files + + def test_no_logging_when_no_files_written(self): + """When no files written, logger not called.""" + from apm_cli.core.scope import InstallScope + from apm_cli.install.phases.finalize import ( + _compile_user_root_contexts_after_install, + ) + + source_root = Path.home() + mock_logger = MagicMock() + ctx = _make_install_context(scope=InstallScope.USER, logger=mock_logger) + + # No written files + results = [ + {"target": "claude", "path": None, "status": "skipped-no-instructions"}, + ] + + with ( + patch( + "apm_cli.core.scope.get_source_root", + return_value=source_root, + ), + patch( + "apm_cli.compilation.compile_user_root_contexts", + return_value=results, + ), + ): + _compile_user_root_contexts_after_install(ctx) + + # Logger should NOT have been called + mock_logger.verbose_detail.assert_not_called() + + def test_no_logging_when_logger_none(self): + """When logger is None, no logging occurs.""" + from apm_cli.core.scope import InstallScope + from apm_cli.install.phases.finalize import ( + _compile_user_root_contexts_after_install, + ) + + source_root = Path.home() + ctx = _make_install_context(scope=InstallScope.USER, logger=None) + + # Files written, but logger is None + results = [ + {"target": "claude", "path": Path(".claude/CLAUDE.md"), "status": "written"}, + ] + + with ( + patch( + "apm_cli.core.scope.get_source_root", + return_value=source_root, + ), + patch( + "apm_cli.compilation.compile_user_root_contexts", + return_value=results, + ), + ): + # Should not raise + _compile_user_root_contexts_after_install(ctx) + + +# --------------------------------------------------------------------------- +# finalize.run() integration tests +# --------------------------------------------------------------------------- + + +class TestFinalizeRunIntegration: + """Tests for run() function's integration of compile hook.""" + + def test_project_scope_no_compile(self): + """When ctx.scope is PROJECT, compile hook is NOT called.""" + from apm_cli.core.scope import InstallScope + from apm_cli.install.phases.finalize import run + + ctx = _make_install_context(scope=InstallScope.PROJECT) + + mock_compile = MagicMock() + + with patch( + "apm_cli.install.phases.finalize._compile_user_root_contexts_after_install", + side_effect=mock_compile, + ): + run(ctx) + + # Compile hook should NOT have been called + mock_compile.assert_not_called() + + def test_user_scope_compile_called(self): + """When ctx.scope is USER, compile hook IS called.""" + from apm_cli.core.scope import InstallScope + from apm_cli.install.phases.finalize import run + + ctx = _make_install_context(scope=InstallScope.USER, logger=None) + + mock_compile = MagicMock() + + with patch( + "apm_cli.install.phases.finalize._compile_user_root_contexts_after_install", + side_effect=mock_compile, + ): + result = run(ctx) + + # Compile hook SHOULD have been called + mock_compile.assert_called_once() + mock_compile.assert_called_once_with(ctx) + # Result should still be valid + assert result is not None + + def test_none_scope_no_compile(self): + """When ctx.scope is None, compile hook is NOT called.""" + from apm_cli.install.phases.finalize import run + + ctx = _make_install_context(scope=None) + + mock_compile = MagicMock() + + with patch( + "apm_cli.install.phases.finalize._compile_user_root_contexts_after_install", + side_effect=mock_compile, + ): + run(ctx) + + # Compile hook should NOT have been called + mock_compile.assert_not_called() + + def test_run_returns_install_result(self): + """run() returns an InstallResult object.""" + from apm_cli.core.scope import InstallScope + from apm_cli.install.phases.finalize import run + from apm_cli.models.results import InstallResult + + ctx = _make_install_context(scope=InstallScope.USER, logger=None) + + with patch( + "apm_cli.install.phases.finalize._compile_user_root_contexts_after_install", + ): + result = run(ctx) + + # Should be an InstallResult + assert isinstance(result, InstallResult) + assert result.installed_count == 1 + + def test_user_scope_compile_receives_context(self): + """compile hook receives the correct context object.""" + from apm_cli.core.scope import InstallScope + from apm_cli.install.phases.finalize import run + + ctx = _make_install_context(scope=InstallScope.USER, logger=None) + + mock_compile = MagicMock() + + with patch( + "apm_cli.install.phases.finalize._compile_user_root_contexts_after_install", + side_effect=mock_compile, + ): + run(ctx) + + # Verify the same context object was passed + mock_compile.assert_called_once_with(ctx) + + def test_all_stats_collected_before_compile(self): + """Stats are collected before compile hook is called.""" + from apm_cli.core.scope import InstallScope + from apm_cli.install.phases.finalize import run + + ctx = _make_install_context(scope=InstallScope.USER, logger=None) + ctx.total_links_resolved = 5 + ctx.total_commands_integrated = 2 + ctx.total_hooks_integrated = 3 + ctx.total_instructions_integrated = 1 + + compile_called = False + + def mock_compile_fn(call_ctx): + nonlocal compile_called + compile_called = True + # At this point, the context should still have its stats + assert call_ctx.total_links_resolved == 5 + assert call_ctx.total_commands_integrated == 2 + + with patch( + "apm_cli.install.phases.finalize._compile_user_root_contexts_after_install", + side_effect=mock_compile_fn, + ): + run(ctx) + + assert compile_called From bf5b74deef671829213f5bf15ed9a2dfe20ebaa6 Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Wed, 3 Jun 2026 08:43:18 +0200 Subject: [PATCH 06/10] fix(install): pass logger=None to compile_user_root_contexts in finalize hook; add scope to test stub InstallLogger does not expose stdlib debug/info API; use stdlib logger for compile_user_root_contexts. Also adds scope field to _FakeCtx in test_finalize_phase.py so the new scope guard compiles cleanly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/apm_cli/install/phases/finalize.py | 4 +++- tests/unit/install/test_finalize_phase.py | 2 ++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/apm_cli/install/phases/finalize.py b/src/apm_cli/install/phases/finalize.py index ac7297207..dfb1df058 100644 --- a/src/apm_cli/install/phases/finalize.py +++ b/src/apm_cli/install/phases/finalize.py @@ -27,7 +27,9 @@ def _compile_user_root_contexts_after_install(ctx: InstallContext) -> None: source_root = get_source_root(InstallScope.USER) targets = list(KNOWN_TARGETS.values()) - results = compile_user_root_contexts(targets, source_root, dry_run=False, logger=ctx.logger) + # Pass logger=None so compile_user_root_contexts uses the stdlib logger; + # ctx.logger is an InstallLogger which does not expose the stdlib debug/info API. + results = compile_user_root_contexts(targets, source_root, dry_run=False, logger=None) written = [r for r in results if r.get("status") == "written"] if written and ctx.logger: ctx.logger.verbose_detail(f"Compiled {len(written)} user-scope root context file(s)") diff --git a/tests/unit/install/test_finalize_phase.py b/tests/unit/install/test_finalize_phase.py index e26188f4c..7d54f22df 100644 --- a/tests/unit/install/test_finalize_phase.py +++ b/tests/unit/install/test_finalize_phase.py @@ -7,6 +7,7 @@ from typing import Any from unittest.mock import MagicMock, patch +from apm_cli.core.scope import InstallScope from apm_cli.install.phases.finalize import run from apm_cli.utils.diagnostics import DiagnosticCollector @@ -33,6 +34,7 @@ class _FakeCtx: diagnostics: Any = field(default_factory=DiagnosticCollector) logger: Any = None package_types: dict[str, str] = field(default_factory=dict) + scope: Any = field(default_factory=lambda: InstallScope.PROJECT) # --------------------------------------------------------------------------- From aef493a5580c9d92c5dec1232d7b756f71ce99f0 Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Wed, 3 Jun 2026 09:32:00 +0200 Subject: [PATCH 07/10] fix: use get_apm_dir for user-scope apm_modules path; fix exit codes and type annotations Addresses all 6 Copilot inline findings (shepherd-driver fold run): - finalize.py and compile/cli.py: get_source_root(USER) returns Path.home() but apm_modules lives under get_apm_dir(USER) (~/.apm/). Both callers now call get_apm_dir(USER) so the feature no longer silently skips compilation. - test_compile_global_flag.py and test_finalize_user_compile.py: test mocks updated to patch get_apm_dir instead of get_source_root. Tests now validate the correct contract. Path.home() / '.apm' used as the mock return value. - compile/cli.py: mutual-exclusion errors for --global+--watch and --global+--root now call sys.exit(2) instead of bare return so CI scripts see a non-zero exit code on flag misuse. - compile/cli.py: _handle_global_flag output now uses symbol= API (symbol='check', 'preview', 'info', 'error') instead of hard-coded bracket prefixes in f-strings. - docs/reference/cli/compile.md: adds --global/-g flag entry in a new 'Global compilation' section with usage notes and overwrite-protection semantics. - user_root_context.py: adds type annotations to compile_user_root_contexts, _resolve_deploy_root, and _generate_content public-ish surface. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/content/docs/reference/cli/compile.md | 10 ++++++ src/apm_cli/commands/compile/cli.py | 20 +++++------ src/apm_cli/compilation/user_root_context.py | 17 +++++---- src/apm_cli/install/phases/finalize.py | 4 +-- .../compilation/test_compile_global_flag.py | 36 ++++++++++--------- .../phases/test_finalize_user_compile.py | 16 ++++----- 6 files changed, 60 insertions(+), 43 deletions(-) diff --git a/docs/src/content/docs/reference/cli/compile.md b/docs/src/content/docs/reference/cli/compile.md index 7cfc734f9..54b1d3e75 100644 --- a/docs/src/content/docs/reference/cli/compile.md +++ b/docs/src/content/docs/reference/cli/compile.md @@ -98,6 +98,16 @@ use `apm install` or `apm deps update` when you want shared | `--dry-run` | Show placement decisions without writing files. | | `-v, --verbose` | Show source attribution and optimizer analysis. | +### Global compilation + +| 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 active target (e.g. `~/.claude/CLAUDE.md`, `~/.codex/AGENTS.md`). Not valid with `--watch` or `--root`. Exits non-zero if `~/.apm/apm_modules` does not exist. | + +`apm install --global` automatically runs this step after installing packages. +Use `apm compile --global` to re-run it manually after adding or removing global packages. +Hand-authored files (files that do not carry the APM-generated marker) are never overwritten. + ## Examples Compile for whatever the project is set up for: diff --git a/src/apm_cli/commands/compile/cli.py b/src/apm_cli/commands/compile/cli.py index c0e752776..5a29a10b5 100644 --- a/src/apm_cli/commands/compile/cli.py +++ b/src/apm_cli/commands/compile/cli.py @@ -327,11 +327,11 @@ def _handle_global_flag(dry_run: bool) -> int: """ from ...compilation import compile_user_root_contexts - from ...core.scope import InstallScope, get_source_root + from ...core.scope import InstallScope, get_apm_dir from ...integration.targets import KNOWN_TARGETS from ...utils.console import _rich_error, _rich_info, _rich_success - source_root = get_source_root(InstallScope.USER) + source_root = get_apm_dir(InstallScope.USER) apm_modules = source_root / "apm_modules" if not apm_modules.is_dir(): _rich_error( @@ -357,17 +357,17 @@ def _handle_global_flag(dry_run: bool) -> int: tname = entry["target"] path = entry.get("path") if status == "written": - _rich_success(f"[+] {tname}: wrote {path}") + _rich_success(f"{tname}: wrote {path}", symbol="check") elif status == "would-write": - _rich_info(f"[*] {tname}: would write {path} (dry-run)") + _rich_info(f"{tname}: would write {path} (dry-run)", symbol="preview") elif status == "unchanged": - _rich_info(f"[i] {tname}: unchanged {path}") + _rich_info(f"{tname}: unchanged {path}", symbol="info") elif status == "skipped-hand-authored": - _rich_info(f"[i] {tname}: skipped (hand-authored) {path}") + _rich_info(f"{tname}: skipped (hand-authored) {path}", symbol="info") elif status == "skipped-no-instructions": - _rich_info(f"[i] {tname}: skipped (no global instructions)") + _rich_info(f"{tname}: skipped (no global instructions)", symbol="info") elif status.startswith("error:"): - _rich_error(f"[x] {tname}: {status[6:]}") + _rich_error(f"{tname}: {status[6:]}", symbol="error") has_error = True return 1 if has_error else 0 @@ -550,10 +550,10 @@ def compile( if watch: _rich_error("--global cannot be combined with --watch") - return + sys.exit(2) if root: _rich_error("--global cannot be combined with --root") - return + sys.exit(2) rc = _handle_global_flag(dry_run=dry_run) if rc != 0: sys.exit(rc) diff --git a/src/apm_cli/compilation/user_root_context.py b/src/apm_cli/compilation/user_root_context.py index 769eebfbc..11d93ce54 100644 --- a/src/apm_cli/compilation/user_root_context.py +++ b/src/apm_cli/compilation/user_root_context.py @@ -23,7 +23,12 @@ import hashlib import logging +from collections.abc import Iterable from pathlib import Path +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + import logging as _logging_module # 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). @@ -35,7 +40,7 @@ } -def _resolve_deploy_root(profile) -> Path: +def _resolve_deploy_root(profile: object) -> Path: """Return the absolute deploy root for a user-scoped TargetProfile. After for_scope(user_scope=True): @@ -67,7 +72,7 @@ def _finalize_build_id(content: str) -> str: return "\n".join(lines) + "\n" -def _generate_content(instructions) -> str: +def _generate_content(instructions: list[object]) -> 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 @@ -93,12 +98,12 @@ def _generate_content(instructions) -> str: def compile_user_root_contexts( - targets, + targets: Iterable[object], source_root: Path, *, dry_run: bool = False, - logger=None, -) -> list[dict]: + logger: _logging_module.Logger | None = None, +) -> list[dict[str, object]]: """Compile user-scope root context files from global (apply_to-less) instructions. Iterates over *targets*, skipping any that: @@ -134,7 +139,7 @@ def compile_user_root_contexts( log = logger or logging.getLogger(__name__) - results: list[dict] = [] + results: list[dict[str, object]] = [] apm_modules = source_root / "apm_modules" if not apm_modules.is_dir(): diff --git a/src/apm_cli/install/phases/finalize.py b/src/apm_cli/install/phases/finalize.py index dfb1df058..93271e528 100644 --- a/src/apm_cli/install/phases/finalize.py +++ b/src/apm_cli/install/phases/finalize.py @@ -22,10 +22,10 @@ def _compile_user_root_contexts_after_install(ctx: InstallContext) -> None: """Invoke compile_user_root_contexts after a user-scope install.""" from apm_cli.compilation import compile_user_root_contexts - from apm_cli.core.scope import InstallScope, get_source_root + from apm_cli.core.scope import InstallScope, get_apm_dir from apm_cli.integration.targets import KNOWN_TARGETS - source_root = get_source_root(InstallScope.USER) + source_root = get_apm_dir(InstallScope.USER) targets = list(KNOWN_TARGETS.values()) # Pass logger=None so compile_user_root_contexts uses the stdlib logger; # ctx.logger is an InstallLogger which does not expose the stdlib debug/info API. diff --git a/tests/unit/compilation/test_compile_global_flag.py b/tests/unit/compilation/test_compile_global_flag.py index 9ee8c844b..1c27e6802 100644 --- a/tests/unit/compilation/test_compile_global_flag.py +++ b/tests/unit/compilation/test_compile_global_flag.py @@ -52,7 +52,7 @@ def test_no_apm_modules_returns_error(self, tmp_path): with ( patch( - "apm_cli.core.scope.get_source_root", + "apm_cli.core.scope.get_apm_dir", return_value=source_root, ), patch( @@ -82,7 +82,7 @@ def test_success_written_status(self, tmp_path): with ( patch( - "apm_cli.core.scope.get_source_root", + "apm_cli.core.scope.get_apm_dir", return_value=source_root, ), patch( @@ -120,7 +120,7 @@ def test_success_would_write_status(self, tmp_path): with ( patch( - "apm_cli.core.scope.get_source_root", + "apm_cli.core.scope.get_apm_dir", return_value=source_root, ), patch( @@ -154,7 +154,7 @@ def test_success_unchanged_status(self, tmp_path): with ( patch( - "apm_cli.core.scope.get_source_root", + "apm_cli.core.scope.get_apm_dir", return_value=source_root, ), patch( @@ -187,7 +187,7 @@ def test_success_skipped_no_instructions(self, tmp_path): with ( patch( - "apm_cli.core.scope.get_source_root", + "apm_cli.core.scope.get_apm_dir", return_value=source_root, ), patch( @@ -222,7 +222,7 @@ def test_success_skipped_hand_authored(self, tmp_path): with ( patch( - "apm_cli.core.scope.get_source_root", + "apm_cli.core.scope.get_apm_dir", return_value=source_root, ), patch( @@ -254,7 +254,7 @@ def test_error_status_returns_1(self, tmp_path): with ( patch( - "apm_cli.core.scope.get_source_root", + "apm_cli.core.scope.get_apm_dir", return_value=source_root, ), patch( @@ -295,7 +295,7 @@ def test_multiple_results_mixed_status(self, tmp_path): with ( patch( - "apm_cli.core.scope.get_source_root", + "apm_cli.core.scope.get_apm_dir", return_value=source_root, ), patch( @@ -332,7 +332,7 @@ def test_no_results_returns_success(self, tmp_path): with ( patch( - "apm_cli.core.scope.get_source_root", + "apm_cli.core.scope.get_apm_dir", return_value=source_root, ), patch( @@ -358,34 +358,36 @@ class TestCompileGlobalCommand: """Tests for compile command with --global flag.""" def test_global_with_watch_rejected(self): - """--global and --watch together -> error message, no sys.exit.""" + """--global and --watch together -> error message and sys.exit(2).""" from apm_cli.commands.compile.cli import compile as compile_cmd runner = CliRunner() with patch("apm_cli.utils.console._rich_error") as mock_error: # Invoke with both --global and --watch - runner.invoke(compile_cmd, ["--global", "--watch"], standalone_mode=False) + result = runner.invoke(compile_cmd, ["--global", "--watch"]) - # Should reject the combination (returns None, not sys.exit) + # Should reject the combination with non-zero exit code mock_error.assert_called() call_str = str(mock_error.call_args) assert "global" in call_str.lower() and "watch" in call_str.lower() + assert result.exit_code == 2 def test_global_with_root_rejected(self): - """--global and --root together -> error message, no sys.exit.""" + """--global and --root together -> error message and sys.exit(2).""" from apm_cli.commands.compile.cli import compile as compile_cmd runner = CliRunner() with patch("apm_cli.utils.console._rich_error") as mock_error: # Invoke with both --global and --root - runner.invoke(compile_cmd, ["--global", "--root", "/tmp"], standalone_mode=False) + result = runner.invoke(compile_cmd, ["--global", "--root", "/nonexistent"]) - # Should reject the combination (returns None, not sys.exit) + # Should reject the combination with non-zero exit code mock_error.assert_called() call_str = str(mock_error.call_args) assert "global" in call_str.lower() and "root" in call_str.lower() + assert result.exit_code == 2 def test_global_success_no_exit(self, tmp_path): """--global with successful _handle_global_flag -> returns normally.""" @@ -397,7 +399,7 @@ def test_global_success_no_exit(self, tmp_path): source_root.mkdir() with ( - patch("apm_cli.core.scope.get_source_root", return_value=source_root), + patch("apm_cli.core.scope.get_apm_dir", return_value=source_root), patch( "apm_cli.commands.compile.cli._handle_global_flag", return_value=0, @@ -419,7 +421,7 @@ def test_global_failure_exits_1(self, tmp_path): source_root.mkdir() with ( - patch("apm_cli.core.scope.get_source_root", return_value=source_root), + patch("apm_cli.core.scope.get_apm_dir", return_value=source_root), patch( "apm_cli.commands.compile.cli._handle_global_flag", return_value=1, diff --git a/tests/unit/install/phases/test_finalize_user_compile.py b/tests/unit/install/phases/test_finalize_user_compile.py index c08876caf..8b0d90f91 100644 --- a/tests/unit/install/phases/test_finalize_user_compile.py +++ b/tests/unit/install/phases/test_finalize_user_compile.py @@ -51,14 +51,14 @@ def test_calls_compile_user_root_contexts(self): _compile_user_root_contexts_after_install, ) - source_root = Path.home() + source_root = Path.home() / ".apm" ctx = _make_install_context(scope=InstallScope.USER) mock_compile = MagicMock(return_value=[]) with ( patch( - "apm_cli.core.scope.get_source_root", + "apm_cli.core.scope.get_apm_dir", return_value=source_root, ), patch( @@ -83,7 +83,7 @@ def test_logs_when_files_written(self): _compile_user_root_contexts_after_install, ) - source_root = Path.home() + source_root = Path.home() / ".apm" mock_logger = MagicMock() ctx = _make_install_context(scope=InstallScope.USER, logger=mock_logger) @@ -95,7 +95,7 @@ def test_logs_when_files_written(self): with ( patch( - "apm_cli.core.scope.get_source_root", + "apm_cli.core.scope.get_apm_dir", return_value=source_root, ), patch( @@ -117,7 +117,7 @@ def test_no_logging_when_no_files_written(self): _compile_user_root_contexts_after_install, ) - source_root = Path.home() + source_root = Path.home() / ".apm" mock_logger = MagicMock() ctx = _make_install_context(scope=InstallScope.USER, logger=mock_logger) @@ -128,7 +128,7 @@ def test_no_logging_when_no_files_written(self): with ( patch( - "apm_cli.core.scope.get_source_root", + "apm_cli.core.scope.get_apm_dir", return_value=source_root, ), patch( @@ -148,7 +148,7 @@ def test_no_logging_when_logger_none(self): _compile_user_root_contexts_after_install, ) - source_root = Path.home() + source_root = Path.home() / ".apm" ctx = _make_install_context(scope=InstallScope.USER, logger=None) # Files written, but logger is None @@ -158,7 +158,7 @@ def test_no_logging_when_logger_none(self): with ( patch( - "apm_cli.core.scope.get_source_root", + "apm_cli.core.scope.get_apm_dir", return_value=source_root, ), patch( From 2b04778daad6e2d15982db476b7eaa23f4265ea7 Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Thu, 11 Jun 2026 19:09:04 +0200 Subject: [PATCH 08/10] Address global compile panel follow-ups Tighten the new user-scope compile surface by folding the review panel follow-ups: use Click usage errors for invalid flag pairs, expose a typed result object, document install -g auto-compilation, improve the producer guide lede, and add an integration regression test for the argv-to-filesystem global compile path. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/src/content/docs/producer/compile.md | 8 +-- .../src/content/docs/reference/cli/install.md | 1 + src/apm_cli/commands/compile/cli.py | 16 ++--- src/apm_cli/compilation/__init__.py | 3 +- src/apm_cli/compilation/user_root_context.py | 62 +++++++++++++------ tests/integration/test_compile_global.py | 34 ++++++++++ .../compilation/test_compile_global_flag.py | 30 ++++----- 7 files changed, 105 insertions(+), 49 deletions(-) create mode 100644 tests/integration/test_compile_global.py diff --git a/docs/src/content/docs/producer/compile.md b/docs/src/content/docs/producer/compile.md index 9dd93cf67..9f291a413 100644 --- a/docs/src/content/docs/producer/compile.md +++ b/docs/src/content/docs/producer/compile.md @@ -220,10 +220,10 @@ you can omit `start_marker` and `end_marker` if you use those verbatim. ## Global compilation (-g) -By default, `apm compile` reads instructions from your workspace and -writes root context files to `.github/`, `.claude/`, etc. For distributing -instructions to all AI tools on your user's machine (not scoped to a project), -use the `--global` or `-g` flag: +Install a package once globally and every AI tool on your machine can pick up +its instructions without per-project setup. By default, `apm compile` reads +instructions from your workspace and writes root context files to `.github/`, +`.claude/`, etc. For user-scope instructions, use the `--global` or `-g` flag: ```bash apm compile --global diff --git a/docs/src/content/docs/reference/cli/install.md b/docs/src/content/docs/reference/cli/install.md index 0432f4de7..8ebecee6e 100644 --- a/docs/src/content/docs/reference/cli/install.md +++ b/docs/src/content/docs/reference/cli/install.md @@ -103,6 +103,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.** After `apm install -g`, APM compiles user-scope root context files such as `~/.claude/CLAUDE.md`, `~/.codex/AGENTS.md`, and `~/.gemini/GEMINI.md` from global instructions. Re-run with [`apm compile --global`](../compile/#global-compilation). - **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/src/apm_cli/commands/compile/cli.py b/src/apm_cli/commands/compile/cli.py index 7bf8a562a..78d932a9c 100644 --- a/src/apm_cli/commands/compile/cli.py +++ b/src/apm_cli/commands/compile/cli.py @@ -352,7 +352,10 @@ def _handle_global_flag(dry_run: bool) -> int: ) if not results: - _rich_info("No user-scope targets produced output (no global instructions found).") + _rich_info( + "No user-scope targets produced output -- run 'apm install -g ' " + "to add global instructions." + ) return 0 has_error = False @@ -931,7 +934,8 @@ def _coerce_provenance_targets(value): default=False, help=( "Compile user-scope root context files (~/.claude/CLAUDE.md, etc.) " - "from ~/.apm/apm_modules. Cannot be combined with --watch or --root." + "from ~/.apm/apm_modules. Cannot be combined with --watch or --root; " + "use with --dry-run to preview changes." ), ) @click.pass_context @@ -994,14 +998,10 @@ def compile( # noqa: PLR0913 -- Click handler # --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 ...utils.console import _rich_error - if watch: - _rich_error("--global cannot be combined with --watch") - sys.exit(2) + raise click.UsageError("--global is not valid with --watch") if root: - _rich_error("--global cannot be combined with --root") - sys.exit(2) + raise click.UsageError("--global is not valid with --root") rc = _handle_global_flag(dry_run=dry_run) if rc != 0: sys.exit(rc) diff --git a/src/apm_cli/compilation/__init__.py b/src/apm_cli/compilation/__init__.py index f3c212c38..9f1ef00a1 100644 --- a/src/apm_cli/compilation/__init__.py +++ b/src/apm_cli/compilation/__init__.py @@ -8,7 +8,7 @@ find_chatmode_by_name, render_instructions_block, ) -from .user_root_context import compile_user_root_contexts +from .user_root_context import UserRootCompileResult, compile_user_root_contexts __all__ = [ # noqa: RUF022 # Main compilation interface @@ -18,6 +18,7 @@ "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 index 11d93ce54..07fb8b704 100644 --- a/src/apm_cli/compilation/user_root_context.py +++ b/src/apm_cli/compilation/user_root_context.py @@ -24,12 +24,16 @@ 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] = { @@ -40,7 +44,33 @@ } -def _resolve_deploy_root(profile: object) -> Path: +@dataclass(frozen=True) +class UserRootCompileResult: + """Result for one user-scope root context compilation target.""" + + target: str + path: Path | None + status: str + + def __getitem__(self, key: str) -> object: + """Provide dict-style access for existing result consumers.""" + if key == "target": + return self.target + if key == "path": + return self.path + if key == "status": + return self.status + raise KeyError(key) + + def get(self, key: str, default: object = None) -> object: + """Provide dict-style optional access for existing result consumers.""" + try: + return self[key] + except KeyError: + return default + + +def _resolve_deploy_root(profile: TargetProfile) -> Path: """Return the absolute deploy root for a user-scoped TargetProfile. After for_scope(user_scope=True): @@ -72,7 +102,7 @@ def _finalize_build_id(content: str) -> str: return "\n".join(lines) + "\n" -def _generate_content(instructions: list[object]) -> str: +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 @@ -98,12 +128,12 @@ def _generate_content(instructions: list[object]) -> str: def compile_user_root_contexts( - targets: Iterable[object], + targets: Iterable[TargetProfile], source_root: Path, *, dry_run: bool = False, logger: _logging_module.Logger | None = None, -) -> list[dict[str, object]]: +) -> list[UserRootCompileResult]: """Compile user-scope root context files from global (apply_to-less) instructions. Iterates over *targets*, skipping any that: @@ -123,8 +153,8 @@ def compile_user_root_contexts( logger: Optional logger. Falls back to ``logging.getLogger(__name__)``. Returns: - A list of dicts, one per target that was evaluated, each containing: - ``{"target": , "path": , "status": }``. + 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 @@ -139,7 +169,7 @@ def compile_user_root_contexts( log = logger or logging.getLogger(__name__) - results: list[dict[str, object]] = [] + results: list[UserRootCompileResult] = [] apm_modules = source_root / "apm_modules" if not apm_modules.is_dir(): @@ -178,9 +208,7 @@ def compile_user_root_contexts( apm_modules, scoped.name, ) - results.append( - {"target": scoped.name, "path": None, "status": "skipped-no-instructions"} - ) + results.append(UserRootCompileResult(scoped.name, None, "skipped-no-instructions")) continue deploy_root = _resolve_deploy_root(scoped) @@ -195,9 +223,7 @@ def compile_user_root_contexts( 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( - {"target": scoped.name, "path": output_path, "status": f"error:{exc}"} - ) + results.append(UserRootCompileResult(scoped.name, output_path, f"error:{exc}")) continue if _COPILOT_ROOT_GENERATED_MARKER not in existing: @@ -206,27 +232,27 @@ def compile_user_root_contexts( output_path, ) results.append( - {"target": scoped.name, "path": output_path, "status": "skipped-hand-authored"} + UserRootCompileResult(scoped.name, output_path, "skipped-hand-authored") ) continue if existing == content: log.debug("user_root_context: %s is unchanged", output_path) - results.append({"target": scoped.name, "path": output_path, "status": "unchanged"}) + 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({"target": scoped.name, "path": output_path, "status": "would-write"}) + results.append(UserRootCompileResult(scoped.name, output_path, "would-write")) continue try: output_path.parent.mkdir(parents=True, exist_ok=True) output_path.write_text(content, encoding="utf-8") log.debug("user_root_context: wrote %s", output_path) - results.append({"target": scoped.name, "path": output_path, "status": "written"}) + results.append(UserRootCompileResult(scoped.name, output_path, "written")) except OSError as exc: log.warning("user_root_context: failed to write %s: %s", output_path, exc) - results.append({"target": scoped.name, "path": output_path, "status": f"error:{exc}"}) + results.append(UserRootCompileResult(scoped.name, output_path, f"error:{exc}")) return results diff --git a/tests/integration/test_compile_global.py b/tests/integration/test_compile_global.py new file mode 100644 index 000000000..53f3b56ab --- /dev/null +++ b/tests/integration/test_compile_global.py @@ -0,0 +1,34 @@ +"""Integration coverage for user-scope global compilation.""" + +from __future__ import annotations + +from click.testing import CliRunner + + +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 "