pydantic-claude-code 0.1.0__tar.gz → 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (25) hide show
  1. {pydantic_claude_code-0.1.0 → pydantic_claude_code-0.2.0}/PKG-INFO +39 -8
  2. {pydantic_claude_code-0.1.0 → pydantic_claude_code-0.2.0}/README.md +37 -7
  3. {pydantic_claude_code-0.1.0 → pydantic_claude_code-0.2.0}/examples/basic.py +2 -2
  4. {pydantic_claude_code-0.1.0 → pydantic_claude_code-0.2.0}/pyproject.toml +2 -1
  5. {pydantic_claude_code-0.1.0 → pydantic_claude_code-0.2.0}/src/pydantic_ai_claude_code/__init__.py +4 -2
  6. {pydantic_claude_code-0.1.0 → pydantic_claude_code-0.2.0}/src/pydantic_ai_claude_code/flow.py +2 -2
  7. {pydantic_claude_code-0.1.0 → pydantic_claude_code-0.2.0}/src/pydantic_ai_claude_code/provider.py +6 -6
  8. pydantic_claude_code-0.2.0/src/pydantic_ai_claude_code/storage.py +135 -0
  9. pydantic_claude_code-0.2.0/tests/test_keyring_store.py +77 -0
  10. {pydantic_claude_code-0.1.0 → pydantic_claude_code-0.2.0}/tests/test_storage.py +2 -2
  11. pydantic_claude_code-0.1.0/src/pydantic_ai_claude_code/storage.py +0 -50
  12. {pydantic_claude_code-0.1.0 → pydantic_claude_code-0.2.0}/.gitignore +0 -0
  13. {pydantic_claude_code-0.1.0 → pydantic_claude_code-0.2.0}/LICENSE +0 -0
  14. {pydantic_claude_code-0.1.0 → pydantic_claude_code-0.2.0}/src/pydantic_ai_claude_code/__main__.py +0 -0
  15. {pydantic_claude_code-0.1.0 → pydantic_claude_code-0.2.0}/src/pydantic_ai_claude_code/auth.py +0 -0
  16. {pydantic_claude_code-0.1.0 → pydantic_claude_code-0.2.0}/src/pydantic_ai_claude_code/config.py +0 -0
  17. {pydantic_claude_code-0.1.0 → pydantic_claude_code-0.2.0}/src/pydantic_ai_claude_code/credentials.py +0 -0
  18. {pydantic_claude_code-0.1.0 → pydantic_claude_code-0.2.0}/src/pydantic_ai_claude_code/model.py +0 -0
  19. {pydantic_claude_code-0.1.0 → pydantic_claude_code-0.2.0}/src/pydantic_ai_claude_code/py.typed +0 -0
  20. {pydantic_claude_code-0.1.0 → pydantic_claude_code-0.2.0}/tests/test_callback_server.py +0 -0
  21. {pydantic_claude_code-0.1.0 → pydantic_claude_code-0.2.0}/tests/test_credentials.py +0 -0
  22. {pydantic_claude_code-0.1.0 → pydantic_claude_code-0.2.0}/tests/test_flow.py +0 -0
  23. {pydantic_claude_code-0.1.0 → pydantic_claude_code-0.2.0}/tests/test_integration.py +0 -0
  24. {pydantic_claude_code-0.1.0 → pydantic_claude_code-0.2.0}/tests/test_model.py +0 -0
  25. {pydantic_claude_code-0.1.0 → pydantic_claude_code-0.2.0}/tests/test_provider.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pydantic-claude-code
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Use your Claude Code subscription from a Pydantic AI Agent, with full tool support
5
5
  Project-URL: Repository, https://github.com/mpfaffenberger/pydantic-ai-claude-code
6
6
  Project-URL: Homepage, https://pypi.org/project/pydantic-claude-code/
@@ -14,6 +14,7 @@ Classifier: Programming Language :: Python :: 3.11
14
14
  Classifier: Programming Language :: Python :: 3.12
15
15
  Classifier: Programming Language :: Python :: 3.13
16
16
  Requires-Python: <3.15,>=3.11
17
+ Requires-Dist: keyring<26,>=23
17
18
  Requires-Dist: pydantic-ai-slim[anthropic]<3,>=2.31.0
18
19
  Provides-Extra: dev
19
20
  Requires-Dist: pytest-asyncio>=0.23.1; extra == 'dev'
