pcode 0.1.0__py3-none-any.whl
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.
- pcode/__init__.py +7 -0
- pcode/_job_supervisor.py +65 -0
- pcode/agent.py +592 -0
- pcode/anthropic_oauth.py +469 -0
- pcode/app.py +3165 -0
- pcode/appearance.py +181 -0
- pcode/aside.py +541 -0
- pcode/aside_guard.py +39 -0
- pcode/aside_ui.py +683 -0
- pcode/auth.py +113 -0
- pcode/block.py +39 -0
- pcode/browser.py +393 -0
- pcode/cache_diagnostics.py +336 -0
- pcode/cache_settings.py +58 -0
- pcode/cache_warnings.py +139 -0
- pcode/claude_sdk/__init__.py +96 -0
- pcode/claude_sdk/cli.py +52 -0
- pcode/claude_sdk/errors.py +61 -0
- pcode/claude_sdk/messages.py +154 -0
- pcode/claude_sdk/model.py +179 -0
- pcode/claude_sdk/resume.py +114 -0
- pcode/claude_sdk/session.py +547 -0
- pcode/claude_sdk/session_pool.py +251 -0
- pcode/claude_sdk/workspace.py +34 -0
- pcode/cli.py +73 -0
- pcode/clipboard.py +76 -0
- pcode/code_mode.py +43 -0
- pcode/codex_login.py +189 -0
- pcode/command_transcript.py +71 -0
- pcode/commands.py +145 -0
- pcode/compaction.py +457 -0
- pcode/completion.py +263 -0
- pcode/config.py +223 -0
- pcode/context_breakdown.py +178 -0
- pcode/context_usage.py +52 -0
- pcode/controller.py +3496 -0
- pcode/conversation_ids.py +27 -0
- pcode/conversation_tree.py +160 -0
- pcode/copy_ui.py +172 -0
- pcode/delegation.py +236 -0
- pcode/delta.py +295 -0
- pcode/diagnostics.py +226 -0
- pcode/edit_preview.py +158 -0
- pcode/edit_transcript.py +287 -0
- pcode/edit_ui.py +850 -0
- pcode/edits.py +162 -0
- pcode/email_remote/__init__.py +14 -0
- pcode/email_remote/cli.py +133 -0
- pcode/email_remote/listener.py +695 -0
- pcode/email_remote/mailbox.py +336 -0
- pcode/email_remote/outbound.py +75 -0
- pcode/email_remote/parsing.py +226 -0
- pcode/email_remote/render.py +175 -0
- pcode/email_remote/state.py +79 -0
- pcode/error_report.py +38 -0
- pcode/ext.py +602 -0
- pcode/extension_guide.md +311 -0
- pcode/extensions/browser.py +284 -0
- pcode/extensions/ponytail.py +217 -0
- pcode/extensions/sandbox.py +138 -0
- pcode/extensions/session_history.py +170 -0
- pcode/extensions/web_research.py +89 -0
- pcode/file_refs.py +265 -0
- pcode/filesystem.py +138 -0
- pcode/frame.py +149 -0
- pcode/gateway.py +465 -0
- pcode/git_diff.py +497 -0
- pcode/history.py +589 -0
- pcode/history_cursor.py +147 -0
- pcode/history_embeddings.py +102 -0
- pcode/host.py +941 -0
- pcode/host_protocol.py +291 -0
- pcode/host_ui.py +225 -0
- pcode/input_keys.py +35 -0
- pcode/inspection.py +289 -0
- pcode/inspector_ui.py +448 -0
- pcode/isolated_delegation.py +425 -0
- pcode/job_notices.py +54 -0
- pcode/jobs.py +584 -0
- pcode/jobs_ui.py +265 -0
- pcode/keymap.py +215 -0
- pcode/layout_speed.py +103 -0
- pcode/links.py +90 -0
- pcode/links_ui.py +200 -0
- pcode/live.py +1301 -0
- pcode/llm_proxy.py +44 -0
- pcode/logins.py +233 -0
- pcode/mcp.py +683 -0
- pcode/mcp_notice.py +91 -0
- pcode/mcp_oauth.py +381 -0
- pcode/meridian.py +233 -0
- pcode/meridian_process.py +337 -0
- pcode/meridian_reminders.py +107 -0
- pcode/meridian_setup.py +356 -0
- pcode/model_metadata.py +451 -0
- pcode/model_ui.py +168 -0
- pcode/models.py +209 -0
- pcode/native_results.py +119 -0
- pcode/oauth_pages.py +66 -0
- pcode/output_limits.py +41 -0
- pcode/paste.py +45 -0
- pcode/path_completion.py +66 -0
- pcode/plan_preview.py +131 -0
- pcode/plan_sizes.py +35 -0
- pcode/planning.py +161 -0
- pcode/popup_ui.py +873 -0
- pcode/preferences.py +1176 -0
- pcode/prefix_keys.py +490 -0
- pcode/profile_benchmark.py +332 -0
- pcode/profiling.py +566 -0
- pcode/project_trust.py +94 -0
- pcode/prompt_keys.py +286 -0
- pcode/remote.py +717 -0
- pcode/remote_print.py +125 -0
- pcode/remote_profile.py +237 -0
- pcode/replay_benchmark.py +319 -0
- pcode/repo_context.py +158 -0
- pcode/retries.py +28 -0
- pcode/rpc.py +280 -0
- pcode/runtime.py +244 -0
- pcode/sandbox.py +454 -0
- pcode/session_naming.py +82 -0
- pcode/session_ui.py +550 -0
- pcode/sessions.py +892 -0
- pcode/shell.py +113 -0
- pcode/shell_mode.py +172 -0
- pcode/shell_tools.py +459 -0
- pcode/skills.py +161 -0
- pcode/stall_log.py +187 -0
- pcode/startup_input.py +96 -0
- pcode/steering.py +27 -0
- pcode/stream_display.py +188 -0
- pcode/stream_events.py +437 -0
- pcode/strict_tools.py +130 -0
- pcode/syntax.py +43 -0
- pcode/syntax_colors.py +206 -0
- pcode/task_prompt.py +28 -0
- pcode/task_worktrees.py +482 -0
- pcode/terminal_notify.py +383 -0
- pcode/theme.py +135 -0
- pcode/theme_gallery.py +88 -0
- pcode/thinking_markdown.py +41 -0
- pcode/token_accounting.py +67 -0
- pcode/tool_display.py +784 -0
- pcode/tool_output_limits.py +108 -0
- pcode/tool_panel.py +377 -0
- pcode/transcript_log.py +127 -0
- pcode/transcript_notice.py +103 -0
- pcode/tree_ui.py +288 -0
- pcode/turn.py +44 -0
- pcode/ui.py +3745 -0
- pcode/usage.py +293 -0
- pcode/word_wrap.py +107 -0
- pcode/worker_ui.py +235 -0
- pcode/workers.py +136 -0
- pcode/workspace.py +57 -0
- pcode/workspace_filesystem.py +72 -0
- pcode/worktree.py +650 -0
- pcode-0.1.0.dist-info/METADATA +145 -0
- pcode-0.1.0.dist-info/RECORD +163 -0
- pcode-0.1.0.dist-info/WHEEL +4 -0
- pcode-0.1.0.dist-info/entry_points.txt +3 -0
- pcode-0.1.0.dist-info/licenses/LICENSE +21 -0
pcode/anthropic_oauth.py
ADDED
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
"""pcode's own Anthropic subscription sign-in: browser OAuth with PKCE.
|
|
2
|
+
|
|
3
|
+
`/login` opens claude.ai in a browser, receives the authorization code on a
|
|
4
|
+
loopback callback, exchanges it for tokens, and stores them under pcode's config
|
|
5
|
+
directory with owner-only permissions. pcode then owns the refresh token, so no
|
|
6
|
+
other agent installation is required for subscription access.
|
|
7
|
+
|
|
8
|
+
This authenticates as the public Claude Code client against an endpoint scoped to
|
|
9
|
+
it. It is compatibility support, not an official third-party OAuth integration:
|
|
10
|
+
entitlements and server behavior can change at any time. The supported path
|
|
11
|
+
remains `ANTHROPIC_API_KEY`. Never log, echo, or copy token values.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
import base64
|
|
16
|
+
import hashlib
|
|
17
|
+
import json
|
|
18
|
+
import math
|
|
19
|
+
import os
|
|
20
|
+
import secrets
|
|
21
|
+
import tempfile
|
|
22
|
+
import time
|
|
23
|
+
import webbrowser
|
|
24
|
+
from dataclasses import dataclass, field
|
|
25
|
+
from functools import cached_property
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from urllib.parse import parse_qsl, urlencode, urlsplit
|
|
28
|
+
|
|
29
|
+
import httpx2
|
|
30
|
+
from anthropic import AsyncAnthropic
|
|
31
|
+
from filelock import FileLock
|
|
32
|
+
from pydantic_ai.models.anthropic import AnthropicModel
|
|
33
|
+
from pydantic_ai.providers.anthropic import AnthropicProvider
|
|
34
|
+
|
|
35
|
+
from pcode.auth import LoginError, SubscriptionOAuthWire
|
|
36
|
+
from pcode.oauth_pages import callback_page
|
|
37
|
+
from pcode.preferences import config_dir
|
|
38
|
+
|
|
39
|
+
CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
|
|
40
|
+
AUTHORIZE_URL = "https://claude.ai/oauth/authorize"
|
|
41
|
+
TOKEN_URL = "https://platform.claude.com/v1/oauth/token"
|
|
42
|
+
SCOPES = (
|
|
43
|
+
"org:create_api_key user:profile user:inference "
|
|
44
|
+
"user:sessions:claude_code user:mcp_servers user:file_upload"
|
|
45
|
+
)
|
|
46
|
+
CALLBACK_HOST = "127.0.0.1"
|
|
47
|
+
CALLBACK_PATH = "/callback"
|
|
48
|
+
DEFAULT_CALLBACK_PORT = 54545
|
|
49
|
+
# Renew before the server's own expiry so an in-flight turn never races it.
|
|
50
|
+
EARLY_REFRESH_SECONDS = 300
|
|
51
|
+
TOKEN_TIMEOUT_SECONDS = 30.0
|
|
52
|
+
LOGIN_TIMEOUT_SECONDS = 300.0
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def callback_port() -> int:
|
|
56
|
+
"""Loopback port for the redirect URI; override it when 54545 is taken."""
|
|
57
|
+
value = os.environ.get("PCODE_OAUTH_CALLBACK_PORT", "").strip()
|
|
58
|
+
if not value:
|
|
59
|
+
return DEFAULT_CALLBACK_PORT
|
|
60
|
+
if not value.isascii() or not value.isdecimal() or not 1 <= int(value) <= 65535:
|
|
61
|
+
raise LoginError("PCODE_OAUTH_CALLBACK_PORT must be a port number between 1 and 65535.")
|
|
62
|
+
return int(value)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def redirect_uri(port: int) -> str:
|
|
66
|
+
# `localhost` (not the bound literal) is what the browser is redirected to.
|
|
67
|
+
return f"http://localhost:{port}{CALLBACK_PATH}"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def credentials_path() -> Path:
|
|
71
|
+
override = os.environ.get("PCODE_CREDENTIALS_FILE", "").strip()
|
|
72
|
+
if override:
|
|
73
|
+
return Path(override).expanduser()
|
|
74
|
+
return config_dir() / "credentials.json"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def have_credentials(path: Path | None = None) -> bool:
|
|
78
|
+
"""Existence check only; the picker must never read a stored token."""
|
|
79
|
+
try:
|
|
80
|
+
return (path if path is not None else credentials_path()).is_file()
|
|
81
|
+
except OSError:
|
|
82
|
+
return False
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def anthropic_auth_source() -> str:
|
|
86
|
+
"""Resolve which Anthropic credential to use: api-key or oauth.
|
|
87
|
+
|
|
88
|
+
`PCODE_ANTHROPIC_AUTH` is authoritative when set. Otherwise the source that
|
|
89
|
+
`/login` last selected applies, then pcode's own stored sign-in, so a login
|
|
90
|
+
keeps working after a restart; `PCODE_ANTHROPIC_AUTH=api-key` opts back out.
|
|
91
|
+
|
|
92
|
+
A saved choice is honored only while its credential file is still present:
|
|
93
|
+
a stale preference must not turn a missing credential into a startup error.
|
|
94
|
+
|
|
95
|
+
Always api-key while `LEGACY_ANTHROPIC_AUTH` is off: a stored sign-in or a
|
|
96
|
+
saved or exported `oauth` is ignored rather than failing.
|
|
97
|
+
"""
|
|
98
|
+
from pcode.models import LEGACY_ANTHROPIC_AUTH
|
|
99
|
+
|
|
100
|
+
if not LEGACY_ANTHROPIC_AUTH:
|
|
101
|
+
return "api-key"
|
|
102
|
+
source = os.environ.get("PCODE_ANTHROPIC_AUTH", "").strip()
|
|
103
|
+
if source:
|
|
104
|
+
return source
|
|
105
|
+
from pcode.preferences import load_preferences
|
|
106
|
+
|
|
107
|
+
saved = load_preferences().get("anthropic_auth")
|
|
108
|
+
if saved == "api-key":
|
|
109
|
+
return "api-key"
|
|
110
|
+
return "oauth" if have_credentials() else "api-key"
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@dataclass(frozen=True)
|
|
114
|
+
class OAuthTokens:
|
|
115
|
+
access: str = field(repr=False)
|
|
116
|
+
refresh: str = field(repr=False)
|
|
117
|
+
expires_at: float
|
|
118
|
+
|
|
119
|
+
@property
|
|
120
|
+
def stale(self) -> bool:
|
|
121
|
+
return self.expires_at <= time.time()
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _number(value: object) -> float | None:
|
|
125
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
126
|
+
return None
|
|
127
|
+
return float(value) if math.isfinite(value) else None
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _token(value: object) -> str | None:
|
|
131
|
+
if not isinstance(value, str) or not value or any(char.isspace() for char in value):
|
|
132
|
+
return None
|
|
133
|
+
return value
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def read_tokens(path: Path) -> OAuthTokens:
|
|
137
|
+
"""Load pcode's own stored credential, never another tool's auth file."""
|
|
138
|
+
try:
|
|
139
|
+
# Bound malformed input and avoid echoing parser exceptions or contents.
|
|
140
|
+
with path.open("r", encoding="utf-8-sig") as source:
|
|
141
|
+
contents = source.read(1024 * 1024 + 1)
|
|
142
|
+
if len(contents) > 1024 * 1024:
|
|
143
|
+
raise ValueError
|
|
144
|
+
data = json.loads(contents)
|
|
145
|
+
entry = data.get("anthropic") if isinstance(data, dict) else None
|
|
146
|
+
if not isinstance(entry, dict) or entry.get("type") != "oauth":
|
|
147
|
+
raise ValueError
|
|
148
|
+
access, refresh = _token(entry.get("access")), _token(entry.get("refresh"))
|
|
149
|
+
expires_at = _number(entry.get("expires_at"))
|
|
150
|
+
if access is None or refresh is None or expires_at is None:
|
|
151
|
+
raise ValueError
|
|
152
|
+
return OAuthTokens(access, refresh, expires_at)
|
|
153
|
+
except Exception:
|
|
154
|
+
raise LoginError(
|
|
155
|
+
"No usable pcode Anthropic login. Run /login to sign in with your "
|
|
156
|
+
"Anthropic account, or set ANTHROPIC_API_KEY for API-key access."
|
|
157
|
+
) from None
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def write_tokens(path: Path, tokens: OAuthTokens) -> None:
|
|
161
|
+
"""Replace the credential file atomically, owner-readable only."""
|
|
162
|
+
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
163
|
+
payload = {
|
|
164
|
+
"anthropic": {
|
|
165
|
+
"type": "oauth",
|
|
166
|
+
"access": tokens.access,
|
|
167
|
+
"refresh": tokens.refresh,
|
|
168
|
+
"expires_at": tokens.expires_at,
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
name = None
|
|
172
|
+
try:
|
|
173
|
+
with tempfile.NamedTemporaryFile(mode="w", dir=path.parent, delete=False) as file:
|
|
174
|
+
name = file.name
|
|
175
|
+
# Restrict before any token bytes reach the filesystem.
|
|
176
|
+
os.chmod(file.fileno(), 0o600)
|
|
177
|
+
json.dump(payload, file, indent=2)
|
|
178
|
+
file.write("\n")
|
|
179
|
+
os.replace(name, path)
|
|
180
|
+
name = None
|
|
181
|
+
finally:
|
|
182
|
+
if name is not None:
|
|
183
|
+
Path(name).unlink(missing_ok=True)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def delete_tokens(path: Path) -> bool:
|
|
187
|
+
try:
|
|
188
|
+
path.unlink()
|
|
189
|
+
return True
|
|
190
|
+
except FileNotFoundError:
|
|
191
|
+
return False
|
|
192
|
+
except OSError:
|
|
193
|
+
raise LoginError(f"Could not remove the stored Anthropic login: {path}") from None
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _parse_tokens(data: object, previous_refresh: str = "") -> OAuthTokens:
|
|
197
|
+
entry = data if isinstance(data, dict) else {}
|
|
198
|
+
access = _token(entry.get("access_token"))
|
|
199
|
+
# A refresh response may legitimately omit a new refresh token.
|
|
200
|
+
refresh = _token(entry.get("refresh_token")) or previous_refresh
|
|
201
|
+
lifetime = _number(entry.get("expires_in"))
|
|
202
|
+
if access is None or not refresh or lifetime is None:
|
|
203
|
+
raise LoginError("Anthropic returned an unusable token response. Try /login again.")
|
|
204
|
+
return OAuthTokens(access, refresh, time.time() + max(lifetime, 0) - EARLY_REFRESH_SECONDS)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _post_tokens(payload: dict[str, str], transport=None) -> object:
|
|
208
|
+
"""Call the token endpoint. Errors never include bodies or credentials."""
|
|
209
|
+
try:
|
|
210
|
+
with httpx2.Client(transport=transport, timeout=TOKEN_TIMEOUT_SECONDS) as client:
|
|
211
|
+
response = client.post(
|
|
212
|
+
TOKEN_URL,
|
|
213
|
+
json={"client_id": CLIENT_ID, **payload},
|
|
214
|
+
headers={"Accept": "application/json", "Content-Type": "application/json"},
|
|
215
|
+
)
|
|
216
|
+
except Exception:
|
|
217
|
+
raise LoginError(
|
|
218
|
+
f"Could not reach Anthropic's token endpoint ({urlsplit(TOKEN_URL).netloc}). "
|
|
219
|
+
"Check your network connection, then retry."
|
|
220
|
+
) from None
|
|
221
|
+
if response.status_code in (400, 401, 403):
|
|
222
|
+
raise LoginError(
|
|
223
|
+
f"Anthropic rejected the sign-in ({response.status_code}). Run /login to sign in again."
|
|
224
|
+
)
|
|
225
|
+
if response.status_code >= 400:
|
|
226
|
+
raise LoginError(
|
|
227
|
+
f"Anthropic's token endpoint failed ({response.status_code}). Retry in a moment."
|
|
228
|
+
)
|
|
229
|
+
try:
|
|
230
|
+
return response.json()
|
|
231
|
+
except Exception:
|
|
232
|
+
raise LoginError(
|
|
233
|
+
"Anthropic returned an unreadable token response. Try /login again."
|
|
234
|
+
) from None
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def refresh_tokens(path: Path, *, force: bool = False, transport=None) -> OAuthTokens:
|
|
238
|
+
"""Renew the stored credential, serialized across pcode processes."""
|
|
239
|
+
with FileLock(str(path) + ".lock", timeout=30):
|
|
240
|
+
# Another process may have refreshed while this one waited for the lock.
|
|
241
|
+
tokens = read_tokens(path)
|
|
242
|
+
if not force and not tokens.stale:
|
|
243
|
+
return tokens
|
|
244
|
+
fresh = _parse_tokens(
|
|
245
|
+
_post_tokens(
|
|
246
|
+
{"grant_type": "refresh_token", "refresh_token": tokens.refresh}, transport
|
|
247
|
+
),
|
|
248
|
+
tokens.refresh,
|
|
249
|
+
)
|
|
250
|
+
write_tokens(path, fresh)
|
|
251
|
+
return fresh
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
class StoredLogin:
|
|
255
|
+
"""Async access-token provider for the Anthropic SDK's `credentials=` hook.
|
|
256
|
+
|
|
257
|
+
The SDK's `TokenCache` caches the token in memory, refreshes it proactively,
|
|
258
|
+
and sets `force_refresh` after a 401, so the file is read and the endpoint
|
|
259
|
+
called only at those boundaries. Blocking file/HTTP work runs off the loop.
|
|
260
|
+
"""
|
|
261
|
+
|
|
262
|
+
def __init__(self, path: Path | None = None, *, transport=None):
|
|
263
|
+
self.path = path if path is not None else credentials_path()
|
|
264
|
+
self._transport = transport
|
|
265
|
+
|
|
266
|
+
def _resolve(self, force_refresh: bool) -> OAuthTokens:
|
|
267
|
+
tokens = read_tokens(self.path)
|
|
268
|
+
if force_refresh or tokens.stale:
|
|
269
|
+
return refresh_tokens(self.path, force=force_refresh, transport=self._transport)
|
|
270
|
+
return tokens
|
|
271
|
+
|
|
272
|
+
async def __call__(self, *, force_refresh: bool = False):
|
|
273
|
+
from anthropic.lib.credentials import AccessToken
|
|
274
|
+
|
|
275
|
+
tokens = await asyncio.to_thread(self._resolve, force_refresh)
|
|
276
|
+
return AccessToken(token=tokens.access, expires_at=int(tokens.expires_at))
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
class OAuthRefreshOnlyClient(AsyncAnthropic):
|
|
280
|
+
"""Retain the SDK's one-shot token refresh, not its general retry policy.
|
|
281
|
+
|
|
282
|
+
These SDK hooks are covered by real-client mock-transport tests: setting
|
|
283
|
+
max_retries=0 also disables credential refresh after a rejected token.
|
|
284
|
+
"""
|
|
285
|
+
|
|
286
|
+
def _should_retry(self, response):
|
|
287
|
+
return response.status_code == 401 and super()._should_retry(response)
|
|
288
|
+
|
|
289
|
+
def _should_retry_exception(self, error):
|
|
290
|
+
# Connection failures belong to the runtime's visible retry loop.
|
|
291
|
+
return False, None
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
class AnthropicOAuthModel(SubscriptionOAuthWire, AnthropicModel):
|
|
295
|
+
"""Anthropic transport authenticated by pcode's own stored subscription login."""
|
|
296
|
+
|
|
297
|
+
_subscription_oauth = True
|
|
298
|
+
|
|
299
|
+
def __init__(
|
|
300
|
+
self,
|
|
301
|
+
model: str,
|
|
302
|
+
*,
|
|
303
|
+
path: Path | None = None,
|
|
304
|
+
http_client=None,
|
|
305
|
+
transport=None,
|
|
306
|
+
):
|
|
307
|
+
login = StoredLogin(path, transport=transport)
|
|
308
|
+
# Fail here, not mid-turn, when there is nothing usable to sign with.
|
|
309
|
+
read_tokens(login.path)
|
|
310
|
+
# Do not inherit API keys, bearer tokens, or base URLs from the
|
|
311
|
+
# environment: `credentials=` already suppresses credential env lookups.
|
|
312
|
+
client = OAuthRefreshOnlyClient(
|
|
313
|
+
credentials=login,
|
|
314
|
+
base_url="https://api.anthropic.com",
|
|
315
|
+
http_client=http_client,
|
|
316
|
+
# Only a rejected access token may retry inside the SDK.
|
|
317
|
+
max_retries=1,
|
|
318
|
+
)
|
|
319
|
+
super().__init__(
|
|
320
|
+
model.removeprefix("anthropic:"),
|
|
321
|
+
provider=AnthropicProvider(anthropic_client=client),
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
@cached_property
|
|
325
|
+
def profile(self):
|
|
326
|
+
# The subscription endpoint accepts `web_search_20260209`/`web_fetch_20260209`
|
|
327
|
+
# but then only exposes them inside a rate-limited `code_execution` sandbox,
|
|
328
|
+
# so nothing ever searches. With the 2025 tool versions it runs the search
|
|
329
|
+
# itself (verified against the live endpoint). Narrow the profile the way
|
|
330
|
+
# Pydantic AI does for Bedrock and Vertex; it costs domain filtering only.
|
|
331
|
+
from pydantic_ai.profiles import merge_profile
|
|
332
|
+
from pydantic_ai.profiles.anthropic import AnthropicModelProfile
|
|
333
|
+
|
|
334
|
+
return merge_profile(
|
|
335
|
+
super().profile, AnthropicModelProfile(anthropic_supports_dynamic_filtering=False)
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def pkce_pair() -> tuple[str, str]:
|
|
340
|
+
"""Return (verifier, S256 challenge) as base64url text without padding."""
|
|
341
|
+
verifier = base64.urlsafe_b64encode(secrets.token_bytes(32)).decode("ascii").rstrip("=")
|
|
342
|
+
digest = hashlib.sha256(verifier.encode("ascii")).digest()
|
|
343
|
+
return verifier, base64.urlsafe_b64encode(digest).decode("ascii").rstrip("=")
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def authorization_url(challenge: str, state: str, port: int) -> str:
|
|
347
|
+
return f"{AUTHORIZE_URL}?" + urlencode(
|
|
348
|
+
{
|
|
349
|
+
"code": "true",
|
|
350
|
+
"client_id": CLIENT_ID,
|
|
351
|
+
"response_type": "code",
|
|
352
|
+
"redirect_uri": redirect_uri(port),
|
|
353
|
+
"scope": SCOPES,
|
|
354
|
+
"code_challenge": challenge,
|
|
355
|
+
"code_challenge_method": "S256",
|
|
356
|
+
# This flow echoes the verifier back as `state`; the callback and the
|
|
357
|
+
# token exchange both compare against it, as Claude Code does.
|
|
358
|
+
"state": state,
|
|
359
|
+
}
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _callback_result(target: str, state: str) -> tuple[str, str, str]:
|
|
364
|
+
"""Return (code, page title, page message) for one callback request."""
|
|
365
|
+
query = urlsplit(target)
|
|
366
|
+
if query.path != CALLBACK_PATH:
|
|
367
|
+
return "", "Not found", "This is not the pcode sign-in callback."
|
|
368
|
+
fields = dict(parse_qsl(query.query))
|
|
369
|
+
if fields.get("error"):
|
|
370
|
+
return "", "Sign-in failed", "Anthropic did not complete the sign-in."
|
|
371
|
+
if not fields.get("code") or not fields.get("state"):
|
|
372
|
+
return "", "Sign-in failed", "The callback was missing its code or state."
|
|
373
|
+
if not secrets.compare_digest(fields["state"], state):
|
|
374
|
+
return "", "Sign-in failed", "The callback state did not match this sign-in."
|
|
375
|
+
return fields["code"], "Signed in", "pcode received the authorization. You can close this tab."
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
async def _receive_code(port: int, state: str, timeout: float) -> str:
|
|
379
|
+
"""Serve the loopback callback until the browser delivers a valid code."""
|
|
380
|
+
loop = asyncio.get_running_loop()
|
|
381
|
+
received: asyncio.Future[str] = loop.create_future()
|
|
382
|
+
|
|
383
|
+
async def handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
|
|
384
|
+
code, title, message = "", "Sign-in failed", "The callback request was unreadable."
|
|
385
|
+
try:
|
|
386
|
+
request = await asyncio.wait_for(reader.readline(), 10)
|
|
387
|
+
parts = request.decode("latin-1", "replace").split()
|
|
388
|
+
if len(parts) >= 2 and parts[0] == "GET":
|
|
389
|
+
code, title, message = _callback_result(parts[1], state)
|
|
390
|
+
# Drain the (bounded) header block so the browser sees a clean reply.
|
|
391
|
+
while (line := await asyncio.wait_for(reader.readline(), 10)) not in (
|
|
392
|
+
b"\r\n",
|
|
393
|
+
b"\n",
|
|
394
|
+
b"",
|
|
395
|
+
):
|
|
396
|
+
if len(line) > 8192:
|
|
397
|
+
break
|
|
398
|
+
body = callback_page(title, message, ok=bool(code)).encode("utf-8")
|
|
399
|
+
status = "200 OK" if code else "400 Bad Request"
|
|
400
|
+
writer.write(
|
|
401
|
+
f"HTTP/1.1 {status}\r\nContent-Type: text/html; charset=utf-8\r\n"
|
|
402
|
+
f"Content-Length: {len(body)}\r\nConnection: close\r\n\r\n".encode("ascii")
|
|
403
|
+
+ body
|
|
404
|
+
)
|
|
405
|
+
await writer.drain()
|
|
406
|
+
except (OSError, asyncio.TimeoutError, UnicodeError):
|
|
407
|
+
code = ""
|
|
408
|
+
finally:
|
|
409
|
+
writer.close()
|
|
410
|
+
if code and not received.done():
|
|
411
|
+
received.set_result(code)
|
|
412
|
+
|
|
413
|
+
try:
|
|
414
|
+
server = await asyncio.start_server(handle, CALLBACK_HOST, port)
|
|
415
|
+
except OSError:
|
|
416
|
+
raise LoginError(
|
|
417
|
+
f"Port {port} is already in use, so the sign-in callback cannot start. "
|
|
418
|
+
"Close the other login, or set PCODE_OAUTH_CALLBACK_PORT to a free port."
|
|
419
|
+
) from None
|
|
420
|
+
async with server:
|
|
421
|
+
try:
|
|
422
|
+
return await asyncio.wait_for(received, timeout)
|
|
423
|
+
except asyncio.TimeoutError:
|
|
424
|
+
raise LoginError("Anthropic sign-in timed out. Run /login to try again.") from None
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
async def login(
|
|
428
|
+
*,
|
|
429
|
+
path: Path | None = None,
|
|
430
|
+
notify=None,
|
|
431
|
+
open_browser: bool = True,
|
|
432
|
+
timeout: float = LOGIN_TIMEOUT_SECONDS,
|
|
433
|
+
transport=None,
|
|
434
|
+
) -> OAuthTokens:
|
|
435
|
+
"""Run the browser sign-in and store the resulting credential."""
|
|
436
|
+
path = path if path is not None else credentials_path()
|
|
437
|
+
port = callback_port()
|
|
438
|
+
verifier, challenge = pkce_pair()
|
|
439
|
+
url = authorization_url(challenge, verifier, port)
|
|
440
|
+
if notify is not None:
|
|
441
|
+
notify(url)
|
|
442
|
+
if open_browser:
|
|
443
|
+
# Opening a browser can block on some desktops; never stall the terminal.
|
|
444
|
+
await asyncio.to_thread(_open_browser, url)
|
|
445
|
+
code = await _receive_code(port, verifier, timeout)
|
|
446
|
+
tokens = await asyncio.to_thread(
|
|
447
|
+
lambda: _parse_tokens(
|
|
448
|
+
_post_tokens(
|
|
449
|
+
{
|
|
450
|
+
"grant_type": "authorization_code",
|
|
451
|
+
"code": code,
|
|
452
|
+
"state": verifier,
|
|
453
|
+
"redirect_uri": redirect_uri(port),
|
|
454
|
+
"code_verifier": verifier,
|
|
455
|
+
},
|
|
456
|
+
transport,
|
|
457
|
+
)
|
|
458
|
+
)
|
|
459
|
+
)
|
|
460
|
+
await asyncio.to_thread(write_tokens, path, tokens)
|
|
461
|
+
return tokens
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def _open_browser(url: str) -> None:
|
|
465
|
+
try:
|
|
466
|
+
webbrowser.open(url)
|
|
467
|
+
except Exception:
|
|
468
|
+
# A headless machine is expected; the URL was already displayed.
|
|
469
|
+
pass
|