diff --git a/src/apm_cli/commands/install.py b/src/apm_cli/commands/install.py index 2017980a..9772f70a 100644 --- a/src/apm_cli/commands/install.py +++ b/src/apm_cli/commands/install.py @@ -837,9 +837,7 @@ def _integrate_package_primitives( package_info, project_root, *, - integrate_vscode, - integrate_claude, - integrate_opencode=False, + targets, prompt_integrator, agent_integrator, skill_integrator, @@ -854,6 +852,11 @@ def _integrate_package_primitives( ): """Run the full integration pipeline for a single package. + Iterates over *targets* (``TargetProfile`` list) and dispatches each + primitive to the appropriate integrator via the target-driven API. + Skills are handled separately because ``SkillIntegrator`` already + routes across all targets internally. + Returns a dict with integration counters and the list of deployed file paths. """ result = { @@ -870,196 +873,95 @@ def _integrate_package_primitives( deployed = result["deployed_files"] - if not (integrate_vscode or integrate_claude or integrate_opencode): + if not targets: return result def _log_integration(msg): if logger: logger.tree_item(msg) - # --- prompts --- - prompt_result = prompt_integrator.integrate_package_prompts( - package_info, project_root, - force=force, managed_files=managed_files, - diagnostics=diagnostics, - ) - if prompt_result.files_integrated > 0: - result["prompts"] += prompt_result.files_integrated - _log_integration(f" └─ {prompt_result.files_integrated} prompts integrated -> .github/prompts/") - if prompt_result.files_updated > 0: - _log_integration(f" └─ {prompt_result.files_updated} prompts updated") - result["links_resolved"] += prompt_result.links_resolved - for tp in prompt_result.target_paths: - deployed.append(tp.relative_to(project_root).as_posix()) - - # --- agents (.github) --- - agent_result = agent_integrator.integrate_package_agents( - package_info, project_root, - force=force, managed_files=managed_files, - diagnostics=diagnostics, - ) - if agent_result.files_integrated > 0: - result["agents"] += agent_result.files_integrated - _log_integration(f" └─ {agent_result.files_integrated} agents integrated -> .github/agents/") - if agent_result.files_updated > 0: - _log_integration(f" └─ {agent_result.files_updated} agents updated") - result["links_resolved"] += agent_result.links_resolved - for tp in agent_result.target_paths: - deployed.append(tp.relative_to(project_root).as_posix()) - - # --- skills --- - if integrate_vscode or integrate_claude or integrate_opencode: - skill_result = skill_integrator.integrate_package_skill( - package_info, project_root, - diagnostics=diagnostics, managed_files=managed_files, force=force, - ) - # Build human-readable list of target dirs from deployed paths - _skill_target_dirs: set[str] = set() - for tp in skill_result.target_paths: - rel = tp.relative_to(project_root) - if rel.parts: - _skill_target_dirs.add(rel.parts[0]) - _skill_targets = sorted(_skill_target_dirs) - _skill_target_str = ", ".join(f"{d}/skills/" for d in _skill_targets) or "skills/" - if skill_result.skill_created: - result["skills"] += 1 - _log_integration(f" |-- Skill integrated -> {_skill_target_str}") - if skill_result.sub_skills_promoted > 0: - result["sub_skills"] += skill_result.sub_skills_promoted - _log_integration(f" |-- {skill_result.sub_skills_promoted} skill(s) integrated -> {_skill_target_str}") - for tp in skill_result.target_paths: - deployed.append(tp.relative_to(project_root).as_posix()) - - # --- instructions (.github) --- - if integrate_vscode: - instruction_result = instruction_integrator.integrate_package_instructions( - package_info, project_root, - force=force, managed_files=managed_files, - diagnostics=diagnostics, - ) - if instruction_result.files_integrated > 0: - result["instructions"] += instruction_result.files_integrated - _log_integration(f" └─ {instruction_result.files_integrated} instruction(s) integrated -> .github/instructions/") - result["links_resolved"] += instruction_result.links_resolved - for tp in instruction_result.target_paths: - deployed.append(tp.relative_to(project_root).as_posix()) - - # --- Cursor rules (.cursor/rules/) --- - cursor_rules_result = instruction_integrator.integrate_package_instructions_cursor( - package_info, project_root, - force=force, managed_files=managed_files, - diagnostics=diagnostics, - ) - if cursor_rules_result.files_integrated > 0: - result["instructions"] += cursor_rules_result.files_integrated - _log_integration(f" └─ {cursor_rules_result.files_integrated} rule(s) integrated -> .cursor/rules/") - result["links_resolved"] += cursor_rules_result.links_resolved - for tp in cursor_rules_result.target_paths: - deployed.append(tp.relative_to(project_root).as_posix()) + # Primitive -> (integrator, method_name, result counter key) + _PRIMITIVE_INTEGRATORS = { + "prompts": (prompt_integrator, "integrate_prompts_for_target", "prompts"), + "agents": (agent_integrator, "integrate_agents_for_target", "agents"), + "commands": (command_integrator, "integrate_commands_for_target", "commands"), + "instructions": (instruction_integrator, "integrate_instructions_for_target", "instructions"), + } - # --- Claude agents (.claude) --- - if integrate_claude: - claude_agent_result = agent_integrator.integrate_package_agents_claude( - package_info, project_root, - force=force, managed_files=managed_files, - diagnostics=diagnostics, - ) - if claude_agent_result.files_integrated > 0: - result["agents"] += claude_agent_result.files_integrated - _log_integration(f" └─ {claude_agent_result.files_integrated} agents integrated -> .claude/agents/") - result["links_resolved"] += claude_agent_result.links_resolved - for tp in claude_agent_result.target_paths: - deployed.append(tp.relative_to(project_root).as_posix()) - - # --- Cursor agents (.cursor) --- - cursor_agent_result = agent_integrator.integrate_package_agents_cursor( - package_info, project_root, - force=force, managed_files=managed_files, - diagnostics=diagnostics, - ) - if cursor_agent_result.files_integrated > 0: - result["agents"] += cursor_agent_result.files_integrated - _log_integration(f" └─ {cursor_agent_result.files_integrated} agents integrated -> .cursor/agents/") - result["links_resolved"] += cursor_agent_result.links_resolved - for tp in cursor_agent_result.target_paths: - deployed.append(tp.relative_to(project_root).as_posix()) + # --- target x primitive dispatch loop --- + for _target in targets: + for _prim_name, _mapping in _target.primitives.items(): + if _prim_name == "skills": + continue # handled separately below + + # --- hooks (different return type) --- + if _prim_name == "hooks": + hook_result = hook_integrator.integrate_hooks_for_target( + _target, package_info, project_root, + force=force, managed_files=managed_files, + diagnostics=diagnostics, + ) + if hook_result.hooks_integrated > 0: + result["hooks"] += hook_result.hooks_integrated + if _target.name == "claude": + _hook_dir = ".claude/settings.json" + elif _target.name == "cursor": + _hook_dir = ".cursor/hooks.json" + else: + _hook_dir = f"{_target.root_dir}/{_mapping.subdir}/" + _log_integration( + f" |-- {hook_result.hooks_integrated} hook(s) integrated -> {_hook_dir}" + ) + for tp in hook_result.target_paths: + deployed.append(tp.relative_to(project_root).as_posix()) + continue - # --- OpenCode agents (.opencode) --- - opencode_agent_result = agent_integrator.integrate_package_agents_opencode( - package_info, project_root, - force=force, managed_files=managed_files, - diagnostics=diagnostics, - ) - if opencode_agent_result.files_integrated > 0: - result["agents"] += opencode_agent_result.files_integrated - _log_integration(f" └─ {opencode_agent_result.files_integrated} agents integrated -> .opencode/agents/") - result["links_resolved"] += opencode_agent_result.links_resolved - for tp in opencode_agent_result.target_paths: - deployed.append(tp.relative_to(project_root).as_posix()) + _entry = _PRIMITIVE_INTEGRATORS.get(_prim_name) + if not _entry: + continue - # --- commands (.claude) --- - if integrate_claude: - command_result = command_integrator.integrate_package_commands( - package_info, project_root, - force=force, managed_files=managed_files, - diagnostics=diagnostics, - ) - if command_result.files_integrated > 0: - result["commands"] += command_result.files_integrated - _log_integration(f" └─ {command_result.files_integrated} commands integrated -> .claude/commands/") - if command_result.files_updated > 0: - _log_integration(f" └─ {command_result.files_updated} commands updated") - result["links_resolved"] += command_result.links_resolved - for tp in command_result.target_paths: - deployed.append(tp.relative_to(project_root).as_posix()) - - # --- OpenCode commands (.opencode) --- - opencode_command_result = command_integrator.integrate_package_commands_opencode( - package_info, project_root, - force=force, managed_files=managed_files, - diagnostics=diagnostics, - ) - if opencode_command_result.files_integrated > 0: - result["commands"] += opencode_command_result.files_integrated - _log_integration(f" └─ {opencode_command_result.files_integrated} commands integrated -> .opencode/commands/") - result["links_resolved"] += opencode_command_result.links_resolved - for tp in opencode_command_result.target_paths: - deployed.append(tp.relative_to(project_root).as_posix()) + _integrator, _method_name, _counter_key = _entry + _int_result = getattr(_integrator, _method_name)( + _target, package_info, project_root, + force=force, managed_files=managed_files, + diagnostics=diagnostics, + ) + if _int_result.files_integrated > 0: + result[_counter_key] += _int_result.files_integrated + _deploy_dir = f"{_target.root_dir}/{_mapping.subdir}/" + if _prim_name == "instructions" and _mapping.format_id == "cursor_rules": + _label = "rule(s)" + elif _prim_name == "instructions": + _label = "instruction(s)" + else: + _label = _prim_name + _log_integration( + f" |-- {_int_result.files_integrated} {_label} integrated -> {_deploy_dir}" + ) + result["links_resolved"] += _int_result.links_resolved + for tp in _int_result.target_paths: + deployed.append(tp.relative_to(project_root).as_posix()) - # --- hooks --- - if integrate_vscode: - hook_result = hook_integrator.integrate_package_hooks( - package_info, project_root, - force=force, managed_files=managed_files, - diagnostics=diagnostics, - ) - if hook_result.hooks_integrated > 0: - result["hooks"] += hook_result.hooks_integrated - _log_integration(f" └─ {hook_result.hooks_integrated} hook(s) integrated -> .github/hooks/") - for tp in hook_result.target_paths: - deployed.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, - diagnostics=diagnostics, - ) - if hook_result_claude.hooks_integrated > 0: - result["hooks"] += hook_result_claude.hooks_integrated - _log_integration(f" └─ {hook_result_claude.hooks_integrated} hook(s) integrated -> .claude/settings.json") - for tp in hook_result_claude.target_paths: - deployed.append(tp.relative_to(project_root).as_posix()) - - # Cursor hooks (.cursor/hooks.json) — method self-guards on .cursor/ existence - hook_result_cursor = hook_integrator.integrate_package_hooks_cursor( + # --- skills (multi-target, handled by SkillIntegrator internally) --- + skill_result = skill_integrator.integrate_package_skill( package_info, project_root, - force=force, managed_files=managed_files, - diagnostics=diagnostics, + diagnostics=diagnostics, managed_files=managed_files, force=force, + targets=targets, ) - if hook_result_cursor.hooks_integrated > 0: - result["hooks"] += hook_result_cursor.hooks_integrated - _log_integration(f" └─ {hook_result_cursor.hooks_integrated} hook(s) integrated -> .cursor/hooks.json") - for tp in hook_result_cursor.target_paths: + _skill_target_dirs: set[str] = builtins.set() + for tp in skill_result.target_paths: + rel = tp.relative_to(project_root) + if rel.parts: + _skill_target_dirs.add(rel.parts[0]) + _skill_targets = sorted(_skill_target_dirs) + _skill_target_str = ", ".join(f"{d}/skills/" for d in _skill_targets) or "skills/" + if skill_result.skill_created: + result["skills"] += 1 + _log_integration(f" |-- Skill integrated -> {_skill_target_str}") + if skill_result.sub_skills_promoted > 0: + result["sub_skills"] += skill_result.sub_skills_promoted + _log_integration(f" |-- {skill_result.sub_skills_promoted} skill(s) integrated -> {_skill_target_str}") + for tp in skill_result.target_paths: deployed.append(tp.relative_to(project_root).as_posix()) return result @@ -1365,9 +1267,6 @@ def _collect_descendants(node, visited=None): # Auto-detect target for integration (same logic as compile) from apm_cli.core.target_detection import ( detect_target, - should_integrate_vscode, - should_integrate_claude, - should_integrate_opencode, get_target_description, ) @@ -1398,11 +1297,6 @@ def _collect_descendants(node, visited=None): config_target=config_target, ) - # Determine which integrations to run based on detected target - integrate_vscode = should_integrate_vscode(detected_target) - integrate_claude = should_integrate_claude(detected_target) - integrate_opencode = should_integrate_opencode(detected_target) - # Initialize integrators prompt_integrator = PromptIntegrator() agent_integrator = AgentIntegrator() @@ -1671,9 +1565,7 @@ def _collect_descendants(node, visited=None): int_result = _integrate_package_primitives( package_info, project_root, - integrate_vscode=integrate_vscode, - integrate_claude=integrate_claude, - integrate_opencode=integrate_opencode, + targets=_targets, prompt_integrator=prompt_integrator, agent_integrator=agent_integrator, skill_integrator=skill_integrator, @@ -1790,7 +1682,7 @@ def _collect_descendants(node, visited=None): unpinned_count += 1 # Skip integration if not needed - if not (integrate_vscode or integrate_claude or integrate_opencode): + if not _targets: continue # Integrate prompts for cached packages (zero-config behavior) @@ -1875,9 +1767,7 @@ def _collect_descendants(node, visited=None): int_result = _integrate_package_primitives( cached_package_info, project_root, - integrate_vscode=integrate_vscode, - integrate_claude=integrate_claude, - integrate_opencode=integrate_opencode, + targets=_targets, prompt_integrator=prompt_integrator, agent_integrator=agent_integrator, skill_integrator=skill_integrator, @@ -2037,13 +1927,11 @@ def _collect_descendants(node, visited=None): package_deployed_files[dep_ref.get_unique_key()] = [] continue - if integrate_vscode or integrate_claude or integrate_opencode: + if _targets: try: int_result = _integrate_package_primitives( package_info, project_root, - integrate_vscode=integrate_vscode, - integrate_claude=integrate_claude, - integrate_opencode=integrate_opencode, + targets=_targets, prompt_integrator=prompt_integrator, agent_integrator=agent_integrator, skill_integrator=skill_integrator, diff --git a/src/apm_cli/commands/uninstall/engine.py b/src/apm_cli/commands/uninstall/engine.py index ed20129b..25c218a2 100644 --- a/src/apm_cli/commands/uninstall/engine.py +++ b/src/apm_cli/commands/uninstall/engine.py @@ -234,6 +234,7 @@ def _sync_integrations_after_uninstall(apm_package, project_root, all_deployed_f from ...integration.command_integrator import CommandIntegrator from ...integration.hook_integrator import HookIntegrator from ...integration.instruction_integrator import InstructionIntegrator + from ...integration.targets import KNOWN_TARGETS, active_targets sync_managed = all_deployed_files if all_deployed_files else None if sync_managed is not None: @@ -244,79 +245,64 @@ def _sync_integrations_after_uninstall(apm_package, project_root, all_deployed_f counts = {"prompts": 0, "agents": 0, "skills": 0, "commands": 0, "hooks": 0, "instructions": 0} # 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) - counts["prompts"] = 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) - counts["agents"] = 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) - counts["agents"] += result.get("files_removed", 0) - - if Path(".cursor/agents").exists(): - integrator = AgentIntegrator() - result = integrator.sync_integration_cursor(apm_package, project_root, - managed_files=_buckets["agents_cursor"] if _buckets else None) - counts["agents"] += result.get("files_removed", 0) - - if Path(".opencode/agents").exists(): - integrator = AgentIntegrator() - result = integrator.sync_integration_opencode(apm_package, project_root, - managed_files=_buckets["agents_opencode"] if _buckets else None) - counts["agents"] += result.get("files_removed", 0) - - if Path(".github/skills").exists() or Path(".claude/skills").exists() or Path(".cursor/skills").exists() or Path(".opencode/skills").exists(): + # Use target-driven sync for prompts, agents, commands, instructions + _prompt_int = PromptIntegrator() + _agent_int = AgentIntegrator() + _cmd_int = CommandIntegrator() + _instr_int = InstructionIntegrator() + + _SYNC_DISPATCH = { + "prompts": (_prompt_int, "prompts"), + "agents": (_agent_int, "agents"), + "commands": (_cmd_int, "commands"), + "instructions": (_instr_int, "instructions"), + } + + for _target in KNOWN_TARGETS.values(): + for _prim_name, _mapping in _target.primitives.items(): + if _prim_name in ("skills", "hooks"): + continue + _entry = _SYNC_DISPATCH.get(_prim_name) + if not _entry: + continue + _integrator, _counter_key = _entry + _prefix = f"{_target.root_dir}/{_mapping.subdir}/" + _deploy_dir = project_root / _target.root_dir / _mapping.subdir + if not _deploy_dir.exists(): + continue + _managed_subset = None + if _buckets is not None: + _bucket_key = BaseIntegrator.partition_bucket_key( + _prim_name, _target.name + ) + _managed_subset = _buckets.get(_bucket_key, set()) + result = _integrator.sync_for_target( + _target, apm_package, project_root, + managed_files=_managed_subset, + ) + counts[_counter_key] += result.get("files_removed", 0) + + # Skills (multi-target, handled by SkillIntegrator) + if any( + (project_root / t.root_dir / "skills").exists() + for t in KNOWN_TARGETS.values() + if t.supports("skills") + ): integrator = SkillIntegrator() result = integrator.sync_integration(apm_package, project_root, managed_files=_buckets["skills"] if _buckets else None) counts["skills"] = result.get("files_removed", 0) - if Path(".claude/commands").exists(): - integrator = CommandIntegrator() - result = integrator.sync_integration(apm_package, project_root, - managed_files=_buckets["commands"] if _buckets else None) - counts["commands"] = result.get("files_removed", 0) - - if Path(".opencode/commands").exists(): - integrator = CommandIntegrator() - result = integrator.sync_integration_opencode(apm_package, project_root, - managed_files=_buckets["commands_opencode"] if _buckets else None) - counts["commands"] += result.get("files_removed", 0) - + # Hooks (multi-target, sync_integration handles all targets) hook_integrator_cleanup = HookIntegrator() result = hook_integrator_cleanup.sync_integration(apm_package, project_root, managed_files=_buckets["hooks"] if _buckets else None) counts["hooks"] = result.get("files_removed", 0) - if Path(".github/instructions").exists(): - integrator = InstructionIntegrator() - result = integrator.sync_integration(apm_package, project_root, - managed_files=_buckets["instructions"] if _buckets else None) - counts["instructions"] = result.get("files_removed", 0) - - # Clean Cursor rules (.cursor/rules/) - if Path(".cursor/rules").exists(): - integrator = InstructionIntegrator() - result = integrator.sync_integration_cursor(apm_package, project_root, - managed_files=_buckets["rules_cursor"] if _buckets else None) - counts["instructions"] += result.get("files_removed", 0) - # Phase 2: Re-integrate from remaining installed packages - from ...core.target_detection import detect_target, should_integrate_claude config_target = apm_package.target - detected_target, _ = detect_target( - project_root=project_root, explicit_target=None, config_target=config_target, - ) - integrate_claude = should_integrate_claude(detected_target) + _explicit = config_target or None + _targets = active_targets(project_root, explicit_target=_explicit) prompt_integrator = PromptIntegrator() agent_integrator = AgentIntegrator() @@ -325,6 +311,13 @@ def _sync_integrations_after_uninstall(apm_package, project_root, all_deployed_f hook_integrator_reint = HookIntegrator() instruction_integrator_reint = InstructionIntegrator() + _REINT_DISPATCH = { + "prompts": (prompt_integrator, "integrate_prompts_for_target"), + "agents": (agent_integrator, "integrate_agents_for_target"), + "commands": (command_integrator, "integrate_commands_for_target"), + "instructions": (instruction_integrator_reint, "integrate_instructions_for_target"), + } + for dep in apm_package.get_apm_dependencies(): dep_ref = dep if hasattr(dep, 'repo_url') else None if not dep_ref: @@ -344,22 +337,22 @@ def _sync_integrations_after_uninstall(apm_package, project_root, all_deployed_f ) try: - if prompt_integrator.should_integrate(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) - if integrate_claude: - agent_integrator.integrate_package_agents_claude(pkg_info, project_root) - agent_integrator.integrate_package_agents_cursor(pkg_info, project_root) + for _target in _targets: + for _prim_name in _target.primitives: + if _prim_name == "skills": + continue + if _prim_name == "hooks": + hook_integrator_reint.integrate_hooks_for_target( + _target, pkg_info, project_root, + ) + continue + _entry = _REINT_DISPATCH.get(_prim_name) + if _entry: + _integrator, _method = _entry + getattr(_integrator, _method)( + _target, pkg_info, project_root, + ) skill_integrator.integrate_package_skill(pkg_info, project_root) - if integrate_claude: - command_integrator.integrate_package_commands(pkg_info, project_root) - hook_integrator_reint.integrate_package_hooks(pkg_info, project_root) - if integrate_claude: - hook_integrator_reint.integrate_package_hooks_claude(pkg_info, project_root) - hook_integrator_reint.integrate_package_hooks_cursor(pkg_info, project_root) - instruction_integrator_reint.integrate_package_instructions(pkg_info, project_root) - instruction_integrator_reint.integrate_package_instructions_cursor(pkg_info, project_root) except Exception: pkg_id = dep_ref.get_identity() if hasattr(dep_ref, "get_identity") else str(dep_ref) logger.warning(f"Best-effort re-integration skipped for {pkg_id}") diff --git a/src/apm_cli/integration/agent_integrator.py b/src/apm_cli/integration/agent_integrator.py index fa6c86da..b1842a07 100644 --- a/src/apm_cli/integration/agent_integrator.py +++ b/src/apm_cli/integration/agent_integrator.py @@ -5,12 +5,17 @@ See skill-strategy.md for the full architectural rationale (T5). """ +from __future__ import annotations + from pathlib import Path -from typing import List, Dict +from typing import TYPE_CHECKING, Dict, List from apm_cli.integration.base_integrator import BaseIntegrator, IntegrationResult from apm_cli.utils.paths import portable_relpath +if TYPE_CHECKING: + from apm_cli.integration.targets import TargetProfile + class AgentIntegrator(BaseIntegrator): """Handles integration of APM package agents into .github/agents/, .claude/agents/, and .cursor/agents/.""" @@ -66,25 +71,122 @@ def find_agent_files(self, package_path: Path) -> List[Path]: # - This preserves the native skill format and avoids semantic confusion # - See skill-strategy.md for the full architectural rationale - - def get_target_filename(self, source_file: Path, package_name: str) -> str: - """Generate target filename (always .agent.md, no suffix). - - Args: - source_file: Source file path - package_name: Name of the package (not used in simple naming) - - Returns: - str: Target filename (e.g., security.agent.md) - """ + # ------------------------------------------------------------------ + # Target-driven API (data-driven dispatch) + # ------------------------------------------------------------------ + + def get_target_filename_for_target( + self, source_file: Path, package_name: str, target: "TargetProfile", + ) -> str: + """Generate target filename using the extension from *target*'s agents mapping.""" + mapping = target.primitives.get("agents") + ext = mapping.extension if mapping else ".agent.md" if source_file.name.endswith('.agent.md'): - stem = source_file.name[:-9] # Remove .agent.md + stem = source_file.name[:-9] elif source_file.name.endswith('.chatmode.md'): - stem = source_file.name[:-12] # Remove .chatmode.md + stem = source_file.name[:-12] else: stem = source_file.stem - - return f"{stem}.agent.md" + return f"{stem}{ext}" + + def integrate_agents_for_target( + self, + target: "TargetProfile", + package_info, + project_root: Path, + *, + force: bool = False, + managed_files: set = None, + diagnostics=None, + ) -> IntegrationResult: + """Integrate agents from a package for a single *target*. + + Each call deploys to exactly one target. The dispatch loop in + ``install.py`` calls this once per active target that supports + the ``agents`` primitive. + """ + mapping = target.primitives.get("agents") + if not mapping: + return IntegrationResult(0, 0, 0, []) + + target_root = project_root / target.root_dir + if not target.auto_create and not target_root.is_dir(): + return IntegrationResult(0, 0, 0, []) + + self.init_link_resolver(package_info, project_root) + agent_files = self.find_agent_files(package_info.install_path) + if not agent_files: + return IntegrationResult(0, 0, 0, []) + + agents_dir = target_root / mapping.subdir + agents_dir.mkdir(parents=True, exist_ok=True) + + files_integrated = 0 + files_skipped = 0 + target_paths: List[Path] = [] + total_links_resolved = 0 + + for source_file in agent_files: + target_filename = self.get_target_filename_for_target( + source_file, package_info.package.name, target, + ) + target_path = agents_dir / target_filename + rel_path = portable_relpath(target_path, project_root) + + if self.check_collision( + target_path, rel_path, managed_files, force, + diagnostics=diagnostics, + ): + files_skipped += 1 + continue + + links_resolved = self.copy_agent(source_file, target_path) + total_links_resolved += links_resolved + files_integrated += 1 + target_paths.append(target_path) + + return IntegrationResult( + files_integrated=files_integrated, + files_updated=0, + files_skipped=files_skipped, + target_paths=target_paths, + links_resolved=total_links_resolved, + ) + + def sync_for_target( + self, + target: "TargetProfile", + apm_package, + project_root: Path, + managed_files: set = None, + ) -> Dict[str, int]: + """Remove APM-managed agent files for a single *target*.""" + mapping = target.primitives.get("agents") + if not mapping: + return {"files_removed": 0, "errors": 0} + prefix = f"{target.root_dir}/{mapping.subdir}/" + legacy_dir = project_root / target.root_dir / mapping.subdir + # Copilot uses .agent.md suffix; others use plain .md + legacy_pattern = "*-apm.agent.md" if mapping.extension == ".agent.md" else "*-apm.md" + return self.sync_remove_files( + project_root, + managed_files, + prefix=prefix, + legacy_glob_dir=legacy_dir, + legacy_glob_pattern=legacy_pattern, + ) + + # ------------------------------------------------------------------ + # Legacy per-target API (delegates to target-driven methods) + # ------------------------------------------------------------------ + + + def get_target_filename(self, source_file: Path, package_name: str) -> str: + """Generate target filename for copilot (always .agent.md).""" + from apm_cli.integration.targets import KNOWN_TARGETS + return self.get_target_filename_for_target( + source_file, package_name, KNOWN_TARGETS["copilot"], + ) def copy_agent(self, source: Path, target: Path) -> int: """Copy agent file verbatim, resolving context links. @@ -105,383 +207,176 @@ def integrate_package_agents(self, package_info, project_root: Path, force: bool = False, managed_files: set = None, diagnostics=None) -> IntegrationResult: - """Integrate all agents from a package into .github/agents/. - - Deploys with clean filenames. Skips user-authored files unless force=True. - Also copies to .claude/agents/ and .cursor/agents/ when the respective - directories exist (multi-target). - - Args: - package_info: PackageInfo object with package metadata - project_root: Root directory of the project - force: If True, overwrite user-authored files on collision - managed_files: Set of relative paths known to be APM-managed - - Returns: - IntegrationResult: Results of the integration operation + """Integrate agents into .github/agents/ + auto-copy to claude/cursor. + + Legacy entry point that preserves the multi-target auto-copy + behaviour. New callers should use ``integrate_agents_for_target`` + directly. """ + from apm_cli.integration.targets import KNOWN_TARGETS + copilot = KNOWN_TARGETS["copilot"] + self.init_link_resolver(package_info, project_root) - - # Find all agent files in the package (.agent.md and .chatmode.md) - # NOTE: SKILL.md is NOT included - skills go to .github/skills/ via SkillIntegrator agent_files = self.find_agent_files(package_info.install_path) - - # If no agent files, return empty result if not agent_files: - return IntegrationResult( - files_integrated=0, - files_updated=0, - files_skipped=0, - target_paths=[], - ) - - # Create .github/agents/ if it doesn't exist + return IntegrationResult(0, 0, 0, []) + agents_dir = project_root / ".github" / "agents" agents_dir.mkdir(parents=True, exist_ok=True) - - # Also target .claude/agents/ when .claude/ folder exists + claude_agents_dir = None claude_dir = project_root / ".claude" if claude_dir.exists() and claude_dir.is_dir(): claude_agents_dir = claude_dir / "agents" claude_agents_dir.mkdir(parents=True, exist_ok=True) - - # Also target .cursor/agents/ when .cursor/ folder exists + cursor_agents_dir = None cursor_dir = project_root / ".cursor" if cursor_dir.exists() and cursor_dir.is_dir(): cursor_agents_dir = cursor_dir / "agents" cursor_agents_dir.mkdir(parents=True, exist_ok=True) - - # Process each agent file + files_integrated = 0 files_skipped = 0 - target_paths = [] + target_paths: List[Path] = [] total_links_resolved = 0 - + for source_file in agent_files: - target_filename = self.get_target_filename(source_file, package_info.package.name) + target_filename = self.get_target_filename_for_target( + source_file, package_info.package.name, copilot, + ) target_path = agents_dir / target_filename rel_path = portable_relpath(target_path, project_root) - + if self.check_collision(target_path, rel_path, managed_files, force, diagnostics=diagnostics): files_skipped += 1 continue - + links_resolved = self.copy_agent(source_file, target_path) total_links_resolved += links_resolved files_integrated += 1 target_paths.append(target_path) - - # Copy to .claude/agents/ as well with same collision check + if claude_agents_dir: - claude_filename = self.get_target_filename_claude(source_file, package_info.package.name) - claude_target = claude_agents_dir / claude_filename - claude_rel = portable_relpath(claude_target, project_root) - if not self.check_collision(claude_target, claude_rel, managed_files, force, diagnostics=diagnostics): - self.copy_agent(source_file, claude_target) - target_paths.append(claude_target) - - # Copy to .cursor/agents/ as well with same collision check + claude_target = KNOWN_TARGETS["claude"] + claude_filename = self.get_target_filename_for_target( + source_file, package_info.package.name, claude_target, + ) + claude_path = claude_agents_dir / claude_filename + claude_rel = portable_relpath(claude_path, project_root) + if not self.check_collision(claude_path, claude_rel, managed_files, force, diagnostics=diagnostics): + self.copy_agent(source_file, claude_path) + target_paths.append(claude_path) + if cursor_agents_dir: - cursor_filename = self.get_target_filename_cursor(source_file, package_info.package.name) - cursor_target = cursor_agents_dir / cursor_filename - cursor_rel = portable_relpath(cursor_target, project_root) - if not self.check_collision(cursor_target, cursor_rel, managed_files, force, diagnostics=diagnostics): - self.copy_agent(source_file, cursor_target) - target_paths.append(cursor_target) - + cursor_target = KNOWN_TARGETS["cursor"] + cursor_filename = self.get_target_filename_for_target( + source_file, package_info.package.name, cursor_target, + ) + cursor_path = cursor_agents_dir / cursor_filename + cursor_rel = portable_relpath(cursor_path, project_root) + if not self.check_collision(cursor_path, cursor_rel, managed_files, force, diagnostics=diagnostics): + self.copy_agent(source_file, cursor_path) + target_paths.append(cursor_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 get_target_filename_claude(self, source_file: Path, package_name: str) -> str: - """Generate target filename for Claude agents (clean, no suffix). - - Claude sub-agents use plain .md files in .claude/agents/. - Both .agent.md and .chatmode.md sources are converted to .md. - - Args: - source_file: Source file path - package_name: Name of the package (not used in simple naming) - - Returns: - str: Target filename (e.g., security.md) - """ - if source_file.name.endswith('.agent.md'): - stem = source_file.name[:-9] # Remove .agent.md - elif source_file.name.endswith('.chatmode.md'): - stem = source_file.name[:-12] # Remove .chatmode.md - else: - stem = source_file.stem - - return f"{stem}.md" - + """Generate target filename for Claude agents (plain .md).""" + from apm_cli.integration.targets import KNOWN_TARGETS + return self.get_target_filename_for_target( + source_file, package_name, KNOWN_TARGETS["claude"], + ) + def integrate_package_agents_claude(self, package_info, project_root: Path, force: bool = False, managed_files: set = None, diagnostics=None) -> IntegrationResult: - """Integrate all agents from a package into .claude/agents/. - - Deploys with clean filenames. Skips user-authored files unless force=True. - - Args: - package_info: PackageInfo object with package metadata - project_root: Root directory of the project - force: If True, overwrite user-authored files on collision - managed_files: Set of relative paths known to be APM-managed - - Returns: - IntegrationResult: Results of the integration operation + """Integrate agents into .claude/agents/. + + Legacy compat: ensures ``.claude/`` exists so the target-driven + method does not skip (the old method did not guard on root-dir + existence). """ - self.init_link_resolver(package_info, project_root) - - # Find all agent files in the package - agent_files = self.find_agent_files(package_info.install_path) - - if not agent_files: - return IntegrationResult( - files_integrated=0, - files_updated=0, - files_skipped=0, - target_paths=[], - ) - - # Create .claude/agents/ if it doesn't exist - agents_dir = project_root / ".claude" / "agents" - agents_dir.mkdir(parents=True, exist_ok=True) - - # Process each agent file - files_integrated = 0 - files_skipped = 0 - target_paths = [] - total_links_resolved = 0 - - for source_file in agent_files: - target_filename = self.get_target_filename_claude(source_file, package_info.package.name) - target_path = agents_dir / target_filename - rel_path = portable_relpath(target_path, project_root) - - if self.check_collision(target_path, rel_path, managed_files, force, diagnostics=diagnostics): - files_skipped += 1 - continue - - links_resolved = self.copy_agent(source_file, target_path) - total_links_resolved += links_resolved - files_integrated += 1 - target_paths.append(target_path) - - return IntegrationResult( - files_integrated=files_integrated, - files_updated=0, - files_skipped=files_skipped, - target_paths=target_paths, - links_resolved=total_links_resolved + from apm_cli.integration.targets import KNOWN_TARGETS + (project_root / ".claude").mkdir(parents=True, exist_ok=True) + return self.integrate_agents_for_target( + KNOWN_TARGETS["claude"], package_info, project_root, + force=force, managed_files=managed_files, + diagnostics=diagnostics, ) - + def sync_integration(self, apm_package, project_root: Path, managed_files: set = None) -> Dict[str, int]: """Remove APM-managed agent files from .github/agents/.""" - agents_dir = project_root / ".github" / "agents" - return self.sync_remove_files( - project_root, - managed_files, - prefix=".github/agents/", - legacy_glob_dir=agents_dir, - legacy_glob_pattern="*-apm.agent.md", + from apm_cli.integration.targets import KNOWN_TARGETS + return self.sync_for_target( + KNOWN_TARGETS["copilot"], apm_package, project_root, + managed_files=managed_files, ) - + def sync_integration_claude(self, apm_package, project_root: Path, managed_files: set = None) -> Dict[str, int]: """Remove APM-managed agent files from .claude/agents/.""" - agents_dir = project_root / ".claude" / "agents" - return self.sync_remove_files( - project_root, - managed_files, - prefix=".claude/agents/", - legacy_glob_dir=agents_dir, - legacy_glob_pattern="*-apm.md", + from apm_cli.integration.targets import KNOWN_TARGETS + return self.sync_for_target( + KNOWN_TARGETS["claude"], apm_package, project_root, + managed_files=managed_files, ) - + def get_target_filename_cursor(self, source_file: Path, package_name: str) -> str: - """Generate target filename for Cursor agents (clean, no suffix). - - Cursor sub-agents use plain .md files in .cursor/agents/. - Both .agent.md and .chatmode.md sources are converted to .md. - - Args: - source_file: Source file path - package_name: Name of the package (not used in simple naming) - - Returns: - str: Target filename (e.g., security.md) - """ - if source_file.name.endswith('.agent.md'): - stem = source_file.name[:-9] # Remove .agent.md - elif source_file.name.endswith('.chatmode.md'): - stem = source_file.name[:-12] # Remove .chatmode.md - else: - stem = source_file.stem - - return f"{stem}.md" - + """Generate target filename for Cursor agents (plain .md).""" + from apm_cli.integration.targets import KNOWN_TARGETS + return self.get_target_filename_for_target( + source_file, package_name, KNOWN_TARGETS["cursor"], + ) + def integrate_package_agents_cursor(self, package_info, project_root: Path, force: bool = False, managed_files: set = None, diagnostics=None) -> IntegrationResult: - """Integrate all agents from a package into .cursor/agents/. - - Deploys with clean filenames. Skips user-authored files unless force=True. - Only deploys if .cursor/ directory already exists (opt-in). - - Args: - package_info: PackageInfo object with package metadata - project_root: Root directory of the project - force: If True, overwrite user-authored files on collision - managed_files: Set of relative paths known to be APM-managed - - Returns: - IntegrationResult: Results of the integration operation - """ - self.init_link_resolver(package_info, project_root) - - # Find all agent files in the package - agent_files = self.find_agent_files(package_info.install_path) - - if not agent_files: - return IntegrationResult( - files_integrated=0, - files_updated=0, - files_skipped=0, - target_paths=[], - ) - - # Only deploy if .cursor/ directory exists (opt-in) - cursor_dir = project_root / ".cursor" - if not cursor_dir.exists() or not cursor_dir.is_dir(): - return IntegrationResult( - files_integrated=0, - files_updated=0, - files_skipped=0, - target_paths=[], - ) - - # Create .cursor/agents/ if it doesn't exist - agents_dir = cursor_dir / "agents" - agents_dir.mkdir(parents=True, exist_ok=True) - - # Process each agent file - files_integrated = 0 - files_skipped = 0 - target_paths = [] - total_links_resolved = 0 - - for source_file in agent_files: - target_filename = self.get_target_filename_cursor(source_file, package_info.package.name) - target_path = agents_dir / target_filename - rel_path = portable_relpath(target_path, project_root) - - if self.check_collision(target_path, rel_path, managed_files, force, diagnostics=diagnostics): - files_skipped += 1 - continue - - links_resolved = self.copy_agent(source_file, target_path) - total_links_resolved += links_resolved - files_integrated += 1 - target_paths.append(target_path) - - return IntegrationResult( - files_integrated=files_integrated, - files_updated=0, - files_skipped=files_skipped, - target_paths=target_paths, - links_resolved=total_links_resolved + """Integrate agents into .cursor/agents/.""" + from apm_cli.integration.targets import KNOWN_TARGETS + return self.integrate_agents_for_target( + KNOWN_TARGETS["cursor"], package_info, project_root, + force=force, managed_files=managed_files, + diagnostics=diagnostics, ) - + def sync_integration_cursor(self, apm_package, project_root: Path, managed_files: set = None) -> Dict[str, int]: """Remove APM-managed agent files from .cursor/agents/.""" - agents_dir = project_root / ".cursor" / "agents" - return self.sync_remove_files( - project_root, - managed_files, - prefix=".cursor/agents/", - legacy_glob_dir=agents_dir, - legacy_glob_pattern="*-apm.md", + from apm_cli.integration.targets import KNOWN_TARGETS + return self.sync_for_target( + KNOWN_TARGETS["cursor"], apm_package, project_root, + managed_files=managed_files, ) def integrate_package_agents_opencode(self, package_info, project_root: Path, force: bool = False, managed_files: set = None, diagnostics=None) -> IntegrationResult: - """Integrate all agents from a package into .opencode/agents/. - - Only deploys if .opencode/ directory already exists (opt-in). - Uses the same clean filename convention as Claude/Cursor agents. - """ - self.init_link_resolver(package_info, project_root) - - agent_files = self.find_agent_files(package_info.install_path) - if not agent_files: - return IntegrationResult( - files_integrated=0, files_updated=0, - files_skipped=0, target_paths=[], - ) - - opencode_dir = project_root / ".opencode" - if not opencode_dir.exists() or not opencode_dir.is_dir(): - return IntegrationResult( - files_integrated=0, files_updated=0, - files_skipped=0, target_paths=[], - ) - - agents_dir = opencode_dir / "agents" - agents_dir.mkdir(parents=True, exist_ok=True) - - files_integrated = 0 - files_skipped = 0 - target_paths = [] - total_links_resolved = 0 - - for source_file in agent_files: - # Reuse Claude naming — plain .md in target dir - target_filename = self.get_target_filename_claude( - source_file, package_info.package.name - ) - target_path = agents_dir / target_filename - rel_path = portable_relpath(target_path, project_root) - - if self.check_collision(target_path, rel_path, managed_files, force, diagnostics=diagnostics): - files_skipped += 1 - continue - - links_resolved = self.copy_agent(source_file, target_path) - total_links_resolved += links_resolved - files_integrated += 1 - target_paths.append(target_path) - - return IntegrationResult( - files_integrated=files_integrated, - files_updated=0, - files_skipped=files_skipped, - target_paths=target_paths, - links_resolved=total_links_resolved, + """Integrate agents into .opencode/agents/.""" + from apm_cli.integration.targets import KNOWN_TARGETS + return self.integrate_agents_for_target( + KNOWN_TARGETS["opencode"], package_info, project_root, + force=force, managed_files=managed_files, + diagnostics=diagnostics, ) def sync_integration_opencode(self, apm_package, project_root: Path, managed_files: set = None) -> Dict[str, int]: """Remove APM-managed agent files from .opencode/agents/.""" - agents_dir = project_root / ".opencode" / "agents" - return self.sync_remove_files( - project_root, - managed_files, - prefix=".opencode/agents/", - legacy_glob_dir=agents_dir, - legacy_glob_pattern="*-apm.md", + from apm_cli.integration.targets import KNOWN_TARGETS + return self.sync_for_target( + KNOWN_TARGETS["opencode"], apm_package, project_root, + managed_files=managed_files, ) diff --git a/src/apm_cli/integration/base_integrator.py b/src/apm_cli/integration/base_integrator.py index 47405060..bd407a15 100644 --- a/src/apm_cli/integration/base_integrator.py +++ b/src/apm_cli/integration/base_integrator.py @@ -130,56 +130,99 @@ def validate_deploy_path( return False return True + # Backward-compat aliases mapping raw ``{prim}_{target}`` keys to + # the bucket names that existing callers expect. Shared between + # ``partition_managed_files`` and ``partition_bucket_key`` so the + # mapping is defined exactly once. + _BUCKET_ALIASES: dict = { + "prompts_copilot": "prompts", + "agents_copilot": "agents_github", + "commands_claude": "commands", + "commands_opencode": "commands_opencode", + "instructions_copilot": "instructions", + "instructions_cursor": "rules_cursor", + } + + @staticmethod + def partition_bucket_key(prim_name: str, target_name: str) -> str: + """Return the canonical bucket key for a (primitive, target) pair. + + Applies backward-compat aliases so callers stay in sync with + ``partition_managed_files`` bucket naming. + """ + raw = f"{prim_name}_{target_name}" + return BaseIntegrator._BUCKET_ALIASES.get(raw, raw) + @staticmethod def partition_managed_files( managed_files: Set[str], ) -> dict: """Partition *managed_files* by integration prefix in a single pass. - Returns a dict with keys ``"prompts"``, ``"agents_github"``, - ``"agents_claude"``, ``"agents_cursor"``, ``"agents_opencode"``, - ``"commands"``, ``"commands_opencode"``, ``"skills"``, ``"hooks"``, - ``"instructions"``, ``"rules_cursor"`` mapping to the subset of - paths for each integration type. + Bucket keys are generated dynamically from ``KNOWN_TARGETS`` so + adding a new target or primitive automatically creates the + corresponding bucket. + + Cross-target buckets (``skills``, ``hooks``) group all targets + together because ``SkillIntegrator`` and ``HookIntegrator`` + handle multi-target sync internally. + + Path routing uses an O(1) dict keyed by ``(root_dir, subdir)`` + parsed from the first two path segments, avoiding a linear scan + over all known prefixes. """ - buckets: dict = { - "prompts": set(), - "agents_github": set(), - "agents_claude": set(), - "agents_cursor": set(), - "agents_opencode": set(), - "commands": set(), - "commands_opencode": set(), - "skills": set(), - "hooks": set(), - "instructions": set(), - "rules_cursor": set(), - } + from apm_cli.integration.targets import KNOWN_TARGETS + + buckets: dict = {} + + # Skills and hooks are cross-target (single bucket each) + skill_prefixes: list = [] + hook_prefixes: list = [] + + # O(1) lookup: (root_dir, subdir) -> bucket_key + component_map: dict = {} + + for target in KNOWN_TARGETS.values(): + for prim_name, mapping in target.primitives.items(): + prefix = f"{target.root_dir}/{mapping.subdir}/" + if prim_name == "skills": + skill_prefixes.append(prefix) + elif prim_name == "hooks": + hook_prefixes.append(prefix) + else: + raw_key = f"{prim_name}_{target.name}" + bucket_key = BaseIntegrator._BUCKET_ALIASES.get( + raw_key, raw_key + ) + if bucket_key not in buckets: + buckets[bucket_key] = set() + component_map[ + (target.root_dir, mapping.subdir) + ] = bucket_key + + buckets["skills"] = set() + buckets["hooks"] = set() + + skill_tuple = tuple(skill_prefixes) + hook_tuple = tuple(hook_prefixes) + + # Single O(M) pass -- each path is routed in O(1) for p in managed_files: - if p.startswith(".github/prompts/"): - buckets["prompts"].add(p) - elif p.startswith(".github/agents/"): - buckets["agents_github"].add(p) - elif p.startswith(".claude/agents/"): - buckets["agents_claude"].add(p) - elif p.startswith(".cursor/agents/"): - buckets["agents_cursor"].add(p) - elif p.startswith(".opencode/agents/"): - buckets["agents_opencode"].add(p) - elif p.startswith(".claude/commands/"): - buckets["commands"].add(p) - elif p.startswith(".opencode/commands/"): - buckets["commands_opencode"].add(p) - elif p.startswith((".github/skills/", ".claude/skills/", ".cursor/skills/", ".opencode/skills/")): + if p.startswith(skill_tuple): buckets["skills"].add(p) - elif p.startswith( - (".github/hooks/", ".claude/hooks/", ".cursor/hooks/", ".opencode/hooks/") - ): + elif p.startswith(hook_tuple): buckets["hooks"].add(p) - elif p.startswith(".github/instructions/"): - buckets["instructions"].add(p) - elif p.startswith(".cursor/rules/"): - buckets["rules_cursor"].add(p) + else: + slash1 = p.find("/") + if slash1 > 0: + slash2 = p.find("/", slash1 + 1) + if slash2 > 0: + bkey = component_map.get( + (p[:slash1], p[slash1 + 1 : slash2]) + ) + if bkey: + buckets[bkey].add(p) + return buckets @staticmethod diff --git a/src/apm_cli/integration/command_integrator.py b/src/apm_cli/integration/command_integrator.py index 883f7ef5..4628b2a4 100644 --- a/src/apm_cli/integration/command_integrator.py +++ b/src/apm_cli/integration/command_integrator.py @@ -1,16 +1,21 @@ -"""Claude command integration functionality for APM packages. +"""Command integration functionality for APM packages. -Integrates .prompt.md files as .claude/commands/ during install, -mirroring how PromptIntegrator handles .github/prompts/. +Integrates .prompt.md files as commands for any target that supports the +``commands`` primitive (e.g. ``.claude/commands/``, ``.opencode/commands/``). """ +from __future__ import annotations + from pathlib import Path -from typing import List, Dict +from typing import TYPE_CHECKING, Dict, List import frontmatter from apm_cli.integration.base_integrator import BaseIntegrator, IntegrationResult from apm_cli.utils.paths import portable_relpath +if TYPE_CHECKING: + from apm_cli.integration.targets import TargetProfile + # Re-export for backward compat (tests import CommandIntegrationResult) CommandIntegrationResult = IntegrationResult @@ -101,159 +106,153 @@ def integrate_command(self, source: Path, target: Path, package_info, original_p return links_resolved - def integrate_package_commands(self, package_info, project_root: Path, - force: bool = False, - managed_files: set = None, - diagnostics=None) -> IntegrationResult: - """Integrate all prompt files from a package as Claude commands. - - Deploys with clean filenames. Skips user-authored files unless force=True. + # ------------------------------------------------------------------ + # Target-driven API (data-driven dispatch) + # ------------------------------------------------------------------ + + def integrate_commands_for_target( + self, + target: "TargetProfile", + package_info, + project_root: Path, + *, + force: bool = False, + managed_files: set = None, + diagnostics=None, + ) -> IntegrationResult: + """Integrate prompt files as commands for a single *target*. + + Reads deployment paths from *target*'s ``commands`` primitive + mapping, applying the opt-in guard when ``auto_create`` is + ``False``. """ - commands_dir = project_root / ".claude" / "commands" + mapping = target.primitives.get("commands") + if not mapping: + return IntegrationResult(0, 0, 0, [], 0) + + target_root = project_root / target.root_dir + if not target.auto_create and not target_root.is_dir(): + return IntegrationResult(0, 0, 0, [], 0) + 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 - ) - + return IntegrationResult(0, 0, 0, [], 0) + self.init_link_resolver(package_info, project_root) - + + commands_dir = target_root / mapping.subdir files_integrated = 0 files_skipped = 0 - target_paths = [] + target_paths: List[Path] = [] total_links_resolved = 0 - + for prompt_file in prompt_files: - # Generate clean command name (no suffix) filename = prompt_file.name if filename.endswith('.prompt.md'): base_name = filename[:-len('.prompt.md')] else: base_name = prompt_file.stem - - target_path = commands_dir / f"{base_name}.md" + + target_path = commands_dir / f"{base_name}{mapping.extension}" rel_path = portable_relpath(target_path, project_root) - - if self.check_collision(target_path, rel_path, managed_files, force, diagnostics=diagnostics): + + if self.check_collision( + target_path, rel_path, managed_files, force, + diagnostics=diagnostics, + ): files_skipped += 1 continue - + links_resolved = self.integrate_command( - prompt_file, target_path, package_info, prompt_file + 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_for_target( + self, + target: "TargetProfile", + apm_package, + project_root: Path, + managed_files: set = None, + ) -> Dict: + """Remove APM-managed command files for a single *target*.""" + mapping = target.primitives.get("commands") + if not mapping: + return {"files_removed": 0, "errors": 0} + prefix = f"{target.root_dir}/{mapping.subdir}/" + legacy_dir = project_root / target.root_dir / mapping.subdir return self.sync_remove_files( project_root, managed_files, - prefix=".claude/commands/", - legacy_glob_dir=commands_dir, + prefix=prefix, + legacy_glob_dir=legacy_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. + + # ------------------------------------------------------------------ + # Legacy per-target API (delegates to target-driven methods) + # ------------------------------------------------------------------ + + def integrate_package_commands(self, package_info, project_root: Path, + force: bool = False, + managed_files: set = None, + diagnostics=None) -> IntegrationResult: + """Integrate prompt files as Claude commands (.claude/commands/). + + Legacy compat: ensures ``.claude/`` exists so the target-driven + method does not skip. """ - stats = self.sync_remove_files( - project_root, - managed_files, - prefix=".claude/commands/", - legacy_glob_dir=project_root / ".claude" / "commands", - legacy_glob_pattern="*-apm.md", + from apm_cli.integration.targets import KNOWN_TARGETS + (project_root / ".claude").mkdir(parents=True, exist_ok=True) + return self.integrate_commands_for_target( + KNOWN_TARGETS["claude"], package_info, project_root, + force=force, managed_files=managed_files, + diagnostics=diagnostics, + ) + + def sync_integration(self, apm_package, project_root: Path, + managed_files: set = None) -> Dict: + """Remove APM-managed command files from .claude/commands/.""" + from apm_cli.integration.targets import KNOWN_TARGETS + return self.sync_for_target( + KNOWN_TARGETS["claude"], apm_package, project_root, + managed_files=managed_files, ) + + def remove_package_commands(self, package_name: str, project_root: Path, + managed_files: set = None) -> int: + """Remove APM-managed command files.""" + stats = self.sync_integration(None, project_root, + managed_files=managed_files) return stats["files_removed"] def integrate_package_commands_opencode(self, package_info, project_root: Path, force: bool = False, managed_files: set = None, diagnostics=None) -> IntegrationResult: - """Integrate all prompt files from a package as OpenCode commands. - - Deploys .prompt.md → .opencode/commands/.md. - Only deploys if .opencode/ directory already exists (opt-in). - """ - opencode_dir = project_root / ".opencode" - if not opencode_dir.exists() or not opencode_dir.is_dir(): - return IntegrationResult( - files_integrated=0, files_updated=0, - files_skipped=0, target_paths=[], links_resolved=0, - ) - - commands_dir = opencode_dir / "commands" - prompt_files = self.find_prompt_files(package_info.install_path) - - if not prompt_files: - return IntegrationResult( - files_integrated=0, files_updated=0, - files_skipped=0, target_paths=[], links_resolved=0, - ) - - self.init_link_resolver(package_info, project_root) - - files_integrated = 0 - files_skipped = 0 - target_paths = [] - total_links_resolved = 0 - - for prompt_file in prompt_files: - filename = prompt_file.name - if filename.endswith('.prompt.md'): - base_name = filename[:-len('.prompt.md')] - else: - base_name = prompt_file.stem - - target_path = commands_dir / f"{base_name}.md" - rel_path = portable_relpath(target_path, project_root) - - if self.check_collision(target_path, rel_path, managed_files, force, diagnostics=diagnostics): - files_skipped += 1 - continue - - links_resolved = self.integrate_command( - prompt_file, target_path, package_info, prompt_file - ) - files_integrated += 1 - total_links_resolved += links_resolved - target_paths.append(target_path) - - return IntegrationResult( - files_integrated=files_integrated, - files_updated=0, - files_skipped=files_skipped, - target_paths=target_paths, - links_resolved=total_links_resolved, + """Integrate prompt files as OpenCode commands (.opencode/commands/).""" + from apm_cli.integration.targets import KNOWN_TARGETS + return self.integrate_commands_for_target( + KNOWN_TARGETS["opencode"], package_info, project_root, + force=force, managed_files=managed_files, + diagnostics=diagnostics, ) def sync_integration_opencode(self, apm_package, project_root: Path, managed_files: set = None) -> Dict: """Remove APM-managed command files from .opencode/commands/.""" - commands_dir = project_root / ".opencode" / "commands" - return self.sync_remove_files( - project_root, - managed_files, - prefix=".opencode/commands/", - legacy_glob_dir=commands_dir, - legacy_glob_pattern="*-apm.md", + from apm_cli.integration.targets import KNOWN_TARGETS + return self.sync_for_target( + KNOWN_TARGETS["opencode"], apm_package, project_root, + managed_files=managed_files, ) diff --git a/src/apm_cli/integration/hook_integrator.py b/src/apm_cli/integration/hook_integrator.py index 2d122c27..23f05937 100644 --- a/src/apm_cli/integration/hook_integrator.py +++ b/src/apm_cli/integration/hook_integrator.py @@ -545,6 +545,46 @@ def integrate_package_hooks_cursor(self, package_info, project_root: Path, target_paths=target_paths, ) + # ------------------------------------------------------------------ + # Target-driven API (thin wrappers — HookIntegrator keeps genuine + # algorithmic diversity per-target, so we dispatch by target.name) + # ------------------------------------------------------------------ + + def integrate_hooks_for_target( + self, + target, + package_info, + project_root: Path, + *, + force: bool = False, + managed_files: set = None, + diagnostics=None, + ) -> "HookIntegrationResult": + """Integrate hooks for a single *target*. + + Dispatches to the existing per-target methods by ``target.name`` + because each hook format has genuine algorithmic diversity. + """ + if target.name == "copilot": + return self.integrate_package_hooks( + package_info, project_root, + force=force, managed_files=managed_files, + diagnostics=diagnostics, + ) + if target.name == "claude": + return self.integrate_package_hooks_claude( + package_info, project_root, + force=force, managed_files=managed_files, + diagnostics=diagnostics, + ) + if target.name == "cursor": + return self.integrate_package_hooks_cursor( + package_info, project_root, + force=force, managed_files=managed_files, + diagnostics=diagnostics, + ) + return HookIntegrationResult(hooks_integrated=0, scripts_copied=0) + def sync_integration(self, apm_package, project_root: Path, managed_files: set = None) -> Dict: """Remove APM-managed hook files. diff --git a/src/apm_cli/integration/instruction_integrator.py b/src/apm_cli/integration/instruction_integrator.py index 2d6f36c1..56b685a4 100644 --- a/src/apm_cli/integration/instruction_integrator.py +++ b/src/apm_cli/integration/instruction_integrator.py @@ -1,19 +1,23 @@ """Instruction integration functionality for APM packages. -Deploys .instructions.md files from APM packages to .github/instructions/ -so VS Code Copilot picks them up natively with applyTo: scoping. - -Also converts instructions to Cursor Rules (.mdc) format when a .cursor/ -directory exists in the project root. +Deploys .instructions.md files from APM packages to the appropriate +target directory (e.g. ``.github/instructions/`` for Copilot, +``.cursor/rules/`` for Cursor). Content transforms are selected by +the ``format_id`` field in ``PrimitiveMapping``. """ +from __future__ import annotations + import re from pathlib import Path -from typing import Dict, List, Optional, Set +from typing import TYPE_CHECKING, Dict, List, Optional, Set from apm_cli.integration.base_integrator import BaseIntegrator, IntegrationResult from apm_cli.utils.paths import portable_relpath +if TYPE_CHECKING: + from apm_cli.integration.targets import TargetProfile + class InstructionIntegrator(BaseIntegrator): """Handles integration of APM package instructions into .github/instructions/. @@ -43,49 +47,74 @@ def copy_instruction(self, source: Path, target: Path) -> int: target.write_text(content, encoding='utf-8') return links_resolved - def integrate_package_instructions( + # ------------------------------------------------------------------ + # Target-driven API (data-driven dispatch) + # ------------------------------------------------------------------ + + def integrate_instructions_for_target( self, + target: "TargetProfile", package_info, project_root: Path, + *, force: bool = False, managed_files: Optional[Set[str]] = None, diagnostics=None, - logger=None, ) -> IntegrationResult: - """Integrate all instructions from a package into .github/instructions/. + """Integrate instructions for a single *target*. - Skips files that exist locally and are not tracked in any package's - deployed_files (user-authored), unless force=True. + Selects the content transform via ``format_id``: + + * ``cursor_rules`` -- convert ``applyTo:`` to ``globs:`` frontmatter + * anything else -- copy verbatim (identity transform) """ - self.init_link_resolver(package_info, project_root) + mapping = target.primitives.get("instructions") + if not mapping: + return IntegrationResult(0, 0, 0, []) - instruction_files = self.find_instruction_files(package_info.install_path) + target_root = project_root / target.root_dir + if not target.auto_create and not target_root.is_dir(): + return IntegrationResult(0, 0, 0, []) + self.init_link_resolver(package_info, project_root) + instruction_files = self.find_instruction_files(package_info.install_path) if not instruction_files: - return IntegrationResult( - files_integrated=0, - files_updated=0, - files_skipped=0, - target_paths=[], - ) + return IntegrationResult(0, 0, 0, []) - instructions_dir = project_root / ".github" / "instructions" - instructions_dir.mkdir(parents=True, exist_ok=True) + deploy_dir = target_root / mapping.subdir + deploy_dir.mkdir(parents=True, exist_ok=True) + + use_cursor_transform = mapping.format_id == "cursor_rules" files_integrated = 0 files_skipped = 0 - target_paths = [] + target_paths: List[Path] = [] total_links_resolved = 0 for source_file in instruction_files: - target_path = instructions_dir / source_file.name + if use_cursor_transform: + stem = source_file.name + if stem.endswith(".instructions.md"): + stem = stem[: -len(".instructions.md")] + target_name = f"{stem}{mapping.extension}" + else: + target_name = source_file.name + + target_path = deploy_dir / target_name rel_path = portable_relpath(target_path, project_root) - if self.check_collision(target_path, rel_path, managed_files, force, diagnostics=diagnostics): + if self.check_collision( + target_path, rel_path, managed_files, force, + diagnostics=diagnostics, + ): files_skipped += 1 continue - links_resolved = self.copy_instruction(source_file, target_path) + if use_cursor_transform: + links_resolved = self.copy_instruction_cursor(source_file, target_path) + else: + links_resolved = self.copy_instruction(source_file, target_path) + total_links_resolved += links_resolved files_integrated += 1 target_paths.append(target_path) @@ -98,25 +127,63 @@ def integrate_package_instructions( links_resolved=total_links_resolved, ) - def sync_integration( + def sync_for_target( self, + target: "TargetProfile", apm_package, project_root: Path, managed_files: Optional[Set[str]] = None, ) -> Dict[str, int]: - """Remove APM-managed instruction files. - - Only removes files listed in *managed_files* (from apm.lock - deployed_files). Falls back to a discovery-based scan when - *managed_files* is ``None`` (old lockfile without deployed_files). - """ - instructions_dir = project_root / ".github" / "instructions" + """Remove APM-managed instruction files for a single *target*.""" + mapping = target.primitives.get("instructions") + if not mapping: + return {"files_removed": 0, "errors": 0} + prefix = f"{target.root_dir}/{mapping.subdir}/" + legacy_dir = project_root / target.root_dir / mapping.subdir + legacy_pattern = ( + "*.mdc" if mapping.format_id == "cursor_rules" + else "*.instructions.md" + ) return self.sync_remove_files( project_root, managed_files, - prefix=".github/instructions/", - legacy_glob_dir=instructions_dir, - legacy_glob_pattern="*.instructions.md", + prefix=prefix, + legacy_glob_dir=legacy_dir, + legacy_glob_pattern=legacy_pattern, + ) + + # ------------------------------------------------------------------ + # Legacy per-target API (delegates to target-driven methods) + # ------------------------------------------------------------------ + + def integrate_package_instructions( + self, + package_info, + project_root: Path, + force: bool = False, + managed_files: Optional[Set[str]] = None, + diagnostics=None, + logger=None, + ) -> IntegrationResult: + """Integrate instructions into .github/instructions/.""" + from apm_cli.integration.targets import KNOWN_TARGETS + return self.integrate_instructions_for_target( + KNOWN_TARGETS["copilot"], package_info, project_root, + force=force, managed_files=managed_files, + diagnostics=diagnostics, + ) + + def sync_integration( + self, + apm_package, + project_root: Path, + managed_files: Optional[Set[str]] = None, + ) -> Dict[str, int]: + """Remove APM-managed instruction files from .github/instructions/.""" + from apm_cli.integration.targets import KNOWN_TARGETS + return self.sync_for_target( + KNOWN_TARGETS["copilot"], apm_package, project_root, + managed_files=managed_files, ) # ------------------------------------------------------------------ @@ -186,65 +253,12 @@ def integrate_package_instructions_cursor( diagnostics=None, logger=None, ) -> IntegrationResult: - """Integrate instructions as Cursor Rules into ``.cursor/rules/``. - - Only deploys when ``.cursor/`` already exists (opt-in). - Creates ``.cursor/rules/`` subdirectory if needed. - """ - cursor_dir = project_root / ".cursor" - if not cursor_dir.exists(): - return IntegrationResult( - files_integrated=0, - files_updated=0, - files_skipped=0, - target_paths=[], - ) - - self.init_link_resolver(package_info, project_root) - - instruction_files = self.find_instruction_files(package_info.install_path) - - if not instruction_files: - return IntegrationResult( - files_integrated=0, - files_updated=0, - files_skipped=0, - target_paths=[], - ) - - rules_dir = cursor_dir / "rules" - rules_dir.mkdir(parents=True, exist_ok=True) - - files_integrated = 0 - files_skipped = 0 - target_paths = [] - total_links_resolved = 0 - - for source_file in instruction_files: - # Strip .instructions.md suffix, add .mdc - stem = source_file.name - if stem.endswith(".instructions.md"): - stem = stem[: -len(".instructions.md")] - mdc_name = f"{stem}.mdc" - - target_path = rules_dir / mdc_name - rel_path = portable_relpath(target_path, project_root) - - if self.check_collision(target_path, rel_path, managed_files, force, diagnostics=diagnostics): - files_skipped += 1 - continue - - links_resolved = self.copy_instruction_cursor(source_file, target_path) - total_links_resolved += links_resolved - files_integrated += 1 - target_paths.append(target_path) - - return IntegrationResult( - files_integrated=files_integrated, - files_updated=0, - files_skipped=files_skipped, - target_paths=target_paths, - links_resolved=total_links_resolved, + """Integrate instructions as Cursor Rules into ``.cursor/rules/``.""" + from apm_cli.integration.targets import KNOWN_TARGETS + return self.integrate_instructions_for_target( + KNOWN_TARGETS["cursor"], package_info, project_root, + force=force, managed_files=managed_files, + diagnostics=diagnostics, ) def sync_integration_cursor( @@ -254,11 +268,8 @@ def sync_integration_cursor( managed_files: Optional[Set[str]] = None, ) -> Dict[str, int]: """Remove APM-managed Cursor Rules files from ``.cursor/rules/``.""" - rules_dir = project_root / ".cursor" / "rules" - return self.sync_remove_files( - project_root, - managed_files, - prefix=".cursor/rules/", - legacy_glob_dir=rules_dir, - legacy_glob_pattern="*.mdc", + from apm_cli.integration.targets import KNOWN_TARGETS + return self.sync_for_target( + KNOWN_TARGETS["cursor"], apm_package, project_root, + managed_files=managed_files, ) diff --git a/src/apm_cli/integration/prompt_integrator.py b/src/apm_cli/integration/prompt_integrator.py index 444831df..97bd43b2 100644 --- a/src/apm_cli/integration/prompt_integrator.py +++ b/src/apm_cli/integration/prompt_integrator.py @@ -1,11 +1,16 @@ """Prompt integration functionality for APM packages.""" +from __future__ import annotations + from pathlib import Path -from typing import List, Dict +from typing import TYPE_CHECKING, Dict, List, Optional, Set from apm_cli.integration.base_integrator import BaseIntegrator, IntegrationResult from apm_cli.utils.paths import portable_relpath +if TYPE_CHECKING: + from apm_cli.integration.targets import TargetProfile + class PromptIntegrator(BaseIntegrator): """Handles integration of APM package prompts into .github/prompts/.""" @@ -66,6 +71,60 @@ def get_target_filename(self, source_file: Path, package_name: str) -> str: + # ------------------------------------------------------------------ + # Target-driven API (data-driven dispatch) + # ------------------------------------------------------------------ + + def integrate_prompts_for_target( + self, + target: "TargetProfile", + package_info, + project_root: Path, + *, + force: bool = False, + managed_files: Optional[Set[str]] = None, + diagnostics=None, + ) -> IntegrationResult: + """Integrate prompts for a single *target*.""" + mapping = target.primitives.get("prompts") + if not mapping: + return IntegrationResult(0, 0, 0, []) + + target_root = project_root / target.root_dir + if not target.auto_create and not target_root.is_dir(): + return IntegrationResult(0, 0, 0, []) + + return self.integrate_package_prompts( + package_info, project_root, + force=force, managed_files=managed_files, + diagnostics=diagnostics, + ) + + def sync_for_target( + self, + target: "TargetProfile", + apm_package, + project_root: Path, + managed_files: Optional[Set[str]] = None, + ) -> Dict[str, int]: + """Remove APM-managed prompt files for a single *target*.""" + mapping = target.primitives.get("prompts") + if not mapping: + return {"files_removed": 0, "errors": 0} + prefix = f"{target.root_dir}/{mapping.subdir}/" + legacy_dir = project_root / target.root_dir / mapping.subdir + return self.sync_remove_files( + project_root, + managed_files, + prefix=prefix, + legacy_glob_dir=legacy_dir, + legacy_glob_pattern="*-apm.prompt.md", + ) + + # ------------------------------------------------------------------ + # Legacy API + # ------------------------------------------------------------------ + def integrate_package_prompts(self, package_info, project_root: Path, force: bool = False, managed_files: set = None, diff --git a/src/apm_cli/integration/skill_integrator.py b/src/apm_cli/integration/skill_integrator.py index e2e1ef19..ddd8bb3c 100644 --- a/src/apm_cli/integration/skill_integrator.py +++ b/src/apm_cli/integration/skill_integrator.py @@ -247,6 +247,7 @@ def copy_skill_to_target( package_info, source_path: Path, target_base: Path, + targets=None, ) -> list[Path]: """Copy skill directory to all active target skills/ directories. @@ -256,7 +257,9 @@ def copy_skill_to_target( - Skill name validation/normalization - Directory structure preservation - Deployment to every active target that supports skills - (driven by ``active_targets()`` from ``targets.py``) + + When *targets* is provided, only those targets are used. + Otherwise falls back to ``active_targets()``. Source SKILL.md is copied verbatim -- no metadata injection. @@ -271,6 +274,7 @@ def copy_skill_to_target( package_info: PackageInfo object with package metadata source_path: Path to skill in apm_modules/ target_base: Usually project root + targets: Optional explicit list of TargetProfile objects. Returns: List of all deployed skill directory paths (empty if skipped). @@ -297,9 +301,9 @@ def copy_skill_to_target( deployed: list[Path] = [] # Deploy to all active targets that support skills. - from apm_cli.integration.targets import active_targets - - targets = active_targets(target_base) + if targets is None: + from apm_cli.integration.targets import active_targets + targets = active_targets(target_base) for target in targets: if not target.supports("skills"): continue @@ -568,6 +572,7 @@ def _build_skill_ownership_map(project_root: Path) -> dict[str, str]: def _promote_sub_skills_standalone( self, package_info, project_root: Path, diagnostics=None, managed_files=None, force: bool = False, logger=None, + targets=None, ) -> tuple[int, list[Path]]: """Promote sub-skills from a package that is NOT itself a skill. @@ -579,6 +584,7 @@ def _promote_sub_skills_standalone( Args: package_info: PackageInfo object with package metadata. project_root: Root directory of the project. + targets: Optional explicit list of TargetProfile objects. Returns: tuple[int, list[Path]]: (count of promoted sub-skills, list of deployed dirs) @@ -588,11 +594,12 @@ def _promote_sub_skills_standalone( if not sub_skills_dir.is_dir(): return 0, [] - from apm_cli.integration.targets import active_targets + if targets is None: + from apm_cli.integration.targets import active_targets + targets = active_targets(project_root) parent_name = package_path.name owned_by = self._build_skill_ownership_map(project_root) - targets = active_targets(project_root) count = 0 all_deployed: list[Path] = [] @@ -622,7 +629,7 @@ def _promote_sub_skills_standalone( def _integrate_native_skill( self, package_info, project_root: Path, source_skill_md: Path, diagnostics=None, managed_files=None, force: bool = False, - logger=None, + logger=None, targets=None, ) -> SkillIntegrationResult: """Copy a native Skill (with existing SKILL.md) to all active targets. @@ -683,11 +690,11 @@ def _integrate_native_skill( pass # CLI not available in tests # Deploy to all active targets that support skills. - # Targets are selected by directory presence, with copilot (.github) - # as the fallback when no target dirs exist. - from apm_cli.integration.targets import active_targets - - targets = active_targets(project_root) + # When *targets* is provided (from --target), use it directly. + # Otherwise auto-detect with copilot as the fallback. + if targets is None: + from apm_cli.integration.targets import active_targets + targets = active_targets(project_root) skill_created = False skill_updated = False files_copied = 0 @@ -752,14 +759,15 @@ def _integrate_native_skill( target_paths=all_target_paths ) - def integrate_package_skill(self, package_info, project_root: Path, diagnostics=None, managed_files=None, force: bool = False, logger=None) -> SkillIntegrationResult: + def integrate_package_skill(self, package_info, project_root: Path, diagnostics=None, managed_files=None, force: bool = False, logger=None, targets=None) -> SkillIntegrationResult: """Integrate a package's skill into all active target directories. Copies native skills (packages with SKILL.md at root) to every active target that supports skills (e.g. .github/skills/, .claude/skills/, .opencode/skills/). Also promotes any sub-skills from .apm/skills/. - Target selection is driven by ``active_targets()`` from ``targets.py``. + When *targets* is provided (e.g. from ``--target cursor``), only those + targets are considered. Otherwise falls back to ``active_targets()``. Packages without SKILL.md at root are not installed as skills -- only their sub-skills (if any) are promoted. @@ -767,6 +775,7 @@ def integrate_package_skill(self, package_info, project_root: Path, diagnostics= Args: package_info: PackageInfo object with package metadata project_root: Root directory of the project + targets: Optional explicit list of TargetProfile objects. Returns: SkillIntegrationResult: Results of the integration operation @@ -778,7 +787,7 @@ def integrate_package_skill(self, package_info, project_root: Path, diagnostics= # 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, diagnostics=diagnostics, managed_files=managed_files, force=force, logger=logger + package_info, project_root, diagnostics=diagnostics, managed_files=managed_files, force=force, logger=logger, targets=targets ) return SkillIntegrationResult( skill_created=False, @@ -811,12 +820,12 @@ def integrate_package_skill(self, package_info, project_root: Path, diagnostics= # 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, diagnostics=diagnostics, managed_files=managed_files, force=force, logger=logger) + return self._integrate_native_skill(package_info, project_root, source_skill_md, diagnostics=diagnostics, managed_files=managed_files, force=force, logger=logger, targets=targets) # 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, diagnostics=diagnostics, managed_files=managed_files, force=force, logger=logger + package_info, project_root, diagnostics=diagnostics, managed_files=managed_files, force=force, logger=logger, targets=targets ) return SkillIntegrationResult( skill_created=False, diff --git a/tests/unit/integration/test_command_integrator.py b/tests/unit/integration/test_command_integrator.py index 18766669..c038fbcd 100644 --- a/tests/unit/integration/test_command_integrator.py +++ b/tests/unit/integration/test_command_integrator.py @@ -368,10 +368,11 @@ def test_sync_handles_missing_dir(self, temp_project_no_opencode): class TestIntegratePackagePrimitivesTargetGating: - """Tests that _integrate_package_primitives respects the integrate_claude flag. + """Tests that _integrate_package_primitives respects target gating. - Regression test for: CommandIntegrator was called unconditionally, causing - .claude/commands/ to be created even when target=copilot (integrate_claude=False). + Regression test for: commands/agents/hooks were dispatched to targets + that were not in the active targets list (e.g., --target copilot wrote + to .claude/). """ def _make_mock_integrators(self): @@ -399,33 +400,28 @@ def _empty_result(*args, **kwargs): "hook_integrator", ): m = MagicMock() + # Target-driven methods used by the dispatch loop for method in ( - "integrate_package_prompts", - "integrate_package_agents", - "integrate_package_agents_claude", - "integrate_package_agents_cursor", - "integrate_package_agents_opencode", + "integrate_prompts_for_target", + "integrate_agents_for_target", + "integrate_commands_for_target", + "integrate_instructions_for_target", + "integrate_hooks_for_target", "integrate_package_skill", - "integrate_package_instructions", - "integrate_package_instructions_cursor", - "integrate_package_commands", - "integrate_package_commands_opencode", - "integrate_package_hooks", - "integrate_package_hooks_claude", - "integrate_package_hooks_cursor", ): getattr(m, method).side_effect = _empty_result integrators[name] = m return integrators - def test_integrate_claude_false_does_not_call_integrate_package_commands(self): - """When integrate_claude=False, integrate_package_commands must not be called. + def test_copilot_only_does_not_dispatch_commands(self): + """When targets=[copilot], commands must not be dispatched. - This is the regression test for the bug where .claude/commands/ was created - even when target=copilot (vscode) set integrate_claude=False. + Copilot has no ``commands`` primitive, so the dispatch loop + should never call ``integrate_commands_for_target``. """ import tempfile, shutil from apm_cli.commands.install import _integrate_package_primitives + from apm_cli.integration.targets import KNOWN_TARGETS from apm_cli.utils.diagnostics import DiagnosticCollector temp_dir = tempfile.mkdtemp() @@ -440,24 +436,23 @@ def test_integrate_claude_false_does_not_call_integrate_package_commands(self): _integrate_package_primitives( package_info, project_root, - integrate_vscode=True, - integrate_claude=False, - integrate_opencode=False, + targets=[KNOWN_TARGETS["copilot"]], managed_files=set(), force=False, diagnostics=diagnostics, **integrators, ) - integrators["command_integrator"].integrate_package_commands.assert_not_called() + integrators["command_integrator"].integrate_commands_for_target.assert_not_called() assert not (project_root / ".claude" / "commands").exists() finally: shutil.rmtree(temp_dir, ignore_errors=True) - def test_integrate_claude_true_calls_integrate_package_commands(self): - """When integrate_claude=True, integrate_package_commands must be called.""" + def test_claude_target_dispatches_commands(self): + """When targets=[claude], commands must be dispatched.""" import tempfile, shutil from apm_cli.commands.install import _integrate_package_primitives + from apm_cli.integration.targets import KNOWN_TARGETS from apm_cli.utils.diagnostics import DiagnosticCollector temp_dir = tempfile.mkdtemp() @@ -472,15 +467,13 @@ def test_integrate_claude_true_calls_integrate_package_commands(self): _integrate_package_primitives( package_info, project_root, - integrate_vscode=False, - integrate_claude=True, - integrate_opencode=False, + targets=[KNOWN_TARGETS["claude"]], managed_files=set(), force=False, diagnostics=diagnostics, **integrators, ) - integrators["command_integrator"].integrate_package_commands.assert_called_once() + integrators["command_integrator"].integrate_commands_for_target.assert_called_once() finally: shutil.rmtree(temp_dir, ignore_errors=True) diff --git a/tests/unit/integration/test_data_driven_dispatch.py b/tests/unit/integration/test_data_driven_dispatch.py new file mode 100644 index 00000000..378ec9fa --- /dev/null +++ b/tests/unit/integration/test_data_driven_dispatch.py @@ -0,0 +1,459 @@ +"""Tests for the data-driven target x primitive dispatch architecture. + +Validates that: +- Target gating correctly restricts which directories are written. +- Every (target, primitive) pair has a dispatch path. +- Synthetic TargetProfiles work without code changes. +- partition_managed_files produces correct bucket keys. +""" + +import shutil +import tempfile +from pathlib import Path +from unittest.mock import MagicMock, patch + +from apm_cli.integration.base_integrator import BaseIntegrator, IntegrationResult +from apm_cli.integration.targets import KNOWN_TARGETS, PrimitiveMapping, TargetProfile +from apm_cli.commands.install import _integrate_package_primitives + + +# ------------------------------------------------------------------ +# Helpers +# ------------------------------------------------------------------ + +def _make_integration_result(n=0): + """Return an IntegrationResult with *n* files integrated.""" + return IntegrationResult( + files_integrated=n, + files_updated=0, + files_skipped=0, + target_paths=[], + links_resolved=0, + ) + + +def _make_hook_result(n=0): + """Return a MagicMock mimicking HookIntegrationResult.""" + hr = MagicMock() + hr.hooks_integrated = n + hr.target_paths = [] + return hr + + +def _make_skill_result(): + """Return a MagicMock mimicking SkillIntegrationResult.""" + sr = MagicMock() + sr.skill_created = False + sr.sub_skills_promoted = 0 + sr.target_paths = [] + return sr + + +def _make_mock_integrators(): + """Build a dict of mock integrators matching _integrate_package_primitives kwargs.""" + prompt = MagicMock() + prompt.integrate_prompts_for_target = MagicMock(return_value=_make_integration_result()) + + agent = MagicMock() + agent.integrate_agents_for_target = MagicMock(return_value=_make_integration_result()) + + command = MagicMock() + command.integrate_commands_for_target = MagicMock(return_value=_make_integration_result()) + + instruction = MagicMock() + instruction.integrate_instructions_for_target = MagicMock(return_value=_make_integration_result()) + + hook = MagicMock() + hook.integrate_hooks_for_target = MagicMock(return_value=_make_hook_result()) + + skill = MagicMock() + skill.integrate_package_skill = MagicMock(return_value=_make_skill_result()) + + return { + "prompt_integrator": prompt, + "agent_integrator": agent, + "command_integrator": command, + "instruction_integrator": instruction, + "hook_integrator": hook, + "skill_integrator": skill, + } + + +def _dispatch(targets, integrators=None, package_info=None, project_root=None): + """Call _integrate_package_primitives with defaults for convenience.""" + if integrators is None: + integrators = _make_mock_integrators() + if package_info is None: + package_info = MagicMock() + if project_root is None: + project_root = Path("/fake/root") + return _integrate_package_primitives( + package_info, + project_root, + targets=targets, + force=False, + managed_files=set(), + diagnostics=None, + **integrators, + ), integrators + + +# =================================================================== +# 1. TestTargetGatingRegression +# =================================================================== + +class TestTargetGatingRegression: + """Verify that the dispatch loop only invokes integrators for the + primitives declared by each target, preventing cross-target writes.""" + + def test_opencode_only_does_not_write_github_dirs(self): + """With targets=[opencode], no .github/ primitive is dispatched.""" + targets = [KNOWN_TARGETS["opencode"]] + _result, mocks = _dispatch(targets) + + # opencode does not declare prompts or instructions (those are copilot/cursor) + for call_args in mocks["prompt_integrator"].integrate_prompts_for_target.call_args_list: + target = call_args[0][0] + assert target.root_dir != ".github" + + for call_args in mocks["instruction_integrator"].integrate_instructions_for_target.call_args_list: + target = call_args[0][0] + assert target.root_dir != ".github" + + # opencode has no hooks -- hook integrator should NOT be called for .github + for call_args in mocks["hook_integrator"].integrate_hooks_for_target.call_args_list: + target = call_args[0][0] + assert target.root_dir != ".github" + + def test_cursor_only_does_not_write_claude_or_github(self): + """With targets=[cursor], no .claude/ or .github/ primitives fire.""" + targets = [KNOWN_TARGETS["cursor"]] + _result, mocks = _dispatch(targets) + + all_calls = [] + for name in ("prompt_integrator", "agent_integrator", + "command_integrator", "instruction_integrator", + "hook_integrator"): + for method_name, method in vars(mocks[name]).items(): + if hasattr(method, "call_args_list"): + for call_args in method.call_args_list: + if call_args[0]: + target = call_args[0][0] + if hasattr(target, "root_dir"): + all_calls.append(target.root_dir) + + assert ".claude" not in all_calls + assert ".github" not in all_calls + + def test_copilot_only_does_not_write_cursor_or_opencode(self): + """With targets=[copilot], no .cursor/ or .opencode/ primitives fire.""" + targets = [KNOWN_TARGETS["copilot"]] + _result, mocks = _dispatch(targets) + + dispatched_roots = set() + for name in ("prompt_integrator", "agent_integrator", + "command_integrator", "instruction_integrator", + "hook_integrator"): + for attr_name in dir(mocks[name]): + method = getattr(mocks[name], attr_name) + if hasattr(method, "call_args_list"): + for call_args in method.call_args_list: + if call_args[0] and hasattr(call_args[0][0], "root_dir"): + dispatched_roots.add(call_args[0][0].root_dir) + + assert ".cursor" not in dispatched_roots + assert ".opencode" not in dispatched_roots + + def test_empty_targets_returns_zeros(self): + """With targets=[], all counters are 0 and no integrators are called.""" + result, mocks = _dispatch(targets=[]) + + assert result["prompts"] == 0 + assert result["agents"] == 0 + assert result["instructions"] == 0 + assert result["commands"] == 0 + assert result["hooks"] == 0 + assert result["skills"] == 0 + assert result["deployed_files"] == [] + + # No target-driven methods should have been called + mocks["prompt_integrator"].integrate_prompts_for_target.assert_not_called() + mocks["agent_integrator"].integrate_agents_for_target.assert_not_called() + mocks["command_integrator"].integrate_commands_for_target.assert_not_called() + mocks["instruction_integrator"].integrate_instructions_for_target.assert_not_called() + mocks["hook_integrator"].integrate_hooks_for_target.assert_not_called() + # Skills are also gated by early return + mocks["skill_integrator"].integrate_package_skill.assert_not_called() + + def test_all_targets_dispatches_all_primitives(self): + """With all 4 targets, every primitive in every target is dispatched.""" + all_targets = list(KNOWN_TARGETS.values()) + _result, mocks = _dispatch(targets=all_targets) + + # Collect (target_name, method_name) pairs that were called + dispatched = set() + method_map = { + "prompt_integrator": "integrate_prompts_for_target", + "agent_integrator": "integrate_agents_for_target", + "command_integrator": "integrate_commands_for_target", + "instruction_integrator": "integrate_instructions_for_target", + "hook_integrator": "integrate_hooks_for_target", + } + prim_from_method = { + "integrate_prompts_for_target": "prompts", + "integrate_agents_for_target": "agents", + "integrate_commands_for_target": "commands", + "integrate_instructions_for_target": "instructions", + "integrate_hooks_for_target": "hooks", + } + + for int_name, method_name in method_map.items(): + method = getattr(mocks[int_name], method_name) + for call_args in method.call_args_list: + target = call_args[0][0] + prim = prim_from_method[method_name] + dispatched.add((target.name, prim)) + + # Verify every non-skills primitive in each target was dispatched + for target in all_targets: + for prim_name in target.primitives: + if prim_name == "skills": + continue # skills handled separately + assert (target.name, prim_name) in dispatched, ( + f"Expected ({target.name}, {prim_name}) to be dispatched" + ) + + +# =================================================================== +# 2. TestExhaustivenessChecks +# =================================================================== + +class TestExhaustivenessChecks: + """Structural checks ensuring no target x primitive pair is orphaned.""" + + def test_every_target_primitive_has_dispatch_path(self): + """For each (target, primitive) in KNOWN_TARGETS, verify the dispatch + loop routes to a real integrator method or a known special case.""" + # The dispatch loop recognizes these primitives via _PRIMITIVE_INTEGRATORS + dispatched_primitives = {"prompts", "agents", "commands", "instructions"} + # Plus these special cases handled inline + special_cases = {"hooks", "skills"} + all_handled = dispatched_primitives | special_cases + + for target_name, profile in KNOWN_TARGETS.items(): + for prim_name in profile.primitives: + assert prim_name in all_handled, ( + f"Primitive '{prim_name}' in target '{target_name}' has no " + f"dispatch path. Add it to _PRIMITIVE_INTEGRATORS or handle " + f"as a special case." + ) + + def test_partition_parity_with_old_buckets(self): + """Verify partition_managed_files produces the expected bucket keys + that callers rely on (backward-compat aliases applied).""" + # Use an empty set -- we only care about the keys produced + buckets = BaseIntegrator.partition_managed_files(set()) + + # Expected keys from the old hardcoded version: + expected_keys = { + "prompts", # was prompts_copilot, aliased + "agents_github", # was agents_copilot, aliased + "agents_claude", + "agents_cursor", + "agents_opencode", + "commands", # was commands_claude, aliased + "commands_opencode", + "instructions", # was instructions_copilot, aliased + "rules_cursor", # was instructions_cursor, aliased + "skills", # cross-target bucket + "hooks", # cross-target bucket + } + + assert expected_keys == set(buckets.keys()), ( + f"Bucket keys mismatch.\n" + f" Missing: {expected_keys - set(buckets.keys())}\n" + f" Extra: {set(buckets.keys()) - expected_keys}" + ) + + +# =================================================================== +# 3. TestSyntheticTargetProfile +# =================================================================== + +class TestSyntheticTargetProfile: + """Verify that a hand-built TargetProfile works end-to-end without + any code changes -- proving the architecture is truly data-driven.""" + + def setup_method(self): + self.temp_dir = tempfile.mkdtemp() + self.root = Path(self.temp_dir) + + def teardown_method(self): + shutil.rmtree(self.temp_dir, ignore_errors=True) + + def test_synthetic_target_integrates_successfully(self): + """A synthetic TargetProfile with a custom root_dir (.newcode) + passes through integrate_commands_for_target without errors.""" + from apm_cli.integration.command_integrator import CommandIntegrator + + synthetic = TargetProfile( + name="newcode", + root_dir=".newcode", + primitives={ + "commands": PrimitiveMapping("cmds", ".md", "newcode_cmd"), + }, + auto_create=True, + detect_by_dir=False, + ) + + # CommandIntegrator.find_prompt_files() discovers .prompt.md files + # in .apm/prompts/ and transforms them to command format. + pkg_dir = self.root / "packages" / "test-pkg" + prompts_dir = pkg_dir / ".apm" / "prompts" + prompts_dir.mkdir(parents=True) + (prompts_dir / "hello.prompt.md").write_text( + "---\nname: hello\n---\nHello world" + ) + + # Create the target root so integration proceeds + (self.root / ".newcode").mkdir(parents=True) + + package_info = MagicMock() + package_info.install_path = pkg_dir + package_info.resolved_reference = None + package_info.package = MagicMock() + package_info.package.name = "test-pkg" + + integrator = CommandIntegrator() + result = integrator.integrate_commands_for_target( + synthetic, package_info, self.root, + force=False, managed_files=set(), + ) + + assert result.files_integrated == 1 + assert len(result.target_paths) == 1 + # Verify the file landed under the synthetic root_dir + deployed = result.target_paths[0] + assert ".newcode" in deployed.parts + assert "cmds" in deployed.parts + assert deployed.name == "hello.md" + + def test_synthetic_target_sync_computes_correct_prefix(self): + """sync_for_target uses the synthetic target's root_dir/subdir + to compute the correct prefix for file removal.""" + from apm_cli.integration.command_integrator import CommandIntegrator + + synthetic = TargetProfile( + name="newcode", + root_dir=".newcode", + primitives={ + "commands": PrimitiveMapping("cmds", ".md", "newcode_cmd"), + }, + auto_create=True, + detect_by_dir=False, + ) + + apm_package = MagicMock() + apm_package.name = "test-pkg" + + integrator = CommandIntegrator() + + # Provide managed files under the synthetic prefix + managed = { + ".newcode/cmds/hello.md", + ".newcode/cmds/goodbye.md", + ".claude/commands/other.md", # should NOT be removed + } + + # Create the files so sync can actually remove them + cmds_dir = self.root / ".newcode" / "cmds" + cmds_dir.mkdir(parents=True) + (cmds_dir / "hello.md").write_text("test") + (cmds_dir / "goodbye.md").write_text("test") + + claude_dir = self.root / ".claude" / "commands" + claude_dir.mkdir(parents=True) + (claude_dir / "other.md").write_text("test") + + # Patch validate_deploy_path to accept .newcode/ prefix (which + # is not in KNOWN_TARGETS) while keeping all other security checks + _orig = BaseIntegrator.validate_deploy_path + + def _patched(rel_path, project_root, allowed_prefixes=None): + extended = (".newcode/",) + (allowed_prefixes or BaseIntegrator._get_integration_prefixes()) + return _orig(rel_path, project_root, allowed_prefixes=extended) + + with patch.object(BaseIntegrator, "validate_deploy_path", staticmethod(_patched)): + result = integrator.sync_for_target( + synthetic, apm_package, self.root, + managed_files=managed, + ) + + # The .newcode files should be removed + assert result["files_removed"] == 2 + assert not (cmds_dir / "hello.md").exists() + assert not (cmds_dir / "goodbye.md").exists() + # The .claude file should still exist (different prefix) + assert (claude_dir / "other.md").exists() + + +# =================================================================== +# 4. TestSkillTargetGating (Issue #482 regression) +# =================================================================== + +class TestSkillTargetGating: + """Verify that the skill integrator respects the targets parameter + passed from the dispatch loop, preventing cross-target skill writes.""" + + def test_skill_integrator_receives_targets_from_dispatch(self): + """_integrate_package_primitives passes its targets list to + skill_integrator.integrate_package_skill (Issue #482).""" + cursor_only = [KNOWN_TARGETS["cursor"]] + _result, mocks = _dispatch(targets=cursor_only) + + # Verify skill integrator was called with targets= kwarg + call_kwargs = mocks["skill_integrator"].integrate_package_skill.call_args + assert call_kwargs is not None, "skill integrator was not called" + assert "targets" in call_kwargs.kwargs, ( + "targets= not passed to skill integrator" + ) + passed_targets = call_kwargs.kwargs["targets"] + assert len(passed_targets) == 1 + assert passed_targets[0].name == "cursor" + + def test_opencode_target_does_not_pass_copilot_to_skills(self): + """With targets=[opencode], skill integrator only gets opencode.""" + opencode_only = [KNOWN_TARGETS["opencode"]] + _result, mocks = _dispatch(targets=opencode_only) + + call_kwargs = mocks["skill_integrator"].integrate_package_skill.call_args + passed_targets = call_kwargs.kwargs["targets"] + assert all(t.name == "opencode" for t in passed_targets) + + def test_empty_targets_skips_skill_integrator(self): + """With targets=[], skill integrator is not called at all.""" + _result, mocks = _dispatch(targets=[]) + mocks["skill_integrator"].integrate_package_skill.assert_not_called() + + +# =================================================================== +# 5. TestPartitionBucketKey +# =================================================================== + +class TestPartitionBucketKey: + """Verify that partition_bucket_key produces the correct aliased keys.""" + + def test_copilot_prompts_alias(self): + assert BaseIntegrator.partition_bucket_key("prompts", "copilot") == "prompts" + + def test_copilot_agents_alias(self): + assert BaseIntegrator.partition_bucket_key("agents", "copilot") == "agents_github" + + def test_claude_commands_alias(self): + assert BaseIntegrator.partition_bucket_key("commands", "claude") == "commands" + + def test_cursor_instructions_alias(self): + assert BaseIntegrator.partition_bucket_key("instructions", "cursor") == "rules_cursor" + + def test_unaliased_key_passthrough(self): + assert BaseIntegrator.partition_bucket_key("agents", "cursor") == "agents_cursor" diff --git a/tests/unit/test_uninstall_transitive_cleanup.py b/tests/unit/test_uninstall_transitive_cleanup.py index 23273303..746f8ffa 100644 --- a/tests/unit/test_uninstall_transitive_cleanup.py +++ b/tests/unit/test_uninstall_transitive_cleanup.py @@ -325,29 +325,11 @@ def _capture_validate(path: Path): "apm_cli.models.apm_package.validate_apm_package", side_effect=_capture_validate, ), patch( - "apm_cli.core.target_detection.detect_target", - return_value=(None, None), - ), patch( - "apm_cli.core.target_detection.should_integrate_claude", - return_value=False, - ), patch( - "apm_cli.integration.prompt_integrator.PromptIntegrator.should_integrate", - return_value=False, - ), patch( - "apm_cli.integration.agent_integrator.AgentIntegrator.should_integrate", - return_value=False, + "apm_cli.integration.targets.active_targets", + return_value=[], ), patch( "apm_cli.integration.skill_integrator.SkillIntegrator.integrate_package_skill", return_value=None, - ), patch( - "apm_cli.integration.command_integrator.CommandIntegrator.integrate_package_commands", - return_value=None, - ), patch( - "apm_cli.integration.hook_integrator.HookIntegrator.integrate_package_hooks", - return_value=None, - ), patch( - "apm_cli.integration.instruction_integrator.InstructionIntegrator.integrate_package_instructions", - return_value=None, ): result = self.runner.invoke(cli, ["uninstall", "acme/pkg-a"])