diff --git a/src/apm_cli/commands/compile/cli.py b/src/apm_cli/commands/compile/cli.py index 1b559eef..400fa8f8 100644 --- a/src/apm_cli/commands/compile/cli.py +++ b/src/apm_cli/commands/compile/cli.py @@ -400,8 +400,9 @@ def compile( config_target=compile_config_target, ) - # Map 'minimal' to 'vscode' for the compiler (AGENTS.md only, no folder integration) - effective_target = detected_target if detected_target != "minimal" else "vscode" + # Keep the detected target intact so the compiler can preserve + # minimal-mode semantics (AGENTS.md only, no .github side outputs). + effective_target = detected_target # Build config with distributed compilation flags (Task 7) config = CompilationConfig.from_apm_yml( diff --git a/src/apm_cli/compilation/agents_compiler.py b/src/apm_cli/compilation/agents_compiler.py index 7dd7d433..953f9c78 100644 --- a/src/apm_cli/compilation/agents_compiler.py +++ b/src/apm_cli/compilation/agents_compiler.py @@ -5,6 +5,7 @@ primitives & constitution are unchanged. """ +import hashlib from dataclasses import dataclass from pathlib import Path from typing import List, Optional, Dict, Any @@ -20,7 +21,13 @@ ) from .link_resolver import resolve_markdown_links, validate_link_targets from ..utils.paths import portable_relpath -from ..core.target_detection import should_compile_agents_md, should_compile_claude_md, should_compile_gemini_md +from ..core.target_detection import ( + should_compile_agents_md, + should_compile_claude_md, + should_compile_copilot_instructions_md, + should_compile_gemini_md, +) +from .constants import BUILD_ID_PLACEHOLDER # User-facing target aliases that map to the canonical "vscode" target. @@ -29,6 +36,7 @@ _KNOWN_TARGETS = ( "vscode", "claude", "cursor", "opencode", "codex", "gemini", "all", "minimal", ) + _VSCODE_TARGET_ALIASES +_COPILOT_ROOT_GENERATED_MARKER = "" @dataclass @@ -279,10 +287,12 @@ def _compile_agents_md(self, config: CompilationConfig, primitives: PrimitiveCol """ # Handle distributed compilation (Task 7 - new default behavior) if config.strategy == "distributed" and not config.single_agents: - return self._compile_distributed(config, primitives) + result = self._compile_distributed(config, primitives) else: # Traditional single-file compilation (backward compatibility) - return self._compile_single_file(config, primitives) + result = self._compile_single_file(config, primitives) + + return self._maybe_emit_copilot_root_instructions(config, primitives, result) def _compile_distributed(self, config: CompilationConfig, primitives: PrimitiveCollection) -> CompilationResult: """Compile using distributed AGENTS.md approach (Task 7). @@ -801,6 +811,152 @@ def _generate_template_data(self, primitives: PrimitiveCollection, config: Compi version=version, chatmode_content=chatmode_content ) + + def _maybe_emit_copilot_root_instructions( + self, + config: CompilationConfig, + primitives: PrimitiveCollection, + result: CompilationResult, + ) -> CompilationResult: + """Generate .github/copilot-instructions.md for Copilot-capable targets.""" + routing_target = "vscode" if config.target in _VSCODE_TARGET_ALIASES else config.target + output_path = self.base_dir / ".github" / "copilot-instructions.md" + if not should_compile_copilot_instructions_md(routing_target): + if not config.dry_run: + self._cleanup_copilot_root_instructions(output_path, result) + result.stats.setdefault("copilot_root_instructions_generated", 0) + result.stats.setdefault("copilot_root_instructions_written", 0) + result.stats.setdefault("copilot_root_instructions_unchanged", 0) + result.stats.setdefault("copilot_root_instructions_removed", 0) + return result + + global_instructions = sorted( + [instruction for instruction in primitives.instructions if not instruction.apply_to], + key=lambda instruction: portable_relpath(instruction.file_path, self.base_dir), + ) + if not global_instructions: + if not config.dry_run: + self._cleanup_copilot_root_instructions(output_path, result) + result.stats.setdefault("copilot_root_instructions_generated", 0) + result.stats.setdefault("copilot_root_instructions_written", 0) + result.stats.setdefault("copilot_root_instructions_unchanged", 0) + result.stats.setdefault("copilot_root_instructions_removed", 0) + return result + + content = self._generate_copilot_root_instructions_content(global_instructions, config) + + result.stats["copilot_root_instructions_generated"] = 1 + result.stats.setdefault("copilot_root_instructions_removed", 0) + + if config.dry_run: + result.stats.setdefault("copilot_root_instructions_written", 0) + result.stats.setdefault("copilot_root_instructions_unchanged", 0) + return result + + from ..security.gate import WARN_POLICY, SecurityGate + + verdict = SecurityGate.scan_text( + content, str(output_path), policy=WARN_POLICY + ) + actionable = verdict.critical_count + verdict.warning_count + if actionable: + if verdict.has_critical: + result.has_critical_security = True + result.warnings.append( + f"copilot-instructions.md contains {actionable} hidden character(s) " + f"-- run 'apm audit --file {output_path}' to inspect" + ) + + try: + output_path.parent.mkdir(parents=True, exist_ok=True) + existing = output_path.read_text(encoding="utf-8") if output_path.exists() else None + if existing == content: + result.stats["copilot_root_instructions_written"] = 0 + result.stats["copilot_root_instructions_unchanged"] = 1 + return result + + output_path.write_text(content, encoding="utf-8") + result.stats["copilot_root_instructions_written"] = 1 + result.stats["copilot_root_instructions_unchanged"] = 0 + return result + except OSError as exc: + message = f"Failed to write {output_path}: {exc}" + self.errors.append(message) + result.errors.append(message) + result.success = False + result.stats["copilot_root_instructions_written"] = 0 + result.stats.setdefault("copilot_root_instructions_unchanged", 0) + return result + + def _generate_copilot_root_instructions_content( + self, + instructions, + config: CompilationConfig, + ) -> str: + """Generate root Copilot instructions content from global instruction primitives.""" + sections = [ + _COPILOT_ROOT_GENERATED_MARKER, + BUILD_ID_PLACEHOLDER, + f"", + "", + ] + + for instruction in instructions: + rel_path = portable_relpath(instruction.file_path, self.base_dir) + sections.append(f"") + sections.append(instruction.content.strip()) + sections.append(f"") + sections.append("") + + sections.append("---") + sections.append("*This file was generated by APM CLI. Do not edit manually.*") + sections.append("*To regenerate: `specify apm compile`*") + sections.append("") + + content = "\n".join(sections) + if config.resolve_links: + content = resolve_markdown_links(content, self.base_dir) + return self._finalize_build_id(content) + + def _finalize_build_id(self, content: str) -> str: + """Replace the build-id placeholder with a deterministic content hash.""" + 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" if content.endswith("\n") else "") + + def _cleanup_copilot_root_instructions( + self, + output_path: Path, + result: CompilationResult, + ) -> CompilationResult: + """Remove stale generated Copilot root instructions when no longer applicable.""" + if not output_path.exists(): + result.stats.setdefault("copilot_root_instructions_removed", 0) + return result + + try: + existing = output_path.read_text(encoding="utf-8") + if _COPILOT_ROOT_GENERATED_MARKER not in existing: + result.stats.setdefault("copilot_root_instructions_removed", 0) + return result + + output_path.unlink() + result.stats["copilot_root_instructions_removed"] = 1 + return result + except OSError as exc: + message = f"Failed to remove stale {output_path}: {exc}" + self.errors.append(message) + result.errors.append(message) + result.success = False + result.stats.setdefault("copilot_root_instructions_removed", 0) + return result def _write_output_file(self, output_path: str, content: str) -> None: """Write the generated content to the output file. @@ -999,4 +1155,4 @@ def compile_agents_md( if not result.success: raise RuntimeError(f"Compilation failed: {'; '.join(result.errors)}") - return result.content \ No newline at end of file + return result.content diff --git a/src/apm_cli/core/target_detection.py b/src/apm_cli/core/target_detection.py index 7f1b12cd..87d8246f 100644 --- a/src/apm_cli/core/target_detection.py +++ b/src/apm_cli/core/target_detection.py @@ -162,6 +162,18 @@ def should_compile_gemini_md(target: TargetType) -> bool: return target in ("gemini", "all") +def should_compile_copilot_instructions_md(target: TargetType) -> bool: + """Check if .github/copilot-instructions.md should be compiled. + + Args: + target: The detected or configured target + + Returns: + bool: True if Copilot root instructions should be generated + """ + return target in ("vscode", "all") + + def get_target_description(target: UserTargetType) -> str: """Get a human-readable description of what will be generated for a target. @@ -176,14 +188,14 @@ def get_target_description(target: UserTargetType) -> str: # Normalize aliases to internal value for lookup normalized = "vscode" if target in ("copilot", "agents") else target descriptions = { - "vscode": "AGENTS.md + .github/prompts/ + .github/agents/", + "vscode": "AGENTS.md + .github/copilot-instructions.md + .github/prompts/ + .github/agents/", "claude": "CLAUDE.md + .claude/commands/ + .claude/agents/ + .claude/skills/", "cursor": ".cursor/agents/ + .cursor/skills/ + .cursor/rules/", "opencode": "AGENTS.md + .opencode/agents/ + .opencode/commands/ + .opencode/skills/", "codex": "AGENTS.md + .agents/skills/ + .codex/agents/ + .codex/hooks.json", "gemini": "GEMINI.md + .gemini/commands/ + .gemini/skills/ + .gemini/settings.json (MCP/hooks)", - "all": "AGENTS.md + CLAUDE.md + GEMINI.md + .github/ + .claude/ + .cursor/ + .opencode/ + .codex/ + .gemini/ + .agents/", - "minimal": "AGENTS.md only (create a target folder for full integration)", + "all": "AGENTS.md + CLAUDE.md + GEMINI.md + .github/copilot-instructions.md + .github/ + .claude/ + .cursor/ + .opencode/ + .codex/ + .gemini/ + .agents/", + "minimal": "AGENTS.md only (create .github/, .claude/, or .gemini/ for full integration)", } return descriptions.get(normalized, "unknown target") diff --git a/src/apm_cli/integration/targets.py b/src/apm_cli/integration/targets.py index 554b6c59..27a54731 100644 --- a/src/apm_cli/integration/targets.py +++ b/src/apm_cli/integration/targets.py @@ -84,6 +84,14 @@ class TargetProfile: target itself is partially supported (e.g. Copilot CLI cannot deploy prompts at user scope).""" + generated_files: Tuple[str, ...] = () + """Additional generated files associated with this target. + + These are compile-time outputs that live at the target root but are not + deployed via primitive integrators, e.g. Copilot's root + ``copilot-instructions.md`` file. + """ + @property def prefix(self) -> str: """Return the path prefix for this target (e.g. ``".github/"``). @@ -181,6 +189,7 @@ def for_scope(self, user_scope: bool = False) -> "TargetProfile | None": user_supported="partial", user_root_dir=".copilot", unsupported_user_primitives=("prompts", "instructions"), + generated_files=("copilot-instructions.md",), ), # Claude Code -- ~/.claude/ is the documented user-level config directory. # All primitives are supported at user scope. diff --git a/tests/integration/test_compile_copilot_root_instructions.py b/tests/integration/test_compile_copilot_root_instructions.py new file mode 100644 index 00000000..21e04618 --- /dev/null +++ b/tests/integration/test_compile_copilot_root_instructions.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + + +CLI = [sys.executable, "-m", "apm_cli.cli", "compile", "--target", "copilot", "--single-agents"] + + +def run_cli(cwd: Path) -> subprocess.CompletedProcess: + return subprocess.run(CLI, cwd=str(cwd), capture_output=True, text=True) + + +def test_compile_emits_copilot_root_instructions_and_is_idempotent(tmp_path: Path): + (tmp_path / "apm.yml").write_text("name: test-project\nversion: 0.1.0\n", encoding="utf-8") + instructions_dir = tmp_path / ".apm" / "instructions" + instructions_dir.mkdir(parents=True) + (instructions_dir / "contributing.instructions.md").write_text( + "---\ndescription: Contributing guide\n---\n\n# Contributing\n\nRun focused tests first.\n", + encoding="utf-8", + ) + + first = run_cli(tmp_path) + assert first.returncode == 0, first.stderr or first.stdout + + copilot_root = tmp_path / ".github" / "copilot-instructions.md" + assert copilot_root.exists() + first_content = copilot_root.read_text(encoding="utf-8") + assert "