diff --git a/CHANGELOG.md b/CHANGELOG.md index 702c28f0..6bf23f0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,6 +50,13 @@ and this project uses [Calendar Versioning](https://calver.org/) in the format ### Added +- **`aipass init` detects missing Claude Code.** Stage 6 (CLI choice) now checks + `shutil.which("claude")` when the picked CLI is Claude Code. If absent: interactive + runs prompt `Install now? [Y/n]` and run the canonical installer on yes (native + `claude.ai/install.sh`, PowerShell on Windows, `npm` fallback, 300s timeout, loud on + failure); non-interactive runs warn and continue. The whole system routes through + Claude Code (hook bridge, dispatch, prompt injection), so init no longer silently + assumes the runtime is present. Only fires when the chosen CLI is `claude`. - **Kernel filesystem boundary for agent containment (DPLAN-0202 / FPLAN-0250).** Every autonomous agent can now launch inside a kernel-enforced mount namespace (`@anthropic-ai/sandbox-runtime` → bwrap+seccomp) where reads stay fully open diff --git a/src/aipass/aipass/apps/modules/init_flow.py b/src/aipass/aipass/apps/modules/init_flow.py index f776efc4..86b02a31 100644 --- a/src/aipass/aipass/apps/modules/init_flow.py +++ b/src/aipass/aipass/apps/modules/init_flow.py @@ -377,6 +377,54 @@ def stage_5_style_questions( return {"style": style} +def _install_claude_code() -> bool: + """Run the canonical Claude Code installer, platform-aware. Returns True on success.""" + if sys.platform == "win32": + cmd = ["powershell", "-Command", "irm https://claude.ai/install.ps1 | iex"] + else: + cmd = ["bash", "-c", "curl -fsSL https://claude.ai/install.sh | bash"] + + try: + result = subprocess.run(cmd, timeout=300) + if result.returncode == 0 and shutil.which("claude"): + return True + except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as exc: + logger.warning("[init_flow] Claude Code installer failed: %s", exc) + + if shutil.which("npm"): + console.print("[dim]Native installer didn't work — trying npm fallback...[/dim]") + try: + result = subprocess.run( + ["npm", "install", "-g", "@anthropic-ai/claude-code"], + timeout=300, + ) + if result.returncode == 0 and shutil.which("claude"): + return True + except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as exc: + logger.warning("[init_flow] npm fallback install failed: %s", exc) + + return False + + +def _handle_missing_claude(non_interactive: bool) -> None: + """Prompt to install Claude Code when missing, or warn in non-interactive mode.""" + if non_interactive: + warning("[bold yellow]Claude Code ('claude') is not installed.[/bold yellow]") + console.print(" Stage 11 handoff requires it. Install manually before then.") + return + + raw = _prompt("Claude Code ('claude') not found. Install now? [Y/n]", "Y") + if raw.lower() in ("y", "yes", ""): + console.print("[dim]Installing Claude Code...[/dim]") + if _install_claude_code(): + console.print("[green]✓[/green] Claude Code installed successfully.") + else: + warning("[bold yellow]Installation failed.[/bold yellow]") + console.print(" Install manually: https://claude.ai/download") + else: + console.print("[dim]Skipped. Stage 11 handoff will need 'claude' on PATH.[/dim]") + + def stage_6_tool_choice( non_interactive: bool = False, cli_override: str | None = None, @@ -393,6 +441,9 @@ def stage_6_tool_choice( else: cli_choice = _choose("Which CLI tool do you use?", CLI_CHOICES, default="claude") + if cli_choice == "claude" and not shutil.which("claude"): + _handle_missing_claude(non_interactive) + if non_interactive: flag_variant = "default" else: diff --git a/src/aipass/aipass/tests/test_init_flow.py b/src/aipass/aipass/tests/test_init_flow.py index a6b60b58..7e8b3251 100644 --- a/src/aipass/aipass/tests/test_init_flow.py +++ b/src/aipass/aipass/tests/test_init_flow.py @@ -488,6 +488,66 @@ class TestStages: result = stage_6_tool_choice(non_interactive=True, cli_override="codex") assert result["cli"] == "codex" + def test_stage_6_claude_present_no_prompt(self, tmp_local_json) -> None: + """When claude is on PATH, no install prompt is shown.""" + mock_profile_mod = MagicMock() + mock_profile_mod.get_user_profile.return_value = {} + with patch(f"{_MOD}.console"): + with patch(f"{_MOD}.shutil.which", return_value="/usr/bin/claude"): + with patch.dict("sys.modules", {"aipass.aipass.apps.modules.profile": mock_profile_mod}): + with patch(f"{_MOD}._handle_missing_claude") as mock_handle: + result = stage_6_tool_choice(non_interactive=True) + mock_handle.assert_not_called() + assert result["cli"] == "claude" + + @patch(f"{_MOD}._choose", return_value="default") + @patch(f"{_MOD}._install_claude_code", return_value=True) + @patch(f"{_MOD}._prompt", return_value="Y") + @patch(f"{_MOD}.shutil.which", return_value=None) + @patch(f"{_MOD}.console") + def test_stage_6_claude_missing_interactive_yes( + self, _con, _which, _prompt, mock_install, _choose, tmp_local_json + ) -> None: + """Missing claude + interactive + yes → installer invoked.""" + mock_profile_mod = MagicMock() + mock_profile_mod.get_user_profile.return_value = {} + with patch.dict("sys.modules", {"aipass.aipass.apps.modules.profile": mock_profile_mod}): + result = stage_6_tool_choice(non_interactive=False, cli_override="claude") + mock_install.assert_called_once() + assert result["cli"] == "claude" + + @patch(f"{_MOD}._choose", return_value="default") + @patch(f"{_MOD}._install_claude_code") + @patch(f"{_MOD}._prompt", return_value="n") + @patch(f"{_MOD}.shutil.which", return_value=None) + @patch(f"{_MOD}.console") + def test_stage_6_claude_missing_interactive_no( + self, _con, _which, _prompt, mock_install, _choose, tmp_local_json + ) -> None: + """Missing claude + interactive + no → no install, continues.""" + mock_profile_mod = MagicMock() + mock_profile_mod.get_user_profile.return_value = {} + with patch.dict("sys.modules", {"aipass.aipass.apps.modules.profile": mock_profile_mod}): + result = stage_6_tool_choice(non_interactive=False, cli_override="claude") + mock_install.assert_not_called() + assert result["cli"] == "claude" + + @patch(f"{_MOD}.warning") + @patch(f"{_MOD}._install_claude_code") + @patch(f"{_MOD}.shutil.which", return_value=None) + @patch(f"{_MOD}.console") + def test_stage_6_claude_missing_non_interactive_warns( + self, _con, _which, mock_install, mock_warn, tmp_local_json + ) -> None: + """Missing claude + non-interactive → warning, no install.""" + mock_profile_mod = MagicMock() + mock_profile_mod.get_user_profile.return_value = {} + with patch.dict("sys.modules", {"aipass.aipass.apps.modules.profile": mock_profile_mod}): + result = stage_6_tool_choice(non_interactive=True) + mock_install.assert_not_called() + mock_warn.assert_called_once() + assert result["cli"] == "claude" + def test_stage_7_skipped_when_no_docker(self, tmp_local_json) -> None: """Docker offer is skipped when has_docker=False.""" with patch(f"{_MOD}.console"):