pydantic-claude-code 0.6.0__tar.gz → 0.6.2__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 (34) hide show
  1. {pydantic_claude_code-0.6.0 → pydantic_claude_code-0.6.2}/.github/workflows/ci.yml +1 -1
  2. {pydantic_claude_code-0.6.0 → pydantic_claude_code-0.6.2}/PKG-INFO +21 -14
  3. {pydantic_claude_code-0.6.0 → pydantic_claude_code-0.6.2}/README.md +16 -11
  4. {pydantic_claude_code-0.6.0 → pydantic_claude_code-0.6.2}/install.sh +2 -2
  5. {pydantic_claude_code-0.6.0 → pydantic_claude_code-0.6.2}/pyproject.toml +8 -15
  6. {pydantic_claude_code-0.6.0 → pydantic_claude_code-0.6.2}/src/pydantic_ai_claude_code/__init__.py +1 -1
  7. {pydantic_claude_code-0.6.0 → pydantic_claude_code-0.6.2}/src/pydantic_ai_claude_code/clai2.py +52 -5
  8. {pydantic_claude_code-0.6.0 → pydantic_claude_code-0.6.2}/src/pydantic_ai_claude_code/config.py +0 -1
  9. {pydantic_claude_code-0.6.0 → pydantic_claude_code-0.6.2}/src/pydantic_ai_claude_code/credentials.py +2 -2
  10. {pydantic_claude_code-0.6.0 → pydantic_claude_code-0.6.2}/src/pydantic_ai_claude_code/flow.py +44 -18
  11. {pydantic_claude_code-0.6.0 → pydantic_claude_code-0.6.2}/tests/test_clai2.py +52 -3
  12. {pydantic_claude_code-0.6.0 → pydantic_claude_code-0.6.2}/tests/test_flow.py +75 -4
  13. {pydantic_claude_code-0.6.0 → pydantic_claude_code-0.6.2}/tests/test_install.py +1 -1
  14. {pydantic_claude_code-0.6.0 → pydantic_claude_code-0.6.2}/tests/test_integration.py +2 -2
  15. {pydantic_claude_code-0.6.0 → pydantic_claude_code-0.6.2}/tests/test_updates.py +2 -4
  16. {pydantic_claude_code-0.6.0 → pydantic_claude_code-0.6.2}/uv.lock +422 -17
  17. {pydantic_claude_code-0.6.0 → pydantic_claude_code-0.6.2}/.github/workflows/publish.yml +0 -0
  18. {pydantic_claude_code-0.6.0 → pydantic_claude_code-0.6.2}/.gitignore +0 -0
  19. {pydantic_claude_code-0.6.0 → pydantic_claude_code-0.6.2}/LICENSE +0 -0
  20. {pydantic_claude_code-0.6.0 → pydantic_claude_code-0.6.2}/examples/basic.py +0 -0
  21. {pydantic_claude_code-0.6.0 → pydantic_claude_code-0.6.2}/src/pydantic_ai_claude_code/__main__.py +0 -0
  22. {pydantic_claude_code-0.6.0 → pydantic_claude_code-0.6.2}/src/pydantic_ai_claude_code/auth.py +0 -0
  23. {pydantic_claude_code-0.6.0 → pydantic_claude_code-0.6.2}/src/pydantic_ai_claude_code/model.py +0 -0
  24. {pydantic_claude_code-0.6.0 → pydantic_claude_code-0.6.2}/src/pydantic_ai_claude_code/provider.py +0 -0
  25. {pydantic_claude_code-0.6.0 → pydantic_claude_code-0.6.2}/src/pydantic_ai_claude_code/py.typed +0 -0
  26. {pydantic_claude_code-0.6.0 → pydantic_claude_code-0.6.2}/src/pydantic_ai_claude_code/storage.py +0 -0
  27. {pydantic_claude_code-0.6.0 → pydantic_claude_code-0.6.2}/src/pydantic_ai_claude_code/updates.py +0 -0
  28. {pydantic_claude_code-0.6.0 → pydantic_claude_code-0.6.2}/tests/conftest.py +0 -0
  29. {pydantic_claude_code-0.6.0 → pydantic_claude_code-0.6.2}/tests/test_callback_server.py +0 -0
  30. {pydantic_claude_code-0.6.0 → pydantic_claude_code-0.6.2}/tests/test_credentials.py +0 -0
  31. {pydantic_claude_code-0.6.0 → pydantic_claude_code-0.6.2}/tests/test_keyring_store.py +0 -0
  32. {pydantic_claude_code-0.6.0 → pydantic_claude_code-0.6.2}/tests/test_model.py +0 -0
  33. {pydantic_claude_code-0.6.0 → pydantic_claude_code-0.6.2}/tests/test_provider.py +0 -0
  34. {pydantic_claude_code-0.6.0 → pydantic_claude_code-0.6.2}/tests/test_storage.py +0 -0
@@ -34,7 +34,7 @@ jobs:
34
34
  fail-fast: false
35
35
  matrix:
36
36
  os: [ubuntu-latest]
37
- python-version: ["3.11", "3.12", "3.13", "3.14"]
37
+ python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
38
38
  include:
39
39
  - os: macos-latest
