Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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 @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- `apm compile --watch` now honors `targets: [claude, cursor]` (and every other multi-target / single-target configuration) on every recompile. Previously the watch path bypassed the resolver the one-shot path uses and let `CompilationConfig.from_apm_yml` fall back to the all-families default, silently regenerating `GEMINI.md` after every file edit. The watch path now resolves the effective target via the same helper the one-shot path uses and forwards it as `target=` into both the initial compile and every debounced recompile. (#1345)
- MCP server installation now respects the `targets:` whitelist exactly like `apm install`: drop a non-listed runtime even when its `.cursor/`, `.codex/`, or other on-disk signal exists. Previously the MCP install path called `active_targets()` reading the singular `target:` key only, so projects whitelisting `targets: [copilot]` could still receive `~/.codex/config.toml` and `.cursor/mcp.json` writes from foreign signals. The fix audits both paths: (a) the call site at `local_bundle_handler.py` now forwards the canonical plural list; (b) the gate now delegates to the same `resolve_targets` resolver that backs `apm install` skills, so a malformed `targets:` field (conflicting `target:` + `targets:`, `targets: []`, or unknown target name) fails closed with the same `[x]` red error voice + remediation block. The same delegation closes a related asymmetry: a greenfield project (no `targets:`, no `--target` flag, no detected signals) used to silently fall back to `[copilot]` for MCP-only invocations, while `apm install` raised `NoHarnessError` on the same input -- both surfaces now error consistently. Drop lines now use the `[i] Skipped MCP config for X (active targets: Y)` format mirroring the canonical `Targets: X (source: Y)` provenance line. The `-g`/`--global` carve-out is unchanged: `apm install -g --mcp NAME` writes to user-scope (`~/.config/...`, `~/.codex/`, etc.) bypassing the project-scope gate by design. (#1335)
- Gemini CLI: `apm install -g --mcp NAME` now correctly writes to `~/.gemini/settings.json` (user scope) and `apm install` from outside the target project writes to `<project_root>/.gemini/settings.json` instead of `cwd`. Previously `--global` had no effect on Gemini and project-scope writes silently landed in the wrong directory. The matching opt-in gate and cleanup paths in `MCPIntegrator` are aligned in the same change. (#1299)
- `apm install --target claude` now preserves self-defined stdio MCP `env` values from `apm.yml` and writes non-string values such as `PORT: 3000` and `DEBUG: false` as MCP-compatible strings. (#1222)
Expand Down
75 changes: 73 additions & 2 deletions src/apm_cli/commands/compile/cli.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""APM compile command CLI."""

import sys
from pathlib import Path # noqa: F401
from pathlib import Path

import click

Expand Down Expand Up @@ -252,6 +252,64 @@ def _family_of(name: str) -> str | None:
return target # single string pass-through


def _resolve_effective_target(target):
"""Resolve the CLI --target arg to the compiler-understood effective target.

Mirrors the resolution the one-shot compile path performs (load
apm.yml ``target:`` / ``targets:``, run :func:`_resolve_compile_target`
on both, fall back to :func:`detect_target` for the auto-detect case)
so the watch path can build ``CompilationConfig`` with the same
``target=`` value the one-shot path uses (#1345).

Args:
target: The raw ``--target`` CLI argument (None, str, or list).

Returns:
Tuple ``(effective_target, detection_reason, config_target)`` where
``effective_target`` is what to pass as ``target=`` to
:meth:`CompilationConfig.from_apm_yml`, ``detection_reason`` is the
provenance label, and ``config_target`` is the raw apm.yml value
(str | list | None) for user-facing label rendering.
"""
from ...core.target_detection import detect_target
from ...models.apm_package import APMPackage

config_target = None
apm_yml_path = Path(APM_YML_FILENAME)
if apm_yml_path.exists():
apm_pkg = APMPackage.from_apm_yml(apm_yml_path)
config_target = apm_pkg.target
if config_target is None:
try:
from ...core.apm_yml import parse_targets_field
from ...utils.yaml_io import load_yaml

_raw = load_yaml(apm_yml_path)
if isinstance(_raw, dict):
_yaml_targets = parse_targets_field(_raw)
if _yaml_targets:
config_target = (
_yaml_targets[0] if len(_yaml_targets) == 1 else _yaml_targets
)
except Exception:
pass

compile_target = _resolve_compile_target(target)
compile_config_target = _resolve_compile_target(config_target)

if isinstance(compile_target, frozenset):
return compile_target, "explicit --target flag", config_target
if isinstance(compile_config_target, frozenset) and compile_target is None:
return compile_config_target, "apm.yml target", config_target

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,
)
return detected_target, detection_reason, config_target


@click.command(help="Compile APM context into distributed AGENTS.md files")
@click.option(
"--output",
Expand Down Expand Up @@ -455,7 +513,20 @@ def compile(

# Watch mode
if watch:
_watch_mode(output, chatmode, no_links, dry_run, verbose=verbose)
# Resolve the same effective target the one-shot path uses so
# `targets: [claude, cursor]` does not silently regress to the
# all-families fanout on every recompile (#1345).
effective_target, _detection_reason, config_target = _resolve_effective_target(target)
_watch_mode(
output,
chatmode,
no_links,
dry_run,
verbose=verbose,
effective_target=effective_target,
target_label_user=target,
target_label_config=config_target,
)
return

logger.start("Starting context compilation...", symbol="cogs")
Expand Down
217 changes: 143 additions & 74 deletions src/apm_cli/commands/compile/watcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,106 +7,169 @@
from ...core.command_logger import CommandLogger


def _watch_mode(output, chatmode, no_links, dry_run, verbose=False):
"""Watch for changes in .apm/ directories and auto-recompile."""
def _format_target_label(effective_target, target_label_user, target_label_config):
"""Render a one-shot-parity 'Compiling for ...' label for the watch path.

Mirrors the family-aware label the one-shot compile path emits so the
user sees the same string in watch mode (#1345).
"""
from ...core.target_detection import (
get_target_description,
should_compile_agents_md,
should_compile_claude_md,
should_compile_gemini_md,
)

if isinstance(effective_target, frozenset):
if isinstance(target_label_user, list):
source = f"--target {','.join(target_label_user)}"
elif isinstance(target_label_config, list):
source = f"apm.yml target: [{', '.join(target_label_config)}]"
else:
source = "multi-target"
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")
return f"Compiling for {' + '.join(parts)} ({source})"
if effective_target is None:
return None
return f"Compiling for {get_target_description(effective_target)}"


class APMFileHandler:
"""Watchdog file-system handler that recompiles APM context on edits.

Defined at module scope (rather than inside ``_watch_mode``) so unit
tests can instantiate it without spinning up a watchdog ``Observer``
-- the regression for #1345 lives entirely in the ``from_apm_yml``
call site this class owns.
"""

def __init__(
self,
output,
chatmode,
no_links,
dry_run,
logger,
effective_target=None,
):
self.output = output
self.chatmode = chatmode
self.no_links = no_links
self.dry_run = dry_run
self.logger = logger
self.effective_target = effective_target
self.last_compile = 0
self.debounce_delay = 1.0 # 1 second debounce

def on_modified(self, event):
if getattr(event, "is_directory", False):
return
src_path = getattr(event, "src_path", "")
if not src_path.endswith(".md") and not src_path.endswith(APM_YML_FILENAME):
return
current_time = time.time()
if current_time - self.last_compile < self.debounce_delay:
return
self.last_compile = current_time
self._recompile(src_path)

def _recompile(self, changed_file):
"""Recompile after a file change, honoring the resolved target."""
try:
self.logger.progress(f"File changed: {changed_file}", symbol="eyes")
self.logger.progress("Recompiling...", symbol="gear")

config = CompilationConfig.from_apm_yml(
output_path=self.output if self.output != AGENTS_MD_FILENAME else None,
chatmode=self.chatmode,
resolve_links=not self.no_links if self.no_links else None,
dry_run=self.dry_run,
target=self.effective_target,
)

compiler = AgentsCompiler(".")
result = compiler.compile(config, logger=self.logger)

if result.success:
if self.dry_run:
self.logger.success("Recompilation successful (dry run)", symbol="sparkles")
else:
self.logger.success(f"Recompiled to {result.output_path}", symbol="sparkles")
else:
self.logger.error("Recompilation failed")
for error in result.errors:
self.logger.error(f" {error}")

except Exception as e:
self.logger.error(f"Error during recompilation: {e}")


def _watch_mode(
output,
chatmode,
no_links,
dry_run,
verbose=False,
effective_target=None,
target_label_user=None,
target_label_config=None,
):
"""Watch for changes in .apm/ directories and auto-recompile.

``effective_target`` is the compiler-understood target resolved by
:func:`apm_cli.commands.compile.cli._resolve_effective_target` (the
same resolver the one-shot path uses) and is forwarded as ``target=``
into every :meth:`CompilationConfig.from_apm_yml` call so watch mode
honors ``targets: [claude, cursor]`` instead of silently fanning out
to all families on every recompile (#1345).
"""
logger = CommandLogger("compile-watch", verbose=verbose, dry_run=dry_run)

try:
# Try to import watchdog for file system monitoring
from pathlib import Path

from watchdog.events import FileSystemEventHandler
from watchdog.observers import Observer

class APMFileHandler(FileSystemEventHandler):
def __init__(self, output, chatmode, no_links, dry_run, logger):
self.output = output
self.chatmode = chatmode
self.no_links = no_links
self.dry_run = dry_run
self.logger = logger
self.last_compile = 0
self.debounce_delay = 1.0 # 1 second debounce

def on_modified(self, event):
if event.is_directory:
return
# Only react to relevant files
if not event.src_path.endswith(".md") and not event.src_path.endswith(
APM_YML_FILENAME
):
return
# Debounce rapid changes
current_time = time.time()
if current_time - self.last_compile < self.debounce_delay:
return

self.last_compile = current_time
self._recompile(event.src_path)

def _recompile(self, changed_file):
"""Recompile after file change."""
try:
self.logger.progress(f"File changed: {changed_file}", symbol="eyes")
self.logger.progress("Recompiling...", symbol="gear")

# Create configuration from apm.yml with overrides
config = CompilationConfig.from_apm_yml(
output_path=self.output if self.output != AGENTS_MD_FILENAME else None,
chatmode=self.chatmode,
resolve_links=not self.no_links if self.no_links else None,
dry_run=self.dry_run,
)

# Create compiler and compile
compiler = AgentsCompiler(".")
result = compiler.compile(config, logger=self.logger)

if result.success:
if self.dry_run:
self.logger.success(
"Recompilation successful (dry run)", symbol="sparkles"
)
else:
self.logger.success(
f"Recompiled to {result.output_path}", symbol="sparkles"
)
else:
self.logger.error("Recompilation failed")
for error in result.errors:
self.logger.error(f" {error}")

except Exception as e:
self.logger.error(f"Error during recompilation: {e}")

# Set up file watching
event_handler = APMFileHandler(output, chatmode, no_links, dry_run, logger)
# Adapt the test-friendly module-level handler to watchdog's
# FileSystemEventHandler base so Observer.schedule() accepts it.
class _WatchdogAdapter(APMFileHandler, FileSystemEventHandler):
pass

event_handler = _WatchdogAdapter(
output,
chatmode,
no_links,
dry_run,
logger,
effective_target=effective_target,
)
observer = Observer()

# Watch patterns for APM files
watch_paths = []

# Check for .apm directory
if Path(APM_DIR).exists():
observer.schedule(event_handler, APM_DIR, recursive=True)
watch_paths.append(f"{APM_DIR}/")

# Check for .github/instructions and agents/chatmodes
if Path(".github/instructions").exists():
observer.schedule(event_handler, ".github/instructions", recursive=True)
watch_paths.append(".github/instructions/")

# Watch .github/agents/ (new standard)
if Path(".github/agents").exists():
observer.schedule(event_handler, ".github/agents", recursive=True)
watch_paths.append(".github/agents/")

# Watch .github/chatmodes/ (legacy)
if Path(".github/chatmodes").exists():
observer.schedule(event_handler, ".github/chatmodes", recursive=True)
watch_paths.append(".github/chatmodes/")

# Watch apm.yml if it exists
if Path(APM_YML_FILENAME).exists():
observer.schedule(event_handler, ".", recursive=False)
watch_paths.append(APM_YML_FILENAME)
Expand All @@ -116,19 +179,25 @@ def _recompile(self, changed_file):
logger.progress("Run 'apm init' to create an APM project")
return

# Start watching
observer.start()
logger.progress(f" Watching for changes in: {', '.join(watch_paths)}", symbol="eyes")
logger.progress("Press Ctrl+C to stop watching...", symbol="info")

# Do initial compilation
# Surface the same family-aware label the one-shot path prints so
# users see at-a-glance which AGENTS / CLAUDE / GEMINI files watch
# mode will (re)write (#1345).
label = _format_target_label(effective_target, target_label_user, target_label_config)
if label:
logger.progress(label, symbol="gear")

logger.progress("Performing initial compilation...", symbol="gear")

config = CompilationConfig.from_apm_yml(
output_path=output if output != AGENTS_MD_FILENAME else None,
chatmode=chatmode,
resolve_links=not no_links if no_links else None,
dry_run=dry_run,
target=effective_target,
)

compiler = AgentsCompiler(".")
Expand Down
Empty file.
Loading
Loading