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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ Use `--repo` to filter the session list to a specific repository:
claude-code-transcripts web --repo simonw/datasette
```

On macOS, API credentials are automatically retrieved from your keychain (requires being logged into Claude Code). On other platforms, provide `--token` and `--org-uuid` manually.
On macOS and Linux, API credentials are automatically retrieved from your system (requires being logged into Claude Code). On macOS, credentials are read from the keychain. On Linux, credentials are read from `~/.claude/.credentials.json` (with token expiration checking). On other platforms, provide `--token` and `--org-uuid` manually.

### Publishing to GitHub Gist

Expand Down
93 changes: 70 additions & 23 deletions src/claude_code_transcripts/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -506,35 +506,82 @@ class CredentialsError(Exception):


def get_access_token_from_keychain():
"""Get access token from macOS keychain.
"""Get access token from system credentials.

Returns the access token or None if not found.
On macOS, retrieves from keychain.
On Linux, retrieves from ~/.claude/.credentials.json.

Returns the access token or None if not found/expired.
Raises CredentialsError with helpful message on failure.
"""
if platform.system() != "Darwin":
return None
system = platform.system()

try:
result = subprocess.run(
[
"security",
"find-generic-password",
"-a",
os.environ.get("USER", ""),
"-s",
"Claude Code-credentials",
"-w",
],
capture_output=True,
text=True,
)
if result.returncode != 0:
if system == "Darwin":
# macOS: use keychain
try:
result = subprocess.run(
[
"security",
"find-generic-password",
"-a",
os.environ.get("USER", ""),
"-s",
"Claude Code-credentials",
"-w",
],
capture_output=True,
text=True,
)
if result.returncode != 0:
return None

# Parse the JSON to get the access token
creds = json.loads(result.stdout.strip())
return creds.get("claudeAiOauth", {}).get("accessToken")
except (json.JSONDecodeError, subprocess.SubprocessError):
return None

elif system == "Linux":
# Linux: read from ~/.claude/.credentials.json
creds_path = Path.home() / ".claude" / ".credentials.json"
if not creds_path.exists():
return None

# Parse the JSON to get the access token
creds = json.loads(result.stdout.strip())
return creds.get("claudeAiOauth", {}).get("accessToken")
except (json.JSONDecodeError, subprocess.SubprocessError):
try:
with open(creds_path, "r") as f:
creds = json.load(f)

oauth_data = creds.get("claudeAiOauth", {})
access_token = oauth_data.get("accessToken")
expires_at = oauth_data.get("expiresAt")

# If no access token, return None
if not access_token:
return None

# Check if token is expired (if expiresAt is provided)
if expires_at:
try:
# Parse ISO format datetime (e.g., "2099-12-31T23:59:59Z")
expiry_time = datetime.fromisoformat(
expires_at.replace("Z", "+00:00")
)
current_time = datetime.now(expiry_time.tzinfo)

if current_time >= expiry_time:
# Token is expired
return None
except (ValueError, AttributeError):
# If we can't parse the expiration date, treat as no expiration
pass

return access_token

except (json.JSONDecodeError, IOError):
return None

else:
# Unsupported platform
return None


Expand Down
204 changes: 204 additions & 0 deletions tests/test_credentials.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
"""Tests for credential retrieval functions."""

import json
import platform
import subprocess
from datetime import datetime, timedelta, timezone
from pathlib import Path
from unittest.mock import MagicMock, Mock, patch

import pytest

from claude_code_transcripts import (
CredentialsError,
get_access_token_from_keychain,
get_org_uuid_from_config,
)


class TestGetAccessTokenFromKeychain:
"""Tests for get_access_token_from_keychain function."""

@patch("claude_code_transcripts.platform.system")
def test_returns_none_on_unsupported_platform(self, mock_system):
"""Should return None on Windows."""
mock_system.return_value = "Windows"
assert get_access_token_from_keychain() is None

@patch("claude_code_transcripts.platform.system")
@patch("claude_code_transcripts.subprocess.run")
def test_macos_keychain_success(self, mock_run, mock_system):
"""Should retrieve token from macOS keychain."""
mock_system.return_value = "Darwin"
mock_result = Mock()
mock_result.returncode = 0
mock_result.stdout = json.dumps(
{
"claudeAiOauth": {
"accessToken": "test-token-macos",
"expiresAt": "2099-12-31T23:59:59Z",
}
}
)
mock_run.return_value = mock_result

token = get_access_token_from_keychain()
assert token == "test-token-macos"

@patch("claude_code_transcripts.platform.system")
@patch("claude_code_transcripts.subprocess.run")
def test_macos_keychain_not_found(self, mock_run, mock_system):
"""Should return None if keychain entry not found."""
mock_system.return_value = "Darwin"
mock_result = Mock()
mock_result.returncode = 1
mock_run.return_value = mock_result

token = get_access_token_from_keychain()
assert token is None

@patch("claude_code_transcripts.platform.system")
@patch("claude_code_transcripts.Path.home")
def test_linux_credentials_file_success(self, mock_home, mock_system, tmp_path):
"""Should retrieve token from ~/.claude/.credentials.json on Linux."""
mock_system.return_value = "Linux"
mock_home.return_value = tmp_path

# Create credentials file
claude_dir = tmp_path / ".claude"
claude_dir.mkdir()
creds_file = claude_dir / ".credentials.json"

future_time = (
(datetime.now(timezone.utc) + timedelta(days=30))
.isoformat()
.replace("+00:00", "Z")
)
creds_data = {
"claudeAiOauth": {
"accessToken": "test-token-linux",
"expiresAt": future_time,
"refreshToken": "refresh-token",
}
}
creds_file.write_text(json.dumps(creds_data))

token = get_access_token_from_keychain()
assert token == "test-token-linux"

@patch("claude_code_transcripts.platform.system")
@patch("claude_code_transcripts.Path.home")
def test_linux_credentials_file_not_found(self, mock_home, mock_system, tmp_path):
"""Should return None if credentials file doesn't exist on Linux."""
mock_system.return_value = "Linux"
mock_home.return_value = tmp_path

token = get_access_token_from_keychain()
assert token is None

@patch("claude_code_transcripts.platform.system")
@patch("claude_code_transcripts.Path.home")
def test_linux_credentials_expired_token(self, mock_home, mock_system, tmp_path):
"""Should return None if token is expired on Linux."""
mock_system.return_value = "Linux"
mock_home.return_value = tmp_path

# Create credentials file with expired token
claude_dir = tmp_path / ".claude"
claude_dir.mkdir()
creds_file = claude_dir / ".credentials.json"

past_time = (
(datetime.now(timezone.utc) - timedelta(days=1))
.isoformat()
.replace("+00:00", "Z")
)
creds_data = {
"claudeAiOauth": {
"accessToken": "expired-token",
"expiresAt": past_time,
"refreshToken": "refresh-token",
}
}
creds_file.write_text(json.dumps(creds_data))

token = get_access_token_from_keychain()
assert token is None

@patch("claude_code_transcripts.platform.system")
@patch("claude_code_transcripts.Path.home")
def test_linux_credentials_invalid_json(self, mock_home, mock_system, tmp_path):
"""Should return None if credentials file contains invalid JSON."""
mock_system.return_value = "Linux"
mock_home.return_value = tmp_path

# Create credentials file with invalid JSON
claude_dir = tmp_path / ".claude"
claude_dir.mkdir()
creds_file = claude_dir / ".credentials.json"
creds_file.write_text("not valid json {")

token = get_access_token_from_keychain()
assert token is None

@patch("claude_code_transcripts.platform.system")
@patch("claude_code_transcripts.Path.home")
def test_linux_credentials_missing_fields(self, mock_home, mock_system, tmp_path):
"""Should return None if credentials file is missing required fields."""
mock_system.return_value = "Linux"
mock_home.return_value = tmp_path

# Create credentials file with missing fields
claude_dir = tmp_path / ".claude"
claude_dir.mkdir()
creds_file = claude_dir / ".credentials.json"
creds_data = {"someOtherField": "value"}
creds_file.write_text(json.dumps(creds_data))

token = get_access_token_from_keychain()
assert token is None

@patch("claude_code_transcripts.platform.system")
@patch("claude_code_transcripts.Path.home")
def test_linux_credentials_no_expiration(self, mock_home, mock_system, tmp_path):
"""Should accept token if expiresAt field is missing (no expiration)."""
mock_system.return_value = "Linux"
mock_home.return_value = tmp_path

# Create credentials file without expiresAt
claude_dir = tmp_path / ".claude"
claude_dir.mkdir()
creds_file = claude_dir / ".credentials.json"
creds_data = {
"claudeAiOauth": {
"accessToken": "test-token-no-expiry",
}
}
creds_file.write_text(json.dumps(creds_data))

token = get_access_token_from_keychain()
assert token == "test-token-no-expiry"

@patch("claude_code_transcripts.platform.system")
@patch("claude_code_transcripts.Path.home")
def test_linux_credentials_invalid_expiration_format(
self, mock_home, mock_system, tmp_path
):
"""Should accept token if expiresAt format is invalid (treat as no expiration)."""
mock_system.return_value = "Linux"
mock_home.return_value = tmp_path

# Create credentials file with invalid expiresAt format
claude_dir = tmp_path / ".claude"
claude_dir.mkdir()
creds_file = claude_dir / ".credentials.json"
creds_data = {
"claudeAiOauth": {
"accessToken": "test-token-invalid-expiry",
"expiresAt": "not-a-valid-date",
}
}
creds_file.write_text(json.dumps(creds_data))

token = get_access_token_from_keychain()
assert token == "test-token-invalid-expiry"