diff --git a/cli/entrypoints.py b/cli/entrypoints.py index 3092d2d9..8e5d0a74 100644 --- a/cli/entrypoints.py +++ b/cli/entrypoints.py @@ -3,6 +3,7 @@ from __future__ import annotations import os +import shutil import subprocess import sys from collections.abc import Mapping, Sequence @@ -153,7 +154,19 @@ def launch_claude(argv: Sequence[str] | None = None) -> None: raise SystemExit(1) args = list(sys.argv[1:] if argv is None else argv) - command = [settings.claude_cli_bin, *args] + claude_command = shutil.which(settings.claude_cli_bin) + if claude_command is None: + print( + f"Could not find Claude Code command: {settings.claude_cli_bin}", + file=sys.stderr, + ) + print( + "Install Claude Code with: npm install -g @anthropic-ai/claude-code", + file=sys.stderr, + ) + raise SystemExit(127) + + command = [claude_command, *args] env = _claude_child_env(settings, os.environ) process: subprocess.Popen[bytes] | None = None try: diff --git a/tests/cli/test_entrypoints.py b/tests/cli/test_entrypoints.py index 2b81c8ad..6b29c0c0 100644 --- a/tests/cli/test_entrypoints.py +++ b/tests/cli/test_entrypoints.py @@ -222,6 +222,7 @@ def test_launch_claude_passes_args_and_child_env( with ( patch("cli.entrypoints.get_settings", return_value=settings), patch("cli.entrypoints._preflight_proxy", return_value=None), + patch("cli.entrypoints.shutil.which", return_value="resolved-claude.cmd"), patch("cli.entrypoints.subprocess.Popen") as popen, patch("cli.entrypoints.register_pid") as register_pid, patch("cli.entrypoints.unregister_pid") as unregister_pid, @@ -234,7 +235,7 @@ def test_launch_claude_passes_args_and_child_env( assert exc_info.value.code == 7 popen.assert_called_once() - assert popen.call_args.args[0] == ["claude-test", "--model", "sonnet"] + assert popen.call_args.args[0] == ["resolved-claude.cmd", "--model", "sonnet"] child_env = popen.call_args.kwargs["env"] assert child_env["ANTHROPIC_BASE_URL"] == "http://127.0.0.1:9191" assert child_env["ANTHROPIC_AUTH_TOKEN"] == "proxy-token" @@ -252,6 +253,7 @@ def test_launch_claude_keyboard_interrupt_kills_child_tree() -> None: with ( patch("cli.entrypoints.get_settings", return_value=settings), patch("cli.entrypoints._preflight_proxy", return_value=None), + patch("cli.entrypoints.shutil.which", return_value="resolved-claude.cmd"), patch("cli.entrypoints.subprocess.Popen") as popen, patch("cli.entrypoints.register_pid"), patch("cli.entrypoints.kill_pid_tree_best_effort") as kill_tree, @@ -268,6 +270,28 @@ def test_launch_claude_keyboard_interrupt_kills_child_tree() -> None: unregister_pid.assert_called_once_with(12345) +def test_launch_claude_exits_when_command_cannot_be_resolved( + capsys: pytest.CaptureFixture[str], +) -> None: + from cli.entrypoints import launch_claude + + settings = _launcher_settings(claude_bin="claude-missing") + with ( + patch("cli.entrypoints.get_settings", return_value=settings), + patch("cli.entrypoints._preflight_proxy", return_value=None), + patch("cli.entrypoints.shutil.which", return_value=None), + patch("cli.entrypoints.subprocess.Popen") as popen, + pytest.raises(SystemExit) as exc_info, + ): + launch_claude([]) + + assert exc_info.value.code == 127 + popen.assert_not_called() + captured = capsys.readouterr() + assert "Could not find Claude Code command: claude-missing" in captured.err + assert "npm install -g @anthropic-ai/claude-code" in captured.err + + def test_launch_claude_unreachable_proxy_exits_with_hint( capsys: pytest.CaptureFixture[str], ) -> None: