Skip to content
Open
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
Binary file added .coverage
Binary file not shown.
34 changes: 34 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,40 @@ response = await claude.query(
print(response.content)
```

## 🧪 Running Tests

This project includes a comprehensive test suite covering unit, integration, and end-to-end scenarios.

### Prerequisites

1. **Install Dependencies:**
```bash
poetry install
```

2. **Verify Environment:**
Run the environment check script to ensure Node.js, Claude CLI, and authentication are properly set up:
```bash
python scripts/check_env.py
```

### Running Tests

**Unit Tests (Offline):**
These tests mock the Claude CLI and do not require authentication or internet access.
```bash
poetry run pytest tests/test_basic.py tests/test_cli_parsing.py tests/test_config_fallback.py tests/test_robustness.py
```

**E2E Live Tests (Online):**
These tests interact with the real Claude API via CLI. They require authentication (`claude auth login`) and will consume tokens/cost.
To enable them, set the `RUN_LIVE_TESTS` environment variable:
```bash
export RUN_LIVE_TESTS=1
poetry run pytest tests/test_e2e_live.py
```
*Note: If you are not authenticated, these tests will be skipped automatically.*

## 📚 Complete Documentation

### Configuration
Expand Down
Binary file added dist/claude_cli_auth-1.0.0-py3-none-any.whl
Binary file not shown.
Binary file added dist/claude_cli_auth-1.0.0.tar.gz
Binary file not shown.
107 changes: 107 additions & 0 deletions scripts/check_env.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
#!/usr/bin/env python3
"""
Environment Validation Script for Claude CLI Auth Project.

This script checks:
1. Node.js installation
2. Claude CLI (@anthropic-ai/claude-code) presence in PATH
3. Authentication state (claude auth whoami)

Usage:
python scripts/check_env.py
"""

import sys
import shutil
import subprocess
import os

def print_status(message, status="INFO"):
colors = {
"INFO": "\033[94m", # Blue
"SUCCESS": "\033[92m", # Green
"WARNING": "\033[93m", # Yellow
"ERROR": "\033[91m", # Red
"RESET": "\033[0m"
}
prefix = f"[{status}]"
print(f"{colors.get(status, '')}{prefix} {message}{colors['RESET']}")

def check_command(command, name):
path = shutil.which(command)
if path:
print_status(f"Found {name} at: {path}", "SUCCESS")
return path
else:
print_status(f"{name} ({command}) not found in PATH.", "ERROR")
return None

def check_claude_auth():
print_status("Checking Claude authentication status...", "INFO")
try:
# Run 'claude auth whoami'
# We need to capture output to check for "not authenticated" messages
# varying by version, but exit code 0 usually means success or at least CLI ran.
result = subprocess.run(
["claude", "auth", "whoami"],
capture_output=True,
text=True,
timeout=10
)

output = result.stdout.strip()
stderr = result.stderr.strip()

print_status(f"Output: {output}")
if stderr:
print_status(f"Stderr: {stderr}", "WARNING")

if result.returncode == 0:
# Heuristic check for success message
# Adjust these strings based on actual CLI output if needed
if "not authenticated" in output.lower() or "login" in output.lower():
print_status("Claude CLI is NOT authenticated. Please run 'claude auth login'.", "ERROR")
return False

print_status("Claude CLI appears authenticated.", "SUCCESS")
return True
else:
print_status(f"Claude auth check failed with exit code {result.returncode}.", "ERROR")
return False

except subprocess.TimeoutExpired:
print_status("Claude auth check timed out.", "ERROR")
return False
except Exception as e:
print_status(f"An unexpected error occurred: {e}", "ERROR")
return False

def main():
print_status("Starting Environment Sanity Check...", "INFO")

# 1. Check Node.js (prerequisite for Claude CLI)
if not check_command("node", "Node.js"):
print_status("Node.js is required to run Claude CLI.", "ERROR")
sys.exit(1)

if not check_command("npm", "NPM"):
print_status("NPM is required to install/update Claude CLI.", "WARNING")

# 2. Check Claude CLI
claude_path = check_command("claude", "Claude CLI")
if not claude_path:
print_status("Claude CLI package (@anthropic-ai/claude-code) is missing.", "ERROR")
print_status("Install it using: npm install -g @anthropic-ai/claude-code", "INFO")
sys.exit(1)

# 3. Check Authentication
if not check_claude_auth():
print_status("Authentication check failed. Tests requiring live API will fail.", "ERROR")
# We exit with error to ensure CI/pipeline stops here if this is a prerequisite
sys.exit(1)

print_status("Environment validation passed! 🚀", "SUCCESS")
sys.exit(0)

if __name__ == "__main__":
main()
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
5 changes: 2 additions & 3 deletions src/claude_cli_auth/auth_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,9 +115,8 @@ def is_authenticated(self) -> bool:
return True
else:
logger.warning(
"Claude auth command failed",
returncode=result.returncode,
stderr=result.stderr.strip()
f"Claude auth command failed - returncode: {result.returncode}, "
f"stderr: {result.stderr.strip()}"
)
return False

Expand Down
48 changes: 21 additions & 27 deletions src/claude_cli_auth/cli_interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -453,10 +453,8 @@ async def _handle_process_output(
await stream_callback(update)
except Exception as e:
logger.warning(
"Stream callback failed",
error=str(e),
update_type=update.type,
process_id=process_id,
f"Stream callback failed - error: {str(e)}, "
f"update_type: {update.type}, process_id: {process_id}"
)

# Check for final result
Expand All @@ -466,10 +464,8 @@ async def _handle_process_output(
except json.JSONDecodeError as e:
parsing_errors.append(f"JSON error: {str(e)}")
logger.debug(
"JSON parsing failed",
line=line[:200],
error=str(e),
process_id=process_id,
f"JSON parsing failed - line: {line[:200]}, "
f"error: {str(e)}, process_id: {process_id}"
)
continue

Expand All @@ -482,10 +478,8 @@ async def _handle_process_output(
stderr_text = stderr.decode("utf-8", errors="replace")

logger.error(
"Claude process failed",
return_code=return_code,
stderr=stderr_text[:500],
process_id=process_id,
f"Claude process failed - return_code: {return_code}, "
f"stderr: {stderr_text[:500]}, process_id: {process_id}"
)

# Handle specific error types
Expand All @@ -499,10 +493,8 @@ async def _handle_process_output(
# Parse final result
if not result_message:
logger.error(
"No result message received",
message_count=len(message_buffer),
parsing_errors=len(parsing_errors),
process_id=process_id,
f"No result message received - message_count: {len(message_buffer)}, "
f"parsing_errors: {len(parsing_errors)}, process_id: {process_id}"
)

raise ClaudeParsingError(
Expand All @@ -525,12 +517,9 @@ async def _handle_process_output(
)

logger.debug(
"Parsed Claude response",
content_length=len(response.content),
cost=response.cost,
tools_used=len(response.tools_used),
parsing_errors=len(parsing_errors),
process_id=process_id,
f"Parsed Claude response - content_length: {len(response.content)}, "
f"cost: {response.cost}, tools_used: {len(response.tools_used)}, "
f"parsing_errors: {len(parsing_errors)}, process_id: {process_id}"
)

return response
Expand All @@ -540,10 +529,8 @@ async def _handle_process_output(
raise
else:
logger.error(
"Unexpected error in output handling",
error=str(e),
error_type=type(e).__name__,
process_id=process_id,
f"Unexpected error in output handling - error: {str(e)}, "
f"error_type: {type(e).__name__}, process_id: {process_id}"
)

raise ClaudeParsingError(
Expand Down Expand Up @@ -719,7 +706,14 @@ def _parse_tool_result_message(self, msg: Dict[str, Any]) -> StreamUpdate:

def _parse_error_update(self, msg: Dict[str, Any]) -> StreamUpdate:
"""Parse error message for streaming."""
error_message = msg.get("message", msg.get("error", str(msg)))
# Try to get error content
error_data = msg.get("message") or msg.get("error") or msg

# If error_data is a dictionary, try to extract message field
if isinstance(error_data, dict):
error_message = error_data.get("message") or str(error_data)
else:
error_message = str(error_data)

return StreamUpdate(
type=StreamType.ERROR,
Expand Down
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
51 changes: 36 additions & 15 deletions tests/test_basic.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from unittest.mock import Mock, patch

from claude_cli_auth import ClaudeAuthManager, AuthConfig
from claude_cli_auth.exceptions import ClaudeAuthError
from claude_cli_auth.exceptions import ClaudeAuthError, ClaudeAuthManagerError


def test_imports():
Expand All @@ -30,7 +30,8 @@ def test_auth_config():
"""Test AuthConfig creation and defaults."""
config = AuthConfig()

assert config.timeout_seconds == 30
# Default is 120 in models.py
assert config.timeout_seconds == 120
assert config.session_timeout_hours == 24
assert config.use_sdk is True
assert config.enable_streaming is True
Expand All @@ -47,23 +48,43 @@ def test_auth_config():
assert custom_config.use_sdk is False


def test_claude_auth_manager_init():
@patch('claude_cli_auth.facade.AuthManager')
def test_claude_auth_manager_init(mock_auth_manager_cls):
"""Test ClaudeAuthManager initialization."""
manager = ClaudeAuthManager()
# Mock AuthManager to be authenticated
mock_instance = mock_auth_manager_cls.return_value
mock_instance.is_authenticated.return_value = True

assert manager is not None
assert manager.auth_manager is not None
assert hasattr(manager, 'list_sessions')
assert hasattr(manager, 'get_session')
# Also patch SDK/CLI interfaces to avoid real initialization logic
with patch('claude_cli_auth.facade.SDKInterface'), \
patch('claude_cli_auth.facade.CLIInterface'):
manager = ClaudeAuthManager()

assert manager is not None
assert manager.auth_manager is not None
assert hasattr(manager, 'list_sessions')
assert hasattr(manager, 'get_session')


@patch('claude_cli_auth.auth_manager.AuthManager.is_authenticated')
def test_authentication_check(mock_is_authenticated):
"""Test authentication status check."""
mock_is_authenticated.return_value = True

manager = ClaudeAuthManager()
is_auth = manager.auth_manager.is_authenticated()
# When initializing ClaudeAuthManager, it calls is_authenticated internally
# We patch it, so it works.

# Need to also patch the Interfaces if we want init to succeed fully without raising ClaudeAuthManagerError
# Or expect the error if we only care about auth check logic.
# But for this test, we are calling manager.auth_manager.is_authenticated() manually.

# Let's mock AuthManager fully to isolate the test of is_authenticated call
# But ClaudeAuthManager creates its own AuthManager.

# Simpler: just create AuthManager directly
from claude_cli_auth.auth_manager import AuthManager
auth = AuthManager()
is_auth = auth.is_authenticated()

assert is_auth is True
mock_is_authenticated.assert_called_once()
Expand All @@ -79,23 +100,23 @@ def test_claude_response_model():
cost=0.01,
duration_ms=1000,
num_turns=1,
tools_used=["test_tool"]
tools_used=[{"name": "test_tool"}] # tools_used is list of dicts
)

assert response.content == "Test response"
assert response.session_id == "test-session"
assert response.cost == 0.01
assert response.duration_ms == 1000
assert response.num_turns == 1
assert response.tools_used == ["test_tool"]
assert response.tools_used == [{"name": "test_tool"}]


def test_session_info_model():
"""Test SessionInfo model."""
from claude_cli_auth.models import SessionInfo, SessionStatus
from datetime import datetime
import time

now = datetime.now()
now = time.time()
session = SessionInfo(
session_id="test-session",
created_at=now,
Expand Down Expand Up @@ -130,4 +151,4 @@ def test_exceptions_hierarchy():
# Test exception creation
error = ClaudeAuthError("Test error", suggestions=["Try again"])
assert str(error) == "Test error"
assert error.suggestions == ["Try again"]
assert error.suggestions == ["Try again"]
Loading