induscode 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 (167) hide show
  1. induscode/__init__.py +56 -0
  2. induscode/addons/__init__.py +176 -0
  3. induscode/addons/contract.py +923 -0
  4. induscode/addons/dispatch/__init__.py +43 -0
  5. induscode/addons/dispatch/event_dispatcher.py +348 -0
  6. induscode/addons/dispatch/tool_interceptor.py +349 -0
  7. induscode/addons/host.py +469 -0
  8. induscode/addons/loader.py +314 -0
  9. induscode/addons/manifest.py +232 -0
  10. induscode/addons/surface.py +199 -0
  11. induscode/boot/__init__.py +108 -0
  12. induscode/boot/auth_vault.py +323 -0
  13. induscode/boot/boot.py +210 -0
  14. induscode/boot/contract.py +223 -0
  15. induscode/boot/invocation.py +117 -0
  16. induscode/boot/runners/__init__.py +42 -0
  17. induscode/boot/runners/link_runner.py +82 -0
  18. induscode/boot/runners/oneshot_runner.py +85 -0
  19. induscode/boot/runners/registry.py +46 -0
  20. induscode/boot/runners/repl_runner.py +340 -0
  21. induscode/boot/runners/session.py +549 -0
  22. induscode/boot/stages.py +198 -0
  23. induscode/boot/upgrade/__init__.py +36 -0
  24. induscode/boot/upgrade/apply.py +125 -0
  25. induscode/boot/upgrade/upgrades.py +136 -0
  26. induscode/briefing/__init__.py +115 -0
  27. induscode/briefing/compose.py +414 -0
  28. induscode/briefing/contract.py +528 -0
  29. induscode/briefing/macros.py +721 -0
  30. induscode/briefing/skills.py +417 -0
  31. induscode/capability_deck/__init__.py +233 -0
  32. induscode/capability_deck/bridge_ledger/__init__.py +66 -0
  33. induscode/capability_deck/bridge_ledger/key.py +181 -0
  34. induscode/capability_deck/bridge_ledger/ledger.py +276 -0
  35. induscode/capability_deck/bridge_ledger/network.py +336 -0
  36. induscode/capability_deck/builtin_bridge.py +358 -0
  37. induscode/capability_deck/cards/__init__.py +116 -0
  38. induscode/capability_deck/cards/bg_process.py +482 -0
  39. induscode/capability_deck/cards/memory.py +226 -0
  40. induscode/capability_deck/cards/saas.py +280 -0
  41. induscode/capability_deck/cards/task.py +256 -0
  42. induscode/capability_deck/cards/todo.py +312 -0
  43. induscode/capability_deck/contract.py +450 -0
  44. induscode/capability_deck/manifest.py +126 -0
  45. induscode/capability_deck/provision.py +217 -0
  46. induscode/channels/__init__.py +146 -0
  47. induscode/channels/contract.py +585 -0
  48. induscode/channels/framer.py +132 -0
  49. induscode/channels/link/__init__.py +50 -0
  50. induscode/channels/link/dialog.py +246 -0
  51. induscode/channels/link/driver.py +308 -0
  52. induscode/channels/link/server.py +217 -0
  53. induscode/channels/oneshot.py +178 -0
  54. induscode/channels/ops.py +140 -0
  55. induscode/channels/session_ops.py +172 -0
  56. induscode/conductor/__init__.py +240 -0
  57. induscode/conductor/catalog.py +309 -0
  58. induscode/conductor/conductor.py +1084 -0
  59. induscode/conductor/contract.py +1035 -0
  60. induscode/conductor/matcher.py +291 -0
  61. induscode/conductor/serialize.py +575 -0
  62. induscode/conductor/signal_hub.py +382 -0
  63. induscode/conductor/skill_parse.py +294 -0
  64. induscode/conductor/transcript_store.py +449 -0
  65. induscode/console/__init__.py +236 -0
  66. induscode/console/app.py +1677 -0
  67. induscode/console/components/__init__.py +62 -0
  68. induscode/console/components/banner.py +499 -0
  69. induscode/console/components/banner_sweep.py +188 -0
  70. induscode/console/components/emblem.py +181 -0
  71. induscode/console/components/status_bar.py +102 -0
  72. induscode/console/contract.py +836 -0
  73. induscode/console/input/__init__.py +107 -0
  74. induscode/console/input/chord.py +197 -0
  75. induscode/console/input/dir_reader.py +113 -0
  76. induscode/console/input/intents.py +258 -0
  77. induscode/console/input/providers.py +469 -0
  78. induscode/console/mount.py +137 -0
  79. induscode/console/overlays/__init__.py +94 -0
  80. induscode/console/overlays/auth.py +503 -0
  81. induscode/console/overlays/pickers.py +526 -0
  82. induscode/console/overlays/router.py +129 -0
  83. induscode/console/overlays/sessions.py +232 -0
  84. induscode/console/reducer.py +145 -0
  85. induscode/console/resume_picker.py +156 -0
  86. induscode/console/slash_commands/__init__.py +78 -0
  87. induscode/console/slash_commands/builtins.py +254 -0
  88. induscode/console/slash_commands/dynamic.py +217 -0
  89. induscode/console/slash_commands/integrations.py +949 -0
  90. induscode/console/slash_commands/transcript.py +404 -0
  91. induscode/console/slash_commands/workbench.py +430 -0
  92. induscode/console/startup.py +434 -0
  93. induscode/console/theme/__init__.py +44 -0
  94. induscode/console/theme/adapter.py +168 -0
  95. induscode/console/theme/palette.py +128 -0
  96. induscode/console/theme/resolve.py +123 -0
  97. induscode/console/theme/tokens.py +185 -0
  98. induscode/console_slash/__init__.py +111 -0
  99. induscode/console_slash/contract.py +185 -0
  100. induscode/console_slash/registry.py +140 -0
  101. induscode/console_slash/resolve.py +194 -0
  102. induscode/console_slash/shared.py +172 -0
  103. induscode/entry.py +108 -0
  104. induscode/insight/__init__.py +153 -0
  105. induscode/insight/collector.py +73 -0
  106. induscode/insight/replay.py +305 -0
  107. induscode/insight/wrapper.py +1115 -0
  108. induscode/kit/__init__.py +82 -0
  109. induscode/kit/clipboard_image.py +215 -0
  110. induscode/kit/external_editor.py +120 -0
  111. induscode/kit/image.py +188 -0
  112. induscode/kit/shell.py +89 -0
  113. induscode/kit/tool_fetch.py +288 -0
  114. induscode/launch/__init__.py +224 -0
  115. induscode/launch/catalog.py +310 -0
  116. induscode/launch/contract.py +569 -0
  117. induscode/launch/credentials.py +852 -0
  118. induscode/launch/invocation/__init__.py +39 -0
  119. induscode/launch/invocation/attachments.py +281 -0
  120. induscode/launch/invocation/flags.py +210 -0
  121. induscode/launch/invocation/read.py +369 -0
  122. induscode/launch/invocation/usage.py +110 -0
  123. induscode/launch/oauth.py +808 -0
  124. induscode/launch/packages.py +299 -0
  125. induscode/launch/pickers.py +291 -0
  126. induscode/py.typed +0 -0
  127. induscode/runtime_bridge/__init__.py +166 -0
  128. induscode/runtime_bridge/bridges/__init__.py +66 -0
  129. induscode/runtime_bridge/bridges/_drive.py +268 -0
  130. induscode/runtime_bridge/bridges/builtins.py +177 -0
  131. induscode/runtime_bridge/bridges/claude_cli.py +198 -0
  132. induscode/runtime_bridge/bridges/codex_cli.py +203 -0
  133. induscode/runtime_bridge/bridges/indusagi_cli.py +217 -0
  134. induscode/runtime_bridge/broker.py +397 -0
  135. induscode/runtime_bridge/contract.py +734 -0
  136. induscode/runtime_bridge/sink.py +351 -0
  137. induscode/sessions/__init__.py +25 -0
  138. induscode/sessions/contract.py +119 -0
  139. induscode/sessions/library.py +350 -0
  140. induscode/settings/__init__.py +47 -0
  141. induscode/settings/contract.py +313 -0
  142. induscode/settings/manager.py +268 -0
  143. induscode/transcript_export/__init__.py +109 -0
  144. induscode/transcript_export/contract.py +522 -0
  145. induscode/transcript_export/publish.py +455 -0
  146. induscode/transcript_export/sgr.py +566 -0
  147. induscode/transcript_export/template.py +319 -0
  148. induscode/transcript_export/theme_bridge.py +325 -0
  149. induscode/window_budget/__init__.py +76 -0
  150. induscode/window_budget/budget/__init__.py +26 -0
  151. induscode/window_budget/budget/estimate.py +273 -0
  152. induscode/window_budget/budget/gate.py +60 -0
  153. induscode/window_budget/budget/slice.py +145 -0
  154. induscode/window_budget/condenser.py +170 -0
  155. induscode/window_budget/contract.py +329 -0
  156. induscode/window_budget/summarize/__init__.py +33 -0
  157. induscode/window_budget/summarize/condense.py +212 -0
  158. induscode/window_budget/summarize/prompt.py +241 -0
  159. induscode/workspace/__init__.py +30 -0
  160. induscode/workspace/brand.py +96 -0
  161. induscode/workspace/locator.py +269 -0
  162. induscode-0.1.0.dist-info/METADATA +97 -0
  163. induscode-0.1.0.dist-info/RECORD +167 -0
  164. induscode-0.1.0.dist-info/WHEEL +4 -0
  165. induscode-0.1.0.dist-info/entry_points.txt +3 -0
  166. induscode-0.1.0.dist-info/licenses/CREDITS.md +22 -0
  167. induscode-0.1.0.dist-info/licenses/NOTICE +7 -0