@@ -36,8 +37,8 @@ The repo is `mpfaffenberger/pydantic-ai-claude-code` and the import is
36
37
  pydantic-ai gained a Codex OAuth path where `openai-codex:gpt-6-astra` just
37
38
  works against a ChatGPT subscription. This wheel brings the same experience to
38
39
  Claude: authenticate once, then run pydantic-ai agents against your Claude
39
- subscription, using `claude-sonnet-4-5`, `claude-opus-5`, or whatever model you
40
- subscribe to.
40
+ subscription, using `claude-fable-5-1`, `claude-sonnet-5`, `claude-opus-5`, or
41
+ whatever model you subscribe to.
41
42
 
42
43
  Two deliberate design choices distinguish this from a fork of pydantic-ai:
43
44
 
@@ -64,7 +65,7 @@ async def main() -> None:
64
65
  await login()
65
66
 
66
67
  provider = ClaudeCodeProvider() # loads the stored tokens
67
- agent = Agent(provider.model('claude-sonnet-4-5'))
68
+ agent = Agent(provider.model('claude-fable-5-1'))
68
69
 
69
70
  result = await agent.run('Say hi in three words.')
70
71
  print(result.data)
@@ -78,7 +79,7 @@ asyncio.run(main())
78
79
  from pydantic_ai import Agent
79
80
 
80
81
  provider = ClaudeCodeProvider()
81
- agent = Agent(provider.model('claude-sonnet-4-5'))
82
+ agent = Agent(provider.model('claude-fable-5-1'))
82
83
 
83
84
  @agent.tool_plain
84
85
  def add(a: int, b: int) -> int:
@@ -90,6 +91,32 @@ Tools defined on the agent are passed to the API as standard Anthropic tool
90
91
  definitions, and structured output works the same way as with the built-in
91
92
  `anthropic` provider. That's the whole point of the wheel.
92
93
 
94
+ ### Structured output
95
+
96
+ ```python
97
+ from pydantic import BaseModel
98
+ from pydantic_ai import Agent
99
+
100
+ class Weather(BaseModel):
101
+ city: str
102
+ temperature_c: float
103
+
104
+ agent = Agent(ClaudeCodeProvider().model('claude-fable-5-1'), output_type=Weather)
105
+ result = await agent.run('Weather in Paris right now?')
106
+ assert result.output.city == 'Paris'
107
+ ```
108
+
109
+ ### Streaming
110
+
111
+ ```python
112
+ from pydantic_ai import Agent
113
+
114
+ agent = Agent(ClaudeCodeProvider().model('claude-fable-5-1'))
115
+ async with agent.run_stream('Count from 1 to 3.') as stream:
116
+ async for chunk in stream.stream_text():
117
+ print(chunk, end='', flush=True)
118
+ ```
119
+
93
120
  ## How auth works
94
121
 
95
122
  The flow uses the same shared OAuth client the Claude Code CLI uses:
@@ -98,14 +125,18 @@ The flow uses the same shared OAuth client the Claude Code CLI uses:
98
125
  - Token URL: `https://platform.claude.com/v1/oauth/token`
99
126
  - Scopes: `org:create_api_key user:profile user:inference`
100
127
 
101
- Tokens are stored in (overridable via `CLAUDE_CODE_AUTH_FILE`):
128
+ Tokens are saved to the OS keyring by default: the Keychain on macOS, Credential
129
+ Manager on Windows, and Secret Service on Linux. On machines without a keychain,
130
+ credentials fall back to a JSON file (overridable via `CLAUDE_CODE_AUTH_FILE`):
102
131
 
103
132
  ```
104
133
  ~/.local/share/pydantic-ai-claude-code/auth.json
105
134
  ```
106
135
 
107
- The file is written with `0o600` permissions and only ever contains what the
108
- issuer gave us. We never read the CLI's own credential files.
136
+ The fallback file is written with `0644` permissions and only ever contains what
137
+ the issuer gave us. We never read the CLI's own credential files.
138
+
139
+ Force a backend with the `CLAUDE_CODE_CREDENTIALS` env var: `keyring` or `file`.
109
140
 
110
141
  Refreshes happen automatically in the background: the auth shim refreshes
111
142
  before expiry and retries once on a 401, exactly like the codex provider does.
@@ -13,8 +13,8 @@ The repo is `mpfaffenberger/pydantic-ai-claude-code` and the import is
13
13
  pydantic-ai gained a Codex OAuth path where `openai-codex:gpt-6-astra` just
