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.
- {pydantic_claude_code-0.6.0 → pydantic_claude_code-0.6.2}/.github/workflows/ci.yml +1 -1
- {pydantic_claude_code-0.6.0 → pydantic_claude_code-0.6.2}/PKG-INFO +21 -14
- {pydantic_claude_code-0.6.0 → pydantic_claude_code-0.6.2}/README.md +16 -11
- {pydantic_claude_code-0.6.0 → pydantic_claude_code-0.6.2}/install.sh +2 -2
- {pydantic_claude_code-0.6.0 → pydantic_claude_code-0.6.2}/pyproject.toml +8 -15
- {pydantic_claude_code-0.6.0 → pydantic_claude_code-0.6.2}/src/pydantic_ai_claude_code/__init__.py +1 -1
- {pydantic_claude_code-0.6.0 → pydantic_claude_code-0.6.2}/src/pydantic_ai_claude_code/clai2.py +52 -5
- {pydantic_claude_code-0.6.0 → pydantic_claude_code-0.6.2}/src/pydantic_ai_claude_code/config.py +0 -1
- {pydantic_claude_code-0.6.0 → pydantic_claude_code-0.6.2}/src/pydantic_ai_claude_code/credentials.py +2 -2
- {pydantic_claude_code-0.6.0 → pydantic_claude_code-0.6.2}/src/pydantic_ai_claude_code/flow.py +44 -18
- {pydantic_claude_code-0.6.0 → pydantic_claude_code-0.6.2}/tests/test_clai2.py +52 -3
- {pydantic_claude_code-0.6.0 → pydantic_claude_code-0.6.2}/tests/test_flow.py +75 -4
- {pydantic_claude_code-0.6.0 → pydantic_claude_code-0.6.2}/tests/test_install.py +1 -1
- {pydantic_claude_code-0.6.0 → pydantic_claude_code-0.6.2}/tests/test_integration.py +2 -2
- {pydantic_claude_code-0.6.0 → pydantic_claude_code-0.6.2}/tests/test_updates.py +2 -4
- {pydantic_claude_code-0.6.0 → pydantic_claude_code-0.6.2}/uv.lock +422 -17
- {pydantic_claude_code-0.6.0 → pydantic_claude_code-0.6.2}/.github/workflows/publish.yml +0 -0
- {pydantic_claude_code-0.6.0 → pydantic_claude_code-0.6.2}/.gitignore +0 -0
- {pydantic_claude_code-0.6.0 → pydantic_claude_code-0.6.2}/LICENSE +0 -0
- {pydantic_claude_code-0.6.0 → pydantic_claude_code-0.6.2}/examples/basic.py +0 -0
- {pydantic_claude_code-0.6.0 → pydantic_claude_code-0.6.2}/src/pydantic_ai_claude_code/__main__.py +0 -0
- {pydantic_claude_code-0.6.0 → pydantic_claude_code-0.6.2}/src/pydantic_ai_claude_code/auth.py +0 -0
- {pydantic_claude_code-0.6.0 → pydantic_claude_code-0.6.2}/src/pydantic_ai_claude_code/model.py +0 -0
- {pydantic_claude_code-0.6.0 → pydantic_claude_code-0.6.2}/src/pydantic_ai_claude_code/provider.py +0 -0
- {pydantic_claude_code-0.6.0 → pydantic_claude_code-0.6.2}/src/pydantic_ai_claude_code/py.typed +0 -0
- {pydantic_claude_code-0.6.0 → pydantic_claude_code-0.6.2}/src/pydantic_ai_claude_code/storage.py +0 -0
- {pydantic_claude_code-0.6.0 → pydantic_claude_code-0.6.2}/src/pydantic_ai_claude_code/updates.py +0 -0
- {pydantic_claude_code-0.6.0 → pydantic_claude_code-0.6.2}/tests/conftest.py +0 -0
- {pydantic_claude_code-0.6.0 → pydantic_claude_code-0.6.2}/tests/test_callback_server.py +0 -0
- {pydantic_claude_code-0.6.0 → pydantic_claude_code-0.6.2}/tests/test_credentials.py +0 -0
- {pydantic_claude_code-0.6.0 → pydantic_claude_code-0.6.2}/tests/test_keyring_store.py +0 -0
- {pydantic_claude_code-0.6.0 → pydantic_claude_code-0.6.2}/tests/test_model.py +0 -0
- {pydantic_claude_code-0.6.0 → pydantic_claude_code-0.6.2}/tests/test_provider.py +0 -0
- {pydantic_claude_code-0.6.0 → pydantic_claude_code-0.6.2}/tests/test_storage.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: pydantic-claude-code
|
|
3
|
-
Version: 0.6.
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
99
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
72
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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 = "
|
|
37
|
+
target-version = "py310"
|
|
36
38
|
|
|
37
39
|
[tool.pyright]
|
|
38
40
|
include = ["src"]
|
|
39
|
-
pythonVersion = "3.
|
|
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,
|
|
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
|
|
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" }
|
{pydantic_claude_code-0.6.0 → pydantic_claude_code-0.6.2}/src/pydantic_ai_claude_code/__init__.py
RENAMED
|
@@ -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.
|
|
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
|
|
{pydantic_claude_code-0.6.0 → pydantic_claude_code-0.6.2}/src/pydantic_ai_claude_code/clai2.py
RENAMED
|
@@ -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
|
-
|
|
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
|
)
|
{pydantic_claude_code-0.6.0 → pydantic_claude_code-0.6.2}/src/pydantic_ai_claude_code/config.py
RENAMED
|
@@ -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.
|
{pydantic_claude_code-0.6.0 → pydantic_claude_code-0.6.2}/src/pydantic_ai_claude_code/credentials.py
RENAMED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import time
|
|
6
|
-
from datetime import
|
|
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=
|
|
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),
|
{pydantic_claude_code-0.6.0 → pydantic_claude_code-0.6.2}/src/pydantic_ai_claude_code/flow.py
RENAMED
|
@@ -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`.
|
|
5
|
-
|
|
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
|
|
127
|
+
"""Parse what the user pasted into `(code, state)`; `None` when it holds no authorization code.
|
|
128
128
|
|
|
129
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*,
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|