diff --git a/src/apm_cli/commands/compile/cli.py b/src/apm_cli/commands/compile/cli.py index e6fb5cace..ac1cca0f0 100644 --- a/src/apm_cli/commands/compile/cli.py +++ b/src/apm_cli/commands/compile/cli.py @@ -258,6 +258,14 @@ def _resolve_compile_target(target): is_flag=True, help="Remove orphaned AGENTS.md files that are no longer generated", ) +@click.option( + "--root", "root", type=click.Path(file_okay=False, resolve_path=True), + default=None, metavar="DIR", + help=("Write AGENTS.md / CLAUDE.md outputs under DIR instead of $PWD; " + "sources (apm.yml, .apm/, project tree for placement scoring) " + "continue resolving from $PWD. Pairs with `apm install --root` " + "for scratch-dir verification."), +) @click.pass_context def compile( ctx, @@ -273,6 +281,7 @@ def compile( verbose, local_only, clean, + root, ): """Compile APM context into distributed AGENTS.md files. @@ -294,373 +303,387 @@ def compile( """ logger = CommandLogger("compile", verbose=verbose, dry_run=dry_run) - try: - # Check if this is an APM project first - from pathlib import Path - - if not Path(APM_YML_FILENAME).exists(): - logger.error("Not an APM project - no apm.yml found") - logger.progress(" To initialize an APM project, run:") - logger.progress(" apm init") - sys.exit(1) - - # Check if there are any instruction files to compile - from ...compilation.constitution import find_constitution + # --root: see apm_cli.install.root_redirect.compile_root_redirect. + # Bracket the handler so writes land under *root* while sources keep + # resolving from the captured original $PWD via the source-root + # override. + from ...install.root_redirect import compile_root_redirect + with compile_root_redirect(root): + try: + # Source root: where apm.yml, .apm/, and the project tree are + # read from. Equals $PWD unless --root redirects writes. + from ...core.scope import InstallScope, get_source_root + from pathlib import Path + source_root = get_source_root(InstallScope.PROJECT) + + # Check if this is an APM project first + if not (source_root / APM_YML_FILENAME).exists(): + logger.error("Not an APM project - no apm.yml found") + logger.progress(" To initialize an APM project, run:") + logger.progress(" apm init") + sys.exit(1) - apm_modules_exists = Path(APM_MODULES_DIR).exists() - constitution_exists = find_constitution(Path(".")).exists() + # Check if there are any instruction files to compile + from ...compilation.constitution import find_constitution - # Check if .apm directory has actual content - apm_dir = Path(APM_DIR) - local_apm_has_content = apm_dir.exists() and ( - any(apm_dir.rglob("*.instructions.md")) or any(apm_dir.rglob("*.chatmode.md")) - ) + apm_modules_exists = (source_root / APM_MODULES_DIR).exists() + constitution_exists = find_constitution(source_root).exists() - # If no primitive sources exist, check deeper to provide better feedback - if not apm_modules_exists and not local_apm_has_content and not constitution_exists: - # Check if .apm directories exist but are empty - has_empty_apm = ( - apm_dir.exists() - and not any(apm_dir.rglob("*.instructions.md")) - and not any(apm_dir.rglob("*.chatmode.md")) + # Check if .apm directory has actual content + apm_dir = source_root / APM_DIR + local_apm_has_content = apm_dir.exists() and ( + any(apm_dir.rglob("*.instructions.md")) or any(apm_dir.rglob("*.chatmode.md")) ) - if has_empty_apm: - logger.error("No instruction files found in .apm/ directory") - logger.progress(" To add instructions, create files like:") - logger.progress(" .apm/instructions/coding-standards.instructions.md") - logger.progress(" .apm/chatmodes/backend-engineer.chatmode.md") - else: - logger.error("No APM content found to compile") - logger.progress(" To get started:") - logger.progress(" 1. Install APM dependencies: apm install /") - logger.progress(" 2. Or create local instructions: mkdir -p .apm/instructions") - logger.progress(" 3. Then create .instructions.md or .chatmode.md files") - - if not dry_run: # Don't exit on dry-run to allow testing - sys.exit(1) - - # Validation-only mode - if validate: - logger.start("Validating APM context...", symbol="gear") - compiler = AgentsCompiler(".") - try: - primitives = discover_primitives(".") - except Exception as e: - logger.error(f"Failed to discover primitives: {e}") - logger.progress(f" Error details: {type(e).__name__}") - sys.exit(1) - validation_errors = compiler.validate_primitives(primitives) - if validation_errors: - _display_validation_errors(validation_errors) - logger.error(f"Validation failed with {len(validation_errors)} errors") - sys.exit(1) - logger.success("All primitives validated successfully!") - logger.progress(f"Validated {primitives.count()} primitives:") - logger.progress(f" * {len(primitives.chatmodes)} chatmodes") - logger.progress(f" * {len(primitives.instructions)} instructions") - logger.progress(f" * {len(primitives.contexts)} contexts") - # Show MCP dependency validation count - try: - from ...models.apm_package import APMPackage - - apm_pkg = APMPackage.from_apm_yml(Path(APM_YML_FILENAME)) - mcp_count = len(apm_pkg.get_mcp_dependencies()) - if mcp_count > 0: - logger.progress(f" * {mcp_count} MCP dependencies") - except Exception: - pass - return + # If no primitive sources exist, check deeper to provide better feedback + if not apm_modules_exists and not local_apm_has_content and not constitution_exists: + # Check if .apm directories exist but are empty + has_empty_apm = ( + apm_dir.exists() + and not any(apm_dir.rglob("*.instructions.md")) + and not any(apm_dir.rglob("*.chatmode.md")) + ) - # Watch mode - if watch: - _watch_mode(output, chatmode, no_links, dry_run, verbose=verbose) - return + if has_empty_apm: + logger.error("No instruction files found in .apm/ directory") + logger.progress(" To add instructions, create files like:") + logger.progress(" .apm/instructions/coding-standards.instructions.md") + logger.progress(" .apm/chatmodes/backend-engineer.chatmode.md") + else: + logger.error("No APM content found to compile") + logger.progress(" To get started:") + logger.progress(" 1. Install APM dependencies: apm install /") + logger.progress(" 2. Or create local instructions: mkdir -p .apm/instructions") + logger.progress(" 3. Then create .instructions.md or .chatmode.md files") + + if not dry_run: # Don't exit on dry-run to allow testing + sys.exit(1) + + # Validation-only mode + if validate: + logger.start("Validating APM context...", symbol="gear") + compiler = AgentsCompiler(".", source_dir=str(source_root)) + try: + primitives = discover_primitives(str(source_root)) + except Exception as e: + logger.error(f"Failed to discover primitives: {e}") + logger.progress(f" Error details: {type(e).__name__}") + sys.exit(1) + validation_errors = compiler.validate_primitives(primitives) + if validation_errors: + _display_validation_errors(validation_errors) + logger.error(f"Validation failed with {len(validation_errors)} errors") + sys.exit(1) + logger.success("All primitives validated successfully!") + logger.progress(f"Validated {primitives.count()} primitives:") + logger.progress(f" * {len(primitives.chatmodes)} chatmodes") + logger.progress(f" * {len(primitives.instructions)} instructions") + logger.progress(f" * {len(primitives.contexts)} contexts") + # Show MCP dependency validation count + try: + from ...models.apm_package import APMPackage + apm_pkg = APMPackage.from_apm_yml(source_root / APM_YML_FILENAME) + mcp_count = len(apm_pkg.get_mcp_dependencies()) + if mcp_count > 0: + logger.progress(f" * {mcp_count} MCP dependencies") + except Exception: + pass + return - logger.start("Starting context compilation...", symbol="cogs") + # Watch mode + if watch: + _watch_mode(output, chatmode, no_links, dry_run, verbose=verbose) + return - # Auto-detect target if not explicitly provided - from ...core.target_detection import ( - REASON_NO_TARGET_FOLDER, - detect_target, - get_target_description, - ) + logger.start("Starting context compilation...", symbol="cogs") - # Get config target from apm.yml if available. When the file is - # absent we proceed with auto-detection; when it is present but - # malformed we let the parse error surface so users see exactly - # what is wrong (e.g. ``target: opencode,bogus`` -> a ValueError - # naming the bad token), rather than silently falling through to - # auto-detect. See #820. - from ...models.apm_package import APMPackage - - config_target = None - apm_yml_path = Path(APM_YML_FILENAME) - if apm_yml_path.exists(): - apm_pkg = APMPackage.from_apm_yml(apm_yml_path) - config_target = apm_pkg.target - - # Resolve list targets to compiler-understood value - compile_target = _resolve_compile_target(target) - # Also handle config_target being a list (from apm.yml target: [claude, copilot]) - compile_config_target = _resolve_compile_target(config_target) - - # A frozenset means multiple compiler families were explicitly - # requested -- bypass detect_target() since it only handles strings. - if isinstance(compile_target, frozenset): - effective_target = compile_target - detection_reason = "explicit --target flag" - elif isinstance(compile_config_target, frozenset) and compile_target is None: - effective_target = compile_config_target - detection_reason = "apm.yml target" - else: - # Pass config_target only when it's a string -- detect_target() is - # typed for Optional[str], and a frozenset config_target is already - # handled by the branch above. - detected_target, detection_reason = detect_target( - project_root=Path("."), - explicit_target=compile_target, - config_target=compile_config_target - if isinstance(compile_config_target, str) - else None, + # Auto-detect target if not explicitly provided + from ...core.target_detection import ( + REASON_NO_TARGET_FOLDER, + detect_target, + get_target_description, ) - # Map 'minimal' to 'vscode' for the compiler (AGENTS.md only, no folder integration) - effective_target = detected_target if detected_target != "minimal" else "vscode" - - # Build config with distributed compilation flags (Task 7) - config = CompilationConfig.from_apm_yml( - output_path=output if output != AGENTS_MD_FILENAME else None, - chatmode=chatmode, - resolve_links=not no_links if no_links else None, - dry_run=dry_run, - single_agents=single_agents, - trace=verbose, - local_only=local_only, - debug=verbose, - clean_orphaned=clean, - target=effective_target, - ) - config.with_constitution = with_constitution - - # Handle distributed vs single-file compilation - if config.strategy == "distributed" and not single_agents: - # Show target-aware message with detection reason. Use - # get_target_description() so any future target added to - # target_detection shows up here automatically. - if isinstance(effective_target, frozenset): - # Multi-target compile (from CLI `--target a,b` OR apm.yml - # `target: [a, b]`): show what the compiler will produce. - if isinstance(target, list): - _target_label = f"--target {','.join(target)}" - elif isinstance(config_target, list): - _target_label = f"apm.yml target: [{', '.join(config_target)}]" - else: - _target_label = "multi-target" - from ...core.target_detection import ( - should_compile_agents_md, - should_compile_claude_md, - should_compile_gemini_md, - ) - _parts = [] - if should_compile_agents_md(effective_target): - _parts.append("AGENTS.md") - if should_compile_claude_md(effective_target): - _parts.append("CLAUDE.md") - if should_compile_gemini_md(effective_target): - _parts.append("GEMINI.md") - logger.progress(f"Compiling for {' + '.join(_parts)} ({_target_label})") - elif ( - isinstance(effective_target, str) - and effective_target == "vscode" - and detection_reason == REASON_NO_TARGET_FOLDER - ): - logger.progress(f"Compiling for AGENTS.md only ({detection_reason})") - logger.progress( - " Create .github/, .claude/, .codex/, .opencode/ or .cursor/ folder for full integration", - symbol="light_bulb", - ) + # Get config target from apm.yml if available. When the file is + # absent we proceed with auto-detection; when it is present but + # malformed we let the parse error surface so users see exactly + # what is wrong (e.g. ``target: opencode,bogus`` -> a ValueError + # naming the bad token), rather than silently falling through to + # auto-detect. See #820. + from ...models.apm_package import APMPackage + + config_target = None + apm_yml_path = source_root / APM_YML_FILENAME + if apm_yml_path.exists(): + apm_pkg = APMPackage.from_apm_yml(apm_yml_path) + config_target = apm_pkg.target + + # Resolve list targets to compiler-understood value + compile_target = _resolve_compile_target(target) + # Also handle config_target being a list (from apm.yml target: [claude, copilot]) + compile_config_target = _resolve_compile_target(config_target) + + # A frozenset means multiple compiler families were explicitly + # requested -- bypass detect_target() since it only handles strings. + if isinstance(compile_target, frozenset): + effective_target = compile_target + detection_reason = "explicit --target flag" + elif isinstance(compile_config_target, frozenset) and compile_target is None: + effective_target = compile_config_target + detection_reason = "apm.yml target" else: - description = get_target_description(effective_target) - logger.progress(f"Compiling for {description} - {detection_reason}") - - if dry_run: - logger.dry_run_notice("showing placement without writing files") - if verbose: - logger.verbose_detail( - "Verbose mode: showing source attribution and optimizer analysis" + # Pass config_target only when it's a string -- detect_target() is + # typed for Optional[str], and a frozenset config_target is already + # handled by the branch above. + detected_target, detection_reason = detect_target( + project_root=source_root, + explicit_target=compile_target, + config_target=compile_config_target + if isinstance(compile_config_target, str) + else None, ) - else: - logger.progress("Using single-file compilation (legacy mode)", symbol="page") - - # Perform compilation - compiler = AgentsCompiler(".") - result = compiler.compile(config, logger=logger) - compile_has_critical = result.has_critical_security + # Map 'minimal' to 'vscode' for the compiler (AGENTS.md only, no folder integration) + effective_target = detected_target if detected_target != "minimal" else "vscode" + + # Build config with distributed compilation flags (Task 7) + config = CompilationConfig.from_apm_yml( + output_path=output if output != AGENTS_MD_FILENAME else None, + chatmode=chatmode, + resolve_links=not no_links if no_links else None, + dry_run=dry_run, + single_agents=single_agents, + trace=verbose, + local_only=local_only, + debug=verbose, + clean_orphaned=clean, + target=effective_target, + ) + config.with_constitution = with_constitution - if result.success: - # Handle different compilation modes + # Handle distributed vs single-file compilation if config.strategy == "distributed" and not single_agents: - # Distributed compilation results - output already shown by professional formatter - # Just show final success message - if dry_run: - # Success message for dry run already included in formatter output - pass + # Show target-aware message with detection reason. Use + # get_target_description() so any future target added to + # target_detection shows up here automatically. + if isinstance(effective_target, frozenset): + # Multi-target compile (from CLI `--target a,b` OR apm.yml + # `target: [a, b]`): show what the compiler will produce. + if isinstance(target, list): + _target_label = f"--target {','.join(target)}" + elif isinstance(config_target, list): + _target_label = f"apm.yml target: [{', '.join(config_target)}]" + else: + _target_label = "multi-target" + from ...core.target_detection import ( + should_compile_agents_md, + should_compile_claude_md, + should_compile_gemini_md, + ) + + _parts = [] + if should_compile_agents_md(effective_target): + _parts.append("AGENTS.md") + if should_compile_claude_md(effective_target): + _parts.append("CLAUDE.md") + if should_compile_gemini_md(effective_target): + _parts.append("GEMINI.md") + logger.progress(f"Compiling for {' + '.join(_parts)} ({_target_label})") + elif ( + isinstance(effective_target, str) + and effective_target == "vscode" + and detection_reason == REASON_NO_TARGET_FOLDER + ): + logger.progress(f"Compiling for AGENTS.md only ({detection_reason})") + logger.progress( + " Create .github/, .claude/, .codex/, .opencode/ or .cursor/ folder for full integration", + symbol="light_bulb", + ) else: - # Defense-in-depth (#820): don't claim "completed - # successfully" when zero files were emitted. With - # parse_target_field as the upstream gatekeeper this is - # unreachable in normal flow, but silent zero-effect - # success is the worst-case package-manager DX. - # - # Pattern-based stat scan (instead of a hardcoded key - # list) so new compile-time targets pick up the guard - # automatically: any stat ending in ``_files_written`` - # or ``_files_generated`` contributes to the total. - _files_written = sum( - int(v or 0) - for k, v in result.stats.items() - if k.endswith(("_files_written", "_files_generated")) + description = get_target_description(effective_target) + logger.progress(f"Compiling for {description} - {detection_reason}") + + if dry_run: + logger.dry_run_notice("showing placement without writing files") + if verbose: + logger.verbose_detail( + "Verbose mode: showing source attribution and optimizer analysis" ) - if _files_written > 0: - logger.success( - "Compilation completed successfully!", - symbol="check", - ) + else: + logger.progress("Using single-file compilation (legacy mode)", symbol="page") + + # Perform compilation. base_dir="." resolves against the active + # cwd -- the deploy root after compile_root_redirect's chdir, or + # plain $PWD otherwise. source_dir keeps primitive discovery + # and project-tree scanning in the user's source tree. + compiler = AgentsCompiler(".", source_dir=str(source_root)) + result = compiler.compile(config, logger=logger) + compile_has_critical = result.has_critical_security + + if result.success: + # Handle different compilation modes + if config.strategy == "distributed" and not single_agents: + # Distributed compilation results - output already shown by professional formatter + # Just show final success message + if dry_run: + # Success message for dry run already included in formatter output + pass else: - # Zero-output compile is the silent-success failure - # mode #820 guards against. Don't claim success; - # surface what the user can act on. The cause is - # usually one of: target dirs not present (auto- - # detect found nothing), explicit target rejected - # by policy, or no primitives in the project. - logger.warning( - "Compilation completed but produced no output " - "files. Check that target directories exist " - "(e.g. .github/, .claude/) or set 'target:' " - "in apm.yml / pass --target explicitly." + # Defense-in-depth (#820): don't claim "completed + # successfully" when zero files were emitted. With + # parse_target_field as the upstream gatekeeper this is + # unreachable in normal flow, but silent zero-effect + # success is the worst-case package-manager DX. + # + # Pattern-based stat scan (instead of a hardcoded key + # list) so new compile-time targets pick up the guard + # automatically: any stat ending in ``_files_written`` + # or ``_files_generated`` contributes to the total. + _files_written = sum( + int(v or 0) + for k, v in result.stats.items() + if k.endswith(("_files_written", "_files_generated")) ) + if _files_written > 0: + logger.success( + "Compilation completed successfully!", + symbol="check", + ) + else: + # Zero-output compile is the silent-success failure + # mode #820 guards against. Don't claim success; + # surface what the user can act on. The cause is + # usually one of: target dirs not present (auto- + # detect found nothing), explicit target rejected + # by policy, or no primitives in the project. + logger.warning( + "Compilation completed but produced no output " + "files. Check that target directories exist " + "(e.g. .github/, .claude/) or set 'target:' " + "in apm.yml / pass --target explicitly." + ) - else: - # Traditional single-file compilation - keep existing logic - # Perform initial compilation in dry-run to get generated body (without constitution) - intermediate_config = CompilationConfig( - output_path=config.output_path, - chatmode=config.chatmode, - resolve_links=config.resolve_links, - dry_run=True, # force - with_constitution=config.with_constitution, - strategy="single-file", - ) - intermediate_result = compiler.compile(intermediate_config) - - if intermediate_result.success: - # Perform constitution injection / preservation - from ...compilation.injector import ConstitutionInjector - - injector = ConstitutionInjector(base_dir=".") - output_path = Path(config.output_path) - final_content, c_status, c_hash = injector.inject( - intermediate_result.content, + else: + # Traditional single-file compilation - keep existing logic + # Perform initial compilation in dry-run to get generated body (without constitution) + intermediate_config = CompilationConfig( + output_path=config.output_path, + chatmode=config.chatmode, + resolve_links=config.resolve_links, + dry_run=True, # force with_constitution=config.with_constitution, - output_path=output_path, + strategy="single-file", ) + intermediate_result = compiler.compile(intermediate_config) + + if intermediate_result.success: + # Perform constitution injection / preservation + from ...compilation.injector import ConstitutionInjector + + injector = ConstitutionInjector(base_dir=".") + output_path = Path(config.output_path) + final_content, c_status, c_hash = injector.inject( + intermediate_result.content, + with_constitution=config.with_constitution, + output_path=output_path, + ) - if not dry_run: - # Only rewrite when content materially changes (creation, update, missing constitution case) - if c_status in ("CREATED", "UPDATED", "MISSING"): - # Defense-in-depth: scan compiled output before writing - from ...security.gate import WARN_POLICY, SecurityGate - - verdict = SecurityGate.scan_text( - final_content, str(output_path), policy=WARN_POLICY + if not dry_run: + # Only rewrite when content materially changes (creation, update, missing constitution case) + if c_status in ("CREATED", "UPDATED", "MISSING"): + # Defense-in-depth: scan compiled output before writing + from ...security.gate import WARN_POLICY, SecurityGate + + verdict = SecurityGate.scan_text( + final_content, str(output_path), policy=WARN_POLICY + ) + if verdict.has_findings: + actionable = verdict.critical_count + verdict.warning_count + if verdict.has_critical: + compile_has_critical = True + if actionable: + logger.warning( + f"Compiled output contains {actionable} hidden character(s) " + f"-- run 'apm audit --file {output_path}' to inspect" + ) + try: + from ...compilation.output_writer import CompiledOutputWriter + + CompiledOutputWriter().write(output_path, final_content) + except OSError as e: + logger.error(f"Failed to write final AGENTS.md: {e}") + sys.exit(1) + else: + logger.progress( + "No changes detected; preserving existing AGENTS.md for idempotency" + ) + + # Report success at the top + if dry_run: + logger.success( + "Context compilation completed successfully (dry run)", + symbol="check", ) - if verdict.has_findings: - actionable = verdict.critical_count + verdict.warning_count - if verdict.has_critical: - compile_has_critical = True - if actionable: - logger.warning( - f"Compiled output contains {actionable} hidden character(s) " - f"-- run 'apm audit --file {output_path}' to inspect" - ) - try: - from ...compilation.output_writer import CompiledOutputWriter - - CompiledOutputWriter().write(output_path, final_content) - except OSError as e: - logger.error(f"Failed to write final AGENTS.md: {e}") - sys.exit(1) else: - logger.progress( - "No changes detected; preserving existing AGENTS.md for idempotency" + logger.success( + f"Context compiled successfully to {output_path}", ) - # Report success at the top - if dry_run: - logger.success( - "Context compilation completed successfully (dry run)", - symbol="check", - ) - else: - logger.success( - f"Context compiled successfully to {output_path}", - ) - - stats = ( - intermediate_result.stats - ) # timestamp removed; stats remain version + counts + stats = ( + intermediate_result.stats + ) # timestamp removed; stats remain version + counts - # Add spacing before summary table - _rich_blank_line() + # Add spacing before summary table + _rich_blank_line() - _display_single_file_summary(stats, c_status, c_hash, output_path, dry_run) + _display_single_file_summary(stats, c_status, c_hash, output_path, dry_run) - if dry_run: - preview = final_content[:500] + ("..." if len(final_content) > 500 else "") - _rich_panel(preview, title=" Generated Content Preview", style="cyan") - else: - _display_next_steps(output) - - # Display warnings for all compilation modes - if result.warnings: - logger.warning(f"Compilation completed with {len(result.warnings)} warning(s):") - for warning in result.warnings: - logger.warning(f" {warning}") - - if result.errors: - logger.error(f"Compilation failed with {len(result.errors)} errors:") - for error in result.errors: - logger.error(f" {error}") - sys.exit(1) + if dry_run: + preview = final_content[:500] + ("..." if len(final_content) > 500 else "") + _rich_panel(preview, title=" Generated Content Preview", style="cyan") + else: + _display_next_steps(output) + + # Display warnings for all compilation modes + if result.warnings: + logger.warning(f"Compilation completed with {len(result.warnings)} warning(s):") + for warning in result.warnings: + logger.warning(f" {warning}") + + if result.errors: + logger.error(f"Compilation failed with {len(result.errors)} errors:") + for error in result.errors: + logger.error(f" {error}") + sys.exit(1) - # Check for orphaned packages after successful compilation - try: - orphaned_packages = _check_orphaned_packages() - if orphaned_packages: - _rich_blank_line() - logger.warning( - f"Found {len(orphaned_packages)} orphaned package(s) that were included in compilation:" + # Check for orphaned packages after successful compilation + try: + orphaned_packages = _check_orphaned_packages() + if orphaned_packages: + _rich_blank_line() + logger.warning( + f"Found {len(orphaned_packages)} orphaned package(s) that were included in compilation:" + ) + for pkg in orphaned_packages: + logger.progress(f" * {pkg}") + logger.progress(" Run 'apm prune' to remove orphaned packages") + except Exception: + pass # Continue if orphan check fails + + # Hard-fail when critical security findings were detected in compiled + # output. Consistent with apm install and apm unpack behavior. + if compile_has_critical: + logger.error( + "Compiled output contains critical hidden characters" + " -- run 'apm audit' to inspect, 'apm audit --strip' to clean" ) - for pkg in orphaned_packages: - logger.progress(f" * {pkg}") - logger.progress(" Run 'apm prune' to remove orphaned packages") - except Exception: - pass # Continue if orphan check fails - - # Hard-fail when critical security findings were detected in compiled - # output. Consistent with apm install and apm unpack behavior. - if compile_has_critical: - logger.error( - "Compiled output contains critical hidden characters" - " -- run 'apm audit' to inspect, 'apm audit --strip' to clean" - ) - sys.exit(1) + sys.exit(1) - except ImportError as e: - logger.error(f"Compilation module not available: {e}") - logger.progress("This might be a development environment issue.") - sys.exit(1) - except Exception as e: - logger.error(f"Error during compilation: {e}") - sys.exit(1) + except ImportError as e: + logger.error(f"Compilation module not available: {e}") + logger.progress("This might be a development environment issue.") + sys.exit(1) + except Exception as e: + logger.error(f"Error during compilation: {e}") + sys.exit(1) + # ``--root`` cleanup (chdir back, clear source-root pin) is + # handled by the outer ``with compile_root_redirect(...)``. diff --git a/src/apm_cli/commands/install.py b/src/apm_cli/commands/install.py index e756898e5..06008ba06 100644 --- a/src/apm_cli/commands/install.py +++ b/src/apm_cli/commands/install.py @@ -940,6 +940,15 @@ def _handle_mcp_install( default=False, help="Skip org policy enforcement for this invocation. Does NOT bypass apm audit --ci.", ) +@click.option( + "--root", "root", type=click.Path(file_okay=False, resolve_path=True), + default=None, metavar="DIR", + help=("Install into DIR instead of $PWD: apm_modules/, apm.lock.yaml, " + ".claude/, .codex/, .agents/, .opencode/ are written under DIR " + "while sources (apm.yml, .apm/, local-path packages) continue " + "resolving from $PWD. Mirrors `pip install --target`/" + "`npm install --prefix`. Project-scope only."), +) @click.pass_context def install( # noqa: PLR0913 ctx, @@ -970,6 +979,7 @@ def install( # noqa: PLR0913 registry_url, skill_names, no_policy, + root, ): """Install APM and MCP dependencies from apm.yml (like npm install). @@ -994,273 +1004,288 @@ def install( # noqa: PLR0913 # C1 #856: defaults BEFORE try so the finally clause never sees an # UnboundLocalError if InstallLogger(...) raises during construction. _apm_verbose_prev = os.environ.get("APM_VERBOSE") - try: - # Create structured logger for install output early so exception - # handlers can always reference it (avoids UnboundLocalError if - # scope initialisation below throws). - is_partial = bool(packages) - logger = InstallLogger(verbose=verbose, dry_run=dry_run, partial=is_partial) - # HACK(#852): surface --verbose to deeper auth layers via env var until - # AuthResolver gains a first-class verbose channel. Restored in finally - # below to keep the mutation scoped to this command invocation. - if verbose: - os.environ["APM_VERBOSE"] = "1" - - # W2-pkg-rollback (#827): snapshot bytes captured BEFORE - # _validate_and_add_packages_to_apm_yml mutates apm.yml. - # Initialised to None here so exception handlers always have it. - _manifest_snapshot: bytes | None = None - # manifest_path is set later (scope-dependent); keep a stable ref - # so exception handlers can use it without NameError. - _snapshot_manifest_path: Path | None = None - - # ---------------------------------------------------------------- - # --mcp branch (W3): when --mcp is set, route to the dedicated - # MCP-add path. We compute the post-`--` argv here BEFORE Click's - # silent handling: see _split_argv_at_double_dash(). - # ---------------------------------------------------------------- - _, command_argv = _split_argv_at_double_dash(_get_invocation_argv()) - # `packages` from Click already includes the post-`--` items; the - # pre-`--` portion is what the user typed as positional packages. - if command_argv: - split_idx = len(packages) - len(command_argv) - if split_idx < 0: # noqa: PLR1730 - split_idx = 0 - pre_dash_packages = builtins.tuple(packages[:split_idx]) - else: - pre_dash_packages = builtins.tuple(packages) - - # Validate --registry (raises UsageError on a bad URL). - validated_registry_url = _validate_registry_url(registry_url) - - _validate_mcp_conflicts( - mcp_name=mcp_name, - packages=packages, - pre_dash_packages=pre_dash_packages, - transport=transport, - url=url, - env=env_pairs, - headers=header_pairs, - mcp_version=mcp_version, - command_argv=command_argv, - global_=global_, - only=only, - update=update, - use_ssh=use_ssh, - use_https=use_https, - allow_protocol_fallback=allow_protocol_fallback, - registry_url=validated_registry_url, - ) + # --root: see apm_cli.install.root_redirect.install_root_redirect. + # Conflicts with --global (user scope writes are anchored at $HOME + # and have no concept of an arbitrary deploy root). + if root and global_: + raise click.UsageError("--root is not valid with --global (user scope)") + from ..install.root_redirect import install_root_redirect + with install_root_redirect(root): + try: + # Create structured logger for install output early so exception + # handlers can always reference it (avoids UnboundLocalError if + # scope initialisation below throws). + is_partial = bool(packages) + logger = InstallLogger(verbose=verbose, dry_run=dry_run, partial=is_partial) + # HACK(#852): surface --verbose to deeper auth layers via env var until + # AuthResolver gains a first-class verbose channel. Restored in finally + # below to keep the mutation scoped to this command invocation. + if verbose: + os.environ["APM_VERBOSE"] = "1" + + # W2-pkg-rollback (#827): snapshot bytes captured BEFORE + # _validate_and_add_packages_to_apm_yml mutates apm.yml. + # Initialised to None here so exception handlers always have it. + _manifest_snapshot: bytes | None = None + # manifest_path is set later (scope-dependent); keep a stable ref + # so exception handlers can use it without NameError. + _snapshot_manifest_path: Path | None = None + + # ---------------------------------------------------------------- + # --mcp branch (W3): when --mcp is set, route to the dedicated + # MCP-add path. We compute the post-`--` argv here BEFORE Click's + # silent handling: see _split_argv_at_double_dash(). + # ---------------------------------------------------------------- + _, command_argv = _split_argv_at_double_dash(_get_invocation_argv()) + # `packages` from Click already includes the post-`--` items; the + # pre-`--` portion is what the user typed as positional packages. + if command_argv: + split_idx = len(packages) - len(command_argv) + if split_idx < 0: # noqa: PLR1730 + split_idx = 0 + pre_dash_packages = builtins.tuple(packages[:split_idx]) + else: + pre_dash_packages = builtins.tuple(packages) - # Normalize --skill: '*' means all (same as absent). Reject with --mcp. - _skill_subset = None - if skill_names: - if mcp_name is not None: - raise click.UsageError("--skill cannot be combined with --mcp.") - if not any(s == "*" for s in skill_names): - _skill_subset = builtins.tuple(skill_names) + # Validate --registry (raises UsageError on a bad URL). + validated_registry_url = _validate_registry_url(registry_url) - if mcp_name is not None: - _handle_mcp_install( + _validate_mcp_conflicts( mcp_name=mcp_name, + packages=packages, + pre_dash_packages=pre_dash_packages, transport=transport, url=url, - env_pairs=env_pairs, - header_pairs=header_pairs, + env=env_pairs, + headers=header_pairs, mcp_version=mcp_version, command_argv=command_argv, - dev=dev, - force=force, - runtime=runtime, - exclude=exclude, - verbose=verbose, - dry_run=dry_run, - logger=logger, - no_policy=no_policy, - validated_registry_url=validated_registry_url, + global_=global_, + only=only, + update=update, + use_ssh=use_ssh, + use_https=use_https, + allow_protocol_fallback=allow_protocol_fallback, + registry_url=validated_registry_url, ) - return - # Resolve transport selection inputs. - from ..deps.transport_selection import ( - ProtocolPreference, - is_fallback_allowed, - protocol_pref_from_env, - ) + # Normalize --skill: '*' means all (same as absent). Reject with --mcp. + _skill_subset = None + if skill_names: + if mcp_name is not None: + raise click.UsageError("--skill cannot be combined with --mcp.") + if not any(s == "*" for s in skill_names): + _skill_subset = builtins.tuple(skill_names) - if use_ssh and use_https: - _rich_error("Options --ssh and --https are mutually exclusive.", symbol="error") - sys.exit(2) - if use_ssh: - protocol_pref = ProtocolPreference.SSH - elif use_https: - protocol_pref = ProtocolPreference.HTTPS - else: - protocol_pref = protocol_pref_from_env() - # CLI flag OR env var enables fallback. - allow_protocol_fallback = allow_protocol_fallback or is_fallback_allowed() - - # Resolve scope - from ..core.scope import ( - InstallScope, - ensure_user_dirs, - get_apm_dir, - get_manifest_path, - get_modules_dir, # noqa: F401 - warn_unsupported_user_scope, - ) + if mcp_name is not None: + _handle_mcp_install( + mcp_name=mcp_name, + transport=transport, + url=url, + env_pairs=env_pairs, + header_pairs=header_pairs, + mcp_version=mcp_version, + command_argv=command_argv, + dev=dev, + force=force, + runtime=runtime, + exclude=exclude, + verbose=verbose, + dry_run=dry_run, + logger=logger, + no_policy=no_policy, + validated_registry_url=validated_registry_url, + ) + return - scope = InstallScope.USER if global_ else InstallScope.PROJECT - - if scope is InstallScope.USER: - ensure_user_dirs() - logger.progress("Installing to user scope (~/.apm/)") - _scope_warn = warn_unsupported_user_scope() - if _scope_warn: - logger.warning(_scope_warn) - - # Scope-aware paths - manifest_path = get_manifest_path(scope) - apm_dir = get_apm_dir(scope) - # Display name for messages (short for project scope, full for user scope) - manifest_display = str(manifest_path) if scope is InstallScope.USER else APM_YML_FILENAME - - # Project root for integration (used by both dep and local integration) - from ..core.scope import get_deploy_root - - project_root = get_deploy_root(scope) - - # Create shared auth resolver for all downloads in this CLI invocation - # to ensure credentials are cached and reused (prevents duplicate auth popups) - auth_resolver = AuthResolver() - # F2/F3 #856: thread the InstallLogger into AuthResolver so the verbose - # auth-source line and the deferred stale-PAT [!] warning route through - # CommandLogger / DiagnosticCollector instead of stderr/inline writes. - auth_resolver.set_logger(logger) - - # Check if apm.yml exists - apm_yml_exists = manifest_path.exists() - - # Auto-bootstrap: create minimal apm.yml when packages specified but no apm.yml - if not apm_yml_exists and packages: - # Get current directory name as project name - project_name = Path.cwd().name if scope is InstallScope.PROJECT else Path.home().name - config = _get_default_config(project_name) - _create_minimal_apm_yml(config, target_path=manifest_path) - logger.success(f"Created {manifest_display}") - - # Error when NO apm.yml AND NO packages - if not apm_yml_exists and not packages: - logger.error(f"No {manifest_display} found") - if scope is InstallScope.USER: - logger.progress("Run 'apm install -g ' to auto-create + install") + # Resolve transport selection inputs. + from ..deps.transport_selection import ( + ProtocolPreference, + is_fallback_allowed, + protocol_pref_from_env, + ) + + if use_ssh and use_https: + _rich_error("Options --ssh and --https are mutually exclusive.", symbol="error") + sys.exit(2) + if use_ssh: + protocol_pref = ProtocolPreference.SSH + elif use_https: + protocol_pref = ProtocolPreference.HTTPS else: - logger.progress("Run 'apm init' to create one, or:") - logger.progress(" apm install to auto-create + install") - sys.exit(1) + protocol_pref = protocol_pref_from_env() + # CLI flag OR env var enables fallback. + allow_protocol_fallback = allow_protocol_fallback or is_fallback_allowed() + + # Resolve scope + from ..core.scope import ( + InstallScope, + ensure_user_dirs, + get_apm_dir, + get_manifest_path, + get_modules_dir, # noqa: F401 + warn_unsupported_user_scope, + ) - # If packages are specified, validate and add them to apm.yml first - validated_packages = [] - outcome = None - if packages: - # -- W2-pkg-rollback (#827): snapshot raw bytes BEFORE mutation -- - # _validate_and_add_packages_to_apm_yml does a YAML round-trip - # (load + dump) which may alter whitespace, key ordering, or - # trailing newlines. We snapshot the raw bytes so rollback is - # byte-exact -- no YAML drift. - if manifest_path.exists(): - _manifest_snapshot = manifest_path.read_bytes() - _snapshot_manifest_path = manifest_path - - validated_packages, outcome = _validate_and_add_packages_to_apm_yml( - packages, - dry_run, - dev=dev, - logger=logger, + scope = InstallScope.USER if global_ else InstallScope.PROJECT + + if scope is InstallScope.USER: + ensure_user_dirs() + logger.progress("Installing to user scope (~/.apm/)") + _scope_warn = warn_unsupported_user_scope() + if _scope_warn: + logger.warning(_scope_warn) + + # Scope-aware paths + manifest_path = get_manifest_path(scope) + apm_dir = get_apm_dir(scope) + # Display name for messages (short for project scope, full for user scope) + manifest_display = str(manifest_path) if scope is InstallScope.USER else APM_YML_FILENAME + + # Project root for integration (used by both dep and local integration) + from ..core.scope import get_deploy_root + + project_root = get_deploy_root(scope) + + # Create shared auth resolver for all downloads in this CLI invocation + # to ensure credentials are cached and reused (prevents duplicate auth popups) + auth_resolver = AuthResolver() + # F2/F3 #856: thread the InstallLogger into AuthResolver so the verbose + # auth-source line and the deferred stale-PAT [!] warning route through + # CommandLogger / DiagnosticCollector instead of stderr/inline writes. + auth_resolver.set_logger(logger) + + # Check if apm.yml exists + apm_yml_exists = manifest_path.exists() + + # Auto-bootstrap: create minimal apm.yml when packages specified but no apm.yml + if not apm_yml_exists and packages: + # Get source directory name as project name (the user's working + # directory, not the --root deploy target). + from ..core.scope import get_source_root as _get_source_root + project_name = ( + _get_source_root(scope).name + if scope is InstallScope.PROJECT + else Path.home().name + ) + config = _get_default_config(project_name) + _create_minimal_apm_yml(config, target_path=manifest_path) + logger.success(f"Created {manifest_display}") + + # Error when NO apm.yml AND NO packages + if not apm_yml_exists and not packages: + logger.error(f"No {manifest_display} found") + if scope is InstallScope.USER: + logger.progress("Run 'apm install -g ' to auto-create + install") + else: + logger.progress("Run 'apm init' to create one, or:") + logger.progress(" apm install to auto-create + install") + sys.exit(1) + + # If packages are specified, validate and add them to apm.yml first + validated_packages = [] + outcome = None + if packages: + # -- W2-pkg-rollback (#827): snapshot raw bytes BEFORE mutation -- + # _validate_and_add_packages_to_apm_yml does a YAML round-trip + # (load + dump) which may alter whitespace, key ordering, or + # trailing newlines. We snapshot the raw bytes so rollback is + # byte-exact -- no YAML drift. + if manifest_path.exists(): + _manifest_snapshot = manifest_path.read_bytes() + _snapshot_manifest_path = manifest_path + + validated_packages, outcome = _validate_and_add_packages_to_apm_yml( + packages, + dry_run, + dev=dev, + logger=logger, + manifest_path=manifest_path, + auth_resolver=auth_resolver, + scope=scope, + allow_insecure=allow_insecure, + ) + # Short-circuit: all packages failed validation -- nothing to install + if outcome.all_failed: + return + # Note: Empty validated_packages is OK if packages are already in apm.yml + # We'll proceed with installation from apm.yml to ensure everything is synced + + # Build install context + install_ctx = InstallContext( + scope=scope, manifest_path=manifest_path, + manifest_display=manifest_display, + apm_dir=apm_dir, + project_root=project_root, + logger=logger, auth_resolver=auth_resolver, - scope=scope, + verbose=verbose, + force=force, + dry_run=dry_run, + update=update, + dev=dev, + runtime=runtime, + exclude=exclude, + target=target, + parallel_downloads=parallel_downloads, allow_insecure=allow_insecure, + allow_insecure_hosts=allow_insecure_hosts, + protocol_pref=protocol_pref, + allow_protocol_fallback=allow_protocol_fallback, + trust_transitive_mcp=trust_transitive_mcp, + no_policy=no_policy, + install_mode=InstallMode(only) if only else InstallMode.ALL, + packages=packages, + only_packages=builtins.list(validated_packages) if packages else None, + manifest_snapshot=_manifest_snapshot, + snapshot_manifest_path=_snapshot_manifest_path, ) - # Short-circuit: all packages failed validation -- nothing to install - if outcome.all_failed: - return - # Note: Empty validated_packages is OK if packages are already in apm.yml - # We'll proceed with installation from apm.yml to ensure everything is synced - - # Build install context - install_ctx = InstallContext( - scope=scope, - manifest_path=manifest_path, - manifest_display=manifest_display, - apm_dir=apm_dir, - project_root=project_root, - logger=logger, - auth_resolver=auth_resolver, - verbose=verbose, - force=force, - dry_run=dry_run, - update=update, - dev=dev, - runtime=runtime, - exclude=exclude, - target=target, - parallel_downloads=parallel_downloads, - allow_insecure=allow_insecure, - allow_insecure_hosts=allow_insecure_hosts, - protocol_pref=protocol_pref, - allow_protocol_fallback=allow_protocol_fallback, - trust_transitive_mcp=trust_transitive_mcp, - no_policy=no_policy, - install_mode=InstallMode(only) if only else InstallMode.ALL, - packages=packages, - only_packages=builtins.list(validated_packages) if packages else None, - manifest_snapshot=_manifest_snapshot, - snapshot_manifest_path=_snapshot_manifest_path, - ) - apm_count, mcp_count, apm_diagnostics = _install_apm_packages( - install_ctx, - outcome, - ) + apm_count, mcp_count, apm_diagnostics = _install_apm_packages( + install_ctx, + outcome, + ) - _post_install_summary( - logger=logger, - apm_count=apm_count, - mcp_count=mcp_count, - apm_diagnostics=apm_diagnostics, - force=force, - ) + _post_install_summary( + logger=logger, + apm_count=apm_count, + mcp_count=mcp_count, + apm_diagnostics=apm_diagnostics, + force=force, + ) - except InsecureDependencyPolicyError: - _maybe_rollback_manifest(_snapshot_manifest_path, _manifest_snapshot, logger) - sys.exit(1) - except AuthenticationError as e: - _maybe_rollback_manifest(_snapshot_manifest_path, _manifest_snapshot, logger) - _rich_error(str(e)) - if e.diagnostic_context: - _rich_echo(e.diagnostic_context) - sys.exit(1) - except DirectDependencyError as e: - _maybe_rollback_manifest(_snapshot_manifest_path, _manifest_snapshot, logger) - logger.error(str(e)) - sys.exit(1) - except click.UsageError: - # Conflict matrix / argv parser raises UsageError -- let Click - # render with exit code 2 and the standard "Usage: ..." prefix. - raise - except Exception as e: - _maybe_rollback_manifest(_snapshot_manifest_path, _manifest_snapshot, logger) - logger.error(f"Error installing dependencies: {e}") - if not verbose: - logger.progress("Run with --verbose for detailed diagnostics") - sys.exit(1) - finally: - # HACK(#852) cleanup: restore APM_VERBOSE so it stays scoped to this call. - if _apm_verbose_prev is None: - os.environ.pop("APM_VERBOSE", None) - else: - os.environ["APM_VERBOSE"] = _apm_verbose_prev + except InsecureDependencyPolicyError: + _maybe_rollback_manifest(_snapshot_manifest_path, _manifest_snapshot, logger) + sys.exit(1) + except AuthenticationError as e: + _maybe_rollback_manifest(_snapshot_manifest_path, _manifest_snapshot, logger) + _rich_error(str(e)) + if e.diagnostic_context: + _rich_echo(e.diagnostic_context) + sys.exit(1) + except DirectDependencyError as e: + _maybe_rollback_manifest(_snapshot_manifest_path, _manifest_snapshot, logger) + logger.error(str(e)) + sys.exit(1) + except click.UsageError: + # Conflict matrix / argv parser raises UsageError -- let Click + # render with exit code 2 and the standard "Usage: ..." prefix. + raise + except Exception as e: + _maybe_rollback_manifest(_snapshot_manifest_path, _manifest_snapshot, logger) + logger.error(f"Error installing dependencies: {e}") + if not verbose: + logger.progress("Run with --verbose for detailed diagnostics") + sys.exit(1) + finally: + # HACK(#852) cleanup: restore APM_VERBOSE so it stays scoped to this call. + # ``--root`` cleanup (chdir back, clear source-root pin) is + # handled by the outer ``with install_root_redirect(...)``. + if _apm_verbose_prev is None: + os.environ.pop("APM_VERBOSE", None) + else: + os.environ["APM_VERBOSE"] = _apm_verbose_prev # --------------------------------------------------------------------------- diff --git a/src/apm_cli/compilation/agents_compiler.py b/src/apm_cli/compilation/agents_compiler.py index bdec805e2..314b40958 100644 --- a/src/apm_cli/compilation/agents_compiler.py +++ b/src/apm_cli/compilation/agents_compiler.py @@ -182,13 +182,21 @@ class CompilationResult: class AgentsCompiler: """Main compiler for generating AGENTS.md files.""" - def __init__(self, base_dir: str = "."): + def __init__(self, base_dir: str = ".", source_dir: str | None = None): """Initialize the compiler. Args: - base_dir (str): Base directory for compilation. Defaults to current directory. + base_dir (str): Base directory for compilation -- where AGENTS.md / + CLAUDE.md outputs are written and the relative root for + placement decisions. Defaults to the current directory. + source_dir (Optional[str]): Where primitives (``.apm/``, + ``apm_modules/``) and source files are discovered. Defaults to + ``base_dir`` for back-compat; set explicitly when ``apm + compile --root`` redirects writes but sources remain in + ``$PWD``. """ self.base_dir = Path(base_dir) + self.source_dir = Path(source_dir) if source_dir else self.base_dir self.warnings: list[str] = [] self.errors: list[str] = [] self._logger = None @@ -228,7 +236,7 @@ def compile( if config.local_only: # Use basic discovery for local-only mode primitives = discover_primitives( - str(self.base_dir), + str(self.source_dir), exclude_patterns=config.exclude, ) else: @@ -236,7 +244,7 @@ def compile( from ..primitives.discovery import discover_primitives_with_dependencies primitives = discover_primitives_with_dependencies( - str(self.base_dir), + str(self.source_dir), exclude_patterns=config.exclude, ) @@ -358,9 +366,14 @@ def _compile_distributed( errors = self.validate_primitives(primitives) self.errors.extend(errors) - # Create distributed compiler with exclude patterns + # Create distributed compiler with exclude patterns. source_dir + # carries through so primitive discovery + project-tree scoring + # honor `apm compile --root` (sources stay in $PWD, writes + # redirect to base_dir). distributed_compiler = DistributedAgentsCompiler( - str(self.base_dir), exclude_patterns=config.exclude + str(self.base_dir), + exclude_patterns=config.exclude, + source_dir=str(self.source_dir), ) # Prepare configuration for distributed compilation @@ -527,7 +540,9 @@ def _compile_claude_md( from .distributed_compiler import DistributedAgentsCompiler distributed_compiler = DistributedAgentsCompiler( - str(self.base_dir), exclude_patterns=config.exclude + str(self.base_dir), + exclude_patterns=config.exclude, + source_dir=str(self.source_dir), ) # Analyze directory structure and determine placement @@ -801,7 +816,10 @@ def validate_primitives(self, primitives: PrimitiveCollection) -> list[str]: for primitive in primitives.all_primitives(): primitive_errors = primitive.validate() if primitive_errors: - file_path = portable_relpath(primitive.file_path, self.base_dir) + # Source files live under source_dir; relativise display + # paths against it so `apm compile --root` doesn't render + # absolute or `../../` paths in warning messages. + file_path = portable_relpath(primitive.file_path, self.source_dir) for error in primitive_errors: # Treat validation errors as warnings instead of hard errors @@ -813,7 +831,7 @@ def validate_primitives(self, primitives: PrimitiveCollection) -> list[str]: primitive_dir = primitive.file_path.parent link_errors = validate_link_targets(primitive.content, primitive_dir) if link_errors: - file_path = portable_relpath(primitive.file_path, self.base_dir) + file_path = portable_relpath(primitive.file_path, self.source_dir) for link_error in link_errors: self.warnings.append(f"{file_path}: {link_error}") @@ -850,8 +868,12 @@ def _generate_template_data( Returns: TemplateData: Template data for generation. """ - # Build instructions content - instructions_content = build_conditional_sections(primitives.instructions) + # Build instructions content. source_dir keeps `` + # display paths relative to the user's working directory when + # `apm compile --root` redirects writes elsewhere. + instructions_content = build_conditional_sections( + primitives.instructions, source_dir=self.source_dir, + ) # Metadata (version only; timestamp intentionally omitted for determinism) version = get_version() @@ -977,7 +999,10 @@ def _display_trace_info(self, distributed_result, primitives: PrimitiveCollectio for instruction in placement.instructions: source = getattr(instruction, "source", "local") - inst_path = portable_relpath(instruction.file_path, self.base_dir) + # instruction.file_path is a source-tree file; relativise + # against source_dir so `apm compile --root` produces + # human-readable paths in verbose output. + inst_path = portable_relpath(instruction.file_path, self.source_dir) self._log( "verbose_detail", diff --git a/src/apm_cli/compilation/distributed_compiler.py b/src/apm_cli/compilation/distributed_compiler.py index f8c2a8c23..8c5aa6932 100644 --- a/src/apm_cli/compilation/distributed_compiler.py +++ b/src/apm_cli/compilation/distributed_compiler.py @@ -71,28 +71,62 @@ class CompilationResult: class DistributedAgentsCompiler: """Main compiler for generating distributed AGENTS.md files.""" - def __init__(self, base_dir: str = ".", exclude_patterns: builtins.list[str] | None = None): + def __init__( + self, + base_dir: str = ".", + exclude_patterns: builtins.list[str] | None = None, + source_dir: str | None = None, + ): """Initialize the distributed AGENTS.md compiler. Args: - base_dir (str): Base directory for compilation. - exclude_patterns (Optional[List[str]]): Glob patterns for directories to exclude. + base_dir (str): Base directory for compilation -- root used to + construct AGENTS.md write paths. Defaults to the current + directory. + exclude_patterns (Optional[List[str]]): Glob patterns for + directories to exclude. + source_dir (Optional[str]): Where primitives and the project + tree are scanned for placement scoring. Defaults to + ``base_dir`` for back-compat; set explicitly when ``apm + compile --root`` redirects writes but sources remain in + ``$PWD``. """ try: self.base_dir = Path(base_dir).resolve() except (OSError, FileNotFoundError): self.base_dir = Path(base_dir).absolute() + if source_dir is None: + self.source_dir = self.base_dir + else: + try: + self.source_dir = Path(source_dir).resolve() + except (OSError, FileNotFoundError): + self.source_dir = Path(source_dir).absolute() self.warnings: builtins.list[str] = [] self.errors: builtins.list[str] = [] self.total_files_written = 0 self.context_optimizer = ContextOptimizer( - str(self.base_dir), exclude_patterns=exclude_patterns + str(self.source_dir), exclude_patterns=exclude_patterns ) - self.link_resolver = UnifiedLinkResolver(self.base_dir) + self.link_resolver = UnifiedLinkResolver(self.source_dir) self.output_formatter = CompilationFormatter() self._placement_map = None + def _source_to_base(self, path: Path) -> Path: + """Map a path rooted at source_dir to the equivalent base_dir path. + + Returns *path* unchanged when source_dir == base_dir (the default + case) or when *path* is not under source_dir (defensive fallback). + """ + if self.source_dir == self.base_dir: + return path + try: + rel = path.resolve().relative_to(self.source_dir.resolve()) + except (ValueError, OSError): + return path + return self.base_dir / rel + def compile_distributed( self, primitives: PrimitiveCollection, config: dict | None = None ) -> CompilationResult: @@ -296,18 +330,26 @@ def determine_agents_placement( Returns: Dict[Path, List[Instruction]]: Optimized mapping of directory paths to instructions. """ - # Use the Context Optimization Engine for intelligent placement + # Use the Context Optimization Engine for intelligent placement. + # The optimizer scans source_dir, so the returned placement keys + # are rooted at source_dir; translate them to base_dir below so + # writes land at the deploy root when source_dir != base_dir + # (`apm compile --root`). optimized_placement = self.context_optimizer.optimize_instruction_placement( instructions, verbose=debug, enable_timing=debug, # Enable timing when debug mode is on ) + if optimized_placement and self.source_dir != self.base_dir: + optimized_placement = { + self._source_to_base(p): v for p, v in optimized_placement.items() + } # Special case: if no instructions but constitution exists, create root placement if not optimized_placement: from .constitution import find_constitution - constitution_path = find_constitution(Path(self.base_dir)) + constitution_path = find_constitution(Path(self.source_dir)) if constitution_path.exists(): # Create an empty placement for the root directory to enable verbose output optimized_placement = {Path(self.base_dir): []} @@ -357,7 +399,7 @@ def generate_distributed_agents_files( if not placement_map: from .constitution import find_constitution - constitution_path = find_constitution(Path(self.base_dir)) + constitution_path = find_constitution(Path(self.source_dir)) if constitution_path.exists(): # Create a root placement for constitution-only projects root_path = Path(self.base_dir) @@ -548,16 +590,20 @@ def _generate_agents_content( for instruction in sorted( pattern_instructions, - key=lambda i: portable_relpath(i.file_path, self.base_dir), + key=lambda i: portable_relpath(i.file_path, self.source_dir), ): content = instruction.content.strip() if content: - # Add source attribution for individual instructions + # Add source attribution for individual instructions. + # Source files live under source_dir (which equals + # base_dir except when `apm compile --root` is in + # play), so format the display path relative to + # source_dir for stable output. if placement.source_attribution: source = placement.source_attribution.get( str(instruction.file_path), "local" ) - rel_path = portable_relpath(instruction.file_path, self.base_dir) + rel_path = portable_relpath(instruction.file_path, self.source_dir) sections.append(f"") diff --git a/src/apm_cli/compilation/template_builder.py b/src/apm_cli/compilation/template_builder.py index 1b560fe19..c8c7c1aec 100644 --- a/src/apm_cli/compilation/template_builder.py +++ b/src/apm_cli/compilation/template_builder.py @@ -18,12 +18,19 @@ class TemplateData: version: str chatmode_content: str | None = None - -def build_conditional_sections(instructions: list[Instruction]) -> str: +def build_conditional_sections( + instructions: list[Instruction], + source_dir: Path | None = None, +) -> str: """Build sections grouped by applyTo patterns. Args: - instructions (List[Instruction]): List of instruction primitives. + instructions: List of instruction primitives. + source_dir: Root used to compute display-relative paths in + ```` comments. Defaults to ``Path.cwd()``; + callers using ``apm compile --root`` should pass the source + root so attribution paths render relative to the user's + working directory rather than the deploy target. Returns: str: Formatted conditional sections content. @@ -31,6 +38,8 @@ def build_conditional_sections(instructions: list[Instruction]) -> str: if not instructions: return "" + relpath_root = source_dir if source_dir is not None else Path.cwd() + # Group instructions by pattern - use raw patterns pattern_groups = _group_instructions_by_pattern(instructions) @@ -42,7 +51,8 @@ def build_conditional_sections(instructions: list[Instruction]) -> str: # Combine content from all instructions for this pattern for instruction in sorted( - pattern_instructions, key=lambda i: portable_relpath(i.file_path, Path.cwd()) + pattern_instructions, + key=lambda i: portable_relpath(i.file_path, relpath_root), ): content = instruction.content.strip() if content: @@ -50,7 +60,7 @@ def build_conditional_sections(instructions: list[Instruction]) -> str: try: # Try to get relative path for cleaner display if instruction.file_path.is_absolute(): - relative_path = portable_relpath(instruction.file_path, Path.cwd()) + relative_path = portable_relpath(instruction.file_path, relpath_root) else: relative_path = str(instruction.file_path) except (ValueError, OSError): diff --git a/src/apm_cli/core/scope.py b/src/apm_cli/core/scope.py index d5f1f610c..d0f58f743 100644 --- a/src/apm_cli/core/scope.py +++ b/src/apm_cli/core/scope.py @@ -9,6 +9,21 @@ User-scope support varies by target -- see ``TargetProfile.user_supported`` in ``apm_cli.integration.targets`` for the canonical registry. + +Source-root override +-------------------- +``set_source_root_override`` pins the source root (where ``apm.yml`` +and local-path packages resolve from) to an explicit directory. The +``apm install --root`` flow uses this together with ``os.chdir(root)`` +so existing call-sites that hardcode ``Path.cwd()`` automatically +resolve to the deploy root, while sources continue to read from the +captured original working directory. + +Write helpers (:func:`get_deploy_root`, :func:`get_apm_dir`, +:func:`get_modules_dir`, :func:`get_lockfile_dir`) intentionally do +NOT consult the override -- after ``chdir`` they already point to the +deploy root. Source helpers (:func:`get_source_root`, +:func:`get_manifest_path`) consult it. """ from __future__ import annotations @@ -37,17 +52,64 @@ class InstallScope(Enum): USER = "user" +# --------------------------------------------------------------------------- +# Source-root override (process-global; managed by --root flag handlers) +# --------------------------------------------------------------------------- + + +_SOURCE_ROOT_OVERRIDE: Path | None = None + + +def set_source_root_override(path: Path | None) -> None: + """Pin the project-scope source root to *path*. + + Used by ``apm install --root`` (and any command that ``chdir``s + into a deploy directory) to remember the user's original working + directory so :func:`get_source_root` and :func:`get_manifest_path` + keep reading sources from there. + + Pass ``None`` to clear the override. Command handlers should + always clear in a ``try/finally`` block so the global state never + leaks across CLI invocations. + """ + global _SOURCE_ROOT_OVERRIDE + _SOURCE_ROOT_OVERRIDE = path.resolve() if path is not None else None + + +def get_source_root_override() -> Path | None: + """Return the active source-root override, or ``None`` when unset.""" + return _SOURCE_ROOT_OVERRIDE + + # --------------------------------------------------------------------------- # Path resolution # --------------------------------------------------------------------------- +def get_source_root(scope: InstallScope) -> Path: + """Return the directory used to read project sources. + + Project scope: the active source-root override when set, otherwise + ``Path.cwd()``. User scope: ``Path.home()``. + + Sources resolved from this root: ``apm.yml``, ``.apm/`` local + primitives, and the resolution base for local-path package + references. + """ + if scope is InstallScope.USER: + return Path.home() + if _SOURCE_ROOT_OVERRIDE is not None: + return _SOURCE_ROOT_OVERRIDE + return Path.cwd() + + def get_deploy_root(scope: InstallScope) -> Path: """Return the root used to construct deployment paths. - For project scope this is ``Path.cwd()``. - For user scope this is ``Path.home()`` so that integrators produce - paths like ``~/.claude/commands/``. + For project scope this is ``Path.cwd()`` -- callers that want to + redirect deployment should ``chdir`` into the target directory and + use :func:`set_source_root_override` to remember the original + source root. For user scope this is ``Path.home()``. """ if scope is InstallScope.USER: return Path.home() @@ -55,9 +117,9 @@ def get_deploy_root(scope: InstallScope) -> Path: def get_apm_dir(scope: InstallScope) -> Path: - """Return the directory that holds APM metadata (manifest, lockfile, modules). + """Return the directory that holds APM metadata (lockfile, modules). - * Project scope: ``/`` + * Project scope: ``/`` (the active deploy root) * User scope: ``~/.apm/`` """ if scope is InstallScope.USER: @@ -73,14 +135,36 @@ def get_modules_dir(scope: InstallScope) -> Path: def get_manifest_path(scope: InstallScope) -> Path: - """Return the ``apm.yml`` path for *scope*.""" + """Return the ``apm.yml`` path for *scope*. + + The manifest is a SOURCE -- its location follows + :func:`get_source_root`, which honours + :func:`set_source_root_override`. This keeps ``apm install --root`` + reading the manifest from the user's original working directory + rather than from the (typically empty) deploy root. + """ from ..constants import APM_YML_FILENAME - return get_apm_dir(scope) / APM_YML_FILENAME + if scope is InstallScope.USER: + return Path.home() / USER_APM_DIR / APM_YML_FILENAME + return get_source_root(scope) / APM_YML_FILENAME def get_lockfile_dir(scope: InstallScope) -> Path: - """Return the directory containing the lockfile for *scope*.""" + """Return the directory containing the lockfile for *scope*. + + The lockfile is a WRITE -- it is regenerated by ``apm install`` and + co-located with ``apm_modules/``, so it follows + :func:`get_apm_dir` (which honours + :func:`set_source_root_override` via ``Path.cwd()`` after + ``install_root_redirect`` chdirs into the deploy root). + + Contrast with :func:`get_manifest_path`, which follows + :func:`get_source_root` because the manifest is a source file. If + you add a new metadata helper, pick the side deliberately: source + files go through :func:`get_source_root`; generated / deployed + artefacts go through :func:`get_apm_dir`. + """ return get_apm_dir(scope) diff --git a/src/apm_cli/install/context.py b/src/apm_cli/install/context.py index 2b16fd144..8741c34e4 100644 --- a/src/apm_cli/install/context.py +++ b/src/apm_cli/install/context.py @@ -34,6 +34,15 @@ class InstallContext: # ------------------------------------------------------------------ project_root: Path apm_dir: Path + # Source root for reads (``apm.yml``, ``.apm/``, local-path + # packages). Equal to ``project_root`` unless ``apm install --root`` + # redirects writes -- then ``source_root`` stays at ``$PWD`` while + # ``project_root`` is the override. + # + # Required. Resolve at the CLI boundary + # (``run_install_pipeline``); phases must NOT fall back to + # ``project_root`` -- that masks bugs whenever the two diverge. + source_root: Path # ------------------------------------------------------------------ # Inputs: populated by the caller from CLI args / APMPackage diff --git a/src/apm_cli/install/phases/integrate.py b/src/apm_cli/install/phases/integrate.py index d1a2843c2..325c5cf59 100644 --- a/src/apm_cli/install/phases/integrate.py +++ b/src/apm_cli/install/phases/integrate.py @@ -225,6 +225,7 @@ def _integrate_root_project( diagnostics=diagnostics, logger=logger, scope=ctx.scope, + source_root=ctx.source_root, ctx=ctx, ) diff --git a/src/apm_cli/install/phases/resolve.py b/src/apm_cli/install/phases/resolve.py index 40032cb2e..75b757729 100644 --- a/src/apm_cli/install/phases/resolve.py +++ b/src/apm_cli/install/phases/resolve.py @@ -109,6 +109,11 @@ def run(ctx: InstallContext) -> None: # This matches the original code's closure over function-level locals. scope = ctx.scope project_root = ctx.project_root + # Local-path package references in apm.yml are relative to the + # manifest's location (source_root), not the deploy override. + # source_root is required on InstallContext; equals project_root + # when --root is not used. + source_root = ctx.source_root update_refs = ctx.update_refs logger = ctx.logger verbose = ctx.verbose # noqa: F841 @@ -135,7 +140,7 @@ def download_callback(dep_ref, modules_dir, parent_chain=""): callback_failures.add(dep_ref.get_unique_key()) return None result_path = _copy_local_package( - dep_ref, install_path, project_root, logger=logger + dep_ref, install_path, source_root, logger=logger ) if result_path: callback_downloaded[dep_ref.get_unique_key()] = None @@ -200,7 +205,10 @@ def download_callback(dep_ref, modules_dir, parent_chain=""): download_callback=download_callback, ) - dependency_graph = resolver.resolve_dependencies(ctx.apm_dir) + # Resolver reads ``source_root / "apm.yml"`` -- always the source + # root, never the deploy root, so ``apm install --root`` keeps + # finding the manifest in the user's working directory. + dependency_graph = resolver.resolve_dependencies(ctx.source_root) ctx.dependency_graph = dependency_graph # Verbose: show resolved tree summary diff --git a/src/apm_cli/install/pipeline.py b/src/apm_cli/install/pipeline.py index 4f7ccaebc..ab46242e5 100644 --- a/src/apm_cli/install/pipeline.py +++ b/src/apm_cli/install/pipeline.py @@ -17,6 +17,25 @@ * Symbols on the ``commands/install`` module that phases access via ``_install_mod.X`` stay as re-exports there -- this module does NOT duplicate those re-exports. + +Source-vs-deploy root convention +-------------------------------- +:class:`InstallContext` carries two roots; phases must pick the +correct one or ``apm install --root`` silently produces wrong paths +(the bug surfaces only when ``project_root != source_root``). + +* ``ctx.source_root`` -- read sources here (``apm.yml``, ``.apm/`` + primitives, local-path packages). Equal to ``$PWD`` regardless of + ``--root``. +* ``ctx.project_root`` / ``ctx.apm_dir`` -- write deploy artefacts + here (``apm_modules/``, ``apm.lock.yaml``, ``.claude/``, ``.codex/``, + etc.). Becomes the ``--root`` target when set. + +Convention: a phase that *reads* an existing project file uses +``source_root``; a phase that *writes* anything uses ``project_root`` +(or the helper that already does -- e.g. :func:`get_apm_dir`). When +a new field is added to :class:`InstallContext`, the source-vs-write +side must be an explicit, documented choice -- not implicit. """ from __future__ import annotations @@ -182,7 +201,12 @@ def run_install_pipeline( # noqa: PLR0913, RUF100 except ImportError: raise RuntimeError("APM dependency system not available") # noqa: B904 - from ..core.scope import InstallScope, get_apm_dir, get_deploy_root + from ..core.scope import ( + InstallScope, + get_apm_dir, + get_deploy_root, + get_source_root, + ) if scope is None: scope = InstallScope.PROJECT @@ -191,13 +215,16 @@ def run_install_pipeline( # noqa: PLR0913, RUF100 dev_apm_deps = apm_package.get_dev_apm_dependencies() all_apm_deps = apm_deps + dev_apm_deps - project_root = get_deploy_root(scope) + project_root = get_deploy_root(scope) # write target + source_root = get_source_root(scope) # source reads (apm.yml, .apm/) apm_dir = get_apm_dir(scope) - # Check whether the project root itself has local .apm/ primitives (#714). + # Check whether the source root has local .apm/ primitives (#714). + # Sources resolve from $PWD even when --root redirects writes, so the + # check uses source_root rather than project_root. from apm_cli.install.phases.local_content import _project_has_root_primitives - _root_has_local_primitives = _project_has_root_primitives(project_root) + _root_has_local_primitives = _project_has_root_primitives(source_root) # Read old local deployed files from the existing lockfile so the # post-deps-local phase can run stale cleanup even when no current @@ -218,6 +245,7 @@ def run_install_pipeline( # noqa: PLR0913, RUF100 ctx = InstallContext( project_root=project_root, apm_dir=apm_dir, + source_root=source_root, apm_package=apm_package, update_refs=update_refs, verbose=verbose, diff --git a/src/apm_cli/install/root_redirect.py b/src/apm_cli/install/root_redirect.py new file mode 100644 index 000000000..419a338f3 --- /dev/null +++ b/src/apm_cli/install/root_redirect.py @@ -0,0 +1,64 @@ +"""``--root`` (deploy-root redirection) support for ``apm install`` / ``compile``. + +The flag lets users install into an arbitrary directory while keeping +sources in ``$PWD`` -- the precedent is ``pip install --target`` and +``npm install --prefix``. Implementation strategy: + +1. ``os.chdir(root)`` so every site that hardcodes ``Path.cwd()`` / + ``os.getcwd()`` (notably the MCP adapters in + :mod:`apm_cli.adapters.client`) automatically resolves to the deploy + root. Refactoring those sites to use scope helpers would touch a + long tail of files; the chdir trick is contained. +2. :func:`apm_cli.core.scope.set_source_root_override` pins the original + working directory so ``apm.yml``, ``.apm/``, and local-path package + resolution keep reading from ``$PWD``. + +Both effects are reverted on exit so global state never leaks across +CLI invocations (test runners, REPL sessions, embedded callers). +""" + +from __future__ import annotations + +import os +from contextlib import contextmanager +from pathlib import Path +from typing import Iterator, Optional + + +@contextmanager +def install_root_redirect(root: Optional[str | os.PathLike]) -> Iterator[None]: + """Redirect deploy-side writes into *root* for the wrapped block. + + When *root* is ``None`` or empty, this is a no-op so callers can + wrap unconditionally. When set, ensures *root* exists, captures + the current working directory as the source root, ``chdir``s into + *root*, and restores both on exit (success or exception). + """ + if not root: + yield + return + + from ..core.scope import set_source_root_override + + target = Path(root) + target.mkdir(parents=True, exist_ok=True) + original = Path.cwd() + set_source_root_override(original) + os.chdir(target) + try: + yield + finally: + os.chdir(original) + set_source_root_override(None) + + +# ``apm compile --root`` and ``apm install --root`` need exactly the +# same chdir + source-root-pin pair: both commands write into *root* +# while reading sources from the captured ``$PWD``. The alias keeps +# them on a single implementation so the two flags can never drift. +# +# Split the alias into its own ``contextmanager`` only if compile +# develops needs that install doesn't (e.g. compile-only environment +# tweaks, an output-only sandbox). Until then, sharing prevents +# silent divergence. +compile_root_redirect = install_root_redirect diff --git a/src/apm_cli/install/services.py b/src/apm_cli/install/services.py index 28ad48944..540815c61 100644 --- a/src/apm_cli/install/services.py +++ b/src/apm_cli/install/services.py @@ -272,6 +272,7 @@ def integrate_local_content( diagnostics: DiagnosticCollector, logger: InstallLogger | None = None, scope: InstallScope | None = None, + source_root: Path | None = None, ctx: InstallContext | None = None, ) -> dict: """Integrate primitives from the project's own .apm/ directory. @@ -284,20 +285,33 @@ def integrate_local_content( intentionally ignored (it describes the project itself, not a deployable skill). + Args: + project_root: Deploy root -- where ``.claude/``, ``.codex/``, + etc. are written. Also used to compute relative paths for + tracking deployed files. + source_root: Where to discover the synthetic local package's + ``.apm/`` content. Defaults to ``project_root`` when not + provided. When ``apm install --root`` is in play, + ``source_root`` stays at ``$PWD`` while ``project_root`` + points to the override. + Returns a dict with integration counters and deployed file paths, same shape as ``integrate_package_primitives()``. """ from ..models.apm_package import APMPackage, PackageInfo, PackageType + if source_root is None: + source_root = project_root + local_pkg = APMPackage( name="_local", version="0.0.0", - package_path=project_root, + package_path=source_root, source="local", ) local_info = PackageInfo( package=local_pkg, - install_path=project_root, + install_path=source_root, package_type=PackageType.APM_PACKAGE, ) diff --git a/src/apm_cli/install/sources.py b/src/apm_cli/install/sources.py index 50a7f1ba7..19e7805e5 100644 --- a/src/apm_cli/install/sources.py +++ b/src/apm_cli/install/sources.py @@ -152,7 +152,12 @@ def acquire(self) -> Materialization | None: ) return None - result_path = _copy_local_package(dep_ref, install_path, ctx.project_root, logger=logger) + result_path = _copy_local_package( + dep_ref, + install_path, + ctx.source_root, + logger=logger, + ) if not result_path: diagnostics.error( f"Failed to copy local package: {dep_ref.local_path}", diff --git a/tests/unit/install/test_architecture_invariants.py b/tests/unit/install/test_architecture_invariants.py index c2ce7296e..5bb082925 100644 --- a/tests/unit/install/test_architecture_invariants.py +++ b/tests/unit/install/test_architecture_invariants.py @@ -155,6 +155,13 @@ def test_install_py_under_legacy_budget(): Post-rebase (main merged into #999) install.py shrank from 2100 to ~1700 as upstream refactors extracted helpers. Budget tightened to 1800 to track the improvement. + + Issue #888 (``--root``) keeps the budget at 1800. The flag adds + a ``--root`` Click option, a single ``UsageError`` validating its + interaction with ``--global``, and the ``install_root_redirect`` + context manager around the handler body (~16 LOC total). The + redirect itself is fully extracted into + :mod:`apm_cli.install.root_redirect`. """ install_py = Path(__file__).resolve().parents[3] / "src" / "apm_cli" / "commands" / "install.py" assert install_py.is_file() diff --git a/tests/unit/install/test_direct_dep_failure.py b/tests/unit/install/test_direct_dep_failure.py index 31d697f63..a00c6f59a 100644 --- a/tests/unit/install/test_direct_dep_failure.py +++ b/tests/unit/install/test_direct_dep_failure.py @@ -40,6 +40,7 @@ def _make_ctx(tmp_path: Path, dep_keys: list[str]) -> InstallContext: ctx = InstallContext( project_root=project_root, apm_dir=apm_dir, + source_root=project_root, ) ctx.apm_modules_dir = modules ctx.deps_to_install = list(deps) diff --git a/tests/unit/install/test_no_policy_flag.py b/tests/unit/install/test_no_policy_flag.py index 4e9feb0d8..3413ed9a0 100644 --- a/tests/unit/install/test_no_policy_flag.py +++ b/tests/unit/install/test_no_policy_flag.py @@ -634,12 +634,14 @@ def test_install_context_has_no_policy_field(self): ctx = InstallContext( project_root=Path("/tmp/fake"), apm_dir=Path("/tmp/fake/.apm"), + source_root=Path("/tmp/fake"), ) assert ctx.no_policy is False ctx = InstallContext( project_root=Path("/tmp/fake"), apm_dir=Path("/tmp/fake/.apm"), + source_root=Path("/tmp/fake"), no_policy=True, ) assert ctx.no_policy is True