Skip to content
Merged
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 @@ -27,6 +27,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `apm marketplace build` now respects `GITHUB_HOST` for GitHub Enterprise repos -- ref resolution, token lookup, and metadata fetch all use the configured host instead of hardcoded `github.com`. `git ls-remote` is authenticated so private repos work without separate credential setup. (#1008)
- `apm marketplace build` now accepts multiple Git URL forms (GitHub, GHES, GitLab, Bitbucket, ADO, SSH) for `type: url` parsing via `DependencyReference.parse()`. Host resolution is still driven by `GITHUB_HOST`, so non-`github.com` hosts require `GITHUB_HOST` to be set accordingly. (#1008)
- **ADO Entra ID auth path no longer silently fails.** Bearer tokens from `az account get-access-token` are now correctly plumbed through validation (auth scheme, git env). Auth failures raise a typed `AuthenticationError` with an actionable 4-case diagnostic instead of the ambiguous "not accessible or doesn't exist" message. `apm install --update` runs a pre-flight auth check before modifying any files -- on failure it aborts with "No files were modified". (#1015)
- Correct targeting of compiled artifacts so GEMINI.md is only created if requested (#1019)

## [0.10.0] - 2026-04-27

Expand Down
114 changes: 77 additions & 37 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. When the file is
# absent we proceed with auto-detection; when it is present but
Expand All @@ -393,18 +408,30 @@ def compile(
apm_pkg = APMPackage.from_apm_yml(apm_yml_path)
config_target = apm_pkg.target

# 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:
# 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,
)
Comment on lines +421 to +432
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.

detect_target() is typed to accept Optional[str] for config_target, but here compile_config_target can be a frozenset (from _resolve_compile_target() when apm.yml specifies a multi-target list and the user also passed a single-string --target). It happens to work today, but it's an implicit type contract violation and makes the control flow harder to reason about. Consider passing config_target=compile_config_target only when it's a string (otherwise None).

Copilot uses AI. Check for mistakes.
# 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,29 +453,42 @@ def compile(
# 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(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})"
)
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:
logger.progress(
f"Compiling for AGENTS.md (--target {_target_label})"
)
elif detected_target == "minimal":
_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:
description = get_target_description(detected_target)
description = get_target_description(effective_target)
logger.progress(
f"Compiling for {description} - {detection_reason}"
)
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
41 changes: 32 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,20 @@
# Valid target values (internal canonical form)
TargetType = Literal["vscode", "claude", "cursor", "opencode", "codex", "gemini", "all", "minimal"]

# Compiler families used inside a multi-target frozenset. Narrower than
# TargetType because the families are produced by _resolve_compile_target()
# (in the compile CLI) from CLI-validated target names.
CompileFamily = Literal["agents", "claude", "gemini"]

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

Comment on lines +37 to +40
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.

CompileTargetType = Union[str, frozenset[str]] is too permissive (any string/frozenset member type). This weakens type checking and makes it easier for invalid targets/families to flow into should_compile_* and callers. Consider tightening this to Union[TargetType, frozenset[Literal['agents','claude','gemini']]] (or define a CompileFamily Literal and use TypeAlias) so mypy/pyright can catch mistakes early.

See below for a potential fix:

from typing import List, Literal, Optional, Tuple, TypeAlias, Union

import click

# Valid target values (internal canonical form)
TargetType = Literal["vscode", "claude", "cursor", "opencode", "codex", "gemini", "all", "minimal"]

# Valid compiler families used when a multi-target list is reduced to the
# corresponding compilation outputs.
CompileFamily = Literal["agents", "claude", "gemini"]

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

Copilot uses AI. Check for mistakes.
# 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 +134,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
Loading