feat(aipass): init detects missing Claude Code, offers install (yes/no)

This commit is contained in:
AIOSAI
2026-06-11 00:12:32 -07:00
parent ed17630b76
commit 54d55abebc
3 changed files with 118 additions and 0 deletions
+7
View File
@@ -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:
+60
View File
@@ -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"):