40
40
  python-version: "3.14"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pydantic-claude-code
3
- Version: 0.6.0
3
+ Version: 0.6.2
4
4
  Summary: Use your Claude Code subscription from a Pydantic AI Agent or CLAI2, 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/
@@ -10,14 +10,16 @@ License-File: LICENSE
10
10
  Classifier: License :: OSI Approved :: MIT License
11
11
  Classifier: Operating System :: OS Independent
12
12
  Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.10
13
14
  Classifier: Programming Language :: Python :: 3.11
14
15
  Classifier: Programming Language :: Python :: 3.12
15
16
  Classifier: Programming Language :: Python :: 3.13
16
- Requires-Python: <3.15,>=3.11
17
+ Classifier: Programming Language :: Python :: 3.14
18
+ Requires-Python: <3.15,>=3.10
17
19
  Requires-Dist: keyring<26,>=23
18
20
  Requires-Dist: pydantic-ai-slim[anthropic]<3,>=2.31.0
19
21
  Provides-Extra: clai2
20
- Requires-Dist: pydantic-clai2>0.52.0; extra == 'clai2'
22
+ Requires-Dist: pydantic-clai2>=0.53.0; extra == 'clai2'
21
23
  Provides-Extra: dev
22
24
  Requires-Dist: pyright>=1.1.400; extra == 'dev'
23
25
  Requires-Dist: pytest-asyncio>=0.23.1; extra == 'dev'
@@ -65,7 +67,7 @@ One line installs the latest release of the plugin, and running it again updates
65
67
  curl -fsSL https://raw.githubusercontent.com/mpfaffenberger/pydantic-ai-claude-code/main/install.sh | sh
66
68
  ```
67
69
 
68
- To pin a release, pass its tag (`... | sh -s -- v0.6.0`), or pass `main` for
70
+ To pin a release, pass its tag (`... | sh -s -- v0.6.2`), or pass `main` for
69
71
  unreleased changes. The script downloads that version of this repo and copies the `src/pydantic_ai_claude_code` folder into CLAI2's
70
72
  plugins folder as `claude_code`, replacing any older copy. Nothing to
71
73
  `pip install`: everything the plugin imports (`pydantic-ai` with Anthropic
@@ -94,26 +96,31 @@ one-liner to update. Being offline is silent. Set `CLAUDE_CODE_NO_UPDATE_CHECK=1
94
96
  to turn the check off.
95
97
 
96
98
  **CLAI2 version:** the plugin is a class-based CLAI2 `Plugin`