14
14
  works against a ChatGPT subscription. This wheel brings the same experience to
15
15
  Claude: authenticate once, then run pydantic-ai agents against your Claude
16
- subscription, using `claude-sonnet-4-5`, `claude-opus-5`, or whatever model you
17
- subscribe to.
16
+ subscription, using `claude-fable-5-1`, `claude-sonnet-5`, `claude-opus-5`, or
17
+ whatever model you subscribe to.
18
18
 
19
19
  Two deliberate design choices distinguish this from a fork of pydantic-ai:
20
20
 
@@ -41,7 +41,7 @@ async def main() -> None:
41
41
  await login()
42
42
 
43
43
  provider = ClaudeCodeProvider() # loads the stored tokens
44
- agent = Agent(provider.model('claude-sonnet-4-5'))
44
+ agent = Agent(provider.model('claude-fable-5-1'))
45
45
 
46
46
  result = await agent.run('Say hi in three words.')
47
47
  print(result.data)
@@ -55,7 +55,7 @@ asyncio.run(main())
55
55
  from pydantic_ai import Agent
56
56
 
57
57
  provider = ClaudeCodeProvider()
58
- agent = Agent(provider.model('claude-sonnet-4-5'))
58
+ agent = Agent(provider.model('claude-fable-5-1'))
59
59
 
60
60
  @agent.tool_plain
61
61
  def add(a: int, b: int) -> int:
@@ -67,6 +67,32 @@ Tools defined on the agent are passed to the API as standard Anthropic tool
67
67
  definitions, and structured output works the same way as with the built-in
68
68
  `anthropic` provider. That's the whole point of the wheel.
69
69
 
70
+ ### Structured output
71
+
72
+ ```python
73
+ from pydantic import BaseModel
74
+ from pydantic_ai import Agent
75
+
76
+ class Weather(BaseModel):
77
+ city: str
78
+ temperature_c: float
79
+
80
+ agent = Agent(ClaudeCodeProvider().model('claude-fable-5-1'), output_type=Weather)
81
+ result = await agent.run('Weather in Paris right now?')
82
+ assert result.output.city == 'Paris'
83
+ ```
84
+
85
+ ### Streaming
86
+
87
+ ```python
88
+ from pydantic_ai import Agent
89
+
90
+ agent = Agent(ClaudeCodeProvider().model('claude-fable-5-1'))
91
+ async with agent.run_stream('Count from 1 to 3.') as stream:
92
+ async for chunk in stream.stream_text():
93
+ print(chunk, end='', flush=True)
94
+ ```
95
+
70
96
  ## How auth works
71
97
 
72
98
  The flow uses the same shared OAuth client the Claude Code CLI uses:
@@ -75,14 +101,18 @@ The flow uses the same shared OAuth client the Claude Code CLI uses:
75
101
  - Token URL: `https://platform.claude.com/v1/oauth/token`
76
102
  - Scopes: `org:create_api_key user:profile user:inference`
77
103
 
78
- Tokens are stored in (overridable via `CLAUDE_CODE_AUTH_FILE`):
104
+ Tokens are saved to the OS keyring by default: the Keychain on macOS, Credential
105
+ Manager on Windows, and Secret Service on Linux. On machines without a keychain,
106
+ credentials fall back to a JSON file (overridable via `CLAUDE_CODE_AUTH_FILE`):
79
107
 
80
108
  ```
81
109
  ~/.local/share/pydantic-ai-claude-code/auth.json
82
110
  ```
83
111
 
84
- The file is written with `0o600` permissions and only ever contains what the
85
- issuer gave us. We never read the CLI's own credential files.
112
+ The fallback file is written with `0644` permissions and only ever contains what
113
+ the issuer gave us. We never read the CLI's own credential files.
114
+
115
+ Force a backend with the `CLAUDE_CODE_CREDENTIALS` env var: `keyring` or `file`.
86
116
 
87
117
  Refreshes happen automatically in the background: the auth shim refreshes
88
118
  before expiry and retries once on a 401, exactly like the codex provider does.
@@ -11,12 +11,12 @@ import asyncio
11
11
 
12
12
  from pydantic_ai import Agent
13
13
 
14
- from pydantic_ai_claude_code import ClaudeCodeProvider, ClaudeCodeTokenStore, login
14
+ from pydantic_ai_claude_code import ClaudeCodeProvider, default_store, login
15
15
 
