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.
- induscode/__init__.py +56 -0
- induscode/addons/__init__.py +176 -0
- induscode/addons/contract.py +923 -0
- induscode/addons/dispatch/__init__.py +43 -0
- induscode/addons/dispatch/event_dispatcher.py +348 -0
- induscode/addons/dispatch/tool_interceptor.py +349 -0
- induscode/addons/host.py +469 -0
- induscode/addons/loader.py +314 -0
- induscode/addons/manifest.py +232 -0
- induscode/addons/surface.py +199 -0
- induscode/boot/__init__.py +108 -0
- induscode/boot/auth_vault.py +323 -0
- induscode/boot/boot.py +210 -0
- induscode/boot/contract.py +223 -0
- induscode/boot/invocation.py +117 -0
- induscode/boot/runners/__init__.py +42 -0
- induscode/boot/runners/link_runner.py +82 -0
- induscode/boot/runners/oneshot_runner.py +85 -0
- induscode/boot/runners/registry.py +46 -0
- induscode/boot/runners/repl_runner.py +340 -0
- induscode/boot/runners/session.py +549 -0
- induscode/boot/stages.py +198 -0
- induscode/boot/upgrade/__init__.py +36 -0
- induscode/boot/upgrade/apply.py +125 -0
- induscode/boot/upgrade/upgrades.py +136 -0
- induscode/briefing/__init__.py +115 -0
- induscode/briefing/compose.py +414 -0
- induscode/briefing/contract.py +528 -0
- induscode/briefing/macros.py +721 -0
- induscode/briefing/skills.py +417 -0
- induscode/capability_deck/__init__.py +233 -0
- induscode/capability_deck/bridge_ledger/__init__.py +66 -0
- induscode/capability_deck/bridge_ledger/key.py +181 -0
- induscode/capability_deck/bridge_ledger/ledger.py +276 -0
- induscode/capability_deck/bridge_ledger/network.py +336 -0
- induscode/capability_deck/builtin_bridge.py +358 -0
- induscode/capability_deck/cards/__init__.py +116 -0
- induscode/capability_deck/cards/bg_process.py +482 -0
- induscode/capability_deck/cards/memory.py +226 -0
- induscode/capability_deck/cards/saas.py +280 -0
- induscode/capability_deck/cards/task.py +256 -0
- induscode/capability_deck/cards/todo.py +312 -0
- induscode/capability_deck/contract.py +450 -0
- induscode/capability_deck/manifest.py +126 -0
- induscode/capability_deck/provision.py +217 -0
- induscode/channels/__init__.py +146 -0
- induscode/channels/contract.py +585 -0
- induscode/channels/framer.py +132 -0
- induscode/channels/link/__init__.py +50 -0
- induscode/channels/link/dialog.py +246 -0
- induscode/channels/link/driver.py +308 -0
- induscode/channels/link/server.py +217 -0
- induscode/channels/oneshot.py +178 -0
- induscode/channels/ops.py +140 -0
- induscode/channels/session_ops.py +172 -0
- induscode/conductor/__init__.py +240 -0
- induscode/conductor/catalog.py +309 -0
- induscode/conductor/conductor.py +1084 -0
- induscode/conductor/contract.py +1035 -0
- induscode/conductor/matcher.py +291 -0
- induscode/conductor/serialize.py +575 -0
- induscode/conductor/signal_hub.py +382 -0
- induscode/conductor/skill_parse.py +294 -0
- induscode/conductor/transcript_store.py +449 -0
- induscode/console/__init__.py +236 -0
- induscode/console/app.py +1677 -0
- induscode/console/components/__init__.py +62 -0
- induscode/console/components/banner.py +499 -0
- induscode/console/components/banner_sweep.py +188 -0
- induscode/console/components/emblem.py +181 -0
- induscode/console/components/status_bar.py +102 -0
- induscode/console/contract.py +836 -0
- induscode/console/input/__init__.py +107 -0
- induscode/console/input/chord.py +197 -0
- induscode/console/input/dir_reader.py +113 -0
- induscode/console/input/intents.py +258 -0
- induscode/console/input/providers.py +469 -0
- induscode/console/mount.py +137 -0
- induscode/console/overlays/__init__.py +94 -0
- induscode/console/overlays/auth.py +503 -0
- induscode/console/overlays/pickers.py +526 -0
- induscode/console/overlays/router.py +129 -0
- induscode/console/overlays/sessions.py +232 -0
- induscode/console/reducer.py +145 -0
- induscode/console/resume_picker.py +156 -0
- induscode/console/slash_commands/__init__.py +78 -0
- induscode/console/slash_commands/builtins.py +254 -0
- induscode/console/slash_commands/dynamic.py +217 -0
- induscode/console/slash_commands/integrations.py +949 -0
- induscode/console/slash_commands/transcript.py +404 -0
- induscode/console/slash_commands/workbench.py +430 -0
- induscode/console/startup.py +434 -0
- induscode/console/theme/__init__.py +44 -0
- induscode/console/theme/adapter.py +168 -0
- induscode/console/theme/palette.py +128 -0
- induscode/console/theme/resolve.py +123 -0
- induscode/console/theme/tokens.py +185 -0
- induscode/console_slash/__init__.py +111 -0
- induscode/console_slash/contract.py +185 -0
- induscode/console_slash/registry.py +140 -0
- induscode/console_slash/resolve.py +194 -0
- induscode/console_slash/shared.py +172 -0
- induscode/entry.py +108 -0
- induscode/insight/__init__.py +153 -0
- induscode/insight/collector.py +73 -0
- induscode/insight/replay.py +305 -0
- induscode/insight/wrapper.py +1115 -0
- induscode/kit/__init__.py +82 -0
- induscode/kit/clipboard_image.py +215 -0
- induscode/kit/external_editor.py +120 -0
- induscode/kit/image.py +188 -0
- induscode/kit/shell.py +89 -0
- induscode/kit/tool_fetch.py +288 -0
- induscode/launch/__init__.py +224 -0
- induscode/launch/catalog.py +310 -0
- induscode/launch/contract.py +569 -0
- induscode/launch/credentials.py +852 -0
- induscode/launch/invocation/__init__.py +39 -0
- induscode/launch/invocation/attachments.py +281 -0
- induscode/launch/invocation/flags.py +210 -0
- induscode/launch/invocation/read.py +369 -0
- induscode/launch/invocation/usage.py +110 -0
- induscode/launch/oauth.py +808 -0
- induscode/launch/packages.py +299 -0
- induscode/launch/pickers.py +291 -0
- induscode/py.typed +0 -0
- induscode/runtime_bridge/__init__.py +166 -0
- induscode/runtime_bridge/bridges/__init__.py +66 -0
- induscode/runtime_bridge/bridges/_drive.py +268 -0
- induscode/runtime_bridge/bridges/builtins.py +177 -0
- induscode/runtime_bridge/bridges/claude_cli.py +198 -0
- induscode/runtime_bridge/bridges/codex_cli.py +203 -0
- induscode/runtime_bridge/bridges/indusagi_cli.py +217 -0
- induscode/runtime_bridge/broker.py +397 -0
- induscode/runtime_bridge/contract.py +734 -0
- induscode/runtime_bridge/sink.py +351 -0
- induscode/sessions/__init__.py +25 -0
- induscode/sessions/contract.py +119 -0
- induscode/sessions/library.py +350 -0
- induscode/settings/__init__.py +47 -0
- induscode/settings/contract.py +313 -0
- induscode/settings/manager.py +268 -0
- induscode/transcript_export/__init__.py +109 -0
- induscode/transcript_export/contract.py +522 -0
- induscode/transcript_export/publish.py +455 -0
- induscode/transcript_export/sgr.py +566 -0
- induscode/transcript_export/template.py +319 -0
- induscode/transcript_export/theme_bridge.py +325 -0
- induscode/window_budget/__init__.py +76 -0
- induscode/window_budget/budget/__init__.py +26 -0
- induscode/window_budget/budget/estimate.py +273 -0
- induscode/window_budget/budget/gate.py +60 -0
- induscode/window_budget/budget/slice.py +145 -0
- induscode/window_budget/condenser.py +170 -0
- induscode/window_budget/contract.py +329 -0
- induscode/window_budget/summarize/__init__.py +33 -0
- induscode/window_budget/summarize/condense.py +212 -0
- induscode/window_budget/summarize/prompt.py +241 -0
- induscode/workspace/__init__.py +30 -0
- induscode/workspace/brand.py +96 -0
- induscode/workspace/locator.py +269 -0
- induscode-0.1.0.dist-info/METADATA +97 -0
- induscode-0.1.0.dist-info/RECORD +167 -0
- induscode-0.1.0.dist-info/WHEEL +4 -0
- induscode-0.1.0.dist-info/entry_points.txt +3 -0
- induscode-0.1.0.dist-info/licenses/CREDITS.md +22 -0
- 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"
|