Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions src/apm_cli/commands/compile/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines +403 to +405
Copy link

Copilot AI Apr 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now that effective_target can remain minimal, make sure every subsequent compile pass in this command uses the same target. In the legacy --single-agents path, an intermediate CompilationConfig(...) is constructed without target=... (so it defaults to all), which can unexpectedly route through CLAUDE compilation and potentially alter what gets injected/written to AGENTS.md. Propagating effective_target (or avoiding the second compile) would keep minimal-mode semantics intact.

Copilot uses AI. Check for mistakes.

# Build config with distributed compilation flags (Task 7)
config = CompilationConfig.from_apm_yml(
Expand Down
164 changes: 160 additions & 4 deletions src/apm_cli/compilation/agents_compiler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand All @@ -29,6 +36,7 @@
_KNOWN_TARGETS = (
"vscode", "claude", "cursor", "opencode", "codex", "gemini", "all", "minimal",
) + _VSCODE_TARGET_ALIASES
_COPILOT_ROOT_GENERATED_MARKER = "<!-- Generated by APM CLI from .apm/ primitives -->"


@dataclass
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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
Comment on lines +875 to +879
Copy link

Copilot AI Apr 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The write path for .github/copilot-instructions.md will overwrite an existing manually-authored file whenever global instructions exist (the only preservation logic is in cleanup, not generation). This risks clobbering user content and contradicts the repo/docs expectation that existing config files are not modified. Consider only writing when the file is missing or already marked as APM-generated (marker present); otherwise, warn and skip (or require an explicit force flag).

Copilot uses AI. Check for mistakes.
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:
Comment on lines +891 to +895
Copy link

Copilot AI Apr 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_generate_copilot_root_instructions_content() adds new public-ish helper logic but leaves the instructions parameter untyped. Please add an explicit type (e.g., a Sequence/List of Instruction primitives) to match the codebase's type-hinting guideline for new/changed code.

Copilot generated this review using guidance from repository custom instructions.
"""Generate root Copilot instructions content from global instruction primitives."""
sections = [
_COPILOT_ROOT_GENERATED_MARKER,
BUILD_ID_PLACEHOLDER,
f"<!-- APM Version: {get_version()} -->",
"",
]

for instruction in instructions:
rel_path = portable_relpath(instruction.file_path, self.base_dir)
sections.append(f"<!-- Source: {rel_path} -->")
sections.append(instruction.content.strip())
sections.append(f"<!-- End source: {rel_path} -->")
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"<!-- Build ID: {build_id} -->"
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.
Expand Down Expand Up @@ -999,4 +1155,4 @@ def compile_agents_md(
if not result.success:
raise RuntimeError(f"Compilation failed: {'; '.join(result.errors)}")

return result.content
return result.content
18 changes: 15 additions & 3 deletions src/apm_cli/core/target_detection.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines +165 to +169
Copy link

Copilot AI Apr 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR changes apm compile behavior by generating (and sometimes removing) .github/copilot-instructions.md. Per the docs-update rule, please update the Starlight docs (notably docs/src/content/docs/guides/compilation.md and the CLI reference) and the apm-guide skill resources (packages/apm-guide/.apm/skills/apm-usage/commands.md) to reflect the new output file and its cleanup/overwrite semantics.

Copilot generated this review using guidance from repository custom instructions.

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.

Expand All @@ -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/",
Comment on lines 190 to 192
Copy link

Copilot AI Apr 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

get_target_description() is used by apm compile logging, but the vscode/all descriptions currently list .github/prompts/ and .github/agents/ as generated outputs. Those are installed/deployed by apm install integrators, not produced by apm compile (agents_compiler only emits AGENTS.md/CLAUDE.md and now copilot-instructions.md). Please adjust the description strings (or wording) so the compile command doesn't claim it is generating install-time directories.

Copilot uses AI. Check for mistakes.
"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")

Expand Down
9 changes: 9 additions & 0 deletions src/apm_cli/integration/targets.py
Original file line number Diff line number Diff line change
Expand Up @@ -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/"``).
Expand Down Expand Up @@ -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.
Expand Down
64 changes: 64 additions & 0 deletions tests/integration/test_compile_copilot_root_instructions.py
Original file line number Diff line number Diff line change
@@ -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 "<!-- Build ID: " in first_content
assert "# Contributing" in first_content
assert "Run focused tests first." in first_content

second = run_cli(tmp_path)
assert second.returncode == 0, second.stderr or second.stdout
second_content = copilot_root.read_text(encoding="utf-8")

assert first_content == second_content


def test_compile_removes_stale_root_file_when_only_scoped_rules_remain(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)
instruction_file = instructions_dir / "contributing.instructions.md"

instruction_file.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()

instruction_file.write_text(
"---\napplyTo: \"**/*.py\"\ndescription: Python guide\n---\n\nUse type hints.\n",
encoding="utf-8",
)
second = run_cli(tmp_path)
assert second.returncode == 0, second.stderr or second.stdout

assert not copilot_root.exists()
Loading