diff --git a/src/apm_cli/commands/install.py b/src/apm_cli/commands/install.py index 3e65bceb9..face1378e 100644 --- a/src/apm_cli/commands/install.py +++ b/src/apm_cli/commands/install.py @@ -57,6 +57,23 @@ _APM_IMPORT_ERROR = str(e) +# --------------------------------------------------------------------------- +# Root primitive detection helper +# --------------------------------------------------------------------------- + +def _project_has_root_primitives(project_root) -> bool: + """Return True when *project_root* has a .apm/ directory of its own. + + Used to decide whether ``apm install`` should enter the integration + pipeline even when no external APM dependencies are declared (#714). + The integrators themselves determine whether the directory contains + anything actionable, so we only check for the directory's existence. + """ + from pathlib import Path as _Path + root = _Path(project_root) + return (root / ".apm").is_dir() + + # --------------------------------------------------------------------------- # Validation helpers # --------------------------------------------------------------------------- @@ -812,8 +829,13 @@ def install(ctx, packages, runtime, exclude, only, update, dry_run, force, verbo old_mcp_configs = builtins.dict(_existing_lock.mcp_configs) old_local_deployed = builtins.list(_existing_lock.local_deployed_files) + # Also enter the APM install path when the project root has local .apm/ + # primitives, even if there are no external APM dependencies (#714). + from apm_cli.core.scope import get_deploy_root as _get_deploy_root + _cli_project_root = _get_deploy_root(scope) + apm_diagnostics = None - if should_install_apm and has_any_apm_deps: + if should_install_apm and (has_any_apm_deps or _project_has_root_primitives(_cli_project_root)): if not APM_DEPS_AVAILABLE: logger.error("APM dependency system not available") logger.progress(f"Import error: {_APM_IMPORT_ERROR}") @@ -1415,12 +1437,18 @@ def _install_apm_dependencies( apm_deps = apm_package.get_apm_dependencies() dev_apm_deps = apm_package.get_dev_apm_dependencies() all_apm_deps = apm_deps + dev_apm_deps - if not all_apm_deps: - return InstallResult() project_root = get_deploy_root(scope) apm_dir = get_apm_dir(scope) + # Check whether the project root itself has local .apm/ primitives (#714). + # Users should be able to keep root-level .apm/ rules alongside their apm.yml + # without creating a dummy sub-package stub. + _root_has_local_primitives = _project_has_root_primitives(project_root) + + if not all_apm_deps and not _root_has_local_primitives: + return InstallResult() + # T5: Check for existing lockfile - use locked versions for reproducible installs from apm_cli.deps.lockfile import LockFile, get_lockfile_path lockfile_path = get_lockfile_path(apm_dir) @@ -1628,7 +1656,7 @@ def _collect_descendants(node, visited=None): if dep.get_identity() in only_identities ] - if not deps_to_install: + if not deps_to_install and not _root_has_local_primitives: if logger: logger.nothing_to_install() return InstallResult() @@ -2564,6 +2592,61 @@ def _collect_descendants(node, visited=None): # Continue with other packages instead of failing completely continue + # ------------------------------------------------------------------ + # Integrate root project's own .apm/ primitives (#714). + # + # Users should not need a dummy "./agent/apm.yml" stub to get their + # root-level .apm/ rules deployed alongside external dependencies. + # Treat the project root as an implicit local package: any primitives + # found in /.apm/ are integrated after all declared + # dependency packages have been processed. + # ------------------------------------------------------------------ + if _root_has_local_primitives and _targets: + from apm_cli.models.apm_package import PackageInfo as _PackageInfo + _root_pkg_info = _PackageInfo( + package=apm_package, + install_path=project_root, + ) + if logger: + logger.download_complete("", ref_suffix="local") + try: + _root_result = _integrate_package_primitives( + _root_pkg_info, project_root, + targets=_targets, + prompt_integrator=prompt_integrator, + agent_integrator=agent_integrator, + skill_integrator=skill_integrator, + instruction_integrator=instruction_integrator, + command_integrator=command_integrator, + hook_integrator=hook_integrator, + force=force, + managed_files=managed_files, + diagnostics=diagnostics, + package_name="", + logger=logger, + scope=scope, + ) + total_prompts_integrated += _root_result["prompts"] + total_agents_integrated += _root_result["agents"] + total_instructions_integrated += _root_result["instructions"] + total_commands_integrated += _root_result["commands"] + total_hooks_integrated += _root_result["hooks"] + total_links_resolved += _root_result["links_resolved"] + installed_count += 1 + except Exception as e: + import traceback as _tb + diagnostics.error( + f"Failed to integrate root project primitives: {e}", + package="", + detail=_tb.format_exc(), + ) + # When root integration is the *only* action (no external deps), + # a failure means nothing was deployed — surface it clearly. + if not all_apm_deps and logger: + logger.error( + f"Root project primitives could not be integrated: {e}" + ) + # Update .gitignore _update_gitignore_for_apm_modules(logger=logger) diff --git a/tests/integration/test_local_install.py b/tests/integration/test_local_install.py index 31d54db8a..989483224 100644 --- a/tests/integration/test_local_install.py +++ b/tests/integration/test_local_install.py @@ -380,6 +380,230 @@ def test_pack_rejects_with_local_deps(self, temp_workspace, apm_command): ) +class TestRootProjectPrimitives: + """Test #714: root project .apm/ integration without a sub-package stub. + + Users should be able to place .apm/ rules directly in their project root + alongside apm.yml without creating a dummy ./agent/apm.yml workaround. + """ + + def _make_project(self, tmp_path, *, apm_deps=None): + """Return a project root with .apm/instructions/ and optional deps.""" + project = tmp_path / "project" + project.mkdir() + + deps_section = {"apm": apm_deps} if apm_deps else {} + (project / "apm.yml").write_text(yaml.dump({ + "name": "my-project", + "version": "1.0.0", + "dependencies": deps_section, + })) + + instructions_dir = project / ".apm" / "instructions" + instructions_dir.mkdir(parents=True) + (instructions_dir / "local-rules.instructions.md").write_text( + "---\napplyTo: '**'\n---\n# Local Rules\nFollow these local rules." + ) + + # Create .claude/rules/ so claude target is auto-detected + (project / ".claude" / "rules").mkdir(parents=True) + return project + + def test_root_apm_primitives_deployed_with_no_deps(self, tmp_path, apm_command): + """root apm.yml with no deps + root .apm/ -> rules deployed. + + Before the fix, apm install returned early with nothing to install + and never deployed the local .apm/ rules. + """ + project = self._make_project(tmp_path) + + result = subprocess.run( + [apm_command, "install"], + cwd=project, + capture_output=True, + text=True, + timeout=60, + ) + combined = result.stdout + result.stderr + assert result.returncode == 0, f"Install failed:\n{combined}" + + deployed = project / ".claude" / "rules" / "local-rules.md" + assert deployed.exists(), ( + f"Root .apm/ rules were NOT deployed to .claude/rules/.\n" + f"Output:\n{combined}" + ) + assert "Local Rules" in deployed.read_text() + + def test_root_apm_primitives_deployed_alongside_external_dep( + self, tmp_path, apm_command + ): + """root apm.yml with external dep + root .apm/ -> both rule sets deployed. + + This is the exact scenario from #714: external dependencies in apm.yml + and local .apm/ rules at the root. Before the fix, only the external + dep's rules were deployed. + """ + ext_pkg = tmp_path / "ext-pkg" + ext_pkg.mkdir() + (ext_pkg / "apm.yml").write_text(yaml.dump({ + "name": "ext-pkg", + "version": "1.0.0", + })) + ext_instr = ext_pkg / ".apm" / "instructions" + ext_instr.mkdir(parents=True) + (ext_instr / "ext-rules.instructions.md").write_text( + "---\napplyTo: '**'\n---\n# External Rules\nFrom external package." + ) + + project = self._make_project(tmp_path, apm_deps=["../ext-pkg"]) + + result = subprocess.run( + [apm_command, "install"], + cwd=project, + capture_output=True, + text=True, + timeout=60, + ) + combined = result.stdout + result.stderr + assert result.returncode == 0, f"Install failed:\n{combined}" + + deployed_names = {f.name for f in (project / ".claude" / "rules").glob("*.md")} + assert "local-rules.md" in deployed_names, ( + f"Root .apm/ rule NOT deployed. Files: {deployed_names}\nOutput:\n{combined}" + ) + assert "ext-rules.md" in deployed_names, ( + f"External dep rule NOT deployed. Files: {deployed_names}\nOutput:\n{combined}" + ) + + def test_workaround_sub_package_still_works(self, tmp_path, apm_command): + """Old ./agent/apm.yml workaround continues to work (regression guard).""" + project = tmp_path / "project" + project.mkdir() + + agent_dir = project / "agent" + agent_dir.mkdir() + (agent_dir / "apm.yml").write_text(yaml.dump({ + "name": "my-project-agent", + "version": "1.0.0", + })) + agent_instr = agent_dir / ".apm" / "instructions" + agent_instr.mkdir(parents=True) + (agent_instr / "agent-rules.instructions.md").write_text( + "---\napplyTo: '**'\n---\n# Agent Rules\nFrom sub-package stub." + ) + + (project / "apm.yml").write_text(yaml.dump({ + "name": "my-project", + "version": "1.0.0", + "dependencies": {"apm": ["./agent"]}, + })) + (project / ".claude" / "rules").mkdir(parents=True) + + result = subprocess.run( + [apm_command, "install"], + cwd=project, + capture_output=True, + text=True, + timeout=60, + ) + combined = result.stdout + result.stderr + assert result.returncode == 0, f"Install failed:\n{combined}" + assert (project / ".claude" / "rules" / "agent-rules.md").exists(), ( + f"Sub-package rules NOT deployed.\nOutput:\n{combined}" + ) + + def test_root_apm_primitives_idempotent(self, tmp_path, apm_command): + """Running apm install twice with root .apm/ is idempotent.""" + project = self._make_project(tmp_path) + + for run in range(2): + result = subprocess.run( + [apm_command, "install"], + cwd=project, + capture_output=True, + text=True, + timeout=60, + ) + assert result.returncode == 0, ( + f"Run {run + 1} failed:\n{result.stdout + result.stderr}" + ) + + assert (project / ".claude" / "rules" / "local-rules.md").exists() + + def test_root_apm_hooks_deployed(self, tmp_path, apm_command): + """root .apm/hooks/ is detected and integrated (not just instructions). + + Guards the _ROOT_PRIM_SUBDIRS list: a project that only has .apm/hooks/ + must still enter the integration path and not hit the early-return guard. + """ + project = tmp_path / "project" + project.mkdir() + + (project / "apm.yml").write_text(yaml.dump({ + "name": "my-project", + "version": "1.0.0", + })) + + hooks_dir = project / ".apm" / "hooks" + hooks_dir.mkdir(parents=True) + (hooks_dir / "on-save.json").write_text( + '{"hooks": {"PostToolUse": [{"matcher": "Write", "hooks": [{"type": "command", "command": "echo saved"}]}]}}' + ) + + # Create .claude/ so claude target is auto-detected + (project / ".claude").mkdir(parents=True) + + result = subprocess.run( + [apm_command, "install"], + cwd=project, + capture_output=True, + text=True, + timeout=60, + ) + combined = result.stdout + result.stderr + assert result.returncode == 0, f"Install failed:\n{combined}" + # The hook integrator merges into settings.json; confirm it was created + # or that install did not silently early-return (exit 0 with no output). + assert "nothing to install" not in combined.lower(), ( + f"Install returned 'nothing to install' — hooks detection guard may " + f"have triggered early return.\nOutput:\n{combined}" + ) + + def test_root_skill_md_detected(self, tmp_path, apm_command): + """A root SKILL.md alone triggers the integration path. + + Guards the (project_root / "SKILL.md").exists() branch in the + root-primitive detection logic. + """ + project = tmp_path / "project" + project.mkdir() + + (project / "apm.yml").write_text(yaml.dump({ + "name": "my-project", + "version": "1.0.0", + })) + (project / "SKILL.md").write_text( + "# My Skill\nThis skill does something useful." + ) + + # Create .claude/ so claude target is auto-detected + (project / ".claude").mkdir(parents=True) + + result = subprocess.run( + [apm_command, "install"], + cwd=project, + capture_output=True, + text=True, + timeout=60, + ) + combined = result.stdout + result.stderr + assert result.returncode == 0, f"Install failed:\n{combined}" + assert "nothing to install" not in combined.lower(), ( + f"Install returned 'nothing to install' — SKILL.md detection may " + f"have been skipped.\nOutput:\n{combined}" + ) + + class TestLocalMixedWithRemote: """Test mixing local and remote dependencies."""