16
16
 
17
17
  async def main() -> None:
18
18
  # Only run the browser flow when no usable credentials are stored yet.
19
- if ClaudeCodeTokenStore().load() is None:
19
+ if default_store().load() is None:
20
20
  await login()
21
21
 
22
22
  provider = ClaudeCodeProvider()
@@ -4,11 +4,12 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "pydantic-claude-code"
7
- version = "0.1.0"
7
+ version = "0.2.0"
8
8
  description = "Use your Claude Code subscription from a Pydantic AI Agent, with full tool support"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11,<3.15"
11
11
  dependencies = [
12
+ "keyring>=23,<26",
12
13
  "pydantic-ai-slim[anthropic]>=2.31.0,<3",
13
14
  ]
14
15
  license = { text = "MIT" }
@@ -6,7 +6,7 @@ Quick start:
6
6
  from pydantic_ai_claude_code import ClaudeCodeProvider
7
7
 
8
8
  provider = ClaudeCodeProvider()
9
- agent = Agent(provider.model('claude-sonnet-4-5'))
9
+ agent = Agent(provider.model('claude-fable-5-1'))
10
10
  """
11
11
 
12
12
  from .credentials import ClaudeCodeCredentials
@@ -19,7 +19,7 @@ from .flow import (
19
19
  )
20
20
  from .model import ClaudeCodeModel
21
21
  from .provider import ClaudeCodeProvider
22
- from .storage import ClaudeCodeTokenStore, default_auth_path
22
+ from .storage import ClaudeCodeTokenStore, KeyringTokenStore, default_auth_path, default_store
23
23
 
24
24
  __all__ = [
25
25
  "ClaudeCodeCredentials",
@@ -27,7 +27,9 @@ __all__ = [
27
27
  "ClaudeCodeOAuthFlow",
28
28
  "ClaudeCodeProvider",
29
29
  "ClaudeCodeTokenStore",
30
+ "KeyringTokenStore",
30
31
  "default_auth_path",
32
+ "default_store",
31
33
  "exchange_code",
32
34
  "login",
33
35
  "parse_pasteback",
@@ -184,10 +184,10 @@ async def login(*, store: Any = None) -> ClaudeCodeCredentials:
184
184
  Returns:
185
185
  The credentials that were persisted.
186
186
  """
187
- from .storage import ClaudeCodeTokenStore
187
+ from .storage import default_store
188
188
 
189
189
  if store is None:
190
- store = ClaudeCodeTokenStore()
190
+ store = default_store()
191
191
  server = start_login_callback_server()
192
192
  try:
193
193
  redirect_uri = f"{config.REDIRECT_HOST}:{server.server_address[1]}/{config.REDIRECT_PATH}"
@@ -15,7 +15,7 @@ from . import config
15
15
  from .model import ClaudeCodeModel
16
16
  from .auth import _ClaudeCodeAuth
17
17
  from .credentials import ClaudeCodeCredentials
18
- from .storage import ClaudeCodeTokenStore
18
+ from .storage import TokenStore, default_store
19
19
 
20
20
 
21
21
  class ClaudeCodeProvider(AnthropicProvider):
@@ -34,7 +34,7 @@ class ClaudeCodeProvider(AnthropicProvider):
34
34
  self,
35
35
  credentials: ClaudeCodeCredentials | None = None,
36
36
  *,
37
- store: ClaudeCodeTokenStore | None = None,
37
+ store: TokenStore | None = None,
38
38
  on_credentials_refresh: Any = None,
39
39
  http_client: httpx2.AsyncClient | None = None,
40
40
  base_url: str | None = None,
@@ -43,7 +43,7 @@ class ClaudeCodeProvider(AnthropicProvider):
43
43
 
44
44
  Args:
45
45
  credentials: Credentials to use. When omitted, they are loaded from
46
- the store (which defaults to the standard token file).
46
+ the store (which defaults to the OS keyring, falling back to a file).
47
47
  store: Token store used to persist refreshed credentials. When
48
48
  credentials are loaded from the store, refreshes are persisted
49
49
  back to it automatically.
