diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c38126a1..e7bbdfe40 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- `--target opencode` no longer creates `.github/` directory -- target-driven dispatch via `INTEGRATION_DISPATCH` registry replaces per-target boolean flags (#470) + +### Changed + +- Refactored `_integrate_package_primitives()` to use declarative `INTEGRATION_DISPATCH` registry in `targets.py` instead of monolithic if-chains (#470) + ## [0.8.6] - 2026-03-27 ### Added diff --git a/src/apm_cli/commands/install.py b/src/apm_cli/commands/install.py index bbda5af15..ba79d634d 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,215 +852,34 @@ def _integrate_package_primitives( ): """Run the full integration pipeline for a single package. + Delegates to ``integrate_package_for_targets()`` which iterates the + active ``TargetProfile`` list and dispatches to the correct integrator + method for each supported primitive. This function is a thin adapter + that keeps the call-site signature stable. + Returns a dict with integration counters and the list of deployed file paths. """ - result = { - "prompts": 0, - "agents": 0, - "skills": 0, - "sub_skills": 0, - "instructions": 0, - "commands": 0, - "hooks": 0, - "links_resolved": 0, - "deployed_files": [], + from apm_cli.integration.targets import integrate_package_for_targets + + integrators = { + "prompt_integrator": prompt_integrator, + "agent_integrator": agent_integrator, + "skill_integrator": skill_integrator, + "instruction_integrator": instruction_integrator, + "command_integrator": command_integrator, + "hook_integrator": hook_integrator, } - deployed = result["deployed_files"] - - if not (integrate_vscode or integrate_claude or integrate_opencode): - 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, + return integrate_package_for_targets( + targets, + package_info, + project_root, + integrators, + force=force, + managed_files=managed_files, diagnostics=diagnostics, + logger=logger, ) - 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()) - - # --- 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()) - - # --- 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()) - - # --- 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()) - - # --- 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( - package_info, project_root, - force=force, managed_files=managed_files, - diagnostics=diagnostics, - ) - 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: - deployed.append(tp.relative_to(project_root).as_posix()) - - return result @@ -1349,9 +1166,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, ) @@ -1382,11 +1196,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() @@ -1647,9 +1456,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, @@ -1766,7 +1573,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) @@ -1851,9 +1658,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, @@ -2013,13 +1818,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/integration/__init__.py b/src/apm_cli/integration/__init__.py index 356f871de..2868bc173 100644 --- a/src/apm_cli/integration/__init__.py +++ b/src/apm_cli/integration/__init__.py @@ -21,8 +21,10 @@ TargetProfile, PrimitiveMapping, KNOWN_TARGETS, + INTEGRATION_DISPATCH, get_integration_prefixes, active_targets, + integrate_package_for_targets, ) __all__ = [ @@ -38,8 +40,10 @@ 'TargetProfile', 'PrimitiveMapping', 'KNOWN_TARGETS', + 'INTEGRATION_DISPATCH', 'get_integration_prefixes', 'active_targets', + 'integrate_package_for_targets', 'validate_skill_name', 'normalize_skill_name', 'to_hyphen_case', diff --git a/src/apm_cli/integration/targets.py b/src/apm_cli/integration/targets.py index 9ee18e1ba..7e3b1f07f 100644 --- a/src/apm_cli/integration/targets.py +++ b/src/apm_cli/integration/targets.py @@ -205,3 +205,237 @@ def active_targets(project_root, explicit_target: "Optional[str]" = None) -> lis # --- fallback: copilot is the universal default --- return [KNOWN_TARGETS["copilot"]] + + +# ------------------------------------------------------------------ +# Integration dispatch registry +# ------------------------------------------------------------------ +# +# Maps (target_name, primitive_name) -> (integrator_key, method_name, log_dir). +# +# ``integrator_key`` selects from the integrator dict passed to +# ``integrate_package_for_targets``. +# +# ``method_name`` is the method to call on that integrator. +# +# ``log_dir`` is the human-readable deploy directory for log output. +# +# Skills are excluded from per-target dispatch because +# ``SkillIntegrator.integrate_package_skill`` already handles +# multi-target routing internally via ``active_targets()``. +# +# Adding a new target or primitive only requires: +# 1. An entry in KNOWN_TARGETS above +# 2. An entry in INTEGRATION_DISPATCH below +# 3. The integrator method it references + +INTEGRATION_DISPATCH: Dict[tuple, tuple] = { + # --- copilot (.github) --- + ("copilot", "prompts"): ( + "prompt_integrator", "integrate_package_prompts", + ".github/prompts/", + ), + ("copilot", "agents"): ( + "agent_integrator", "integrate_package_agents", + ".github/agents/", + ), + ("copilot", "instructions"): ( + "instruction_integrator", "integrate_package_instructions", + ".github/instructions/", + ), + ("copilot", "hooks"): ( + "hook_integrator", "integrate_package_hooks", + ".github/hooks/", + ), + + # --- claude (.claude) --- + ("claude", "agents"): ( + "agent_integrator", "integrate_package_agents_claude", + ".claude/agents/", + ), + ("claude", "commands"): ( + "command_integrator", "integrate_package_commands", + ".claude/commands/", + ), + ("claude", "hooks"): ( + "hook_integrator", "integrate_package_hooks_claude", + ".claude/settings.json", + ), + + # --- cursor (.cursor) --- + ("cursor", "instructions"): ( + "instruction_integrator", "integrate_package_instructions_cursor", + ".cursor/rules/", + ), + ("cursor", "agents"): ( + "agent_integrator", "integrate_package_agents_cursor", + ".cursor/agents/", + ), + ("cursor", "hooks"): ( + "hook_integrator", "integrate_package_hooks_cursor", + ".cursor/hooks.json", + ), + + # --- opencode (.opencode) --- + ("opencode", "agents"): ( + "agent_integrator", "integrate_package_agents_opencode", + ".opencode/agents/", + ), + ("opencode", "commands"): ( + "command_integrator", "integrate_package_commands_opencode", + ".opencode/commands/", + ), +} + + +def integrate_package_for_targets( + targets, + package_info, + project_root, + integrators, + *, + force=False, + managed_files=None, + diagnostics=None, + logger=None, +): + """Run the full integration pipeline for a single package. + + Iterates *targets* (a list of ``TargetProfile``), and for each + primitive that target supports, dispatches to the correct integrator + method via ``INTEGRATION_DISPATCH``. + + Skills are handled separately because ``SkillIntegrator`` already + routes to all active targets internally. + + Args: + targets: List of ``TargetProfile`` to deploy into. + package_info: ``PackageInfo`` for the package being installed. + project_root: Workspace root ``Path``. + integrators: Dict mapping integrator key (e.g. ``"agent_integrator"``) + to integrator instance. + force: Overwrite user-authored files on collision. + managed_files: Set of workspace-relative paths from apm.lock. + diagnostics: ``DiagnosticCollector`` instance. + logger: Optional ``CommandLogger`` for tree output. + + Returns: + Dict with integration counters and deployed file paths:: + + { + "prompts": int, + "agents": int, + "skills": int, + "sub_skills": int, + "instructions": int, + "commands": int, + "hooks": int, + "links_resolved": int, + "deployed_files": list[str], + } + """ + result = { + "prompts": 0, + "agents": 0, + "skills": 0, + "sub_skills": 0, + "instructions": 0, + "commands": 0, + "hooks": 0, + "links_resolved": 0, + "deployed_files": [], + } + + if not targets: + return result + + deployed = result["deployed_files"] + + def _log(msg): + if logger: + logger.tree_item(msg) + + # Collect target names for the skill integrator check + target_names = {t.name for t in targets} + + # --- per-target primitive dispatch --- + for target in targets: + for primitive in target.primitives: + # Skills are handled separately below + if primitive == "skills": + continue + + key = (target.name, primitive) + entry = INTEGRATION_DISPATCH.get(key) + if entry is None: + continue + + integrator_key, method_name, log_dir = entry + integrator = integrators.get(integrator_key) + if integrator is None: + continue + + method = getattr(integrator, method_name, None) + if method is None: + continue + + call_result = method( + package_info, project_root, + force=force, managed_files=managed_files, + diagnostics=diagnostics, + ) + + # Accumulate counts -- handle both IntegrationResult and + # HookIntegrationResult (which uses hooks_integrated). + files = getattr(call_result, "files_integrated", 0) + hooks = getattr(call_result, "hooks_integrated", 0) + updated = getattr(call_result, "files_updated", 0) + links = getattr(call_result, "links_resolved", 0) + + if primitive == "hooks": + if hooks > 0: + result["hooks"] += hooks + _log(f" |-- {hooks} hook(s) integrated -> {log_dir}") + elif primitive in ("prompts", "agents", "instructions", "commands"): + if files > 0: + result[primitive] += files + _log(f" |-- {files} {primitive} integrated -> {log_dir}") + if updated > 0: + _log(f" |-- {updated} {primitive} updated") + + result["links_resolved"] += links + for tp in getattr(call_result, "target_paths", []): + deployed.append(tp.relative_to(project_root).as_posix()) + + # --- skills (multi-target, handled by SkillIntegrator) --- + has_skill_target = any(t.supports("skills") for t in targets) + if has_skill_target: + skill_integrator = integrators.get("skill_integrator") + if skill_integrator is not None: + skill_result = skill_integrator.integrate_package_skill( + package_info, project_root, + diagnostics=diagnostics, managed_files=managed_files, + force=force, + ) + _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(f" |-- Skill integrated -> {_skill_target_str}") + if skill_result.sub_skills_promoted > 0: + result["sub_skills"] += skill_result.sub_skills_promoted + _log( + f" |-- {skill_result.sub_skills_promoted} skill(s) " + f"integrated -> {_skill_target_str}" + ) + for tp in skill_result.target_paths: + deployed.append(tp.relative_to(project_root).as_posix()) + + return result diff --git a/tests/unit/integration/test_command_integrator.py b/tests/unit/integration/test_command_integrator.py index 18766669e..4cfdafd9c 100644 --- a/tests/unit/integration/test_command_integrator.py +++ b/tests/unit/integration/test_command_integrator.py @@ -368,10 +368,10 @@ 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 only dispatches to active targets. - Regression test for: CommandIntegrator was called unconditionally, causing - .claude/commands/ to be created even when target=copilot (integrate_claude=False). + Uses KNOWN_TARGETS profiles to verify target-driven routing: + each target profile only triggers integrators for its declared primitives. """ def _make_mock_integrators(self): @@ -418,14 +418,15 @@ def _empty_result(*args, **kwargs): 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_target_does_not_call_claude_commands(self): + """When targets=[copilot], integrate_package_commands must not be called. - This is the regression test for the bug where .claude/commands/ was created - even when target=copilot (vscode) set integrate_claude=False. + Regression test for the bug where .claude/commands/ was created + even when target=copilot. """ 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,9 +441,7 @@ 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, @@ -454,10 +453,11 @@ def test_integrate_claude_false_does_not_call_integrate_package_commands(self): 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_calls_integrate_package_commands(self): + """When targets=[claude], integrate_package_commands must be called.""" 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,9 +472,7 @@ 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, @@ -484,3 +482,203 @@ def test_integrate_claude_true_calls_integrate_package_commands(self): integrators["command_integrator"].integrate_package_commands.assert_called_once() finally: shutil.rmtree(temp_dir, ignore_errors=True) + + def test_opencode_only_does_not_call_github_prompts_or_agents(self): + """When targets=[opencode], .github/ prompts and agents must not run. + + Regression test for: prompts and .github/agents were called unconditionally, + creating .github/ even when --target opencode was set. + """ + 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() + try: + project_root = Path(temp_dir) + (project_root / ".opencode").mkdir() + + package_info = MagicMock() + integrators = self._make_mock_integrators() + diagnostics = DiagnosticCollector(verbose=False) + + _integrate_package_primitives( + package_info, + project_root, + targets=[KNOWN_TARGETS["opencode"]], + managed_files=set(), + force=False, + diagnostics=diagnostics, + **integrators, + ) + + integrators["prompt_integrator"].integrate_package_prompts.assert_not_called() + integrators["agent_integrator"].integrate_package_agents.assert_not_called() + integrators["instruction_integrator"].integrate_package_instructions.assert_not_called() + integrators["hook_integrator"].integrate_package_hooks.assert_not_called() + assert not (project_root / ".github").exists() + finally: + shutil.rmtree(temp_dir, ignore_errors=True) + + def test_opencode_only_calls_opencode_agents_and_commands(self): + """When targets=[opencode], OpenCode agents and commands must run.""" + 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() + try: + project_root = Path(temp_dir) + (project_root / ".opencode").mkdir() + + package_info = MagicMock() + integrators = self._make_mock_integrators() + diagnostics = DiagnosticCollector(verbose=False) + + _integrate_package_primitives( + package_info, + project_root, + targets=[KNOWN_TARGETS["opencode"]], + managed_files=set(), + force=False, + diagnostics=diagnostics, + **integrators, + ) + + integrators["agent_integrator"].integrate_package_agents_opencode.assert_called_once() + integrators["command_integrator"].integrate_package_commands_opencode.assert_called_once() + finally: + shutil.rmtree(temp_dir, ignore_errors=True) + + def test_cursor_only_does_not_call_github_or_claude_or_opencode(self): + """When targets=[cursor], only cursor-specific integrations run.""" + 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() + try: + project_root = Path(temp_dir) + (project_root / ".cursor").mkdir() + + package_info = MagicMock() + integrators = self._make_mock_integrators() + diagnostics = DiagnosticCollector(verbose=False) + + _integrate_package_primitives( + package_info, + project_root, + targets=[KNOWN_TARGETS["cursor"]], + managed_files=set(), + force=False, + diagnostics=diagnostics, + **integrators, + ) + + # .github/ integrations must NOT run + integrators["prompt_integrator"].integrate_package_prompts.assert_not_called() + integrators["agent_integrator"].integrate_package_agents.assert_not_called() + integrators["instruction_integrator"].integrate_package_instructions.assert_not_called() + integrators["hook_integrator"].integrate_package_hooks.assert_not_called() + # Claude must NOT run + integrators["command_integrator"].integrate_package_commands.assert_not_called() + integrators["agent_integrator"].integrate_package_agents_claude.assert_not_called() + integrators["hook_integrator"].integrate_package_hooks_claude.assert_not_called() + # OpenCode must NOT run + integrators["agent_integrator"].integrate_package_agents_opencode.assert_not_called() + integrators["command_integrator"].integrate_package_commands_opencode.assert_not_called() + # Cursor MUST run + integrators["instruction_integrator"].integrate_package_instructions_cursor.assert_called_once() + integrators["agent_integrator"].integrate_package_agents_cursor.assert_called_once() + integrators["hook_integrator"].integrate_package_hooks_cursor.assert_called_once() + assert not (project_root / ".github").exists() + finally: + shutil.rmtree(temp_dir, ignore_errors=True) + + def test_copilot_only_does_not_call_opencode_or_cursor(self): + """When targets=[copilot], OpenCode and Cursor integrations must not run.""" + 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() + try: + project_root = Path(temp_dir) + (project_root / ".github").mkdir() + + package_info = MagicMock() + integrators = self._make_mock_integrators() + diagnostics = DiagnosticCollector(verbose=False) + + _integrate_package_primitives( + package_info, + project_root, + targets=[KNOWN_TARGETS["copilot"]], + managed_files=set(), + force=False, + diagnostics=diagnostics, + **integrators, + ) + + # .github/ integrations MUST run + integrators["prompt_integrator"].integrate_package_prompts.assert_called_once() + integrators["agent_integrator"].integrate_package_agents.assert_called_once() + integrators["instruction_integrator"].integrate_package_instructions.assert_called_once() + integrators["hook_integrator"].integrate_package_hooks.assert_called_once() + # OpenCode must NOT run + integrators["agent_integrator"].integrate_package_agents_opencode.assert_not_called() + integrators["command_integrator"].integrate_package_commands_opencode.assert_not_called() + # Cursor must NOT run + integrators["instruction_integrator"].integrate_package_instructions_cursor.assert_not_called() + integrators["agent_integrator"].integrate_package_agents_cursor.assert_not_called() + integrators["hook_integrator"].integrate_package_hooks_cursor.assert_not_called() + finally: + shutil.rmtree(temp_dir, ignore_errors=True) + + def test_empty_targets_returns_zero_counters(self): + """When targets=[], no integrators should be called.""" + import tempfile, shutil + from apm_cli.commands.install import _integrate_package_primitives + from apm_cli.utils.diagnostics import DiagnosticCollector + + temp_dir = tempfile.mkdtemp() + try: + project_root = Path(temp_dir) + + package_info = MagicMock() + integrators = self._make_mock_integrators() + diagnostics = DiagnosticCollector(verbose=False) + + result = _integrate_package_primitives( + package_info, + project_root, + targets=[], + managed_files=set(), + force=False, + diagnostics=diagnostics, + **integrators, + ) + + assert result["prompts"] == 0 + assert result["agents"] == 0 + assert result["deployed_files"] == [] + # No integrator should have been called + integrators["prompt_integrator"].integrate_package_prompts.assert_not_called() + integrators["agent_integrator"].integrate_package_agents.assert_not_called() + integrators["agent_integrator"].integrate_package_agents_claude.assert_not_called() + integrators["agent_integrator"].integrate_package_agents_cursor.assert_not_called() + integrators["agent_integrator"].integrate_package_agents_opencode.assert_not_called() + integrators["skill_integrator"].integrate_package_skill.assert_not_called() + integrators["instruction_integrator"].integrate_package_instructions.assert_not_called() + integrators["instruction_integrator"].integrate_package_instructions_cursor.assert_not_called() + integrators["command_integrator"].integrate_package_commands.assert_not_called() + integrators["command_integrator"].integrate_package_commands_opencode.assert_not_called() + integrators["hook_integrator"].integrate_package_hooks.assert_not_called() + integrators["hook_integrator"].integrate_package_hooks_claude.assert_not_called() + integrators["hook_integrator"].integrate_package_hooks_cursor.assert_not_called() + finally: + shutil.rmtree(temp_dir, ignore_errors=True) diff --git a/tests/unit/integration/test_targets.py b/tests/unit/integration/test_targets.py index 0d2845a41..d775bee0a 100644 --- a/tests/unit/integration/test_targets.py +++ b/tests/unit/integration/test_targets.py @@ -97,3 +97,148 @@ def test_explicit_overrides_detection(self): def test_explicit_unknown_returns_empty(self): targets = active_targets(self.root, explicit_target="nonexistent") assert targets == [] + + +class TestIntegrationDispatchRegistry: + """Verify INTEGRATION_DISPATCH covers every non-skill primitive.""" + + def test_every_target_primitive_has_dispatch_entry(self): + """Each (target, primitive) pair in KNOWN_TARGETS that is not 'skills' + must have a matching entry in INTEGRATION_DISPATCH.""" + from apm_cli.integration.targets import INTEGRATION_DISPATCH + + missing = [] + for name, profile in KNOWN_TARGETS.items(): + for primitive in profile.primitives: + if primitive == "skills": + continue + key = (name, primitive) + if key not in INTEGRATION_DISPATCH: + missing.append(key) + + assert missing == [], f"Missing dispatch entries: {missing}" + + def test_dispatch_entries_reference_valid_methods(self): + """All integrator_key/method_name pairs in INTEGRATION_DISPATCH + must exist on the actual integrator classes.""" + from apm_cli.integration.targets import INTEGRATION_DISPATCH + from apm_cli.integration.prompt_integrator import PromptIntegrator + from apm_cli.integration.agent_integrator import AgentIntegrator + from apm_cli.integration.instruction_integrator import InstructionIntegrator + from apm_cli.integration.command_integrator import CommandIntegrator + from apm_cli.integration.hook_integrator import HookIntegrator + + class_map = { + "prompt_integrator": PromptIntegrator, + "agent_integrator": AgentIntegrator, + "instruction_integrator": InstructionIntegrator, + "command_integrator": CommandIntegrator, + "hook_integrator": HookIntegrator, + } + + for (target, primitive), (ikey, method, _) in INTEGRATION_DISPATCH.items(): + cls = class_map.get(ikey) + assert cls is not None, f"Unknown integrator key: {ikey}" + assert hasattr(cls, method), ( + f"({target}, {primitive}): {cls.__name__} has no method {method}" + ) + + +class TestIntegratePackageForTargets: + """Tests for integrate_package_for_targets() dispatcher.""" + + 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 _make_mock_integrators(self): + from unittest.mock import MagicMock + + def _empty_result(*args, **kwargs): + r = MagicMock() + r.files_integrated = 0 + r.files_updated = 0 + r.links_resolved = 0 + r.target_paths = [] + r.skill_created = False + r.sub_skills_promoted = 0 + r.hooks_integrated = 0 + return r + + integrators = {} + for name in ( + "prompt_integrator", + "agent_integrator", + "skill_integrator", + "instruction_integrator", + "command_integrator", + "hook_integrator", + ): + m = MagicMock() + for method in ( + "integrate_package_prompts", + "integrate_package_agents", + "integrate_package_agents_claude", + "integrate_package_agents_cursor", + "integrate_package_agents_opencode", + "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_opencode_target_skips_github(self): + """Only opencode primitives should fire for opencode target.""" + from unittest.mock import MagicMock + from apm_cli.integration.targets import integrate_package_for_targets + + pkg = MagicMock() + integrators = self._make_mock_integrators() + + result = integrate_package_for_targets( + [KNOWN_TARGETS["opencode"]], + pkg, self.root, integrators, + ) + + integrators["prompt_integrator"].integrate_package_prompts.assert_not_called() + integrators["agent_integrator"].integrate_package_agents.assert_not_called() + integrators["agent_integrator"].integrate_package_agents_opencode.assert_called_once() + integrators["command_integrator"].integrate_package_commands_opencode.assert_called_once() + assert result["deployed_files"] == [] + + def test_all_targets_calls_every_dispatch_entry(self): + """With all 4 targets, every dispatch entry should fire once.""" + from unittest.mock import MagicMock + from apm_cli.integration.targets import ( + integrate_package_for_targets, + INTEGRATION_DISPATCH, + ) + + for d in (".github", ".claude", ".cursor", ".opencode"): + (self.root / d).mkdir() + + pkg = MagicMock() + integrators = self._make_mock_integrators() + + integrate_package_for_targets( + list(KNOWN_TARGETS.values()), + pkg, self.root, integrators, + ) + + for (target, primitive), (ikey, method, _) in INTEGRATION_DISPATCH.items(): + m = integrators[ikey] + fn = getattr(m, method) + assert fn.call_count == 1, ( + f"({target}, {primitive}) -> {ikey}.{method} " + f"expected 1 call, got {fn.call_count}" + ) diff --git a/uv.lock b/uv.lock index 83746999a..16a1330ac 100644 --- a/uv.lock +++ b/uv.lock @@ -179,7 +179,7 @@ wheels = [ [[package]] name = "apm-cli" -version = "0.8.5" +version = "0.8.6" source = { editable = "." } dependencies = [ { name = "click" },