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,503 @@
|
|
|
1
|
+
"""Auth overlays — the sign-in / sign-out, OAuth-flow, and plugin dialogs.
|
|
2
|
+
|
|
3
|
+
Port of TS ``src/console/overlays/auth.tsx``. This module owns the four modal
|
|
4
|
+
kinds tied to credentials and host-supplied overlays: the provider sign-in
|
|
5
|
+
launcher (``signIn``), the sign-out confirmation (``signOut``), the in-flight
|
|
6
|
+
OAuth device/redirect flow (``oauth``), and a plugin-supplied overlay
|
|
7
|
+
(``plugin``).
|
|
8
|
+
|
|
9
|
+
Dialog-API inversion (port plan analysis 02, risk 1) — the redesigned spots:
|
|
10
|
+
|
|
11
|
+
- **Sign-in hand-off.** TS dispatched ``modal:open { kind: "oauth" }`` from
|
|
12
|
+
inside the sign-in dialog and let the host re-route. Here the chain is an
|
|
13
|
+
*awaited sub-flow*: :func:`run_sign_in` awaits the provider pick (or skips
|
|
14
|
+
it for a ``/login <provider>`` payload) and then awaits
|
|
15
|
+
:func:`_run_auth_entry` directly, so the reducer sees exactly one
|
|
16
|
+
``modal:open``/``modal:close`` pair per user-raised overlay.
|
|
17
|
+
- **The parked resolver.** TS parked a ``pending.current`` callback ref the
|
|
18
|
+
dialog's submit fulfilled. Here the framework
|
|
19
|
+
:class:`~indusagi.react_ink.OAuthDialog` *is* the prompt seam: its
|
|
20
|
+
``ask()`` parks an :class:`asyncio.Future` that Enter resolves, and the
|
|
21
|
+
whole login choreography runs as a ``flow`` worker the dialog drives —
|
|
22
|
+
the launch adapter's :class:`~induscode.launch.OAuthLoginCallbacks` are
|
|
23
|
+
bridged onto the dialog's ``update_state``/``ask`` seams.
|
|
24
|
+
- **Completion.** TS closed the modal from inside the callback chain; here
|
|
25
|
+
the flow returns exit code 0, the dialog dismisses itself with that code,
|
|
26
|
+
and this module's post-await code binds a model + returns the success
|
|
27
|
+
status event. A failed flow posts the TS ``"Sign-in did not complete."``
|
|
28
|
+
progress line and keeps the dialog open (Esc then cancels).
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import asyncio
|
|
34
|
+
from dataclasses import dataclass
|
|
35
|
+
from typing import TYPE_CHECKING, Any, Final
|
|
36
|
+
|
|
37
|
+
from rich.text import Text
|
|
38
|
+
from textual.binding import Binding
|
|
39
|
+
from textual.screen import ModalScreen
|
|
40
|
+
from textual.widgets import Static
|
|
41
|
+
|
|
42
|
+
from indusagi.react_ink import (
|
|
43
|
+
AuthRedirectInfo,
|
|
44
|
+
DialogFrame,
|
|
45
|
+
LoginDialog,
|
|
46
|
+
LoginProviderOption,
|
|
47
|
+
OAuthDialog,
|
|
48
|
+
OAuthOverlayState,
|
|
49
|
+
SavedAccountOption,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
from induscode.console.contract import (
|
|
53
|
+
ConsoleEvent,
|
|
54
|
+
OverlayServices,
|
|
55
|
+
StatusMessage,
|
|
56
|
+
StatusSet,
|
|
57
|
+
)
|
|
58
|
+
from induscode.launch import OAuthAuthorization, OAuthLoginCallbacks, OAuthPrompt
|
|
59
|
+
|
|
60
|
+
if TYPE_CHECKING:
|
|
61
|
+
from textual.app import App, ComposeResult
|
|
62
|
+
|
|
63
|
+
__all__ = [
|
|
64
|
+
"PluginOverlayScreen",
|
|
65
|
+
"PluginRequest",
|
|
66
|
+
"login_provider_rows",
|
|
67
|
+
"read_entry_mode",
|
|
68
|
+
"read_plugin_request",
|
|
69
|
+
"read_provider_id",
|
|
70
|
+
"read_requested_provider",
|
|
71
|
+
"run_oauth_flow",
|
|
72
|
+
"run_plugin",
|
|
73
|
+
"run_sign_in",
|
|
74
|
+
"run_sign_out",
|
|
75
|
+
"saved_account_rows",
|
|
76
|
+
"seed_oauth_state",
|
|
77
|
+
"select_provider_model",
|
|
78
|
+
]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# ---------------------------------------------------------------------------
|
|
82
|
+
# Payload narrowing (TS readRequestedProvider / readProviderId / readEntryMode)
|
|
83
|
+
# ---------------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def read_requested_provider(payload: object | None) -> str | None:
|
|
87
|
+
"""Narrow the opaque sign-in payload to a requested provider id, when one
|
|
88
|
+
was carried (from ``/login <provider>``). ``None`` for a bare launcher."""
|
|
89
|
+
if isinstance(payload, dict):
|
|
90
|
+
provider_id = payload.get("providerId")
|
|
91
|
+
if isinstance(provider_id, str) and len(provider_id) > 0:
|
|
92
|
+
return provider_id
|
|
93
|
+
return None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def read_provider_id(payload: object | None) -> str:
|
|
97
|
+
"""Narrow the opaque oauth payload to the provider id it carries."""
|
|
98
|
+
if isinstance(payload, dict):
|
|
99
|
+
provider_id = payload.get("providerId")
|
|
100
|
+
if isinstance(provider_id, str):
|
|
101
|
+
return provider_id
|
|
102
|
+
return ""
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def read_entry_mode(payload: object | None) -> str:
|
|
106
|
+
"""Narrow the opaque payload to its entry mode; defaults to the OAuth
|
|
107
|
+
flow (``"apiKey"`` is the only other recognised mode)."""
|
|
108
|
+
if isinstance(payload, dict) and payload.get("mode") == "apiKey":
|
|
109
|
+
return "apiKey"
|
|
110
|
+
return "oauth"
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# ---------------------------------------------------------------------------
|
|
114
|
+
# Directory / vault projections
|
|
115
|
+
# ---------------------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def login_provider_rows(services: OverlayServices) -> list[LoginProviderOption]:
|
|
119
|
+
"""The merged login directory as framework dialog rows, tolerant of any
|
|
120
|
+
directory fault (the TS provider-list try/catch)."""
|
|
121
|
+
try:
|
|
122
|
+
return [
|
|
123
|
+
LoginProviderOption(id=entry.id, label=entry.label, authKind=entry.auth_kind)
|
|
124
|
+
for entry in services.list_login_providers()
|
|
125
|
+
]
|
|
126
|
+
except Exception:
|
|
127
|
+
return []
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
async def saved_account_rows(services: OverlayServices) -> list[SavedAccountOption]:
|
|
131
|
+
"""Every saved account across the known providers, as sign-out rows.
|
|
132
|
+
|
|
133
|
+
A provider whose vault read fails contributes nothing; a directory fault
|
|
134
|
+
yields the rows gathered so far (TS semantics verbatim, label included:
|
|
135
|
+
``"{provider label} · {accountId}"``).
|
|
136
|
+
"""
|
|
137
|
+
rows: list[SavedAccountOption] = []
|
|
138
|
+
try:
|
|
139
|
+
providers = services.list_login_providers()
|
|
140
|
+
except Exception:
|
|
141
|
+
return rows
|
|
142
|
+
for provider in providers:
|
|
143
|
+
try:
|
|
144
|
+
names = await services.vault.list_accounts(provider.id)
|
|
145
|
+
except Exception:
|
|
146
|
+
names = []
|
|
147
|
+
for account_id in names:
|
|
148
|
+
rows.append(
|
|
149
|
+
SavedAccountOption(
|
|
150
|
+
provider=provider.id,
|
|
151
|
+
accountId=account_id,
|
|
152
|
+
label=f"{provider.label} · {account_id}",
|
|
153
|
+
)
|
|
154
|
+
)
|
|
155
|
+
return rows
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _provider_name(services: OverlayServices, provider_id: str) -> str:
|
|
159
|
+
"""The human label for a provider id, falling back to the id itself."""
|
|
160
|
+
try:
|
|
161
|
+
for entry in services.list_login_providers():
|
|
162
|
+
if entry.id == provider_id:
|
|
163
|
+
return entry.label
|
|
164
|
+
except Exception:
|
|
165
|
+
pass
|
|
166
|
+
return provider_id
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def select_provider_model(services: OverlayServices, provider_id: str) -> None:
|
|
170
|
+
"""Bind the session to a model from a freshly-authenticated provider, so
|
|
171
|
+
a chat turn works immediately after sign-in without a manual ``/model``
|
|
172
|
+
round-trip.
|
|
173
|
+
|
|
174
|
+
Best-effort: a provider with no catalog entry, or a conductor that
|
|
175
|
+
rejects the switch, is silently left on the current model. The catalog
|
|
176
|
+
lists a provider's models oldest-first and some early entries are retired
|
|
177
|
+
(they 404), so the *newest* entry is preferred.
|
|
178
|
+
"""
|
|
179
|
+
try:
|
|
180
|
+
cards = [
|
|
181
|
+
card
|
|
182
|
+
for card in services.conductor.available_models()
|
|
183
|
+
if card.provider == provider_id
|
|
184
|
+
]
|
|
185
|
+
if cards:
|
|
186
|
+
services.conductor.select_model(cards[-1].id)
|
|
187
|
+
except Exception:
|
|
188
|
+
# Leave the session on its current model.
|
|
189
|
+
pass
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
# ---------------------------------------------------------------------------
|
|
193
|
+
# signIn — the provider launcher
|
|
194
|
+
# ---------------------------------------------------------------------------
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
async def run_sign_in(
|
|
198
|
+
app: "App[Any]",
|
|
199
|
+
payload: object | None,
|
|
200
|
+
services: OverlayServices | None,
|
|
201
|
+
) -> tuple[ConsoleEvent, ...]:
|
|
202
|
+
"""The provider sign-in launcher flow.
|
|
203
|
+
|
|
204
|
+
Lists the merged login directory and routes the chosen entry by its auth
|
|
205
|
+
kind into :func:`_run_auth_entry` — a browser provider opens the OAuth
|
|
206
|
+
choreography, an api-key provider opens the inline key input. When the
|
|
207
|
+
payload names a known provider (``/login anthropic``), the picker is
|
|
208
|
+
skipped entirely and that provider's entry flow opens straight away.
|
|
209
|
+
"""
|
|
210
|
+
if services is None:
|
|
211
|
+
return ()
|
|
212
|
+
providers = login_provider_rows(services)
|
|
213
|
+
|
|
214
|
+
requested = read_requested_provider(payload)
|
|
215
|
+
direct = (
|
|
216
|
+
next((p for p in providers if p.id == requested), None)
|
|
217
|
+
if requested is not None
|
|
218
|
+
else None
|
|
219
|
+
)
|
|
220
|
+
if direct is not None:
|
|
221
|
+
mode = "oauth" if direct.authKind == "oauth" else "apiKey"
|
|
222
|
+
return await _run_auth_entry(app, services, direct.id, mode)
|
|
223
|
+
|
|
224
|
+
chosen = await app.push_screen_wait(LoginDialog(mode="login", providers=providers))
|
|
225
|
+
if not isinstance(chosen, LoginProviderOption):
|
|
226
|
+
return ()
|
|
227
|
+
mode = "oauth" if chosen.authKind == "oauth" else "apiKey"
|
|
228
|
+
return await _run_auth_entry(app, services, chosen.id, mode)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
# ---------------------------------------------------------------------------
|
|
232
|
+
# signOut — the saved-account remover
|
|
233
|
+
# ---------------------------------------------------------------------------
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
async def run_sign_out(
|
|
237
|
+
app: "App[Any]",
|
|
238
|
+
payload: object | None,
|
|
239
|
+
services: OverlayServices | None,
|
|
240
|
+
) -> tuple[ConsoleEvent, ...]:
|
|
241
|
+
"""The sign-out confirmation flow: enumerate every saved account across
|
|
242
|
+
the known providers and remove the chosen one through the vault."""
|
|
243
|
+
if services is None:
|
|
244
|
+
return ()
|
|
245
|
+
accounts = await saved_account_rows(services)
|
|
246
|
+
chosen = await app.push_screen_wait(LoginDialog(mode="logout", accounts=accounts))
|
|
247
|
+
if isinstance(chosen, SavedAccountOption):
|
|
248
|
+
try:
|
|
249
|
+
await services.vault.remove(chosen.provider, chosen.accountId)
|
|
250
|
+
except Exception:
|
|
251
|
+
# Swallow: closing the overlay is the right end state regardless.
|
|
252
|
+
pass
|
|
253
|
+
return ()
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
# ---------------------------------------------------------------------------
|
|
257
|
+
# oauth — the in-flight browser / api-key entry flow
|
|
258
|
+
# ---------------------------------------------------------------------------
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def seed_oauth_state(provider_id: str, provider_name: str, mode: str) -> OAuthOverlayState:
|
|
262
|
+
"""Seed a fresh overlay state for a provider, before the flow reports
|
|
263
|
+
anything (the TS ``seedOAuthState`` verbatim)."""
|
|
264
|
+
entry_mode = "apiKey" if mode == "apiKey" else "oauth"
|
|
265
|
+
return OAuthOverlayState(
|
|
266
|
+
providerId=provider_id,
|
|
267
|
+
providerName=provider_name,
|
|
268
|
+
mode=entry_mode,
|
|
269
|
+
inputLabel=(
|
|
270
|
+
f"{provider_name} API key" if entry_mode == "apiKey" else "Authorization code"
|
|
271
|
+
),
|
|
272
|
+
inputValue="",
|
|
273
|
+
accountId="default",
|
|
274
|
+
accountName="Default",
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
async def run_oauth_flow(
|
|
279
|
+
app: "App[Any]",
|
|
280
|
+
payload: object | None,
|
|
281
|
+
services: OverlayServices | None,
|
|
282
|
+
) -> tuple[ConsoleEvent, ...]:
|
|
283
|
+
"""The ``oauth`` modal kind: narrow the payload and run the entry flow."""
|
|
284
|
+
if services is None:
|
|
285
|
+
return ()
|
|
286
|
+
return await _run_auth_entry(
|
|
287
|
+
app, services, read_provider_id(payload), read_entry_mode(payload)
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
async def _run_auth_entry(
|
|
292
|
+
app: "App[Any]",
|
|
293
|
+
services: OverlayServices,
|
|
294
|
+
provider_id: str,
|
|
295
|
+
mode: str,
|
|
296
|
+
) -> tuple[ConsoleEvent, ...]:
|
|
297
|
+
"""The shared credential-entry flow behind ``signIn`` and ``oauth``."""
|
|
298
|
+
provider_name = _provider_name(services, provider_id)
|
|
299
|
+
if mode == "apiKey":
|
|
300
|
+
return await _run_api_key_entry(app, services, provider_id, provider_name)
|
|
301
|
+
return await _run_browser_login(app, services, provider_id, provider_name)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
async def _run_api_key_entry(
|
|
305
|
+
app: "App[Any]",
|
|
306
|
+
services: OverlayServices,
|
|
307
|
+
provider_id: str,
|
|
308
|
+
provider_name: str,
|
|
309
|
+
) -> tuple[ConsoleEvent, ...]:
|
|
310
|
+
"""Collect an API key through the entry dialog and persist it.
|
|
311
|
+
|
|
312
|
+
The TS ``saveApiKey`` ignored an empty submit and kept the dialog open;
|
|
313
|
+
the Python dialog dismisses on every Enter, so an empty submit re-raises
|
|
314
|
+
the dialog (same observable behaviour: nothing is stored until a
|
|
315
|
+
non-empty key is entered or the user cancels).
|
|
316
|
+
"""
|
|
317
|
+
state = seed_oauth_state(provider_id, provider_name, "apiKey")
|
|
318
|
+
key = ""
|
|
319
|
+
while True:
|
|
320
|
+
result = await app.push_screen_wait(OAuthDialog(state))
|
|
321
|
+
if result is None:
|
|
322
|
+
return ()
|
|
323
|
+
key = result.value.strip()
|
|
324
|
+
if len(key) > 0:
|
|
325
|
+
break
|
|
326
|
+
|
|
327
|
+
account = state.accountId or "default"
|
|
328
|
+
try:
|
|
329
|
+
await services.vault.put_api_key(provider_id, account, key, True)
|
|
330
|
+
select_provider_model(services, provider_id)
|
|
331
|
+
status = StatusMessage(kind="success", text=f"Saved your {provider_name} API key.")
|
|
332
|
+
except Exception:
|
|
333
|
+
status = StatusMessage(
|
|
334
|
+
kind="error", text=f"Could not save the {provider_name} API key."
|
|
335
|
+
)
|
|
336
|
+
return (StatusSet(status=status),)
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
async def _run_browser_login(
|
|
340
|
+
app: "App[Any]",
|
|
341
|
+
services: OverlayServices,
|
|
342
|
+
provider_id: str,
|
|
343
|
+
provider_name: str,
|
|
344
|
+
) -> tuple[ConsoleEvent, ...]:
|
|
345
|
+
"""Drive the launch adapter's browser sign-in through the entry dialog.
|
|
346
|
+
|
|
347
|
+
The :class:`~induscode.launch.OAuthLoginCallbacks` bag is bridged onto
|
|
348
|
+
the dialog's seams (risk-1 redesign; module docstring):
|
|
349
|
+
|
|
350
|
+
- ``on_auth`` → ``update_state(authInfo=..., progress=...)`` plus the
|
|
351
|
+
automatic Chrome-first browser launch via ``services.open_login_url``
|
|
352
|
+
(the dialog renders the instructions and URL on their own rows, so the
|
|
353
|
+
progress line carries a DISTINCT status — never a copy of the
|
|
354
|
+
instructions);
|
|
355
|
+
- ``on_prompt`` → the dialog's ``ask()`` (a parked ``asyncio.Future``
|
|
356
|
+
resolved by Enter — the TS ``pending.current`` resolver);
|
|
357
|
+
- ``on_progress`` → ``update_state(progress=...)``.
|
|
358
|
+
|
|
359
|
+
Success persists through the vault inside ``start_oauth_login``; this
|
|
360
|
+
flow then binds a model for the provider and returns the success status.
|
|
361
|
+
"""
|
|
362
|
+
state = seed_oauth_state(provider_id, provider_name, "oauth")
|
|
363
|
+
|
|
364
|
+
async def flow(io: OAuthDialog) -> int:
|
|
365
|
+
def on_progress(message: str) -> None:
|
|
366
|
+
io.update_state(progress=message)
|
|
367
|
+
|
|
368
|
+
async def on_auth(info: OAuthAuthorization) -> None:
|
|
369
|
+
io.update_state(
|
|
370
|
+
authInfo=AuthRedirectInfo(url=info.url, instructions=info.instructions),
|
|
371
|
+
progress="Opening your browser to sign in…",
|
|
372
|
+
)
|
|
373
|
+
opened = await services.open_login_url(info.url)
|
|
374
|
+
io.update_state(
|
|
375
|
+
progress=(
|
|
376
|
+
"Opened the login URL in your browser. Finish there, then paste "
|
|
377
|
+
"the code if prompted."
|
|
378
|
+
if opened
|
|
379
|
+
else "Could not open a browser automatically — open the URL above, "
|
|
380
|
+
"then paste the code if prompted."
|
|
381
|
+
)
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
async def on_prompt(prompt: OAuthPrompt) -> str:
|
|
385
|
+
io.update_state(inputLabel=prompt.message)
|
|
386
|
+
return await io.ask(prompt.message)
|
|
387
|
+
|
|
388
|
+
callbacks = OAuthLoginCallbacks(
|
|
389
|
+
on_auth=on_auth, on_prompt=on_prompt, on_progress=on_progress
|
|
390
|
+
)
|
|
391
|
+
try:
|
|
392
|
+
await services.start_oauth_login(provider_id, callbacks, services.vault)
|
|
393
|
+
except asyncio.CancelledError:
|
|
394
|
+
raise
|
|
395
|
+
except Exception:
|
|
396
|
+
io.update_state(progress="Sign-in did not complete.")
|
|
397
|
+
return 1
|
|
398
|
+
return 0
|
|
399
|
+
|
|
400
|
+
result = await app.push_screen_wait(OAuthDialog(state, flow=flow))
|
|
401
|
+
if result is not None and result.exit_code == 0:
|
|
402
|
+
select_provider_model(services, provider_id)
|
|
403
|
+
return (
|
|
404
|
+
StatusSet(
|
|
405
|
+
status=StatusMessage(kind="success", text=f"Signed in to {provider_name}.")
|
|
406
|
+
),
|
|
407
|
+
)
|
|
408
|
+
return ()
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
# ---------------------------------------------------------------------------
|
|
412
|
+
# plugin — the host-supplied overlay
|
|
413
|
+
# ---------------------------------------------------------------------------
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
@dataclass(frozen=True, slots=True)
|
|
417
|
+
class PluginRequest:
|
|
418
|
+
"""The narrowed shape of a plugin overlay request (TS ``PluginRequest``)."""
|
|
419
|
+
|
|
420
|
+
#: The surface name (``mcp``, ``memory``, ``composio``, …).
|
|
421
|
+
surface: str
|
|
422
|
+
#: A human title for the frame, when the command supplied one.
|
|
423
|
+
title: str | None = None
|
|
424
|
+
#: The real, command-gathered body text to render.
|
|
425
|
+
text: str | None = None
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def read_plugin_request(payload: object | None) -> PluginRequest:
|
|
429
|
+
"""Narrow the opaque plugin payload into a :class:`PluginRequest`."""
|
|
430
|
+
if isinstance(payload, dict):
|
|
431
|
+
surface = payload.get("surface")
|
|
432
|
+
title = payload.get("title")
|
|
433
|
+
text = payload.get("text")
|
|
434
|
+
return PluginRequest(
|
|
435
|
+
surface=surface if isinstance(surface, str) else "plugin",
|
|
436
|
+
title=title if isinstance(title, str) else None,
|
|
437
|
+
text=text if isinstance(text, str) else None,
|
|
438
|
+
)
|
|
439
|
+
return PluginRequest(surface="plugin")
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
#: The TS plugin-dialog footer, string identical.
|
|
443
|
+
_PLUGIN_FOOTER: Final[str] = "esc to close"
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
class PluginOverlayScreen(ModalScreen[None]):
|
|
447
|
+
"""A plugin-supplied overlay: the command-gathered body text rendered
|
|
448
|
+
line-by-line in a labelled :class:`~indusagi.react_ink.DialogFrame`.
|
|
449
|
+
|
|
450
|
+
A request that carries no text (an unknown surface) falls back to naming
|
|
451
|
+
the surface so the frame is never empty. Esc dismisses with ``None``.
|
|
452
|
+
"""
|
|
453
|
+
|
|
454
|
+
BINDINGS = [Binding("escape", "close_overlay", "Close", show=False)]
|
|
455
|
+
|
|
456
|
+
DEFAULT_CSS = """
|
|
457
|
+
PluginOverlayScreen {
|
|
458
|
+
align: center middle;
|
|
459
|
+
}
|
|
460
|
+
PluginOverlayScreen .plugin-line {
|
|
461
|
+
height: auto;
|
|
462
|
+
}
|
|
463
|
+
"""
|
|
464
|
+
|
|
465
|
+
def __init__(
|
|
466
|
+
self,
|
|
467
|
+
request: PluginRequest,
|
|
468
|
+
*,
|
|
469
|
+
name: str | None = None,
|
|
470
|
+
id: str | None = None,
|
|
471
|
+
classes: str | None = None,
|
|
472
|
+
) -> None:
|
|
473
|
+
super().__init__(name=name, id=id, classes=classes)
|
|
474
|
+
self._request = request
|
|
475
|
+
|
|
476
|
+
def compose(self) -> "ComposeResult":
|
|
477
|
+
request = self._request
|
|
478
|
+
body = (
|
|
479
|
+
request.text
|
|
480
|
+
if request.text is not None
|
|
481
|
+
else f"No data available for the {request.surface} surface."
|
|
482
|
+
)
|
|
483
|
+
with DialogFrame(
|
|
484
|
+
title=request.title if request.title is not None else "Plugin surface",
|
|
485
|
+
subtitle=request.surface,
|
|
486
|
+
footer=_PLUGIN_FOOTER,
|
|
487
|
+
):
|
|
488
|
+
for line in body.split("\n"):
|
|
489
|
+
yield Static(Text(line if len(line) > 0 else " "), classes="plugin-line")
|
|
490
|
+
|
|
491
|
+
def action_close_overlay(self) -> None:
|
|
492
|
+
self.dismiss(None)
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
async def run_plugin(
|
|
496
|
+
app: "App[Any]",
|
|
497
|
+
payload: object | None,
|
|
498
|
+
services: OverlayServices | None,
|
|
499
|
+
) -> tuple[ConsoleEvent, ...]:
|
|
500
|
+
"""The plugin overlay flow. The only kind that renders without runtime
|
|
501
|
+
services (the TS group special-cased it the same way)."""
|
|
502
|
+
await app.push_screen_wait(PluginOverlayScreen(read_plugin_request(payload)))
|
|
503
|
+
return ()
|