Skip to content
Draft
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
697 changes: 360 additions & 337 deletions src/apm_cli/commands/compile/cli.py

Large diffs are not rendered by default.

519 changes: 272 additions & 247 deletions src/apm_cli/commands/install.py

Large diffs are not rendered by default.

49 changes: 37 additions & 12 deletions src/apm_cli/compilation/agents_compiler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -228,15 +236,15 @@ 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:
# Use enhanced discovery with dependencies (Task 4 integration)
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,
)

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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}")
Expand Down Expand Up @@ -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 `<!-- Source: -->`
# 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()
Expand Down Expand Up @@ -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",
Expand Down
68 changes: 57 additions & 11 deletions src/apm_cli/compilation/distributed_compiler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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): []}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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"<!-- Source: {source} {rel_path} -->")

Expand Down
20 changes: 15 additions & 5 deletions src/apm_cli/compilation/template_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,19 +18,28 @@ 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
``<!-- Source: ... -->`` 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.
"""
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)

Expand All @@ -42,15 +51,16 @@ 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:
# Add source file comment before the content
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):
Expand Down
Loading