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.
Files changed (163) hide show
  1. pcode/__init__.py +7 -0
  2. pcode/_job_supervisor.py +65 -0
  3. pcode/agent.py +592 -0
  4. pcode/anthropic_oauth.py +469 -0
  5. pcode/app.py +3165 -0
  6. pcode/appearance.py +181 -0
  7. pcode/aside.py +541 -0
  8. pcode/aside_guard.py +39 -0
  9. pcode/aside_ui.py +683 -0
  10. pcode/auth.py +113 -0
  11. pcode/block.py +39 -0
  12. pcode/browser.py +393 -0
  13. pcode/cache_diagnostics.py +336 -0
  14. pcode/cache_settings.py +58 -0
  15. pcode/cache_warnings.py +139 -0
  16. pcode/claude_sdk/__init__.py +96 -0
  17. pcode/claude_sdk/cli.py +52 -0
  18. pcode/claude_sdk/errors.py +61 -0
  19. pcode/claude_sdk/messages.py +154 -0
  20. pcode/claude_sdk/model.py +179 -0
  21. pcode/claude_sdk/resume.py +114 -0
  22. pcode/claude_sdk/session.py +547 -0
  23. pcode/claude_sdk/session_pool.py +251 -0
  24. pcode/claude_sdk/workspace.py +34 -0
  25. pcode/cli.py +73 -0
  26. pcode/clipboard.py +76 -0
  27. pcode/code_mode.py +43 -0
  28. pcode/codex_login.py +189 -0
  29. pcode/command_transcript.py +71 -0
  30. pcode/commands.py +145 -0
  31. pcode/compaction.py +457 -0
  32. pcode/completion.py +263 -0
  33. pcode/config.py +223 -0
  34. pcode/context_breakdown.py +178 -0
  35. pcode/context_usage.py +52 -0
  36. pcode/controller.py +3496 -0
  37. pcode/conversation_ids.py +27 -0
  38. pcode/conversation_tree.py +160 -0
  39. pcode/copy_ui.py +172 -0
  40. pcode/delegation.py +236 -0
  41. pcode/delta.py +295 -0
  42. pcode/diagnostics.py +226 -0
  43. pcode/edit_preview.py +158 -0
  44. pcode/edit_transcript.py +287 -0
  45. pcode/edit_ui.py +850 -0
  46. pcode/edits.py +162 -0
  47. pcode/email_remote/__init__.py +14 -0
  48. pcode/email_remote/cli.py +133 -0
  49. pcode/email_remote/listener.py +695 -0
  50. pcode/email_remote/mailbox.py +336 -0
  51. pcode/email_remote/outbound.py +75 -0
  52. pcode/email_remote/parsing.py +226 -0
  53. pcode/email_remote/render.py +175 -0
  54. pcode/email_remote/state.py +79 -0
  55. pcode/error_report.py +38 -0
  56. pcode/ext.py +602 -0
  57. pcode/extension_guide.md +311 -0
  58. pcode/extensions/browser.py +284 -0
  59. pcode/extensions/ponytail.py +217 -0
  60. pcode/extensions/sandbox.py +138 -0
  61. pcode/extensions/session_history.py +170 -0
  62. pcode/extensions/web_research.py +89 -0
  63. pcode/file_refs.py +265 -0
  64. pcode/filesystem.py +138 -0
  65. pcode/frame.py +149 -0
  66. pcode/gateway.py +465 -0
  67. pcode/git_diff.py +497 -0
  68. pcode/history.py +589 -0
  69. pcode/history_cursor.py +147 -0
  70. pcode/history_embeddings.py +102 -0
  71. pcode/host.py +941 -0
  72. pcode/host_protocol.py +291 -0
  73. pcode/host_ui.py +225 -0
  74. pcode/input_keys.py +35 -0
  75. pcode/inspection.py +289 -0
  76. pcode/inspector_ui.py +448 -0
  77. pcode/isolated_delegation.py +425 -0
  78. pcode/job_notices.py +54 -0
  79. pcode/jobs.py +584 -0
  80. pcode/jobs_ui.py +265 -0
  81. pcode/keymap.py +215 -0
  82. pcode/layout_speed.py +103 -0
  83. pcode/links.py +90 -0
  84. pcode/links_ui.py +200 -0
  85. pcode/live.py +1301 -0
  86. pcode/llm_proxy.py +44 -0
  87. pcode/logins.py +233 -0
  88. pcode/mcp.py +683 -0
  89. pcode/mcp_notice.py +91 -0
  90. pcode/mcp_oauth.py +381 -0
  91. pcode/meridian.py +233 -0
  92. pcode/meridian_process.py +337 -0
  93. pcode/meridian_reminders.py +107 -0
  94. pcode/meridian_setup.py +356 -0
  95. pcode/model_metadata.py +451 -0
  96. pcode/model_ui.py +168 -0
  97. pcode/models.py +209 -0
  98. pcode/native_results.py +119 -0
  99. pcode/oauth_pages.py +66 -0
  100. pcode/output_limits.py +41 -0
  101. pcode/paste.py +45 -0
  102. pcode/path_completion.py +66 -0
  103. pcode/plan_preview.py +131 -0
  104. pcode/plan_sizes.py +35 -0
  105. pcode/planning.py +161 -0
  106. pcode/popup_ui.py +873 -0
  107. pcode/preferences.py +1176 -0
  108. pcode/prefix_keys.py +490 -0
  109. pcode/profile_benchmark.py +332 -0
  110. pcode/profiling.py +566 -0
  111. pcode/project_trust.py +94 -0
  112. pcode/prompt_keys.py +286 -0
  113. pcode/remote.py +717 -0
  114. pcode/remote_print.py +125 -0
  115. pcode/remote_profile.py +237 -0
  116. pcode/replay_benchmark.py +319 -0
  117. pcode/repo_context.py +158 -0
  118. pcode/retries.py +28 -0
  119. pcode/rpc.py +280 -0
  120. pcode/runtime.py +244 -0
  121. pcode/sandbox.py +454 -0
  122. pcode/session_naming.py +82 -0
  123. pcode/session_ui.py +550 -0
  124. pcode/sessions.py +892 -0
  125. pcode/shell.py +113 -0
  126. pcode/shell_mode.py +172 -0
  127. pcode/shell_tools.py +459 -0
  128. pcode/skills.py +161 -0
  129. pcode/stall_log.py +187 -0
  130. pcode/startup_input.py +96 -0
  131. pcode/steering.py +27 -0
  132. pcode/stream_display.py +188 -0
  133. pcode/stream_events.py +437 -0
  134. pcode/strict_tools.py +130 -0
  135. pcode/syntax.py +43 -0
  136. pcode/syntax_colors.py +206 -0
  137. pcode/task_prompt.py +28 -0
  138. pcode/task_worktrees.py +482 -0
  139. pcode/terminal_notify.py +383 -0
  140. pcode/theme.py +135 -0
  141. pcode/theme_gallery.py +88 -0
  142. pcode/thinking_markdown.py +41 -0
  143. pcode/token_accounting.py +67 -0
  144. pcode/tool_display.py +784 -0
  145. pcode/tool_output_limits.py +108 -0
  146. pcode/tool_panel.py +377 -0
  147. pcode/transcript_log.py +127 -0
  148. pcode/transcript_notice.py +103 -0
  149. pcode/tree_ui.py +288 -0
  150. pcode/turn.py +44 -0
  151. pcode/ui.py +3745 -0
  152. pcode/usage.py +293 -0
  153. pcode/word_wrap.py +107 -0
  154. pcode/worker_ui.py +235 -0
  155. pcode/workers.py +136 -0
  156. pcode/workspace.py +57 -0
  157. pcode/workspace_filesystem.py +72 -0
  158. pcode/worktree.py +650 -0
  159. pcode-0.1.0.dist-info/METADATA +145 -0
  160. pcode-0.1.0.dist-info/RECORD +163 -0
  161. pcode-0.1.0.dist-info/WHEEL +4 -0
  162. pcode-0.1.0.dist-info/entry_points.txt +3 -0
  163. pcode-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -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