diff --git a/docs/src/content/docs/consumer/install-packages.md b/docs/src/content/docs/consumer/install-packages.md index ab63c7994..9034f7c41 100644 --- a/docs/src/content/docs/consumer/install-packages.md +++ b/docs/src/content/docs/consumer/install-packages.md @@ -159,10 +159,13 @@ apm install --only apm # skip MCP server integration this run apm install --frozen # CI: lockfile-only; fail on drift apm install --refresh # bypass the cache; re-fetch everything apm install --dev # treat positional args as devDependencies -apm install -g # install to user scope (~/.apm/) +apm install -g # install globally and compile user root files apm install -v # verbose: show resolution and integration ``` +Global installs compile user-scope root files such as `~/.claude/CLAUDE.md`; +re-run that step with [`apm compile --global`](../../reference/cli/compile/#global-compilation). + For the full flag reference, run `apm install --help` or see [CLI commands](../../reference/cli/install/). diff --git a/docs/src/content/docs/producer/compile.md b/docs/src/content/docs/producer/compile.md index 1c076f92f..9f291a413 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) + +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 +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/docs/src/content/docs/reference/cli/compile.md b/docs/src/content/docs/reference/cli/compile.md index 7cfc734f9..fceac3b8b 100644 --- a/docs/src/content/docs/reference/cli/compile.md +++ b/docs/src/content/docs/reference/cli/compile.md @@ -98,6 +98,20 @@ 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. + +```bash +apm compile -g +``` + ## Examples Compile for whatever the project is set up for: diff --git a/docs/src/content/docs/reference/cli/install.md b/docs/src/content/docs/reference/cli/install.md index 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/packages/apm-guide/.apm/skills/apm-usage/commands.md b/packages/apm-guide/.apm/skills/apm-usage/commands.md index 6a3a75e6b..debd43dd8 100644 --- a/packages/apm-guide/.apm/skills/apm-usage/commands.md +++ b/packages/apm-guide/.apm/skills/apm-usage/commands.md @@ -52,7 +52,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. diff --git a/src/apm_cli/commands/compile/cli.py b/src/apm_cli/commands/compile/cli.py index 264dfaea4..78d932a9c 100644 --- a/src/apm_cli/commands/compile/cli.py +++ b/src/apm_cli/commands/compile/cli.py @@ -324,6 +324,62 @@ 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_apm_dir + from ...integration.targets import KNOWN_TARGETS + from ...utils.console import _rich_error, _rich_info, _rich_success + + source_root = get_apm_dir(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 -- run 'apm install -g ' " + "to add global instructions." + ) + 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}", symbol="check") + elif status == "would-write": + _rich_info(f"{tname}: would write {path} (dry-run)", symbol="preview") + elif status == "unchanged": + _rich_info(f"{tname}: unchanged {path}", symbol="info") + elif status == "skipped-hand-authored": + _rich_info(f"{tname}: skipped (hand-authored) {path}", symbol="info") + elif status == "skipped-no-instructions": + _rich_info(f"{tname}: skipped (no global instructions)", symbol="info") + elif status.startswith("error:"): + _rich_error(f"{tname}: {status[6:]}", symbol="error") + has_error = True + + return 1 if has_error else 0 + + def _validate_project(logger: CommandLogger, dry_run: bool, source_root: Path) -> None: """Check APM project exists and has content. @@ -870,6 +926,18 @@ def _coerce_provenance_targets(value): "for scratch-dir verification. Cannot be combined with --watch." ), ) +@click.option( + "--global", + "-g", + "global_", + is_flag=True, + default=False, + help=( + "Compile user-scope root context files (~/.claude/CLAUDE.md, etc.) " + "from ~/.apm/apm_modules. Cannot be combined with --watch or --root; " + "use with --dry-run to preview changes." + ), +) @click.pass_context def compile( # noqa: PLR0913 -- Click handler ctx, @@ -889,6 +957,7 @@ def compile( # noqa: PLR0913 -- Click handler compile_all, no_dedup, root, + global_, ): """Compile APM context into distributed AGENTS.md files. @@ -926,6 +995,18 @@ def compile( # noqa: PLR0913 -- Click handler # consumers running with -W default, which we have none of. logger.warning("'--target all' is deprecated; use '--all' instead.") + # --global: compile user-scope root context files from ~/.apm/apm_modules. + # Must be checked before --watch / --root guards so we return early. + if global_: + if watch: + raise click.UsageError("--global is not valid with --watch") + if root: + raise click.UsageError("--global is not valid with --root") + 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 diff --git a/src/apm_cli/compilation/__init__.py b/src/apm_cli/compilation/__init__.py index 41254e1af..9f1ef00a1 100644 --- a/src/apm_cli/compilation/__init__.py +++ b/src/apm_cli/compilation/__init__.py @@ -8,6 +8,7 @@ find_chatmode_by_name, render_instructions_block, ) +from .user_root_context import UserRootCompileResult, compile_user_root_contexts __all__ = [ # noqa: RUF022 # Main compilation interface @@ -15,6 +16,9 @@ "compile_agents_md", "CompilationConfig", "CompilationResult", + # User-scope root context compilation + "compile_user_root_contexts", + "UserRootCompileResult", # Template building "build_conditional_sections", "render_instructions_block", diff --git a/src/apm_cli/compilation/user_root_context.py b/src/apm_cli/compilation/user_root_context.py new file mode 100644 index 000000000..2218c4477 --- /dev/null +++ b/src/apm_cli/compilation/user_root_context.py @@ -0,0 +1,266 @@ +"""User-scope root-context compilation engine. + +Reads global (apply_to-less) instructions from ~/.apm/apm_modules and +writes each active target's user-scope root context file: + claude -> ~/.claude/CLAUDE.md (or $CLAUDE_CONFIG_DIR/CLAUDE.md) + codex -> ~/.codex/AGENTS.md + gemini -> ~/.gemini/GEMINI.md + copilot -> ~/.copilot/AGENTS.md + vscode -> ~/.copilot/AGENTS.md (same deploy root at user scope) + cursor -> ~/.cursor/AGENTS.md + opencode -> ~/.config/opencode/AGENTS.md + +Files are ONLY written when: +1. The target supports user scope (for_scope returns non-None) +2. The target has a recognised compile_family with a root-file mapping +3. Global instructions exist in the module tree +4. The existing file either does not exist OR carries the generated marker + +Hand-authored files (no marker) are left untouched. +""" + +from __future__ import annotations + +import hashlib +import logging +from collections.abc import Iterable +from dataclasses import dataclass +from pathlib import Path +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + import logging as _logging_module + + from ..integration.targets import TargetProfile + from ..primitives.models import Instruction + +# Root filename by compile_family. Targets whose compile_family is not in +# this map do not produce a root file (e.g. family=None for agent-skills). +_ROOT_FILENAME: dict[str, str] = { + "claude": "CLAUDE.md", + "agents": "AGENTS.md", + "vscode": "AGENTS.md", + "gemini": "GEMINI.md", +} + + +@dataclass(frozen=True) +class UserRootCompileResult: + """Result for one user-scope root context compilation target.""" + + target: str + path: Path | None + status: str + + 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): + * profile.resolved_deploy_root is set -> use it directly + * otherwise -> Path.home() / profile.root_dir + """ + if profile.resolved_deploy_root is not None: + return profile.resolved_deploy_root + return Path.home() / profile.root_dir + + +def _finalize_build_id(content: str) -> str: + """Replace the BUILD_ID_PLACEHOLDER sentinel with a 12-char content hash. + + The hash is computed over all lines EXCEPT the placeholder line so the + result is deterministic (not self-referential). + """ + from .constants import BUILD_ID_PLACEHOLDER + + lines = content.splitlines() + try: + idx = lines.index(BUILD_ID_PLACEHOLDER) + except ValueError: + return content + + hash_input_lines = [line for i, line in enumerate(lines) if i != idx] + build_id = hashlib.sha256("\n".join(hash_input_lines).encode("utf-8")).hexdigest()[:12] + lines[idx] = f"" + return "\n".join(lines) + "\n" + + +def _generate_content(instructions: list[Instruction]) -> str: + """Generate the root context file content from a list of global instructions. + + Embeds the APM-generated marker and a deterministic Build ID so that + subsequent runs can detect APM-owned files and apply overwrite protection. + + ASCII-only: no Unicode in the generated skeleton; instruction *content* + is passed through as-is (callers are responsible for encoding checks). + """ + from .agents_compiler import _COPILOT_ROOT_GENERATED_MARKER + from .constants import BUILD_ID_PLACEHOLDER + + sections: list[str] = [ + _COPILOT_ROOT_GENERATED_MARKER, + BUILD_ID_PLACEHOLDER, + "", + ] + + for instruction in instructions: + sections.append(instruction.content.strip()) + sections.append("") + + return _finalize_build_id("\n".join(sections)) + + +def compile_user_root_contexts( + targets: Iterable[TargetProfile], + source_root: Path, + *, + dry_run: bool = False, + logger: _logging_module.Logger | None = None, +) -> list[UserRootCompileResult]: + """Compile user-scope root context files from global (apply_to-less) instructions. + + Iterates over *targets*, skipping any that: + * do not support user scope (for_scope returns None) + * have no recognised compile_family root-file mapping + + For each remaining target the function discovers global instructions from + ``source_root / "apm_modules"``, generates content, and writes the root + file -- unless the existing file is hand-authored (no marker). + + Args: + targets: Iterable of TargetProfile instances to process. + source_root: Root of the user's APM installation tree, + e.g. ``Path.home() / ".apm"``. + dry_run: When True, no files are written or directories created. + The returned status values reflect what *would* happen. + logger: Optional logger. Falls back to ``logging.getLogger(__name__)``. + + Returns: + A list of UserRootCompileResult entries, one per target that was + evaluated. Each entry contains ``target``, ``path``, and ``status``. + + Status values: + * ``"written"`` -- file was created or updated + * ``"unchanged"`` -- file already matches generated content + * ``"would-write"`` -- dry_run; file would have been written + * ``"skipped-no-instructions"`` -- no global instructions found + * ``"skipped-hand-authored"`` -- existing file has no APM marker + * ``"error:"`` -- OS error during read or write + """ + from ..primitives.discovery import discover_primitives + from ..utils.path_security import PathTraversalError, ensure_path_within + from .agents_compiler import _COPILOT_ROOT_GENERATED_MARKER + + log = logger or logging.getLogger(__name__) + + results: list[UserRootCompileResult] = [] + + apm_modules = source_root / "apm_modules" + if not apm_modules.is_dir(): + log.debug( + "user_root_context: apm_modules dir not found at %s -- no root files written", + apm_modules, + ) + return results + + 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(UserRootCompileResult(scoped.name, None, "skipped-no-instructions")) + continue + + deploy_root = _resolve_deploy_root(scoped) + root_filename = _ROOT_FILENAME[family] + try: + output_path = ensure_path_within(deploy_root / root_filename, deploy_root) + except PathTraversalError as exc: + log.warning("user_root_context: unsafe output path for %s: %s", scoped.name, exc) + results.append( + UserRootCompileResult(scoped.name, deploy_root / root_filename, f"error:{exc}") + ) + continue + + content = _generate_content(global_instructions) + + # -- overwrite protection -- + if output_path.exists(): + try: + existing = output_path.read_text(encoding="utf-8") + except OSError as exc: + log.warning("user_root_context: cannot read %s: %s", output_path, exc) + results.append(UserRootCompileResult(scoped.name, output_path, f"error:{exc}")) + continue + + if _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( + UserRootCompileResult(scoped.name, output_path, "skipped-hand-authored") + ) + continue + + if existing == content: + log.debug("user_root_context: %s is unchanged", output_path) + results.append(UserRootCompileResult(scoped.name, output_path, "unchanged")) + continue + + if dry_run: + log.debug("user_root_context: [dry-run] would write %s", output_path) + results.append(UserRootCompileResult(scoped.name, output_path, "would-write")) + continue + + try: + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text(content, encoding="utf-8") + log.debug("user_root_context: wrote %s", output_path) + results.append(UserRootCompileResult(scoped.name, output_path, "written")) + except OSError as exc: + log.warning("user_root_context: failed to write %s: %s", output_path, exc) + results.append(UserRootCompileResult(scoped.name, output_path, f"error:{exc}")) + + return results diff --git a/src/apm_cli/install/phases/finalize.py b/src/apm_cli/install/phases/finalize.py index d2707a151..6fd23c647 100644 --- a/src/apm_cli/install/phases/finalize.py +++ b/src/apm_cli/install/phases/finalize.py @@ -19,6 +19,23 @@ 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_apm_dir + from apm_cli.integration.targets import KNOWN_TARGETS + + 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. + 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: + target_names = ", ".join(str(r["target"]) for r in written) + ctx.logger.verbose_detail(f"Compiled user-scope root contexts: {target_names}") + + 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 +100,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, 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 "