97
- ([pydantic/pydantic-ai#9493](https://github.com/pydantic/pydantic-ai/pull/9493),
98
- merged), so it needs a CLAI2 built from pydantic-ai `main` at or after that
99
- commit; no `pydantic-clai2` release has it yet (0.52.0 is the latest). Plugin
100
- 0.5.0 was the last `activate(host)` plugin, for CLAI2 `main` before #9493. Until
101
- a release is out, run CLAI2 from pydantic-ai's `main`:
99
+ ([pydantic/pydantic-ai#9493](https://github.com/pydantic/pydantic-ai/pull/9493)),
100
+ which needs `pydantic-clai2` 0.53.0 or later, on Python 3.10 to 3.14. Run it
101
+ without installing anything:
102
102
 
103
103
  ```bash
104
- git clone https://github.com/pydantic/pydantic-ai
105
- cd pydantic-ai
106
- uv run clai2
104
+ uvx --from pydantic-clai2 clai2
107
105
  ```
108
106
 
107
+ (The PyPI package is `pydantic-clai2`; plain `uvx clai2` looks for a package
108
+ named `clai2`, which does not exist.) Plugin 0.5.0 was the last `activate(host)`
109
+ plugin, for CLAI2 `main` before #9493.
110
+
109
111
  Or install it as a package instead, into CLAI2's environment, and point CLAI2
110
112
  at it: `uv tool install pydantic-clai2 --with pydantic-claude-code`, then
111
113
  `/plugins add claude_code pydantic_ai_claude_code`.
112
114
 
115
+
113
116
  ### Sign in
114
117
 
115
118
  Run `/login claude`. Your browser opens Claude's sign-in page, and CLAI2
116
- prints the URL in case it doesn't. Or open the settings menu with `/claude_code`
119
+ prints the URL in case it doesn't. On a machine whose browser can't reach CLAI2
120
+ (over SSH, or a remote box), open that URL on any machine and sign in. The
121
+ browser then ends on a `http://localhost:.../callback?code=...` page that fails
122
+ to load; copy that whole address and paste it at CLAI2's prompt, then press
123
+ Enter. CLAI2 counts the characters instead of echoing the code. Or open the settings menu with `/claude_code`
117
124
  (or `C` on the plugin in `/plugins`), choose **Sign-in**, and press Enter.
118
125
 
119
126
  ### Pick a model
@@ -305,7 +312,7 @@ uv run pytest
305
312
 
306
313
  To release, bump `version` in `pyproject.toml` and `__version__` in
307
314
  `src/pydantic_ai_claude_code/__init__.py` (a test checks they match), merge, and
308
- push a matching tag (`git tag v0.6.0 && git push origin v0.6.0`). The `Publish` workflow tests,
315
+ push a matching tag (`git tag v0.6.2 && git push origin v0.6.2`). The `Publish` workflow tests,
309
316
  builds, and uploads it to PyPI with the `PYPI_API_TOKEN` repository secret.
310
317
 
311
318
  The tests never touch your real keychain or token file. They run against a
@@ -38,7 +38,7 @@ One line installs the latest release of the plugin, and running it again updates
38
38
  curl -fsSL https://raw.githubusercontent.com/mpfaffenberger/pydantic-ai-claude-code/main/install.sh | sh
39
39
  ```
40
40
 
41
- To pin a release, pass its tag (`... | sh -s -- v0.6.0`), or pass `main` for
41
+ To pin a release, pass its tag (`... | sh -s -- v0.6.2`), or pass `main` for
42
42
  unreleased changes. The script downloads that version of this repo and copies the `src/pydantic_ai_claude_code` folder into CLAI2's
43
43
  plugins folder as `claude_code`, replacing any older copy. Nothing to
44
44
  `pip install`: everything the plugin imports (`pydantic-ai` with Anthropic
@@ -67,26 +67,31 @@ one-liner to update. Being offline is silent. Set `CLAUDE_CODE_NO_UPDATE_CHECK=1
67
67
  to turn the check off.
68
68
 
69
69
  **CLAI2 version:** the plugin is a class-based CLAI2 `Plugin`
70
- ([pydantic/pydantic-ai#9493](https://github.com/pydantic/pydantic-ai/pull/9493),
71
- merged), so it needs a CLAI2 built from pydantic-ai `main` at or after that
72
- commit; no `pydantic-clai2` release has it yet (0.52.0 is the latest). Plugin
73
- 0.5.0 was the last `activate(host)` plugin, for CLAI2 `main` before #9493. Until
74
- a release is out, run CLAI2 from pydantic-ai's `main`:
70
+ ([pydantic/pydantic-ai#9493](https://github.com/pydantic/pydantic-ai/pull/9493)),
71
+ which needs `pydantic-clai2` 0.53.0 or later, on Python 3.10 to 3.14. Run it
72
+ without installing anything:
75
73
 
76
74
  ```bash
77
- git clone https://github.com/pydantic/pydantic-ai
78
- cd pydantic-ai
79
- uv run clai2
75
+ uvx --from pydantic-clai2 clai2
80
76
  ```
81
77
 
78
+ (The PyPI package is `pydantic-clai2`; plain `uvx clai2` looks for a package
79
+ named `clai2`, which does not exist.) Plugin 0.5.0 was the last `activate(host)`
80
+ plugin, for CLAI2 `main` before #9493.
81
+
82
82
  Or install it as a package instead, into CLAI2's environment, and point CLAI2
83
83
  at it: `uv tool install pydantic-clai2 --with pydantic-claude-code`, then
84
84
  `/plugins add claude_code pydantic_ai_claude_code`.
85
85
 
86
+
86
87
  ### Sign in
87
88
 
88
89
  Run `/login claude`. Your browser opens Claude's sign-in page, and CLAI2
89
- prints the URL in case it doesn't. Or open the settings menu with `/claude_code`
90
+ prints the URL in case it doesn't. On a machine whose browser can't reach CLAI2
91
+ (over SSH, or a remote box), open that URL on any machine and sign in. The
92
+ browser then ends on a `http://localhost:.../callback?code=...` page that fails
93
+ to load; copy that whole address and paste it at CLAI2's prompt, then press
94
+ Enter. CLAI2 counts the characters instead of echoing the code. Or open the settings menu with `/claude_code`
90
95
  (or `C` on the plugin in `/plugins`), choose **Sign-in**, and press Enter.
91
96
 
92
97
  ### Pick a model
@@ -278,7 +283,7 @@ uv run pytest
278
283
 
279
284
  To release, bump `version` in `pyproject.toml` and `__version__` in
280
285
  `src/pydantic_ai_claude_code/__init__.py` (a test checks they match), merge, and
281
- push a matching tag (`git tag v0.6.0 && git push origin v0.6.0`). The `Publish` workflow tests,
286
+ push a matching tag (`git tag v0.6.2 && git push origin v0.6.2`). The `Publish` workflow tests,
282
287
  builds, and uploads it to PyPI with the `PYPI_API_TOKEN` repository secret.
283
288
 
284
289
  The tests never touch your real keychain or token file. They run against a
@@ -1,5 +1,5 @@
1
1
  #!/bin/sh
2
- # Install or update the Claude Code plugin for CLAI2, at the latest release (pass a tag such as v0.6.0, or main):
2
+ # Install or update the Claude Code plugin for CLAI2, at the latest release (pass a tag such as v0.6.2, or main):
3
3
  # curl -fsSL https://raw.githubusercontent.com/mpfaffenberger/pydantic-ai-claude-code/main/install.sh | sh
4
4
  set -eu
5
5
 
@@ -7,7 +7,7 @@ ref="${1:-}"
7
7
  if [ -z "$ref" ]; then
8
8
  version=$(curl -fsSL https://pypi.org/pypi/pydantic-claude-code/json | grep -o '"version": *"[^"]*"' | head -n 1 | cut -d '"' -f 4)
9
9
  if [ -z "$version" ]; then
10
- echo "Could not look up the latest release on PyPI. Pass a tag (sh -s -- v0.6.0) or main." >&2
10
+ echo "Could not look up the latest release on PyPI. Pass a tag (sh -s -- v0.6.2) or main." >&2
11
11
  exit 1
12
12
  fi
13
13
  ref="v$version"
@@ -4,10 +4,10 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "pydantic-claude-code"
7
- version = "0.6.0"
7
+ version = "0.6.2"
8
8
  description = "Use your Claude Code subscription from a Pydantic AI Agent or CLAI2, with full tool support"
9
9
  readme = "README.md"
10
- requires-python = ">=3.11,<3.15"
10
+ requires-python = ">=3.10,<3.15"
11
11
  dependencies = [
12
12
  "keyring>=23,<26",
13
13
  "pydantic-ai-slim[anthropic]>=2.31.0,<3",
@@ -16,9 +16,11 @@ license = { text = "MIT" }
16
16
  authors = [{ name = "Michael Pfaffenberger" }]
17
17
  classifiers = [
18
18
  "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3.10",
19
20
  "Programming Language :: Python :: 3.11",
20
21
  "Programming Language :: Python :: 3.12",
21
22
  "Programming Language :: Python :: 3.13",
23
+ "Programming Language :: Python :: 3.14",
22
24
  "License :: OSI Approved :: MIT License",
23
25
  "Operating System :: OS Independent",
24
26
  ]
@@ -32,11 +34,11 @@ packages = ["src/pydantic_ai_claude_code"]
32
34
 
33
35
  [tool.ruff]
34
36
  line-length = 120
35
- target-version = "py311"
37
+ target-version = "py310"
36
38
 
37
39
  [tool.pyright]
38
40
  include = ["src"]
39
- pythonVersion = "3.11"
41
+ pythonVersion = "3.10"
40
42
 
41
43
  [tool.pytest.ini_options]
42
44
  testpaths = ["tests"]
@@ -45,9 +47,9 @@ addopts = "-q"
45
47
 
46
48
  [project.optional-dependencies]
47
49
  # Only for installing the plugin as a package; the usual install copies the package folder into CLAI2's
48
- # plugins folder. Either way it needs the class-based `Plugin` API, which 0.52.0 lacks:
50
+ # plugins folder. Either way it needs the class-based `Plugin` API, first released in 0.53.0:
49
51
  # https://github.com/pydantic/pydantic-ai/pull/9493.
50
- clai2 = ["pydantic-clai2>0.52.0"]
52
+ clai2 = ["pydantic-clai2>=0.53.0"]
51
53
  dev = [
52
54
  "pytest>=8.3.4",
53
55
  "pytest-asyncio>=0.23.1",
@@ -55,12 +57,3 @@ dev = [
55
57
  "pyright>=1.1.400",
56
58
  ]
57
59
 
58
- # Until a pydantic-clai2 release includes pydantic/pydantic-ai#9493 (class-based `Plugin`s, which also carry #9558's
59
- # `PluginLogin.models` and `ModelProvider.settings_from`), develop and test the plugin against #9493's merge commit on
60
- # main. CLAI2 pins Pydantic AI and the harness to its own build, so all four come from the same commit. Remove this
61
- # table once a release has it.
62
- [tool.uv.sources]
63
- pydantic-clai2 = { git = "https://github.com/pydantic/pydantic-ai", rev = "e4e0da04f0c698c5837cc7099711eebaff3f46c3", subdirectory = "src/pydantic_clai2" }
64
- pydantic-ai-harness = { git = "https://github.com/pydantic/pydantic-ai", rev = "e4e0da04f0c698c5837cc7099711eebaff3f46c3", subdirectory = "src/pydantic_ai_harness" }
65
- pydantic-ai-slim = { git = "https://github.com/pydantic/pydantic-ai", rev = "e4e0da04f0c698c5837cc7099711eebaff3f46c3", subdirectory = "pydantic_ai_slim" }
66
- pydantic-graph = { git = "https://github.com/pydantic/pydantic-ai", rev = "e4e0da04f0c698c5837cc7099711eebaff3f46c3", subdirectory = "pydantic_graph" }
@@ -26,7 +26,7 @@ from .model import ClaudeCodeModel
26
26
  from .provider import ClaudeCodeProvider
27
27
  from .storage import ClaudeCodeTokenStore, KeyringTokenStore, default_auth_path, default_store
28
28
 
29
- __version__ = "0.6.0"
29
+ __version__ = "0.6.2"
30
30
  """Matches `version` in pyproject.toml (a test checks), so a copied folder knows its release."""
31
31
 
32
32
 
@@ -14,17 +14,20 @@ Plugin settings, which are plaintext SQLite, hold only the storage choice.
14
14
  from __future__ import annotations
15
15
 
16
16
  import asyncio
17
+ import sys
17
18
  from collections.abc import Callable, Sequence
18
- from typing import get_args
19
+ from typing import IO, get_args
19
20
 
20
21
  from pydantic import BaseModel, ConfigDict, Field, ValidationError
21
22
  from pydantic_ai.exceptions import UserError
22
23
  from pydantic_clai2.commands import Command
24
+ from termflow.tui.terminal import raw_mode
23
25
  from pydantic_clai2.plugins import ModelProvider, Plugin, PluginHost, PluginLogin, SessionEnd, SessionStart, TurnEnd
26
+ from pydantic_clai2.ui.menus.menu_worker import menu_key, run_worker
24
27
  from pydantic_clai2.ui.menus.field_menu import TERMINAL, FieldMenu, FieldRow, Runners, first_error, run_flow_async
25
28
 
26
29
  from . import __version__, config, updates
27
- from .flow import login
30
+ from .flow import login, parse_pasteback
28
31
  from .model import ClaudeCodeModel
29
32
  from .provider import ClaudeCodeProvider
30
33
  from .storage import Backend, ClaudeCodeTokenStore, TokenStore, default_store
@@ -164,12 +167,55 @@ def status(source: ClaudeCodeConfig) -> str:
164
167
 
165
168
 
166
169
  async def sign_in(source: ClaudeCodeConfig, show_url: Callable[[str], None]) -> str:
167
- """Sign in through the browser and save the tokens to the chosen store."""
168
- await login(store=source.store(), on_url=show_url)
170
+ """Sign in through the browser, or a pasted redirect address, and save the tokens to the chosen store."""
171
+ await login(store=source.store(), on_url=show_url, read_pasteback=lambda: run_worker(read_pasted_url))
169
172
  first = config.MODELS[0]
170
173
  return f"Signed in to Claude Code. Choose a model in /add_model > {PREFIX}, or run /model {PREFIX}:{first}."
171
174
 
172
175
 
176
+ PASTE_PROMPT = "Or paste the address your browser ended on, then Enter (Esc cancels): "
177
+
178
+
179
+ def read_pasted_url(keys: Callable[[], str] = menu_key, output: IO[str] = sys.stdout) -> str | None:
180
+ """Read a pasted redirect address on one line, under the printed sign-in URL; `None` on Esc.
181
+
182
+ The address carries the authorization code, so it is counted, not echoed: a long URL would also wrap
183
+ and break the one-line redraw. `keys` is `run_worker`'s `menu_key`, which turns into `ctrl-c` when the
184
+ browser's callback wins and `login` cancels this read.
185
+ """
186
+ chars: list[str] = []
187
+ note = ""
188
+
189
+ def draw() -> None:
190
+ shown = note or (f"[{len(chars)} characters]" if chars else "")
191
+ output.write(f"\r\x1b[K{PASTE_PROMPT}{shown}")
192
+ output.flush()
193
+
194
+ with raw_mode():
195
+ draw()
196
+ while True:
197
+ key = keys()
198
+ if key in ("escape", "ctrl-c"):
199
+ output.write("\r\n")
200
+ return None
201
+ if key == "enter":
202
+ if parse_pasteback("".join(chars)) is not None:
203
+ output.write("\r\n")
204
+ return "".join(chars)
205
+ note = "no authorization code in that; paste the whole address"
206
+ elif key == "backspace":
207
+ chars[-1:] = []
208
+ note = ""
209
+ elif key == "ctrl-u":
210
+ chars, note = [], ""
211
+ elif len(key) == 1 and key.isprintable():
212
+ chars.append(key)
213
+ note = ""
214
+ else:
215
+ continue
216
+ draw()
217
+
218
+
173
219
  def sign_out(source: ClaudeCodeConfig) -> str:
174
220
  """Forget the stored tokens; runs with `claude-code:` models then ask to sign in again."""
175
221
  source.store().delete()
@@ -207,7 +253,8 @@ class ClaudeCodePlugin(Plugin[ClaudeCodeSettings]):
207
253
 
208
254
  def _show_url(self, url: str) -> None:
209
255
  self.host.console.print(
210
- f"Opening your browser to sign in to Claude Code. If it does not open, visit:\n{url}",
256
+ "Opening your browser to sign in to Claude Code. If it does not open, or this machine's browser "
257
+ f"cannot reach it (SSH, a remote box), open this anywhere and sign in:\n{url}",
211
258
  markup=False,
212
259
  soft_wrap=True,
213
260
  )
@@ -42,7 +42,6 @@ REDIRECT_HOST = "http://localhost"
42
42
  REDIRECT_PATH = "callback"
43
43
  CALLBACK_PORT_RANGE = (8765, 8795)
44
44
  CALLBACK_TIMEOUT = 180
45
- PASTEBACK_SCHEMES = ("claude://",)
46
45
 
47
46
  # Request headers that must accompany Claude Code subscription tokens. `anthropic-beta` is
48
47
  # appended (never replaced) by the auth layer so feature betas set by pydantic-ai survive.
@@ -3,7 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import time
6
- from datetime import UTC, datetime
6
+ from datetime import datetime, timezone
7
7
 
8
8
  from pydantic import BaseModel, SecretStr
9
9
 
@@ -59,7 +59,7 @@ class ClaudeCodeCredentials(BaseModel):
59
59
  expires_at = None
60
60
  if expires_in := root.get("expires_in"):
61
61
  if isinstance(expires_in, (int, float)) and not isinstance(expires_in, bool):
62
- expires_at = datetime.fromtimestamp(time.time() + float(expires_in), tz=UTC)
62
+ expires_at = datetime.fromtimestamp(time.time() + float(expires_in), tz=timezone.utc)
63
63
  return cls(
64
64
  access_token=SecretStr(access_token),
65
65
  refresh_token=SecretStr(refresh_token),
@@ -1,8 +1,8 @@
1
1
  """Authorization-code PKCE flow for Claude Code credentials.
2
2
 
3
3
  The browser flow hosts a short-lived localhost callback server, the same shape as
4
- the Codex provider's `_REDIRECT_URI`. Both the `claude://` pasteback scheme and a
5
- plain localhost redirect are supported.
4
+ the Codex provider's `_REDIRECT_URI`. When the browser cannot reach it (SSH, a remote
5
+ box), the user can paste the address the browser ended on instead: see `read_pasteback`.
6
6
  """
7
7
 
8
8
  from __future__ import annotations
@@ -13,7 +13,7 @@ import hashlib
13
13
  import secrets
14
14
  import threading
15
15
  import webbrowser
16
- from collections.abc import Callable
16
+ from collections.abc import Awaitable, Callable
17
17
  from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
18
18
  from urllib.parse import parse_qs, urlencode
19
19
 
@@ -124,20 +124,20 @@ def _token_headers() -> dict[str, str]:
124
124
 
125
125
 
126
126
  def parse_pasteback(raw: str) -> tuple[str, str] | None:
127
- """Parse a `claude://oauth/callback?...` paste back into `(code, state)`.
127
+ """Parse what the user pasted into `(code, state)`; `None` when it holds no authorization code.
128
128
 
129
- Returns `None` when the input isn't a pasteback URL.
129
+ Accepts the address the browser ended on (`http://localhost:PORT/callback?code=...&state=...`, or
130
+ `claude://...`), just its query string, or the `code#state` form Anthropic's code page shows.
130
131
  """
131
- if not raw.startswith(config.PASTEBACK_SCHEMES):
132
+ text = raw.strip()
133
+ if "?" in text or text.startswith("code="):
134
+ params = parse_qs(text.partition("?")[2] if "?" in text else text)
135
+ code, state = params.get("code", [""])[0], params.get("state", [""])[0]
136
+ elif "#" in text:
137
+ code, _, state = text.partition("#")
138
+ else:
132
139
  return None
133
- try:
134
- query = raw.partition("?")[2]
135
- params = parse_qs(query)
136
- except ValueError:
137
- return None
138
- code = params.get("code", [""])[0]
139
- state = params.get("state", [""])[0]
140
- if not code:
140
+ if not code or any(char.isspace() for char in code + state):
141
141
  return None
142
142
  return code, state
143
143
 
@@ -182,7 +182,10 @@ def _print_url(url: str) -> None:
182
182
 
183
183
 
184
184
  async def login(
185
- *, store: TokenStore | None = None, on_url: Callable[[str], None] = _print_url
185
+ *,
186
+ store: TokenStore | None = None,
187
+ on_url: Callable[[str], None] = _print_url,
188
+ read_pasteback: Callable[[], Awaitable[str | None]] | None = None,
186
189
  ) -> ClaudeCodeCredentials:
187
190
  """Run the full OAuth flow and persist the minted credentials.
188
191
 
@@ -191,6 +194,10 @@ async def login(
191
194
  Args:
192
195
  store: The token store to write to. Defaults to the standard store.
193
196
  on_url: Shows the authorization URL, in case the browser does not open. Prints it by default.
197
+ read_pasteback: Reads the address the browser ended on, for a browser that cannot reach this
198
+ machine's localhost callback (SSH, a remote box). Returns `None` to cancel. Runs alongside
199
+ the callback, which cancels it when the browser gets through first. Without it the
200
+ callback is the only way in, and it times out.
194
201
 
195
202
  Returns:
196
203
  The credentials that were persisted.
@@ -198,6 +205,9 @@ async def login(
198
205
  if store is None:
199
206
  store = default_store()
200
207
  server = start_login_callback_server()
208
+ timeout = None if read_pasteback is not None else config.CALLBACK_TIMEOUT
209
+ callback = asyncio.ensure_future(asyncio.to_thread(server.received.wait, timeout))
210
+ paste = asyncio.ensure_future(read_pasteback()) if read_pasteback is not None else None
201
211
  try:
202
212
  redirect_uri = f"{config.REDIRECT_HOST}:{server.server_address[1]}/{config.REDIRECT_PATH}"
203
213
  flow = ClaudeCodeOAuthFlow(redirect_uri=redirect_uri)
@@ -206,9 +216,13 @@ async def login(
206
216
  # Launch the browser off the event path: on some macOS setups `webbrowser.open`
207
217
  # hangs until the UI settles, which would starve the callback wait below.
208
218
  threading.Thread(target=_open_browser, args=(url,), daemon=True).start()
209
- if not await asyncio.to_thread(server.received.wait, config.CALLBACK_TIMEOUT):
219
+ await asyncio.wait({callback} if paste is None else {callback, paste}, return_when=asyncio.FIRST_COMPLETED)
220
+ if server.query is not None:
221
+ code, state = _parse_callback_query(server.query)
222
+ elif paste is not None and paste.done():
223
+ code, state = _pasted(paste.result())
224
+ else:
210
225
  raise UserError("Claude Code OAuth callback timed out.")
211
- code, state = _parse_callback_query(server.query)
212
226
  if state and state != flow.state:
213
227
  raise UserError("Claude Code OAuth state mismatch; the redirect may be a replay.")
214
228
  if not code:
@@ -217,12 +231,24 @@ async def login(
217
231
  finally:
218
232
  server.shutdown()
219
233
  server.server_close()
220
- # Release the waiting thread at once when the login was cancelled.
234
+ # Release the waiting thread at once when the login was cancelled or a paste won.
221
235
  server.received.set()
236
+ if paste is not None and not paste.done():
237
+ paste.cancel()
238
+ await asyncio.gather(callback, *(() if paste is None else (paste,)), return_exceptions=True)
222
239
  store.save(credentials)
223
240
  return credentials
224
241
 
225
242
 
243
+ def _pasted(text: str | None) -> tuple[str, str]:
244
+ if text is None:
245
+ raise UserError("Claude Code sign-in cancelled.")
246
+ parsed = parse_pasteback(text)
247
+ if parsed is None:
248
+ raise UserError("That paste has no authorization code; copy the whole address your browser ended on.")
249
+ return parsed
250
+
251
+
226
252
  def _open_browser(url: str) -> None:
227
253
  try:
228
254
  webbrowser.open(url)
@@ -36,6 +36,7 @@ from pydantic_ai_claude_code.clai2 import (
36
36
  ClaudeCodePlugin,
37
37
  ClaudeCodeSettings,
38
38
  configure,
39
+ read_pasted_url,
39
40
  token_store,
40
41
  )
41
42
  from pydantic_ai_claude_code.credentials import ClaudeCodeCredentials
@@ -71,13 +72,17 @@ async def run(loaded: LoadedPlugin[None], *args: str) -> str:
71
72
  return await cast(Awaitable[str], result)
72
73
 
73
74
 
74
- async def fake_login(*, store: TokenStore, on_url: Callable[[str], None]) -> ClaudeCodeCredentials:
75
+ async def fake_login(
76
+ *, store: TokenStore, on_url: Callable[[str], None], read_pasteback: Callable[[], Awaitable[str | None]]
77
+ ) -> ClaudeCodeCredentials:
75
78
  on_url(URL)
76
79
  store.save(CREDS)
77
80
  return CREDS
78
81
 
79
82
 
80
- async def failing_login(*, store: TokenStore, on_url: Callable[[str], None]) -> ClaudeCodeCredentials:
83
+ async def failing_login(
84
+ *, store: TokenStore, on_url: Callable[[str], None], read_pasteback: Callable[[], Awaitable[str | None]]
85
+ ) -> ClaudeCodeCredentials:
81
86
  raise UserError("Claude Code OAuth callback timed out.")
82
87
 
83
88
 
@@ -287,7 +292,7 @@ def test_rows_show_state_validate_and_reset() -> None:
287
292
 
288
293
  PACKAGE = Path(pydantic_ai_claude_code.__file__).parent
289
294
  # What CLAI2 itself installs, so a copied folder can import it: see pydantic-clai2's dependencies.
290
- CLAI2_PROVIDES = {"anthropic", "httpx2", "keyring", "pydantic", "pydantic_ai", "pydantic_clai2"}
295
+ CLAI2_PROVIDES = {"anthropic", "httpx2", "keyring", "pydantic", "pydantic_ai", "pydantic_clai2", "termflow"}
291
296
 
292
297
 
293
298
  @pytest.mark.parametrize("module", sorted(PACKAGE.glob("*.py")), ids=lambda path: path.name)
@@ -345,3 +350,47 @@ async def test_clai2_runs_the_package_folder_as_a_drop_in(
345
350
  }
346
351
  assert drop_in in loaded, "the turn ran on the copied folder"
347
352
  assert "You are Claude Code" in str(messages_stub.received.get("system"))
353
+
354
+
355
+ PASTED = "http://localhost:8765/callback?code=abc&state=xyz"
356
+
357
+
358
+ def typing(*keys: str) -> Callable[[], str]:
359
+ remaining = iter(keys)
360
+ return lambda: next(remaining)
361
+
362
+
363
+ def test_a_pasted_address_is_counted_not_echoed_and_returned_on_enter() -> None:
364
+ shown = io.StringIO()
365
+ assert read_pasted_url(typing("", *PASTED, "tab", "enter"), shown) == PASTED
366
+ assert "code=abc" not in shown.getvalue(), "the authorization code never reaches the screen"
367
+ assert f"[{len(PASTED)} characters]" in shown.getvalue()
368
+
369
+
370
+ def test_a_paste_without_a_code_is_refused_and_can_be_fixed() -> None:
371
+ shown = io.StringIO()
372
+ keys = typing(*"nope", "enter", "ctrl-u", *"x", "backspace", *PASTED, "enter")
373
+ assert read_pasted_url(keys, shown) == PASTED
374
+ assert "no authorization code in that" in shown.getvalue()
375
+
376
+
377
+ @pytest.mark.parametrize("key", ["escape", "ctrl-c"])
378
+ def test_escape_or_a_winning_callback_ends_the_read(key: str) -> None:
379
+ assert read_pasted_url(typing(*"abc", key), io.StringIO()) is None
380
+
381
+
382
+ async def test_sign_in_offers_the_paste_reader_to_login(monkeypatch: pytest.MonkeyPatch) -> None:
383
+ offered: list[str | None] = []
384
+
385
+ async def pasting_login(
386
+ *, store: TokenStore, on_url: Callable[[str], None], read_pasteback: Callable[[], Awaitable[str | None]]
387
+ ) -> ClaudeCodeCredentials:
388
+ offered.append(await read_pasteback())
389
+ store.save(CREDS)
390
+ return CREDS
391
+
392
+ monkeypatch.setattr(clai2, "login", pasting_login)
393
+ monkeypatch.setattr(clai2, "read_pasted_url", lambda: PASTED)
394
+ source = ClaudeCodeConfig(ClaudeCodeSettings(), lambda settings: None)
395
+ assert (await clai2.sign_in(source, print)).startswith("Signed in to Claude Code.")
396
+ assert offered == [PASTED]
@@ -139,7 +139,78 @@ async def test_login_rejects_a_mismatched_state(token_stub, monkeypatch) -> None
139
139
  assert store.saved is None
140
140
 
141
141
 
142
- def test_parse_pasteback() -> None:
143
- assert parse_pasteback("claude://oauth/callback?code=abc&state=xyz") == ("abc", "xyz")
144
- assert parse_pasteback("https://example.com/callback?code=nope") is None
145
- assert parse_pasteback("claude://oauth/callback") is None # missing code
142
+ @pytest.mark.parametrize(
143
+ ("pasted", "parsed"),
144
+ [
145
+ ("http://localhost:8765/callback?code=abc&state=xyz", ("abc", "xyz")),
146
+ (" claude://oauth/callback?code=abc&state=xyz\n", ("abc", "xyz")),
147
+ ("code=abc&state=xyz", ("abc", "xyz")),
148
+ ("abc#xyz", ("abc", "xyz")),
149
+ ("http://localhost:8765/callback?state=xyz", None),
150
+ ("claude://oauth/callback", None),
151
+ ("", None),
152
+ ("two words", None),
153
+ ],
154
+ )
155
+ def test_parse_pasteback(pasted: str, parsed: tuple[str, str] | None) -> None:
156
+ assert parse_pasteback(pasted) == parsed
157
+
158
+
159
+ def _paste_from(url: str, *, state: str | None = None) -> str:
160
+ """What a browser on another machine ends on: the localhost redirect, which it could not load."""
161
+ query = parse_qs(urlsplit(url).query)
162
+ return f"{query['redirect_uri'][0]}?code=pasted-code&state={state or query['state'][0]}"
163
+
164
+
165
+ async def test_login_takes_a_pasted_address_when_the_callback_never_comes(token_stub, monkeypatch) -> None:
166
+ monkeypatch.setattr(flow, "_open_browser", lambda url: None)
167
+ shown: list[str] = []
168
+
169
+ async def paste() -> str:
170
+ await asyncio.sleep(0)
171
+ return _paste_from(shown[0])
172
+
173
+ store = _MemoryStore()
174
+ credentials = await flow.login(store=store, on_url=shown.append, read_pasteback=paste)
175
+ assert store.saved == credentials
176
+ assert token_stub.last_body["code"] == "pasted-code"
177
+ assert token_stub.last_body["redirect_uri"] == parse_qs(urlsplit(shown[0]).query)["redirect_uri"][0]
178
+
179
+
180
+ async def test_login_cancels_the_paste_read_when_the_browser_gets_through(token_stub, monkeypatch) -> None:
181
+ monkeypatch.setattr(flow, "_open_browser", lambda url: None)
182
+ cancelled = asyncio.Event()
183
+
184
+ async def wait_for_paste() -> str | None:
185
+ try:
186
+ await asyncio.Event().wait()
187
+ except asyncio.CancelledError:
188
+ cancelled.set()
189
+ raise
190
+ return None # pragma: no cover
191
+
192
+ await flow.login(store=_MemoryStore(), on_url=_visit_redirect_later, read_pasteback=wait_for_paste)
193
+ assert cancelled.is_set()
194
+ assert token_stub.last_body["code"] == "the-code"
195
+
196
+
197
+ @pytest.mark.parametrize(
198
+ ("pasted", "error"),
199
+ [
200
+ (None, "sign-in cancelled"),
201
+ ("not a url", "no authorization code"),
202
+ ("forged", "state mismatch"),
203
+ ],
204
+ )
205
+ async def test_login_rejects_a_cancelled_empty_or_forged_paste(token_stub, monkeypatch, pasted, error) -> None:
206
+ monkeypatch.setattr(flow, "_open_browser", lambda url: None)
207
+ shown: list[str] = []
208
+
209
+ async def paste() -> str | None:
210
+ await asyncio.sleep(0)
211
+ return _paste_from(shown[0], state="forged") if pasted == "forged" else pasted
212
+
213
+ store = _MemoryStore()
214
+ with pytest.raises(UserError, match=error):
215
+ await flow.login(store=store, on_url=shown.append, read_pasteback=paste)
216
+ assert store.saved is None