@@ -54,7 +54,7 @@ class ClaudeCodeProvider(AnthropicProvider):
54
54
  """
55
55
  if credentials is None:
56
56
  if store is None:
57
- store = ClaudeCodeTokenStore()
57
+ store = default_store()
58
58
  creds = store.load()
59
59
  if creds is None:
60
60
  raise UserError(
@@ -98,9 +98,9 @@ class ClaudeCodeProvider(AnthropicProvider):
98
98
  def from_token_store(
99
99
  cls,
100
100
  *,
101
- store: ClaudeCodeTokenStore | None = None,
101
+ store: TokenStore | None = None,
102
102
  http_client: httpx2.AsyncClient | None = None,
103
103
  base_url: str | None = None,
104
104
  ) -> "ClaudeCodeProvider":
105
- """Build a provider from the stored token file."""
105
+ """Build a provider from the default store."""
106
106
  return cls(store=store, http_client=http_client, base_url=base_url)
@@ -0,0 +1,135 @@
1
+ """Token storage for Claude Code credentials.
2
+
3
+ By default credentials live in the OS keyring: the Keychain on macOS, Credential
4
+ Manager on Windows, Secret Service on Linux. When no keychain is available, a
5
+ JSON file with `0644` permissions is used instead. Force a backend with the
6
+ `CLAUDE_CODE_CREDENTIALS` env var (`keyring` or `file`); the file path honors
7
+ `CLAUDE_CODE_AUTH_FILE`.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import os
14
+ from pathlib import Path
15
+ from typing import Protocol
16
+
17
+ from pydantic_ai.exceptions import UserError
18
+
19
+ from .credentials import ClaudeCodeCredentials
20
+
21
+ _FILE_PERMISSIONS = 0o644
22
+
23
+
24
+ class TokenStore(Protocol):
25
+ """Sibling store backends must both implement this shape."""
26
+
27
+ def load(self) -> ClaudeCodeCredentials | None:
28
+ """Return stored credentials, or `None` when nothing is stored yet."""
29
+ ...
30
+
31
+ def save(self, credentials: ClaudeCodeCredentials) -> None:
32
+ """Persist credentials for the next run."""
33
+ ...
34
+
35
+
36
+ def default_auth_path() -> Path:
37
+ """The fallback token file path, honoring the `CLAUDE_CODE_AUTH_FILE` override."""
38
+ env_path = os.environ.get("CLAUDE_CODE_AUTH_FILE")
39
+ if env_path:
40
+ return Path(env_path).expanduser()
41
+ data_dir = Path(os.environ.get("XDG_DATA_HOME", "") or (Path.home() / ".local" / "share"))
42
+ return data_dir / "pydantic-ai-claude-code" / "auth.json"
43
+
44
+
45
+ def default_store() -> TokenStore:
46
+ """The default store: keyring when available, otherwise the JSON file."""
47
+ backend = os.environ.get("CLAUDE_CODE_CREDENTIALS", "auto").lower()
48
+ if backend not in ("auto", "keyring", "file"):
49
+ raise UserError(f"Unknown CLAUDE_CODE_CREDENTIALS backend {backend!r}; expected 'auto', 'keyring', or 'file'.")
50
+ if backend == "file":
51
+ return ClaudeCodeTokenStore()
52
+ if backend == "keyring" or _keyring_available():
53
+ return KeyringTokenStore()
54
+ return ClaudeCodeTokenStore()
55
+
56
+
57
+ class ClaudeCodeTokenStore:
58
+ """JSON-file store with `0644` permissions, the no-keychain fallback."""
59
+
60
+ def __init__(self, path: str | Path | None = None) -> None:
61
+ self.path = Path(path) if path is not None else default_auth_path()
62
+
63
+ def load(self) -> ClaudeCodeCredentials | None:
64
+ """Return stored credentials, or `None` when nothing is stored yet."""
65
+ try:
66
+ data = json.loads(self.path.read_text())
67
+ except FileNotFoundError:
68
+ return None
69
+ except (OSError, json.JSONDecodeError) as exc:
70
+ raise UserError(f"Unable to read Claude Code credentials from {str(self.path)!r}.") from exc
71
+ return ClaudeCodeCredentials.from_token_file(data)
72
+
73
+ def save(self, credentials: ClaudeCodeCredentials) -> None:
74
+ """Persist credentials atomically with `0644` permissions."""
75
+ self.path.parent.mkdir(parents=True, exist_ok=True)
76
+ tmp = self.path.with_suffix(".tmp")
77
+ tmp.write_text(json.dumps(credentials.to_wire_dict(), indent=2))
78
+ tmp.chmod(_FILE_PERMISSIONS)
79
+ os.replace(tmp, self.path)
80
+
81
+
82
+ class KeyringTokenStore:
83
+ """OS-keyring store backed by the `keyring` package.
84
+
85
+ The service name is `pydantic-ai-claude-code` and credentials are stored as
86
+ a single JSON blob under the `default` user name.
87
+ """
88
+
89
+ SERVICE = "pydantic-ai-claude-code"
90
+ USERNAME = "default"
91
+
92
+ def __init__(self, service: str = SERVICE, username: str = USERNAME) -> None:
93
+ self.service = service
94
+ self.username = username
95
+
96
+ def load(self) -> ClaudeCodeCredentials | None:
97
+ """Return stored credentials from the keyring, or `None` when absent."""
98
+ raw = self._keyring_get()
99
+ if not raw:
100
+ return None
101
+ return ClaudeCodeCredentials.from_token_file(json.loads(raw))
102
+
103
+ def save(self, credentials: ClaudeCodeCredentials) -> None:
104
+ """Persist credentials to the keyring."""
105
+ self._keyring_require()
106
+ import keyring
107
+
108
+ keyring.set_password(self.service, self.username, json.dumps(credentials.to_wire_dict()))
109
+
110
+ def _keyring_get(self) -> str | None:
111
+ self._keyring_require()
112
+ import keyring
113
+
114
+ return keyring.get_password(self.service, self.username)
115
+
116
+ @staticmethod
117
+ def _keyring_require() -> None:
118
+ try:
119
+ import keyring
120
+
121
+ keyring.get_keyring()
122
+ except Exception as exc: # noqa: BLE001 - no keychain service on this machine
123
+ raise UserError(
124
+ "No OS keyring is available. Set CLAUDE_CODE_CREDENTIALS=file to use the JSON file store."
125
+ ) from exc
126
+
127
+
128
+ def _keyring_available() -> bool:
129
+ try:
130
+ import keyring
131
+
132
+ keyring.get_keyring()
133
+ return True
134
+ except Exception: # noqa: BLE001 - probe only; the store validates again on use
135
+ return False
@@ -0,0 +1,77 @@
1
+ """Tests for keyring-backed storage and `default_store()` selection."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+
7
+ import keyring
8
+ import pytest
9
+ from pydantic_ai.exceptions import UserError
10
+
11
+ from pydantic_ai_claude_code.credentials import ClaudeCodeCredentials
12
+ from pydantic_ai_claude_code.storage import (
13
+ ClaudeCodeTokenStore,
14
+ KeyringTokenStore,
15
+ default_store,
16
+ )
17
+
18
+
19
+ @pytest.fixture
20
+ def fake_keyring(monkeypatch) -> dict[str, str]:
21
+ store: dict[str, str] = {}
22
+
23
+ def set_password(service: str, username: str, password: str) -> None:
24
+ store[(service, username)] = password
25
+
26
+ def get_password(service: str, username: str) -> str | None:
27
+ return store.get((service, username))
28
+
29
+ def get_keyring() -> object:
30
+ # Pretend a keychain backend exists.
31
+ return object()
32
+
33
+ monkeypatch.setattr(keyring, "set_password", set_password)
34
+ monkeypatch.setattr(keyring, "get_password", get_password)
35
+ monkeypatch.setattr(keyring, "get_keyring", get_keyring)
36
+ return store
37
+
38
+
39
+ def test_keyring_roundtrip(fake_keyring) -> None:
40
+ store = KeyringTokenStore()
41
+ creds = ClaudeCodeCredentials(access_token="a", refresh_token="b")
42
+ store.save(creds)
43
+ assert store.load() == creds
44
+ stored = fake_keyring[(KeyringTokenStore.SERVICE, KeyringTokenStore.USERNAME)]
45
+ assert json.loads(stored)["access_token"] == "a"
46
+
47
+
48
+ def test_default_store_prefers_keyring(fake_keyring, monkeypatch) -> None:
49
+ monkeypatch.delenv("CLAUDE_CODE_CREDENTIALS", raising=False)
50
+ assert isinstance(default_store(), KeyringTokenStore)
51
+
52
+
53
+ def test_default_store_falls_back_to_file(monkeypatch, tmp_path) -> None:
54
+ monkeypatch.delenv("CLAUDE_CODE_CREDENTIALS", raising=False)
55
+
56
+ def no_keyring() -> object:
57
+ raise RuntimeError("no keychain (headless)")
58
+
59
+ monkeypatch.setattr(keyring, "get_keyring", no_keyring)
60
+ assert isinstance(default_store(), ClaudeCodeTokenStore)
61
+ # The real (working) backend is irrelevant: with keyring out of the picture the
62
+ # file store must be used, and it must respect CLAUDE_CODE_AUTH_FILE.
63
+ monkeypatch.setenv("CLAUDE_CODE_AUTH_FILE", str(tmp_path / "custom.json"))
64
+ assert default_store().path == tmp_path / "custom.json" # type: ignore[attr-defined]
65
+
66
+
67
+ def test_default_store_env_override(monkeypatch, fake_keyring) -> None:
68
+ monkeypatch.setenv("CLAUDE_CODE_CREDENTIALS", "file")
69
+ assert isinstance(default_store(), ClaudeCodeTokenStore)
70
+ monkeypatch.setenv("CLAUDE_CODE_CREDENTIALS", "keyring")
71
+ assert isinstance(default_store(), KeyringTokenStore)
72
+
73
+
74
+ def test_default_store_bad_env_raises(monkeypatch) -> None:
75
+ monkeypatch.setenv("CLAUDE_CODE_CREDENTIALS", "bogus")
76
+ with pytest.raises(UserError):
77
+ default_store()
@@ -18,11 +18,11 @@ def test_save_load_roundtrip(tmp_path) -> None:
18
18
  assert store.load() == creds
19
19
 
20
20
 
21
- def test_save_sets_0600_perm(tmp_path) -> None:
21
+ def test_save_sets_0644_perm(tmp_path) -> None:
22
22
  store = ClaudeCodeTokenStore(path=tmp_path / "auth.json")
23
23
  store.save(ClaudeCodeCredentials(access_token="a", refresh_token="b"))
24
24
  mode = stat.S_IMODE((tmp_path / "auth.json").stat().st_mode)
25
- assert mode == 0o600
25
+ assert mode == 0o644
26
26
 
27
27
 
28
28
  def test_load_missing_returns_none(tmp_path) -> None:
@@ -1,50 +0,0 @@
1
- """Persistent token storage for Claude Code credentials.
2
-
3
- The file lives under the user's data directory, is created with `0o600`
4
- permissions, and its path can be overridden with the `CLAUDE_CODE_AUTH_FILE`
5
- environment variable.
6
- """
7
-
8
- from __future__ import annotations
9
-
10
- import json
11
- import os
12
- from pathlib import Path
13
-
14
- from pydantic_ai.exceptions import UserError
15
-
16
- from .credentials import ClaudeCodeCredentials
17
-
18
-
19
- def default_auth_path() -> Path:
20
- """The token file path, honoring the `CLAUDE_CODE_AUTH_FILE` override."""
21
- env_path = os.environ.get("CLAUDE_CODE_AUTH_FILE")
22
- if env_path:
23
- return Path(env_path).expanduser()
24
- data_dir = Path(os.environ.get("XDG_DATA_HOME", "") or (Path.home() / ".local" / "share"))
25
- return data_dir / "pydantic-ai-claude-code" / "auth.json"
26
-
27
-
28
- class ClaudeCodeTokenStore:
29
- """Read and write `ClaudeCodeCredentials` to a single JSON file."""
30
-
31
- def __init__(self, path: str | Path | None = None) -> None:
32
- self.path = Path(path) if path is not None else default_auth_path()
33
-
34
- def load(self) -> ClaudeCodeCredentials | None:
35
- """Return stored credentials, or `None` when nothing is stored yet."""
36
- try:
37
- data = json.loads(self.path.read_text())
38
- except FileNotFoundError:
39
- return None
40
- except (OSError, json.JSONDecodeError) as exc:
41
- raise UserError(f"Unable to read Claude Code credentials from {str(self.path)!r}.") from exc
42
- return ClaudeCodeCredentials.from_token_file(data)
43
-
44
- def save(self, credentials: ClaudeCodeCredentials) -> None:
45
- """Persist credentials atomically with restrictive permissions."""
46
- self.path.parent.mkdir(parents=True, exist_ok=True)
47
- tmp = self.path.with_suffix(".tmp")
48
- tmp.write_text(json.dumps(credentials.to_wire_dict(), indent=2))
49
- tmp.chmod(0o600)
50
- os.replace(tmp, self.path)