diff --git a/src/clayde/claude.py b/src/clayde/claude.py index d5c93a7..2f0b07a 100644 --- a/src/clayde/claude.py +++ b/src/clayde/claude.py @@ -43,6 +43,14 @@ "hit your limit", ] +# Text patterns that indicate an authentication error in CLI error output. +_AUTH_ERROR_PATTERNS = [ + "not logged in", + "failed to authenticate", + "authentication_error", + "invalid authentication credentials", +] + @dataclasses.dataclass class InvocationResult: @@ -446,6 +454,12 @@ def _is_limit_error(text: str) -> bool: return any(p in t for p in _LIMIT_PATTERNS) +def _is_auth_error(text: str) -> bool: + """Return True if text contains an authentication error pattern.""" + t = text.lower() + return any(p in t for p in _AUTH_ERROR_PATTERNS) + + def _make_cli_env() -> dict[str, str]: """Build an environment dict for CLI subprocess calls.""" env = os.environ.copy() @@ -613,6 +627,11 @@ def invoke( exc = UsageLimitError("Claude CLI usage limit hit") span.record_exception(exc) raise exc + if _is_auth_error(error_text): + log.error("Claude CLI authentication failed") + exc = RuntimeError("Claude CLI authentication failed") + span.record_exception(exc) + raise exc if result.returncode != 0: log.error("Claude CLI exited with code %d", result.returncode) @@ -652,8 +671,8 @@ def is_available(self) -> bool: if _is_limit_error(error_text): span.set_attribute("claude.available", False) return False - if is_error and "not logged in" in error_text.lower(): - log.warning("Claude CLI is not logged in — marking unavailable") + if is_error and _is_auth_error(error_text): + log.warning("Claude CLI authentication failed — marking unavailable") span.set_attribute("claude.available", False) return False span.set_attribute("claude.available", True) diff --git a/tests/test_claude.py b/tests/test_claude.py index 7c330dd..7d1066c 100644 --- a/tests/test_claude.py +++ b/tests/test_claude.py @@ -888,6 +888,42 @@ def test_stale_session_no_conv_path_no_retry(self, tmp_path): assert result.output == "" assert mock_run.call_count == 1 + def test_auth_error_raises_runtime_error(self, tmp_path): + """is_error=True with auth failure raises RuntimeError instead of returning error text.""" + (tmp_path / "CLAUDE.md").write_text("identity") + auth_error_text = ( + 'Failed to authenticate. API Error: 401 {"type":"error","error":{' + '"type":"authentication_error","message":"Invalid authentication credentials"}}' + ) + mock_result = MagicMock() + mock_result.stdout = json.dumps({"is_error": True, "result": auth_error_text}) + mock_result.stderr = "" + mock_result.returncode = 0 + backend = CliBackend() + + with patch("clayde.claude.APP_DIR", tmp_path), \ + patch("clayde.claude.get_settings", return_value=_mock_settings(backend="cli")), \ + patch("clayde.claude._resolve_cli_bin", return_value="/usr/bin/claude"), \ + patch("clayde.claude.subprocess.run", return_value=mock_result): + with pytest.raises(RuntimeError, match="authentication failed"): + backend.invoke("prompt", "/repo") + + def test_not_logged_in_raises_runtime_error(self, tmp_path): + """is_error=True with 'not logged in' text raises RuntimeError.""" + (tmp_path / "CLAUDE.md").write_text("identity") + mock_result = MagicMock() + mock_result.stdout = json.dumps({"is_error": True, "result": "Not logged in · Please run /login"}) + mock_result.stderr = "" + mock_result.returncode = 0 + backend = CliBackend() + + with patch("clayde.claude.APP_DIR", tmp_path), \ + patch("clayde.claude.get_settings", return_value=_mock_settings(backend="cli")), \ + patch("clayde.claude._resolve_cli_bin", return_value="/usr/bin/claude"), \ + patch("clayde.claude.subprocess.run", return_value=mock_result): + with pytest.raises(RuntimeError, match="authentication failed"): + backend.invoke("prompt", "/repo") + class TestCliBackendIsAvailable: def test_available_on_success(self): @@ -926,6 +962,25 @@ def test_unavailable_on_not_logged_in(self): patch("clayde.claude.subprocess.run", return_value=mock_result): assert backend.is_available() is False + def test_unavailable_on_failed_to_authenticate(self): + mock_result = MagicMock() + mock_result.stdout = json.dumps({ + "is_error": True, + "result": ( + "Failed to authenticate. API Error: 401 " + '{"type":"error","error":{"type":"authentication_error",' + '"message":"Invalid authentication credentials"}}' + ), + }) + mock_result.stderr = "" + mock_result.returncode = 0 + backend = CliBackend() + + with patch("clayde.claude.get_settings", return_value=_mock_settings(backend="cli")), \ + patch("clayde.claude._resolve_cli_bin", return_value="/usr/bin/claude"), \ + patch("clayde.claude.subprocess.run", return_value=mock_result): + assert backend.is_available() is False + def test_available_on_exception(self): backend = CliBackend()