diff --git a/src/apm_cli/commands/compile.py b/src/apm_cli/commands/compile.py index f7ac5b1e4..372abea1f 100644 --- a/src/apm_cli/commands/compile.py +++ b/src/apm_cli/commands/compile.py @@ -250,9 +250,9 @@ def _recompile(self, changed_file): @click.option( "--target", "-t", - type=click.Choice(["vscode", "agents", "claude", "all"]), + type=click.Choice(["vscode", "agents", "claude", "opencode", "all"]), default=None, - help="Target platform: vscode/agents (AGENTS.md), claude (CLAUDE.md), or all. Auto-detects if not specified.", + help="Target platform: vscode/agents (AGENTS.md), opencode (AGENTS.md + .opencode/), claude (CLAUDE.md), or all. Auto-detects if not specified.", ) @click.option( "--dry-run", @@ -316,6 +316,7 @@ def compile( Target platforms: * vscode/agents: Generates AGENTS.md + .github/ structure (VSCode/GitHub Copilot) + * opencode: Generates AGENTS.md + .opencode/ structure (OpenCode) * claude: Generates CLAUDE.md + .claude/ structure (Claude Code) * all: Generates both targets (default) @@ -467,6 +468,8 @@ def compile( _rich_info( f"Compiling for AGENTS.md (VSCode/Copilot) - {detection_reason}" ) + elif detected_target == "opencode": + _rich_info(f"Compiling for AGENTS.md (OpenCode) - {detection_reason}") elif detected_target == "claude": _rich_info( f"Compiling for CLAUDE.md (Claude Code) - {detection_reason}" diff --git a/src/apm_cli/commands/install.py b/src/apm_cli/commands/install.py index 6b726572a..a99c66e5c 100644 --- a/src/apm_cli/commands/install.py +++ b/src/apm_cli/commands/install.py @@ -878,6 +878,7 @@ def _collect_descendants(node, visited=None): detect_target, should_integrate_vscode, should_integrate_claude, + should_integrate_opencode, get_target_description, ) @@ -888,10 +889,11 @@ def _collect_descendants(node, visited=None): # Per skill-strategy Decision 1, .github/skills/ is the standard skills location; # creating .github/ here ensures a consistent skills root and also enables # VSCode/Copilot integration by default (quick path to value), even for - # projects that don't yet use .claude/. + # projects that don't yet use .claude/ or .opencode/. github_dir = project_root / ".github" claude_dir = project_root / ".claude" - if not github_dir.exists() and not claude_dir.exists(): + opencode_dir = project_root / ".opencode" + if not github_dir.exists() and not claude_dir.exists() and not opencode_dir.exists(): github_dir.mkdir(parents=True, exist_ok=True) _rich_info( "Created .github/ as standard skills root (.github/skills/) and to enable VSCode/Copilot integration" @@ -906,6 +908,7 @@ def _collect_descendants(node, visited=None): # Determine which integrations to run based on detected target integrate_vscode = should_integrate_vscode(detected_target) integrate_claude = should_integrate_claude(detected_target) + integrate_opencode = should_integrate_opencode(detected_target) # Initialize integrators prompt_integrator = PromptIntegrator() @@ -1232,7 +1235,7 @@ def _collect_descendants(node, visited=None): installed_count += 1 # Still need to integrate prompts for cached packages (zero-config behavior) - if integrate_vscode or integrate_claude: + if integrate_vscode or integrate_claude or integrate_opencode: try: # Create PackageInfo from cached package from apm_cli.models.apm_package import ( @@ -1312,7 +1315,7 @@ def _collect_descendants(node, visited=None): package_types[dep_key] = cached_package_info.package_type.value # VSCode + Claude integration (prompts + agents) - if integrate_vscode or integrate_claude: + if integrate_vscode or integrate_claude or integrate_opencode: # Integrate prompts prompt_result = ( prompt_integrator.integrate_package_prompts( @@ -1363,7 +1366,7 @@ def _collect_descendants(node, visited=None): # Skill integration (works for both VSCode and Claude) # Skills go to .github/skills/ (primary) and .claude/skills/ (if .claude/ exists) - if integrate_vscode or integrate_claude: + if integrate_vscode or integrate_claude or integrate_opencode: skill_result = skill_integrator.integrate_package_skill( cached_package_info, project_root ) @@ -1380,6 +1383,28 @@ def _collect_descendants(node, visited=None): for tp in skill_result.target_paths: dep_deployed.append(tp.relative_to(project_root).as_posix()) + if integrate_opencode: + skill_result_opencode = ( + skill_integrator.integrate_package_skill_opencode( + cached_package_info, project_root, + diagnostics=diagnostics, + ) + ) + if skill_result_opencode.skill_created: + total_skills_integrated += 1 + _rich_info( + " └─ Skill integrated → .opencode/skills/" + ) + if skill_result_opencode.sub_skills_promoted > 0: + total_sub_skills_promoted += ( + skill_result_opencode.sub_skills_promoted + ) + _rich_info( + f" └─ {skill_result_opencode.sub_skills_promoted} skill(s) integrated → .opencode/skills/" + ) + for tp in skill_result_opencode.target_paths: + dep_deployed.append(tp.relative_to(project_root).as_posix()) + # Integrate instructions → .github/instructions/ if integrate_vscode: instruction_result = ( @@ -1444,6 +1469,42 @@ def _collect_descendants(node, visited=None): for tp in command_result.target_paths: dep_deployed.append(tp.relative_to(project_root).as_posix()) + # OpenCode-specific integration (agents + commands) + if integrate_opencode: + opencode_agent_result = ( + agent_integrator.integrate_package_agents_opencode( + cached_package_info, project_root, + force=force, managed_files=managed_files, + diagnostics=diagnostics, + ) + ) + if opencode_agent_result.files_integrated > 0: + total_agents_integrated += ( + opencode_agent_result.files_integrated + ) + _rich_info( + f" └─ {opencode_agent_result.files_integrated} agents integrated → .opencode/agents/" + ) + total_links_resolved += opencode_agent_result.links_resolved + for tp in opencode_agent_result.target_paths: + dep_deployed.append(tp.relative_to(project_root).as_posix()) + + opencode_command_result = command_integrator.integrate_package_commands_opencode( + cached_package_info, project_root, + force=force, managed_files=managed_files, + diagnostics=diagnostics, + ) + if opencode_command_result.files_integrated > 0: + total_commands_integrated += ( + opencode_command_result.files_integrated + ) + _rich_info( + f" └─ {opencode_command_result.files_integrated} commands integrated → .opencode/commands/" + ) + total_links_resolved += opencode_command_result.links_resolved + for tp in opencode_command_result.target_paths: + dep_deployed.append(tp.relative_to(project_root).as_posix()) + # Hook integration (target-aware) if integrate_vscode: hook_result = hook_integrator.integrate_package_hooks( @@ -1562,7 +1623,7 @@ def _collect_descendants(node, visited=None): _rich_info(f" └─ Package type: APM Package (apm.yml)") # Auto-integrate prompts and agents if enabled - if integrate_vscode or integrate_claude: + if integrate_vscode or integrate_claude or integrate_opencode: try: # Integrate prompts + agents (dual-target: .github/ + .claude/) # Integrate prompts @@ -1615,7 +1676,7 @@ def _collect_descendants(node, visited=None): # Skill integration (works for both VSCode and Claude) # Skills go to .github/skills/ (primary) and .claude/skills/ (if .claude/ exists) - if integrate_vscode or integrate_claude: + if integrate_vscode or integrate_claude or integrate_opencode: skill_result = skill_integrator.integrate_package_skill( package_info, project_root ) @@ -1632,6 +1693,28 @@ def _collect_descendants(node, visited=None): for tp in skill_result.target_paths: dep_deployed_fresh.append(tp.relative_to(project_root).as_posix()) + if integrate_opencode: + skill_result_opencode = ( + skill_integrator.integrate_package_skill_opencode( + package_info, project_root, + diagnostics=diagnostics, + ) + ) + if skill_result_opencode.skill_created: + total_skills_integrated += 1 + _rich_info( + " └─ Skill integrated → .opencode/skills/" + ) + if skill_result_opencode.sub_skills_promoted > 0: + total_sub_skills_promoted += ( + skill_result_opencode.sub_skills_promoted + ) + _rich_info( + f" └─ {skill_result_opencode.sub_skills_promoted} skill(s) integrated → .opencode/skills/" + ) + for tp in skill_result_opencode.target_paths: + dep_deployed_fresh.append(tp.relative_to(project_root).as_posix()) + # Integrate instructions → .github/instructions/ if integrate_vscode: instruction_result = ( @@ -1696,6 +1779,42 @@ def _collect_descendants(node, visited=None): for tp in command_result.target_paths: dep_deployed_fresh.append(tp.relative_to(project_root).as_posix()) + # OpenCode-specific integration (agents + commands) + if integrate_opencode: + opencode_agent_result = ( + agent_integrator.integrate_package_agents_opencode( + package_info, project_root, + force=force, managed_files=managed_files, + diagnostics=diagnostics, + ) + ) + if opencode_agent_result.files_integrated > 0: + total_agents_integrated += ( + opencode_agent_result.files_integrated + ) + _rich_info( + f" └─ {opencode_agent_result.files_integrated} agents integrated → .opencode/agents/" + ) + total_links_resolved += opencode_agent_result.links_resolved + for tp in opencode_agent_result.target_paths: + dep_deployed_fresh.append(tp.relative_to(project_root).as_posix()) + + opencode_command_result = command_integrator.integrate_package_commands_opencode( + package_info, project_root, + force=force, managed_files=managed_files, + diagnostics=diagnostics, + ) + if opencode_command_result.files_integrated > 0: + total_commands_integrated += ( + opencode_command_result.files_integrated + ) + _rich_info( + f" └─ {opencode_command_result.files_integrated} commands integrated → .opencode/commands/" + ) + total_links_resolved += opencode_command_result.links_resolved + for tp in opencode_command_result.target_paths: + dep_deployed_fresh.append(tp.relative_to(project_root).as_posix()) + # Hook integration (target-aware) if integrate_vscode: hook_result = hook_integrator.integrate_package_hooks( diff --git a/src/apm_cli/commands/uninstall.py b/src/apm_cli/commands/uninstall.py index 99a523502..5200d06c0 100644 --- a/src/apm_cli/commands/uninstall.py +++ b/src/apm_cli/commands/uninstall.py @@ -446,6 +446,7 @@ def _find_transitive_orphans(lockfile, removed_urls): from ..core.target_detection import ( detect_target, should_integrate_claude, + should_integrate_opencode, ) config_target = apm_package.target detected_target, _ = detect_target( @@ -454,6 +455,7 @@ def _find_transitive_orphans(lockfile, removed_urls): config_target=config_target, ) integrate_claude = should_integrate_claude(detected_target) + integrate_opencode = should_integrate_opencode(detected_target) prompt_integrator = PromptIntegrator() agent_integrator = AgentIntegrator() @@ -489,9 +491,14 @@ def _find_transitive_orphans(lockfile, removed_urls): agent_integrator.integrate_package_agents(pkg_info, project_root) if integrate_claude: agent_integrator.integrate_package_agents_claude(pkg_info, project_root) + if integrate_opencode: + agent_integrator.integrate_package_agents_opencode(pkg_info, project_root) skill_integrator.integrate_package_skill(pkg_info, project_root) if integrate_claude: command_integrator.integrate_package_commands(pkg_info, project_root) + if integrate_opencode: + command_integrator.integrate_package_commands_opencode(pkg_info, project_root) + skill_integrator.integrate_package_skill_opencode(pkg_info, project_root) hook_integrator_reint.integrate_package_hooks(pkg_info, project_root) if integrate_claude: hook_integrator_reint.integrate_package_hooks_claude(pkg_info, project_root) diff --git a/src/apm_cli/compilation/agents_compiler.py b/src/apm_cli/compilation/agents_compiler.py index 97cb3bb9d..154bbef12 100644 --- a/src/apm_cli/compilation/agents_compiler.py +++ b/src/apm_cli/compilation/agents_compiler.py @@ -188,8 +188,8 @@ def compile(self, config: CompilationConfig, primitives: Optional[PrimitiveColle # Route to targets based on config.target results: List[CompilationResult] = [] - # AGENTS.md target (vscode/agents) - if config.target in ("vscode", "agents", "all"): + # AGENTS.md target (vscode/agents/opencode) + if config.target in ("vscode", "agents", "opencode", "all"): results.append(self._compile_agents_md(config, primitives)) # CLAUDE.md target diff --git a/src/apm_cli/core/target_detection.py b/src/apm_cli/core/target_detection.py index 5f524ce46..d0a7509e6 100644 --- a/src/apm_cli/core/target_detection.py +++ b/src/apm_cli/core/target_detection.py @@ -1,16 +1,17 @@ """Target detection for auto-selecting compilation and integration targets. This module implements the auto-detection pattern for determining which agent -targets (VSCode/Copilot vs Claude) should be used based on existing project -structure and configuration. +targets (VSCode/Copilot, Claude, or OpenCode) should be used based on existing +project structure and configuration. Detection priority (highest to lowest): 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 -> copilot (internal: "vscode") - - .claude/ exists AND .github/ doesn't -> claude - - Both exist -> all + - .github/ only -> copilot (internal: "vscode") + - .claude/ only -> claude + - .opencode/ only -> opencode + - Multiple target folders exist -> all - Neither exists -> minimal (AGENTS.md only, no folder integration) "copilot" is the recommended user-facing target name. "vscode" and "agents" @@ -20,11 +21,11 @@ from pathlib import Path from typing import Literal, Optional, Tuple -# Valid target values (internal canonical form) -TargetType = Literal["vscode", "claude", "all", "minimal"] +# Valid target values +TargetType = Literal["vscode", "claude", "opencode", "all", "minimal"] # User-facing target values (includes aliases accepted by CLI) -UserTargetType = Literal["copilot", "vscode", "agents", "claude", "all", "minimal"] +UserTargetType = Literal["copilot", "vscode", "agents", "claude", "opencode", "all", "minimal"] def detect_target( @@ -50,31 +51,44 @@ def detect_target( return "vscode", "explicit --target flag" elif explicit_target == "claude": return "claude", "explicit --target flag" + elif explicit_target == "opencode": + return "opencode", "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 ("copilot", "vscode", "agents"): return "vscode", "apm.yml target" elif config_target == "claude": return "claude", "apm.yml target" + elif config_target == "opencode": + return "opencode", "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: - return "vscode", "detected .github/ folder" - elif claude_exists and not github_exists: - return "claude", "detected .claude/ folder" + opencode_exists = (project_root / ".opencode").exists() + + if github_exists and claude_exists and opencode_exists: + return "all", "detected all target folders (.github/, .claude/, .opencode/)" elif github_exists and claude_exists: return "all", "detected both .github/ and .claude/ folders" + elif github_exists and opencode_exists: + return "all", "detected both .github/ and .opencode/ folders" + elif claude_exists and opencode_exists: + return "all", "detected both .claude/ and .opencode/ folders" + elif github_exists: + return "vscode", "detected .github/ folder" + elif claude_exists: + return "claude", "detected .claude/ folder" + elif opencode_exists: + return "opencode", "detected .opencode/ folder" else: - # Neither folder exists - minimal output - return "minimal", "no .github/ or .claude/ folder found" + # No known target folders exist - minimal output + return "minimal", "no .github/, .claude/, or .opencode/ folder found" def should_integrate_vscode(target: TargetType) -> bool: @@ -101,10 +115,22 @@ def should_integrate_claude(target: TargetType) -> bool: 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: @@ -113,7 +139,7 @@ def should_compile_agents_md(target: TargetType) -> bool: 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: @@ -144,7 +170,8 @@ def get_target_description(target: UserTargetType) -> str: descriptions = { "vscode": "AGENTS.md + .github/prompts/ + .github/agents/", "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)", + "opencode": "AGENTS.md + .opencode/agents/ + .opencode/commands/ + .opencode/skills/", + "all": "AGENTS.md + CLAUDE.md + .github/ + .claude/ + .opencode/", + "minimal": "AGENTS.md only (create .github/, .claude/, or .opencode/ for full integration)", } return descriptions.get(normalized, "unknown target") diff --git a/src/apm_cli/integration/agent_integrator.py b/src/apm_cli/integration/agent_integrator.py index 7cedf1245..035cd548c 100644 --- a/src/apm_cli/integration/agent_integrator.py +++ b/src/apm_cli/integration/agent_integrator.py @@ -203,6 +203,13 @@ def get_target_filename_claude(self, source_file: Path, package_name: str) -> st return f"{stem}.md" + def get_target_filename_opencode(self, source_file: Path, package_name: str) -> str: + """Generate target filename for OpenCode agents. + + OpenCode agents use plain .md files in .opencode/agents/. + """ + return self.get_target_filename_claude(source_file, package_name) + def integrate_package_agents_claude(self, package_info, project_root: Path, force: bool = False, managed_files: set = None, @@ -265,6 +272,58 @@ def integrate_package_agents_claude(self, package_info, project_root: Path, links_resolved=total_links_resolved ) + def integrate_package_agents_opencode(self, package_info, project_root: Path, + force: bool = False, + managed_files: set = None, + diagnostics=None) -> IntegrationResult: + """Integrate all agents from a package into .opencode/agents/.""" + self.init_link_resolver(package_info, project_root) + + # Find all agent files in the package + agent_files = self.find_agent_files(package_info.install_path) + + if not agent_files: + return IntegrationResult( + files_integrated=0, + files_updated=0, + files_skipped=0, + target_paths=[], + ) + + # Create .opencode/agents/ if it doesn't exist + agents_dir = project_root / ".opencode" / "agents" + agents_dir.mkdir(parents=True, exist_ok=True) + + # Process each agent file + files_integrated = 0 + files_skipped = 0 + target_paths = [] + total_links_resolved = 0 + + for source_file in agent_files: + target_filename = self.get_target_filename_opencode( + source_file, package_info.package.name + ) + target_path = agents_dir / target_filename + rel_path = str(target_path.relative_to(project_root)) + + if self.check_collision(target_path, rel_path, managed_files, force, diagnostics=diagnostics): + files_skipped += 1 + continue + + links_resolved = self.copy_agent(source_file, target_path) + total_links_resolved += links_resolved + files_integrated += 1 + target_paths.append(target_path) + + return IntegrationResult( + files_integrated=files_integrated, + files_updated=0, + files_skipped=files_skipped, + target_paths=target_paths, + links_resolved=total_links_resolved + ) + def sync_integration(self, apm_package, project_root: Path, managed_files: set = None) -> Dict[str, int]: """Remove APM-managed agent files from .github/agents/.""" @@ -289,4 +348,14 @@ def sync_integration_claude(self, apm_package, project_root: Path, legacy_glob_pattern="*-apm.md", ) - + def sync_integration_opencode(self, apm_package, project_root: Path, + managed_files: set = None) -> Dict[str, int]: + """Remove APM-managed agent files from .opencode/agents/.""" + agents_dir = project_root / ".opencode" / "agents" + return self.sync_remove_files( + project_root, + managed_files, + prefix=".opencode/agents/", + legacy_glob_dir=agents_dir, + legacy_glob_pattern="*-apm.md", + ) diff --git a/src/apm_cli/integration/base_integrator.py b/src/apm_cli/integration/base_integrator.py index fcc7f5b0e..0ea248cb3 100644 --- a/src/apm_cli/integration/base_integrator.py +++ b/src/apm_cli/integration/base_integrator.py @@ -94,13 +94,13 @@ def normalize_managed_files(managed_files: Optional[Set[str]]) -> Optional[Set[s 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. @@ -135,7 +135,9 @@ def partition_managed_files( "prompts": set(), "agents_github": set(), "agents_claude": set(), + "agents_opencode": set(), "commands": set(), + "commands_opencode": set(), "skills": set(), "hooks": set(), "instructions": set(), @@ -147,9 +149,13 @@ def partition_managed_files( buckets["agents_github"].add(p) elif p.startswith(".claude/agents/"): buckets["agents_claude"].add(p) + elif p.startswith(".opencode/agents/"): + buckets["agents_opencode"].add(p) elif p.startswith(".claude/commands/"): buckets["commands"].add(p) - elif p.startswith((".github/skills/", ".claude/skills/")): + elif p.startswith(".opencode/commands/"): + buckets["commands_opencode"].add(p) + elif p.startswith((".github/skills/", ".claude/skills/", ".opencode/skills/")): buckets["skills"].add(p) elif p.startswith((".github/hooks/", ".claude/hooks/")): buckets["hooks"].add(p) diff --git a/src/apm_cli/integration/command_integrator.py b/src/apm_cli/integration/command_integrator.py index 432817849..e676075af 100644 --- a/src/apm_cli/integration/command_integrator.py +++ b/src/apm_cli/integration/command_integrator.py @@ -169,6 +169,72 @@ def sync_integration(self, apm_package, project_root: Path, legacy_glob_pattern="*-apm.md", ) + def integrate_package_commands_opencode(self, package_info, project_root: Path, + force: bool = False, + managed_files: set = None, + diagnostics=None) -> IntegrationResult: + """Integrate all prompt files from a package as OpenCode commands.""" + commands_dir = project_root / ".opencode" / "commands" + prompt_files = self.find_prompt_files(package_info.install_path) + + if not prompt_files: + return IntegrationResult( + files_integrated=0, + files_updated=0, + files_skipped=0, + target_paths=[], + links_resolved=0 + ) + + self.init_link_resolver(package_info, project_root) + + files_integrated = 0 + files_skipped = 0 + target_paths = [] + total_links_resolved = 0 + + for prompt_file in prompt_files: + # Generate clean command name (no suffix) + filename = prompt_file.name + if filename.endswith('.prompt.md'): + base_name = filename[:-len('.prompt.md')] + else: + base_name = prompt_file.stem + + target_path = commands_dir / f"{base_name}.md" + rel_path = str(target_path.relative_to(project_root)) + + if self.check_collision(target_path, rel_path, managed_files, force, diagnostics=diagnostics): + files_skipped += 1 + continue + + links_resolved = self.integrate_command( + prompt_file, target_path, package_info, prompt_file + ) + files_integrated += 1 + total_links_resolved += links_resolved + target_paths.append(target_path) + + return IntegrationResult( + files_integrated=files_integrated, + files_updated=0, + files_skipped=files_skipped, + target_paths=target_paths, + links_resolved=total_links_resolved + ) + + def sync_integration_opencode(self, apm_package, project_root: Path, + managed_files: set = None) -> Dict: + """Remove APM-managed command files from .opencode/commands/.""" + commands_dir = project_root / ".opencode" / "commands" + return self.sync_remove_files( + project_root, + managed_files, + prefix=".opencode/commands/", + legacy_glob_dir=commands_dir, + legacy_glob_pattern="*-apm.md", + ) + def remove_package_commands(self, package_name: str, project_root: Path, managed_files: set = None) -> int: """Remove APM-managed command files. diff --git a/src/apm_cli/integration/skill_integrator.py b/src/apm_cli/integration/skill_integrator.py index 621649ba7..eacbd4e1e 100644 --- a/src/apm_cli/integration/skill_integrator.py +++ b/src/apm_cli/integration/skill_integrator.py @@ -246,44 +246,47 @@ def copy_skill_to_target( package_info, source_path: Path, target_base: Path, -) -> tuple[Path | None, Path | None]: - """Copy skill directory to .github/skills/ and optionally .claude/skills/. - +) -> tuple[Path | None, Path | None, Path | None]: + """Copy skill directory to .github/skills/ and optionally .claude/skills/ and .opencode/skills/. + This is a standalone function for direct skill copy operations. It handles: - Package type routing via should_install_skill() - Skill name validation/normalization - Directory structure preservation - Compatibility copy to .claude/skills/ when .claude/ exists (T7) - - Source SKILL.md is copied verbatim -- no metadata injection. - + - Compatibility copy to .opencode/skills/ when .opencode/ exists (T8) + + Source SKILL.md is copied verbatim -- no metadata injection. + + Copies: - SKILL.md (required) - scripts/ (optional) - references/ (optional) - assets/ (optional) - Any other subdirectories the package contains - + Args: package_info: PackageInfo object with package metadata source_path: Path to skill in apm_modules/ target_base: Usually project root - + Returns: - Tuple of (github_path, claude_path): + Tuple of (github_path, claude_path, opencode_path): - github_path: Path to .github/skills/{name}/ or None if skipped - claude_path: Path to .claude/skills/{name}/ or None if .claude/ doesn't exist + - opencode_path: Path to .opencode/skills/{name}/ or None if .opencode/ doesn't exist """ # Check if package type allows skill installation (T4 routing) if not should_install_skill(package_info): - return (None, None) - + return (None, None, None) + # Check for SKILL.md existence source_skill_md = source_path / "SKILL.md" if not source_skill_md.exists(): # No SKILL.md means this package is handled by compilation, not skill copy - return (None, None) + return (None, None, None) # Get and validate skill name from folder raw_skill_name = source_path.name @@ -326,8 +329,18 @@ def copy_skill_to_target( # Copy the entire skill folder (identical to github copy) shutil.copytree(source_path, claude_skill_dir) - - return (github_skill_dir, claude_skill_dir) + + # === T8: .opencode/skills/ (OpenCode compatibility copy) === + opencode_skill_dir: Path | None = None + opencode_dir = target_base / ".opencode" + if opencode_dir.exists() and opencode_dir.is_dir(): + opencode_skill_dir = opencode_dir / "skills" / skill_name + opencode_skill_dir.parent.mkdir(parents=True, exist_ok=True) + if opencode_skill_dir.exists(): + shutil.rmtree(opencode_skill_dir) + shutil.copytree(source_path, opencode_skill_dir) + + return (github_skill_dir, claude_skill_dir, opencode_skill_dir) class SkillIntegrator(BaseIntegrator): @@ -552,6 +565,26 @@ def _promote_sub_skills_standalone( return count, all_deployed + def _promote_sub_skills_standalone_opencode( + self, package_info, project_root: Path, diagnostics=None + ) -> tuple[int, list[Path]]: + """Promote sub-skills from a non-skill package into .opencode/skills/.""" + package_path = package_info.install_path + sub_skills_dir = package_path / ".apm" / "skills" + if not sub_skills_dir.is_dir(): + return 0, [] + + parent_name = package_path.name + opencode_dir = project_root / ".opencode" + if not opencode_dir.exists() or not opencode_dir.is_dir(): + return 0, [] + + opencode_skills_root = opencode_dir / "skills" + count, deployed = self._promote_sub_skills( + sub_skills_dir, opencode_skills_root, parent_name, warn=False + ) + return count, list(deployed) + def _integrate_native_skill( self, package_info, project_root: Path, source_skill_md: Path, diagnostics=None, @@ -750,7 +783,107 @@ def integrate_package_skill(self, package_info, project_root: Path, diagnostics= sub_skills_promoted=sub_skills_count, target_paths=sub_deployed ) - + + def integrate_package_skill_opencode( + self, package_info, project_root: Path, diagnostics=None + ) -> SkillIntegrationResult: + """Integrate a package's skill into .opencode/skills/ only.""" + if not should_install_skill(package_info): + sub_skills_count, sub_deployed = ( + self._promote_sub_skills_standalone_opencode(package_info, project_root, diagnostics=diagnostics) + ) + return SkillIntegrationResult( + skill_created=False, + skill_updated=False, + skill_skipped=True, + skill_path=None, + references_copied=0, + links_resolved=0, + sub_skills_promoted=sub_skills_count, + target_paths=sub_deployed, + ) + + # Skip virtual FILE/COLLECTION packages; allow virtual subdirectories + if package_info.dependency_ref and package_info.dependency_ref.is_virtual: + if not package_info.dependency_ref.is_virtual_subdirectory(): + return SkillIntegrationResult( + skill_created=False, + skill_updated=False, + skill_skipped=True, + skill_path=None, + references_copied=0, + links_resolved=0, + ) + + package_path = package_info.install_path + source_skill_md = package_path / "SKILL.md" + if not source_skill_md.exists(): + sub_skills_count, sub_deployed = ( + self._promote_sub_skills_standalone_opencode(package_info, project_root, diagnostics=diagnostics) + ) + return SkillIntegrationResult( + skill_created=False, + skill_updated=False, + skill_skipped=True, + skill_path=None, + references_copied=0, + links_resolved=0, + sub_skills_promoted=sub_skills_count, + target_paths=sub_deployed, + ) + + opencode_dir = project_root / ".opencode" + if not opencode_dir.exists() or not opencode_dir.is_dir(): + return SkillIntegrationResult( + skill_created=False, + skill_updated=False, + skill_skipped=True, + skill_path=None, + references_copied=0, + links_resolved=0, + sub_skills_promoted=0, + target_paths=[], + ) + + raw_skill_name = package_path.name + is_valid, _ = validate_skill_name(raw_skill_name) + skill_name = ( + raw_skill_name if is_valid else normalize_skill_name(raw_skill_name) + ) + + opencode_skill_dir = opencode_dir / "skills" / skill_name + opencode_skill_md = opencode_skill_dir / "SKILL.md" + skill_created = not opencode_skill_dir.exists() + + if opencode_skill_dir.exists(): + shutil.rmtree(opencode_skill_dir) + + opencode_skill_dir.parent.mkdir(parents=True, exist_ok=True) + shutil.copytree( + package_path, opencode_skill_dir, ignore=shutil.ignore_patterns(".apm") + ) + + files_copied = sum(1 for _ in opencode_skill_dir.rglob("*") if _.is_file()) + all_target_paths = [opencode_skill_dir] + + sub_skills_dir = package_path / ".apm" / "skills" + opencode_skills_root = opencode_dir / "skills" + sub_skills_count, sub_deployed = self._promote_sub_skills( + sub_skills_dir, opencode_skills_root, skill_name, warn=False + ) + all_target_paths.extend(sub_deployed) + + return SkillIntegrationResult( + skill_created=skill_created, + skill_updated=not skill_created, + skill_skipped=False, + skill_path=opencode_skill_md, + references_copied=files_copied, + links_resolved=0, + sub_skills_promoted=sub_skills_count, + target_paths=all_target_paths, + ) + def sync_integration(self, apm_package, project_root: Path, managed_files: set = None) -> Dict[str, int]: """Sync .github/skills/ and .claude/skills/ with currently installed packages. @@ -776,6 +909,7 @@ def sync_integration(self, apm_package, project_root: Path, is_skill = ( rel_path.startswith(".github/skills/") or rel_path.startswith(".claude/skills/") + or rel_path.startswith(".opencode/skills/") ) if not is_skill or ".." in rel_path: continue @@ -824,7 +958,16 @@ def sync_integration(self, apm_package, project_root: Path, result = self._clean_orphaned_skills(claude_skills_dir, installed_skill_names) stats['files_removed'] += result['files_removed'] stats['errors'] += result['errors'] - + + # Clean .opencode/skills/ (OpenCode compatibility) + opencode_skills_dir = project_root / ".opencode" / "skills" + if opencode_skills_dir.exists(): + result = self._clean_orphaned_skills( + opencode_skills_dir, installed_skill_names + ) + stats['files_removed'] += result['files_removed'] + stats['errors'] += result['errors'] + return stats def _clean_orphaned_skills(self, skills_dir: Path, installed_skill_names: set) -> Dict[str, int]: diff --git a/tests/unit/core/test_target_detection.py b/tests/unit/core/test_target_detection.py index ca110dd67..09c8a9afa 100644 --- a/tests/unit/core/test_target_detection.py +++ b/tests/unit/core/test_target_detection.py @@ -4,6 +4,7 @@ detect_target, should_integrate_vscode, should_integrate_claude, + should_integrate_opencode, should_compile_agents_md, should_compile_claude_md, get_target_description, @@ -70,6 +71,20 @@ def test_explicit_target_all_wins(self, tmp_path): assert target == "all" assert reason == "explicit --target flag" + def test_explicit_target_opencode_wins(self, tmp_path): + """Explicit --target opencode always wins.""" + (tmp_path / ".github").mkdir() + (tmp_path / ".claude").mkdir() + + target, reason = detect_target( + project_root=tmp_path, + explicit_target="opencode", + config_target="all", + ) + + assert target == "opencode" + assert reason == "explicit --target flag" + def test_config_target_copilot(self, tmp_path): """Config target copilot maps to vscode.""" target, reason = detect_target( @@ -103,6 +118,17 @@ def test_config_target_claude(self, tmp_path): assert target == "claude" assert reason == "apm.yml target" + def test_config_target_opencode(self, tmp_path): + """Config target opencode is used when no explicit target.""" + target, reason = detect_target( + project_root=tmp_path, + explicit_target=None, + config_target="opencode", + ) + + assert target == "opencode" + assert reason == "apm.yml target" + def test_config_target_all(self, tmp_path): """Config target all is used when no explicit target.""" target, reason = detect_target( @@ -154,6 +180,34 @@ def test_auto_detect_both_folders(self, tmp_path): assert target == "all" assert "both" in reason + def test_auto_detect_opencode_only(self, tmp_path): + """Auto-detect opencode when only .opencode/ exists.""" + (tmp_path / ".opencode").mkdir() + + target, reason = detect_target( + project_root=tmp_path, + explicit_target=None, + config_target=None, + ) + + assert target == "opencode" + assert "detected .opencode/ folder" in reason + + def test_auto_detect_all_three_folders(self, tmp_path): + """Auto-detect all when .github/, .claude/, and .opencode/ exist.""" + (tmp_path / ".github").mkdir() + (tmp_path / ".claude").mkdir() + (tmp_path / ".opencode").mkdir() + + target, reason = detect_target( + project_root=tmp_path, + explicit_target=None, + config_target=None, + ) + + assert target == "all" + assert "all target folders" in reason + def test_auto_detect_neither_folder(self, tmp_path): """Auto-detect minimal when neither folder exists.""" target, reason = detect_target( @@ -161,9 +215,9 @@ def test_auto_detect_neither_folder(self, tmp_path): explicit_target=None, config_target=None, ) - + assert target == "minimal" - assert "no .github/ or .claude/" in reason + assert "no .github/, .claude/, or .opencode/" in reason class TestShouldIntegrateVscode: @@ -206,6 +260,30 @@ def test_minimal_target(self): assert should_integrate_claude("minimal") is False +class TestShouldIntegrateOpencode: + """Tests for should_integrate_opencode function.""" + + def test_opencode_target(self): + """OpenCode integration enabled for opencode target.""" + assert should_integrate_opencode("opencode") is True + + def test_all_target(self): + """OpenCode integration enabled for all target.""" + assert should_integrate_opencode("all") is True + + def test_vscode_target(self): + """OpenCode integration disabled for vscode target.""" + assert should_integrate_opencode("vscode") is False + + def test_claude_target(self): + """OpenCode integration disabled for claude target.""" + assert should_integrate_opencode("claude") is False + + def test_minimal_target(self): + """OpenCode integration disabled for minimal target.""" + assert should_integrate_opencode("minimal") is False + + class TestShouldCompileAgentsMd: """Tests for should_compile_agents_md function.""" @@ -213,6 +291,10 @@ def test_vscode_target(self): """AGENTS.md compiled for vscode target.""" assert should_compile_agents_md("vscode") is True + def test_opencode_target(self): + """AGENTS.md compiled for opencode target.""" + assert should_compile_agents_md("opencode") is True + def test_all_target(self): """AGENTS.md compiled for all target.""" assert should_compile_agents_md("all") is True @@ -241,6 +323,10 @@ def test_vscode_target(self): """CLAUDE.md not compiled for vscode target.""" assert should_compile_claude_md("vscode") is False + def test_opencode_target(self): + """CLAUDE.md not compiled for opencode target.""" + assert should_compile_claude_md("opencode") is False + def test_minimal_target(self): """CLAUDE.md not compiled for minimal target.""" assert should_compile_claude_md("minimal") is False @@ -273,6 +359,12 @@ def test_all_description(self): assert "AGENTS.md" in desc assert "CLAUDE.md" in desc + def test_opencode_description(self): + """Description for opencode target.""" + desc = get_target_description("opencode") + assert "AGENTS.md" in desc + assert ".opencode/" in desc + def test_minimal_description(self): """Description for minimal target.""" desc = get_target_description("minimal") diff --git a/tests/unit/integration/test_agent_integrator.py b/tests/unit/integration/test_agent_integrator.py index 48f5d96f1..d125cc429 100644 --- a/tests/unit/integration/test_agent_integrator.py +++ b/tests/unit/integration/test_agent_integrator.py @@ -782,6 +782,239 @@ def test_sync_integration_claude_removes_apm_agents(self): def test_sync_integration_claude_handles_missing_dir(self): """Test sync handles missing .claude/agents/ gracefully.""" result = self.integrator.sync_integration_claude(None, self.project_root) - + assert result['files_removed'] == 0 assert result['errors'] == 0 + + def test_get_target_filename_opencode_from_agent_md(self): + source = Path("security.agent.md") + result = self.integrator.get_target_filename_opencode(source, "pkg") + assert result == "security.md" + + def test_integrate_creates_opencode_agents_directory(self): + package_dir = self.project_root / "package" + package_dir.mkdir() + (package_dir / "security.agent.md").write_text("# Security Agent") + + package_info = self._create_package_info(package_dir) + result = self.integrator.integrate_package_agents_opencode( + package_info, self.project_root + ) + + assert result.files_integrated == 1 + assert (self.project_root / ".opencode" / "agents").exists() + + def test_integrate_copies_agent_to_opencode_agents(self): + package_dir = self.project_root / "package" + package_dir.mkdir() + (package_dir / "security.agent.md").write_text("# Security Agent") + + package_info = self._create_package_info(package_dir) + result = self.integrator.integrate_package_agents_opencode( + package_info, self.project_root + ) + + assert result.files_integrated == 1 + target_file = self.project_root / ".opencode" / "agents" / "security.md" + assert target_file.exists() + + def test_sync_integration_opencode_removes_apm_agents(self): + agents_dir = self.project_root / ".opencode" / "agents" + agents_dir.mkdir(parents=True) + (agents_dir / "security-apm.md").write_text("# APM managed") + (agents_dir / "custom.md").write_text("# User created") + + result = self.integrator.sync_integration_opencode(None, self.project_root) + + assert result["files_removed"] == 1 + assert not (agents_dir / "security-apm.md").exists() + assert (agents_dir / "custom.md").exists() + + +class TestOpenCodeAgentCollisionAndForce: + """Collision detection and force-overwrite tests for OpenCode agent integration. + + These mirror the Claude collision tests to ensure parity. + """ + + def setup_method(self): + """Set up test fixtures.""" + self.temp_dir = tempfile.mkdtemp() + self.project_root = Path(self.temp_dir) + self.integrator = AgentIntegrator() + + def teardown_method(self): + """Clean up after tests.""" + import shutil + shutil.rmtree(self.temp_dir, ignore_errors=True) + + def _create_package_info(self, package_dir): + """Helper to create a PackageInfo object.""" + package = APMPackage( + name="test-pkg", + version="1.0.0", + package_path=package_dir + ) + resolved_ref = ResolvedReference( + original_ref="main", + ref_type=GitReferenceType.BRANCH, + resolved_commit="abc123", + ref_name="main" + ) + return PackageInfo( + package=package, + install_path=package_dir, + resolved_reference=resolved_ref, + installed_at=datetime.now().isoformat() + ) + + # ========== Collision: no manifest (managed_files=None) ========== + + def test_overwrites_when_no_manifest(self): + """Without managed_files (no manifest), overwrites existing files.""" + package_dir = self.project_root / "package" + package_dir.mkdir() + (package_dir / "security.agent.md").write_text("# New version") + + agents_dir = self.project_root / ".opencode" / "agents" + agents_dir.mkdir(parents=True) + (agents_dir / "security.md").write_text("# Old version") + + package_info = self._create_package_info(package_dir) + result = self.integrator.integrate_package_agents_opencode( + package_info, self.project_root + ) + + assert result.files_integrated == 1 + assert result.files_skipped == 0 + assert (agents_dir / "security.md").read_text() == "# New version" + + # ========== Collision: user-authored file skipped ========== + + def test_skips_user_file_collision(self): + """Skips user-authored file when managed_files says it's not APM-owned.""" + package_dir = self.project_root / "package" + package_dir.mkdir() + (package_dir / "security.agent.md").write_text("# APM version") + + agents_dir = self.project_root / ".opencode" / "agents" + agents_dir.mkdir(parents=True) + (agents_dir / "security.md").write_text("# User version") + + package_info = self._create_package_info(package_dir) + # managed_files is empty set — security.md not in it → user-authored + result = self.integrator.integrate_package_agents_opencode( + package_info, self.project_root, managed_files=set() + ) + + assert result.files_integrated == 0 + assert result.files_skipped == 1 + assert (agents_dir / "security.md").read_text() == "# User version" + + # ========== Collision: managed file is overwritten ========== + + def test_overwrites_managed_file(self): + """Overwrites file when managed_files includes it (APM-owned).""" + package_dir = self.project_root / "package" + package_dir.mkdir() + (package_dir / "security.agent.md").write_text("# Updated APM version") + + agents_dir = self.project_root / ".opencode" / "agents" + agents_dir.mkdir(parents=True) + (agents_dir / "security.md").write_text("# Old APM version") + + package_info = self._create_package_info(package_dir) + managed = {".opencode/agents/security.md"} + result = self.integrator.integrate_package_agents_opencode( + package_info, self.project_root, managed_files=managed + ) + + assert result.files_integrated == 1 + assert result.files_skipped == 0 + assert (agents_dir / "security.md").read_text() == "# Updated APM version" + + # ========== Force flag overrides collision ========== + + def test_force_overwrites_user_file(self): + """Force flag overrides collision detection.""" + package_dir = self.project_root / "package" + package_dir.mkdir() + (package_dir / "security.agent.md").write_text("# APM version") + + agents_dir = self.project_root / ".opencode" / "agents" + agents_dir.mkdir(parents=True) + (agents_dir / "security.md").write_text("# User version") + + package_info = self._create_package_info(package_dir) + result = self.integrator.integrate_package_agents_opencode( + package_info, self.project_root, force=True, managed_files=set() + ) + + assert result.files_integrated == 1 + assert result.files_skipped == 0 + assert (agents_dir / "security.md").read_text() == "# APM version" + + # ========== Sync removes stale files ========== + + def test_sync_removes_stale_apm_agents(self): + """Sync removes APM-managed agents from .opencode/agents/.""" + agents_dir = self.project_root / ".opencode" / "agents" + agents_dir.mkdir(parents=True) + (agents_dir / "security-apm.md").write_text("# APM managed") + (agents_dir / "planner-apm.md").write_text("# APM managed") + (agents_dir / "custom.md").write_text("# User created") + + result = self.integrator.sync_integration_opencode(None, self.project_root) + + assert result["files_removed"] == 2 + assert not (agents_dir / "security-apm.md").exists() + assert not (agents_dir / "planner-apm.md").exists() + assert (agents_dir / "custom.md").exists() # Preserved + + def test_sync_handles_missing_opencode_dir(self): + """Sync handles missing .opencode/agents/ gracefully.""" + result = self.integrator.sync_integration_opencode(None, self.project_root) + + assert result["files_removed"] == 0 + assert result["errors"] == 0 + + def test_sync_with_managed_files_removes_tracked_files(self): + """Sync removes files listed in managed_files from .opencode/agents/.""" + agents_dir = self.project_root / ".opencode" / "agents" + agents_dir.mkdir(parents=True) + (agents_dir / "security.md").write_text("# APM managed") + (agents_dir / "custom.md").write_text("# User created") + + managed = {".opencode/agents/security.md"} + result = self.integrator.sync_integration_opencode( + None, self.project_root, managed_files=managed + ) + + assert result["files_removed"] == 1 + assert not (agents_dir / "security.md").exists() + assert (agents_dir / "custom.md").exists() + + # ========== Multiple agents: partial collision ========== + + def test_partial_collision_skips_only_conflicting(self): + """With multiple agents, only the colliding user-authored one is skipped.""" + package_dir = self.project_root / "package" + package_dir.mkdir() + (package_dir / "security.agent.md").write_text("# Security") + (package_dir / "planner.agent.md").write_text("# Planner") + + agents_dir = self.project_root / ".opencode" / "agents" + agents_dir.mkdir(parents=True) + # Pre-create security.md as a user file + (agents_dir / "security.md").write_text("# User's security agent") + + package_info = self._create_package_info(package_dir) + # managed_files is empty → security.md is user-authored, planner.md is new + result = self.integrator.integrate_package_agents_opencode( + package_info, self.project_root, managed_files=set() + ) + + assert result.files_integrated == 1 # Only planner + assert result.files_skipped == 1 # Security skipped + assert (agents_dir / "security.md").read_text() == "# User's security agent" + assert (agents_dir / "planner.md").read_text() == "# Planner" diff --git a/tests/unit/integration/test_skill_integrator.py b/tests/unit/integration/test_skill_integrator.py index 43e019269..adaf3906d 100644 --- a/tests/unit/integration/test_skill_integrator.py +++ b/tests/unit/integration/test_skill_integrator.py @@ -1026,7 +1026,7 @@ def test_copy_skill_preserves_skill_md_content_exactly(self): ) # Copy skill to target - github_path, _ = copy_skill_to_target(package_info, skill_source, self.project_root) + github_path, _, _ = copy_skill_to_target(package_info, skill_source, self.project_root) assert github_path is not None target_skill_md = github_path / "SKILL.md" @@ -1063,7 +1063,7 @@ def test_copy_skill_copies_scripts_directory(self): install_path=skill_source ) - github_path, _ = copy_skill_to_target(package_info, skill_source, self.project_root) + github_path, _, _ = copy_skill_to_target(package_info, skill_source, self.project_root) assert github_path is not None assert (github_path / "scripts").exists() @@ -1091,7 +1091,7 @@ def test_copy_skill_copies_references_directory(self): install_path=skill_source ) - github_path, _ = copy_skill_to_target(package_info, skill_source, self.project_root) + github_path, _, _ = copy_skill_to_target(package_info, skill_source, self.project_root) assert github_path is not None assert (github_path / "references").exists() @@ -1116,7 +1116,7 @@ def test_copy_skill_copies_assets_directory(self): install_path=skill_source ) - github_path, _ = copy_skill_to_target(package_info, skill_source, self.project_root) + github_path, _, _ = copy_skill_to_target(package_info, skill_source, self.project_root) assert github_path is not None assert (github_path / "assets").exists() @@ -1149,7 +1149,7 @@ def test_copy_skill_copies_all_subdirectories(self): install_path=skill_source ) - github_path, _ = copy_skill_to_target(package_info, skill_source, self.project_root) + github_path, _, _ = copy_skill_to_target(package_info, skill_source, self.project_root) assert github_path is not None assert (github_path / "SKILL.md").exists() @@ -1172,7 +1172,7 @@ def test_copy_skill_validates_skill_name(self): install_path=skill_source ) - github_path, _ = copy_skill_to_target(package_info, skill_source, self.project_root) + github_path, _, _ = copy_skill_to_target(package_info, skill_source, self.project_root) assert github_path is not None assert github_path.name == "valid-skill-name" @@ -1189,7 +1189,7 @@ def test_copy_skill_normalizes_invalid_skill_name(self): install_path=skill_source ) - github_path, _ = copy_skill_to_target(package_info, skill_source, self.project_root) + github_path, _, _ = copy_skill_to_target(package_info, skill_source, self.project_root) assert github_path is not None # Name should be normalized to hyphen-case lowercase @@ -1216,7 +1216,7 @@ def test_copy_skill_updates_existing_skill(self): install_path=skill_source ) - github_path, _ = copy_skill_to_target(package_info, skill_source, self.project_root) + github_path, _, _ = copy_skill_to_target(package_info, skill_source, self.project_root) assert github_path is not None assert github_path == skill_dir @@ -1246,11 +1246,12 @@ def test_copy_skill_skips_packages_without_skill_md(self): install_path=pkg_source ) - github_path, claude_path = copy_skill_to_target(package_info, pkg_source, self.project_root) - - # Should return None (skipped) - both paths should be None + github_path, claude_path, opencode_path = copy_skill_to_target(package_info, pkg_source, self.project_root) + + # Should return None (skipped) - all paths should be None assert github_path is None assert claude_path is None + assert opencode_path is None # No skill directory should be created assert not (self.project_root / ".github" / "skills" / "instructions-only").exists() @@ -1271,7 +1272,7 @@ def test_copy_skill_respects_skill_type(self): pkg_type=PackageContentType.SKILL ) - github_path, _ = copy_skill_to_target(package_info, skill_source, self.project_root) + github_path, _, _ = copy_skill_to_target(package_info, skill_source, self.project_root) assert github_path is not None assert (github_path / "SKILL.md").exists() @@ -1290,7 +1291,7 @@ def test_copy_skill_respects_hybrid_type(self): pkg_type=PackageContentType.HYBRID ) - github_path, _ = copy_skill_to_target(package_info, skill_source, self.project_root) + github_path, _, _ = copy_skill_to_target(package_info, skill_source, self.project_root) assert github_path is not None assert (github_path / "SKILL.md").exists() @@ -1311,7 +1312,7 @@ def test_copy_skill_creates_github_skills_directory(self): install_path=skill_source ) - github_path, _ = copy_skill_to_target(package_info, skill_source, self.project_root) + github_path, _, _ = copy_skill_to_target(package_info, skill_source, self.project_root) assert github_path is not None assert (self.project_root / ".github" / "skills").exists() @@ -1334,7 +1335,7 @@ def test_copy_skill_preserves_source_integrity(self): source="owner/my-skill" ) - github_path, _ = copy_skill_to_target(package_info, skill_source, self.project_root) + github_path, _, _ = copy_skill_to_target(package_info, skill_source, self.project_root) assert github_path is not None @@ -1776,31 +1777,53 @@ def test_copy_skill_to_target_returns_both_paths_when_claude_exists(self): install_path=skill_source ) - github_path, claude_path = copy_skill_to_target(package_info, skill_source, self.project_root) - + github_path, claude_path, opencode_path = copy_skill_to_target(package_info, skill_source, self.project_root) + assert github_path is not None assert claude_path is not None assert github_path == self.project_root / ".github" / "skills" / "my-skill" assert claude_path == self.project_root / ".claude" / "skills" / "my-skill" - + def test_copy_skill_to_target_returns_none_claude_when_no_claude_dir(self): """Test that copy_skill_to_target returns None for claude_path when .claude/ doesn't exist.""" # Ensure .claude/ does NOT exist assert not (self.project_root / ".claude").exists() - + skill_source = self.apm_modules / "owner" / "my-skill" skill_source.mkdir(parents=True) (skill_source / "SKILL.md").write_text("---\nname: my-skill\n---\n# Skill") - + package_info = self._create_package_info( name="my-skill", install_path=skill_source ) - - github_path, claude_path = copy_skill_to_target(package_info, skill_source, self.project_root) - + + github_path, claude_path, opencode_path = copy_skill_to_target(package_info, skill_source, self.project_root) + assert github_path is not None assert claude_path is None + assert opencode_path is None + + def test_copy_skill_to_target_returns_opencode_path_when_opencode_exists(self): + """Test that copy_skill_to_target returns opencode_path when .opencode/ exists.""" + # Create .opencode/ directory + (self.project_root / ".opencode").mkdir() + + skill_source = self.apm_modules / "owner" / "my-skill" + skill_source.mkdir(parents=True) + (skill_source / "SKILL.md").write_text("---\nname: my-skill\n---\n# Skill") + + package_info = self._create_package_info( + name="my-skill", + install_path=skill_source + ) + + github_path, claude_path, opencode_path = copy_skill_to_target(package_info, skill_source, self.project_root) + + assert github_path is not None + assert opencode_path is not None + assert opencode_path == self.project_root / ".opencode" / "skills" / "my-skill" + assert (opencode_path / "SKILL.md").exists() # ========== Test: sync_integration cleans both locations ========== @@ -2371,3 +2394,132 @@ def test_sync_preserves_promoted_sub_skills_when_package_installed(self): assert result['files_removed'] == 0 assert style_checker.exists() + + +# ============================================================================= +# OpenCode Skills Collision / Overwrite Tests +# ============================================================================= + +class TestOpenCodeSkillCollisionAndOverwrite: + """Collision and overwrite tests for OpenCode skill integration. + + integrate_package_skill_opencode always overwrites (rmtree + copytree), + so these tests verify that behaviour plus edge cases. + """ + + def setup_method(self): + """Set up test fixtures.""" + self.temp_dir = tempfile.mkdtemp() + self.project_root = Path(self.temp_dir) + self.apm_modules = self.project_root / "apm_modules" + self.apm_modules.mkdir(parents=True) + self.integrator = SkillIntegrator() + + def teardown_method(self): + """Clean up after tests.""" + shutil.rmtree(self.temp_dir, ignore_errors=True) + + def _create_package_info( + self, + name: str = "test-skill", + version: str = "1.0.0", + install_path: Path = None, + package_type: PackageType = PackageType.CLAUDE_SKILL, + ) -> PackageInfo: + """Helper to create PackageInfo objects for OpenCode skill tests.""" + package = APMPackage( + name=name, + version=version, + package_path=install_path or self.project_root / "package", + source=f"github.com/test/{name}", + ) + resolved_ref = ResolvedReference( + original_ref="main", + ref_type=GitReferenceType.BRANCH, + resolved_commit="abc123", + ref_name="main", + ) + return PackageInfo( + package=package, + install_path=install_path or self.project_root / "package", + resolved_reference=resolved_ref, + installed_at=datetime.now().isoformat(), + package_type=package_type, + ) + + def test_first_install_creates_skill(self): + """First install creates the skill directory under .opencode/skills/.""" + (self.project_root / ".opencode").mkdir() + + skill_source = self.apm_modules / "owner" / "my-skill" + skill_source.mkdir(parents=True) + (skill_source / "SKILL.md").write_text("---\nname: my-skill\n---\n# My Skill") + + pkg_info = self._create_package_info(name="my-skill", install_path=skill_source) + result = self.integrator.integrate_package_skill_opencode(pkg_info, self.project_root) + + assert result.skill_created is True + assert result.skill_updated is False + opencode_skill = self.project_root / ".opencode" / "skills" / "my-skill" / "SKILL.md" + assert opencode_skill.exists() + + def test_existing_skill_is_overwritten_on_reinstall(self): + """Existing skill directory is replaced (overwritten) on reinstall.""" + (self.project_root / ".opencode").mkdir() + + # Create the initial skill directory + existing_skill = self.project_root / ".opencode" / "skills" / "my-skill" + existing_skill.mkdir(parents=True) + (existing_skill / "SKILL.md").write_text("# Old content") + (existing_skill / "extra-file.md").write_text("# Should be removed") + + # Re-install with new content + skill_source = self.apm_modules / "owner" / "my-skill" + skill_source.mkdir(parents=True) + (skill_source / "SKILL.md").write_text("---\nname: my-skill\n---\n# Updated Skill") + + pkg_info = self._create_package_info(name="my-skill", install_path=skill_source) + result = self.integrator.integrate_package_skill_opencode(pkg_info, self.project_root) + + assert result.skill_created is False + assert result.skill_updated is True + # Content should be the new version + content = (existing_skill / "SKILL.md").read_text() + assert "Updated Skill" in content + # Old extra file should be gone (rmtree + copytree) + assert not (existing_skill / "extra-file.md").exists() + + def test_skipped_when_opencode_dir_missing(self): + """Skill integration is skipped when .opencode/ does not exist.""" + skill_source = self.apm_modules / "owner" / "my-skill" + skill_source.mkdir(parents=True) + (skill_source / "SKILL.md").write_text("---\nname: my-skill\n---\n# Skill") + + pkg_info = self._create_package_info(name="my-skill", install_path=skill_source) + result = self.integrator.integrate_package_skill_opencode(pkg_info, self.project_root) + + assert result.skill_skipped is True + assert result.skill_created is False + + def test_overwrite_preserves_references(self): + """After overwrite, reference files from the new version are present.""" + (self.project_root / ".opencode").mkdir() + + # Pre-existing skill + existing_skill = self.project_root / ".opencode" / "skills" / "my-skill" + existing_skill.mkdir(parents=True) + (existing_skill / "SKILL.md").write_text("# Old") + + # New source with references + skill_source = self.apm_modules / "owner" / "my-skill" + skill_source.mkdir(parents=True) + (skill_source / "SKILL.md").write_text("---\nname: my-skill\n---\n# New") + refs = skill_source / "references" + refs.mkdir() + (refs / "api.md").write_text("# API Guide") + + pkg_info = self._create_package_info(name="my-skill", install_path=skill_source) + self.integrator.integrate_package_skill_opencode(pkg_info, self.project_root) + + assert (existing_skill / "references" / "api.md").exists() + assert (existing_skill / "references" / "api.md").read_text() == "# API Guide"