Skip to content
Closed
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed

- Remove redundant `seen` set from `_scan_patterns()` discovery walk (#918)
- Correct targeting of compiled artifacts so GEMINI.md is only created if requested (#1019)

Comment on lines 18 to 20
Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

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

This changelog entry appears to reference the issue number (#1019) rather than the PR that delivered the fix, and it doesn't end with a PR number as required by the project's Keep a Changelog convention. Update the line to end with the actual PR number (likely #1020 and/or this follow-up PR), and consider wrapping GEMINI.md in backticks for consistency with nearby entries.

Copilot generated this review using guidance from repository custom instructions.
## [0.10.0] - 2026-04-27

Expand Down
101 changes: 66 additions & 35 deletions src/apm_cli/commands/compile/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -164,31 +164,42 @@ def _get_validation_suggestion(error_msg):


def _resolve_compile_target(target):
"""Map CLI target input to compiler-understood target string.
"""Map CLI target input to a compiler-understood target.

The compiler understands ``"vscode"``, ``"claude"``, ``"gemini"``,
and ``"all"``. Multi-target lists are mapped to the narrowest
equivalent; any combination of two or more distinct compiler
families collapses to ``"all"``.
The compiler understands single-string targets (``"vscode"``,
``"claude"``, ``"gemini"``, ``"all"``) and ``frozenset`` targets
containing compiler-family names (``"agents"``, ``"claude"``,
``"gemini"``).

Multi-target lists are mapped to the narrowest representation:
a single string when only one compiler family is needed, or a
``frozenset`` of families when multiple are needed. This avoids
collapsing to ``"all"`` (which would incorrectly generate files
for every family).

Args:
target: A single target string, a list of target strings, or ``None``.

Returns:
A single string (or ``None``) suitable for :func:`detect_target`.
A single string, a ``frozenset`` of compiler families, or ``None``.
"""
if target is None:
return None # will trigger detect_target() auto-detection
if isinstance(target, list):
target_set = set(target)
has_agents_family = bool(
target_set & {"copilot", "vscode", "agents", "cursor", "opencode", "codex"}
)
agents_family = {"copilot", "vscode", "agents", "cursor", "opencode", "codex"}
has_agents_family = bool(target_set & agents_family)
has_claude = "claude" in target_set
has_gemini = "gemini" in target_set
distinct = sum([has_agents_family, has_claude, has_gemini])
if distinct >= 2:
return "all"
families = set()
if has_agents_family:
families.add("agents")
if has_claude:
families.add("claude")
if has_gemini:
families.add("gemini")
if len(families) >= 2:
return frozenset(families)
elif has_claude:
return "claude"
elif has_gemini:
Expand Down Expand Up @@ -377,7 +388,11 @@ def compile(
logger.start("Starting context compilation...", symbol="cogs")

# Auto-detect target if not explicitly provided
from ...core.target_detection import detect_target, get_target_description
from ...core.target_detection import (
REASON_NO_TARGET_FOLDER,
detect_target,
get_target_description,
)

# Get config target from apm.yml if available
config_target = None
Expand All @@ -390,18 +405,27 @@ def compile(
# No apm.yml or parsing error - proceed with auto-detection
pass

# Resolve list targets to compiler-understood string
# 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)
detected_target, detection_reason = detect_target(
project_root=Path("."),
explicit_target=compile_target,
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"
# 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:
detected_target, detection_reason = detect_target(
project_root=Path("."),
explicit_target=compile_target,
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"

# Build config with distributed compilation flags (Task 7)
config = CompilationConfig.from_apm_yml(
Expand All @@ -426,26 +450,33 @@ def compile(
if isinstance(target, list):
# Multi-target list: show what the compiler will produce
_target_label = ",".join(target)
if effective_target == "all":
logger.progress(
f"Compiling for AGENTS.md + CLAUDE.md (--target {_target_label})"
)
elif effective_target == "claude":
logger.progress(
f"Compiling for CLAUDE.md (--target {_target_label})"
)
else:
logger.progress(
f"Compiling for AGENTS.md (--target {_target_label})"
)
elif detected_target == "minimal":
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 {_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:
description = get_target_description(detected_target)
description = get_target_description(effective_target)
logger.progress(
f"Compiling for {description} - {detection_reason}"
Comment on lines 450 to 481
Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

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

When effective_target comes from apm.yml as a multi-target list (so _resolve_compile_target(config_target) returns a frozenset) and the CLI --target flag is not a list, this branch falls through to get_target_description(effective_target), which will render as "unknown target" in the progress log. Consider handling isinstance(effective_target, frozenset) (regardless of the original target type) by building the AGENTS.md/CLAUDE.md/GEMINI.md parts list the same way as the isinstance(target, list) branch, so config-driven multi-targets get an accurate progress message.

Copilot uses AI. Check for mistakes.
)
Expand Down
65 changes: 47 additions & 18 deletions src/apm_cli/compilation/agents_compiler.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
)
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_gemini_md, CompileTargetType

_logger = logging.getLogger(__name__)

Expand All @@ -33,6 +33,11 @@
"vscode", "claude", "cursor", "opencode", "codex", "gemini", "all", "minimal",
) + _VSCODE_TARGET_ALIASES

# Compiler families allowed inside a multi-target frozenset (built by
# _resolve_compile_target() from CLI-validated target names). Kept narrow
# because the frozenset path bypasses _KNOWN_TARGETS validation.
_KNOWN_COMPILE_FAMILIES = frozenset({"agents", "claude", "gemini"})


@dataclass
class CompilationConfig:
Expand All @@ -47,7 +52,8 @@ class CompilationConfig:
# "vscode" or "agents" -> AGENTS.md + .github/
# "claude" -> CLAUDE.md + .claude/
# "all" -> both targets
target: str = "all"
# frozenset({"agents","claude"}) -> AGENTS.md + CLAUDE.md (multi-target)
target: CompileTargetType = "all"

# Distributed compilation settings (Task 7)
strategy: str = "distributed" # "distributed" or "single-file"
Expand Down Expand Up @@ -214,24 +220,47 @@ def compile(self, config: CompilationConfig, primitives: Optional[PrimitiveColle
# Use target_detection helpers as the single source of truth so
# new targets (codex, opencode, cursor, minimal, ...) route
# correctly without touching this method again.
routing_target = (
"vscode" if config.target in _VSCODE_TARGET_ALIASES else config.target
)

if routing_target not in _KNOWN_TARGETS and config.target not in _KNOWN_TARGETS:
self.errors.append(
f"Unknown compilation target: {config.target!r}. "
f"Expected one of: {', '.join(sorted(set(_KNOWN_TARGETS)))}"
)
return CompilationResult(
success=False,
output_path="",
content="",
warnings=self.warnings.copy(),
errors=self.errors.copy(),
stats={},
if isinstance(config.target, frozenset):
# Multi-target lists are normalized by _resolve_compile_target()
# into compiler families only. Validate defensively for direct
# API callers so invalid families do not silently produce
# partial output or a successful no-op.
invalid_families = config.target - _KNOWN_COMPILE_FAMILIES
if invalid_families:
self.errors.append(
"Unknown compilation target family in multi-target set: "
f"{', '.join(sorted(invalid_families))}. "
"Expected subset of: "
f"{', '.join(sorted(_KNOWN_COMPILE_FAMILIES))}"
)
return CompilationResult(
success=False,
output_path="",
content="",
warnings=self.warnings.copy(),
errors=self.errors.copy(),
stats={},
)
routing_target = config.target
else:
routing_target = (
"vscode" if config.target in _VSCODE_TARGET_ALIASES else config.target
)

if routing_target not in _KNOWN_TARGETS and config.target not in _KNOWN_TARGETS:
self.errors.append(
f"Unknown compilation target: {config.target!r}. "
f"Expected one of: {', '.join(sorted(set(_KNOWN_TARGETS)))}"
)
return CompilationResult(
success=False,
output_path="",
content="",
warnings=self.warnings.copy(),
errors=self.errors.copy(),
stats={},
)

results: List[CompilationResult] = []

if should_compile_agents_md(routing_target):
Expand Down
36 changes: 27 additions & 9 deletions src/apm_cli/core/target_detection.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,15 @@
# Valid target values (internal canonical form)
TargetType = Literal["vscode", "claude", "cursor", "opencode", "codex", "gemini", "all", "minimal"]

# Compile target: either a single TargetType string or a frozenset of compiler
# families ({"agents", "claude", "gemini"}) for multi-target lists.
CompileTargetType = Union[str, frozenset[str]]

# Detection reason returned by detect_target() when no integration folder is
# present. Exported as a constant so consumers can compare with equality
# instead of substring matching.
REASON_NO_TARGET_FOLDER = "no target folder found"

# User-facing target values (includes aliases accepted by CLI)
UserTargetType = Literal["copilot", "vscode", "agents", "claude", "cursor", "opencode", "codex", "gemini", "all", "minimal"]

Expand Down Expand Up @@ -120,45 +129,54 @@ def detect_target(
elif gemini_exists:
return "gemini", "detected .gemini/ folder"
else:
return "minimal", "no target folder found"
return "minimal", REASON_NO_TARGET_FOLDER


def should_compile_agents_md(target: TargetType) -> bool:
def should_compile_agents_md(target: CompileTargetType) -> bool:
"""Check if AGENTS.md should be compiled.

AGENTS.md is generated for vscode, codex, gemini, all, and minimal
targets. Gemini needs it because GEMINI.md imports AGENTS.md.

Args:
target: The detected or configured target

target: The detected or configured target. May be a string or a
frozenset of compiler families for multi-target lists.

Returns:
bool: True if AGENTS.md should be generated
"""
if isinstance(target, frozenset):
return "agents" in target or "gemini" in target
return target in ("vscode", "opencode", "codex", "gemini", "all", "minimal")


def should_compile_claude_md(target: TargetType) -> bool:
def should_compile_claude_md(target: CompileTargetType) -> bool:
"""Check if CLAUDE.md should be compiled.

Args:
target: The detected or configured target
target: The detected or configured target. May be a string or a
frozenset of compiler families for multi-target lists.

Returns:
bool: True if CLAUDE.md should be generated
"""
if isinstance(target, frozenset):
return "claude" in target
return target in ("claude", "all")


def should_compile_gemini_md(target: TargetType) -> bool:
def should_compile_gemini_md(target: CompileTargetType) -> bool:
"""Check if GEMINI.md should be compiled.

Args:
target: The detected or configured target
target: The detected or configured target. May be a string or a
frozenset of compiler families for multi-target lists.

Returns:
bool: True if GEMINI.md should be generated
"""
if isinstance(target, frozenset):
return "gemini" in target
return target in ("gemini", "all")


Expand Down
Loading