Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 21 additions & 2 deletions src/clayde/claude.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
55 changes: 55 additions & 0 deletions tests/test_claude.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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()

Expand Down
Loading