feat(aipass): init detects missing Claude Code, offers install (yes/no)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"):
|
||||
|
||||
Reference in New Issue
Block a user