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
822 changes: 592 additions & 230 deletions src/apm_cli/cli.py

Large diffs are not rendered by default.

491 changes: 277 additions & 214 deletions src/apm_cli/compilation/agents_compiler.py

Large diffs are not rendered by default.

83 changes: 53 additions & 30 deletions src/apm_cli/core/target_detection.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,18 @@
1. Explicit --target flag (always wins)
2. apm.yml target setting (top-level field)
3. Auto-detect from existing folders:
- .github/ exists AND .claude/ doesn't → vscode
- .claude/ exists AND .github/ doesn't → claude
- Both exist → all
- Neither exists → minimal (AGENTS.md only, no folder integration)
- only .github/ exists → vscode
- only .claude/ exists → claude
- only .opencode/ exists → opencode
- 2+ integration roots exist → all
- none exist → minimal (AGENTS.md only, no folder integration)
"""

from pathlib import Path
from typing import Literal, Optional, Tuple

# Valid target values
TargetType = Literal["vscode", "claude", "all", "minimal"]
TargetType = Literal["vscode", "opencode", "claude", "all", "minimal"]


def detect_target(
Expand All @@ -27,12 +28,12 @@ def detect_target(
config_target: Optional[str] = None,
) -> Tuple[TargetType, str]:
"""Detect the appropriate target for compilation and integration.

Args:
project_root: Root directory of the project
explicit_target: Explicitly provided --target flag value
config_target: Target from apm.yml top-level 'target' field

Returns:
Tuple of (target, reason) where:
- target: The detected target type
Expand All @@ -42,41 +43,50 @@ def detect_target(
if explicit_target:
if explicit_target in ("vscode", "agents"):
return "vscode", "explicit --target flag"
elif explicit_target == "opencode":
return "opencode", "explicit --target flag"
elif explicit_target == "claude":
return "claude", "explicit --target flag"
elif explicit_target == "all":
return "all", "explicit --target flag"

# Priority 2: apm.yml target setting
if config_target:
if config_target in ("vscode", "agents"):
return "vscode", "apm.yml target"
elif config_target == "opencode":
return "opencode", "apm.yml target"
elif config_target == "claude":
return "claude", "apm.yml target"
elif config_target == "all":
return "all", "apm.yml target"

# Priority 3: Auto-detect from existing folders
github_exists = (project_root / ".github").exists()
claude_exists = (project_root / ".claude").exists()

if github_exists and not claude_exists:
opencode_exists = (project_root / ".opencode").exists()

integration_roots = sum([github_exists, claude_exists, opencode_exists])

if integration_roots >= 2:
return "all", "detected multiple integration folders"
elif github_exists:
return "vscode", "detected .github/ folder"
elif claude_exists and not github_exists:
elif claude_exists:
return "claude", "detected .claude/ folder"
elif github_exists and claude_exists:
return "all", "detected both .github/ and .claude/ folders"
elif opencode_exists:
return "opencode", "detected .opencode/ folder"
else:
# Neither folder exists - minimal output
return "minimal", "no .github/ or .claude/ folder found"
return "minimal", "no .github/, .claude/, or .opencode/ folder found"


def should_integrate_vscode(target: TargetType) -> bool:
"""Check if VSCode integration should be performed.

Args:
target: The detected or configured target

Returns:
bool: True if VSCode integration (prompts, agents) should run
"""
Expand All @@ -85,37 +95,49 @@ def should_integrate_vscode(target: TargetType) -> bool:

def should_integrate_claude(target: TargetType) -> bool:
"""Check if Claude integration should be performed.

Args:
target: The detected or configured target

Returns:
bool: True if Claude integration (commands, skills) should run
"""
return target in ("claude", "all")


def should_integrate_opencode(target: TargetType) -> bool:
"""Check if OpenCode integration should be performed.

Args:
target: The detected or configured target

Returns:
bool: True if OpenCode integration should run
"""
return target in ("opencode", "all")


def should_compile_agents_md(target: TargetType) -> bool:
"""Check if AGENTS.md should be compiled.
AGENTS.md is generated for vscode, all, and minimal targets.

AGENTS.md is generated for vscode, opencode, all, and minimal targets.
It's the universal format that works everywhere.

Args:
target: The detected or configured target

Returns:
bool: True if AGENTS.md should be generated
"""
return target in ("vscode", "all", "minimal")
return target in ("vscode", "opencode", "all", "minimal")


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

Args:
target: The detected or configured target

Returns:
bool: True if CLAUDE.md should be generated
"""
Expand All @@ -124,17 +146,18 @@ def should_compile_claude_md(target: TargetType) -> bool:

def get_target_description(target: TargetType) -> str:
"""Get a human-readable description of what will be generated for a target.

Args:
target: The target type

Returns:
str: Description of output files
"""
descriptions = {
"vscode": "AGENTS.md + .github/prompts/ + .github/agents/",
"opencode": "AGENTS.md + .opencode/",
"claude": "CLAUDE.md + .claude/commands/ + .claude/agents/ + .claude/skills/",
"all": "AGENTS.md + CLAUDE.md + .github/ + .claude/",
"minimal": "AGENTS.md only (create .github/ or .claude/ for full integration)",
"all": "AGENTS.md + CLAUDE.md + .github/ + .claude/ + .opencode/",
"minimal": "AGENTS.md only (create .github/, .claude/, or .opencode/ for full integration)",
}
return descriptions.get(target, "unknown target")
14 changes: 9 additions & 5 deletions src/apm_cli/integration/base_integrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,20 +83,22 @@ def check_collision(
return True

@staticmethod
def normalize_managed_files(managed_files: Optional[Set[str]]) -> Optional[Set[str]]:
def normalize_managed_files(
managed_files: Optional[Set[str]],
) -> Optional[Set[str]]:
"""Normalize path separators once for O(1) lookups."""
if managed_files is None:
return None
return {p.replace("\\", "/") for p in managed_files}

# Known integration prefixes that APM is allowed to deploy/remove under
INTEGRATION_PREFIXES = (".github/", ".claude/")
INTEGRATION_PREFIXES = (".github/", ".claude/", ".opencode/")

@staticmethod
def validate_deploy_path(
rel_path: str,
project_root: Path,
allowed_prefixes: tuple = (".github/", ".claude/"),
allowed_prefixes: tuple = (".github/", ".claude/", ".opencode/"),
) -> bool:
"""Return True if *rel_path* is safe for APM to deploy or remove.

Expand Down Expand Up @@ -143,9 +145,11 @@ def partition_managed_files(
buckets["agents_github"].add(p)
elif p.startswith(".claude/agents/"):
buckets["agents_claude"].add(p)
elif p.startswith(".claude/commands/"):
elif p.startswith((".claude/commands/", ".opencode/commands/")):
buckets["commands"].add(p)
elif p.startswith((".github/skills/", ".claude/skills/")):
elif p.startswith(
(".github/skills/", ".claude/skills/", ".opencode/skills/")
):
buckets["skills"].add(p)
elif p.startswith((".github/hooks/", ".claude/hooks/")):
buckets["hooks"].add(p)
Expand Down
Loading