@@ -0,0 +1,808 @@
1
+ """OAuth integration — the browser-sign-in counterpart to the api-key flow.
2
+
3
+ **THE adapter** (port plan gap #1). The TS build leaned on the framework's
4
+ provider-object registry: each ``OAuthProviderInterface`` owned a whole
5
+ ``login(callbacks)`` choreography, ``registerOAuthProvider`` populated a
6
+ registry at import time, and ``startOAuthLogin`` drove ``provider.login`` and
7
+ persisted the result. The Python framework ships no provider objects — its
8
+ OAuth surface is transport primitives
9
+ (:mod:`indusagi.llmgateway.credentials.oauth`:
10
+ :data:`~indusagi.llmgateway.credentials.oauth.OAUTH_PROVIDERS` /
11
+ :func:`~indusagi.llmgateway.credentials.oauth.oauth_config_for` /
12
+ :func:`~indusagi.llmgateway.credentials.oauth.build_auth_url` /
13
+ :func:`~indusagi.llmgateway.credentials.oauth.exchange_code` /
14
+ :func:`~indusagi.llmgateway.credentials.oauth.refresh_token`) plus PKCE
15
+ (:mod:`indusagi.llmgateway.credentials.pkce`). This module re-choreographs the
16
+ TS ``provider.login(callbacks)`` flow over those primitives:
17
+
18
+ 1. mint a PKCE pair and a CSRF ``state``;
19
+ 2. build the authorization URL and surface it through ``callbacks.on_auth``
20
+ (the credential command opens it via :func:`open_login_url`);
21
+ 3. read the pasted authorization code (or full redirect URL) back through
22
+ ``callbacks.on_prompt`` — the paste choreography, exactly as the framework
23
+ ``auth_cli`` does it: no local callback HTTP server exists anywhere in this
24
+ lineage;
25
+ 4. exchange the code for tokens and map the framework
26
+ :class:`~indusagi.llmgateway.credentials.oauth.OAuthTokens` into the app's
27
+ vault-stored :class:`~.contract.OAuthCredentials` shape;
28
+ 5. persist through the injected :class:`~.contract.AuthVault` Protocol — the
29
+ vault (the boot layer's disk implementation) stays the single
30
+ ``auth.json`` writer.
31
+
32
+ On top of the flow it offers what the credential command needs:
33
+
34
+ - :func:`register_built_in_oauth_providers` — populates the adapter registry
35
+ from the framework's :data:`OAUTH_PROVIDERS` config table. **Explicitly** —
36
+ there is deliberately no import-time registration (the TS module registered
37
+ on import; the port plan forbids import-time side effects). The boot layer
38
+ primes the registry as a stage; tests prime it in a fixture.
39
+ - :func:`list_login_providers` — the merged sign-in directory, every entry
40
+ tagged with whether it authenticates by browser sign-in or by api key.
41
+ - :func:`start_oauth_login` — drives the re-choreographed login flow and
42
+ persists the resulting credentials through the vault.
43
+ - :func:`refresh_oauth_credentials` — the refresh seam (the TS
44
+ ``ensureFreshOAuthCredentials`` analogue) the boot vault calls when a stored
45
+ browser token has expired.
46
+ - :func:`open_login_url` — a default-browser launcher
47
+ (:mod:`webbrowser`-backed; the TS per-platform ``launchAttempts`` spawn
48
+ table collapses into the stdlib, which encodes the same fallbacks).
49
+
50
+ Adaptation notes
51
+ ----------------
52
+ - The framework's :data:`OAUTH_PROVIDERS` table carries **two** providers —
53
+ ``anthropic`` and ``openai`` — and its ``ProviderId`` literal does not admit
54
+ a ``github-copilot`` id, so a Copilot config cannot live framework-side
55
+ without widening that type. The TS build registered three provider objects
56
+ (``anthropic``, ``openai-codex``, ``github-copilot``); the first two map to
57
+ the framework's authorization-code (PKCE) configs, but **GitHub Copilot uses
58
+ the OAuth device-authorization grant** (RFC 8628) — a flow the framework has
59
+ no primitive for. So the Copilot provider is registered **app-side** here,
60
+ with its own device-flow login/refresh driver ported from the TS
61
+ ``githubCopilotOAuthProvider``. The built-in set is therefore the two
62
+ framework configs *plus* the app's ``github-copilot`` adapter, and the tests
63
+ pin *that* set.
64
+ - A provider's login choreography is its adapter's
65
+ :attr:`OAuthProviderAdapter.driver`. The default driver is the
66
+ authorization-code (PKCE) flow the two framework configs run; Copilot
67
+ supplies the device-flow driver. ``start_oauth_login`` always drives through
68
+ the adapter's driver, so adding a new flow is a per-adapter change, not a
69
+ fork in the command path.
70
+ - ``callbacks.on_auth`` may return an awaitable; the driver awaits it. (TS
71
+ fired the browser-open as a dangling ``void`` promise; awaiting keeps the
72
+ print ordering deterministic without changing the callback shape tests
73
+ drive.)
74
+ """
75
+
76
+ from __future__ import annotations
77
+
78
+ import asyncio
79
+ import inspect
80
+ import os
81
+ import time
82
+ import uuid
83
+ from collections.abc import Awaitable, Callable
84
+ from dataclasses import dataclass
85
+ from typing import Any, Final, Literal, TypeAlias
86
+ from urllib.parse import parse_qs, urlsplit
87
+
88
+ from indusagi.llmgateway.credentials.oauth import (
89
+ OAUTH_PROVIDERS,
90
+ AuthUrlInputs,
91
+ OAuthConfig,
92
+ build_auth_url,
93
+ exchange_code,
94
+ refresh_token,
95
+ )
96
+ from indusagi.llmgateway.credentials.pkce import create_pkce_pair
97
+
98
+ from .contract import AuthVault, OAuthCredentials
99
+
100
+ __all__ = [
101
+ "AuthKind",
102
+ "LoginDriver",
103
+ "LoginProvider",
104
+ "OAuthAuthorization",
105
+ "OAuthLoginCallbacks",
106
+ "OAuthLoginError",
107
+ "OAuthLoginResult",
108
+ "OAuthPrompt",
109
+ "OAuthProviderAdapter",
110
+ "RefreshDriver",
111
+ "get_oauth_provider",
112
+ "get_oauth_providers",
113
+ "github_copilot_adapter",
114
+ "list_login_providers",
115
+ "open_login_url",
116
+ "refresh_oauth_credentials",
117
+ "register_built_in_oauth_providers",
118
+ "register_oauth_provider",
119
+ "start_oauth_login",
120
+ ]
121
+
122
+
123
+ # ---------------------------------------------------------------------------
124
+ # Login callbacks (the TS OAuthLoginCallbacks shape, app-owned in the port)
125
+ # ---------------------------------------------------------------------------
126
+
127
+
128
+ @dataclass(frozen=True, slots=True)
129
+ class OAuthAuthorization:
130
+ """What ``on_auth`` receives: the consent URL the user must visit, plus
131
+ optional human-facing instructions."""
132
+
133
+ url: str
134
+ instructions: str | None = None
135
+
136
+
137
+ @dataclass(frozen=True, slots=True)
138
+ class OAuthPrompt:
139
+ """What ``on_prompt`` receives: the question the flow needs answered (the
140
+ pasted authorization code)."""
141
+
142
+ message: str
143
+
144
+
145
+ @dataclass(frozen=True, slots=True)
146
+ class OAuthLoginCallbacks:
147
+ """The interactive seams the login flow drives. ``on_auth`` surfaces the
148
+ consent URL (and may return an awaitable, which is awaited); ``on_prompt``
149
+ reads one line of user input; ``on_progress`` relays status lines."""
150
+
151
+ on_auth: Callable[[OAuthAuthorization], None | Awaitable[None]]
152
+ on_prompt: Callable[[OAuthPrompt], Awaitable[str]]
153
+ on_progress: Callable[[str], None] | None = None
154
+
155
+
156
+ class OAuthLoginError(RuntimeError):
157
+ """A browser sign-in failure (unknown provider, aborted paste, or a
158
+ failed exchange wrapped by the gateway)."""
159
+
160
+
161
+ # ---------------------------------------------------------------------------
162
+ # Provider registry (the adapter's own — the framework has none to prime)
163
+ # ---------------------------------------------------------------------------
164
+
165
+
166
+ #: A provider's login choreography: drive the callbacks and return the
167
+ #: vault-shaped credentials. The default is the authorization-code (PKCE)
168
+ #: flow; a provider whose sign-in differs (GitHub Copilot's device grant)
169
+ #: supplies its own.
170
+ LoginDriver: TypeAlias = Callable[
171
+ ["OAuthProviderAdapter", "OAuthLoginCallbacks"], Awaitable[OAuthCredentials]
172
+ ]
173
+
174
+ #: A provider's refresh choreography: mint fresh credentials from a stored
175
+ #: refresh token. The default trades a refresh token at the framework token
176
+ #: endpoint; a device-flow provider re-derives a short-lived token instead.
177
+ RefreshDriver: TypeAlias = Callable[
178
+ ["OAuthProviderAdapter", OAuthCredentials], Awaitable[OAuthCredentials]
179
+ ]
180
+
181
+
182
+ @dataclass(frozen=True, slots=True)
183
+ class OAuthProviderAdapter:
184
+ """One registered browser-sign-in provider: its stable id, a human name,
185
+ the framework :class:`OAuthConfig` the flow runs over, and the
186
+ login/refresh drivers that choreograph it.
187
+
188
+ ``driver`` / ``refresh_driver`` default to ``None``, which selects the
189
+ built-in authorization-code (PKCE) flow the two framework configs use. A
190
+ provider with a different sign-in shape — GitHub Copilot's device grant —
191
+ supplies its own drivers; ``config`` then carries only what that driver
192
+ reads (Copilot reads none of it, so a placeholder config stands in)."""
193
+
194
+ # Stable provider id (the registry key).
195
+ id: str
196
+ # Human-facing provider name (the directory fallback label).
197
+ name: str
198
+ # The framework endpoint + client configuration the default flow drives.
199
+ config: OAuthConfig
200
+ # The login choreography; None selects the default PKCE flow.
201
+ driver: LoginDriver | None = None
202
+ # The refresh choreography; None selects the default token-endpoint refresh.
203
+ refresh_driver: RefreshDriver | None = None
204
+
205
+
206
+ #: Human labels for the provider ids this app supplies one for. Covers both
207
+ #: the Python framework's config ids and the TS lineage's provider-object ids
208
+ #: so the merged directory reads well whichever the table grows to carry.
209
+ _OAUTH_LABELS: Final[dict[str, str]] = {
210
+ "anthropic": "Anthropic (Claude sign-in)",
211
+ "openai": "OpenAI (ChatGPT sign-in)",
212
+ "openai-codex": "OpenAI (ChatGPT sign-in)",
213
+ "github-copilot": "GitHub Copilot",
214
+ }
215
+
216
+ #: The adapter registry. Module-held like the TS framework registry was, but
217
+ #: populated only by the explicit prime below — never at import time.
218
+ _REGISTRY: dict[str, OAuthProviderAdapter] = {}
219
+
220
+
221
+ def register_oauth_provider(provider: OAuthProviderAdapter) -> None:
222
+ """Register (or replace) one browser-sign-in provider."""
223
+ _REGISTRY[provider.id] = provider
224
+
225
+
226
+ def get_oauth_provider(provider_id: str) -> OAuthProviderAdapter | None:
227
+ """Look up a registered provider by id, or ``None``."""
228
+ return _REGISTRY.get(provider_id)
229
+
230
+
231
+ def get_oauth_providers() -> list[OAuthProviderAdapter]:
232
+ """Every registered provider, in registration order."""
233
+ return list(_REGISTRY.values())
234
+
235
+
236
+ def _reset_oauth_providers_for_tests() -> None:
237
+ """Test hook: empty the registry so suites stay hermetic."""
238
+ _REGISTRY.clear()
239
+
240
+
241
+ def register_built_in_oauth_providers() -> list[str]:
242
+ """Register the built-in sign-in providers, idempotently.
243
+
244
+ Walks the framework :data:`OAUTH_PROVIDERS` config table and registers an
245
+ adapter for each authorization-code provider (``anthropic``, ``openai``)
246
+ not already present, then registers the **app-side**
247
+ :func:`github_copilot_adapter` whose device-flow driver has no framework
248
+ config (see the module docstring). Repeated calls leave the registry
249
+ unchanged. Returns the ids that are registered after the call, for callers
250
+ that want to confirm the set.
251
+
252
+ This is the **explicit prime** the boot layer runs as a stage; nothing
253
+ runs it at import time.
254
+ """
255
+ for provider_id, config in OAUTH_PROVIDERS.items():
256
+ if get_oauth_provider(provider_id) is None:
257
+ register_oauth_provider(
258
+ OAuthProviderAdapter(
259
+ id=str(provider_id),
260
+ name=_OAUTH_LABELS.get(str(provider_id), str(provider_id)),
261
+ config=config,
262
+ )
263
+ )
264
+ if get_oauth_provider("github-copilot") is None:
265
+ register_oauth_provider(github_copilot_adapter())
266
+ return [provider.id for provider in get_oauth_providers()]
267
+
268
+
269
+ # ---------------------------------------------------------------------------
270
+ # Merged sign-in directory
271
+ # ---------------------------------------------------------------------------
272
+
273
+ #: How a provider authenticates in the merged sign-in directory.
274
+ AuthKind: TypeAlias = Literal["oauth", "apiKey"]
275
+
276
+
277
+ @dataclass(frozen=True, slots=True)
278
+ class LoginProvider:
279
+ """One row of the merged sign-in directory."""
280
+
281
+ # Stable provider id (adapter registry id or api-key directory id).
282
+ id: str
283
+ # Human-facing label for menus and prompts.
284
+ label: str
285
+ # Whether this entry signs in via the browser or stores an api key.
286
+ auth_kind: AuthKind
287
+
288
+
289
+ def list_login_providers() -> list[LoginProvider]:
290
+ """The merged sign-in directory: every registered browser-sign-in provider
291
+ plus every api-key provider the app knows, each tagged with its auth kind.
292
+
293
+ Browser-sign-in providers lead the list (they are the preferred path for
294
+ the providers that support them); an api-key provider that shares an id
295
+ with a registered sign-in provider is still listed, so a user can choose
296
+ either path for the same underlying account.
297
+ """
298
+ # Imported lazily: credentials.py imports this module at the top level
299
+ # (the same cycle the TS pair had; one side must defer in Python).
300
+ from .credentials import PROVIDER_DIRECTORY
301
+
302
+ oauth_rows = [
303
+ LoginProvider(
304
+ id=provider.id,
305
+ label=_OAUTH_LABELS.get(provider.id, provider.name),
306
+ auth_kind="oauth",
307
+ )
308
+ for provider in get_oauth_providers()
309
+ ]
310
+ api_key_rows = [
311
+ LoginProvider(id=entry.id, label=entry.label, auth_kind="apiKey")
312
+ for entry in PROVIDER_DIRECTORY
313
+ ]
314
+ return [*oauth_rows, *api_key_rows]
315
+
316
+
317
+ # ---------------------------------------------------------------------------
318
+ # Browser launcher
319
+ # ---------------------------------------------------------------------------
320
+
321
+
322
+ def _is_web_url(url: str) -> bool:
323
+ """Accept only ``http``/``https`` urls; anything else is refused before
324
+ launch."""
325
+ try:
326
+ split = urlsplit(url)
327
+ except ValueError:
328
+ return False
329
+ return split.scheme in ("http", "https") and bool(split.netloc)
330
+
331
+
332
+ async def open_login_url(url: str) -> bool:
333
+ """Open a sign-in url in the user's default browser.
334
+
335
+ Refuses anything that is not an ``http``/``https`` url and resolves
336
+ ``False``, so a provider that hands back a non-web redirect never spawns a
337
+ process. The launch itself goes through :func:`webbrowser.open`, which
338
+ encodes the per-platform default-browser fallbacks the TS spawn table
339
+ spelled out by hand. Resolves ``True`` if a browser was launched,
340
+ ``False`` otherwise.
341
+ """
342
+ if not _is_web_url(url):
343
+ return False
344
+ import asyncio
345
+ import webbrowser
346
+
347
+ try:
348
+ return bool(await asyncio.to_thread(webbrowser.open, url))
349
+ except Exception:
350
+ return False
351
+
352
+
353
+ # ---------------------------------------------------------------------------
354
+ # Login flow
355
+ # ---------------------------------------------------------------------------
356
+
357
+
358
+ @dataclass(frozen=True, slots=True)
359
+ class OAuthLoginResult:
360
+ """The outcome of :func:`start_oauth_login`: the provider and account the
361
+ credentials were stored under, and whether this account became the
362
+ default."""
363
+
364
+ # The provider id the sign-in completed for.
365
+ provider: str
366
+ # The account name the credentials were stored under.
367
+ account: str
368
+ # Whether the new account was stored as the provider default.
369
+ made_default: bool
370
+
371
+
372
+ def _extract_code(pasted: str) -> str:
373
+ """Pull an authorization code out of whatever the user pasted. They may
374
+ paste the bare code, or the whole redirect URL — in the latter case the
375
+ ``code`` query parameter is read. A ``code#state`` fragment (some
376
+ providers append the state) is trimmed to the code half."""
377
+ if len(pasted) == 0:
378
+ return ""
379
+ if "://" in pasted or pasted.startswith("http"):
380
+ try:
381
+ split = urlsplit(pasted)
382
+ except ValueError:
383
+ split = None
384
+ if split is not None and split.scheme and (split.netloc or split.path):
385
+ from_query = parse_qs(split.query).get("code", [])
386
+ if from_query and from_query[0]:
387
+ return from_query[0]
388
+ hash_index = pasted.find("#")
389
+ return pasted if hash_index == -1 else pasted[:hash_index]
390
+
391
+
392
+ def _to_credentials(
393
+ tokens: object, *, prior_refresh: str | None = None
394
+ ) -> OAuthCredentials:
395
+ """Map a framework ``OAuthTokens`` record into the vault's
396
+ :class:`~.contract.OAuthCredentials` shape, carrying a prior refresh token
397
+ forward when the server did not rotate one."""
398
+ access = getattr(tokens, "access_token", "")
399
+ refresh = getattr(tokens, "refresh_token", None)
400
+ expires = getattr(tokens, "expires_at", None)
401
+ return OAuthCredentials(
402
+ access=str(access),
403
+ refresh=refresh if isinstance(refresh, str) else prior_refresh,
404
+ expires=expires if isinstance(expires, int) else None,
405
+ )
406
+
407
+
408
+ async def _drive_login(
409
+ adapter: OAuthProviderAdapter,
410
+ callbacks: OAuthLoginCallbacks,
411
+ ) -> OAuthCredentials:
412
+ """The re-choreographed ``provider.login(callbacks)``: PKCE + auth URL →
413
+ ``on_auth`` → pasted code via ``on_prompt`` → ``exchange_code`` →
414
+ credentials. Raises :class:`OAuthLoginError` when the user pastes nothing;
415
+ a failed exchange propagates the gateway error."""
416
+ pkce = create_pkce_pair()
417
+ state = str(uuid.uuid4())
418
+ url = build_auth_url(adapter.config, AuthUrlInputs(state=state, pkce=pkce))
419
+
420
+ handled = callbacks.on_auth(
421
+ OAuthAuthorization(
422
+ url=url,
423
+ instructions=(
424
+ "Approve access, then paste the authorization code "
425
+ "(or the full redirect URL) back here."
426
+ ),
427
+ )
428
+ )
429
+ if inspect.isawaitable(handled):
430
+ await handled
431
+
432
+ pasted = (
433
+ await callbacks.on_prompt(OAuthPrompt(message="Paste the authorization code:"))
434
+ ).strip()
435
+ code = _extract_code(pasted)
436
+ if len(code) == 0:
437
+ raise OAuthLoginError("No authorization code was entered.")
438
+
439
+ if callbacks.on_progress is not None:
440
+ callbacks.on_progress("Exchanging the authorization code for tokens...")
441
+ tokens = await exchange_code(adapter.config, code, pkce.verifier)
442
+ return _to_credentials(tokens)
443
+
444
+
445
+ async def start_oauth_login(
446
+ provider_id: str,
447
+ callbacks: OAuthLoginCallbacks,
448
+ vault: AuthVault,
449
+ account: str = "default",
450
+ ) -> OAuthLoginResult:
451
+ """Drive a provider's browser sign-in and persist the result.
452
+
453
+ Looks the provider up in the (primed) adapter registry, runs the
454
+ re-choreographed login flow with the supplied callbacks, and writes the
455
+ returned credentials into the vault under ``account``. The first account
456
+ stored for a provider becomes its default. Raises when the provider id is
457
+ not registered or the login flow fails.
458
+
459
+ :param provider_id: the registered sign-in provider id
460
+ :param callbacks: the login callbacks (url surface, prompt, progress)
461
+ :param vault: the credential store the result is persisted through
462
+ :param account: the account name to store under (defaults to "default")
463
+ """
464
+ adapter = get_oauth_provider(provider_id)
465
+ if adapter is None:
466
+ raise OAuthLoginError(
467
+ f'No browser sign-in is available for "{provider_id}".'
468
+ )
469
+
470
+ # Each adapter chooses its own login choreography; the default is the
471
+ # authorization-code (PKCE) flow.
472
+ drive = adapter.driver if adapter.driver is not None else _drive_login
473
+ credentials = await drive(adapter, callbacks)
474
+
475
+ existing = await vault.list_accounts(provider_id)
476
+ made_default = len(existing) == 0
477
+ await vault.put_oauth(provider_id, account, credentials, made_default)
478
+
479
+ return OAuthLoginResult(
480
+ provider=provider_id, account=account, made_default=made_default
481
+ )
482
+
483
+
484
+ # ---------------------------------------------------------------------------
485
+ # Refresh seam (the TS ensureFreshOAuthCredentials analogue)
486
+ # ---------------------------------------------------------------------------
487
+
488
+
489
+ async def refresh_oauth_credentials(
490
+ provider_id: str,
491
+ credentials: OAuthCredentials,
492
+ ) -> OAuthCredentials:
493
+ """Mint fresh credentials from a stored refresh token.
494
+
495
+ The seam the boot layer's disk vault calls from ``read_usable_key`` when a
496
+ stored browser token has expired (it persists whatever this returns).
497
+ Carries the prior refresh token forward when the server does not rotate
498
+ one. Raises :class:`OAuthLoginError` when the provider is unknown or the
499
+ record has no refresh token; a failed wire refresh propagates the gateway
500
+ error.
501
+ """
502
+ adapter = get_oauth_provider(provider_id)
503
+ if adapter is None:
504
+ raise OAuthLoginError(
505
+ f'No browser sign-in is available for "{provider_id}".'
506
+ )
507
+ if credentials.refresh is None:
508
+ raise OAuthLoginError(
509
+ f"Stored {provider_id} credentials have no refresh token."
510
+ )
511
+ # A provider with a custom login flow (device grant) refreshes its own way.
512
+ if adapter.refresh_driver is not None:
513
+ return await adapter.refresh_driver(adapter, credentials)
514
+ tokens = await refresh_token(adapter.config, credentials.refresh)
515
+ return _to_credentials(tokens, prior_refresh=credentials.refresh)
516
+
517
+
518
+ # ---------------------------------------------------------------------------
519
+ # GitHub Copilot — the OAuth device-authorization grant (RFC 8628)
520
+ # ---------------------------------------------------------------------------
521
+ #
522
+ # Ported from the TS ``githubCopilotOAuthProvider``
523
+ # (indus-rebuild/src/facade/ml/kit/auth/github-copilot.ts). Copilot signs in
524
+ # with the *device* grant, not the authorization-code flow the framework
525
+ # configs use: the CLI shows the user a verification URL and a short code, the
526
+ # user enters the code in a browser, and the CLI polls GitHub until the grant
527
+ # is approved. The GitHub access token is then traded at Copilot's token
528
+ # endpoint for the short-lived bearer the model API consumes — that bearer is
529
+ # the stored ``access`` and the GitHub token is the stored ``refresh``.
530
+
531
+ #: OAuth client id GitHub recognises for the device flow. A real deployment
532
+ #: registers its own GitHub OAuth app and exports its id; the placeholder
533
+ #: mirrors the TS default (the flow runs but GitHub rejects an unregistered
534
+ #: client, exactly as in the TS build).
535
+ _COPILOT_CLIENT_ID: Final[str] = os.environ.get(
536
+ "INDUSAGI_GITHUB_COPILOT_CLIENT_ID", "<UNREGISTERED-COPILOT-APP>"
537
+ )
538
+
539
+ #: Headers GitHub's Copilot backend expects from a recognised editor
540
+ #: integration (identical to the TS request headers).
541
+ _COPILOT_HEADERS: Final[dict[str, str]] = {
542
+ "User-Agent": "GitHubCopilotChat/0.35.0",
543
+ "Editor-Version": "vscode/1.107.0",
544
+ "Editor-Plugin-Version": "copilot-chat/0.35.0",
545
+ "Copilot-Integration-Id": "vscode-chat",
546
+ }
547
+
548
+ #: The scope the device-code request asks for (TS ``"read:user"``).
549
+ _COPILOT_SCOPE: Final[str] = "read:user"
550
+
551
+ #: A placeholder framework config so the adapter dataclass stays well-formed;
552
+ #: the device-flow driver reads none of it (GitHub's endpoints are derived
553
+ #: from the host below, not this config).
554
+ _COPILOT_CONFIG: Final[OAuthConfig] = OAuthConfig(
555
+ provider="anthropic", # type: ignore[arg-type] # placeholder; unused by the device driver
556
+ authorize_url="https://github.com/login/device",
557
+ token_url="https://github.com/login/oauth/access_token",
558
+ client_id=_COPILOT_CLIENT_ID,
559
+ redirect_uri="",
560
+ scopes=(_COPILOT_SCOPE,),
561
+ )
562
+
563
+
564
+ def _copilot_endpoints(domain: str) -> tuple[str, str, str]:
565
+ """The three GitHub endpoints the device flow uses for a host (TS
566
+ ``buildEndpoints``): device-code, access-token, copilot-token."""
567
+ return (
568
+ f"https://{domain}/login/device/code",
569
+ f"https://{domain}/login/oauth/access_token",
570
+ f"https://api.{domain}/copilot_internal/v2/token",
571
+ )
572
+
573
+
574
+ async def _copilot_post_json(
575
+ url: str, body: dict[str, Any], *, extra_headers: dict[str, str] | None = None
576
+ ) -> dict[str, Any]:
577
+ """POST a JSON body and return the parsed JSON object, raising
578
+ :class:`OAuthLoginError` on a non-2xx status (TS ``request``)."""
579
+ import httpx
580
+
581
+ headers = {
582
+ "Accept": "application/json",
583
+ "Content-Type": "application/json",
584
+ "User-Agent": "GitHubCopilotChat/0.35.0",
585
+ }
586
+ if extra_headers is not None:
587
+ headers.update(extra_headers)
588
+ async with httpx.AsyncClient(timeout=None) as client:
589
+ response = await client.post(url, json=body, headers=headers)
590
+ if not response.is_success:
591
+ detail = ""
592
+ try:
593
+ detail = response.text
594
+ except Exception:
595
+ detail = ""
596
+ raise OAuthLoginError(
597
+ f"GitHub returned {response.status_code}{f': {detail}' if detail else ''}."
598
+ )
599
+ parsed = response.json()
600
+ if not isinstance(parsed, dict):
601
+ raise OAuthLoginError("GitHub returned an unexpected (non-object) response.")
602
+ return parsed
603
+
604
+
605
+ async def _copilot_get_json(
606
+ url: str, *, headers: dict[str, str]
607
+ ) -> dict[str, Any]:
608
+ """GET a JSON object from the Copilot token endpoint, raising on a non-2xx
609
+ status (TS ``request`` over a GET)."""
610
+ import httpx
611
+
612
+ async with httpx.AsyncClient(timeout=None) as client:
613
+ response = await client.get(url, headers=headers)
614
+ if not response.is_success:
615
+ detail = ""
616
+ try:
617
+ detail = response.text
618
+ except Exception:
619
+ detail = ""
620
+ raise OAuthLoginError(
621
+ f"Copilot token endpoint returned {response.status_code}"
622
+ f"{f': {detail}' if detail else ''}."
623
+ )
624
+ parsed = response.json()
625
+ if not isinstance(parsed, dict):
626
+ raise OAuthLoginError("Copilot token endpoint returned a non-object response.")
627
+ return parsed
628
+
629
+
630
+ async def _copilot_poll_access_token(
631
+ domain: str, device_code: str, interval_seconds: float, expires_in: float
632
+ ) -> str:
633
+ """Poll GitHub for the device-grant access token until it is granted,
634
+ rejected, or the deadline passes (TS ``pollAccessToken``). Honours the
635
+ ``authorization_pending`` / ``slow_down`` back-off signals."""
636
+ _, access_token_url, _ = _copilot_endpoints(domain)
637
+ deadline = time.time() + expires_in
638
+ interval = max(1.0, float(interval_seconds))
639
+
640
+ while time.time() < deadline:
641
+ raw = await _copilot_post_json(
642
+ access_token_url,
643
+ {
644
+ "client_id": _COPILOT_CLIENT_ID,
645
+ "device_code": device_code,
646
+ "grant_type": "urn:ietf:params:oauth:grant-type:device_code",
647
+ },
648
+ )
649
+ access = raw.get("access_token")
650
+ if isinstance(access, str) and access:
651
+ return access
652
+ error = raw.get("error")
653
+ if isinstance(error, str):
654
+ if error == "authorization_pending":
655
+ await asyncio.sleep(interval)
656
+ continue
657
+ if error == "slow_down":
658
+ interval += 5.0
659
+ await asyncio.sleep(interval)
660
+ continue
661
+ raise OAuthLoginError(
662
+ f"The device-authorization grant was rejected ({error})."
663
+ )
664
+ await asyncio.sleep(interval)
665
+
666
+ raise OAuthLoginError(
667
+ "Timed out waiting for the device authorization to be approved."
668
+ )
669
+
670
+
671
+ async def _copilot_token_from_github(
672
+ github_access_token: str, domain: str
673
+ ) -> OAuthCredentials:
674
+ """Trade a GitHub access token for a short-lived Copilot bearer (TS
675
+ ``refreshToken``). The Copilot bearer is the stored ``access``; the GitHub
676
+ token is kept as ``refresh`` so it can be re-traded when the bearer
677
+ expires."""
678
+ _, _, copilot_token_url = _copilot_endpoints(domain)
679
+ raw = await _copilot_get_json(
680
+ copilot_token_url,
681
+ headers={
682
+ "Accept": "application/json",
683
+ "Authorization": f"Bearer {github_access_token}",
684
+ **_COPILOT_HEADERS,
685
+ },
686
+ )
687
+ token = raw.get("token")
688
+ expires_at = raw.get("expires_at")
689
+ if not isinstance(token, str) or not isinstance(expires_at, (int, float)):
690
+ raise OAuthLoginError(
691
+ "Copilot token response was missing or had malformed fields."
692
+ )
693
+ # TS subtracts a 5-minute safety margin and stores epoch-ms.
694
+ expires_ms = int(expires_at) * 1000 - 5 * 60 * 1000
695
+ return OAuthCredentials(
696
+ access=token, refresh=github_access_token, expires=expires_ms
697
+ )
698
+
699
+
700
+ def _normalize_copilot_domain(raw: str) -> str | None:
701
+ """Reduce a user-entered GitHub Enterprise host to a bare hostname, or
702
+ ``None`` when unusable (TS ``normalizeDomain``)."""
703
+ cleaned = raw.strip()
704
+ if not cleaned:
705
+ return None
706
+ with_scheme = cleaned if cleaned.startswith("http") else f"https://{cleaned}"
707
+ try:
708
+ host = urlsplit(with_scheme).hostname
709
+ except ValueError:
710
+ return None
711
+ return host or None
712
+
713
+
714
+ async def _drive_github_copilot_login(
715
+ adapter: OAuthProviderAdapter,
716
+ callbacks: OAuthLoginCallbacks,
717
+ ) -> OAuthCredentials:
718
+ """The GitHub Copilot device-flow login (TS ``CopilotAuthFlow.login``).
719
+
720
+ Prompts for an optional Enterprise host (blank → ``github.com``), starts
721
+ the device-authorization grant, surfaces the verification URL and user
722
+ code through ``on_auth``, polls GitHub until the grant is approved, and
723
+ trades the resulting GitHub token for a Copilot bearer."""
724
+ del adapter # the device flow derives its endpoints from the host below
725
+
726
+ entered = await callbacks.on_prompt(
727
+ OAuthPrompt(
728
+ message=(
729
+ "Your GitHub Enterprise host (leave empty to use github.com):"
730
+ )
731
+ )
732
+ )
733
+ trimmed = entered.strip()
734
+ enterprise = _normalize_copilot_domain(trimmed) if trimmed else None
735
+ if trimmed and enterprise is None:
736
+ raise OAuthLoginError("That GitHub Enterprise host could not be parsed.")
737
+ domain = enterprise if enterprise is not None else "github.com"
738
+
739
+ device_code_url, _, _ = _copilot_endpoints(domain)
740
+ init = await _copilot_post_json(
741
+ device_code_url,
742
+ {"client_id": _COPILOT_CLIENT_ID, "scope": _COPILOT_SCOPE},
743
+ )
744
+ device_code = init.get("device_code")
745
+ user_code = init.get("user_code")
746
+ verification_uri = init.get("verification_uri")
747
+ interval = init.get("interval")
748
+ expires_in = init.get("expires_in")
749
+ if (
750
+ not isinstance(device_code, str)
751
+ or not isinstance(user_code, str)
752
+ or not isinstance(verification_uri, str)
753
+ or not isinstance(interval, (int, float))
754
+ or not isinstance(expires_in, (int, float))
755
+ ):
756
+ raise OAuthLoginError(
757
+ "Device authorization response was missing or had malformed fields."
758
+ )
759
+
760
+ handled = callbacks.on_auth(
761
+ OAuthAuthorization(
762
+ url=verification_uri,
763
+ instructions=f"Type this code when prompted: {user_code}",
764
+ )
765
+ )
766
+ if inspect.isawaitable(handled):
767
+ await handled
768
+
769
+ if callbacks.on_progress is not None:
770
+ callbacks.on_progress("Waiting for the device authorization to be approved...")
771
+
772
+ github_access_token = await _copilot_poll_access_token(
773
+ domain, device_code, float(interval), float(expires_in)
774
+ )
775
+
776
+ if callbacks.on_progress is not None:
777
+ callbacks.on_progress("Exchanging the GitHub token for a Copilot token...")
778
+ return await _copilot_token_from_github(github_access_token, domain)
779
+
780
+
781
+ async def _refresh_github_copilot(
782
+ adapter: OAuthProviderAdapter,
783
+ credentials: OAuthCredentials,
784
+ ) -> OAuthCredentials:
785
+ """Re-derive a Copilot bearer from the stored GitHub token (TS
786
+ ``githubCopilotOAuthProvider.refreshToken``). The stored ``refresh`` is the
787
+ GitHub access token; it is re-traded against ``github.com`` (the default
788
+ host)."""
789
+ del adapter
790
+ if credentials.refresh is None: # pragma: no cover - guarded by the caller
791
+ raise OAuthLoginError("Stored github-copilot credentials have no refresh token.")
792
+ return await _copilot_token_from_github(credentials.refresh, "github.com")
793
+
794
+
795
+ def github_copilot_adapter() -> OAuthProviderAdapter:
796
+ """Build the app-side GitHub Copilot sign-in adapter, wired to the
797
+ device-flow login and refresh drivers. Registered by
798
+ :func:`register_built_in_oauth_providers` so ``pindus signin --provider
799
+ github-copilot`` drives the device grant — the framework has no
800
+ ``github-copilot`` config or device-code primitive (see the module
801
+ docstring), so this provider is owned entirely here."""
802
+ return OAuthProviderAdapter(
803
+ id="github-copilot",
804
+ name=_OAUTH_LABELS["github-copilot"],
805
+ config=_COPILOT_CONFIG,
806
+ driver=_drive_github_copilot_login,
807
+ refresh_driver=_refresh_github_copilot,
808
+ )