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,852 @@
1
+ """Credential command — :func:`run_credential_command`.
2
+
3
+ The top-level ``signin`` / ``signout`` surface. Sign-in supports two methods:
4
+ a browser sign-in (OAuth) for the providers the adapter registry exposes, and
5
+ an api-key flow for every provider in the directory. When a provider is
6
+ sign-in-capable the command prefers the browser flow but still lets the user
7
+ pick an api key; for the rest it stores a key directly. The env-var override
8
+ is consulted first via the framework :func:`indusagi.ai.get_env_api_key`, so a
9
+ user who already exported (e.g.) ``ANTHROPIC_API_KEY`` can sign in without
10
+ re-typing the secret.
11
+
12
+ The command owns the first positional token only: it returns
13
+ ``handled=False`` when that token is neither
14
+ :data:`~.contract.CredentialVerb` so the caller falls through to a normal
15
+ launch. Every failure is a typed :class:`~.contract.CredentialFault`; nothing
16
+ is signalled by a string sentinel or a bare ``sys.exit``.
17
+
18
+ I/O is injected. The default :class:`CredentialIo` wraps stdout plus
19
+ :func:`input` / :func:`getpass.getpass`, but tests drive the whole flow over
20
+ an in-memory stand-in with no real terminal and an in-memory vault.
21
+
22
+ (Port of TS ``src/launch/credentials.ts``; the browser path runs over the
23
+ launch OAuth adapter rather than the TS framework provider registry.)
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import asyncio
29
+ import getpass
30
+ import re
31
+ import sys
32
+ from collections.abc import Awaitable, Sequence
33
+ from dataclasses import dataclass
34
+ from typing import Final, Literal, Protocol, TypeAlias
35
+
36
+ from indusagi.ai import get_env_api_key
37
+
38
+ from .contract import (
39
+ AuthVault,
40
+ CredentialFault,
41
+ CredentialFaultKind,
42
+ CredentialVerb,
43
+ ProviderEntry,
44
+ credential_fault,
45
+ )
46
+ from .oauth import (
47
+ OAuthAuthorization,
48
+ OAuthLoginCallbacks,
49
+ OAuthPrompt,
50
+ get_oauth_provider,
51
+ list_login_providers,
52
+ open_login_url,
53
+ start_oauth_login,
54
+ )
55
+
56
+ __all__ = [
57
+ "CredentialIo",
58
+ "CredentialResult",
59
+ "PROVIDER_DIRECTORY",
60
+ "SigninMethod",
61
+ "as_signin_method",
62
+ "default_credential_io",
63
+ "find_provider",
64
+ "format_credential_fault",
65
+ "is_oauth_capable",
66
+ "run_credential_command",
67
+ "validate_account_name",
68
+ "validate_api_key",
69
+ ]
70
+
71
+
72
+ # ---------------------------------------------------------------------------
73
+ # Injected I/O
74
+ # ---------------------------------------------------------------------------
75
+
76
+
77
+ class CredentialIo(Protocol):
78
+ """The console seam the credential command reads and writes through.
79
+
80
+ Pinned to the three operations the flow actually needs — emit a line, read
81
+ a line of input, and read a secret line — so a test can capture output
82
+ into a list and feed scripted answers without a real TTY.
83
+ """
84
+
85
+ def print(self, line: str) -> None:
86
+ """Emit one line of human-facing text."""
87
+ ...
88
+
89
+ async def ask(self, prompt: str) -> str:
90
+ """Prompt for and read one line of visible input."""
91
+ ...
92
+
93
+ async def ask_secret(self, prompt: str) -> str:
94
+ """Prompt for and read one line of secret input (key entry)."""
95
+ ...
96
+
97
+
98
+ class _DefaultCredentialIo:
99
+ """The default :class:`CredentialIo` backed by stdout, :func:`input`, and
100
+ :func:`getpass.getpass` (which mutes the echo while the secret is
101
+ typed)."""
102
+
103
+ def print(self, line: str) -> None:
104
+ sys.stdout.write(line if line.endswith("\n") else line + "\n")
105
+
106
+ async def ask(self, prompt: str) -> str:
107
+ answer = await asyncio.to_thread(input, prompt)
108
+ return answer.strip()
109
+
110
+ async def ask_secret(self, prompt: str) -> str:
111
+ answer = await asyncio.to_thread(getpass.getpass, prompt)
112
+ return answer.strip()
113
+
114
+
115
+ def default_credential_io() -> CredentialIo:
116
+ """Build the live terminal-backed :class:`CredentialIo`."""
117
+ return _DefaultCredentialIo()
118
+
119
+
120
+ # ---------------------------------------------------------------------------
121
+ # Provider directory
122
+ # ---------------------------------------------------------------------------
123
+
124
+ #: The api-key provider directory: every provider the sign-in surface can
125
+ #: store a key for, with its conventional env var and the page a user obtains
126
+ #: a key from. These are external provider facts; ``env_key`` is the variable
127
+ #: :func:`indusagi.ai.get_env_api_key` reads for the env-first shortcut.
128
+ PROVIDER_DIRECTORY: Final[tuple[ProviderEntry, ...]] = (
129
+ ProviderEntry(
130
+ id="anthropic",
131
+ label="Anthropic (Claude)",
132
+ env_key="ANTHROPIC_API_KEY",
133
+ docs_url="https://console.anthropic.com/settings/keys",
134
+ ),
135
+ ProviderEntry(
136
+ id="openai",
137
+ label="OpenAI",
138
+ env_key="OPENAI_API_KEY",
139
+ docs_url="https://platform.openai.com/api-keys",
140
+ ),
141
+ ProviderEntry(
142
+ id="google",
143
+ label="Google Gemini",
144
+ env_key="GEMINI_API_KEY",
145
+ docs_url="https://aistudio.google.com/apikey",
146
+ ),
147
+ ProviderEntry(
148
+ id="xai",
149
+ label="xAI (Grok)",
150
+ env_key="XAI_API_KEY",
151
+ docs_url="https://console.x.ai",
152
+ ),
153
+ ProviderEntry(
154
+ id="groq",
155
+ label="Groq",
156
+ env_key="GROQ_API_KEY",
157
+ docs_url="https://console.groq.com/keys",
158
+ ),
159
+ ProviderEntry(
160
+ id="cerebras",
161
+ label="Cerebras",
162
+ env_key="CEREBRAS_API_KEY",
163
+ docs_url="https://cloud.cerebras.ai",
164
+ ),
165
+ ProviderEntry(
166
+ id="mistral",
167
+ label="Mistral",
168
+ env_key="MISTRAL_API_KEY",
169
+ docs_url="https://console.mistral.ai/api-keys",
170
+ ),
171
+ ProviderEntry(
172
+ id="openrouter",
173
+ label="OpenRouter",
174
+ env_key="OPENROUTER_API_KEY",
175
+ docs_url="https://openrouter.ai/keys",
176
+ ),
177
+ ProviderEntry(
178
+ id="minimax",
179
+ label="MiniMax",
180
+ env_key="MINIMAX_API_KEY",
181
+ docs_url="https://www.minimax.io",
182
+ ),
183
+ ProviderEntry(
184
+ id="kimi",
185
+ label="Kimi (Moonshot)",
186
+ env_key="MOONSHOT_API_KEY",
187
+ docs_url="https://platform.moonshot.ai/console/api-keys",
188
+ ),
189
+ ProviderEntry(
190
+ id="sarvam",
191
+ label="Sarvam",
192
+ env_key="SARVAM_API_KEY",
193
+ docs_url="https://dashboard.sarvam.ai",
194
+ ),
195
+ ProviderEntry(
196
+ id="krutrim",
197
+ label="Krutrim",
198
+ env_key="KRUTRIM_API_KEY",
199
+ docs_url="https://cloud.olakrutrim.com",
200
+ ),
201
+ ProviderEntry(
202
+ id="nvidia",
203
+ label="NVIDIA",
204
+ env_key="NVIDIA_API_KEY",
205
+ docs_url="https://build.nvidia.com",
206
+ ),
207
+ ProviderEntry(
208
+ id="zai",
209
+ label="Z.ai",
210
+ env_key="ZAI_API_KEY",
211
+ docs_url="https://z.ai",
212
+ ),
213
+ )
214
+
215
+
216
+ def find_provider(id: str) -> ProviderEntry | None:
217
+ """Look up a provider entry by id (case-insensitive over the directory)."""
218
+ needle = id.strip().lower()
219
+ for entry in PROVIDER_DIRECTORY:
220
+ if entry.id.lower() == needle:
221
+ return entry
222
+ return None
223
+
224
+
225
+ # ---------------------------------------------------------------------------
226
+ # Sign-in method
227
+ # ---------------------------------------------------------------------------
228
+
229
+ #: The two ways a provider can be signed in to.
230
+ #:
231
+ #: - ``oauth`` — a browser sign-in driven by the adapter registry.
232
+ #: - ``api-key`` — paste / store a provider api key.
233
+ SigninMethod: TypeAlias = Literal["oauth", "api-key"]
234
+
235
+
236
+ def as_signin_method(value: str | None) -> SigninMethod | None:
237
+ """Narrow a ``--method`` value to a :data:`SigninMethod`."""
238
+ normalised = value.strip().lower() if value is not None else None
239
+ if normalised == "oauth":
240
+ return "oauth"
241
+ if normalised == "api-key" or normalised == "apikey":
242
+ return "api-key"
243
+ return None
244
+
245
+
246
+ def is_oauth_capable(id: str) -> bool:
247
+ """Whether a provider id can be signed in to through the browser
248
+ registry."""
249
+ return get_oauth_provider(id) is not None
250
+
251
+
252
+ # ---------------------------------------------------------------------------
253
+ # Validation
254
+ # ---------------------------------------------------------------------------
255
+
256
+ #: Lower bound on a plausible api key length.
257
+ _MIN_KEY_LENGTH: Final[int] = 20
258
+ #: Upper bound on an account name.
259
+ _MAX_ACCOUNT_LENGTH: Final[int] = 50
260
+ #: Allowed characters in an account name.
261
+ _ACCOUNT_PATTERN: Final[re.Pattern[str]] = re.compile(r"^[A-Za-z0-9_-]+$")
262
+ #: Substrings that mark a placeholder rather than a real key.
263
+ _PLACEHOLDER_MARKERS: Final[tuple[str, ...]] = (
264
+ "your-api-key",
265
+ "your_api_key",
266
+ "xxxx",
267
+ "placeholder",
268
+ "changeme",
269
+ "<",
270
+ )
271
+
272
+
273
+ def validate_api_key(key: str) -> CredentialFault | None:
274
+ """Validate an api key against the format rules: non-empty, at least
275
+ :data:`_MIN_KEY_LENGTH` characters, and free of placeholder markers.
276
+ Returns a typed :class:`~.contract.CredentialFault` on rejection, or
277
+ ``None`` when the key passes."""
278
+ trimmed = key.strip()
279
+ if len(trimmed) == 0:
280
+ return credential_fault(
281
+ "invalid-key",
282
+ "No api key was provided.",
283
+ hint="Paste the key from the provider dashboard and try again.",
284
+ )
285
+ if len(trimmed) < _MIN_KEY_LENGTH:
286
+ return credential_fault(
287
+ "invalid-key",
288
+ f"The api key is too short (expected at least {_MIN_KEY_LENGTH} characters).",
289
+ hint="Copy the full key, including any prefix.",
290
+ )
291
+ lowered = trimmed.lower()
292
+ if any(marker in lowered for marker in _PLACEHOLDER_MARKERS):
293
+ return credential_fault(
294
+ "invalid-key",
295
+ "The value looks like a placeholder rather than a real api key.",
296
+ hint="Replace the placeholder with the actual key.",
297
+ )
298
+ return None
299
+
300
+
301
+ def validate_account_name(name: str) -> CredentialFault | None:
302
+ """Validate an account name: at most :data:`_MAX_ACCOUNT_LENGTH`
303
+ characters and matching :data:`_ACCOUNT_PATTERN`. Returns a typed fault on
304
+ rejection, or ``None`` when the name is acceptable."""
305
+ trimmed = name.strip()
306
+ if len(trimmed) == 0:
307
+ return credential_fault(
308
+ "invalid-account",
309
+ "The account name is empty.",
310
+ hint="Use letters, digits, dashes, or underscores.",
311
+ )
312
+ if len(trimmed) > _MAX_ACCOUNT_LENGTH:
313
+ return credential_fault(
314
+ "invalid-account",
315
+ f"The account name is too long (max {_MAX_ACCOUNT_LENGTH} characters).",
316
+ )
317
+ if _ACCOUNT_PATTERN.match(trimmed) is None:
318
+ return credential_fault(
319
+ "invalid-account",
320
+ "The account name contains unsupported characters.",
321
+ hint="Use letters, digits, dashes, or underscores only.",
322
+ )
323
+ return None
324
+
325
+
326
+ # ---------------------------------------------------------------------------
327
+ # Command surface
328
+ # ---------------------------------------------------------------------------
329
+
330
+ #: The default account name used when the user does not name one explicitly.
331
+ _DEFAULT_ACCOUNT: Final[str] = "default"
332
+
333
+
334
+ @dataclass(frozen=True, slots=True)
335
+ class CredentialResult:
336
+ """The outcome of :func:`run_credential_command`.
337
+
338
+ :attr:`handled` reports whether the first positional token was a
339
+ recognised :data:`~.contract.CredentialVerb`; when ``False``, the caller
340
+ proceeds to a normal launch unchanged. :attr:`fault` carries a typed
341
+ failure when the command was handled but did not complete."""
342
+
343
+ # Whether this invocation was a signin / signout command.
344
+ handled: bool
345
+ # The verb that ran, when handled is True.
346
+ verb: CredentialVerb | None = None
347
+ # The provider id the verb acted on, when one was resolved.
348
+ provider: str | None = None
349
+ # A typed failure, present only when the handled command did not complete.
350
+ fault: CredentialFault | None = None
351
+
352
+
353
+ def _as_verb(token: str | None) -> CredentialVerb | None:
354
+ """Narrow the leading token to a :data:`~.contract.CredentialVerb`.
355
+
356
+ ``login`` / ``logout`` are accepted as the natural-language aliases of
357
+ ``signin`` / ``signout``, so ``pindus login`` does the obvious thing
358
+ rather than being mistaken for a chat prompt."""
359
+ if token == "signin" or token == "login":
360
+ return "signin"
361
+ if token == "signout" or token == "logout":
362
+ return "signout"
363
+ return None
364
+
365
+
366
+ class _FaultSignal(Exception):
367
+ """Internal control-flow carrier for a fault raised mid-resolution (TS
368
+ threw the plain fault object; Python needs an Exception)."""
369
+
370
+ def __init__(self, fault: CredentialFault) -> None:
371
+ super().__init__(fault.message)
372
+ self.fault = fault
373
+
374
+
375
+ def _to_vault_fault(cause: object) -> CredentialFault:
376
+ """Wrap an unexpected thrown error as a ``vault``
377
+ :class:`~.contract.CredentialFault`.
378
+
379
+ # parity: the TS flow threw *plain fault objects* (not Errors) from the
380
+ # provider resolvers; ``cause instanceof Error`` was false for them, so
381
+ # they degraded to this generic vault message. The port preserves that
382
+ # quirk by treating the internal fault signal like a non-Error.
383
+ """
384
+ if isinstance(cause, Exception) and not isinstance(cause, _FaultSignal):
385
+ message = str(cause)
386
+ else:
387
+ message = "The credential store failed."
388
+ return credential_fault("vault", message, cause=cause)
389
+
390
+
391
+ async def run_credential_command(
392
+ argv: Sequence[str],
393
+ *,
394
+ vault: AuthVault,
395
+ profile_dir: str,
396
+ io: CredentialIo | None = None,
397
+ ) -> CredentialResult:
398
+ """Run the sign-in / sign-out command.
399
+
400
+ Returns ``handled=False`` immediately when ``argv[0]`` is neither verb, so
401
+ the orchestrator can call this first and fall through on a miss. Otherwise
402
+ it resolves the provider (prompting from the directory when none is
403
+ named), runs the verb, and resolves a :class:`CredentialResult` — never
404
+ raising for an expected failure, which surfaces as the result's ``fault``.
405
+
406
+ :param argv: the raw token list following the program name
407
+ :param vault: the injected credential store
408
+ :param profile_dir: absolute directory the vault persists credentials
409
+ under (informational; the vault owns the writes)
410
+ :param io: the console seam; defaults to the live terminal
411
+ """
412
+ del profile_dir # informational in the TS contract too; the vault writes
413
+ verb = _as_verb(argv[0] if len(argv) > 0 else None)
414
+ if verb is None:
415
+ return CredentialResult(handled=False)
416
+
417
+ live_io = io if io is not None else default_credential_io()
418
+ rest = list(argv[1:])
419
+ named_provider, account, method, list_flag = _read_verb_flags(rest)
420
+
421
+ # `--list` short-circuits the sign-in verb: it reports every saved account
422
+ # without prompting and stores nothing. It is meaningless for sign-out,
423
+ # where the verb already names what to remove, so it is honoured on
424
+ # `signin` only.
425
+ if verb == "signin" and list_flag:
426
+ try:
427
+ await _do_list_accounts(vault, live_io)
428
+ return CredentialResult(handled=True, verb=verb)
429
+ except Exception as cause:
430
+ return CredentialResult(handled=True, verb=verb, fault=_to_vault_fault(cause))
431
+
432
+ try:
433
+ if verb == "signin":
434
+ resolved = await _resolve_signin_target(named_provider, live_io)
435
+ if resolved is None:
436
+ return CredentialResult(
437
+ handled=True,
438
+ verb=verb,
439
+ fault=credential_fault("aborted", "No provider was selected."),
440
+ )
441
+
442
+ fault = await _do_signin(resolved, account, method, vault, live_io)
443
+ return CredentialResult(
444
+ handled=True, verb=verb, provider=resolved.id, fault=fault
445
+ )
446
+
447
+ provider = await _resolve_provider(named_provider, live_io)
448
+ if provider is None:
449
+ return CredentialResult(
450
+ handled=True,
451
+ verb=verb,
452
+ fault=credential_fault("aborted", "No provider was selected."),
453
+ )
454
+
455
+ fault = await _do_signout(provider, account, vault, live_io)
456
+ return CredentialResult(
457
+ handled=True, verb=verb, provider=provider.id, fault=fault
458
+ )
459
+ except Exception as cause:
460
+ return CredentialResult(handled=True, verb=verb, fault=_to_vault_fault(cause))
461
+
462
+
463
+ def _read_verb_flags(
464
+ rest: Sequence[str],
465
+ ) -> tuple[str | None, str | None, SigninMethod | None, bool]:
466
+ """Extract the optional ``--provider`` / ``--account`` / ``--method`` /
467
+ ``--list`` flags (and the bare positional fallbacks) from the verb tail.
468
+ The first positional is treated as the provider when no ``--provider``
469
+ flag is present. ``--oauth`` / ``--api-key`` are accepted as shorthands
470
+ for the corresponding ``--method`` value; ``--list`` is a bare switch that
471
+ requests a read-only listing of saved accounts."""
472
+ provider: str | None = None
473
+ account: str | None = None
474
+ method: SigninMethod | None = None
475
+ list_flag = False
476
+ positionals: list[str] = []
477
+ i = 0
478
+ while i < len(rest):
479
+ token = rest[i]
480
+ if token == "--provider" and i + 1 < len(rest):
481
+ i += 1
482
+ provider = rest[i]
483
+ elif token == "--account" and i + 1 < len(rest):
484
+ i += 1
485
+ account = rest[i]
486
+ elif token == "--method" and i + 1 < len(rest):
487
+ i += 1
488
+ method = as_signin_method(rest[i])
489
+ elif token == "--oauth":
490
+ method = "oauth"
491
+ elif token == "--api-key" or token == "--apikey":
492
+ method = "api-key"
493
+ elif token == "--list" or token == "--ls":
494
+ list_flag = True
495
+ elif not token.startswith("-"):
496
+ positionals.append(token)
497
+ i += 1
498
+ if provider is None and positionals:
499
+ provider = positionals[0]
500
+ return provider, account, method, list_flag
501
+
502
+
503
+ async def _do_list_accounts(vault: AuthVault, io: CredentialIo) -> None:
504
+ """Print every saved provider account, grouped by provider, without
505
+ prompting.
506
+
507
+ Walks the merged sign-in directory for the full set of provider ids the
508
+ app knows (deduplicated, since a provider can appear under both the
509
+ browser and api-key directories), queries the vault for each, and prints
510
+ one line per saved account — tagging the provider default and the stored
511
+ credential kind. When no provider holds a saved account it prints a single
512
+ "none" line. Read-only: it never writes to the vault."""
513
+ seen: set[str] = set()
514
+ providers: list[tuple[str, str]] = []
515
+ for entry in list_login_providers():
516
+ if entry.id in seen:
517
+ continue
518
+ seen.add(entry.id)
519
+ providers.append((entry.id, entry.label))
520
+
521
+ io.print("Saved accounts:")
522
+ any_found = False
523
+ for provider_id, label in providers:
524
+ accounts = await vault.list_accounts(provider_id)
525
+ if not accounts:
526
+ continue
527
+ any_found = True
528
+ fallback_default = await vault.default_account(provider_id)
529
+ io.print(f" {label} ({provider_id}):")
530
+ for account in accounts:
531
+ kind = await vault.auth_kind(provider_id, account)
532
+ marker = " (default)" if account == fallback_default else ""
533
+ kind_label = (
534
+ "browser" if kind == "oauth" else "api key" if kind == "apiKey" else "unknown"
535
+ )
536
+ io.print(f" - {account}{marker} [{kind_label}]")
537
+ if not any_found:
538
+ io.print(" (none)")
539
+
540
+
541
+ async def _resolve_provider(
542
+ named: str | None, io: CredentialIo
543
+ ) -> ProviderEntry | None:
544
+ """Resolve the provider to act on. When a name was supplied it is
545
+ validated against the directory; when none was, the directory is printed
546
+ and the user picks by number. Returns ``None`` only when the interactive
547
+ pick is aborted; raises a typed fault for a named-but-unknown provider."""
548
+ if named is not None:
549
+ entry = find_provider(named)
550
+ if entry is None:
551
+ raise _FaultSignal(
552
+ credential_fault(
553
+ "unknown-provider",
554
+ f"Unknown provider: {named}.",
555
+ hint="Run the command with no provider to list the choices.",
556
+ )
557
+ )
558
+ return entry
559
+
560
+ io.print("Available providers:")
561
+ for index, entry in enumerate(PROVIDER_DIRECTORY):
562
+ io.print(f" {index + 1}. {entry.label} ({entry.env_key})")
563
+ answer = await io.ask("Select a provider by number: ")
564
+ if len(answer) == 0:
565
+ return None
566
+ index = _parse_pick(answer)
567
+ if index is None or index < 0 or index >= len(PROVIDER_DIRECTORY):
568
+ raise _FaultSignal(
569
+ credential_fault(
570
+ "unknown-provider", "That is not one of the listed providers."
571
+ )
572
+ )
573
+ return PROVIDER_DIRECTORY[index]
574
+
575
+
576
+ def _parse_pick(answer: str) -> int | None:
577
+ """Parse a 1-based menu answer into a 0-based index (None on a
578
+ non-integer)."""
579
+ try:
580
+ return int(answer.strip(), 10) - 1
581
+ except ValueError:
582
+ return None
583
+
584
+
585
+ @dataclass(frozen=True, slots=True)
586
+ class _SigninTarget:
587
+ """A resolved sign-in target — the provider id and label the sign-in flow
588
+ acts on, plus whether a browser sign-in is available for it. An
589
+ api-key-only provider carries its directory :class:`ProviderEntry`; a
590
+ browser-sign-in provider that has no api-key directory entry has none."""
591
+
592
+ # Stable provider id.
593
+ id: str
594
+ # Human-facing label.
595
+ label: str
596
+ # Whether the adapter registry can sign this provider in via the browser.
597
+ oauth_capable: bool
598
+ # The api-key directory entry, when one exists for this provider.
599
+ api_key_entry: ProviderEntry | None = None
600
+
601
+
602
+ def _make_signin_target(id: str) -> _SigninTarget | None:
603
+ """Build a :class:`_SigninTarget` for a provider id (looked up across both
604
+ kinds)."""
605
+ api_key_entry = find_provider(id)
606
+ oauth_capable = is_oauth_capable(id)
607
+ if api_key_entry is None and not oauth_capable:
608
+ return None
609
+ merged = next((p for p in list_login_providers() if p.id == id), None)
610
+ label = (
611
+ api_key_entry.label
612
+ if api_key_entry is not None
613
+ else merged.label if merged is not None else id
614
+ )
615
+ return _SigninTarget(
616
+ id=id, label=label, oauth_capable=oauth_capable, api_key_entry=api_key_entry
617
+ )
618
+
619
+
620
+ async def _resolve_signin_target(
621
+ named: str | None, io: CredentialIo
622
+ ) -> _SigninTarget | None:
623
+ """Resolve the sign-in target. When a name was supplied it is validated
624
+ across both the browser-sign-in registry and the api-key directory; when
625
+ none was, the merged directory is printed (each entry tagged by kind) and
626
+ the user picks by number. Returns ``None`` only when the interactive pick
627
+ is aborted; raises a typed fault for a named-but-unknown provider."""
628
+ if named is not None:
629
+ target = _make_signin_target(named.strip().lower())
630
+ if target is None:
631
+ raise _FaultSignal(
632
+ credential_fault(
633
+ "unknown-provider",
634
+ f"Unknown provider: {named}.",
635
+ hint="Run the command with no provider to list the choices.",
636
+ )
637
+ )
638
+ return target
639
+
640
+ directory = list_login_providers()
641
+ io.print("Available providers:")
642
+ for index, entry in enumerate(directory):
643
+ kind = "browser sign-in" if entry.auth_kind == "oauth" else "api key"
644
+ io.print(f" {index + 1}. {entry.label} ({kind})")
645
+ answer = await io.ask("Select a provider by number: ")
646
+ if len(answer) == 0:
647
+ return None
648
+ index = _parse_pick(answer)
649
+ if index is None or index < 0 or index >= len(directory):
650
+ raise _FaultSignal(
651
+ credential_fault(
652
+ "unknown-provider", "That is not one of the listed providers."
653
+ )
654
+ )
655
+ return _make_signin_target(directory[index].id)
656
+
657
+
658
+ async def _do_signin(
659
+ target: _SigninTarget,
660
+ requested_account: str | None,
661
+ method: SigninMethod | None,
662
+ vault: AuthVault,
663
+ io: CredentialIo,
664
+ ) -> CredentialFault | None:
665
+ """The sign-in flow for a resolved target.
666
+
667
+ Routes by method: a browser sign-in for an OAuth-capable provider, or the
668
+ api-key flow otherwise. When no method is given and the provider supports
669
+ both, the browser path is preferred; an explicit ``--method`` always wins
670
+ (and a browser request against a provider that cannot sign in that way is
671
+ a typed fault). Returns a typed fault on any rejection."""
672
+ account = requested_account if requested_account is not None else _DEFAULT_ACCOUNT
673
+ account_fault = validate_account_name(account)
674
+ if account_fault is not None:
675
+ return account_fault
676
+
677
+ existing = await vault.list_accounts(target.id)
678
+ if account in existing:
679
+ return credential_fault(
680
+ "name-collision",
681
+ f'An account named "{account}" already exists for {target.label}.',
682
+ hint="Pass --account with a different name, or sign out first.",
683
+ )
684
+
685
+ # Choose the path. Prefer browser sign-in when the provider supports it
686
+ # and no explicit method was requested; honour an explicit method
687
+ # otherwise.
688
+ use_oauth = method == "oauth" or (method is None and target.oauth_capable)
689
+
690
+ if use_oauth:
691
+ if not target.oauth_capable:
692
+ return credential_fault(
693
+ "unknown-provider",
694
+ f"{target.label} does not support browser sign-in.",
695
+ hint="Re-run with --method api-key to store an api key instead.",
696
+ )
697
+ return await _do_oauth_signin(target, account, vault, io)
698
+
699
+ if target.api_key_entry is None:
700
+ return credential_fault(
701
+ "unknown-provider",
702
+ f"{target.label} has no api-key sign-in; use browser sign-in instead.",
703
+ hint="Re-run with --method oauth.",
704
+ )
705
+ return await _do_api_key_signin(
706
+ target.api_key_entry, account, len(existing) == 0, vault, io
707
+ )
708
+
709
+
710
+ async def _do_api_key_signin(
711
+ provider: ProviderEntry,
712
+ account: str,
713
+ make_default: bool,
714
+ vault: AuthVault,
715
+ io: CredentialIo,
716
+ ) -> CredentialFault | None:
717
+ """The api-key sign-in sub-flow.
718
+
719
+ Consults the env-var override first; if a key is already exported the user
720
+ is offered to store it directly. Otherwise it prompts for a secret,
721
+ validates it, and persists through the vault."""
722
+ env_key = get_env_api_key(provider.id)
723
+ api_key = env_key.strip() if env_key is not None else ""
724
+ if len(api_key) > 0:
725
+ io.print(f"Found {provider.env_key} in the environment for {provider.label}.")
726
+ use_env = await io.ask("Store the environment key? [Y/n] ")
727
+ if re.match(r"^n", use_env, re.IGNORECASE) is not None:
728
+ api_key = ""
729
+
730
+ if len(api_key) == 0:
731
+ io.print(f"Obtain a key at {provider.docs_url}")
732
+ api_key = await io.ask_secret(f"Enter the {provider.label} api key: ")
733
+
734
+ key_fault = validate_api_key(api_key)
735
+ if key_fault is not None:
736
+ return key_fault
737
+
738
+ await vault.put_api_key(provider.id, account, api_key.strip(), make_default)
739
+
740
+ suffix = " (default)" if make_default else ""
741
+ io.print(f'Stored {provider.label} key under account "{account}"{suffix}.')
742
+ return None
743
+
744
+
745
+ async def _do_oauth_signin(
746
+ target: _SigninTarget,
747
+ account: str,
748
+ vault: AuthVault,
749
+ io: CredentialIo,
750
+ ) -> CredentialFault | None:
751
+ """The browser sign-in sub-flow.
752
+
753
+ Drives the adapter's login through :func:`~.oauth.start_oauth_login`,
754
+ opening the consent url in the user's browser and relaying any interactive
755
+ prompt the flow raises through the injected io. A failure of the flow
756
+ surfaces as a typed ``vault`` fault so the command exit path is uniform."""
757
+ io.print(f"Starting browser sign-in for {target.label}...")
758
+
759
+ async def on_auth(info: OAuthAuthorization) -> None:
760
+ opened = await open_login_url(info.url)
761
+ io.print(f"{'Opened' if opened else 'Open this url'}: {info.url}")
762
+ if info.instructions is not None:
763
+ io.print(info.instructions)
764
+
765
+ def on_prompt(prompt: OAuthPrompt) -> Awaitable[str]:
766
+ return io.ask(f"{prompt.message} ")
767
+
768
+ try:
769
+ result = await start_oauth_login(
770
+ target.id,
771
+ OAuthLoginCallbacks(
772
+ on_auth=on_auth,
773
+ on_prompt=on_prompt,
774
+ on_progress=io.print,
775
+ ),
776
+ vault,
777
+ account,
778
+ )
779
+ suffix = " (default)" if result.made_default else ""
780
+ io.print(f'Signed in to {target.label} under account "{result.account}"{suffix}.')
781
+ return None
782
+ except Exception as cause:
783
+ message = str(cause) if isinstance(cause, Exception) else "The browser sign-in failed."
784
+ return credential_fault(
785
+ "vault",
786
+ f"Browser sign-in failed: {message}",
787
+ hint="Try again, or use --method api-key to store an api key instead.",
788
+ cause=cause,
789
+ )
790
+
791
+
792
+ async def _do_signout(
793
+ provider: ProviderEntry,
794
+ requested_account: str | None,
795
+ vault: AuthVault,
796
+ io: CredentialIo,
797
+ ) -> CredentialFault | None:
798
+ """The sign-out flow for a resolved provider.
799
+
800
+ Removes the named account (or every account for the provider when none is
801
+ named). Returns a ``not-found`` fault when nothing was stored for the
802
+ target."""
803
+ if requested_account is not None:
804
+ account_fault = validate_account_name(requested_account)
805
+ if account_fault is not None:
806
+ return account_fault
807
+
808
+ removed = await vault.remove(provider.id, requested_account)
809
+ if not removed:
810
+ scope = (
811
+ f'account "{requested_account}"'
812
+ if requested_account is not None
813
+ else "any account"
814
+ )
815
+ return credential_fault(
816
+ "not-found", f"No stored credential for {provider.label} ({scope})."
817
+ )
818
+
819
+ io.print(
820
+ f'Removed {provider.label} account "{requested_account}".'
821
+ if requested_account is not None
822
+ else f"Removed all {provider.label} credentials."
823
+ )
824
+ return None
825
+
826
+
827
+ def format_credential_fault(fault: CredentialFault) -> str:
828
+ """Render a :class:`~.contract.CredentialFault` as the single human-facing
829
+ message the caller prints before exiting non-zero. Pure; no I/O."""
830
+ head = f"{_fault_label(fault.kind)}: {fault.message}"
831
+ return f"{head}\n {fault.hint}" if fault.hint is not None else head
832
+
833
+
834
+ def _fault_label(kind: CredentialFaultKind) -> str:
835
+ """A short human label for a :data:`~.contract.CredentialFaultKind`."""
836
+ match kind:
837
+ case "unknown-provider":
838
+ return "Unknown provider"
839
+ case "invalid-key":
840
+ return "Invalid api key"
841
+ case "invalid-account":
842
+ return "Invalid account name"
843
+ case "name-collision":
844
+ return "Account already exists"
845
+ case "not-found":
846
+ return "Nothing to remove"
847
+ case "vault":
848
+ return "Credential store error"
849
+ case "aborted":
850
+ return "Cancelled"
851
+ case _: # pragma: no cover - closed union
852
+ return "Error"