From 83554caa28b23063c1e5ec0519544ac6380ff8dc Mon Sep 17 00:00:00 2001 From: claude-code Date: Sat, 7 Feb 2026 09:43:39 +0000 Subject: [PATCH] Add Linux support for automatic credential retrieval --- README.md | 2 +- src/claude_code_transcripts/__init__.py | 93 ++++++++--- tests/test_credentials.py | 204 ++++++++++++++++++++++++ 3 files changed, 275 insertions(+), 24 deletions(-) create mode 100644 tests/test_credentials.py diff --git a/README.md b/README.md index 25572c9..9a9821d 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/claude_code_transcripts/__init__.py b/src/claude_code_transcripts/__init__.py index e4854a3..9fc434c 100644 --- a/src/claude_code_transcripts/__init__.py +++ b/src/claude_code_transcripts/__init__.py @@ -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 diff --git a/tests/test_credentials.py b/tests/test_credentials.py new file mode 100644 index 0000000..bfefde1 --- /dev/null +++ b/tests/test_credentials.py @@ -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"