diff --git a/README.md b/README.md index 03859a6ef..ba9e0defd 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,8 @@ high-quality code. supported) - **Runtime**: Python 3.12+, Node.js 18+ - **Tools**: git, npm, uv ([astral.sh/uv](https://docs.astral.sh/uv/)) +- **Recommended**: Rust/cargo ([rustup.rs](https://rustup.rs/)) — required for + the Rust recipe runner - **Optional**: GitHub CLI (`gh`), Azure CLI (`az`) Detailed setup: diff --git a/docs/PREREQUISITES.md b/docs/PREREQUISITES.md index af865f907..3e366f3e5 100644 --- a/docs/PREREQUISITES.md +++ b/docs/PREREQUISITES.md @@ -13,6 +13,7 @@ The amplihack framework requires the following tools. Each entry explains **what | **uv** | latest | Fast Python package installer | Installs amplihack itself and its Python dependencies | | **git** | 2.0+ | Version control | Branch management, PRs, and workflow automation | | **claude** | latest | Claude Code CLI | Core AI coding assistant that amplihack extends | +| **cargo** | 1.70+ | Rust package manager | Installs the Rust recipe runner for fast recipe execution. Install via [rustup.rs](https://rustup.rs/) | ## Quick Check @@ -20,7 +21,7 @@ Before installing amplihack, verify your prerequisites with this script: ```bash # Copy-paste this into your terminal — no installation required -node --version && npm --version && uv --version && git --version && echo "All prerequisites OK" +node --version && npm --version && uv --version && git --version && cargo --version && echo "All prerequisites OK" ``` After installing amplihack, running `amplihack` will also check for missing tools and display installation instructions. @@ -48,6 +49,9 @@ brew install uv # git brew install git + +# Rust/cargo (for recipe runner) +curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh ``` #### Verify Installation @@ -57,6 +61,7 @@ node --version # Should show v18.x or higher npm --version # Should show 9.x or higher uv --version # Should show version info git --version # Should show 2.x or higher +cargo --version # Should show 1.70 or higher ``` --- @@ -77,6 +82,9 @@ curl -LsSf https://astral.sh/uv/install.sh | sh # git sudo apt install git + +# Rust/cargo (for recipe runner) +curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh ``` #### Fedora/RHEL/CentOS @@ -90,6 +98,9 @@ curl -LsSf https://astral.sh/uv/install.sh | sh # git sudo dnf install git + +# Rust/cargo (for recipe runner) +curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh ``` #### Arch Linux @@ -103,6 +114,9 @@ curl -LsSf https://astral.sh/uv/install.sh | sh # git sudo pacman -S git + +# Rust/cargo (for recipe runner) +sudo pacman -S rust ``` #### Verify Installation @@ -112,6 +126,7 @@ node --version # Should show v18.x or higher npm --version # Should show 9.x or higher uv --version # Should show version info git --version # Should show 2.x or higher +cargo --version # Should show 1.70 or higher ``` --- diff --git a/src/amplihack/cli.py b/src/amplihack/cli.py index 8870c4109..95e890a79 100644 --- a/src/amplihack/cli.py +++ b/src/amplihack/cli.py @@ -139,63 +139,12 @@ def launch_command(args: argparse.Namespace, claude_args: list[str] | None = Non # --subprocess-safe: skip all staging/env mutations to avoid concurrent # write races when running as a delegate from another amplihack process # (e.g. multitask workstreams). See issue #2567. - subprocess_safe = getattr(args, "subprocess_safe", False) from .launcher.session_tracker import SessionTracker - # Detect nesting BEFORE any .claude/ operations - original_cwd = None - nesting_result = None - - if not subprocess_safe: - from .launcher.auto_stager import AutoStager - from .launcher.nesting_detector import NestingDetector - - detector = NestingDetector() - nesting_result = detector.detect_nesting(Path.cwd(), sys.argv) - - # Auto-stage if nested execution in source repo detected - if nesting_result.requires_staging: - print("\n🚨 SELF-MODIFICATION PROTECTION ACTIVATED") - print(" Running nested in amplihack source repository") - print(" Auto-staging .claude/ to temp directory for safety") - - stager = AutoStager() - original_cwd = Path.cwd() - staging_result = stager.stage_for_nested_execution( - original_cwd, f"nested-{os.getpid()}" - ) - - print(f" šŸ“ Staged to: {staging_result.temp_root}") - print(" Your original .claude/ files are protected") - - # CRITICAL: Change to temp directory so all .claude/ operations happen there - os.chdir(staging_result.temp_root) - print(f" šŸ“‚ CWD changed to: {staging_result.temp_root}\n") - - # Ensure amplihack framework is staged to ~/.amplihack/.claude/ - _ensure_amplihack_staged() - - # Auto-install missing SDK dependencies (e.g. agent-framework) - # Uses --python sys.executable to target the running interpreter, - # critical when launched via uvx (ephemeral venv != project .venv). - try: - from .dep_check import ensure_sdk_deps - - dep_result = ensure_sdk_deps() - if not dep_result.all_ok: - logger.warning("Some SDK deps could not be installed: %s", dep_result.missing) - except Exception as e: - logger.debug("SDK dep check skipped: %s", e) - - # Prompt to re-enable power-steering if disabled (#2544) - try: - from .power_steering.re_enable_prompt import prompt_re_enable_if_disabled - - prompt_re_enable_if_disabled() - except Exception as e: - # Fail-open: log error but continue - logger.debug(f"Error checking power-steering re-enable prompt: {e}") + # Run shared startup (nesting, staging, deps, power-steering) + _common_launcher_startup(args) + nesting_result = getattr(args, "_nesting_result", None) # Start session tracking tracker = SessionTracker() @@ -992,6 +941,101 @@ def _fix_global_statusline_path() -> None: print(f"Warning: Could not update global statusline path: {e}") +def _ensure_rust_recipe_runner() -> None: + """Ensure the Rust recipe runner binary is available. + + Called during startup for all launcher paths. Non-fatal — logs a + warning if the binary cannot be installed or found. + """ + try: + from .recipes.rust_runner import ensure_rust_recipe_runner + + if ensure_rust_recipe_runner(): + print("āœ“ Rust recipe runner available") + else: + print("⚠ Rust recipe runner not installed — install Rust (rustup.rs) and run:") + print(" cargo install --git https://github.com/rysweet/amplihack-recipe-runner") + except Exception as e: + logging.getLogger(__name__).warning( + "Could not check recipe-runner-rs: %s", e, exc_info=True, + ) + + +def _common_launcher_startup(args: "argparse.Namespace") -> None: + """Run all shared startup initialization for launcher commands. + + Consolidates initialization that must happen for every launcher path + (launch, claude, RustyClawd, copilot, codex, amplifier). Respects + --subprocess-safe to avoid concurrent write races (#2567). + + Idempotent — safe to call multiple times (e.g. RustyClawd → launch_command). + + Steps performed (in order): + 1. Nesting detection and auto-staging + 2. Framework staging (~/.amplihack/.claude/) + 3. Rust recipe runner check + 4. SDK dependency check + 5. Power-steering re-enable prompt (#2544) + """ + # Idempotency guard — skip if already run this process + if getattr(args, "_startup_done", False): + return + args._startup_done = True # noqa: SLF001 + + subprocess_safe = getattr(args, "subprocess_safe", False) + if subprocess_safe: + return + + # 1. Nesting detection — protect .claude/ when running in source repo + from .launcher.auto_stager import AutoStager + from .launcher.nesting_detector import NestingDetector + + detector = NestingDetector() + nesting_result = detector.detect_nesting(Path.cwd(), sys.argv) + + if nesting_result.requires_staging: + print("\n🚨 SELF-MODIFICATION PROTECTION ACTIVATED") + print(" Running nested in amplihack source repository") + print(" Auto-staging .claude/ to temp directory for safety") + + stager = AutoStager() + staging_result = stager.stage_for_nested_execution( + Path.cwd(), f"nested-{os.getpid()}" + ) + + print(f" šŸ“ Staged to: {staging_result.temp_root}") + print(" Your original .claude/ files are protected") + os.chdir(staging_result.temp_root) + print(f" šŸ“‚ CWD changed to: {staging_result.temp_root}\n") + + # Store nesting result on args for session tracking + args._nesting_result = nesting_result # noqa: SLF001 + + # 2. Framework staging + _ensure_amplihack_staged() + + # 3. Rust recipe runner + _ensure_rust_recipe_runner() + + # 4. SDK dependency check + try: + from .dep_check import ensure_sdk_deps + + dep_result = ensure_sdk_deps() + if not dep_result.all_ok: + logger.warning("Some SDK deps could not be installed: %s", dep_result.missing) + except Exception as e: + logger.debug("SDK dep check skipped: %s", e) + + # 5. Power-steering re-enable prompt (#2544) + try: + from .power_steering.re_enable_prompt import prompt_re_enable_if_disabled + + prompt_re_enable_if_disabled() + except Exception as e: + logger.debug(f"Error checking power-steering re-enable prompt: {e}") + + def _ensure_amplihack_staged() -> None: """Ensure .claude/ files are staged to ~/.amplihack/.claude/ for non-Claude commands. @@ -1448,9 +1492,8 @@ def main(argv: list[str] | None = None) -> int: if getattr(args, "append", None): return handle_append_instruction(args) - # Ensure amplihack framework is staged (skip in subprocess-safe mode) - if not getattr(args, "subprocess_safe", False): - _ensure_amplihack_staged() + # Shared startup (nesting, staging, deps, power-steering) + _common_launcher_startup(args) # Force RustyClawd usage (Rust implementation of Claude Code) os.environ["AMPLIHACK_USE_RUSTYCLAWD"] = "1" @@ -1474,9 +1517,8 @@ def main(argv: list[str] | None = None) -> int: if getattr(args, "append", None): return handle_append_instruction(args) - # Ensure amplihack framework is staged (skip in subprocess-safe mode) - if not getattr(args, "subprocess_safe", False): - _ensure_amplihack_staged() + # Shared startup (nesting, staging, deps, power-steering) + _common_launcher_startup(args) # Handle auto mode exit_code = handle_auto_mode("copilot", args, claude_args) @@ -1498,9 +1540,8 @@ def main(argv: list[str] | None = None) -> int: if getattr(args, "append", None): return handle_append_instruction(args) - # Ensure amplihack framework is staged (skip in subprocess-safe mode) - if not getattr(args, "subprocess_safe", False): - _ensure_amplihack_staged() + # Shared startup (nesting, staging, deps, power-steering) + _common_launcher_startup(args) # Handle auto mode exit_code = handle_auto_mode("codex", args, claude_args) @@ -1522,9 +1563,8 @@ def main(argv: list[str] | None = None) -> int: if getattr(args, "append", None): return handle_append_instruction(args) - # Ensure amplihack framework is staged (skip in subprocess-safe mode) - if not getattr(args, "subprocess_safe", False): - _ensure_amplihack_staged() + # Shared startup (nesting, staging, deps, power-steering) + _common_launcher_startup(args) # Environment setup if getattr(args, "no_reflection", False): diff --git a/src/amplihack/launcher/copilot.py b/src/amplihack/launcher/copilot.py index 44d53211f..c7dd9300e 100644 --- a/src/amplihack/launcher/copilot.py +++ b/src/amplihack/launcher/copilot.py @@ -904,6 +904,7 @@ def launch_copilot(args: list[str] | None = None, interactive: bool = True) -> i # Register awesome-copilot marketplace extensions (best-effort, silent on failure) register_awesome_copilot_marketplace() + # Prompt to re-enable power-steering if disabled (#2544) try: from ..power_steering.re_enable_prompt import prompt_re_enable_if_disabled diff --git a/tests/test_cli_claude_command_guard.py b/tests/test_cli_claude_command_guard.py index 0bf8389f4..fe4b90da9 100644 --- a/tests/test_cli_claude_command_guard.py +++ b/tests/test_cli_claude_command_guard.py @@ -70,6 +70,7 @@ def _apply_patches(extra_patches: dict | None = None): patches = { "amplihack.cli.is_uvx_deployment": MagicMock(return_value=True), "amplihack.cli.cleanup_legacy_skills": MagicMock(), + "amplihack.cli._common_launcher_startup": MagicMock(), "amplihack.safety.GitConflictDetector": MagicMock(return_value=mock_detector), "amplihack.safety.SafeCopyStrategy": MagicMock(return_value=mock_strategy_manager), "amplihack.cli._configure_amplihack_marketplace": mock_configure, diff --git a/tests/test_common_launcher_startup.py b/tests/test_common_launcher_startup.py new file mode 100644 index 000000000..65a523688 --- /dev/null +++ b/tests/test_common_launcher_startup.py @@ -0,0 +1,503 @@ +"""Tests for _common_launcher_startup() — the consolidated launcher initialization. + +Verifies that all 5 startup steps run correctly and in order for every +launcher path (launch, claude, RustyClawd, copilot, codex, amplifier). + +Covers: +- Idempotency guard (double-call safe for RustyClawd → launch_command) +- subprocess_safe skip +- Nesting detection and auto-staging +- Framework staging (_ensure_amplihack_staged) +- Rust recipe runner check (_ensure_rust_recipe_runner) +- SDK dependency check +- Power-steering re-enable prompt +""" + +import argparse +from pathlib import Path +from unittest.mock import MagicMock, call, patch + +import pytest + + +def _make_args(**overrides) -> argparse.Namespace: + """Build a minimal args Namespace for testing.""" + defaults = { + "command": "launch", + "verbose": False, + "quiet": False, + "subprocess_safe": False, + } + defaults.update(overrides) + return argparse.Namespace(**defaults) + + +# --------------------------------------------------------------------------- +# Patch targets — all inside amplihack.cli +# --------------------------------------------------------------------------- +_NESTING_DETECTOR = "amplihack.cli.NestingDetector" +_AUTO_STAGER = "amplihack.cli.AutoStager" +_ENSURE_STAGED = "amplihack.cli._ensure_amplihack_staged" +_ENSURE_RUST = "amplihack.cli._ensure_rust_recipe_runner" +_ENSURE_SDK = "amplihack.cli.ensure_sdk_deps" +_POWER_STEERING = "amplihack.cli.prompt_re_enable_if_disabled" + + +def _patch_all_steps(): + """Return a dict of patches that mock all startup steps. + + The nesting detector and auto-stager are imported locally inside the + function, so we patch at the module level where they'll be resolved. + """ + mock_nesting_result = MagicMock( + is_nested=False, + requires_staging=False, + parent_session_id=None, + ) + mock_detector = MagicMock() + mock_detector.detect_nesting.return_value = mock_nesting_result + + mock_sdk_result = MagicMock(all_ok=True, missing=[]) + + return { + "amplihack.launcher.nesting_detector.NestingDetector": MagicMock( + return_value=mock_detector + ), + "amplihack.launcher.auto_stager.AutoStager": MagicMock(), + _ENSURE_STAGED: MagicMock(), + _ENSURE_RUST: MagicMock(), + "amplihack.dep_check.ensure_sdk_deps": MagicMock(return_value=mock_sdk_result), + "amplihack.power_steering.re_enable_prompt.prompt_re_enable_if_disabled": MagicMock(), + } + + +# --------------------------------------------------------------------------- +# Core behavior tests +# --------------------------------------------------------------------------- + + +class TestIdempotency: + """_common_launcher_startup must be safe to call multiple times.""" + + def test_second_call_is_noop(self): + """When called twice on the same args, second call does nothing.""" + from amplihack.cli import _common_launcher_startup + + args = _make_args() + + with ( + patch(_ENSURE_STAGED) as mock_staged, + patch(_ENSURE_RUST) as mock_rust, + patch( + "amplihack.launcher.nesting_detector.NestingDetector", + return_value=MagicMock( + detect_nesting=MagicMock( + return_value=MagicMock( + is_nested=False, requires_staging=False, parent_session_id=None + ) + ) + ), + ), + patch("amplihack.dep_check.ensure_sdk_deps", side_effect=ImportError), + patch( + "amplihack.power_steering.re_enable_prompt.prompt_re_enable_if_disabled", + side_effect=ImportError, + ), + ): + _common_launcher_startup(args) + _common_launcher_startup(args) # second call + + # Each step should only run once + mock_staged.assert_called_once() + mock_rust.assert_called_once() + + def test_startup_done_flag_set(self): + """After first call, _startup_done flag is set on args.""" + from amplihack.cli import _common_launcher_startup + + args = _make_args() + + with ( + patch(_ENSURE_STAGED), + patch(_ENSURE_RUST), + patch( + "amplihack.launcher.nesting_detector.NestingDetector", + return_value=MagicMock( + detect_nesting=MagicMock( + return_value=MagicMock( + is_nested=False, requires_staging=False, parent_session_id=None + ) + ) + ), + ), + patch("amplihack.dep_check.ensure_sdk_deps", side_effect=ImportError), + patch( + "amplihack.power_steering.re_enable_prompt.prompt_re_enable_if_disabled", + side_effect=ImportError, + ), + ): + assert not getattr(args, "_startup_done", False) + _common_launcher_startup(args) + assert args._startup_done is True + + +class TestSubprocessSafe: + """subprocess_safe mode must skip all initialization.""" + + def test_skips_all_steps(self): + """With subprocess_safe=True, no startup steps run.""" + from amplihack.cli import _common_launcher_startup + + args = _make_args(subprocess_safe=True) + + with ( + patch(_ENSURE_STAGED) as mock_staged, + patch(_ENSURE_RUST) as mock_rust, + ): + _common_launcher_startup(args) + + mock_staged.assert_not_called() + mock_rust.assert_not_called() + + def test_startup_done_flag_still_set(self): + """Even in subprocess_safe, the idempotency flag is set.""" + from amplihack.cli import _common_launcher_startup + + args = _make_args(subprocess_safe=True) + _common_launcher_startup(args) + assert args._startup_done is True + + +class TestNestingDetection: + """Nesting detection must run and auto-stage if needed.""" + + def test_nesting_result_stored_on_args(self): + """The nesting result is stored as args._nesting_result.""" + from amplihack.cli import _common_launcher_startup + + args = _make_args() + mock_result = MagicMock( + is_nested=False, requires_staging=False, parent_session_id=None + ) + + with ( + patch( + "amplihack.launcher.nesting_detector.NestingDetector", + return_value=MagicMock(detect_nesting=MagicMock(return_value=mock_result)), + ), + patch(_ENSURE_STAGED), + patch(_ENSURE_RUST), + patch("amplihack.dep_check.ensure_sdk_deps", side_effect=ImportError), + patch( + "amplihack.power_steering.re_enable_prompt.prompt_re_enable_if_disabled", + side_effect=ImportError, + ), + ): + _common_launcher_startup(args) + assert args._nesting_result is mock_result + + def test_auto_staging_triggered_when_nested(self): + """When nesting requires staging, AutoStager is invoked and cwd changes.""" + from amplihack.cli import _common_launcher_startup + + args = _make_args() + mock_nesting = MagicMock( + is_nested=True, requires_staging=True, parent_session_id="parent-123" + ) + mock_staging_result = MagicMock(temp_root=Path("/tmp/staged")) + + mock_detector = MagicMock() + mock_detector.detect_nesting.return_value = mock_nesting + + mock_stager = MagicMock() + mock_stager.stage_for_nested_execution.return_value = mock_staging_result + + with ( + patch( + "amplihack.launcher.nesting_detector.NestingDetector", + return_value=mock_detector, + ), + patch( + "amplihack.launcher.auto_stager.AutoStager", + return_value=mock_stager, + ), + patch("amplihack.cli.os.chdir") as mock_chdir, + patch(_ENSURE_STAGED), + patch(_ENSURE_RUST), + patch("amplihack.dep_check.ensure_sdk_deps", side_effect=ImportError), + patch( + "amplihack.power_steering.re_enable_prompt.prompt_re_enable_if_disabled", + side_effect=ImportError, + ), + ): + _common_launcher_startup(args) + mock_stager.stage_for_nested_execution.assert_called_once() + mock_chdir.assert_called_once_with(Path("/tmp/staged")) + + def test_no_staging_when_not_nested(self): + """When not nested, AutoStager is NOT invoked.""" + from amplihack.cli import _common_launcher_startup + + args = _make_args() + mock_nesting = MagicMock( + is_nested=False, requires_staging=False, parent_session_id=None + ) + + with ( + patch( + "amplihack.launcher.nesting_detector.NestingDetector", + return_value=MagicMock( + detect_nesting=MagicMock(return_value=mock_nesting) + ), + ), + patch( + "amplihack.launcher.auto_stager.AutoStager" + ) as mock_stager_cls, + patch(_ENSURE_STAGED), + patch(_ENSURE_RUST), + patch("amplihack.dep_check.ensure_sdk_deps", side_effect=ImportError), + patch( + "amplihack.power_steering.re_enable_prompt.prompt_re_enable_if_disabled", + side_effect=ImportError, + ), + ): + _common_launcher_startup(args) + mock_stager_cls.return_value.stage_for_nested_execution.assert_not_called() + + +class TestStartupStepsOrder: + """All 5 startup steps must run in correct order.""" + + def test_all_steps_called(self): + """Verify staging, rust runner, SDK deps, and power-steering all run.""" + from amplihack.cli import _common_launcher_startup + + args = _make_args() + call_order = [] + + mock_nesting = MagicMock( + is_nested=False, requires_staging=False, parent_session_id=None + ) + mock_sdk_result = MagicMock(all_ok=True, missing=[]) + + with ( + patch( + "amplihack.launcher.nesting_detector.NestingDetector", + return_value=MagicMock( + detect_nesting=MagicMock(return_value=mock_nesting) + ), + ), + patch( + _ENSURE_STAGED, + side_effect=lambda: call_order.append("staged"), + ), + patch( + _ENSURE_RUST, + side_effect=lambda: call_order.append("rust"), + ), + patch( + "amplihack.dep_check.ensure_sdk_deps", + side_effect=lambda: (call_order.append("sdk"), mock_sdk_result)[-1], + ), + patch( + "amplihack.power_steering.re_enable_prompt.prompt_re_enable_if_disabled", + side_effect=lambda: call_order.append("power_steering"), + ), + ): + _common_launcher_startup(args) + + assert call_order == ["staged", "rust", "sdk", "power_steering"] + + def test_sdk_dep_failure_is_nonfatal(self): + """SDK dep check failure doesn't stop startup.""" + from amplihack.cli import _common_launcher_startup + + args = _make_args() + + with ( + patch( + "amplihack.launcher.nesting_detector.NestingDetector", + return_value=MagicMock( + detect_nesting=MagicMock( + return_value=MagicMock( + is_nested=False, + requires_staging=False, + parent_session_id=None, + ) + ) + ), + ), + patch(_ENSURE_STAGED), + patch(_ENSURE_RUST) as mock_rust, + patch( + "amplihack.dep_check.ensure_sdk_deps", + side_effect=RuntimeError("dep check broke"), + ), + patch( + "amplihack.power_steering.re_enable_prompt.prompt_re_enable_if_disabled" + ) as mock_ps, + ): + # Should NOT raise + _common_launcher_startup(args) + + # Downstream steps still ran + mock_rust.assert_called_once() + mock_ps.assert_called_once() + + def test_power_steering_failure_is_nonfatal(self): + """Power-steering prompt failure doesn't stop startup.""" + from amplihack.cli import _common_launcher_startup + + args = _make_args() + + with ( + patch( + "amplihack.launcher.nesting_detector.NestingDetector", + return_value=MagicMock( + detect_nesting=MagicMock( + return_value=MagicMock( + is_nested=False, + requires_staging=False, + parent_session_id=None, + ) + ) + ), + ), + patch(_ENSURE_STAGED) as mock_staged, + patch(_ENSURE_RUST) as mock_rust, + patch("amplihack.dep_check.ensure_sdk_deps", side_effect=ImportError), + patch( + "amplihack.power_steering.re_enable_prompt.prompt_re_enable_if_disabled", + side_effect=RuntimeError("power steering broke"), + ), + ): + # Should NOT raise + _common_launcher_startup(args) + + # Upstream steps still ran + mock_staged.assert_called_once() + mock_rust.assert_called_once() + + +class TestEnsureRustRecipeRunner: + """Tests for the _ensure_rust_recipe_runner helper.""" + + def test_prints_success_when_installed(self, capsys): + """When binary is available, prints success message.""" + from amplihack.cli import _ensure_rust_recipe_runner + + with patch( + "amplihack.recipes.rust_runner.ensure_rust_recipe_runner", return_value=True + ): + _ensure_rust_recipe_runner() + + captured = capsys.readouterr() + assert "Rust recipe runner available" in captured.out + + def test_prints_warning_when_not_installed(self, capsys): + """When binary is missing, prints install instructions.""" + from amplihack.cli import _ensure_rust_recipe_runner + + with patch( + "amplihack.recipes.rust_runner.ensure_rust_recipe_runner", return_value=False + ): + _ensure_rust_recipe_runner() + + captured = capsys.readouterr() + assert "not installed" in captured.out + assert "rustup.rs" in captured.out + + def test_import_error_is_nonfatal(self): + """If rust_runner module can't be imported, doesn't crash.""" + from amplihack.cli import _ensure_rust_recipe_runner + + with patch( + "amplihack.recipes.rust_runner.ensure_rust_recipe_runner", + side_effect=ImportError("no module"), + ): + # Should NOT raise + _ensure_rust_recipe_runner() + + +# --------------------------------------------------------------------------- +# Integration-style: verify each command path calls _common_launcher_startup +# --------------------------------------------------------------------------- + + +class TestAllLauncherPathsCallStartup: + """Every launcher command must call _common_launcher_startup.""" + + @pytest.mark.parametrize( + "command", + ["launch", "claude", "copilot", "codex", "amplifier"], + ) + def test_command_calls_common_startup(self, command): + """Each launcher command invokes _common_launcher_startup.""" + from amplihack.cli import main + + args = _make_args(command=command, skip_update_check=True, no_proxy=True) + + patches = { + "amplihack.cli.parse_args_with_passthrough": MagicMock( + return_value=(args, []) + ), + "amplihack.cli._common_launcher_startup": MagicMock(), + "amplihack.cli.is_uvx_deployment": MagicMock(return_value=False), + "amplihack.cli.cleanup_legacy_skills": MagicMock(), + } + + import contextlib + + with contextlib.ExitStack() as stack: + mock_startup = None + for target, mock_obj in patches.items(): + m = stack.enter_context(patch(target, mock_obj)) + if target == "amplihack.cli._common_launcher_startup": + mock_startup = m + + try: + main() + except (SystemExit, Exception): + # Commands will fail after startup (unmocked launchers), that's fine + pass + + mock_startup.assert_called_once_with(args) + + def test_rustyclawd_calls_startup_twice_but_idempotent(self): + """RustyClawd calls startup in its own block then via launch_command(). + + The idempotency guard in _common_launcher_startup ensures only the + first call actually runs the init steps. + """ + from amplihack.cli import main + + args = _make_args(command="RustyClawd", skip_update_check=True, no_proxy=True) + + patches = { + "amplihack.cli.parse_args_with_passthrough": MagicMock( + return_value=(args, []) + ), + "amplihack.cli._common_launcher_startup": MagicMock(), + "amplihack.cli.is_uvx_deployment": MagicMock(return_value=False), + "amplihack.cli.cleanup_legacy_skills": MagicMock(), + } + + import contextlib + + with contextlib.ExitStack() as stack: + mock_startup = None + for target, mock_obj in patches.items(): + m = stack.enter_context(patch(target, mock_obj)) + if target == "amplihack.cli._common_launcher_startup": + mock_startup = m + + try: + main() + except (SystemExit, Exception): + pass + + # Called twice: once from RustyClawd block, once from launch_command() + assert mock_startup.call_count == 2 + # Both calls use the same args object + for c in mock_startup.call_args_list: + assert c == call(args) diff --git a/uv.lock b/uv.lock index 4d1e7bc16..09f77deb2 100644 --- a/uv.lock +++ b/uv.lock @@ -519,7 +519,7 @@ dependencies = [ [[package]] name = "amplihack" -version = "0.5.116" +version = "0.5.120" source = { editable = "." } dependencies = [ { name = "aiohttp" },