diff --git a/src/apm_cli/cli.py b/src/apm_cli/cli.py index 2a07bf7dc..d92929687 100644 --- a/src/apm_cli/cli.py +++ b/src/apm_cli/cli.py @@ -136,7 +136,10 @@ def _lazy_confirm(): # Shared orphan-detection helpers # ------------------------------------------------------------------ -def _build_expected_install_paths(declared_deps, lockfile, apm_modules_dir: Path) -> set: + +def _build_expected_install_paths( + declared_deps, lockfile, apm_modules_dir: Path +) -> set: """Build expected package paths under *apm_modules_dir*. Combines direct deps (from ``apm.yml``) with transitive deps @@ -215,7 +218,9 @@ def _check_orphaned_packages(): apm_package = APMPackage.from_apm_yml(Path("apm.yml")) declared_deps = apm_package.get_apm_dependencies() lockfile = LockFile.read(Path.cwd() / "apm.lock") - expected = _build_expected_install_paths(declared_deps, lockfile, apm_modules_dir) + expected = _build_expected_install_paths( + declared_deps, lockfile, apm_modules_dir + ) except Exception: return [] @@ -313,7 +318,10 @@ def cli(ctx): @cli.command(help="🚀 Initialize a new APM project") @click.argument("project_name", required=False) @click.option( - "--yes", "-y", is_flag=True, help="Skip interactive prompts and use auto-detected defaults" + "--yes", + "-y", + is_flag=True, + help="Skip interactive prompts and use auto-detected defaults", ) @click.pass_context def init(ctx, project_name, yes): @@ -415,7 +423,7 @@ def init(ctx, project_name, yes): def _validate_and_add_packages_to_apm_yml(packages, dry_run=False): """Validate packages exist and can be accessed, then add to apm.yml dependencies section. - + Implements normalize-on-write: any input form (HTTPS URL, SSH URL, FQDN, shorthand) is canonicalized before storage. Default host (github.com) is stripped; non-default hosts are preserved. Duplicates are detected by identity. @@ -548,7 +556,10 @@ def _validate_package_exists(package): # For Azure DevOps or GitHub Enterprise (non-github.com hosts), # use the downloader which handles authentication properly if dep_ref.is_azure_devops() or (dep_ref.host and dep_ref.host != "github.com"): - from apm_cli.utils.github_host import is_github_hostname, is_azure_devops_hostname + from apm_cli.utils.github_host import ( + is_github_hostname, + is_azure_devops_hostname, + ) downloader = GitHubPackageDownloader() # Set the host @@ -563,11 +574,17 @@ def _validate_package_exists(package): # For generic hosts (not GitHub, not ADO), relax the env so native # credential helpers (SSH keys, macOS Keychain, etc.) can work. # This mirrors _clone_with_fallback() which does the same relaxation. - is_generic = not is_github_hostname(dep_ref.host) and not is_azure_devops_hostname(dep_ref.host) + is_generic = not is_github_hostname( + dep_ref.host + ) and not is_azure_devops_hostname(dep_ref.host) if is_generic: - validate_env = {k: v for k, v in downloader.git_env.items() - if k not in ('GIT_ASKPASS', 'GIT_CONFIG_GLOBAL', 'GIT_CONFIG_NOSYSTEM')} - validate_env['GIT_TERMINAL_PROMPT'] = '0' + validate_env = { + k: v + for k, v in downloader.git_env.items() + if k + not in ("GIT_ASKPASS", "GIT_CONFIG_GLOBAL", "GIT_CONFIG_NOSYSTEM") + } + validate_env["GIT_TERMINAL_PROMPT"] = "0" else: validate_env = {**os.environ, **downloader.git_env} @@ -587,7 +604,6 @@ def _validate_package_exists(package): # For regular packages, use git ls-remote with tempfile.TemporaryDirectory() as temp_dir: try: - # Try cloning with minimal fetch cmd = [ "git", @@ -597,7 +613,10 @@ def _validate_package_exists(package): package_url, ] result = subprocess.run( - cmd, capture_output=True, text=True, timeout=30 # 30 second timeout + cmd, + capture_output=True, + text=True, + timeout=30, # 30 second timeout ) return result.returncode == 0 @@ -651,7 +670,9 @@ def _validate_package_exists(package): @click.option( "--dry-run", is_flag=True, help="Show what would be installed without installing" ) -@click.option("--force", is_flag=True, help="Overwrite locally-authored files on collision") +@click.option( + "--force", is_flag=True, help="Overwrite locally-authored files on collision" +) @click.option("--verbose", is_flag=True, help="Show detailed installation information") @click.option( "--trust-transitive-mcp", @@ -666,7 +687,19 @@ def _validate_package_exists(package): help="Max concurrent package downloads (0 to disable parallelism)", ) @click.pass_context -def install(ctx, packages, runtime, exclude, only, update, dry_run, force, verbose, trust_transitive_mcp, parallel_downloads): +def install( + ctx, + packages, + runtime, + exclude, + only, + update, + dry_run, + force, + verbose, + trust_transitive_mcp, + parallel_downloads, +): """Install APM and MCP dependencies from apm.yml (like npm install). This command automatically detects AI runtimes from your apm.yml scripts and installs @@ -765,7 +798,11 @@ def install(ctx, packages, runtime, exclude, only, update, dry_run, force, verbo # Otherwise install all from apm.yml only_pkgs = builtins.list(packages) if packages else None apm_count, prompt_count, agent_count = _install_apm_dependencies( - apm_package, update, verbose, only_pkgs, force=force, + apm_package, + update, + verbose, + only_pkgs, + force=force, parallel_downloads=parallel_downloads, ) except Exception as e: @@ -778,9 +815,13 @@ def install(ctx, packages, runtime, exclude, only, update, dry_run, force, verbo apm_modules_path = Path.cwd() / "apm_modules" if should_install_mcp and apm_modules_path.exists(): lock_path = Path.cwd() / "apm.lock" - transitive_mcp = _collect_transitive_mcp_deps(apm_modules_path, lock_path, trust_transitive_mcp) + transitive_mcp = _collect_transitive_mcp_deps( + apm_modules_path, lock_path, trust_transitive_mcp + ) if transitive_mcp: - _rich_info(f"Collected {len(transitive_mcp)} transitive MCP dependency(ies)") + _rich_info( + f"Collected {len(transitive_mcp)} transitive MCP dependency(ies)" + ) mcp_deps = _deduplicate_mcp_deps(mcp_deps + transitive_mcp) # Continue with MCP installation (existing logic) @@ -842,13 +883,17 @@ def prune(ctx, dry_run): apm_package = APMPackage.from_apm_yml(Path("apm.yml")) declared_deps = apm_package.get_apm_dependencies() lockfile = LockFile.read(Path.cwd() / "apm.lock") - expected_installed = _build_expected_install_paths(declared_deps, lockfile, apm_modules_dir) + expected_installed = _build_expected_install_paths( + declared_deps, lockfile, apm_modules_dir + ) except Exception as e: _rich_error(f"Failed to parse apm.yml: {e}") sys.exit(1) installed_packages = _scan_installed_packages(apm_modules_dir) - orphaned_packages = [p for p in installed_packages if p not in expected_installed] + orphaned_packages = [ + p for p in installed_packages if p not in expected_installed + ] if not orphaned_packages: _rich_success("No orphaned packages found. apm_modules/ is clean.") @@ -884,11 +929,13 @@ def prune(ctx, dry_run): # Batch parent cleanup — single bottom-up pass from apm_cli.integration.base_integrator import BaseIntegrator + BaseIntegrator.cleanup_empty_parents(deleted_pkg_paths, stop_at=apm_modules_dir) # Clean deployed files for pruned packages and update lockfile if pruned_keys: from apm_cli.deps.lockfile import get_lockfile_path + lockfile_path = get_lockfile_path(Path(".")) lockfile = LockFile.read(lockfile_path) project_root = Path(".") @@ -899,7 +946,9 @@ def prune(ctx, dry_run): dep = lockfile.get_dependency(dep_key) if dep and dep.deployed_files: for rel_path in dep.deployed_files: - if not BaseIntegrator.validate_deploy_path(rel_path, project_root): + if not BaseIntegrator.validate_deploy_path( + rel_path, project_root + ): continue target = project_root / rel_path if target.is_file(): @@ -914,9 +963,13 @@ def prune(ctx, dry_run): if dep_key in lockfile.dependencies: del lockfile.dependencies[dep_key] # Batch parent cleanup — single bottom-up pass - BaseIntegrator.cleanup_empty_parents(deleted_targets, stop_at=project_root) + BaseIntegrator.cleanup_empty_parents( + deleted_targets, stop_at=project_root + ) if deployed_cleaned > 0: - _rich_info(f"✓ Cleaned {deployed_cleaned} deployed integration file(s)") + _rich_info( + f"✓ Cleaned {deployed_cleaned} deployed integration file(s)" + ) # Write updated lockfile (or remove if empty) try: if lockfile.dependencies: @@ -1021,9 +1074,7 @@ def update(check): # Use /bin/sh for better cross-platform compatibility # Note: We don't capture output so the installer can prompt for sudo shell_path = "/bin/sh" if os.path.exists("/bin/sh") else "sh" - result = subprocess.run( - [shell_path, temp_script], check=False - ) + result = subprocess.run([shell_path, temp_script], check=False) # Clean up temp file try: @@ -1145,7 +1196,9 @@ def uninstall(ctx, packages, dry_run): break except (ValueError, TypeError, AttributeError, KeyError): # Fallback: exact string match - dep_str = dep_entry if isinstance(dep_entry, str) else str(dep_entry) + dep_str = ( + dep_entry if isinstance(dep_entry, str) else str(dep_entry) + ) if dep_str == package: matched_dep = dep_entry break @@ -1178,6 +1231,7 @@ def uninstall(ctx, packages, dry_run): # Show transitive deps that would be removed from apm_cli.deps.lockfile import LockFile, get_lockfile_path + lockfile_path = get_lockfile_path(Path(".")) lockfile = LockFile.read(lockfile_path) if lockfile: @@ -1233,6 +1287,7 @@ def uninstall(ctx, packages, dry_run): # npm-style transitive dep cleanup: use lockfile to find orphaned transitive deps from apm_cli.deps.lockfile import LockFile, get_lockfile_path + lockfile_path = get_lockfile_path(Path(".")) lockfile = LockFile.read(lockfile_path) @@ -1268,6 +1323,7 @@ def uninstall(ctx, packages, dry_run): # Batch parent cleanup — single bottom-up pass from apm_cli.integration.base_integrator import BaseIntegrator as _BI2 + _BI2.cleanup_empty_parents(deleted_pkg_paths, stop_at=apm_modules_dir) # npm-style transitive dependency cleanup: remove orphaned transitive deps @@ -1309,7 +1365,9 @@ def _find_transitive_orphans(lockfile, removed_urls): try: with open(apm_yml_path, "r") as f: updated_data = yaml.safe_load(f) or {} - for dep_str in updated_data.get("dependencies", {}).get("apm", []) or []: + for dep_str in ( + updated_data.get("dependencies", {}).get("apm", []) or [] + ): try: ref = DependencyReference.parse(dep_str) remaining_deps.add(ref.get_unique_key()) @@ -1321,7 +1379,10 @@ def _find_transitive_orphans(lockfile, removed_urls): # Also check remaining lockfile deps that are NOT orphaned for dep in lockfile.get_all_dependencies(): key = dep.get_unique_key() - if key not in potential_orphans and dep.repo_url not in removed_repo_urls: + if ( + key not in potential_orphans + and dep.repo_url not in removed_repo_urls + ): remaining_deps.add(key) # Remove only true orphans (not needed by remaining deps) @@ -1336,24 +1397,34 @@ def _find_transitive_orphans(lockfile, removed_urls): orphan_path = orphan_ref.get_install_path(apm_modules_dir) except ValueError: parts = orphan_key.split("/") - orphan_path = apm_modules_dir.joinpath(*parts) if len(parts) >= 2 else apm_modules_dir / orphan_key + orphan_path = ( + apm_modules_dir.joinpath(*parts) + if len(parts) >= 2 + else apm_modules_dir / orphan_key + ) if orphan_path.exists(): try: shutil.rmtree(orphan_path) - _rich_info(f"✓ Removed transitive dependency {orphan_key} from apm_modules/") + _rich_info( + f"✓ Removed transitive dependency {orphan_key} from apm_modules/" + ) removed_from_modules += 1 deleted_orphan_paths.append(orphan_path) except Exception as e: - _rich_error(f"✗ Failed to remove transitive dep {orphan_key}: {e}") + _rich_error( + f"✗ Failed to remove transitive dep {orphan_key}: {e}" + ) # Batch parent cleanup — single bottom-up pass from apm_cli.integration.base_integrator import BaseIntegrator as _BI + _BI.cleanup_empty_parents(deleted_orphan_paths, stop_at=apm_modules_dir) # Collect deployed_files only for REMOVED packages (direct + transitive) # so sync_integration doesn't iterate paths from packages still installed. from apm_cli.integration.base_integrator import BaseIntegrator + removed_keys = builtins.set() for pkg in packages_to_remove: try: @@ -1369,7 +1440,9 @@ def _find_transitive_orphans(lockfile, removed_urls): if dep_key in removed_keys: all_deployed_files.update(dep.deployed_files) # Normalize path separators once - all_deployed_files = BaseIntegrator.normalize_managed_files(all_deployed_files) or builtins.set() + all_deployed_files = ( + BaseIntegrator.normalize_managed_files(all_deployed_files) or builtins.set() + ) # Update lockfile: remove entries for all removed packages (direct + transitive) removed_orphan_keys = builtins.set() @@ -1410,16 +1483,44 @@ def _find_transitive_orphans(lockfile, removed_urls): instructions_cleaned = 0 try: - from apm_cli.models.apm_package import APMPackage, PackageInfo, PackageType, validate_apm_package + from apm_cli.models.apm_package import ( + APMPackage, + PackageInfo, + PackageType, + validate_apm_package, + ) from apm_cli.integration.prompt_integrator import PromptIntegrator from apm_cli.integration.agent_integrator import AgentIntegrator from apm_cli.integration.skill_integrator import SkillIntegrator from apm_cli.integration.command_integrator import CommandIntegrator from apm_cli.integration.hook_integrator import HookIntegrator from apm_cli.integration.instruction_integrator import InstructionIntegrator + from apm_cli.core.target_detection import ( + detect_target, + should_integrate_vscode, + should_integrate_claude, + should_integrate_opencode, + ) apm_package = APMPackage.from_apm_yml(Path("apm.yml")) project_root = Path(".") + config_target = apm_package.target + detected_target, _ = detect_target( + project_root=project_root, + explicit_target=None, + config_target=config_target, + ) + integrate_vscode = should_integrate_vscode(detected_target) + integrate_claude = should_integrate_claude(detected_target) + integrate_opencode = should_integrate_opencode(detected_target) + + skill_destinations = set() + if integrate_vscode or integrate_claude: + skill_destinations.add("github") + if integrate_claude: + skill_destinations.add("claude") + if integrate_opencode: + skill_destinations.add("opencode") # Use pre-collected deployed_files (captured before lockfile entries were deleted) sync_managed = all_deployed_files if all_deployed_files else None @@ -1434,45 +1535,71 @@ def _find_transitive_orphans(lockfile, removed_urls): # Phase 1: Remove all APM-deployed files if Path(".github/prompts").exists(): integrator = PromptIntegrator() - result = integrator.sync_integration(apm_package, project_root, - managed_files=_buckets["prompts"] if _buckets else None) + result = integrator.sync_integration( + apm_package, + project_root, + managed_files=_buckets["prompts"] if _buckets else None, + ) prompts_cleaned = result.get("files_removed", 0) if Path(".github/agents").exists(): integrator = AgentIntegrator() - result = integrator.sync_integration(apm_package, project_root, - managed_files=_buckets["agents_github"] if _buckets else None) + result = integrator.sync_integration( + apm_package, + project_root, + managed_files=_buckets["agents_github"] if _buckets else None, + ) agents_cleaned = result.get("files_removed", 0) if Path(".claude/agents").exists(): integrator = AgentIntegrator() - result = integrator.sync_integration_claude(apm_package, project_root, - managed_files=_buckets["agents_claude"] if _buckets else None) + result = integrator.sync_integration_claude( + apm_package, + project_root, + managed_files=_buckets["agents_claude"] if _buckets else None, + ) agents_cleaned += result.get("files_removed", 0) - if Path(".github/skills").exists() or Path(".claude/skills").exists(): + if ( + Path(".github/skills").exists() + or Path(".claude/skills").exists() + or Path(".opencode/skills").exists() + ): integrator = SkillIntegrator() - result = integrator.sync_integration(apm_package, project_root, - managed_files=_buckets["skills"] if _buckets else None) + result = integrator.sync_integration( + apm_package, + project_root, + managed_files=_buckets["skills"] if _buckets else None, + destinations=skill_destinations, + ) skills_cleaned = result.get("files_removed", 0) - if Path(".claude/commands").exists(): + if Path(".opencode/commands").exists(): integrator = CommandIntegrator() - result = integrator.sync_integration(apm_package, project_root, - managed_files=_buckets["commands"] if _buckets else None) + result = integrator.sync_integration( + apm_package, + project_root, + managed_files=_buckets["commands"] if _buckets else None, + ) commands_cleaned = result.get("files_removed", 0) # Clean hooks (.github/hooks/ and .claude/settings.json) hook_integrator_cleanup = HookIntegrator() - result = hook_integrator_cleanup.sync_integration(apm_package, project_root, - managed_files=_buckets["hooks"] if _buckets else None) + result = hook_integrator_cleanup.sync_integration( + apm_package, + project_root, + managed_files=_buckets["hooks"] if _buckets else None, + ) hooks_cleaned = result.get("files_removed", 0) # Clean instructions (.github/instructions/) if Path(".github/instructions").exists(): integrator = InstructionIntegrator() - result = integrator.sync_integration(apm_package, project_root, - managed_files=_buckets["instructions"] if _buckets else None) + result = integrator.sync_integration( + apm_package, + project_root, + managed_files=_buckets["instructions"] if _buckets else None, + ) instructions_cleaned = result.get("files_removed", 0) # Phase 2: Re-integrate from remaining installed packages in apm_modules/ @@ -1490,7 +1617,9 @@ def _find_transitive_orphans(lockfile, removed_urls): # Build install path install_path = Path("apm_modules") / dep_ref.repo_url if dep_ref.is_virtual and dep_ref.virtual_path: - install_path = Path("apm_modules") / dep_ref.repo_url / dep_ref.virtual_path + install_path = ( + Path("apm_modules") / dep_ref.repo_url / dep_ref.virtual_path + ) if not install_path.exists(): continue @@ -1508,17 +1637,35 @@ def _find_transitive_orphans(lockfile, removed_urls): try: if prompt_integrator.should_integrate(project_root): - prompt_integrator.integrate_package_prompts(pkg_info, project_root) + prompt_integrator.integrate_package_prompts( + pkg_info, project_root + ) if agent_integrator.should_integrate(project_root): - agent_integrator.integrate_package_agents(pkg_info, project_root) + agent_integrator.integrate_package_agents( + pkg_info, project_root + ) if Path(".claude").exists(): - agent_integrator.integrate_package_agents_claude(pkg_info, project_root) - skill_integrator.integrate_package_skill(pkg_info, project_root) + agent_integrator.integrate_package_agents_claude( + pkg_info, project_root + ) + skill_integrator.integrate_package_skill( + pkg_info, + project_root, + destinations=skill_destinations, + ) if command_integrator.should_integrate(project_root): - command_integrator.integrate_package_commands(pkg_info, project_root) - hook_integrator_reint.integrate_package_hooks(pkg_info, project_root) - hook_integrator_reint.integrate_package_hooks_claude(pkg_info, project_root) - instruction_integrator.integrate_package_instructions(pkg_info, project_root) + command_integrator.integrate_package_commands( + pkg_info, project_root + ) + hook_integrator_reint.integrate_package_hooks( + pkg_info, project_root + ) + hook_integrator_reint.integrate_package_hooks_claude( + pkg_info, project_root + ) + instruction_integrator.integrate_package_instructions( + pkg_info, project_root + ) except Exception: pass # Best effort re-integration @@ -1589,22 +1736,25 @@ def _install_apm_dependencies( _rich_info(f"Installing APM dependencies ({len(apm_deps)})...") project_root = Path.cwd() - + # T5: Check for existing lockfile - use locked versions for reproducible installs from apm_cli.deps.lockfile import LockFile, get_lockfile_path + lockfile_path = get_lockfile_path(project_root) existing_lockfile = None if lockfile_path.exists() and not update_refs: existing_lockfile = LockFile.read(lockfile_path) if existing_lockfile and existing_lockfile.dependencies: - _rich_info(f"Using apm.lock ({len(existing_lockfile.dependencies)} locked dependencies)") - + _rich_info( + f"Using apm.lock ({len(existing_lockfile.dependencies)} locked dependencies)" + ) + apm_modules_dir = project_root / "apm_modules" apm_modules_dir.mkdir(exist_ok=True) - + # Create downloader early so it can be used for transitive dependency resolution downloader = GitHubPackageDownloader() - + # Track direct dependency keys so the download callback can distinguish them from transitive direct_dep_keys = builtins.set(dep.get_unique_key() for dep in apm_deps) @@ -1626,38 +1776,47 @@ def download_callback(dep_ref, modules_dir): repo_ref = f"{dep_ref.host}/{dep_ref.repo_url}" if dep_ref.virtual_path: repo_ref = f"{repo_ref}/{dep_ref.virtual_path}" - + # T5: Use locked commit if available (reproducible installs) locked_ref = None if existing_lockfile: locked_dep = existing_lockfile.get_dependency(dep_ref.get_unique_key()) - if locked_dep and locked_dep.resolved_commit and locked_dep.resolved_commit != "cached": + if ( + locked_dep + and locked_dep.resolved_commit + and locked_dep.resolved_commit != "cached" + ): locked_ref = locked_dep.resolved_commit - + # Priority: locked commit > explicit reference > default branch if locked_ref: repo_ref = f"{repo_ref}#{locked_ref}" elif dep_ref.reference: repo_ref = f"{repo_ref}#{dep_ref.reference}" - + # Silent download - no progress display for transitive deps result = downloader.download_package(repo_ref, install_path) # Capture resolved commit SHA for lockfile resolved_sha = None - if result and hasattr(result, 'resolved_reference') and result.resolved_reference: + if ( + result + and hasattr(result, "resolved_reference") + and result.resolved_reference + ): resolved_sha = result.resolved_reference.resolved_commit callback_downloaded[dep_ref.get_unique_key()] = resolved_sha return install_path except Exception as e: # Log but don't fail - allow resolution to continue if verbose: - _rich_error(f" └─ Failed to resolve transitive dep {dep_ref.repo_url}: {e}") + _rich_error( + f" └─ Failed to resolve transitive dep {dep_ref.repo_url}: {e}" + ) return None - + # Resolve dependencies with transitive download support resolver = APMDependencyResolver( - apm_modules_dir=apm_modules_dir, - download_callback=download_callback + apm_modules_dir=apm_modules_dir, download_callback=download_callback ) try: @@ -1688,8 +1847,7 @@ def download_callback(dep_ref, modules_dir): only_identities.add(p) deps_to_install = [ - dep for dep in deps_to_install - if dep.get_identity() in only_identities + dep for dep in deps_to_install if dep.get_identity() in only_identities ] if not deps_to_install: @@ -1703,25 +1861,33 @@ def download_callback(dep_ref, modules_dir): detect_target, should_integrate_vscode, should_integrate_claude, + should_integrate_opencode, get_target_description, ) # Get config target from apm.yml if available config_target = apm_package.target - # Auto-create .github/ if neither .github/ nor .claude/ exists. - # 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/. + # Auto-create .github/ only when no integration root exists. 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" ) + detected_target, detection_reason = detect_target( + project_root=project_root, + explicit_target=None, + config_target=config_target, + ) + detected_target, detection_reason = detect_target( project_root=project_root, explicit_target=None, # No explicit flag for install @@ -1731,11 +1897,22 @@ def download_callback(dep_ref, modules_dir): # 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) + skill_destinations = set() + if integrate_vscode or integrate_claude: + skill_destinations.add("github") + if integrate_claude: + skill_destinations.add("claude") + if integrate_opencode: + skill_destinations.add("opencode") # Initialize integrators prompt_integrator = PromptIntegrator() agent_integrator = AgentIntegrator() - from apm_cli.integration.skill_integrator import SkillIntegrator, should_install_skill + from apm_cli.integration.skill_integrator import ( + SkillIntegrator, + should_install_skill, + ) from apm_cli.integration.command_integrator import CommandIntegrator from apm_cli.integration.hook_integrator import HookIntegrator from apm_cli.integration.instruction_integrator import InstructionIntegrator @@ -1755,17 +1932,23 @@ def download_callback(dep_ref, modules_dir): # Collect installed packages for lockfile generation from apm_cli.deps.lockfile import LockFile, LockedDependency, get_lockfile_path - installed_packages: List[tuple] = [] # List of (dep_ref, resolved_commit, depth, resolved_by) + + installed_packages: List[ + tuple + ] = [] # List of (dep_ref, resolved_commit, depth, resolved_by) package_deployed_files: dict = {} # dep_key → list of relative deployed paths # Build managed_files from existing lockfile for collision detection managed_files = builtins.set() - existing_lockfile = LockFile.read(get_lockfile_path(project_root)) if project_root else None + existing_lockfile = ( + LockFile.read(get_lockfile_path(project_root)) if project_root else None + ) if existing_lockfile: for dep in existing_lockfile.dependencies.values(): managed_files.update(dep.deployed_files) # Normalize path separators once for O(1) lookups in check_collision from apm_cli.integration.base_integrator import BaseIntegrator + managed_files = BaseIntegrator.normalize_managed_files(managed_files) # Install each dependency with Rich progress display @@ -1783,23 +1966,38 @@ def download_callback(dep_ref, modules_dir): # Phase 4 (#171): Parallel package downloads using ThreadPoolExecutor # Pre-download all non-cached packages in parallel for wall-clock speedup. # Results are stored and consumed by the sequential integration loop below. - from concurrent.futures import ThreadPoolExecutor, as_completed as _futures_completed + from concurrent.futures import ( + ThreadPoolExecutor, + as_completed as _futures_completed, + ) - _pre_download_results = {} # dep_key -> PackageInfo + _pre_download_results = {} # dep_key -> PackageInfo _need_download = [] for _pd_ref in deps_to_install: _pd_key = _pd_ref.get_unique_key() - _pd_path = (apm_modules_dir / _pd_ref.alias) if _pd_ref.alias else _pd_ref.get_install_path(apm_modules_dir) + _pd_path = ( + (apm_modules_dir / _pd_ref.alias) + if _pd_ref.alias + else _pd_ref.get_install_path(apm_modules_dir) + ) # Skip if already downloaded during BFS resolution if _pd_key in callback_downloaded: continue # Skip if lockfile SHA matches local HEAD (Phase 5 check) if _pd_path.exists() and existing_lockfile and not update_refs: _pd_locked = existing_lockfile.get_dependency(_pd_key) - if _pd_locked and _pd_locked.resolved_commit and _pd_locked.resolved_commit != "cached": + if ( + _pd_locked + and _pd_locked.resolved_commit + and _pd_locked.resolved_commit != "cached" + ): try: from git import Repo as _PDGitRepo - if _PDGitRepo(_pd_path).head.commit.hexsha == _pd_locked.resolved_commit: + + if ( + _PDGitRepo(_pd_path).head.commit.hexsha + == _pd_locked.resolved_commit + ): continue except Exception: pass @@ -1807,7 +2005,11 @@ def download_callback(dep_ref, modules_dir): _pd_dlref = str(_pd_ref) if existing_lockfile and not update_refs: _pd_locked = existing_lockfile.get_dependency(_pd_key) - if _pd_locked and _pd_locked.resolved_commit and _pd_locked.resolved_commit != "cached": + if ( + _pd_locked + and _pd_locked.resolved_commit + and _pd_locked.resolved_commit != "cached" + ): _pd_base = _pd_ref.repo_url if _pd_ref.virtual_path: _pd_base = f"{_pd_base}/{_pd_ref.virtual_path}" @@ -1826,12 +2028,21 @@ def download_callback(dep_ref, modules_dir): with ThreadPoolExecutor(max_workers=_max_workers) as _executor: _futures = {} for _pd_ref, _pd_path, _pd_dlref in _need_download: - _pd_disp = str(_pd_ref) if _pd_ref.is_virtual else _pd_ref.repo_url - _pd_short = _pd_disp.split("/")[-1] if "/" in _pd_disp else _pd_disp - _pd_tid = _dl_progress.add_task(description=f"Fetching {_pd_short}", total=None) + _pd_disp = ( + str(_pd_ref) if _pd_ref.is_virtual else _pd_ref.repo_url + ) + _pd_short = ( + _pd_disp.split("/")[-1] if "/" in _pd_disp else _pd_disp + ) + _pd_tid = _dl_progress.add_task( + description=f"Fetching {_pd_short}", total=None + ) _pd_fut = _executor.submit( - downloader.download_package, _pd_dlref, _pd_path, - progress_task_id=_pd_tid, progress_obj=_dl_progress, + downloader.download_package, + _pd_dlref, + _pd_path, + progress_task_id=_pd_tid, + progress_obj=_dl_progress, ) _futures[_pd_fut] = (_pd_ref, _pd_tid, _pd_disp) for _pd_fut in _futures_completed(_futures): @@ -1874,7 +2085,10 @@ def download_callback(dep_ref, modules_dir): from apm_cli.models.apm_package import GitReferenceType resolved_ref = None - if dep_ref.reference and dep_ref.get_unique_key() not in _pre_downloaded_keys: + if ( + dep_ref.reference + and dep_ref.get_unique_key() not in _pre_downloaded_keys + ): try: resolved_ref = downloader.resolve_git_reference( f"{dep_ref.repo_url}@{dep_ref.reference}" @@ -1892,17 +2106,29 @@ def download_callback(dep_ref, modules_dir): # Phase 5 (#171): Also skip when lockfile SHA matches local HEAD lockfile_match = False if install_path.exists() and existing_lockfile and not update_refs: - locked_dep = existing_lockfile.get_dependency(dep_ref.get_unique_key()) - if locked_dep and locked_dep.resolved_commit and locked_dep.resolved_commit != "cached": + locked_dep = existing_lockfile.get_dependency( + dep_ref.get_unique_key() + ) + if ( + locked_dep + and locked_dep.resolved_commit + and locked_dep.resolved_commit != "cached" + ): try: from git import Repo as GitRepo + local_repo = GitRepo(install_path) - if local_repo.head.commit.hexsha == locked_dep.resolved_commit: + if ( + local_repo.head.commit.hexsha + == locked_dep.resolved_commit + ): lockfile_match = True except Exception: pass # Not a git repo or invalid — fall through to download skip_download = install_path.exists() and ( - (is_cacheable and not update_refs) or already_resolved or lockfile_match + (is_cacheable and not update_refs) + or already_resolved + or lockfile_match ) if skip_download: @@ -1912,8 +2138,8 @@ def download_callback(dep_ref, modules_dir): ref_str = f" @{dep_ref.reference}" if dep_ref.reference else "" _rich_info(f"✓ {display_name}{ref_str} (cached)") - # Still need to integrate prompts for cached packages (zero-config behavior) - if integrate_vscode or integrate_claude: + # Still need to integrate primitives for cached packages (zero-config behavior) + if integrate_vscode or integrate_claude or integrate_opencode: try: # Create PackageInfo from cached package from apm_cli.models.apm_package import ( @@ -1964,14 +2190,24 @@ def download_callback(dep_ref, modules_dir): if skill_md_exists and apm_yml_exists: cached_package_info.package_type = PackageType.HYBRID elif skill_md_exists: - cached_package_info.package_type = PackageType.CLAUDE_SKILL + cached_package_info.package_type = ( + PackageType.CLAUDE_SKILL + ) elif apm_yml_exists: - cached_package_info.package_type = PackageType.APM_PACKAGE + cached_package_info.package_type = ( + PackageType.APM_PACKAGE + ) # Collect for lockfile (cached packages still need to be tracked) - node = dependency_graph.dependency_tree.get_node(dep_ref.get_unique_key()) + node = dependency_graph.dependency_tree.get_node( + dep_ref.get_unique_key() + ) depth = node.depth if node else 1 - resolved_by = node.parent.dependency_ref.repo_url if node and node.parent else None + resolved_by = ( + node.parent.dependency_ref.repo_url + if node and node.parent + else None + ) # Get commit SHA: callback capture > existing lockfile > explicit reference dep_key = dep_ref.get_unique_key() cached_commit = callback_downloaded.get(dep_key) @@ -1981,16 +2217,20 @@ def download_callback(dep_ref, modules_dir): cached_commit = locked_dep.resolved_commit if not cached_commit: cached_commit = dep_ref.reference - installed_packages.append((dep_ref, cached_commit, depth, resolved_by)) + installed_packages.append( + (dep_ref, cached_commit, depth, resolved_by) + ) dep_deployed: list = [] # collect deployed paths for this package - # VSCode + Claude integration (prompts + agents) + # VSCode/Claude integration (prompts + agents) if integrate_vscode or integrate_claude: # Integrate prompts prompt_result = ( prompt_integrator.integrate_package_prompts( - cached_package_info, project_root, - force=force, managed_files=managed_files, + cached_package_info, + project_root, + force=force, + managed_files=managed_files, ) ) if prompt_result.files_integrated > 0: @@ -2007,13 +2247,17 @@ def download_callback(dep_ref, modules_dir): # Track links resolved total_links_resolved += prompt_result.links_resolved for tp in prompt_result.target_paths: - dep_deployed.append(tp.relative_to(project_root).as_posix()) + dep_deployed.append( + tp.relative_to(project_root).as_posix() + ) # Integrate agents agent_result = ( agent_integrator.integrate_package_agents( - cached_package_info, project_root, - force=force, managed_files=managed_files, + cached_package_info, + project_root, + force=force, + managed_files=managed_files, ) ) if agent_result.files_integrated > 0: @@ -2030,13 +2274,16 @@ def download_callback(dep_ref, modules_dir): # Track links resolved total_links_resolved += agent_result.links_resolved for tp in agent_result.target_paths: - dep_deployed.append(tp.relative_to(project_root).as_posix()) + dep_deployed.append( + tp.relative_to(project_root).as_posix() + ) - # 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: + # Skill integration + if skill_destinations: skill_result = skill_integrator.integrate_package_skill( - cached_package_info, project_root + cached_package_info, + project_root, + destinations=skill_destinations, ) if skill_result.skill_created: total_skills_integrated += 1 @@ -2044,20 +2291,24 @@ def download_callback(dep_ref, modules_dir): f" └─ Skill integrated → .github/skills/" ) if skill_result.sub_skills_promoted > 0: - total_sub_skills_promoted += skill_result.sub_skills_promoted + total_sub_skills_promoted += ( + skill_result.sub_skills_promoted + ) _rich_info( f" └─ {skill_result.sub_skills_promoted} skill(s) integrated → .github/skills/" ) for tp in skill_result.target_paths: - dep_deployed.append(tp.relative_to(project_root).as_posix()) + dep_deployed.append( + tp.relative_to(project_root).as_posix() + ) # Integrate instructions → .github/instructions/ if integrate_vscode: - instruction_result = ( - instruction_integrator.integrate_package_instructions( - cached_package_info, project_root, - force=force, managed_files=managed_files, - ) + instruction_result = instruction_integrator.integrate_package_instructions( + cached_package_info, + project_root, + force=force, + managed_files=managed_files, ) if instruction_result.files_integrated > 0: total_instructions_integrated += ( @@ -2066,17 +2317,23 @@ def download_callback(dep_ref, modules_dir): _rich_info( f" └─ {instruction_result.files_integrated} instruction(s) integrated → .github/instructions/" ) - total_links_resolved += instruction_result.links_resolved + total_links_resolved += ( + instruction_result.links_resolved + ) for tp in instruction_result.target_paths: - dep_deployed.append(tp.relative_to(project_root).as_posix()) + dep_deployed.append( + tp.relative_to(project_root).as_posix() + ) - # Claude-specific integration (agents + commands) + # Claude-specific integration (agents) if integrate_claude: # Integrate agents to .claude/agents/ claude_agent_result = ( agent_integrator.integrate_package_agents_claude( - cached_package_info, project_root, - force=force, managed_files=managed_files, + cached_package_info, + project_root, + force=force, + managed_files=managed_files, ) ) if claude_agent_result.files_integrated > 0: @@ -2086,15 +2343,21 @@ def download_callback(dep_ref, modules_dir): _rich_info( f" └─ {claude_agent_result.files_integrated} agents integrated → .claude/agents/" ) - total_links_resolved += claude_agent_result.links_resolved + total_links_resolved += ( + claude_agent_result.links_resolved + ) for tp in claude_agent_result.target_paths: - dep_deployed.append(tp.relative_to(project_root).as_posix()) + dep_deployed.append( + tp.relative_to(project_root).as_posix() + ) - # Generate Claude commands from prompts + if integrate_opencode: command_result = ( command_integrator.integrate_package_commands( - cached_package_info, project_root, - force=force, managed_files=managed_files, + cached_package_info, + project_root, + force=force, + managed_files=managed_files, ) ) if command_result.files_integrated > 0: @@ -2102,7 +2365,7 @@ def download_callback(dep_ref, modules_dir): command_result.files_integrated ) _rich_info( - f" └─ {command_result.files_integrated} commands integrated → .claude/commands/" + f" └─ {command_result.files_integrated} commands integrated → .opencode/commands/" ) if command_result.files_updated > 0: _rich_info( @@ -2110,33 +2373,49 @@ def download_callback(dep_ref, modules_dir): ) total_links_resolved += command_result.links_resolved for tp in command_result.target_paths: - dep_deployed.append(tp.relative_to(project_root).as_posix()) + dep_deployed.append( + tp.relative_to(project_root).as_posix() + ) # Hook integration (target-aware) if integrate_vscode: hook_result = hook_integrator.integrate_package_hooks( - cached_package_info, project_root, - force=force, managed_files=managed_files, + cached_package_info, + project_root, + force=force, + managed_files=managed_files, ) if hook_result.hooks_integrated > 0: - total_hooks_integrated += hook_result.hooks_integrated + total_hooks_integrated += ( + hook_result.hooks_integrated + ) _rich_info( f" └─ {hook_result.hooks_integrated} hook(s) integrated → .github/hooks/" ) for tp in hook_result.target_paths: - dep_deployed.append(tp.relative_to(project_root).as_posix()) + dep_deployed.append( + tp.relative_to(project_root).as_posix() + ) if integrate_claude: - hook_result_claude = hook_integrator.integrate_package_hooks_claude( - cached_package_info, project_root, - force=force, managed_files=managed_files, + hook_result_claude = ( + hook_integrator.integrate_package_hooks_claude( + cached_package_info, + project_root, + force=force, + managed_files=managed_files, + ) ) if hook_result_claude.hooks_integrated > 0: - total_hooks_integrated += hook_result_claude.hooks_integrated + total_hooks_integrated += ( + hook_result_claude.hooks_integrated + ) _rich_info( f" └─ {hook_result_claude.hooks_integrated} hook(s) integrated → .claude/settings.json" ) for tp in hook_result_claude.target_paths: - dep_deployed.append(tp.relative_to(project_root).as_posix()) + dep_deployed.append( + tp.relative_to(project_root).as_posix() + ) # Record deployed files for this package package_deployed_files[dep_key] = dep_deployed @@ -2168,8 +2447,14 @@ def download_callback(dep_ref, modules_dir): # T5: Build download ref - use locked commit if available download_ref = str(dep_ref) if existing_lockfile and not update_refs: - locked_dep = existing_lockfile.get_dependency(dep_ref.get_unique_key()) - if locked_dep and locked_dep.resolved_commit and locked_dep.resolved_commit != "cached": + locked_dep = existing_lockfile.get_dependency( + dep_ref.get_unique_key() + ) + if ( + locked_dep + and locked_dep.resolved_commit + and locked_dep.resolved_commit != "cached" + ): # Override with locked commit for reproducible install base_ref = dep_ref.repo_url if dep_ref.virtual_path: @@ -2198,13 +2483,26 @@ def download_callback(dep_ref, modules_dir): # Collect for lockfile: get resolved commit and depth resolved_commit = None - if hasattr(package_info, 'resolved_reference') and package_info.resolved_reference: - resolved_commit = package_info.resolved_reference.resolved_commit + if ( + hasattr(package_info, "resolved_reference") + and package_info.resolved_reference + ): + resolved_commit = ( + package_info.resolved_reference.resolved_commit + ) # Get depth from dependency tree - node = dependency_graph.dependency_tree.get_node(dep_ref.get_unique_key()) + node = dependency_graph.dependency_tree.get_node( + dep_ref.get_unique_key() + ) depth = node.depth if node else 1 - resolved_by = node.parent.dependency_ref.repo_url if node and node.parent else None - installed_packages.append((dep_ref, resolved_commit, depth, resolved_by)) + resolved_by = ( + node.parent.dependency_ref.repo_url + if node and node.parent + else None + ) + installed_packages.append( + (dep_ref, resolved_commit, depth, resolved_by) + ) dep_deployed_fresh: list = [] # collect deployed paths for this package # Show package type in verbose mode @@ -2213,9 +2511,7 @@ def download_callback(dep_ref, modules_dir): package_type = package_info.package_type if package_type == PackageType.CLAUDE_SKILL: - _rich_info( - f" └─ Package type: Skill (SKILL.md detected)" - ) + _rich_info(f" └─ Package type: Skill (SKILL.md detected)") elif package_type == PackageType.HYBRID: _rich_info( f" └─ Package type: Hybrid (apm.yml + SKILL.md)" @@ -2223,16 +2519,16 @@ def download_callback(dep_ref, modules_dir): elif package_type == PackageType.APM_PACKAGE: _rich_info(f" └─ Package type: APM Package (apm.yml)") - # Auto-integrate prompts and agents if enabled - if integrate_vscode or integrate_claude: + # Auto-integrate primitives if enabled + if integrate_vscode or integrate_claude or integrate_opencode: try: # Integrate prompts + agents (dual-target: .github/ + .claude/) # Integrate prompts - prompt_result = ( - prompt_integrator.integrate_package_prompts( - package_info, project_root, - force=force, managed_files=managed_files, - ) + prompt_result = prompt_integrator.integrate_package_prompts( + package_info, + project_root, + force=force, + managed_files=managed_files, ) if prompt_result.files_integrated > 0: total_prompts_integrated += ( @@ -2248,19 +2544,19 @@ def download_callback(dep_ref, modules_dir): # Track links resolved total_links_resolved += prompt_result.links_resolved for tp in prompt_result.target_paths: - dep_deployed_fresh.append(tp.relative_to(project_root).as_posix()) + dep_deployed_fresh.append( + tp.relative_to(project_root).as_posix() + ) # Integrate agents - agent_result = ( - agent_integrator.integrate_package_agents( - package_info, project_root, - force=force, managed_files=managed_files, - ) + agent_result = agent_integrator.integrate_package_agents( + package_info, + project_root, + force=force, + managed_files=managed_files, ) if agent_result.files_integrated > 0: - total_agents_integrated += ( - agent_result.files_integrated - ) + total_agents_integrated += agent_result.files_integrated _rich_info( f" └─ {agent_result.files_integrated} agents integrated → .github/agents/" ) @@ -2271,13 +2567,16 @@ def download_callback(dep_ref, modules_dir): # Track links resolved total_links_resolved += agent_result.links_resolved for tp in agent_result.target_paths: - dep_deployed_fresh.append(tp.relative_to(project_root).as_posix()) + dep_deployed_fresh.append( + tp.relative_to(project_root).as_posix() + ) - # 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: + # Skill integration + if skill_destinations: skill_result = skill_integrator.integrate_package_skill( - package_info, project_root + package_info, + project_root, + destinations=skill_destinations, ) if skill_result.skill_created: total_skills_integrated += 1 @@ -2285,20 +2584,24 @@ def download_callback(dep_ref, modules_dir): f" └─ Skill integrated → .github/skills/" ) if skill_result.sub_skills_promoted > 0: - total_sub_skills_promoted += skill_result.sub_skills_promoted + total_sub_skills_promoted += ( + skill_result.sub_skills_promoted + ) _rich_info( f" └─ {skill_result.sub_skills_promoted} skill(s) integrated → .github/skills/" ) for tp in skill_result.target_paths: - dep_deployed_fresh.append(tp.relative_to(project_root).as_posix()) + dep_deployed_fresh.append( + tp.relative_to(project_root).as_posix() + ) # Integrate instructions → .github/instructions/ if integrate_vscode: - instruction_result = ( - instruction_integrator.integrate_package_instructions( - package_info, project_root, - force=force, managed_files=managed_files, - ) + instruction_result = instruction_integrator.integrate_package_instructions( + package_info, + project_root, + force=force, + managed_files=managed_files, ) if instruction_result.files_integrated > 0: total_instructions_integrated += ( @@ -2307,17 +2610,23 @@ def download_callback(dep_ref, modules_dir): _rich_info( f" └─ {instruction_result.files_integrated} instruction(s) integrated → .github/instructions/" ) - total_links_resolved += instruction_result.links_resolved + total_links_resolved += ( + instruction_result.links_resolved + ) for tp in instruction_result.target_paths: - dep_deployed_fresh.append(tp.relative_to(project_root).as_posix()) + dep_deployed_fresh.append( + tp.relative_to(project_root).as_posix() + ) - # Claude-specific integration (agents + commands) + # Claude-specific integration (agents) if integrate_claude: # Integrate agents to .claude/agents/ claude_agent_result = ( agent_integrator.integrate_package_agents_claude( - package_info, project_root, - force=force, managed_files=managed_files, + package_info, + project_root, + force=force, + managed_files=managed_files, ) ) if claude_agent_result.files_integrated > 0: @@ -2327,15 +2636,21 @@ def download_callback(dep_ref, modules_dir): _rich_info( f" └─ {claude_agent_result.files_integrated} agents integrated → .claude/agents/" ) - total_links_resolved += claude_agent_result.links_resolved + total_links_resolved += ( + claude_agent_result.links_resolved + ) for tp in claude_agent_result.target_paths: - dep_deployed_fresh.append(tp.relative_to(project_root).as_posix()) + dep_deployed_fresh.append( + tp.relative_to(project_root).as_posix() + ) - # Generate Claude commands from prompts + if integrate_opencode: command_result = ( command_integrator.integrate_package_commands( - package_info, project_root, - force=force, managed_files=managed_files, + package_info, + project_root, + force=force, + managed_files=managed_files, ) ) if command_result.files_integrated > 0: @@ -2343,7 +2658,7 @@ def download_callback(dep_ref, modules_dir): command_result.files_integrated ) _rich_info( - f" └─ {command_result.files_integrated} commands integrated → .claude/commands/" + f" └─ {command_result.files_integrated} commands integrated → .opencode/commands/" ) if command_result.files_updated > 0: _rich_info( @@ -2351,36 +2666,54 @@ def download_callback(dep_ref, modules_dir): ) total_links_resolved += command_result.links_resolved for tp in command_result.target_paths: - dep_deployed_fresh.append(tp.relative_to(project_root).as_posix()) + 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( - package_info, project_root, - force=force, managed_files=managed_files, + package_info, + project_root, + force=force, + managed_files=managed_files, ) if hook_result.hooks_integrated > 0: - total_hooks_integrated += hook_result.hooks_integrated + total_hooks_integrated += ( + hook_result.hooks_integrated + ) _rich_info( f" └─ {hook_result.hooks_integrated} hook(s) integrated → .github/hooks/" ) for tp in hook_result.target_paths: - dep_deployed_fresh.append(tp.relative_to(project_root).as_posix()) + dep_deployed_fresh.append( + tp.relative_to(project_root).as_posix() + ) if integrate_claude: - hook_result_claude = hook_integrator.integrate_package_hooks_claude( - package_info, project_root, - force=force, managed_files=managed_files, + hook_result_claude = ( + hook_integrator.integrate_package_hooks_claude( + package_info, + project_root, + force=force, + managed_files=managed_files, + ) ) if hook_result_claude.hooks_integrated > 0: - total_hooks_integrated += hook_result_claude.hooks_integrated + total_hooks_integrated += ( + hook_result_claude.hooks_integrated + ) _rich_info( f" └─ {hook_result_claude.hooks_integrated} hook(s) integrated → .claude/settings.json" ) for tp in hook_result_claude.target_paths: - dep_deployed_fresh.append(tp.relative_to(project_root).as_posix()) + dep_deployed_fresh.append( + tp.relative_to(project_root).as_posix() + ) # Record deployed files for this package - package_deployed_files[dep_ref.get_unique_key()] = dep_deployed_fresh + package_deployed_files[dep_ref.get_unique_key()] = ( + dep_deployed_fresh + ) except Exception as e: # Don't fail installation if integration fails _rich_warning(f" ⚠ Failed to integrate primitives: {e}") @@ -2402,14 +2735,18 @@ def download_callback(dep_ref, modules_dir): # Generate apm.lock for reproducible installs (T4: lockfile generation) if installed_packages: try: - lockfile = LockFile.from_installed_packages(installed_packages, dependency_graph) + lockfile = LockFile.from_installed_packages( + installed_packages, dependency_graph + ) # Attach deployed_files to each LockedDependency for dep_key, dep_files in package_deployed_files.items(): if dep_key in lockfile.dependencies: lockfile.dependencies[dep_key].deployed_files = dep_files lockfile_path = get_lockfile_path(project_root) lockfile.save(lockfile_path) - _rich_info(f"Generated apm.lock with {len(lockfile.dependencies)} dependencies") + _rich_info( + f"Generated apm.lock with {len(lockfile.dependencies)} dependencies" + ) except Exception as e: _rich_warning(f"Could not generate apm.lock: {e}") @@ -2437,7 +2774,9 @@ def download_callback(dep_ref, modules_dir): raise RuntimeError(f"Failed to resolve APM dependencies: {e}") -def _collect_transitive_mcp_deps(apm_modules_dir: Path, lock_path: Path = None, trust_private: bool = False) -> list: +def _collect_transitive_mcp_deps( + apm_modules_dir: Path, lock_path: Path = None, trust_private: bool = False +) -> list: """Collect MCP dependencies from resolved APM packages listed in apm.lock. Only scans apm.yml files for packages present in apm.lock to avoid @@ -2469,7 +2808,11 @@ def _collect_transitive_mcp_deps(apm_modules_dir: Path, lock_path: Path = None, locked_paths = builtins.set() for dep in lockfile.get_all_dependencies(): if dep.repo_url: - yml = apm_modules_dir / dep.repo_url / dep.virtual_path / "apm.yml" if dep.virtual_path else apm_modules_dir / dep.repo_url / "apm.yml" + yml = ( + apm_modules_dir / dep.repo_url / dep.virtual_path / "apm.yml" + if dep.virtual_path + else apm_modules_dir / dep.repo_url / "apm.yml" + ) locked_paths.add(yml.resolve()) # Prefer iterating lock-derived paths directly (existing files only). @@ -2562,8 +2905,7 @@ def _build_self_defined_server_info(dep) -> dict: env_vars = [] if dep.env: env_vars = [ - {"name": k, "description": "", "required": True} - for k in dep.env + {"name": k, "description": "", "required": True} for k in dep.env ] runtime_args = [] @@ -2618,7 +2960,11 @@ def _apply_mcp_overlay(server_info_cache: dict, dep) -> None: # Package type overlay: select specific package registry (npm, pypi, oci) if dep.package and "packages" in info: - filtered = [p for p in info["packages"] if p.get("registry_name", "").lower() == dep.package.lower()] + filtered = [ + p + for p in info["packages"] + if p.get("registry_name", "").lower() == dep.package.lower() + ] if filtered: info["packages"] = filtered @@ -2682,10 +3028,21 @@ def _install_mcp_dependencies( # Split into registry-resolved and self-defined deps # Backward compat: plain strings are treated as registry deps - registry_deps = [dep for dep in mcp_deps if isinstance(dep, str) or (hasattr(dep, 'is_registry_resolved') and dep.is_registry_resolved)] - self_defined_deps = [dep for dep in mcp_deps if hasattr(dep, 'is_self_defined') and dep.is_self_defined] - registry_dep_names = [dep.name if hasattr(dep, 'name') else dep for dep in registry_deps] - registry_dep_map = {dep.name: dep for dep in registry_deps if hasattr(dep, 'name')} + registry_deps = [ + dep + for dep in mcp_deps + if isinstance(dep, str) + or (hasattr(dep, "is_registry_resolved") and dep.is_registry_resolved) + ] + self_defined_deps = [ + dep + for dep in mcp_deps + if hasattr(dep, "is_self_defined") and dep.is_self_defined + ] + registry_dep_names = [ + dep.name if hasattr(dep, "name") else dep for dep in registry_deps + ] + registry_dep_map = {dep.name: dep for dep in registry_deps if hasattr(dep, "name")} console = _get_console() @@ -2820,7 +3177,9 @@ def _install_mcp_dependencies( # Early validation: check if all servers exist in registry (fail-fast like npm) if verbose: _rich_info(f"Validating {len(registry_deps)} registry servers...") - valid_servers, invalid_servers = operations.validate_servers_exist(registry_dep_names) + valid_servers, invalid_servers = operations.validate_servers_exist( + registry_dep_names + ) if invalid_servers: _rich_error( @@ -2866,7 +3225,9 @@ def _install_mcp_dependencies( # Batch fetch server info once to avoid duplicate registry calls if verbose: _rich_info(f"Installing {len(servers_to_install)} servers...") - server_info_cache = operations.batch_fetch_server_info(servers_to_install) + server_info_cache = operations.batch_fetch_server_info( + servers_to_install + ) # Apply overlays from MCPDependency fields onto fetched server_info for server_name in servers_to_install: @@ -2915,7 +3276,9 @@ def _install_mcp_dependencies( except ImportError: _rich_warning("Registry operations not available") _rich_error("Cannot validate MCP servers without registry operations") - raise RuntimeError("Registry operations module required for MCP installation") + raise RuntimeError( + "Registry operations module required for MCP installation" + ) # --- Self-defined deps (registry: false) --- if self_defined_deps: @@ -2926,7 +3289,9 @@ def _install_mcp_dependencies( if console: transport_label = dep.transport or "stdio" - console.print(f"│ [cyan]⬇️[/cyan] {dep.name} [dim](self-defined, {transport_label})[/dim]") + console.print( + f"│ [cyan]⬇️[/cyan] {dep.name} [dim](self-defined, {transport_label})[/dim]" + ) console.print( f"│ └─ Configuring for {', '.join([rt.title() for rt in target_runtimes])}..." ) @@ -2959,9 +3324,6 @@ def _install_mcp_dependencies( return configured_count - - - def _show_install_summary( apm_count: int, prompt_count: int, agent_count: int, mcp_count: int, apm_config ): @@ -3515,7 +3877,6 @@ def on_modified(self, event): # Check if it's a relevant file if event.src_path.endswith(".md") or event.src_path.endswith("apm.yml"): - # Debounce rapid changes current_time = time.time() if current_time - self.last_compile < self.debounce_delay: @@ -3643,9 +4004,7 @@ def _recompile(self, changed_file): except ImportError: _rich_error("Watch mode requires the 'watchdog' library") _rich_info("Install it with: uv pip install watchdog") - _rich_info( - "Or reinstall APM: uv pip install -e . (from the apm directory)" - ) + _rich_info("Or reinstall APM: uv pip install -e . (from the apm directory)") sys.exit(1) except Exception as e: _rich_error(f"Error in watch mode: {e}") @@ -3662,9 +4021,9 @@ def _recompile(self, changed_file): @click.option( "--target", "-t", - type=click.Choice(["vscode", "agents", "claude", "all"]), + type=click.Choice(["vscode", "agents", "opencode", "claude", "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/opencode (AGENTS.md), claude (CLAUDE.md), or all. Auto-detects if not specified.", ) @click.option( "--dry-run", @@ -3727,7 +4086,7 @@ def compile( Use --single-agents for traditional single-file compilation when needed. Target platforms: - • vscode/agents: Generates AGENTS.md + .github/ structure (VSCode/GitHub Copilot) + • vscode/agents/opencode: Generates AGENTS.md + .github/ structure (VSCode/GitHub Copilot/OpenCode) • claude: Generates CLAUDE.md + .claude/ structure (Claude Code) • all: Generates both targets (default) @@ -3813,6 +4172,7 @@ def compile( # Show MCP dependency validation count try: from apm_cli.models.apm_package import APMPackage + apm_pkg = APMPackage.from_apm_yml(Path("apm.yml")) mcp_count = len(apm_pkg.get_mcp_dependencies()) if mcp_count > 0: @@ -3879,6 +4239,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}" @@ -4040,7 +4402,7 @@ def compile( os.path.getsize(output_path) if not dry_run else 0 ) size_str = ( - f"{file_size/1024:.1f}KB" + f"{file_size / 1024:.1f}KB" if file_size > 0 else "Preview" ) diff --git a/src/apm_cli/compilation/agents_compiler.py b/src/apm_cli/compilation/agents_compiler.py index 8e2cc3aca..fde0efa87 100644 --- a/src/apm_cli/compilation/agents_compiler.py +++ b/src/apm_cli/compilation/agents_compiler.py @@ -16,7 +16,7 @@ build_conditional_sections, generate_agents_md_template, TemplateData, - find_chatmode_by_name + find_chatmode_by_name, ) from .link_resolver import resolve_markdown_links, validate_link_targets @@ -24,29 +24,34 @@ @dataclass class CompilationConfig: """Configuration for AGENTS.md compilation.""" + output_path: str = "AGENTS.md" chatmode: Optional[str] = None resolve_links: bool = True dry_run: bool = False with_constitution: bool = True # Phase 0 feature flag - + # Multi-target compilation settings - # "vscode" or "agents" -> AGENTS.md + .github/ + # "vscode" or "agents" or "opencode" -> AGENTS.md + .github/ # "claude" -> CLAUDE.md + .claude/ # "all" -> both targets target: str = "all" - + # Distributed compilation settings (Task 7) strategy: str = "distributed" # "distributed" or "single-file" single_agents: bool = False # Force single-file mode trace: bool = False # Show source attribution and conflicts local_only: bool = False # Ignore dependencies, compile only local primitives debug: bool = False # Show context optimizer analysis and metrics - min_instructions_per_file: int = 1 # Minimum instructions per AGENTS.md file (Minimal Context Principle) + min_instructions_per_file: int = ( + 1 # Minimum instructions per AGENTS.md file (Minimal Context Principle) + ) source_attribution: bool = True # Include source file comments clean_orphaned: bool = False # Remove orphaned AGENTS.md files - exclude: List[str] = None # Glob patterns for directories to exclude during compilation - + exclude: List[str] = ( + None # Glob patterns for directories to exclude during compilation + ) + def __post_init__(self): """Handle CLI flag precedence after initialization.""" if self.single_agents: @@ -54,31 +59,31 @@ def __post_init__(self): # Initialize exclude list if None if self.exclude is None: self.exclude = [] - + @classmethod def from_apm_yml(cls, **overrides) -> 'CompilationConfig': """Create configuration from apm.yml with command-line overrides. - + Args: **overrides: Command-line arguments that override config file values. - + Returns: CompilationConfig: Configuration with apm.yml values and overrides applied. """ config = cls() - + # Try to load from apm.yml try: from pathlib import Path import yaml - + if Path('apm.yml').exists(): with open('apm.yml', 'r') as f: apm_config = yaml.safe_load(f) or {} - + # Look for compilation section compilation_config = apm_config.get('compilation', {}) - + # Apply config file values if 'output' in compilation_config: config.output_path = compilation_config['output'] @@ -88,7 +93,7 @@ def from_apm_yml(cls, **overrides) -> 'CompilationConfig': config.resolve_links = compilation_config['resolve_links'] if 'target' in compilation_config: config.target = compilation_config['target'] - + # Distributed compilation settings (Task 7) if 'strategy' in compilation_config: config.strategy = compilation_config['strategy'] @@ -97,16 +102,18 @@ def from_apm_yml(cls, **overrides) -> 'CompilationConfig': if compilation_config['single_file']: config.strategy = "single-file" config.single_agents = True - + # Placement settings placement_config = compilation_config.get('placement', {}) if 'min_instructions_per_file' in placement_config: - config.min_instructions_per_file = placement_config['min_instructions_per_file'] - + config.min_instructions_per_file = placement_config[ + "min_instructions_per_file" + ] + # Source attribution if 'source_attribution' in compilation_config: config.source_attribution = compilation_config['source_attribution'] - + # Directory exclusion patterns if 'exclude' in compilation_config: exclude_patterns = compilation_config['exclude'] @@ -115,26 +122,27 @@ def from_apm_yml(cls, **overrides) -> 'CompilationConfig': elif isinstance(exclude_patterns, str): # Support single pattern as string config.exclude = [exclude_patterns] - + except Exception: # If config loading fails, use defaults pass - + # Apply command-line overrides (highest priority) for key, value in overrides.items(): if value is not None: # Only override if explicitly provided setattr(config, key, value) - + # Handle CLI flag precedence if config.single_agents: config.strategy = "single-file" - + return config @dataclass class CompilationResult: """Result of AGENTS.md compilation.""" + success: bool output_path: str content: str @@ -145,35 +153,39 @@ class CompilationResult: class AgentsCompiler: """Main compiler for generating AGENTS.md files.""" - + def __init__(self, base_dir: str = "."): """Initialize the compiler. - + Args: base_dir (str): Base directory for compilation. Defaults to current directory. """ self.base_dir = Path(base_dir) self.warnings: List[str] = [] self.errors: List[str] = [] - - def compile(self, config: CompilationConfig, primitives: Optional[PrimitiveCollection] = None) -> CompilationResult: + + def compile( + self, + config: CompilationConfig, + primitives: Optional[PrimitiveCollection] = None, + ) -> CompilationResult: """Compile AGENTS.md and/or CLAUDE.md based on target configuration. - + Routes compilation to appropriate targets based on config.target: - - "vscode" or "agents": Generate AGENTS.md + .github/ structure - - "claude": Generate CLAUDE.md + .claude/ structure + - "vscode" or "agents" or "opencode": Generate AGENTS.md + .github/ structure + - "claude": Generate CLAUDE.md + .claude/ structure - "all": Generate both targets - + Args: config (CompilationConfig): Compilation configuration. primitives (Optional[PrimitiveCollection]): Primitives to use, or None to discover. - + Returns: CompilationResult: Result of the compilation. """ self.warnings.clear() self.errors.clear() - + try: # Use provided primitives or discover them (with dependency support) if primitives is None: @@ -182,23 +194,28 @@ def compile(self, config: CompilationConfig, primitives: Optional[PrimitiveColle primitives = discover_primitives(str(self.base_dir)) 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)) - + from ..primitives.discovery import ( + discover_primitives_with_dependencies, + ) + + primitives = discover_primitives_with_dependencies( + str(self.base_dir) + ) + # 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 if config.target in ("claude", "all"): results.append(self._compile_claude_md(config, primitives)) - + # Merge results from all targets return self._merge_results(results) - + except Exception as e: self.errors.append(f"Compilation failed: {str(e)}") return CompilationResult( @@ -207,16 +224,18 @@ def compile(self, config: CompilationConfig, primitives: Optional[PrimitiveColle content="", warnings=self.warnings.copy(), errors=self.errors.copy(), - stats={} + stats={}, ) - - def _compile_agents_md(self, config: CompilationConfig, primitives: PrimitiveCollection) -> CompilationResult: + + def _compile_agents_md( + self, config: CompilationConfig, primitives: PrimitiveCollection + ) -> CompilationResult: """Compile AGENTS.md files (VSCode/Copilot target). - + Args: config (CompilationConfig): Compilation configuration. primitives (PrimitiveCollection): Primitives to compile. - + Returns: CompilationResult: Result of the AGENTS.md compilation. """ @@ -226,25 +245,26 @@ def _compile_agents_md(self, config: CompilationConfig, primitives: PrimitiveCol else: # Traditional single-file compilation (backward compatibility) return self._compile_single_file(config, primitives) - - def _compile_distributed(self, config: CompilationConfig, primitives: PrimitiveCollection) -> CompilationResult: + + def _compile_distributed( + self, config: CompilationConfig, primitives: PrimitiveCollection + ) -> CompilationResult: """Compile using distributed AGENTS.md approach (Task 7). - + Args: config (CompilationConfig): Compilation configuration. primitives (PrimitiveCollection): Primitives to compile. - + Returns: CompilationResult: Result of distributed compilation. """ from .distributed_compiler import DistributedAgentsCompiler - + # Create distributed compiler with exclude patterns distributed_compiler = DistributedAgentsCompiler( - str(self.base_dir), - exclude_patterns=config.exclude + str(self.base_dir), exclude_patterns=config.exclude ) - + # Prepare configuration for distributed compilation distributed_config = { 'min_instructions_per_file': config.min_instructions_per_file, @@ -252,28 +272,38 @@ def _compile_distributed(self, config: CompilationConfig, primitives: PrimitiveC 'source_attribution': config.source_attribution, 'debug': config.debug, 'clean_orphaned': config.clean_orphaned, - 'dry_run': config.dry_run + "dry_run": config.dry_run, } - + # Compile distributed - distributed_result = distributed_compiler.compile_distributed(primitives, distributed_config) - + distributed_result = distributed_compiler.compile_distributed( + primitives, distributed_config + ) + # Display professional compilation output (always show, not just in debug) - compilation_results = distributed_compiler.get_compilation_results_for_display(config.dry_run) + compilation_results = distributed_compiler.get_compilation_results_for_display( + config.dry_run + ) if compilation_results: if config.debug or config.trace: # Verbose mode with mathematical analysis - output = distributed_compiler.output_formatter.format_verbose(compilation_results) + output = distributed_compiler.output_formatter.format_verbose( + compilation_results + ) elif config.dry_run: # Dry run mode with placement preview - output = distributed_compiler.output_formatter.format_dry_run(compilation_results) + output = distributed_compiler.output_formatter.format_dry_run( + compilation_results + ) else: # Default mode with essential information - output = distributed_compiler.output_formatter.format_default(compilation_results) - + output = distributed_compiler.output_formatter.format_default( + compilation_results + ) + # Display the professional output print(output) - + if not distributed_result.success: self.warnings.extend(distributed_result.warnings) self.errors.extend(distributed_result.errors) @@ -283,9 +313,9 @@ def _compile_distributed(self, config: CompilationConfig, primitives: PrimitiveC content="", warnings=self.warnings.copy(), errors=self.errors.copy(), - stats=distributed_result.stats + stats=distributed_result.stats, ) - + # Handle dry-run mode (preview placement without writing files) if config.dry_run: # Count files that would be written (directories that exist) @@ -293,11 +323,11 @@ def _compile_distributed(self, config: CompilationConfig, primitives: PrimitiveC for agents_path in distributed_result.content_map.keys(): if agents_path.parent.exists(): successful_writes += 1 - + # Update stats with actual files that would be written if distributed_result.stats: distributed_result.stats["agents_files_generated"] = successful_writes - + # Don't write files in preview mode - output already shown above return CompilationResult( success=True, @@ -305,47 +335,49 @@ def _compile_distributed(self, config: CompilationConfig, primitives: PrimitiveC content=self._generate_placement_summary(distributed_result), warnings=distributed_result.warnings, errors=distributed_result.errors, - stats=distributed_result.stats + stats=distributed_result.stats, ) - + # Write distributed AGENTS.md files successful_writes = 0 total_content_entries = len(distributed_result.content_map) - + for agents_path, content in distributed_result.content_map.items(): try: self._write_distributed_file(agents_path, content, config) successful_writes += 1 except OSError as e: self.errors.append(f"Failed to write {agents_path}: {str(e)}") - + # Update stats with actual files written if distributed_result.stats: distributed_result.stats["agents_files_generated"] = successful_writes - + # Merge warnings and errors self.warnings.extend(distributed_result.warnings) self.errors.extend(distributed_result.errors) - + # Create summary for backward compatibility summary_content = self._generate_distributed_summary(distributed_result, config) - + return CompilationResult( success=len(self.errors) == 0, output_path=f"Distributed: {len(distributed_result.placements)} AGENTS.md files", content=summary_content, warnings=self.warnings.copy(), errors=self.errors.copy(), - stats=distributed_result.stats + stats=distributed_result.stats, ) - - def _compile_single_file(self, config: CompilationConfig, primitives: PrimitiveCollection) -> CompilationResult: + + def _compile_single_file( + self, config: CompilationConfig, primitives: PrimitiveCollection + ) -> CompilationResult: """Compile using traditional single-file approach (backward compatibility). - + Args: config (CompilationConfig): Compilation configuration. primitives (PrimitiveCollection): Primitives to compile. - + Returns: CompilationResult: Result of single-file compilation. """ @@ -353,77 +385,83 @@ def _compile_single_file(self, config: CompilationConfig, primitives: PrimitiveC validation_errors = self.validate_primitives(primitives) if validation_errors: self.errors.extend(validation_errors) - + # Generate template data template_data = self._generate_template_data(primitives, config) - + # Generate final output content = self.generate_output(template_data, config) - + # Write output file (constitution injection handled externally in CLI) output_path = str(self.base_dir / config.output_path) if not config.dry_run: self._write_output_file(output_path, content) - + # Compile statistics stats = self._compile_stats(primitives, template_data) - + return CompilationResult( success=len(self.errors) == 0, output_path=output_path, content=content, warnings=self.warnings.copy(), errors=self.errors.copy(), - stats=stats + stats=stats, ) - - def _compile_claude_md(self, config: CompilationConfig, primitives: PrimitiveCollection) -> CompilationResult: + + def _compile_claude_md( + self, config: CompilationConfig, primitives: PrimitiveCollection + ) -> CompilationResult: """Compile CLAUDE.md files (Claude Code target). - + Uses ClaudeFormatter to generate CLAUDE.md files following Claude's Memory format with @import syntax, grouped project standards, and workflows section for agents/roles. - + Args: config (CompilationConfig): Compilation configuration. primitives (PrimitiveCollection): Primitives to compile. - + Returns: CompilationResult: Result of the CLAUDE.md compilation. """ # Create Claude formatter claude_formatter = ClaudeFormatter(str(self.base_dir)) - + # Get placement map from distributed compiler for consistency from .distributed_compiler import DistributedAgentsCompiler + distributed_compiler = DistributedAgentsCompiler( - str(self.base_dir), - exclude_patterns=config.exclude + str(self.base_dir), exclude_patterns=config.exclude ) - + # Analyze directory structure and determine placement - directory_map = distributed_compiler.analyze_directory_structure(primitives.instructions) + directory_map = distributed_compiler.analyze_directory_structure( + primitives.instructions + ) placement_map = distributed_compiler.determine_agents_placement( primitives.instructions, directory_map, min_instructions=config.min_instructions_per_file, - debug=config.debug + debug=config.debug, ) - + # Format CLAUDE.md files claude_config = { 'source_attribution': config.source_attribution, - 'debug': config.debug + "debug": config.debug, } - claude_result = claude_formatter.format_distributed(primitives, placement_map, claude_config) - + claude_result = claude_formatter.format_distributed( + primitives, placement_map, claude_config + ) + # NOTE: Claude commands are now generated at install time via CommandIntegrator, # not at compile time. This keeps behavior consistent with VSCode prompt integration. - + # Merge warnings and errors (no command result anymore) all_warnings = claude_result.warnings all_errors = claude_result.errors - + # Handle dry-run mode if config.dry_run: # Generate preview summary @@ -436,52 +474,53 @@ def _compile_claude_md(self, config: CompilationConfig, primitives: PrimitiveCol except ValueError: rel_path = claude_path preview_lines.append(f" 📄 {rel_path}") - + return CompilationResult( success=len(all_errors) == 0, output_path="Preview mode - CLAUDE.md", content="\n".join(preview_lines), warnings=all_warnings, errors=all_errors, - stats=claude_result.stats + stats=claude_result.stats, ) - + # Write CLAUDE.md files files_written = 0 for claude_path, content in claude_result.content_map.items(): try: # Create directory if needed claude_path.parent.mkdir(parents=True, exist_ok=True) - + # Handle constitution injection if enabled final_content = content if config.with_constitution: try: from .injector import ConstitutionInjector + injector = ConstitutionInjector(str(claude_path.parent)) final_content, _, _ = injector.inject( - content, - with_constitution=True, - output_path=claude_path + content, with_constitution=True, output_path=claude_path ) except Exception: pass # Use original content if injection fails - + claude_path.write_text(final_content, encoding='utf-8') files_written += 1 except OSError as e: all_errors.append(f"Failed to write {claude_path}: {str(e)}") - + # Update stats stats = claude_result.stats.copy() stats['claude_files_written'] = files_written - + # Display CLAUDE.md compilation output using standard formatter # Get proper compilation results from distributed compiler (has optimization decisions) from ..output.formatters import CompilationFormatter from ..output.models import CompilationResults - - compilation_results = distributed_compiler.get_compilation_results_for_display(is_dry_run=config.dry_run) + + compilation_results = distributed_compiler.get_compilation_results_for_display( + is_dry_run=config.dry_run + ) if compilation_results: # Update target name for CLAUDE.md output formatter_results = CompilationResults( @@ -492,9 +531,9 @@ def _compile_claude_md(self, config: CompilationConfig, primitives: PrimitiveCol warnings=all_warnings, errors=all_errors, is_dry_run=config.dry_run, - target_name="CLAUDE.md" + target_name="CLAUDE.md", ) - + # Use the same formatter as AGENTS.md formatter = CompilationFormatter(use_color=True) if config.debug or config.trace: @@ -504,38 +543,40 @@ def _compile_claude_md(self, config: CompilationConfig, primitives: PrimitiveCol else: output = formatter.format_default(formatter_results) print(output) - + # Generate summary content for result object summary_lines = [ f"# CLAUDE.md Compilation Summary", f"", - f"Generated {files_written} CLAUDE.md files:" + f"Generated {files_written} CLAUDE.md files:", ] for placement in claude_result.placements: try: rel_path = placement.claude_path.relative_to(self.base_dir) except ValueError: rel_path = placement.claude_path - summary_lines.append(f"- {rel_path} ({len(placement.instructions)} instructions)") - + summary_lines.append( + f"- {rel_path} ({len(placement.instructions)} instructions)" + ) + return CompilationResult( success=len(all_errors) == 0, output_path=f"CLAUDE.md: {files_written} files", content="\n".join(summary_lines), warnings=all_warnings, errors=all_errors, - stats=stats + stats=stats, ) - + def _merge_results(self, results: List[CompilationResult]) -> CompilationResult: """Merge multiple compilation results into a single result. - + Combines warnings, errors, stats, and content from multiple target compilations into a unified result. - + Args: results (List[CompilationResult]): List of compilation results to merge. - + Returns: CompilationResult: Merged compilation result. """ @@ -546,60 +587,62 @@ def _merge_results(self, results: List[CompilationResult]) -> CompilationResult: content="", warnings=[], errors=[], - stats={} + stats={}, ) - + if len(results) == 1: return results[0] - + # Merge all results merged_warnings: List[str] = [] merged_errors: List[str] = [] merged_stats: Dict[str, Any] = {} output_paths: List[str] = [] content_parts: List[str] = [] - + for result in results: merged_warnings.extend(result.warnings) merged_errors.extend(result.errors) - + # Merge stats with prefixes to avoid collisions for key, value in result.stats.items(): if key in merged_stats: # Handle numeric stats by summing - if isinstance(value, (int, float)) and isinstance(merged_stats[key], (int, float)): + if isinstance(value, (int, float)) and isinstance( + merged_stats[key], (int, float) + ): merged_stats[key] = merged_stats[key] + value else: merged_stats[key] = value - + if result.output_path: output_paths.append(result.output_path) if result.content: content_parts.append(result.content) - + # Determine overall success overall_success = all(r.success for r in results) - + return CompilationResult( success=overall_success, output_path=" | ".join(output_paths) if output_paths else "", content="\n\n---\n\n".join(content_parts) if content_parts else "", warnings=merged_warnings, errors=merged_errors, - stats=merged_stats + stats=merged_stats, ) - + def validate_primitives(self, primitives: PrimitiveCollection) -> List[str]: """Validate primitives for compilation. - + Args: primitives (PrimitiveCollection): Collection of primitives to validate. - + Returns: List[str]: List of validation errors. """ errors = [] - + # Validate each primitive for primitive in primitives.all_primitives(): primitive_errors = primitive.validate() @@ -610,12 +653,12 @@ def validate_primitives(self, primitives: PrimitiveCollection) -> List[str]: except ValueError: # File is outside base_dir, use absolute path file_path = str(primitive.file_path) - + for error in primitive_errors: # Treat validation errors as warnings instead of hard errors # This allows compilation to continue with incomplete primitives self.warnings.append(f"{file_path}: {error}") - + # Validate markdown links in each primitive's content using its own directory as base if hasattr(primitive, 'content') and primitive.content: primitive_dir = primitive.file_path.parent @@ -625,37 +668,41 @@ def validate_primitives(self, primitives: PrimitiveCollection) -> List[str]: file_path = str(primitive.file_path.relative_to(self.base_dir)) except ValueError: file_path = str(primitive.file_path) - + for link_error in link_errors: self.warnings.append(f"{file_path}: {link_error}") - + return errors - - def generate_output(self, template_data: TemplateData, config: CompilationConfig) -> str: + + def generate_output( + self, template_data: TemplateData, config: CompilationConfig + ) -> str: """Generate the final AGENTS.md output. - + Args: template_data (TemplateData): Data for template generation. config (CompilationConfig): Compilation configuration. - + Returns: str: Generated AGENTS.md content. """ content = generate_agents_md_template(template_data) - + # Resolve markdown links if enabled if config.resolve_links: content = resolve_markdown_links(content, self.base_dir) - + return content - - def _generate_template_data(self, primitives: PrimitiveCollection, config: CompilationConfig) -> TemplateData: + + def _generate_template_data( + self, primitives: PrimitiveCollection, config: CompilationConfig + ) -> TemplateData: """Generate template data from primitives and configuration. - + Args: primitives (PrimitiveCollection): Discovered primitives. config (CompilationConfig): Compilation configuration. - + Returns: TemplateData: Template data for generation. """ @@ -677,12 +724,12 @@ def _generate_template_data(self, primitives: PrimitiveCollection, config: Compi return TemplateData( instructions_content=instructions_content, version=version, - chatmode_content=chatmode_content + chatmode_content=chatmode_content, ) - + def _write_output_file(self, output_path: str, content: str) -> None: """Write the generated content to the output file. - + Args: output_path (str): Path to write the output. content (str): Content to write. @@ -692,14 +739,16 @@ def _write_output_file(self, output_path: str, content: str) -> None: f.write(content) except OSError as e: self.errors.append(f"Failed to write output file {output_path}: {str(e)}") - - def _compile_stats(self, primitives: PrimitiveCollection, template_data: TemplateData) -> Dict[str, Any]: + + def _compile_stats( + self, primitives: PrimitiveCollection, template_data: TemplateData + ) -> Dict[str, Any]: """Compile statistics about the compilation. - + Args: primitives (PrimitiveCollection): Discovered primitives. template_data (TemplateData): Generated template data. - + Returns: Dict[str, Any]: Compilation statistics. """ @@ -710,13 +759,14 @@ def _compile_stats(self, primitives: PrimitiveCollection, template_data: Templat "contexts": len(primitives.contexts), "content_length": len(template_data.instructions_content), # timestamp removed - "version": template_data.version + "version": template_data.version, } - - def _write_distributed_file(self, agents_path: Path, content: str, config: CompilationConfig) -> None: + def _write_distributed_file( + self, agents_path: Path, content: str, config: CompilationConfig + ) -> None: """Write a distributed AGENTS.md file with constitution injection support. - + Args: agents_path (Path): Path to write the AGENTS.md file. content (str): Content to write. @@ -725,40 +775,41 @@ def _write_distributed_file(self, agents_path: Path, content: str, config: Compi try: # Handle constitution injection for distributed files final_content = content - + if config.with_constitution: # Try to inject constitution if available try: from .injector import ConstitutionInjector + injector = ConstitutionInjector(str(agents_path.parent)) final_content, c_status, c_hash = injector.inject( - content, - with_constitution=True, - output_path=agents_path + content, with_constitution=True, output_path=agents_path ) except Exception: # If constitution injection fails, use original content pass - + # Create directory if it doesn't exist agents_path.parent.mkdir(parents=True, exist_ok=True) - + # Write the file with open(agents_path, 'w', encoding='utf-8') as f: f.write(final_content) - + except OSError as e: - raise OSError(f"Failed to write distributed AGENTS.md file {agents_path}: {str(e)}") - + raise OSError( + f"Failed to write distributed AGENTS.md file {agents_path}: {str(e)}" + ) + def _display_placement_preview(self, distributed_result) -> None: """Display placement preview for --show-placement mode. - + Args: distributed_result: Result from distributed compilation. """ print("🔍 Distributed AGENTS.md Placement Preview:") print() - + for placement in distributed_result.placements: try: rel_path = placement.agents_path.relative_to(self.base_dir.resolve()) @@ -772,45 +823,51 @@ def _display_placement_preview(self, distributed_result) -> None: sources = set(placement.source_attribution.values()) print(f" Sources: {', '.join(sorted(sources))}") print() - - def _display_trace_info(self, distributed_result, primitives: PrimitiveCollection) -> None: + + def _display_trace_info( + self, distributed_result, primitives: PrimitiveCollection + ) -> None: """Display detailed trace information for --trace mode. - + Args: distributed_result: Result from distributed compilation. primitives (PrimitiveCollection): Full primitive collection. """ print("🔍 Distributed Compilation Trace:") print() - + for placement in distributed_result.placements: try: rel_path = placement.agents_path.relative_to(self.base_dir.resolve()) except ValueError: rel_path = placement.agents_path print(f"📄 {rel_path}") - + for instruction in placement.instructions: source = getattr(instruction, 'source', 'local') try: - inst_path = instruction.file_path.relative_to(self.base_dir.resolve()) + inst_path = instruction.file_path.relative_to( + self.base_dir.resolve() + ) except ValueError: inst_path = instruction.file_path - - print(f" • {instruction.apply_to or 'no pattern'} <- {source} {inst_path}") + + print( + f" • {instruction.apply_to or 'no pattern'} <- {source} {inst_path}" + ) print() - + def _generate_placement_summary(self, distributed_result) -> str: """Generate a text summary of placement results. - + Args: distributed_result: Result from distributed compilation. - + Returns: str: Text summary of placements. """ lines = ["Distributed AGENTS.md Placement Summary:", ""] - + for placement in distributed_result.placements: try: rel_path = placement.agents_path.relative_to(self.base_dir.resolve()) @@ -818,19 +875,23 @@ def _generate_placement_summary(self, distributed_result) -> str: rel_path = placement.agents_path lines.append(f"📄 {rel_path}") lines.append(f" Instructions: {len(placement.instructions)}") - lines.append(f" Patterns: {', '.join(sorted(placement.coverage_patterns))}") + lines.append( + f" Patterns: {', '.join(sorted(placement.coverage_patterns))}" + ) lines.append("") - + lines.append(f"Total AGENTS.md files: {len(distributed_result.placements)}") return "\n".join(lines) - - def _generate_distributed_summary(self, distributed_result, config: CompilationConfig) -> str: + + def _generate_distributed_summary( + self, distributed_result, config: CompilationConfig + ) -> str: """Generate a summary of distributed compilation results. - + Args: distributed_result: Result from distributed compilation. config (CompilationConfig): Compilation configuration. - + Returns: str: Summary content. """ @@ -838,24 +899,26 @@ def _generate_distributed_summary(self, distributed_result, config: CompilationC "# Distributed AGENTS.md Compilation Summary", "", f"Generated {len(distributed_result.placements)} AGENTS.md files:", - "" + "", ] - + for placement in distributed_result.placements: try: rel_path = placement.agents_path.relative_to(self.base_dir.resolve()) except ValueError: rel_path = placement.agents_path lines.append(f"- {rel_path} ({len(placement.instructions)} instructions)") - - lines.extend([ - "", - f"Total instructions: {distributed_result.stats.get('total_instructions_placed', 0)}", - f"Total patterns: {distributed_result.stats.get('total_patterns_covered', 0)}", - "", - "Use 'apm compile --single-agents' for traditional single-file compilation." - ]) - + + lines.extend( + [ + "", + f"Total instructions: {distributed_result.stats.get('total_instructions_placed', 0)}", + f"Total patterns: {distributed_result.stats.get('total_patterns_covered', 0)}", + "", + "Use 'apm compile --single-agents' for traditional single-file compilation.", + ] + ) + return "\n".join(lines) @@ -864,17 +927,17 @@ def compile_agents_md( output_path: str = "AGENTS.md", chatmode: Optional[str] = None, dry_run: bool = False, - base_dir: str = "." + base_dir: str = ".", ) -> str: """Generate AGENTS.md with conditional sections. - + Args: primitives (Optional[PrimitiveCollection]): Primitives to use, or None to discover. output_path (str): Output file path. Defaults to "AGENTS.md". chatmode (str): Specific chatmode to use, or None for default. dry_run (bool): If True, don't write output file. Defaults to False. base_dir (str): Base directory for compilation. Defaults to current directory. - + Returns: str: Generated AGENTS.md content. """ @@ -883,14 +946,14 @@ def compile_agents_md( output_path=output_path, chatmode=chatmode, dry_run=dry_run, - strategy="single-file" # Force single-file mode for backward compatibility + strategy="single-file", # Force single-file mode for backward compatibility ) - + # Create compiler and compile compiler = AgentsCompiler(base_dir) result = compiler.compile(config, primitives) - + if not result.success: raise RuntimeError(f"Compilation failed: {'; '.join(result.errors)}") - - return result.content \ No newline at end of file + + return result.content diff --git a/src/apm_cli/core/target_detection.py b/src/apm_cli/core/target_detection.py index cd7190ab7..8161c4fb8 100644 --- a/src/apm_cli/core/target_detection.py +++ b/src/apm_cli/core/target_detection.py @@ -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( @@ -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 @@ -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 """ @@ -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 """ @@ -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") diff --git a/src/apm_cli/integration/base_integrator.py b/src/apm_cli/integration/base_integrator.py index a71084018..dd40ee1cf 100644 --- a/src/apm_cli/integration/base_integrator.py +++ b/src/apm_cli/integration/base_integrator.py @@ -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. @@ -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) diff --git a/src/apm_cli/integration/command_integrator.py b/src/apm_cli/integration/command_integrator.py index bebcf7589..3c7c08340 100644 --- a/src/apm_cli/integration/command_integrator.py +++ b/src/apm_cli/integration/command_integrator.py @@ -1,11 +1,12 @@ -"""Claude command integration functionality for APM packages. +"""OpenCode command integration functionality for APM packages. -Integrates .prompt.md files as .claude/commands/ during install, +Integrates .prompt.md files as .opencode/commands/ during install, mirroring how PromptIntegrator handles .github/prompts/. """ from pathlib import Path -from typing import List, Dict +from typing import Dict, List + import frontmatter from apm_cli.integration.base_integrator import BaseIntegrator, IntegrationResult @@ -15,170 +16,147 @@ class CommandIntegrator(BaseIntegrator): - """Handles integration of APM package prompts into .claude/commands/. - - Transforms .prompt.md files into Claude Code custom slash commands - during package installation, following the same pattern as PromptIntegrator. - """ - + """Handles integration of APM package prompts into .opencode/commands/.""" + def find_prompt_files(self, package_path: Path) -> List[Path]: """Find all .prompt.md files in a package.""" return self.find_files_by_glob( - package_path, "*.prompt.md", subdirs=[".apm/prompts"] + package_path, + "*.prompt.md", + subdirs=[".apm/prompts"], ) - - def _transform_prompt_to_command(self, source: Path) -> tuple: - """Transform a .prompt.md file into Claude command format. - - Args: - source: Path to the .prompt.md file - - Returns: - Tuple[str, frontmatter.Post, List[str]]: (command_name, post, warnings) - """ + + def _transform_prompt_to_command( + self, source: Path + ) -> tuple[str, frontmatter.Post, List[str]]: + """Transform a .prompt.md file into OpenCode command format.""" warnings: List[str] = [] - - post = frontmatter.load(source) - - # Extract command name from filename + post = frontmatter.load(str(source)) + filename = source.name if filename.endswith('.prompt.md'): - command_name = filename[:-len('.prompt.md')] + command_name = filename[: -len(".prompt.md")] else: command_name = source.stem - - # Build Claude command frontmatter (preserve existing, add Claude-specific) - claude_metadata = {} - - # Map APM frontmatter to Claude frontmatter - if 'description' in post.metadata: - claude_metadata['description'] = post.metadata['description'] - - if 'allowed-tools' in post.metadata: - claude_metadata['allowed-tools'] = post.metadata['allowed-tools'] - elif 'allowedTools' in post.metadata: - claude_metadata['allowed-tools'] = post.metadata['allowedTools'] - - if 'model' in post.metadata: - claude_metadata['model'] = post.metadata['model'] - - if 'argument-hint' in post.metadata: - claude_metadata['argument-hint'] = post.metadata['argument-hint'] - elif 'argumentHint' in post.metadata: - claude_metadata['argument-hint'] = post.metadata['argumentHint'] - - # Create new post with Claude metadata + + opencode_metadata = {} + if "description" in post.metadata: + opencode_metadata["description"] = post.metadata["description"] + if "agent" in post.metadata: + opencode_metadata["agent"] = post.metadata["agent"] + if "model" in post.metadata: + opencode_metadata["model"] = post.metadata["model"] + if "subtask" in post.metadata: + opencode_metadata["subtask"] = post.metadata["subtask"] + new_post = frontmatter.Post(post.content) - new_post.metadata = claude_metadata - - return (command_name, new_post, warnings) - - def integrate_command(self, source: Path, target: Path, package_info, original_path: Path) -> int: - """Integrate a prompt file as a Claude command (verbatim copy with format conversion). - - Args: - source: Source .prompt.md file path - target: Target command file path in .claude/commands/ - package_info: PackageInfo object with package metadata - original_path: Original path to the prompt file - - Returns: - int: Number of links resolved - """ - # Transform to command format - command_name, post, warnings = self._transform_prompt_to_command(source) - - # Resolve context links in content + new_post.metadata = opencode_metadata + return command_name, new_post, warnings + + def integrate_command( + self, + source: Path, + target: Path, + package_info, + original_path: Path, + ) -> int: + """Integrate a prompt file as an OpenCode command.""" + _command_name, post, _warnings = self._transform_prompt_to_command(source) post.content, links_resolved = self.resolve_links(post.content, source, target) - - # Ensure target directory exists + target.parent.mkdir(parents=True, exist_ok=True) - - # Write the command file - with open(target, 'w', encoding='utf-8') as f: + with open(target, "w", encoding="utf-8") as f: f.write(frontmatter.dumps(post)) - + return links_resolved - - def integrate_package_commands(self, package_info, project_root: Path, - force: bool = False, - managed_files: set = None) -> IntegrationResult: - """Integrate all prompt files from a package as Claude commands. - - Deploys with clean filenames. Skips user-authored files unless force=True. - """ - commands_dir = project_root / ".claude" / "commands" + + def integrate_package_commands( + self, + package_info, + project_root: Path, + force: bool = False, + managed_files: set | None = 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 + 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')] + 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): files_skipped += 1 continue - + links_resolved = self.integrate_command( - prompt_file, target_path, package_info, prompt_file + 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 + links_resolved=total_links_resolved, ) - - def sync_integration(self, apm_package, project_root: Path, - managed_files: set = None) -> Dict: - """Remove APM-managed command files from .claude/commands/.""" - commands_dir = project_root / ".claude" / "commands" + + def sync_integration( + self, + apm_package, + project_root: Path, + managed_files: set | None = None, + ) -> Dict[str, int]: + """Remove APM-managed command files from .opencode/commands/.""" + commands_dir = project_root / ".opencode" / "commands" return self.sync_remove_files( project_root, managed_files, - prefix=".claude/commands/", + 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. - - Uses *managed_files* when available; falls back to legacy glob. - """ + + def remove_package_commands( + self, + package_name: str, + project_root: Path, + managed_files: set | None = None, + ) -> int: + """Remove APM-managed command files.""" stats = self.sync_remove_files( project_root, managed_files, - prefix=".claude/commands/", - legacy_glob_dir=project_root / ".claude" / "commands", + prefix=".opencode/commands/", + legacy_glob_dir=project_root / ".opencode" / "commands", legacy_glob_pattern="*-apm.md", ) return stats["files_removed"] diff --git a/src/apm_cli/integration/skill_integrator.py b/src/apm_cli/integration/skill_integrator.py index f4b544ff8..5b65dce08 100644 --- a/src/apm_cli/integration/skill_integrator.py +++ b/src/apm_cli/integration/skill_integrator.py @@ -1,7 +1,7 @@ """Skill integration functionality for APM packages (Claude Code support).""" from pathlib import Path -from typing import List, Dict +from typing import Dict, Iterable, List, Literal, TYPE_CHECKING from dataclasses import dataclass from datetime import datetime import hashlib @@ -13,9 +13,16 @@ from apm_cli.integration.base_integrator import BaseIntegrator +SkillDestination = Literal["github", "claude", "opencode"] + +if TYPE_CHECKING: + from apm_cli.models.apm_package import PackageContentType + + @dataclass class SkillIntegrationResult: """Result of skill integration operation.""" + skill_created: bool skill_updated: bool skill_skipped: bool @@ -23,8 +30,10 @@ class SkillIntegrationResult: references_copied: int # Now tracks total files copied to subdirectories links_resolved: int = 0 # Kept for backwards compatibility sub_skills_promoted: int = 0 # Number of sub-skills promoted to top-level - target_paths: List[Path] = None # All deployed directories (for deployed_files manifest) - + target_paths: List[Path] = ( + None # All deployed directories (for deployed_files manifest) + ) + def __post_init__(self): if self.target_paths is None: self.target_paths = [] @@ -32,48 +41,48 @@ def __post_init__(self): def to_hyphen_case(name: str) -> str: """Convert a package name to hyphen-case for Claude Skills spec. - + Args: name: Package name (e.g., "owner/repo" or "MyPackage") - + Returns: str: Hyphen-case name, max 64 chars (e.g., "owner-repo" or "my-package") """ # Extract just the repo name if it's owner/repo format if "/" in name: name = name.split("/")[-1] - + # Replace underscores and spaces with hyphens result = name.replace("_", "-").replace(" ", "-") - + # Insert hyphens before uppercase letters (camelCase to hyphen-case) result = re.sub(r'([a-z])([A-Z])', r'\1-\2', result) - + # Convert to lowercase and remove any invalid characters result = re.sub(r'[^a-z0-9-]', '', result.lower()) - + # Remove consecutive hyphens result = re.sub(r'-+', '-', result) - + # Remove leading/trailing hyphens result = result.strip('-') - + # Truncate to 64 chars (Claude Skills spec limit) return result[:64] def validate_skill_name(name: str) -> tuple[bool, str]: """Validate skill name per agentskills.io spec. - + Skill names must: - Be 1-64 characters long - Contain only lowercase alphanumeric characters and hyphens (a-z, 0-9, -) - Not contain consecutive hyphens (--) - Not start or end with a hyphen - + Args: name: Skill name to validate - + Returns: tuple[bool, str]: (is_valid, error_message) - is_valid: True if name is valid, False otherwise @@ -82,21 +91,21 @@ def validate_skill_name(name: str) -> tuple[bool, str]: # Check length if len(name) < 1: return (False, "Skill name cannot be empty") - + if len(name) > 64: return (False, f"Skill name must be 1-64 characters (got {len(name)})") - + # Check for consecutive hyphens if '--' in name: return (False, "Skill name cannot contain consecutive hyphens (--)") - + # Check for leading/trailing hyphens if name.startswith('-'): return (False, "Skill name cannot start with a hyphen") - + if name.endswith('-'): return (False, "Skill name cannot end with a hyphen") - + # Check for valid characters (lowercase alphanumeric + hyphens only) # Pattern: must start and end with alphanumeric, with alphanumeric or hyphens in between pattern = r'^[a-z0-9]([a-z0-9-]*[a-z0-9])?$' @@ -104,26 +113,32 @@ def validate_skill_name(name: str) -> tuple[bool, str]: # Determine specific error if any(c.isupper() for c in name): return (False, "Skill name must be lowercase (no uppercase letters)") - + if '_' in name: - return (False, "Skill name cannot contain underscores (use hyphens instead)") - - if ' ' in name: + return ( + False, + "Skill name cannot contain underscores (use hyphens instead)", + ) + + if " " in name: return (False, "Skill name cannot contain spaces (use hyphens instead)") - + # Check for other invalid characters invalid_chars = set(re.findall(r'[^a-z0-9-]', name)) if invalid_chars: - return (False, f"Skill name contains invalid characters: {', '.join(sorted(invalid_chars))}") - + return ( + False, + f"Skill name contains invalid characters: {', '.join(sorted(invalid_chars))}", + ) + return (False, "Skill name must be lowercase alphanumeric with hyphens only") - + return (True, "") def normalize_skill_name(name: str) -> str: """Convert any package name to a valid skill name per agentskills.io spec. - + Normalization steps: 1. Extract repo name if owner/repo format 2. Convert to lowercase @@ -133,10 +148,10 @@ def normalize_skill_name(name: str) -> str: 6. Remove consecutive hyphens 7. Strip leading/trailing hyphens 8. Truncate to 64 characters - + Args: name: Package name to normalize (e.g., "owner/MyRepo_Name") - + Returns: str: Valid skill name (e.g., "my-repo-name") """ @@ -156,57 +171,58 @@ def normalize_skill_name(name: str) -> str: # - Packages with SKILL.md OR explicit type: skill/hybrid → become skills # - Packages with only instructions → compile to AGENTS.md, NOT skills -def get_effective_type(package_info) -> "PackageContentType": + +def get_effective_type(package_info): """Get effective package content type based on package structure. - + Determines type by: 1. Package has SKILL.md (PackageType.CLAUDE_SKILL or HYBRID) → SKILL 2. Otherwise → INSTRUCTIONS (compile to AGENTS.md only) - + Args: package_info: PackageInfo object containing package metadata - + Returns: PackageContentType: The effective type """ from apm_cli.models.apm_package import PackageContentType, PackageType - + # Check if package has SKILL.md (via package_type field) # PackageType.CLAUDE_SKILL = has SKILL.md only # PackageType.HYBRID = has both apm.yml AND SKILL.md if package_info.package_type in (PackageType.CLAUDE_SKILL, PackageType.HYBRID): return PackageContentType.SKILL - + # Default to INSTRUCTIONS for packages without SKILL.md return PackageContentType.INSTRUCTIONS def should_install_skill(package_info) -> bool: """Determine if package should be installed as a native skill. - + This controls whether a package gets installed to .github/skills/ (or .claude/skills/). - + Per skill-strategy.md Decision 2 - "Skills are explicit, not implicit": - + Returns True for: - SKILL: Package has SKILL.md or declares type: skill - HYBRID: Package declares type: hybrid in apm.yml - + Returns False for: - INSTRUCTIONS: Compile to AGENTS.md only, no skill created - PROMPTS: Commands/prompts only, no skill created - Packages without SKILL.md and no explicit type field - + Args: package_info: PackageInfo object containing package metadata - + Returns: bool: True if package should be installed as a native skill """ from apm_cli.models.apm_package import PackageContentType - + effective_type = get_effective_type(package_info) - + # SKILL and HYBRID should install as skills # INSTRUCTIONS and PROMPTS should NOT install as skills return effective_type in (PackageContentType.SKILL, PackageContentType.HYBRID) @@ -214,62 +230,66 @@ def should_install_skill(package_info) -> bool: def should_compile_instructions(package_info) -> bool: """Determine if package should compile to AGENTS.md/CLAUDE.md. - + This controls whether a package's instructions are included in compiled output. - + Per skill-strategy.md Decision 2: - + Returns True for: - INSTRUCTIONS: Compile to AGENTS.md only (default for packages without SKILL.md) - HYBRID: Package declares type: hybrid in apm.yml - + Returns False for: - SKILL: Install as native skill only, no AGENTS.md compilation - PROMPTS: Commands/prompts only, no instructions compiled - + Args: package_info: PackageInfo object containing package metadata - + Returns: bool: True if package's instructions should be compiled to AGENTS.md/CLAUDE.md """ from apm_cli.models.apm_package import PackageContentType - + effective_type = get_effective_type(package_info) - + # INSTRUCTIONS and HYBRID should compile to AGENTS.md # SKILL and PROMPTS should NOT compile to AGENTS.md - return effective_type in (PackageContentType.INSTRUCTIONS, PackageContentType.HYBRID) + return effective_type in ( + PackageContentType.INSTRUCTIONS, + PackageContentType.HYBRID, + ) def copy_skill_to_target( package_info, source_path: Path, target_base: Path, + destinations: Iterable[SkillDestination] | None = None, ) -> tuple[Path | None, Path | None]: """Copy skill directory to .github/skills/ and optionally .claude/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. - + 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): - github_path: Path to .github/skills/{name}/ or None if skipped @@ -278,165 +298,181 @@ def copy_skill_to_target( # Check if package type allows skill installation (T4 routing) if not should_install_skill(package_info): return (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) - + # Get and validate skill name from folder raw_skill_name = source_path.name - + is_valid, error_msg = validate_skill_name(raw_skill_name) if is_valid: skill_name = raw_skill_name else: skill_name = normalize_skill_name(raw_skill_name) - - # === Primary target: .github/skills/ === - github_skill_dir = target_base / ".github" / "skills" / skill_name - - # Create .github/skills/ if it doesn't exist - github_skill_dir.parent.mkdir(parents=True, exist_ok=True) - - # If skill already exists, remove it for update - if github_skill_dir.exists(): - shutil.rmtree(github_skill_dir) - - # Copy the entire skill folder preserving structure - # This copies SKILL.md, scripts/, references/, assets/, etc. - shutil.copytree(source_path, github_skill_dir) - - # === Secondary target: .claude/skills/ (T7 - compatibility copy) === - claude_skill_dir: Path | None = None - claude_dir = target_base / ".claude" - - # Only copy to .claude/skills/ if .claude/ directory already exists - # Do NOT create .claude/ folder if it doesn't exist - if claude_dir.exists() and claude_dir.is_dir(): - claude_skill_dir = claude_dir / "skills" / skill_name - - # Create .claude/skills/ if needed (but .claude/ must already exist) - claude_skill_dir.parent.mkdir(parents=True, exist_ok=True) - - # If skill already exists, remove it for update - if claude_skill_dir.exists(): - shutil.rmtree(claude_skill_dir) - - # Copy the entire skill folder (identical to github copy) - shutil.copytree(source_path, claude_skill_dir) - - return (github_skill_dir, claude_skill_dir) + + destination_paths = SkillIntegrator._resolve_target_skill_roots( + target_base, destinations + ) + + github_skill_dir = destination_paths.get("github") + claude_skill_dir = destination_paths.get("claude") + + for skill_root in destination_paths.values(): + skill_dir = skill_root / skill_name + skill_dir.parent.mkdir(parents=True, exist_ok=True) + if skill_dir.exists(): + shutil.rmtree(skill_dir) + shutil.copytree(source_path, skill_dir) + + return ( + github_skill_dir / skill_name if github_skill_dir else None, + claude_skill_dir / skill_name if claude_skill_dir else None, + ) class SkillIntegrator(BaseIntegrator): """Handles integration of native SKILL.md files for Claude Code and VS Code. - + Claude Skills Spec: - SKILL.md files provide structured context for Claude Code - YAML frontmatter with name, description, and metadata - Markdown body with instructions and agent definitions - references/ subdirectory for prompt files """ - + + @staticmethod + def _resolve_target_skill_roots( + project_root: Path, + destinations: Iterable[SkillDestination] | None = None, + ) -> Dict[SkillDestination, Path]: + """Resolve active target skill roots based on requested destinations.""" + roots: Dict[SkillDestination, Path] = {} + if destinations is None: + roots["github"] = project_root / ".github" / "skills" + claude_dir = project_root / ".claude" + if claude_dir.exists() and claude_dir.is_dir(): + roots["claude"] = claude_dir / "skills" + opencode_dir = project_root / ".opencode" + if opencode_dir.exists() and opencode_dir.is_dir(): + roots["opencode"] = opencode_dir / "skills" + return roots + + requested = set(destinations) + if "github" in requested: + roots["github"] = project_root / ".github" / "skills" + if "claude" in requested: + roots["claude"] = project_root / ".claude" / "skills" + if "opencode" in requested: + roots["opencode"] = project_root / ".opencode" / "skills" + return roots + def find_instruction_files(self, package_path: Path) -> List[Path]: """Find all instruction files in a package. - + Searches in: - .apm/instructions/ subdirectory - + Args: package_path: Path to the package directory - + Returns: List[Path]: List of absolute paths to instruction files """ instruction_files = [] - + # Search in .apm/instructions/ apm_instructions = package_path / ".apm" / "instructions" if apm_instructions.exists(): instruction_files.extend(apm_instructions.glob("*.instructions.md")) - + return instruction_files - + def find_agent_files(self, package_path: Path) -> List[Path]: """Find all agent files in a package. - + Searches in: - .apm/agents/ subdirectory - + Args: package_path: Path to the package directory - + Returns: List[Path]: List of absolute paths to agent files """ agent_files = [] - + # Search in .apm/agents/ apm_agents = package_path / ".apm" / "agents" if apm_agents.exists(): agent_files.extend(apm_agents.glob("*.agent.md")) - + return agent_files - + def find_prompt_files(self, package_path: Path) -> List[Path]: """Find all prompt files in a package. - + Searches in: - Package root directory - .apm/prompts/ subdirectory - + Args: package_path: Path to the package directory - + Returns: List[Path]: List of absolute paths to prompt files """ prompt_files = [] - + # Search in package root if package_path.exists(): prompt_files.extend(package_path.glob("*.prompt.md")) - + # Search in .apm/prompts/ apm_prompts = package_path / ".apm" / "prompts" if apm_prompts.exists(): prompt_files.extend(apm_prompts.glob("*.prompt.md")) - + return prompt_files - + def find_context_files(self, package_path: Path) -> List[Path]: """Find all context/memory files in a package. - + Searches in: - .apm/context/ subdirectory - .apm/memory/ subdirectory - + Args: package_path: Path to the package directory - + Returns: List[Path]: List of absolute paths to context files """ context_files = [] - + # Search in .apm/context/ apm_context = package_path / ".apm" / "context" if apm_context.exists(): context_files.extend(apm_context.glob("*.context.md")) - + # Search in .apm/memory/ apm_memory = package_path / ".apm" / "memory" if apm_memory.exists(): context_files.extend(apm_memory.glob("*.memory.md")) - + return context_files - + @staticmethod - def _promote_sub_skills(sub_skills_dir: Path, target_skills_root: Path, parent_name: str, *, warn: bool = True) -> tuple[int, list[Path]]: + def _promote_sub_skills( + sub_skills_dir: Path, + target_skills_root: Path, + parent_name: str, + *, + warn: bool = True, + ) -> tuple[int, list[Path]]: """Promote sub-skills from .apm/skills/ to top-level skill entries. Args: @@ -465,6 +501,7 @@ def _promote_sub_skills(sub_skills_dir: Path, target_skills_root: Path, parent_n if warn: try: from apm_cli.cli import _rich_warning + _rich_warning( f"Sub-skill '{sub_name}' from '{parent_name}' overwrites existing skill at {target_skills_root.name}/{sub_name}/" ) @@ -478,7 +515,10 @@ def _promote_sub_skills(sub_skills_dir: Path, target_skills_root: Path, parent_n return promoted, deployed def _promote_sub_skills_standalone( - self, package_info, project_root: Path + self, + package_info, + project_root: Path, + destinations: Iterable[SkillDestination] | None = None, ) -> tuple[int, list[Path]]: """Promote sub-skills from a package that is NOT itself a skill. @@ -500,64 +540,67 @@ def _promote_sub_skills_standalone( return 0, [] parent_name = package_path.name - github_skills_root = project_root / ".github" / "skills" - github_skills_root.mkdir(parents=True, exist_ok=True) - count, deployed = self._promote_sub_skills( - sub_skills_dir, github_skills_root, parent_name, warn=True - ) - all_deployed = list(deployed) - - # Also promote into .claude/skills/ when .claude/ exists - claude_dir = project_root / ".claude" - if claude_dir.exists() and claude_dir.is_dir(): - claude_skills_root = claude_dir / "skills" - _, claude_deployed = self._promote_sub_skills( - sub_skills_dir, claude_skills_root, parent_name, warn=False + target_roots = self._resolve_target_skill_roots(project_root, destinations) + count = 0 + all_deployed: list[Path] = [] + for destination, skills_root in target_roots.items(): + skills_root.mkdir(parents=True, exist_ok=True) + promoted, deployed = self._promote_sub_skills( + sub_skills_dir, + skills_root, + parent_name, + warn=destination == "github", ) - all_deployed.extend(claude_deployed) + if destination == "github": + count = promoted + all_deployed.extend(deployed) return count, all_deployed def _integrate_native_skill( - self, package_info, project_root: Path, source_skill_md: Path + self, + package_info, + project_root: Path, + source_skill_md: Path, + destinations: Iterable[SkillDestination] | None = None, ) -> SkillIntegrationResult: """Copy a native Skill (with existing SKILL.md) to .github/skills/ and optionally .claude/skills/. - + For packages that already have a SKILL.md at their root (like those from - awesome-claude-skills), we copy the entire skill folder rather than + awesome-claude-skills), we copy the entire skill folder rather than regenerating from .apm/ primitives. - + The skill folder name is the source folder name (e.g., `mcp-builder`), validated and normalized per the agentskills.io spec. - + Source SKILL.md is copied verbatim — no metadata injection. Orphan detection uses apm.lock via directory name matching instead. - + T7 Enhancement: Also copies to .claude/skills/ when .claude/ folder exists. This ensures Claude Code users get skills while not polluting projects that don't use Claude. - + Copies: - SKILL.md (required) - scripts/ (optional) - references/ (optional) - assets/ (optional) - Any other subdirectories the package contains - + Args: package_info: PackageInfo object with package metadata project_root: Root directory of the project source_skill_md: Path to the source SKILL.md file - + Returns: SkillIntegrationResult: Results of the integration operation """ package_path = package_info.install_path - + # Use the source folder name as the skill name # e.g., apm_modules/ComposioHQ/awesome-claude-skills/mcp-builder → mcp-builder raw_skill_name = package_path.name - + # Validate skill name per agentskills.io spec is_valid, error_msg = validate_skill_name(raw_skill_name) if is_valid: @@ -568,87 +611,100 @@ def _integrate_native_skill( # Log warning about name normalization (import here to avoid circular import) try: from apm_cli.cli import _rich_warning + _rich_warning( f"Skill name '{raw_skill_name}' normalized to '{skill_name}' ({error_msg})" ) except ImportError: pass # CLI not available in tests - - # Primary target: .github/skills/ - github_skill_dir = project_root / ".github" / "skills" / skill_name - github_skill_md = github_skill_dir / "SKILL.md" - + + target_roots = self._resolve_target_skill_roots(project_root, destinations) + if not target_roots: + 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=[], + ) + + if "github" in target_roots: + primary_skill_dir = target_roots["github"] / skill_name + else: + primary_skill_dir = next(iter(target_roots.values())) / skill_name + primary_skill_md = primary_skill_dir / "SKILL.md" + # Always copy — source integrity is preserved, orphan detection uses apm.lock - skill_created = not github_skill_dir.exists() + skill_created = not primary_skill_dir.exists() skill_updated = not skill_created - + files_copied = 0 - claude_skill_dir: Path | None = None - - # === Copy to .github/skills/ (primary) === - if github_skill_dir.exists(): - shutil.rmtree(github_skill_dir) - - github_skill_dir.parent.mkdir(parents=True, exist_ok=True) - shutil.copytree(package_path, github_skill_dir, - ignore=shutil.ignore_patterns('.apm')) - - files_copied = sum(1 for _ in github_skill_dir.rglob('*') if _.is_file()) - - # Track deployed paths - all_target_paths = [github_skill_dir] - - # === Promote sub-skills to top-level entries === - # Packages may contain sub-skills in .apm/skills/*/ subdirectories. - # Copilot only discovers .github/skills//SKILL.md (direct children), - # so we promote each sub-skill to an independent top-level entry. + + all_target_paths: list[Path] = [] + for destination, skills_root in target_roots.items(): + skill_dir = skills_root / skill_name + if skill_dir.exists(): + shutil.rmtree(skill_dir) + + skill_dir.parent.mkdir(parents=True, exist_ok=True) + shutil.copytree( + package_path, + skill_dir, + ignore=shutil.ignore_patterns(".apm"), + ) + all_target_paths.append(skill_dir) + + if destination == "github": + files_copied = sum(1 for _ in skill_dir.rglob("*") if _.is_file()) + + if files_copied == 0: + files_copied = sum(1 for _ in primary_skill_dir.rglob("*") if _.is_file()) + sub_skills_dir = package_path / ".apm" / "skills" - github_skills_root = project_root / ".github" / "skills" - sub_skills_count, sub_deployed = self._promote_sub_skills(sub_skills_dir, github_skills_root, skill_name, warn=True) - all_target_paths.extend(sub_deployed) - - # === T7: Copy to .claude/skills/ (secondary - compatibility) === - claude_dir = project_root / ".claude" - if claude_dir.exists() and claude_dir.is_dir(): - claude_skill_dir = claude_dir / "skills" / skill_name - - if claude_skill_dir.exists(): - shutil.rmtree(claude_skill_dir) - - claude_skill_dir.parent.mkdir(parents=True, exist_ok=True) - shutil.copytree(package_path, claude_skill_dir, - ignore=shutil.ignore_patterns('.apm')) - all_target_paths.append(claude_skill_dir) - - # Promote sub-skills for Claude too - claude_skills_root = claude_dir / "skills" - _, claude_sub_deployed = self._promote_sub_skills(sub_skills_dir, claude_skills_root, skill_name, warn=False) - all_target_paths.extend(claude_sub_deployed) - + sub_skills_count = 0 + for destination, skills_root in target_roots.items(): + promoted, deployed = self._promote_sub_skills( + sub_skills_dir, + skills_root, + skill_name, + warn=destination == "github", + ) + if destination == "github": + sub_skills_count = promoted + all_target_paths.extend(deployed) + return SkillIntegrationResult( skill_created=skill_created, skill_updated=skill_updated, skill_skipped=False, - skill_path=github_skill_md, + skill_path=primary_skill_md, references_copied=files_copied, links_resolved=0, sub_skills_promoted=sub_skills_count, - target_paths=all_target_paths + target_paths=all_target_paths, ) - def integrate_package_skill(self, package_info, project_root: Path) -> SkillIntegrationResult: + def integrate_package_skill( + self, + package_info, + project_root: Path, + destinations: Iterable[SkillDestination] | None = None, + ) -> SkillIntegrationResult: """Integrate a package's skill into .github/skills/ directory. - + Copies native skills (packages with SKILL.md at root) to .github/skills/ and optionally .claude/skills/. Also promotes any sub-skills from .apm/skills/. - + Packages without SKILL.md at root are not installed as skills — only their sub-skills (if any) are promoted. - + Args: package_info: PackageInfo object with package metadata project_root: Root directory of the project - + Returns: SkillIntegrationResult: Results of the integration operation """ @@ -659,7 +715,9 @@ def integrate_package_skill(self, package_info, project_root: Path) -> SkillInte # Even non-skill packages may ship sub-skills under .apm/skills/. # Promote them so Copilot can discover them independently. sub_skills_count, sub_deployed = self._promote_sub_skills_standalone( - package_info, project_root + package_info, + project_root, + destinations, ) return SkillIntegrationResult( skill_created=False, @@ -669,9 +727,9 @@ def integrate_package_skill(self, package_info, project_root: Path) -> SkillInte references_copied=0, links_resolved=0, sub_skills_promoted=sub_skills_count, - target_paths=sub_deployed + target_paths=sub_deployed, ) - + # Skip virtual FILE and COLLECTION packages - they're individual files, not full packages # Multiple virtual files from the same repo would collide on skill name # BUT: subdirectory packages (like Claude Skills) SHOULD generate skills @@ -684,20 +742,27 @@ def integrate_package_skill(self, package_info, project_root: Path) -> SkillInte skill_skipped=True, skill_path=None, references_copied=0, - links_resolved=0 + links_resolved=0, ) - + package_path = package_info.install_path - + # Check if this is a native Skill (already has SKILL.md at root) source_skill_md = package_path / "SKILL.md" if source_skill_md.exists(): - return self._integrate_native_skill(package_info, project_root, source_skill_md) - + return self._integrate_native_skill( + package_info, + project_root, + source_skill_md, + destinations, + ) + # No SKILL.md at root — not a skill package. # Still promote any sub-skills shipped under .apm/skills/. sub_skills_count, sub_deployed = self._promote_sub_skills_standalone( - package_info, project_root + package_info, + project_root, + destinations, ) return SkillIntegrationResult( skill_created=False, @@ -707,22 +772,27 @@ def integrate_package_skill(self, package_info, project_root: Path) -> SkillInte references_copied=0, links_resolved=0, sub_skills_promoted=sub_skills_count, - target_paths=sub_deployed + target_paths=sub_deployed, ) - - def sync_integration(self, apm_package, project_root: Path, - managed_files: set = None) -> Dict[str, int]: + + def sync_integration( + self, + apm_package, + project_root: Path, + managed_files: set | None = None, + destinations: Iterable[SkillDestination] | None = None, + ) -> Dict[str, int]: """Sync .github/skills/ and .claude/skills/ with currently installed packages. - + When *managed_files* is provided, only removes skill directories whose paths appear in the set. Otherwise falls back to npm-style orphan detection (derives expected names from installed dependencies). - + Args: apm_package: APMPackage with current dependencies project_root: Root directory of the project managed_files: Set of relative paths known to be APM-managed - + Returns: Dict with cleanup statistics """ @@ -735,6 +805,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 @@ -748,7 +819,7 @@ def sync_integration(self, apm_package, project_root: Path, except Exception: stats['errors'] += 1 return stats - + # Legacy fallback: npm-style orphan detection # Build set of expected skill directory names from installed packages installed_skill_names = set() @@ -759,49 +830,53 @@ def sync_integration(self, apm_package, project_root: Path, is_valid, _ = validate_skill_name(raw_name) skill_name = raw_name if is_valid else normalize_skill_name(raw_name) installed_skill_names.add(skill_name) - + # Also include promoted sub-skills from installed packages install_path = dep.get_install_path(project_root / "apm_modules") sub_skills_dir = install_path / ".apm" / "skills" if sub_skills_dir.is_dir(): for sub_skill_path in sub_skills_dir.iterdir(): - if sub_skill_path.is_dir() and (sub_skill_path / "SKILL.md").exists(): + if ( + sub_skill_path.is_dir() + and (sub_skill_path / "SKILL.md").exists() + ): raw_sub = sub_skill_path.name is_valid, _ = validate_skill_name(raw_sub) - installed_skill_names.add(raw_sub if is_valid else normalize_skill_name(raw_sub)) - - # Clean .github/skills/ (primary) - github_skills_dir = project_root / ".github" / "skills" - if github_skills_dir.exists(): - result = self._clean_orphaned_skills(github_skills_dir, installed_skill_names) - stats['files_removed'] += result['files_removed'] - stats['errors'] += result['errors'] - - # Clean .claude/skills/ (secondary - T7 compatibility) - claude_skills_dir = project_root / ".claude" / "skills" - if claude_skills_dir.exists(): - result = self._clean_orphaned_skills(claude_skills_dir, installed_skill_names) - stats['files_removed'] += result['files_removed'] - stats['errors'] += result['errors'] - + installed_skill_names.add( + raw_sub if is_valid else normalize_skill_name(raw_sub) + ) + + target_roots = self._resolve_target_skill_roots(project_root, destinations) + for skills_dir in target_roots.values(): + if not skills_dir.exists(): + continue + result = self._clean_orphaned_skills( + 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]: + + def _clean_orphaned_skills( + self, skills_dir: Path, installed_skill_names: set + ) -> Dict[str, int]: """Clean orphaned skills from a skills directory. - + Uses npm-style approach: any skill directory not matching an installed package name is considered orphaned and removed. - + Args: skills_dir: Path to skills directory (.github/skills/ or .claude/skills/) installed_skill_names: Set of expected skill directory names - + Returns: Dict with cleanup statistics """ files_removed = 0 errors = 0 - + for skill_subdir in skills_dir.iterdir(): if skill_subdir.is_dir(): if skill_subdir.name not in installed_skill_names: @@ -810,7 +885,5 @@ def _clean_orphaned_skills(self, skills_dir: Path, installed_skill_names: set) - files_removed += 1 except Exception: errors += 1 - - return {'files_removed': files_removed, 'errors': errors} - + return {"files_removed": files_removed, "errors": errors} diff --git a/tests/unit/compilation/test_compile_target_flag.py b/tests/unit/compilation/test_compile_target_flag.py index 7178d1246..52ac8bb88 100644 --- a/tests/unit/compilation/test_compile_target_flag.py +++ b/tests/unit/compilation/test_compile_target_flag.py @@ -45,6 +45,11 @@ def test_target_can_be_set_to_agents(self): config = CompilationConfig(target="agents") assert config.target == "agents" + def test_target_can_be_set_to_opencode(self): + """Test that target can be set to 'opencode'.""" + config = CompilationConfig(target="opencode") + assert config.target == "opencode" + def test_target_can_be_set_to_claude(self): """Test that target can be set to 'claude'.""" config = CompilationConfig(target="claude") @@ -69,10 +74,10 @@ def temp_project(self): """Create a temporary project directory with APM structure.""" temp_dir = tempfile.mkdtemp() temp_path = Path(temp_dir) - + # Create minimal apm.yml (temp_path / "apm.yml").write_text("name: test-project\nversion: 0.1.0\n") - + # Create instruction file apm_dir = temp_path / ".apm" / "instructions" apm_dir.mkdir(parents=True) @@ -82,7 +87,7 @@ def temp_project(self): --- Use type hints in Python code. """) - + yield temp_path shutil.rmtree(temp_dir, ignore_errors=True) @@ -90,7 +95,7 @@ def temp_project(self): def sample_primitives(self, temp_project): """Create sample primitives for testing.""" primitives = PrimitiveCollection() - + instruction = Instruction( name="python-style", file_path=temp_project / ".apm/instructions/test.instructions.md", @@ -98,10 +103,10 @@ def sample_primitives(self, temp_project): apply_to="**/*.py", content="Use type hints in Python code.", author="test", - source="local" + source="local", ) primitives.add_primitive(instruction) - + return primitives def test_target_vscode_generates_agents_md(self, temp_project, sample_primitives): @@ -109,12 +114,12 @@ def test_target_vscode_generates_agents_md(self, temp_project, sample_primitives config = CompilationConfig( target="vscode", dry_run=True, - single_agents=True # Use single-file mode for simpler test + single_agents=True, # Use single-file mode for simpler test ) - + compiler = AgentsCompiler(str(temp_project)) result = compiler.compile(config, sample_primitives) - + assert result.success # Output path should be for AGENTS.md assert "AGENTS.md" in result.output_path @@ -124,37 +129,51 @@ def test_target_vscode_generates_agents_md(self, temp_project, sample_primitives def test_target_agents_is_alias_for_vscode(self, temp_project, sample_primitives): """Test that target='agents' produces same result as 'vscode'.""" config_vscode = CompilationConfig( - target="vscode", - dry_run=True, - single_agents=True + target="vscode", dry_run=True, single_agents=True ) - + config_agents = CompilationConfig( - target="agents", - dry_run=True, - single_agents=True + target="agents", dry_run=True, single_agents=True ) - + compiler = AgentsCompiler(str(temp_project)) - + result_vscode = compiler.compile(config_vscode, sample_primitives) result_agents = compiler.compile(config_agents, sample_primitives) - + assert result_vscode.success == result_agents.success # Both should reference AGENTS.md assert "AGENTS.md" in result_vscode.output_path assert "AGENTS.md" in result_agents.output_path + def test_target_opencode_routes_to_agents_compilation_path( + self, temp_project, sample_primitives + ): + """Test that target='opencode' compiles through AGENTS.md path.""" + config_vscode = CompilationConfig( + target="vscode", dry_run=True, single_agents=True + ) + + config_opencode = CompilationConfig( + target="opencode", dry_run=True, single_agents=True + ) + + compiler = AgentsCompiler(str(temp_project)) + + result_vscode = compiler.compile(config_vscode, sample_primitives) + result_opencode = compiler.compile(config_opencode, sample_primitives) + + assert result_vscode.success == result_opencode.success + assert "AGENTS.md" in result_vscode.output_path + assert "AGENTS.md" in result_opencode.output_path + def test_target_claude_generates_claude_md(self, temp_project, sample_primitives): """Test that target='claude' generates CLAUDE.md files.""" - config = CompilationConfig( - target="claude", - dry_run=True - ) - + config = CompilationConfig(target="claude", dry_run=True) + compiler = AgentsCompiler(str(temp_project)) result = compiler.compile(config, sample_primitives) - + assert result.success # Output path should reference CLAUDE.md assert "CLAUDE" in result.output_path @@ -164,12 +183,12 @@ def test_target_all_generates_both(self, temp_project, sample_primitives): config = CompilationConfig( target="all", dry_run=True, - single_agents=True # Use single-file for AGENTS.md + single_agents=True, # Use single-file for AGENTS.md ) - + compiler = AgentsCompiler(str(temp_project)) result = compiler.compile(config, sample_primitives) - + assert result.success # Output path should mention both targets assert "AGENTS.md" in result.output_path or "CLAUDE" in result.output_path @@ -186,7 +205,7 @@ def compiler(self): def test_merge_empty_results_list(self, compiler): """Test merging an empty results list.""" result = compiler._merge_results([]) - + assert result.success is True assert result.output_path == "" assert result.content == "" @@ -202,11 +221,11 @@ def test_merge_single_result(self, compiler): content="# Test content", warnings=["warning1"], errors=[], - stats={"test": 1} + stats={"test": 1}, ) - + result = compiler._merge_results([single_result]) - + assert result.success is True assert result.output_path == "AGENTS.md" assert result.content == "# Test content" @@ -221,20 +240,20 @@ def test_merge_multiple_results_success(self, compiler): content="AGENTS content", warnings=["agents warning"], errors=[], - stats={"agents_files_generated": 2} + stats={"agents_files_generated": 2}, ) - + result2 = CompilationResult( success=True, output_path="CLAUDE.md: 1 files", content="CLAUDE content", warnings=["claude warning"], errors=[], - stats={"claude_files_written": 1} + stats={"claude_files_written": 1}, ) - + merged = compiler._merge_results([result1, result2]) - + assert merged.success is True assert "AGENTS.md" in merged.output_path assert "CLAUDE" in merged.output_path @@ -251,20 +270,20 @@ def test_merge_results_with_one_failure(self, compiler): content="Success", warnings=[], errors=[], - stats={} + stats={}, ) - + result2 = CompilationResult( success=False, output_path="CLAUDE.md", content="", warnings=[], errors=["Failed to compile"], - stats={} + stats={}, ) - + merged = compiler._merge_results([result1, result2]) - + assert merged.success is False assert "Failed to compile" in merged.errors @@ -276,20 +295,20 @@ def test_merge_results_combines_numeric_stats(self, compiler): content="", warnings=[], errors=[], - stats={"primitives_found": 5, "instructions": 3} + stats={"primitives_found": 5, "instructions": 3}, ) - + result2 = CompilationResult( success=True, output_path="B", content="", warnings=[], errors=[], - stats={"primitives_found": 2, "claude_files_written": 1} + stats={"primitives_found": 2, "claude_files_written": 1}, ) - + merged = compiler._merge_results([result1, result2]) - + # Same key should be summed assert merged.stats["primitives_found"] == 7 # Different keys should be kept @@ -304,20 +323,20 @@ def test_merge_results_preserves_all_warnings_and_errors(self, compiler): content="", warnings=["warn1", "warn2"], errors=[], - stats={} + stats={}, ) - + result2 = CompilationResult( success=True, output_path="B", content="", warnings=["warn3"], errors=["error1"], - stats={} + stats={}, ) - + merged = compiler._merge_results([result1, result2]) - + assert len(merged.warnings) == 3 assert "warn1" in merged.warnings assert "warn2" in merged.warnings @@ -333,20 +352,20 @@ def test_merge_results_joins_output_paths(self, compiler): content="", warnings=[], errors=[], - stats={} + stats={}, ) - + result2 = CompilationResult( success=True, output_path="CLAUDE.md: 2 files", content="", warnings=[], errors=[], - stats={} + stats={}, ) - + merged = compiler._merge_results([result1, result2]) - + assert " | " in merged.output_path assert "Distributed" in merged.output_path assert "CLAUDE.md" in merged.output_path @@ -359,20 +378,20 @@ def test_merge_results_joins_content_with_separator(self, compiler): content="Content A", warnings=[], errors=[], - stats={} + stats={}, ) - + result2 = CompilationResult( success=True, output_path="B", content="Content B", warnings=[], errors=[], - stats={} + stats={}, ) - + merged = compiler._merge_results([result1, result2]) - + assert "---" in merged.content assert "Content A" in merged.content assert "Content B" in merged.content @@ -391,10 +410,10 @@ def temp_project(self): """Create a temporary project directory.""" temp_dir = tempfile.mkdtemp() temp_path = Path(temp_dir) - + # Create minimal apm.yml (temp_path / "apm.yml").write_text("name: test-project\nversion: 0.1.0\n") - + # Create instruction file for compilation apm_dir = temp_path / ".apm" / "instructions" apm_dir.mkdir(parents=True) @@ -404,7 +423,7 @@ def temp_project(self): --- Use type hints in Python code. """) - + yield temp_path shutil.rmtree(temp_dir, ignore_errors=True) @@ -414,7 +433,7 @@ def test_target_flag_accepts_vscode(self, runner, temp_project): try: os.chdir(temp_project) result = runner.invoke(cli, ["compile", "--target", "vscode", "--dry-run"]) - + # Should not fail due to invalid choice assert "Invalid value for '--target'" not in result.output finally: @@ -426,18 +445,47 @@ def test_target_flag_accepts_agents(self, runner, temp_project): try: os.chdir(temp_project) result = runner.invoke(cli, ["compile", "--target", "agents", "--dry-run"]) - + + assert "Invalid value for '--target'" not in result.output + finally: + os.chdir(original_dir) + + def test_target_flag_accepts_opencode(self, runner, temp_project): + """Test that --target opencode is accepted.""" + original_dir = os.getcwd() + try: + os.chdir(temp_project) + result = runner.invoke( + cli, ["compile", "--target", "opencode", "--dry-run"] + ) + assert "Invalid value for '--target'" not in result.output finally: os.chdir(original_dir) + def test_target_opencode_shows_opencode_specific_compile_message( + self, runner, temp_project + ): + """Test that opencode target message is OpenCode-specific.""" + original_dir = os.getcwd() + try: + os.chdir(temp_project) + result = runner.invoke( + cli, ["compile", "--target", "opencode", "--dry-run"] + ) + + assert result.exit_code == 0 + assert "Compiling for AGENTS.md (OpenCode)" in result.output + finally: + os.chdir(original_dir) + def test_target_flag_accepts_claude(self, runner, temp_project): """Test that --target claude is accepted.""" original_dir = os.getcwd() try: os.chdir(temp_project) result = runner.invoke(cli, ["compile", "--target", "claude", "--dry-run"]) - + assert "Invalid value for '--target'" not in result.output finally: os.chdir(original_dir) @@ -448,7 +496,7 @@ def test_target_flag_accepts_all(self, runner, temp_project): try: os.chdir(temp_project) result = runner.invoke(cli, ["compile", "--target", "all", "--dry-run"]) - + assert "Invalid value for '--target'" not in result.output finally: os.chdir(original_dir) @@ -459,7 +507,7 @@ def test_target_flag_rejects_invalid(self, runner, temp_project): try: os.chdir(temp_project) result = runner.invoke(cli, ["compile", "--target", "invalid", "--dry-run"]) - + assert result.exit_code != 0 assert "Invalid value for '--target'" in result.output finally: @@ -472,7 +520,7 @@ def test_target_default_is_all(self, runner, temp_project): os.chdir(temp_project) # Run compile with dry-run to just test config result = runner.invoke(cli, ["compile", "--dry-run"]) - + # Should succeed and compile for all targets # Exit code should be 0 (success) since we have valid primitives assert result.exit_code == 0 or "No APM content" in result.output @@ -485,7 +533,7 @@ def test_short_flag_t_works(self, runner, temp_project): try: os.chdir(temp_project) result = runner.invoke(cli, ["compile", "-t", "vscode", "--dry-run"]) - + assert "Invalid value for '--target'" not in result.output finally: os.chdir(original_dir) @@ -499,10 +547,10 @@ def temp_project(self): """Create a temporary project directory.""" temp_dir = tempfile.mkdtemp() temp_path = Path(temp_dir) - + # Create minimal apm.yml (temp_path / "apm.yml").write_text("name: test-project\nversion: 0.1.0\n") - + # Create instruction file apm_dir = temp_path / ".apm" / "instructions" apm_dir.mkdir(parents=True) @@ -512,21 +560,17 @@ def temp_project(self): --- Use type hints. """) - + yield temp_path shutil.rmtree(temp_dir, ignore_errors=True) def test_vscode_target_does_not_create_claude_md(self, temp_project): """Test that --target vscode doesn't create CLAUDE.md.""" - config = CompilationConfig( - target="vscode", - dry_run=False, - single_agents=True - ) - + config = CompilationConfig(target="vscode", dry_run=False, single_agents=True) + compiler = AgentsCompiler(str(temp_project)) primitives = PrimitiveCollection() - + instruction = Instruction( name="test", file_path=temp_project / ".apm/instructions/test.instructions.md", @@ -534,19 +578,19 @@ def test_vscode_target_does_not_create_claude_md(self, temp_project): apply_to="**/*.py", content="Use type hints.", author="test", - source="local" + source="local", ) primitives.add_primitive(instruction) - + result = compiler.compile(config, primitives) - + # Should succeed assert result.success - + # AGENTS.md should be created agents_md = temp_project / "AGENTS.md" assert agents_md.exists() - + # CLAUDE.md should NOT be created claude_md = temp_project / "CLAUDE.md" assert not claude_md.exists() @@ -560,10 +604,10 @@ def temp_project(self): """Create a temporary project directory.""" temp_dir = tempfile.mkdtemp() temp_path = Path(temp_dir) - + # Create minimal apm.yml (temp_path / "apm.yml").write_text("name: test-project\nversion: 0.1.0\n") - + # Create instruction file apm_dir = temp_path / ".apm" / "instructions" apm_dir.mkdir(parents=True) @@ -573,20 +617,17 @@ def temp_project(self): --- Use type hints. """) - + yield temp_path shutil.rmtree(temp_dir, ignore_errors=True) def test_claude_target_does_not_create_agents_md(self, temp_project): """Test that --target claude doesn't create AGENTS.md.""" - config = CompilationConfig( - target="claude", - dry_run=False - ) - + config = CompilationConfig(target="claude", dry_run=False) + compiler = AgentsCompiler(str(temp_project)) primitives = PrimitiveCollection() - + instruction = Instruction( name="test", file_path=temp_project / ".apm/instructions/test.instructions.md", @@ -594,19 +635,19 @@ def test_claude_target_does_not_create_agents_md(self, temp_project): apply_to="**/*.py", content="Use type hints.", author="test", - source="local" + source="local", ) primitives.add_primitive(instruction) - + result = compiler.compile(config, primitives) - + # Should succeed assert result.success - + # CLAUDE.md should be created (in root or with distributed) claude_md = temp_project / "CLAUDE.md" assert claude_md.exists() - + # AGENTS.md should NOT be created at root # (checking root AGENTS.md since distributed could create subdirectory ones) agents_md = temp_project / "AGENTS.md" @@ -621,10 +662,10 @@ def temp_project(self): """Create a temporary project directory.""" temp_dir = tempfile.mkdtemp() temp_path = Path(temp_dir) - + # Create minimal apm.yml (temp_path / "apm.yml").write_text("name: test-project\nversion: 0.1.0\n") - + # Create instruction file apm_dir = temp_path / ".apm" / "instructions" apm_dir.mkdir(parents=True) @@ -634,7 +675,7 @@ def temp_project(self): --- Use type hints. """) - + yield temp_path shutil.rmtree(temp_dir, ignore_errors=True) @@ -643,12 +684,12 @@ def test_all_target_creates_both_files(self, temp_project): config = CompilationConfig( target="all", dry_run=False, - single_agents=True # Use single-file for simpler verification + single_agents=True, # Use single-file for simpler verification ) - + compiler = AgentsCompiler(str(temp_project)) primitives = PrimitiveCollection() - + instruction = Instruction( name="test", file_path=temp_project / ".apm/instructions/test.instructions.md", @@ -656,33 +697,29 @@ def test_all_target_creates_both_files(self, temp_project): apply_to="**/*.py", content="Use type hints.", author="test", - source="local" + source="local", ) primitives.add_primitive(instruction) - + result = compiler.compile(config, primitives) - + # Should succeed assert result.success - + # Both files should be created agents_md = temp_project / "AGENTS.md" claude_md = temp_project / "CLAUDE.md" - + assert agents_md.exists(), "AGENTS.md should be created for target='all'" assert claude_md.exists(), "CLAUDE.md should be created for target='all'" def test_all_target_result_references_both(self, temp_project): """Test that --target all result references both outputs.""" - config = CompilationConfig( - target="all", - dry_run=True, - single_agents=True - ) - + config = CompilationConfig(target="all", dry_run=True, single_agents=True) + compiler = AgentsCompiler(str(temp_project)) primitives = PrimitiveCollection() - + instruction = Instruction( name="test", file_path=temp_project / ".apm/instructions/test.instructions.md", @@ -690,12 +727,12 @@ def test_all_target_result_references_both(self, temp_project): apply_to="**/*.py", content="Use type hints.", author="test", - source="local" + source="local", ) primitives.add_primitive(instruction) - + result = compiler.compile(config, primitives) - + assert result.success # The merged output path should reference both targets assert "AGENTS.md" in result.output_path or "CLAUDE" in result.output_path @@ -703,7 +740,7 @@ def test_all_target_result_references_both(self, temp_project): class TestClaudeAndAgentsMdConsistentOutput: """Tests to ensure CLAUDE.md and AGENTS.md use the same optimization logic. - + Both targets should produce: - Same optimization decisions (placement table) - Same efficiency metrics @@ -716,11 +753,11 @@ def temp_project_with_instructions(self): """Create a temporary project with instruction files.""" temp_dir = tempfile.mkdtemp() temp_path = Path(temp_dir) - + # Create .apm directory with instructions apm_dir = temp_path / ".apm" / "instructions" apm_dir.mkdir(parents=True) - + # Create instruction file that targets specific pattern (apm_dir / "code-standards.instructions.md").write_text("""--- applyTo: "**/*.py" @@ -729,7 +766,7 @@ def temp_project_with_instructions(self): # Python Coding Standards Follow PEP 8 guidelines. """) - + # Create another instruction file with different pattern (apm_dir / "test-guidelines.instructions.md").write_text("""--- applyTo: "tests/**/*.py" @@ -738,63 +775,70 @@ def temp_project_with_instructions(self): # Testing Guidelines Use pytest for all tests. """) - + # Create target directories to match patterns (temp_path / "src").mkdir() (temp_path / "src" / "main.py").write_text("# Main file") (temp_path / "tests").mkdir() (temp_path / "tests" / "test_main.py").write_text("# Test file") - + # Create apm.yml (temp_path / "apm.yml").write_text(""" name: test-project version: 0.1.0 """) - + yield temp_path shutil.rmtree(temp_dir, ignore_errors=True) - def test_claude_and_agents_have_same_placement_count(self, temp_project_with_instructions): + def test_claude_and_agents_have_same_placement_count( + self, temp_project_with_instructions + ): """Test that CLAUDE.md and AGENTS.md generate the same number of placement files.""" compiler = AgentsCompiler(str(temp_project_with_instructions)) - + # Compile for VSCode/AGENTS.md vscode_config = CompilationConfig(target="vscode", dry_run=True) vscode_result = compiler.compile(vscode_config) - + # Reset compiler state compiler = AgentsCompiler(str(temp_project_with_instructions)) - - # Compile for Claude/CLAUDE.md + + # Compile for Claude/CLAUDE.md claude_config = CompilationConfig(target="claude", dry_run=True) claude_result = compiler.compile(claude_config) - + # Both should succeed assert vscode_result.success assert claude_result.success - + # Both should have the same file count in stats (using target-specific keys) - vscode_file_count = vscode_result.stats.get('agents_files_generated', vscode_result.stats.get('total_agents_files', 0)) - claude_file_count = claude_result.stats.get('claude_files_generated', 0) - + vscode_file_count = vscode_result.stats.get( + "agents_files_generated", vscode_result.stats.get("total_agents_files", 0) + ) + claude_file_count = claude_result.stats.get("claude_files_generated", 0) + # The file counts should be equal (same optimization logic) - assert vscode_file_count == claude_file_count, \ + assert vscode_file_count == claude_file_count, ( f"File counts differ: AGENTS.md={vscode_file_count}, CLAUDE.md={claude_file_count}" + ) - def test_claude_compilation_produces_optimization_output(self, temp_project_with_instructions): + def test_claude_compilation_produces_optimization_output( + self, temp_project_with_instructions + ): """Test that CLAUDE.md compilation produces proper optimization metrics.""" compiler = AgentsCompiler(str(temp_project_with_instructions)) - - # Compile for Claude/CLAUDE.md + + # Compile for Claude/CLAUDE.md claude_config = CompilationConfig(target="claude", dry_run=True) claude_result = compiler.compile(claude_config) - + # Should succeed assert claude_result.success - + # Should have file count assert claude_result.stats.get('claude_files_generated', 0) > 0 - + # Should have primitives count assert claude_result.stats.get('primitives_found', 0) > 0 @@ -807,7 +851,7 @@ def temp_project_with_config(self): """Create a temporary project with apm.yml containing compilation config.""" temp_dir = tempfile.mkdtemp() temp_path = Path(temp_dir) - + yield temp_path shutil.rmtree(temp_dir, ignore_errors=True) @@ -820,7 +864,7 @@ def test_target_from_apm_yml(self, temp_project_with_config): compilation: target: claude """) - + original_dir = os.getcwd() try: os.chdir(temp_project_with_config) @@ -829,6 +873,62 @@ def test_target_from_apm_yml(self, temp_project_with_config): finally: os.chdir(original_dir) + def test_target_opencode_from_apm_yml_maps_to_agents_semantics( + self, temp_project_with_config + ): + """Test that apm.yml target opencode compiles AGENTS target path.""" + apm_yml = temp_project_with_config / "apm.yml" + apm_yml.write_text(""" +name: test-project +version: 0.1.0 +compilation: + target: opencode +""") + + original_dir = os.getcwd() + try: + os.chdir(temp_project_with_config) + config = CompilationConfig.from_apm_yml() + assert config.target == "opencode" + + compiler = AgentsCompiler(str(temp_project_with_config)) + primitives = PrimitiveCollection() + instruction = Instruction( + name="test", + file_path=temp_project_with_config + / ".apm/instructions/test.instructions.md", + description="Test", + apply_to="**/*.py", + content="Use type hints.", + author="test", + source="local", + ) + (temp_project_with_config / ".apm" / "instructions").mkdir( + parents=True, exist_ok=True + ) + ( + temp_project_with_config + / ".apm" + / "instructions" + / "test.instructions.md" + ).write_text("""--- +applyTo: "**/*.py" +--- +Use type hints. +""") + primitives.add_primitive(instruction) + + result = compiler.compile( + CompilationConfig( + target=config.target, dry_run=True, single_agents=True + ), + primitives, + ) + assert result.success + assert "AGENTS.md" in result.output_path + finally: + os.chdir(original_dir) + def test_cli_override_takes_precedence(self, temp_project_with_config): """Test that CLI --target overrides apm.yml config.""" apm_yml = temp_project_with_config / "apm.yml" @@ -838,7 +938,7 @@ def test_cli_override_takes_precedence(self, temp_project_with_config): compilation: target: claude """) - + original_dir = os.getcwd() try: os.chdir(temp_project_with_config) diff --git a/tests/unit/core/test_target_detection.py b/tests/unit/core/test_target_detection.py index 3a7574ff0..2784429f6 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, @@ -18,13 +19,13 @@ def test_explicit_target_vscode_wins(self, tmp_path): # Create both folders - should still use explicit (tmp_path / ".github").mkdir() (tmp_path / ".claude").mkdir() - + target, reason = detect_target( project_root=tmp_path, explicit_target="vscode", config_target="claude", ) - + assert target == "vscode" assert reason == "explicit --target flag" @@ -34,19 +35,29 @@ def test_explicit_target_agents_maps_to_vscode(self, tmp_path): project_root=tmp_path, explicit_target="agents", ) - + assert target == "vscode" assert reason == "explicit --target flag" + def test_explicit_target_opencode_stays_opencode(self, tmp_path): + """Explicit --target opencode remains opencode.""" + target, reason = detect_target( + project_root=tmp_path, + explicit_target="opencode", + ) + + assert target == "opencode" + assert reason == "explicit --target flag" + def test_explicit_target_claude_wins(self, tmp_path): """Explicit --target claude always wins.""" (tmp_path / ".github").mkdir() - + target, reason = detect_target( project_root=tmp_path, explicit_target="claude", ) - + assert target == "claude" assert reason == "explicit --target flag" @@ -56,7 +67,7 @@ def test_explicit_target_all_wins(self, tmp_path): project_root=tmp_path, explicit_target="all", ) - + assert target == "all" assert reason == "explicit --target flag" @@ -67,7 +78,7 @@ def test_config_target_vscode(self, tmp_path): explicit_target=None, config_target="vscode", ) - + assert target == "vscode" assert reason == "apm.yml target" @@ -78,7 +89,7 @@ def test_config_target_claude(self, tmp_path): explicit_target=None, config_target="claude", ) - + assert target == "claude" assert reason == "apm.yml target" @@ -89,33 +100,44 @@ def test_config_target_all(self, tmp_path): explicit_target=None, config_target="all", ) - + assert target == "all" assert reason == "apm.yml target" + def test_config_target_opencode_stays_opencode(self, tmp_path): + """Config target opencode remains opencode.""" + target, reason = detect_target( + project_root=tmp_path, + explicit_target=None, + config_target="opencode", + ) + + assert target == "opencode" + assert reason == "apm.yml target" + def test_auto_detect_github_only(self, tmp_path): """Auto-detect vscode when only .github/ exists.""" (tmp_path / ".github").mkdir() - + target, reason = detect_target( project_root=tmp_path, explicit_target=None, config_target=None, ) - + assert target == "vscode" assert "detected .github/ folder" in reason def test_auto_detect_claude_only(self, tmp_path): """Auto-detect claude when only .claude/ exists.""" (tmp_path / ".claude").mkdir() - + target, reason = detect_target( project_root=tmp_path, explicit_target=None, config_target=None, ) - + assert target == "claude" assert "detected .claude/ folder" in reason @@ -123,15 +145,44 @@ def test_auto_detect_both_folders(self, tmp_path): """Auto-detect all when both folders exist.""" (tmp_path / ".github").mkdir() (tmp_path / ".claude").mkdir() - + target, reason = detect_target( project_root=tmp_path, explicit_target=None, config_target=None, ) - + assert target == "all" - assert "both" in reason + assert "multiple integration folders" in reason + + def test_auto_detect_any_two_folders_means_all(self, tmp_path): + """Auto-detect all when any two integration roots exist.""" + (tmp_path / ".github").mkdir() + (tmp_path / ".opencode").mkdir() + + target, reason = detect_target( + project_root=tmp_path, + explicit_target=None, + config_target=None, + ) + + assert target == "all" + assert "multiple integration folders" in reason + + def test_auto_detect_all_three_folders_means_all(self, tmp_path): + """Auto-detect all when .github/.claude/.opencode all 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 "multiple integration folders" in reason def test_auto_detect_neither_folder(self, tmp_path): """Auto-detect minimal when neither folder exists.""" @@ -140,9 +191,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: @@ -164,6 +215,10 @@ def test_minimal_target(self): """VSCode integration disabled for minimal target.""" assert should_integrate_vscode("minimal") is False + def test_opencode_target(self): + """VSCode integration disabled for opencode target.""" + assert should_integrate_vscode("opencode") is False + class TestShouldIntegrateClaude: """Tests for should_integrate_claude function.""" @@ -185,6 +240,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.""" @@ -196,6 +275,10 @@ def test_all_target(self): """AGENTS.md compiled for all target.""" assert should_compile_agents_md("all") is True + def test_opencode_target(self): + """AGENTS.md compiled for opencode target.""" + assert should_compile_agents_md("opencode") is True + def test_minimal_target(self): """AGENTS.md compiled for minimal target (universal format).""" assert should_compile_agents_md("minimal") is True @@ -240,6 +323,12 @@ def test_claude_description(self): assert "CLAUDE.md" in desc assert ".claude/" 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_all_description(self): """Description for all target.""" desc = get_target_description("all") @@ -250,3 +339,4 @@ def test_minimal_description(self): """Description for minimal target.""" desc = get_target_description("minimal") assert "AGENTS.md only" in desc + assert ".opencode/" in desc diff --git a/tests/unit/integration/test_command_integrator.py b/tests/unit/integration/test_command_integrator.py index ab202e25b..6cd08ede2 100644 --- a/tests/unit/integration/test_command_integrator.py +++ b/tests/unit/integration/test_command_integrator.py @@ -24,74 +24,74 @@ class TestCommandIntegratorSyncIntegration: @pytest.fixture def temp_project(self): - """Create a temporary project with .claude/commands directory.""" + """Create a temporary project with .opencode/commands directory.""" temp_dir = tempfile.mkdtemp() temp_path = Path(temp_dir) - + # Create commands directory - commands_dir = temp_path / ".claude" / "commands" + commands_dir = temp_path / ".opencode" / "commands" commands_dir.mkdir(parents=True) - + yield temp_path shutil.rmtree(temp_dir, ignore_errors=True) def test_sync_removes_all_apm_commands(self, temp_project): """Test that sync_integration removes all *-apm.md files.""" - commands_dir = temp_project / ".claude" / "commands" - + commands_dir = temp_project / ".opencode" / "commands" + # Create command files for two packages pkg1_command = commands_dir / "audit-apm.md" pkg1_command.write_text("# Audit Command\n") - + pkg2_command = commands_dir / "review-apm.md" pkg2_command.write_text("# Review Command\n") - + integrator = CommandIntegrator() result = integrator.sync_integration(None, temp_project) - + assert result['files_removed'] == 2 assert not pkg1_command.exists() assert not pkg2_command.exists() def test_sync_handles_empty_dependencies(self, temp_project): """Test sync removes all apm commands regardless of dependencies.""" - commands_dir = temp_project / ".claude" / "commands" - + commands_dir = temp_project / ".opencode" / "commands" + command1 = commands_dir / "cmd1-apm.md" command1.write_text("# Command 1\n") - + command2 = commands_dir / "cmd2-apm.md" command2.write_text("# Command 2\n") - + mock_package = MagicMock() mock_package.dependencies = {'apm': []} - + integrator = CommandIntegrator() result = integrator.sync_integration(mock_package, temp_project) - + assert result['files_removed'] == 2 assert not command1.exists() assert not command2.exists() def test_sync_ignores_non_apm_command_files(self, temp_project): """Test that sync_integration ignores command files without -apm suffix.""" - commands_dir = temp_project / ".claude" / "commands" - + commands_dir = temp_project / ".opencode" / "commands" + # Create a non-APM command file (user-created) user_command = commands_dir / "my-custom-command.md" user_command.write_text("# My Custom Command\n") - + integrator = CommandIntegrator() result = integrator.sync_integration(None, temp_project) - + assert result['files_removed'] == 0 assert user_command.exists() def test_sync_handles_nonexistent_commands_dir(self): - """Test sync handles missing .claude/commands directory.""" + """Test sync handles missing .opencode/commands directory.""" temp_dir = tempfile.mkdtemp() temp_path = Path(temp_dir) - + try: integrator = CommandIntegrator() result = integrator.sync_integration(None, temp_path) @@ -102,13 +102,13 @@ def test_sync_handles_nonexistent_commands_dir(self): def test_sync_apm_package_param_is_unused(self, temp_project): """Test that sync works regardless of what apm_package is passed.""" - commands_dir = temp_project / ".claude" / "commands" - + commands_dir = temp_project / ".opencode" / "commands" + cmd = commands_dir / "test-apm.md" cmd.write_text("# Test\n") - + integrator = CommandIntegrator() - + # Works with None result = integrator.sync_integration(None, temp_project) assert result['files_removed'] == 1 @@ -119,32 +119,32 @@ class TestRemovePackageCommands: @pytest.fixture def temp_project(self): - """Create a temporary project with .claude/commands directory.""" + """Create a temporary project with .opencode/commands directory.""" temp_dir = tempfile.mkdtemp() temp_path = Path(temp_dir) - - commands_dir = temp_path / ".claude" / "commands" + + commands_dir = temp_path / ".opencode" / "commands" commands_dir.mkdir(parents=True) - + yield temp_path shutil.rmtree(temp_dir, ignore_errors=True) def test_removes_all_apm_commands(self, temp_project): """Test that remove_package_commands removes all *-apm.md files.""" - commands_dir = temp_project / ".claude" / "commands" - + commands_dir = temp_project / ".opencode" / "commands" + cmd1 = commands_dir / "audit-apm.md" cmd1.write_text("# Audit\n") - + cmd2 = commands_dir / "review-apm.md" cmd2.write_text("# Review\n") - + cmd3 = commands_dir / "design-apm.md" cmd3.write_text("# Design\n") - + integrator = CommandIntegrator() removed = integrator.remove_package_commands("any/package", temp_project) - + assert removed == 3 assert not cmd1.exists() assert not cmd2.exists() @@ -152,26 +152,26 @@ def test_removes_all_apm_commands(self, temp_project): def test_returns_zero_when_no_commands_dir(self, temp_project): """Test that remove_package_commands returns 0 when no commands directory exists.""" - shutil.rmtree(temp_project / ".claude" / "commands") - + shutil.rmtree(temp_project / ".opencode" / "commands") + integrator = CommandIntegrator() removed = integrator.remove_package_commands("any/package", temp_project) - + assert removed == 0 def test_preserves_non_apm_files(self, temp_project): """Test that non-APM files are preserved.""" - commands_dir = temp_project / ".claude" / "commands" - + commands_dir = temp_project / ".opencode" / "commands" + user_cmd = commands_dir / "my-command.md" user_cmd.write_text("# User command\n") - + apm_cmd = commands_dir / "test-apm.md" apm_cmd.write_text("# APM command\n") - + integrator = CommandIntegrator() removed = integrator.remove_package_commands("any/package", temp_project) - + assert removed == 1 assert not apm_cmd.exists() assert user_cmd.exists() @@ -185,10 +185,10 @@ def temp_project(self): """Create temporary project with source and target dirs.""" temp_dir = tempfile.mkdtemp() temp_path = Path(temp_dir) - + (temp_path / "source").mkdir() - (temp_path / ".claude" / "commands").mkdir(parents=True) - + (temp_path / ".opencode" / "commands").mkdir(parents=True) + yield temp_path shutil.rmtree(temp_dir, ignore_errors=True) @@ -201,9 +201,9 @@ def test_no_apm_metadata_in_output(self, temp_project): # Audit Command Run compliance audit. """) - - target = temp_project / ".claude" / "commands" / "audit-apm.md" - + + target = temp_project / ".opencode" / "commands" / "audit-apm.md" + mock_info = MagicMock() mock_info.package.name = "test/pkg" mock_info.package.version = "1.0.0" @@ -212,14 +212,14 @@ def test_no_apm_metadata_in_output(self, temp_project): mock_info.install_path = temp_project / "source" mock_info.installed_at = "2024-01-01" mock_info.get_canonical_dependency_string.return_value = "test/pkg" - + integrator = CommandIntegrator() integrator.integrate_command(source, target, mock_info, source) - + # Verify no APM metadata post = frontmatter.load(target) assert 'apm' not in post.metadata - + # Verify legitimate metadata IS preserved assert post.metadata.get('description') == 'Run audit checks' @@ -228,41 +228,45 @@ def test_content_preserved_verbatim(self, temp_project): content = "# My Command\nDo something useful.\n\n## Steps\n1. First\n2. Second" source = temp_project / "source" / "test.prompt.md" source.write_text(f"---\ndescription: Test\n---\n{content}\n") - - target = temp_project / ".claude" / "commands" / "test-apm.md" - + + target = temp_project / ".opencode" / "commands" / "test-apm.md" + mock_info = MagicMock() mock_info.resolved_reference = None - + integrator = CommandIntegrator() integrator.integrate_command(source, target, mock_info, source) - + post = frontmatter.load(target) assert content in post.content - def test_claude_metadata_mapping(self, temp_project): - """Test that Claude-specific frontmatter fields are mapped correctly.""" + def test_opencode_metadata_mapping(self, temp_project): + """Test that only OpenCode-supported frontmatter fields are mapped.""" source = temp_project / "source" / "cmd.prompt.md" source.write_text("""--- description: A command +agent: helper-agent +subtask: true allowed-tools: ["bash", "edit"] model: claude-sonnet argument-hint: "file path" --- # Command """) - - target = temp_project / ".claude" / "commands" / "cmd-apm.md" - + + target = temp_project / ".opencode" / "commands" / "cmd-apm.md" + mock_info = MagicMock() mock_info.resolved_reference = None - + integrator = CommandIntegrator() integrator.integrate_command(source, target, mock_info, source) - + post = frontmatter.load(target) assert post.metadata['description'] == 'A command' - assert post.metadata['allowed-tools'] == ['bash', 'edit'] + assert post.metadata["agent"] == "helper-agent" assert post.metadata['model'] == 'claude-sonnet' - assert post.metadata['argument-hint'] == 'file path' - assert 'apm' not in post.metadata + assert post.metadata["subtask"] is True + assert "allowed-tools" not in post.metadata + assert "argument-hint" not in post.metadata + assert "apm" not in post.metadata diff --git a/tests/unit/integration/test_deployed_files_manifest.py b/tests/unit/integration/test_deployed_files_manifest.py index a650764cd..828870131 100644 --- a/tests/unit/integration/test_deployed_files_manifest.py +++ b/tests/unit/integration/test_deployed_files_manifest.py @@ -31,12 +31,17 @@ ) -def _make_package_info(tmp_path: Path, name: str = "test-pkg", - prompt_files: dict = None, agent_files: dict = None, - command_files: dict = None, hook_files: dict = None, - skill_md: str = None) -> PackageInfo: +def _make_package_info( + tmp_path: Path, + name: str = "test-pkg", + prompt_files: dict = None, + agent_files: dict = None, + command_files: dict = None, + hook_files: dict = None, + skill_md: str = None, +) -> PackageInfo: """Create a PackageInfo with optional primitive files on disk. - + prompt_files/agent_files: placed in package root (found by integrators) command_files: placed in .apm/prompts/ (found by CommandIntegrator) hook_files: placed in hooks/ (found by HookIntegrator) @@ -89,7 +94,10 @@ def test_serialize_with_deployed_files(self): """Produce a dict containing sorted deployed_files.""" dep = LockedDependency( repo_url="github.com/o/r", - deployed_files=[".github/prompts/b.prompt.md", ".github/prompts/a.prompt.md"], + deployed_files=[ + ".github/prompts/b.prompt.md", + ".github/prompts/a.prompt.md", + ], ) d = dep.to_dict() assert d["deployed_files"] == [ @@ -182,9 +190,7 @@ def test_managed_file_not_collision(self, tmp_path: Path): prompts_dir.mkdir(parents=True) (prompts_dir / "review.prompt.md").write_text("# old") - info = _make_package_info( - tmp_path, prompt_files={"review.prompt.md": "# new"} - ) + info = _make_package_info(tmp_path, prompt_files={"review.prompt.md": "# new"}) managed = {".github/prompts/review.prompt.md"} result = PromptIntegrator().integrate_package_prompts( info, tmp_path, force=False, managed_files=managed @@ -199,9 +205,7 @@ def test_unmanaged_file_is_collision(self, tmp_path: Path): prompts_dir.mkdir(parents=True) (prompts_dir / "review.prompt.md").write_text("# user") - info = _make_package_info( - tmp_path, prompt_files={"review.prompt.md": "# pkg"} - ) + info = _make_package_info(tmp_path, prompt_files={"review.prompt.md": "# pkg"}) managed = {".github/prompts/OTHER.prompt.md"} result = PromptIntegrator().integrate_package_prompts( info, tmp_path, force=False, managed_files=managed @@ -215,9 +219,7 @@ def test_force_overrides_collision(self, tmp_path: Path): prompts_dir.mkdir(parents=True) (prompts_dir / "review.prompt.md").write_text("# user") - info = _make_package_info( - tmp_path, prompt_files={"review.prompt.md": "# pkg"} - ) + info = _make_package_info(tmp_path, prompt_files={"review.prompt.md": "# pkg"}) result = PromptIntegrator().integrate_package_prompts( info, tmp_path, force=True, managed_files=set() ) @@ -263,7 +265,9 @@ def test_sync_removes_managed_files(self, tmp_path: Path): (prompts_dir / "b.prompt.md").write_text("user") managed = {".github/prompts/a.prompt.md"} - stats = PromptIntegrator().sync_integration(None, tmp_path, managed_files=managed) + stats = PromptIntegrator().sync_integration( + None, tmp_path, managed_files=managed + ) assert stats["files_removed"] == 1 assert not (prompts_dir / "a.prompt.md").exists() @@ -291,7 +295,9 @@ def test_sync_ignores_non_prompt_paths(self, tmp_path: Path): (agents_dir / "sec.agent.md").write_text("agent") managed = {".github/agents/sec.agent.md"} - stats = PromptIntegrator().sync_integration(None, tmp_path, managed_files=managed) + stats = PromptIntegrator().sync_integration( + None, tmp_path, managed_files=managed + ) assert stats["files_removed"] == 0 assert (agents_dir / "sec.agent.md").exists() @@ -310,9 +316,7 @@ def test_managed_files_none_no_collision_check(self, tmp_path: Path): agents_dir.mkdir(parents=True) (agents_dir / "security.agent.md").write_text("# user") - info = _make_package_info( - tmp_path, agent_files={"security.agent.md": "# pkg"} - ) + info = _make_package_info(tmp_path, agent_files={"security.agent.md": "# pkg"}) result = AgentIntegrator().integrate_package_agents( info, tmp_path, force=False, managed_files=None ) @@ -325,9 +329,7 @@ def test_empty_managed_set_all_collisions(self, tmp_path: Path): agents_dir.mkdir(parents=True) (agents_dir / "security.agent.md").write_text("# user") - info = _make_package_info( - tmp_path, agent_files={"security.agent.md": "# pkg"} - ) + info = _make_package_info(tmp_path, agent_files={"security.agent.md": "# pkg"}) result = AgentIntegrator().integrate_package_agents( info, tmp_path, force=False, managed_files=set() ) @@ -341,9 +343,7 @@ def test_force_overrides_agent_collision(self, tmp_path: Path): agents_dir.mkdir(parents=True) (agents_dir / "security.agent.md").write_text("# user") - info = _make_package_info( - tmp_path, agent_files={"security.agent.md": "# pkg"} - ) + info = _make_package_info(tmp_path, agent_files={"security.agent.md": "# pkg"}) result = AgentIntegrator().integrate_package_agents( info, tmp_path, force=True, managed_files=set() ) @@ -362,9 +362,7 @@ def test_managed_files_none_no_collision_check(self, tmp_path: Path): claude_dir.mkdir(parents=True) (claude_dir / "security.md").write_text("# user") - info = _make_package_info( - tmp_path, agent_files={"security.agent.md": "# pkg"} - ) + info = _make_package_info(tmp_path, agent_files={"security.agent.md": "# pkg"}) result = AgentIntegrator().integrate_package_agents_claude( info, tmp_path, force=False, managed_files=None ) @@ -377,9 +375,7 @@ def test_empty_managed_set_all_collisions(self, tmp_path: Path): claude_dir.mkdir(parents=True) (claude_dir / "security.md").write_text("# user") - info = _make_package_info( - tmp_path, agent_files={"security.agent.md": "# pkg"} - ) + info = _make_package_info(tmp_path, agent_files={"security.agent.md": "# pkg"}) result = AgentIntegrator().integrate_package_agents_claude( info, tmp_path, force=False, managed_files=set() ) @@ -393,9 +389,7 @@ def test_force_overrides_claude_collision(self, tmp_path: Path): claude_dir.mkdir(parents=True) (claude_dir / "security.md").write_text("# user") - info = _make_package_info( - tmp_path, agent_files={"security.agent.md": "# pkg"} - ) + info = _make_package_info(tmp_path, agent_files={"security.agent.md": "# pkg"}) result = AgentIntegrator().integrate_package_agents_claude( info, tmp_path, force=True, managed_files=set() ) @@ -421,7 +415,9 @@ def test_sync_github_removes_managed_files(self, tmp_path: Path): (agents_dir / "b.agent.md").write_text("user") managed = {".github/agents/a.agent.md"} - stats = AgentIntegrator().sync_integration(None, tmp_path, managed_files=managed) + stats = AgentIntegrator().sync_integration( + None, tmp_path, managed_files=managed + ) assert stats["files_removed"] == 1 assert not (agents_dir / "a.agent.md").exists() @@ -473,11 +469,13 @@ def test_sync_claude_legacy_glob(self, tmp_path: Path): # --------------------------------------------------------------------------- -# 6. Command integrator — collision detection (.claude/commands/) +# 6. Command integrator — collision detection (.opencode/commands/) # --------------------------------------------------------------------------- -SAMPLE_PROMPT_MD = "---\nmode: agent\ndescription: test\n---\n# Test Prompt\nDo something.\n" +SAMPLE_PROMPT_MD = ( + "---\nmode: agent\ndescription: test\n---\n# Test Prompt\nDo something.\n" +) class TestCommandCollisionDetection: @@ -485,7 +483,7 @@ class TestCommandCollisionDetection: def test_managed_files_none_no_collision_check(self, tmp_path: Path): """Legacy mode: managed_files=None → always overwrite.""" - cmds_dir = tmp_path / ".claude" / "commands" + cmds_dir = tmp_path / ".opencode" / "commands" cmds_dir.mkdir(parents=True) (cmds_dir / "review.md").write_text("# user version") @@ -501,7 +499,7 @@ def test_managed_files_none_no_collision_check(self, tmp_path: Path): def test_empty_managed_set_all_collisions(self, tmp_path: Path): """managed_files=set() → every pre-existing file is a collision.""" - cmds_dir = tmp_path / ".claude" / "commands" + cmds_dir = tmp_path / ".opencode" / "commands" cmds_dir.mkdir(parents=True) (cmds_dir / "review.md").write_text("# user version") @@ -518,14 +516,14 @@ def test_empty_managed_set_all_collisions(self, tmp_path: Path): def test_managed_file_not_collision(self, tmp_path: Path): """File listed in managed_files is overwritten (not a collision).""" - cmds_dir = tmp_path / ".claude" / "commands" + cmds_dir = tmp_path / ".opencode" / "commands" cmds_dir.mkdir(parents=True) (cmds_dir / "review.md").write_text("# old") info = _make_package_info( tmp_path, command_files={"review.prompt.md": SAMPLE_PROMPT_MD} ) - managed = {".claude/commands/review.md"} + managed = {".opencode/commands/review.md"} result = CommandIntegrator().integrate_package_commands( info, tmp_path, force=False, managed_files=managed ) @@ -534,14 +532,14 @@ def test_managed_file_not_collision(self, tmp_path: Path): def test_unmanaged_file_is_collision(self, tmp_path: Path): """File NOT in managed_files is skipped as a collision.""" - cmds_dir = tmp_path / ".claude" / "commands" + cmds_dir = tmp_path / ".opencode" / "commands" cmds_dir.mkdir(parents=True) (cmds_dir / "review.md").write_text("# user") info = _make_package_info( tmp_path, command_files={"review.prompt.md": SAMPLE_PROMPT_MD} ) - managed = {".claude/commands/OTHER.md"} + managed = {".opencode/commands/OTHER.md"} result = CommandIntegrator().integrate_package_commands( info, tmp_path, force=False, managed_files=managed ) @@ -551,7 +549,7 @@ def test_unmanaged_file_is_collision(self, tmp_path: Path): def test_force_overrides_collision(self, tmp_path: Path): """force=True overwrites even unmanaged command files.""" - cmds_dir = tmp_path / ".claude" / "commands" + cmds_dir = tmp_path / ".opencode" / "commands" cmds_dir.mkdir(parents=True) (cmds_dir / "review.md").write_text("# user") @@ -567,7 +565,7 @@ def test_force_overrides_collision(self, tmp_path: Path): def test_skipped_files_excluded_from_target_paths(self, tmp_path: Path): """Skipped (collision) files are excluded from target_paths.""" - cmds_dir = tmp_path / ".claude" / "commands" + cmds_dir = tmp_path / ".opencode" / "commands" cmds_dir.mkdir(parents=True) (cmds_dir / "a.md").write_text("# user a") @@ -578,13 +576,13 @@ def test_skipped_files_excluded_from_target_paths(self, tmp_path: Path): "b.prompt.md": SAMPLE_PROMPT_MD, }, ) - managed = {".claude/commands/b.md"} # only b is managed + managed = {".opencode/commands/b.md"} # only b is managed result = CommandIntegrator().integrate_package_commands( info, tmp_path, force=False, managed_files=managed ) rel_paths = [str(p.relative_to(tmp_path)) for p in result.target_paths] - assert ".claude/commands/b.md" in rel_paths - assert ".claude/commands/a.md" not in rel_paths + assert ".opencode/commands/b.md" in rel_paths + assert ".opencode/commands/a.md" not in rel_paths # --------------------------------------------------------------------------- @@ -597,13 +595,15 @@ class TestCommandSync: def test_sync_removes_managed_files(self, tmp_path: Path): """Only files in managed_files are removed.""" - cmds_dir = tmp_path / ".claude" / "commands" + cmds_dir = tmp_path / ".opencode" / "commands" cmds_dir.mkdir(parents=True) (cmds_dir / "a.md").write_text("managed") (cmds_dir / "b.md").write_text("user") - managed = {".claude/commands/a.md"} - stats = CommandIntegrator().sync_integration(None, tmp_path, managed_files=managed) + managed = {".opencode/commands/a.md"} + stats = CommandIntegrator().sync_integration( + None, tmp_path, managed_files=managed + ) assert stats["files_removed"] == 1 assert not (cmds_dir / "a.md").exists() @@ -611,7 +611,7 @@ def test_sync_removes_managed_files(self, tmp_path: Path): def test_sync_legacy_fallback_glob(self, tmp_path: Path): """managed_files=None → legacy glob removes *-apm.md only.""" - cmds_dir = tmp_path / ".claude" / "commands" + cmds_dir = tmp_path / ".opencode" / "commands" cmds_dir.mkdir(parents=True) (cmds_dir / "review-apm.md").write_text("old style") (cmds_dir / "my-custom.md").write_text("user") @@ -623,15 +623,17 @@ def test_sync_legacy_fallback_glob(self, tmp_path: Path): assert (cmds_dir / "my-custom.md").exists() def test_sync_ignores_non_command_paths(self, tmp_path: Path): - """Managed paths outside .claude/commands/ are ignored by command sync.""" - cmds_dir = tmp_path / ".claude" / "commands" + """Managed paths outside .opencode/commands/ are ignored by command sync.""" + cmds_dir = tmp_path / ".opencode" / "commands" cmds_dir.mkdir(parents=True) agents_dir = tmp_path / ".github" / "agents" agents_dir.mkdir(parents=True) (agents_dir / "sec.agent.md").write_text("agent") managed = {".github/agents/sec.agent.md"} - stats = CommandIntegrator().sync_integration(None, tmp_path, managed_files=managed) + stats = CommandIntegrator().sync_integration( + None, tmp_path, managed_files=managed + ) assert stats["files_removed"] == 0 assert (agents_dir / "sec.agent.md").exists() @@ -641,14 +643,20 @@ def test_sync_ignores_non_command_paths(self, tmp_path: Path): # --------------------------------------------------------------------------- -SAMPLE_HOOK_JSON = json.dumps({ - "hooks": { - "PostToolUse": [{ - "matcher": "write_to_file", - "hooks": [{"type": "command", "command": "echo lint", "timeout": 5}] - }] +SAMPLE_HOOK_JSON = json.dumps( + { + "hooks": { + "PostToolUse": [ + { + "matcher": "write_to_file", + "hooks": [ + {"type": "command", "command": "echo lint", "timeout": 5} + ], + } + ] + } } -}) +) class TestHookCollisionDetection: @@ -660,9 +668,7 @@ def test_managed_files_none_no_collision_check(self, tmp_path: Path): hooks_dir.mkdir(parents=True) (hooks_dir / "test-pkg-hooks.json").write_text('{"user": true}') - info = _make_package_info( - tmp_path, hook_files={"hooks.json": SAMPLE_HOOK_JSON} - ) + info = _make_package_info(tmp_path, hook_files={"hooks.json": SAMPLE_HOOK_JSON}) result = HookIntegrator().integrate_package_hooks( info, tmp_path, force=False, managed_files=None ) @@ -674,16 +680,16 @@ def test_empty_managed_set_all_collisions(self, tmp_path: Path): hooks_dir.mkdir(parents=True) (hooks_dir / "test-pkg-hooks.json").write_text('{"user": true}') - info = _make_package_info( - tmp_path, hook_files={"hooks.json": SAMPLE_HOOK_JSON} - ) + info = _make_package_info(tmp_path, hook_files={"hooks.json": SAMPLE_HOOK_JSON}) result = HookIntegrator().integrate_package_hooks( info, tmp_path, force=False, managed_files=set() ) # Hook file collides → skipped, so no hooks actually integrated assert result.hooks_integrated == 0 # Verify user content is preserved - assert json.loads((hooks_dir / "test-pkg-hooks.json").read_text()) == {"user": True} + assert json.loads((hooks_dir / "test-pkg-hooks.json").read_text()) == { + "user": True + } def test_managed_file_not_collision(self, tmp_path: Path): """Hook file in managed_files is overwritten (not a collision).""" @@ -691,9 +697,7 @@ def test_managed_file_not_collision(self, tmp_path: Path): hooks_dir.mkdir(parents=True) (hooks_dir / "test-pkg-hooks.json").write_text('{"old": true}') - info = _make_package_info( - tmp_path, hook_files={"hooks.json": SAMPLE_HOOK_JSON} - ) + info = _make_package_info(tmp_path, hook_files={"hooks.json": SAMPLE_HOOK_JSON}) managed = {".github/hooks/test-pkg-hooks.json"} result = HookIntegrator().integrate_package_hooks( info, tmp_path, force=False, managed_files=managed @@ -706,15 +710,15 @@ def test_force_overrides_collision(self, tmp_path: Path): hooks_dir.mkdir(parents=True) (hooks_dir / "test-pkg-hooks.json").write_text('{"user": true}') - info = _make_package_info( - tmp_path, hook_files={"hooks.json": SAMPLE_HOOK_JSON} - ) + info = _make_package_info(tmp_path, hook_files={"hooks.json": SAMPLE_HOOK_JSON}) result = HookIntegrator().integrate_package_hooks( info, tmp_path, force=True, managed_files=set() ) assert result.hooks_integrated >= 1 # Verify user content was overwritten - assert json.loads((hooks_dir / "test-pkg-hooks.json").read_text()) != {"user": True} + assert json.loads((hooks_dir / "test-pkg-hooks.json").read_text()) != { + "user": True + } # --------------------------------------------------------------------------- @@ -773,7 +777,9 @@ def test_sync_removes_managed_skill_dirs(self, tmp_path: Path): (user_skill / "SKILL.md").write_text("user authored") managed = {".github/skills/code-review/"} - stats = SkillIntegrator().sync_integration(None, tmp_path, managed_files=managed) + stats = SkillIntegrator().sync_integration( + None, tmp_path, managed_files=managed + ) assert stats["files_removed"] == 1 assert not managed_skill.exists() @@ -792,7 +798,9 @@ def test_sync_removes_claude_skill_dirs(self, tmp_path: Path): (user_skill / "SKILL.md").write_text("user") managed = {".claude/skills/code-review/"} - stats = SkillIntegrator().sync_integration(None, tmp_path, managed_files=managed) + stats = SkillIntegrator().sync_integration( + None, tmp_path, managed_files=managed + ) assert stats["files_removed"] == 1 assert not managed_skill.exists() @@ -807,7 +815,9 @@ def test_sync_ignores_non_skill_paths(self, tmp_path: Path): (prompts_dir / "a.prompt.md").write_text("prompt") managed = {".github/prompts/a.prompt.md"} - stats = SkillIntegrator().sync_integration(None, tmp_path, managed_files=managed) + stats = SkillIntegrator().sync_integration( + None, tmp_path, managed_files=managed + ) assert stats["files_removed"] == 0 assert (prompts_dir / "a.prompt.md").exists() @@ -826,9 +836,7 @@ def test_prompt_collision_warns_on_stderr(self, tmp_path: Path, capsys): prompts_dir.mkdir(parents=True) (prompts_dir / "review.prompt.md").write_text("# user") - info = _make_package_info( - tmp_path, prompt_files={"review.prompt.md": "# pkg"} - ) + info = _make_package_info(tmp_path, prompt_files={"review.prompt.md": "# pkg"}) PromptIntegrator().integrate_package_prompts( info, tmp_path, force=False, managed_files=set() ) @@ -842,9 +850,7 @@ def test_agent_collision_warns_on_stderr(self, tmp_path: Path, capsys): agents_dir.mkdir(parents=True) (agents_dir / "security.agent.md").write_text("# user") - info = _make_package_info( - tmp_path, agent_files={"security.agent.md": "# pkg"} - ) + info = _make_package_info(tmp_path, agent_files={"security.agent.md": "# pkg"}) AgentIntegrator().integrate_package_agents( info, tmp_path, force=False, managed_files=set() ) @@ -854,7 +860,7 @@ def test_agent_collision_warns_on_stderr(self, tmp_path: Path, capsys): def test_command_collision_warns_on_stderr(self, tmp_path: Path, capsys): """Command collision should print warning to stderr.""" - cmds_dir = tmp_path / ".claude" / "commands" + cmds_dir = tmp_path / ".opencode" / "commands" cmds_dir.mkdir(parents=True) (cmds_dir / "review.md").write_text("# user") @@ -874,9 +880,7 @@ def test_hook_collision_warns_on_stderr(self, tmp_path: Path, capsys): hooks_dir.mkdir(parents=True) (hooks_dir / "test-pkg-hooks.json").write_text('{"user": true}') - info = _make_package_info( - tmp_path, hook_files={"hooks.json": SAMPLE_HOOK_JSON} - ) + info = _make_package_info(tmp_path, hook_files={"hooks.json": SAMPLE_HOOK_JSON}) HookIntegrator().integrate_package_hooks( info, tmp_path, force=False, managed_files=set() ) @@ -929,8 +933,8 @@ def test_agent_deployed_to_claude(self, tmp_path: Path): assert result.files_integrated >= 1 assert (tmp_path / ".claude" / "agents" / "sec.md").exists() - def test_command_deployed_to_claude(self, tmp_path: Path): - """Command files are deployed to .claude/commands/.""" + def test_command_deployed_to_opencode(self, tmp_path: Path): + """Command files are deployed to .opencode/commands/.""" info = _make_package_info( tmp_path, command_files={"review.prompt.md": SAMPLE_PROMPT_MD} ) @@ -938,13 +942,11 @@ def test_command_deployed_to_claude(self, tmp_path: Path): info, tmp_path, force=False, managed_files=set() ) assert result.files_integrated == 1 - assert (tmp_path / ".claude" / "commands" / "review.md").exists() + assert (tmp_path / ".opencode" / "commands" / "review.md").exists() def test_hook_deployed_to_github(self, tmp_path: Path): """Hook JSON files are deployed to .github/hooks/.""" - info = _make_package_info( - tmp_path, hook_files={"hooks.json": SAMPLE_HOOK_JSON} - ) + info = _make_package_info(tmp_path, hook_files={"hooks.json": SAMPLE_HOOK_JSON}) result = HookIntegrator().integrate_package_hooks( info, tmp_path, force=False, managed_files=set() ) @@ -1006,13 +1008,13 @@ def test_claude_agent_sync_preserves_user_files(self, tmp_path: Path): assert (claude_dir / "my-agent.md").read_text() == "user authored" def test_command_sync_preserves_user_files(self, tmp_path: Path): - """User-authored commands in .claude/commands/ survive sync.""" - cmds_dir = tmp_path / ".claude" / "commands" + """User-authored commands in .opencode/commands/ survive sync.""" + cmds_dir = tmp_path / ".opencode" / "commands" cmds_dir.mkdir(parents=True) (cmds_dir / "managed.md").write_text("managed") (cmds_dir / "my-command.md").write_text("user command") - managed = {".claude/commands/managed.md"} + managed = {".opencode/commands/managed.md"} CommandIntegrator().sync_integration(None, tmp_path, managed_files=managed) assert not (cmds_dir / "managed.md").exists() diff --git a/tests/unit/test_install_command.py b/tests/unit/test_install_command.py index 4e8b50f61..b7ed9326a 100644 --- a/tests/unit/test_install_command.py +++ b/tests/unit/test_install_command.py @@ -66,7 +66,11 @@ def test_install_no_apm_yml_with_packages_creates_minimal_apm_yml( mock_apm_package.from_apm_yml.return_value = mock_pkg_instance # Mock the install function to avoid actual installation - mock_install_apm.return_value = (0, 0, 0) # Return tuple (installed_count, prompts_integrated, agents_integrated) + mock_install_apm.return_value = ( + 0, + 0, + 0, + ) # Return tuple (installed_count, prompts_integrated, agents_integrated) result = self.runner.invoke(cli, ["install", "test/package"]) @@ -106,7 +110,11 @@ def test_install_no_apm_yml_with_multiple_packages( mock_pkg_instance.get_mcp_dependencies.return_value = [] mock_apm_package.from_apm_yml.return_value = mock_pkg_instance - mock_install_apm.return_value = (0, 0, 0) # Return tuple (installed_count, prompts_integrated, agents_integrated) + mock_install_apm.return_value = ( + 0, + 0, + 0, + ) # Return tuple (installed_count, prompts_integrated, agents_integrated) result = self.runner.invoke(cli, ["install", "org1/pkg1", "org2/pkg2"]) @@ -148,7 +156,11 @@ def test_install_existing_apm_yml_preserves_behavior( mock_pkg_instance.get_mcp_dependencies.return_value = [] mock_apm_package.from_apm_yml.return_value = mock_pkg_instance - mock_install_apm.return_value = (0, 0, 0) # Return tuple (installed_count, prompts_integrated, agents_integrated) + mock_install_apm.return_value = ( + 0, + 0, + 0, + ) # Return tuple (installed_count, prompts_integrated, agents_integrated) result = self.runner.invoke(cli, ["install"]) @@ -186,7 +198,11 @@ def test_install_auto_created_apm_yml_has_correct_metadata( mock_pkg_instance.get_mcp_dependencies.return_value = [] mock_apm_package.from_apm_yml.return_value = mock_pkg_instance - mock_install_apm.return_value = (0, 0, 0) # Return tuple (installed_count, prompts_integrated, agents_integrated) + mock_install_apm.return_value = ( + 0, + 0, + 0, + ) # Return tuple (installed_count, prompts_integrated, agents_integrated) result = self.runner.invoke(cli, ["install", "test/package"]) @@ -239,3 +255,63 @@ def test_install_dry_run_with_no_apm_yml_shows_what_would_be_created( assert "Would add" in result.output or "Dry run" in result.output # apm.yml should still be created (for dry-run to work) assert Path("apm.yml").exists() + + @patch("apm_cli.cli._validate_package_exists") + @patch("apm_cli.cli.APM_DEPS_AVAILABLE", True) + @patch("apm_cli.cli.APMPackage") + @patch("apm_cli.cli._install_apm_dependencies") + def test_install_with_existing_opencode_dir_does_not_create_github( + self, mock_install_apm, mock_apm_package, mock_validate + ): + """Test auto-bootstrap does not create .github/ when .opencode/ already exists.""" + with tempfile.TemporaryDirectory() as tmp_dir: + os.chdir(tmp_dir) + + Path(".opencode").mkdir() + mock_validate.return_value = True + + mock_pkg_instance = MagicMock() + mock_pkg_instance.get_apm_dependencies.return_value = [ + MagicMock(repo_url="test/package", reference="main") + ] + mock_pkg_instance.get_mcp_dependencies.return_value = [] + mock_pkg_instance.target = None + mock_apm_package.from_apm_yml.return_value = mock_pkg_instance + + mock_install_apm.return_value = (0, 0, 0) + + result = self.runner.invoke(cli, ["install", "test/package"]) + + assert result.exit_code == 0 + assert Path(".opencode").exists() + assert not Path(".github").exists() + + @patch("apm_cli.cli._validate_package_exists") + @patch("apm_cli.cli.APM_DEPS_AVAILABLE", True) + @patch("apm_cli.cli.APMPackage") + @patch("apm_cli.cli._install_apm_dependencies") + def test_install_with_existing_claude_dir_does_not_create_github( + self, mock_install_apm, mock_apm_package, mock_validate + ): + """Test auto-bootstrap does not create .github/ when .claude/ already exists.""" + with tempfile.TemporaryDirectory() as tmp_dir: + os.chdir(tmp_dir) + + Path(".claude").mkdir() + mock_validate.return_value = True + + mock_pkg_instance = MagicMock() + mock_pkg_instance.get_apm_dependencies.return_value = [ + MagicMock(repo_url="test/package", reference="main") + ] + mock_pkg_instance.get_mcp_dependencies.return_value = [] + mock_pkg_instance.target = None + mock_apm_package.from_apm_yml.return_value = mock_pkg_instance + + mock_install_apm.return_value = (0, 0, 0) + + result = self.runner.invoke(cli, ["install", "test/package"]) + + assert result.exit_code == 0 + assert Path(".claude").exists() + assert not Path(".github").exists() diff --git a/tests/unit/test_uninstall_reintegration.py b/tests/unit/test_uninstall_reintegration.py index ba44c1bf5..c83b591d7 100644 --- a/tests/unit/test_uninstall_reintegration.py +++ b/tests/unit/test_uninstall_reintegration.py @@ -36,9 +36,7 @@ def _make_package( pkg_path.mkdir(parents=True, exist_ok=True) type_line = f"\ntype: {pkg_type.value}" if pkg_type else "" - (pkg_path / "apm.yml").write_text( - f"name: {name}\nversion: 1.0.0{type_line}\n" - ) + (pkg_path / "apm.yml").write_text(f"name: {name}\nversion: 1.0.0{type_line}\n") if prompts: prompts_dir = pkg_path / ".apm" / "prompts" @@ -94,11 +92,15 @@ def test_uninstall_preserves_other_package_prompts(self, tmp_path: Path): # Two packages, each with a prompt pkg_a = _make_package( - tmp_path, "owner", "pkg-a", + tmp_path, + "owner", + "pkg-a", prompts={"review.prompt.md": "---\nname: review\n---\n# Review A"}, ) pkg_b = _make_package( - tmp_path, "owner", "pkg-b", + tmp_path, + "owner", + "pkg-b", prompts={"lint.prompt.md": "---\nname: lint\n---\n# Lint B"}, ) @@ -119,7 +121,9 @@ def test_uninstall_preserves_other_package_prompts(self, tmp_path: Path): ".github/prompts/lint.prompt.md", } dummy_pkg = APMPackage(name="root", version="0.0.0") - prompt_int.sync_integration(dummy_pkg, project_root, managed_files=managed_files) + prompt_int.sync_integration( + dummy_pkg, project_root, managed_files=managed_files + ) # Everything removed assert not (prompts_dir / "review.prompt.md").exists() @@ -145,11 +149,15 @@ def test_uninstall_preserves_other_package_agents(self, tmp_path: Path): (project_root / ".github").mkdir() pkg_a = _make_package( - tmp_path, "owner", "pkg-a", + tmp_path, + "owner", + "pkg-a", agents={"security.agent.md": "---\nname: security\n---\n# Security A"}, ) pkg_b = _make_package( - tmp_path, "owner", "pkg-b", + tmp_path, + "owner", + "pkg-b", agents={"planner.agent.md": "---\nname: planner\n---\n# Planner B"}, ) @@ -193,12 +201,16 @@ def test_uninstall_preserves_other_package_skills(self, tmp_path: Path): (project_root / ".github").mkdir() pkg_a = _make_package( - tmp_path, "owner", "skill-a", + tmp_path, + "owner", + "skill-a", skill_md="---\nname: skill-a\ndescription: test A\n---\n# Skill A", pkg_type=PackageContentType.SKILL, ) pkg_b = _make_package( - tmp_path, "owner", "skill-b", + tmp_path, + "owner", + "skill-b", skill_md="---\nname: skill-b\ndescription: test B\n---\n# Skill B", pkg_type=PackageContentType.SKILL, ) @@ -215,18 +227,71 @@ def test_uninstall_preserves_other_package_skills(self, tmp_path: Path): # Build an APMPackage that only lists skill-b as a remaining dependency. # sync_integration derives expected names from get_apm_dependencies(). # We use a real APMPackage loaded from a manifest that references skill-b only. + remaining_manifest = tmp_path / "remaining_apm.yml" + remaining_manifest.write_text( + "name: root\nversion: 0.0.0\ndependencies:\n apm:\n - owner/skill-b\n" + ) + root_pkg = APMPackage.from_apm_yml(remaining_manifest) + + skill_int.sync_integration(root_pkg, project_root) + + # skill-a removed, skill-b preserved + assert not (skills_dir / "skill-a").exists() + assert (skills_dir / "skill-b").is_dir() + + def test_uninstall_preserves_other_package_skills_opencode(self, tmp_path: Path): + """Skills in .opencode/skills follow same name-based cleanup behavior.""" + project_root = tmp_path + (project_root / ".opencode").mkdir() + + pkg_a = _make_package( + tmp_path, + "owner", + "skill-a", + skill_md="---\nname: skill-a\ndescription: test A\n---\n# Skill A", + pkg_type=PackageContentType.SKILL, + ) + pkg_b = _make_package( + tmp_path, + "owner", + "skill-b", + skill_md="---\nname: skill-b\ndescription: test B\n---\n# Skill B", + pkg_type=PackageContentType.SKILL, + ) + + skill_int = SkillIntegrator() + + skill_int.integrate_package_skill( + pkg_a, + project_root, + destinations={"opencode"}, + ) + skill_int.integrate_package_skill( + pkg_b, + project_root, + destinations={"opencode"}, + ) + + skills_dir = project_root / ".opencode" / "skills" + assert (skills_dir / "skill-a").is_dir() + assert (skills_dir / "skill-b").is_dir() + remaining_manifest = tmp_path / "remaining_apm.yml" remaining_manifest.write_text( "name: root\nversion: 0.0.0\n" "dependencies:\n" " apm:\n" " - owner/skill-b\n" + "target: opencode\n" ) root_pkg = APMPackage.from_apm_yml(remaining_manifest) - skill_int.sync_integration(root_pkg, project_root) + skill_int.sync_integration( + root_pkg, + project_root, + destinations={"opencode"}, + ) - # skill-a removed, skill-b preserved assert not (skills_dir / "skill-a").exists() assert (skills_dir / "skill-b").is_dir() @@ -256,7 +321,7 @@ def test_uninstall_preserves_user_files(self, tmp_path: Path): user_agent.write_text("# My custom agent") # User-created command - commands_dir = project_root / ".claude" / "commands" + commands_dir = project_root / ".opencode" / "commands" commands_dir.mkdir(parents=True) user_cmd = commands_dir / "my-command.md" user_cmd.write_text("# My custom command") @@ -299,7 +364,9 @@ def test_uninstall_last_package_leaves_clean_dirs(self, tmp_path: Path): (project_root / ".github").mkdir() pkg = _make_package( - tmp_path, "owner", "only-pkg", + tmp_path, + "owner", + "only-pkg", prompts={"guide.prompt.md": "---\nname: guide\n---\n# Guide"}, agents={"helper.agent.md": "---\nname: helper\n---\n# Helper"}, ) @@ -314,7 +381,7 @@ def test_uninstall_last_package_leaves_clean_dirs(self, tmp_path: Path): prompts_dir = project_root / ".github" / "prompts" agents_dir = project_root / ".github" / "agents" - commands_dir = project_root / ".claude" / "commands" + commands_dir = project_root / ".opencode" / "commands" # Verify files were created (clean naming, no -apm suffix) assert (prompts_dir / "guide.prompt.md").exists() @@ -325,10 +392,12 @@ def test_uninstall_last_package_leaves_clean_dirs(self, tmp_path: Path): managed_files = { ".github/prompts/guide.prompt.md", ".github/agents/helper.agent.md", - ".claude/commands/guide.md", + ".opencode/commands/guide.md", } dummy_pkg = APMPackage(name="root", version="0.0.0") - prompt_int.sync_integration(dummy_pkg, project_root, managed_files=managed_files) + prompt_int.sync_integration( + dummy_pkg, project_root, managed_files=managed_files + ) agent_int.sync_integration(dummy_pkg, project_root, managed_files=managed_files) cmd_int.sync_integration(dummy_pkg, project_root, managed_files=managed_files)