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,199 @@
|
|
|
1
|
+
"""AddonSurface implementation — the recording registration API.
|
|
2
|
+
|
|
3
|
+
This module realizes the contract's central design stance: an addon's
|
|
4
|
+
``register`` entry point is handed an :class:`~induscode.addons.contract.AddonSurface`
|
|
5
|
+
and *records* its intent — event subscriptions, tool interceptors, slash
|
|
6
|
+
commands, contributed tools — rather than mutating any shared runtime. Each
|
|
7
|
+
surface is a small, per-addon accumulator: every ``on`` / ``intercept_tool``
|
|
8
|
+
/ ``add_command`` / ``add_tool`` call appends to a private list, and
|
|
9
|
+
:meth:`RecordingSurface.manifest` reads the accumulated
|
|
10
|
+
:class:`RegisteredManifest` back out for the host to fold.
|
|
11
|
+
|
|
12
|
+
Because registration is pure recording, the host stays in control of *what to
|
|
13
|
+
do* with the contributions. The surface only:
|
|
14
|
+
|
|
15
|
+
- stamps every recorded shape with the owning :data:`AddonId` (so a later
|
|
16
|
+
fault is attributable) and fills the ``match`` / ``name`` fields the
|
|
17
|
+
surface methods take separately from the handler object;
|
|
18
|
+
- threads the host-supplied :class:`FrameworkHandles` through unchanged, so
|
|
19
|
+
an addon can act at registration time and the same handles reach its
|
|
20
|
+
command contexts; and
|
|
21
|
+
- returns an immutable snapshot from :meth:`RecordingSurface.manifest` (a
|
|
22
|
+
frozen dataclass over tuples), so a read after ``register`` settles cannot
|
|
23
|
+
be mutated by a stray later call.
|
|
24
|
+
|
|
25
|
+
The surface performs no dispatch, no loading, and no conflict resolution —
|
|
26
|
+
those are the host's job once it has folded every addon's manifest. Keeping
|
|
27
|
+
the surface this thin is what makes registration a description of capability
|
|
28
|
+
rather than a side effect on the agent.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
from .contract import (
|
|
34
|
+
AddonCommand,
|
|
35
|
+
AddonId,
|
|
36
|
+
AddonSurface,
|
|
37
|
+
AddonTool,
|
|
38
|
+
CommandSpec,
|
|
39
|
+
EventSubscription,
|
|
40
|
+
FrameworkHandles,
|
|
41
|
+
HookEvent,
|
|
42
|
+
HookHandler,
|
|
43
|
+
InterceptorStage,
|
|
44
|
+
RegisteredManifest,
|
|
45
|
+
ToolInterceptor,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
__all__ = [
|
|
49
|
+
"RecordingSurface",
|
|
50
|
+
"create_surface",
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# ---------------------------------------------------------------------------
|
|
55
|
+
# Recording surface
|
|
56
|
+
# ---------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class RecordingSurface:
|
|
60
|
+
"""The concrete, per-addon :class:`AddonSurface` the host hands to one
|
|
61
|
+
addon's ``register``.
|
|
62
|
+
|
|
63
|
+
Construct one with :func:`create_surface` (the host mints a fresh surface
|
|
64
|
+
per addon, scoped to that addon's :data:`AddonId` and the session's
|
|
65
|
+
:class:`FrameworkHandles`). The surface accumulates contributions into
|
|
66
|
+
private lists and exposes them as a frozen :class:`RegisteredManifest`
|
|
67
|
+
once ``register`` has run.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
def __init__(
|
|
71
|
+
self,
|
|
72
|
+
id: AddonId,
|
|
73
|
+
handles: FrameworkHandles,
|
|
74
|
+
version: str | None = None,
|
|
75
|
+
) -> None:
|
|
76
|
+
"""
|
|
77
|
+
:param id: the addon this surface is scoped to (stamped onto every shape)
|
|
78
|
+
:param handles: the session's framework handles, threaded through unchanged
|
|
79
|
+
:param version: optional addon version, carried into the manifest
|
|
80
|
+
"""
|
|
81
|
+
self._id = id
|
|
82
|
+
self._handles = handles
|
|
83
|
+
self._version = version
|
|
84
|
+
# Event subscriptions recorded via `on`, in call order.
|
|
85
|
+
self._subscriptions: list[EventSubscription] = []
|
|
86
|
+
# Tool interceptors recorded via `intercept_tool`, in call order.
|
|
87
|
+
self._interceptors: list[ToolInterceptor] = []
|
|
88
|
+
# Slash commands recorded via `add_command`, in call order.
|
|
89
|
+
self._commands: list[AddonCommand] = []
|
|
90
|
+
# Contributed tools recorded via `add_tool`, in call order.
|
|
91
|
+
self._tools: list[AddonTool] = []
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def id(self) -> AddonId:
|
|
95
|
+
"""The id of the addon this surface is scoped to."""
|
|
96
|
+
return self._id
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def handles(self) -> FrameworkHandles:
|
|
100
|
+
"""The session's framework handles, threaded through unchanged."""
|
|
101
|
+
return self._handles
|
|
102
|
+
|
|
103
|
+
def on(self, event: HookEvent, handler: HookHandler) -> None:
|
|
104
|
+
"""Record a :data:`HookHandler` subscription to a colon-named
|
|
105
|
+
:data:`HookEvent`.
|
|
106
|
+
|
|
107
|
+
The handler's payload type is erased to the contract's recorded
|
|
108
|
+
:class:`EventSubscription` shape; the dispatcher re-narrows it per
|
|
109
|
+
dispatch.
|
|
110
|
+
|
|
111
|
+
:param event: the event to hook
|
|
112
|
+
:param handler: the observe/transform/gate middleware
|
|
113
|
+
"""
|
|
114
|
+
self._subscriptions.append(
|
|
115
|
+
EventSubscription(event=event, handler=handler, addon=self._id)
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
def intercept_tool(self, name: str, stage: InterceptorStage) -> None:
|
|
119
|
+
"""Record a :class:`ToolInterceptor`, filling its ``match`` (the tool
|
|
120
|
+
name or ``"*"``) and ``addon`` from the surface around the supplied
|
|
121
|
+
enter/exit stage.
|
|
122
|
+
|
|
123
|
+
:param name: the tool name to intercept, or ``"*"`` for every tool
|
|
124
|
+
:param stage: the enter/exit stage (its ``match``/``addon`` are filled here)
|
|
125
|
+
"""
|
|
126
|
+
self._interceptors.append(
|
|
127
|
+
ToolInterceptor(
|
|
128
|
+
match=name,
|
|
129
|
+
addon=self._id,
|
|
130
|
+
enter=stage.enter,
|
|
131
|
+
exit=stage.exit,
|
|
132
|
+
)
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
def add_command(self, name: str, spec: CommandSpec) -> None:
|
|
136
|
+
"""Record a slash :class:`AddonCommand`, filling its ``name`` and
|
|
137
|
+
``addon`` from the surface around the supplied definition.
|
|
138
|
+
|
|
139
|
+
:param name: the invocation token (no leading slash)
|
|
140
|
+
:param spec: the command definition (its ``name``/``addon`` are filled here)
|
|
141
|
+
"""
|
|
142
|
+
self._commands.append(
|
|
143
|
+
AddonCommand(
|
|
144
|
+
name=name,
|
|
145
|
+
summary=spec.summary,
|
|
146
|
+
addon=self._id,
|
|
147
|
+
run=spec.run,
|
|
148
|
+
)
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
def add_tool(self, card: AddonTool) -> None:
|
|
152
|
+
"""Record an LLM-callable :data:`AddonTool` the addon contributes.
|
|
153
|
+
|
|
154
|
+
:param card: the framework tool to add to the session's deck
|
|
155
|
+
"""
|
|
156
|
+
self._tools.append(card)
|
|
157
|
+
|
|
158
|
+
def manifest(self) -> RegisteredManifest:
|
|
159
|
+
"""The accumulated :class:`RegisteredManifest` of everything recorded
|
|
160
|
+
so far.
|
|
161
|
+
|
|
162
|
+
Returns an immutable snapshot: the lists are copied into tuples on a
|
|
163
|
+
frozen dataclass, so a later surface call cannot retroactively alter a
|
|
164
|
+
manifest the host has already read, and a consumer cannot mutate the
|
|
165
|
+
host's view either (the TS ``Object.freeze`` parity).
|
|
166
|
+
"""
|
|
167
|
+
return RegisteredManifest(
|
|
168
|
+
addon=self._id,
|
|
169
|
+
version=self._version,
|
|
170
|
+
subscriptions=tuple(self._subscriptions),
|
|
171
|
+
interceptors=tuple(self._interceptors),
|
|
172
|
+
commands=tuple(self._commands),
|
|
173
|
+
tools=tuple(self._tools),
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
# ---------------------------------------------------------------------------
|
|
178
|
+
# Construction
|
|
179
|
+
# ---------------------------------------------------------------------------
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def create_surface(
|
|
183
|
+
id: AddonId,
|
|
184
|
+
handles: FrameworkHandles,
|
|
185
|
+
version: str | None = None,
|
|
186
|
+
) -> AddonSurface:
|
|
187
|
+
"""Mint a fresh :class:`AddonSurface` for one addon.
|
|
188
|
+
|
|
189
|
+
The host calls this once per addon, scoping the surface to that addon's
|
|
190
|
+
:data:`AddonId` and the session's :class:`FrameworkHandles`, then passes
|
|
191
|
+
the surface to the addon's ``register``. After ``register`` settles the
|
|
192
|
+
host reads :meth:`AddonSurface.manifest` to obtain the contributions to
|
|
193
|
+
fold.
|
|
194
|
+
|
|
195
|
+
:param id: the addon the surface is scoped to
|
|
196
|
+
:param handles: the framework handles the addon (and its commands) may act through
|
|
197
|
+
:param version: optional addon version carried into the recorded manifest
|
|
198
|
+
"""
|
|
199
|
+
return RecordingSurface(id, handles, version)
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Boot subsystem — public barrel (port of TS ``src/boot/index.ts``).
|
|
2
|
+
|
|
3
|
+
Re-exports the frozen contract type surface plus the assembled boot layer:
|
|
4
|
+
the orchestrator (:func:`boot`), the stage pipeline (:func:`run_stages` /
|
|
5
|
+
:data:`STAGES`), the invocation projection (:func:`tokenize_invocation`),
|
|
6
|
+
the runner registry (:func:`select_runner` / :data:`RUNNERS`), the disk
|
|
7
|
+
credential vault (:func:`create_auth_vault`), and the upgrade driver
|
|
8
|
+
(:func:`apply_upgrades`). Consumers import the boot type surface and
|
|
9
|
+
behavior from ``induscode.boot`` rather than reaching into individual
|
|
10
|
+
modules.
|
|
11
|
+
|
|
12
|
+
The workspace surface is re-exported too, so ``induscode.boot`` is a
|
|
13
|
+
one-stop boot import site (as the TS barrel was).
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from induscode.workspace import (
|
|
19
|
+
BRAND,
|
|
20
|
+
Brand,
|
|
21
|
+
Workspace,
|
|
22
|
+
WorkspaceOverrides,
|
|
23
|
+
create_workspace,
|
|
24
|
+
ensure_dirs,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
from .auth_vault import DiskAuthVault, create_auth_vault
|
|
28
|
+
from .boot import boot
|
|
29
|
+
from .contract import (
|
|
30
|
+
BootContext,
|
|
31
|
+
Closable,
|
|
32
|
+
CredentialGraph,
|
|
33
|
+
Invocation,
|
|
34
|
+
Runner,
|
|
35
|
+
RunnerId,
|
|
36
|
+
Stage,
|
|
37
|
+
StartupResources,
|
|
38
|
+
ThinkingLevel,
|
|
39
|
+
)
|
|
40
|
+
from .invocation import to_runner_id, tokenize_invocation, wants_help, wants_version
|
|
41
|
+
from .runners import (
|
|
42
|
+
RUNNERS,
|
|
43
|
+
ConsoleMount,
|
|
44
|
+
ReplServices,
|
|
45
|
+
build_session_conductor,
|
|
46
|
+
link_runner,
|
|
47
|
+
oneshot_runner,
|
|
48
|
+
repl_runner,
|
|
49
|
+
select_runner,
|
|
50
|
+
session_scope_dir,
|
|
51
|
+
set_console_mount,
|
|
52
|
+
)
|
|
53
|
+
from .stages import (
|
|
54
|
+
STAGES,
|
|
55
|
+
build_invocation,
|
|
56
|
+
locate_workspace,
|
|
57
|
+
resolve_resources,
|
|
58
|
+
run_stages,
|
|
59
|
+
select_runner_stage,
|
|
60
|
+
upgrade_stage,
|
|
61
|
+
)
|
|
62
|
+
from .upgrade import UPGRADES, Upgrade, UpgradeReport, apply_upgrades
|
|
63
|
+
|
|
64
|
+
__all__ = [
|
|
65
|
+
"BRAND",
|
|
66
|
+
"BootContext",
|
|
67
|
+
"Brand",
|
|
68
|
+
"Closable",
|
|
69
|
+
"ConsoleMount",
|
|
70
|
+
"CredentialGraph",
|
|
71
|
+
"DiskAuthVault",
|
|
72
|
+
"Invocation",
|
|
73
|
+
"RUNNERS",
|
|
74
|
+
"ReplServices",
|
|
75
|
+
"Runner",
|
|
76
|
+
"RunnerId",
|
|
77
|
+
"STAGES",
|
|
78
|
+
"Stage",
|
|
79
|
+
"StartupResources",
|
|
80
|
+
"ThinkingLevel",
|
|
81
|
+
"UPGRADES",
|
|
82
|
+
"Upgrade",
|
|
83
|
+
"UpgradeReport",
|
|
84
|
+
"Workspace",
|
|
85
|
+
"WorkspaceOverrides",
|
|
86
|
+
"apply_upgrades",
|
|
87
|
+
"boot",
|
|
88
|
+
"build_invocation",
|
|
89
|
+
"build_session_conductor",
|
|
90
|
+
"create_auth_vault",
|
|
91
|
+
"create_workspace",
|
|
92
|
+
"ensure_dirs",
|
|
93
|
+
"link_runner",
|
|
94
|
+
"locate_workspace",
|
|
95
|
+
"oneshot_runner",
|
|
96
|
+
"repl_runner",
|
|
97
|
+
"resolve_resources",
|
|
98
|
+
"run_stages",
|
|
99
|
+
"select_runner",
|
|
100
|
+
"select_runner_stage",
|
|
101
|
+
"session_scope_dir",
|
|
102
|
+
"set_console_mount",
|
|
103
|
+
"to_runner_id",
|
|
104
|
+
"tokenize_invocation",
|
|
105
|
+
"upgrade_stage",
|
|
106
|
+
"wants_help",
|
|
107
|
+
"wants_version",
|
|
108
|
+
]
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
"""Disk-backed credential vault — the boot-layer implementation of the launch
|
|
2
|
+
:class:`~induscode.launch.AuthVault` Protocol.
|
|
3
|
+
|
|
4
|
+
Port of TS ``src/boot/auth-vault.ts``. The store is a single JSON file
|
|
5
|
+
(``auth.json`` under the profile directory), keyed provider → account →
|
|
6
|
+
record. Each record is a discriminated union: it holds *either* a stored api
|
|
7
|
+
key or a set of browser-sign-in credentials, plus a default marker:
|
|
8
|
+
|
|
9
|
+
- ``{"kind": "apiKey", "key": ..., "isDefault": bool}``
|
|
10
|
+
- ``{"kind": "oauth", "isDefault": bool, "access": ..., "refresh": ...,
|
|
11
|
+
"expires": ...}`` (the launch :class:`~induscode.launch.OAuthCredentials`
|
|
12
|
+
fields flattened in, exactly as the TS spread laid them out)
|
|
13
|
+
|
|
14
|
+
A legacy pre-discriminant ``{"apiKey": ..., "isDefault": bool}`` record is
|
|
15
|
+
tolerated on read. An entry that cannot be understood is dropped.
|
|
16
|
+
|
|
17
|
+
The adapter is deliberately small and self-contained: it reads / rewrites the
|
|
18
|
+
whole file each time (the file is tiny and the operations are interactive, so
|
|
19
|
+
atomic-rewrite simplicity beats incremental I/O), and every rewrite applies
|
|
20
|
+
owner-only ``0600`` permissions.
|
|
21
|
+
|
|
22
|
+
Two kinds of read are offered. The default-account lookup is plain
|
|
23
|
+
bookkeeping; :meth:`DiskAuthVault.read_usable_key` resolves a record to a
|
|
24
|
+
live api-key string — for an api-key record it returns the stored key
|
|
25
|
+
verbatim, and for a browser-sign-in record it refreshes an expired access
|
|
26
|
+
token through the launch OAuth adapter
|
|
27
|
+
(:func:`~induscode.launch.refresh_oauth_credentials`), persisting the rotated
|
|
28
|
+
credentials back to disk, before handing back the usable key.
|
|
29
|
+
|
|
30
|
+
Port notes
|
|
31
|
+
----------
|
|
32
|
+
- TS delegated freshness to the framework's ``ensureFreshOAuthCredentials``
|
|
33
|
+
(refresh-when-expired) and then asked the provider object for
|
|
34
|
+
``getApiKey(credentials)``. The Python framework has no provider objects:
|
|
35
|
+
the vault itself owns the expiry check (refresh when the stored ``expires``
|
|
36
|
+
deadline is within a one-minute margin of now) and the usable key for a
|
|
37
|
+
browser-sign-in record *is* its access token.
|
|
38
|
+
- TS returned ``undefined`` from ``readUsableKey`` when no OAuth provider was
|
|
39
|
+
registered for the record's provider id; the port keeps that (the registry
|
|
40
|
+
is primed explicitly by the boot layer, never at import time).
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
from __future__ import annotations
|
|
44
|
+
|
|
45
|
+
import json
|
|
46
|
+
import os
|
|
47
|
+
import time
|
|
48
|
+
from collections.abc import Mapping
|
|
49
|
+
from pathlib import Path
|
|
50
|
+
from typing import Any, Final, Literal
|
|
51
|
+
|
|
52
|
+
from induscode.launch import (
|
|
53
|
+
AuthVault,
|
|
54
|
+
OAuthCredentials,
|
|
55
|
+
get_oauth_provider,
|
|
56
|
+
refresh_oauth_credentials,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
__all__ = [
|
|
60
|
+
"DiskAuthVault",
|
|
61
|
+
"create_auth_vault",
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
#: Owner-only read/write file mode for the credential store.
|
|
65
|
+
_SECURE_FILE_MODE: Final[int] = 0o600
|
|
66
|
+
|
|
67
|
+
#: Refresh a stored browser token this many milliseconds *before* its
|
|
68
|
+
#: recorded ``expires`` deadline, so a token never dies mid-request. (The TS
|
|
69
|
+
#: margin lived inside the framework's ``ensureFreshOAuthCredentials``; the
|
|
70
|
+
#: port owns it here.)
|
|
71
|
+
_EXPIRY_MARGIN_MS: Final[int] = 60_000
|
|
72
|
+
|
|
73
|
+
#: One stored record (the on-disk dict shape, camelCase keys verbatim).
|
|
74
|
+
_Record = dict[str, Any]
|
|
75
|
+
|
|
76
|
+
#: The on-disk shape: provider → account → record.
|
|
77
|
+
_AuthFile = dict[str, dict[str, _Record]]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _normalise_record(value: object) -> _Record | None:
|
|
81
|
+
"""Normalise a parsed record to the discriminated shape, tolerating the
|
|
82
|
+
older ``{apiKey, isDefault}`` layout that predates the discriminant. An
|
|
83
|
+
entry that cannot be understood is dropped (returned as ``None``)."""
|
|
84
|
+
if not isinstance(value, Mapping):
|
|
85
|
+
return None
|
|
86
|
+
is_default = value.get("isDefault") is True
|
|
87
|
+
|
|
88
|
+
if value.get("kind") == "oauth":
|
|
89
|
+
if isinstance(value.get("access"), str) and isinstance(value.get("refresh"), str):
|
|
90
|
+
record = dict(value)
|
|
91
|
+
record["kind"] = "oauth"
|
|
92
|
+
record["isDefault"] = is_default
|
|
93
|
+
return record
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
if value.get("kind") == "apiKey" and isinstance(value.get("key"), str):
|
|
97
|
+
return {"kind": "apiKey", "key": value["key"], "isDefault": is_default}
|
|
98
|
+
# Legacy pre-discriminant layout: a bare { apiKey, isDefault } record.
|
|
99
|
+
if isinstance(value.get("apiKey"), str):
|
|
100
|
+
return {"kind": "apiKey", "key": value["apiKey"], "isDefault": is_default}
|
|
101
|
+
return None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _read_auth_file(path: Path) -> _AuthFile:
|
|
105
|
+
"""Read the auth file, tolerating a missing or malformed file as empty."""
|
|
106
|
+
try:
|
|
107
|
+
parsed = json.loads(path.read_text(encoding="utf-8"))
|
|
108
|
+
if not isinstance(parsed, Mapping):
|
|
109
|
+
return {}
|
|
110
|
+
file: _AuthFile = {}
|
|
111
|
+
for provider, accounts in parsed.items():
|
|
112
|
+
if not isinstance(accounts, Mapping):
|
|
113
|
+
continue
|
|
114
|
+
normalised: dict[str, _Record] = {}
|
|
115
|
+
for account, record in accounts.items():
|
|
116
|
+
value = _normalise_record(record)
|
|
117
|
+
if value is not None:
|
|
118
|
+
normalised[str(account)] = value
|
|
119
|
+
file[str(provider)] = normalised
|
|
120
|
+
return file
|
|
121
|
+
except Exception:
|
|
122
|
+
return {}
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _write_auth_file(path: Path, data: _AuthFile) -> None:
|
|
126
|
+
"""Persist the auth file, creating the parent directory if needed and
|
|
127
|
+
applying the owner-only mode authoritatively (the rewrite path keeps the
|
|
128
|
+
mode even when the file pre-existed with looser bits)."""
|
|
129
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
130
|
+
path.write_text(f"{json.dumps(data, indent=2)}\n", encoding="utf-8")
|
|
131
|
+
os.chmod(path, _SECURE_FILE_MODE)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _to_credentials(record: _Record) -> OAuthCredentials:
|
|
135
|
+
"""Strip the bookkeeping fields off an oauth record, leaving the launch
|
|
136
|
+
credential shape the refresh seam speaks."""
|
|
137
|
+
refresh = record.get("refresh")
|
|
138
|
+
expires = record.get("expires")
|
|
139
|
+
return OAuthCredentials(
|
|
140
|
+
access=str(record.get("access", "")),
|
|
141
|
+
refresh=refresh if isinstance(refresh, str) else None,
|
|
142
|
+
expires=int(expires) if isinstance(expires, (int, float)) and not isinstance(expires, bool) else None,
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _is_expired(credentials: OAuthCredentials) -> bool:
|
|
147
|
+
"""Whether a stored browser token's recorded deadline has passed (or is
|
|
148
|
+
inside the early-refresh margin). A record with no deadline is treated as
|
|
149
|
+
fresh — the refresh path only fires when expiry is actually known."""
|
|
150
|
+
if credentials.expires is None:
|
|
151
|
+
return False
|
|
152
|
+
return credentials.expires <= int(time.time() * 1000) + _EXPIRY_MARGIN_MS
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _clear_defaults(accounts: dict[str, _Record]) -> None:
|
|
156
|
+
"""Clear the default flag on every account in a provider bucket."""
|
|
157
|
+
for name, record in list(accounts.items()):
|
|
158
|
+
accounts[name] = {**record, "isDefault": False}
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class DiskAuthVault:
|
|
162
|
+
"""The disk vault: a structural implementation of the launch
|
|
163
|
+
:class:`~induscode.launch.AuthVault` Protocol persisting to one
|
|
164
|
+
``auth.json``. Construct via :func:`create_auth_vault`."""
|
|
165
|
+
|
|
166
|
+
__slots__ = ("_path",)
|
|
167
|
+
|
|
168
|
+
def __init__(self, auth_path: str | os.PathLike[str]) -> None:
|
|
169
|
+
self._path = Path(auth_path)
|
|
170
|
+
|
|
171
|
+
async def list_accounts(self, provider: str) -> list[str]:
|
|
172
|
+
"""Stored account names for a provider, in insertion order."""
|
|
173
|
+
file = _read_auth_file(self._path)
|
|
174
|
+
return list(file.get(provider, {}).keys())
|
|
175
|
+
|
|
176
|
+
async def default_account(self, provider: str) -> str | None:
|
|
177
|
+
"""The account flagged default for a provider, if any."""
|
|
178
|
+
file = _read_auth_file(self._path)
|
|
179
|
+
for account, record in file.get(provider, {}).items():
|
|
180
|
+
if record.get("isDefault") is True:
|
|
181
|
+
return account
|
|
182
|
+
return None
|
|
183
|
+
|
|
184
|
+
async def put_api_key(
|
|
185
|
+
self,
|
|
186
|
+
provider: str,
|
|
187
|
+
account: str,
|
|
188
|
+
api_key: str,
|
|
189
|
+
make_default: bool = False,
|
|
190
|
+
) -> None:
|
|
191
|
+
"""Persist an api key under a provider / account, optionally as the
|
|
192
|
+
provider's default (clearing any prior default)."""
|
|
193
|
+
file = _read_auth_file(self._path)
|
|
194
|
+
accounts = dict(file.get(provider, {}))
|
|
195
|
+
if make_default:
|
|
196
|
+
_clear_defaults(accounts)
|
|
197
|
+
accounts[account] = {"kind": "apiKey", "key": api_key, "isDefault": make_default}
|
|
198
|
+
file[provider] = accounts
|
|
199
|
+
_write_auth_file(self._path, file)
|
|
200
|
+
|
|
201
|
+
async def put_oauth(
|
|
202
|
+
self,
|
|
203
|
+
provider: str,
|
|
204
|
+
account: str,
|
|
205
|
+
credentials: OAuthCredentials,
|
|
206
|
+
make_default: bool = False,
|
|
207
|
+
) -> None:
|
|
208
|
+
"""Persist browser-sign-in credentials under a provider / account,
|
|
209
|
+
optionally as the provider's default (clearing any prior default)."""
|
|
210
|
+
file = _read_auth_file(self._path)
|
|
211
|
+
accounts = dict(file.get(provider, {}))
|
|
212
|
+
if make_default:
|
|
213
|
+
_clear_defaults(accounts)
|
|
214
|
+
record: _Record = {
|
|
215
|
+
"access": credentials.access,
|
|
216
|
+
"kind": "oauth",
|
|
217
|
+
"isDefault": make_default,
|
|
218
|
+
}
|
|
219
|
+
if credentials.refresh is not None:
|
|
220
|
+
record["refresh"] = credentials.refresh
|
|
221
|
+
if credentials.expires is not None:
|
|
222
|
+
record["expires"] = credentials.expires
|
|
223
|
+
accounts[account] = record
|
|
224
|
+
file[provider] = accounts
|
|
225
|
+
_write_auth_file(self._path, file)
|
|
226
|
+
|
|
227
|
+
async def auth_kind(
|
|
228
|
+
self, provider: str, account: str
|
|
229
|
+
) -> Literal["apiKey", "oauth"] | None:
|
|
230
|
+
"""Report whether a stored account holds an api key or browser
|
|
231
|
+
sign-in credentials, or ``None`` when nothing is stored there."""
|
|
232
|
+
file = _read_auth_file(self._path)
|
|
233
|
+
record = file.get(provider, {}).get(account)
|
|
234
|
+
kind = record.get("kind") if record is not None else None
|
|
235
|
+
return kind if kind in ("apiKey", "oauth") else None
|
|
236
|
+
|
|
237
|
+
async def read_usable_key(self, provider: str, account: str) -> str | None:
|
|
238
|
+
"""Resolve a stored account to a live api-key string.
|
|
239
|
+
|
|
240
|
+
An api-key record yields its key verbatim. A browser-sign-in record
|
|
241
|
+
yields its access token, refreshing first (and persisting the rotated
|
|
242
|
+
credentials, preserving the default flag) when the recorded expiry
|
|
243
|
+
deadline has passed. Resolves ``None`` when nothing usable is stored
|
|
244
|
+
or no OAuth adapter is registered for the provider.
|
|
245
|
+
"""
|
|
246
|
+
file = _read_auth_file(self._path)
|
|
247
|
+
record = file.get(provider, {}).get(account)
|
|
248
|
+
if record is None:
|
|
249
|
+
return None
|
|
250
|
+
|
|
251
|
+
if record.get("kind") == "apiKey":
|
|
252
|
+
key = record.get("key")
|
|
253
|
+
return key if isinstance(key, str) else None
|
|
254
|
+
|
|
255
|
+
# A browser-sign-in record: hand the launch adapter the chance to
|
|
256
|
+
# rotate an expired access token, persisting refreshed credentials.
|
|
257
|
+
if get_oauth_provider(provider) is None:
|
|
258
|
+
return None
|
|
259
|
+
|
|
260
|
+
current = _to_credentials(record)
|
|
261
|
+
if not _is_expired(current):
|
|
262
|
+
return current.access
|
|
263
|
+
|
|
264
|
+
refreshed = await refresh_oauth_credentials(provider, current)
|
|
265
|
+
accounts = dict(file.get(provider, {}))
|
|
266
|
+
rotated: _Record = {
|
|
267
|
+
"access": refreshed.access,
|
|
268
|
+
"kind": "oauth",
|
|
269
|
+
"isDefault": record.get("isDefault") is True,
|
|
270
|
+
}
|
|
271
|
+
if refreshed.refresh is not None:
|
|
272
|
+
rotated["refresh"] = refreshed.refresh
|
|
273
|
+
if refreshed.expires is not None:
|
|
274
|
+
rotated["expires"] = refreshed.expires
|
|
275
|
+
accounts[account] = rotated
|
|
276
|
+
file[provider] = accounts
|
|
277
|
+
_write_auth_file(self._path, file)
|
|
278
|
+
return refreshed.access
|
|
279
|
+
|
|
280
|
+
async def remove(self, provider: str, account: str | None = None) -> bool:
|
|
281
|
+
"""Remove a stored credential.
|
|
282
|
+
|
|
283
|
+
With no ``account``, the whole provider bucket is removed. Removing
|
|
284
|
+
the default account promotes the first surviving account to default;
|
|
285
|
+
removing the last account drops the provider bucket entirely.
|
|
286
|
+
Resolves ``False`` when nothing was removed.
|
|
287
|
+
"""
|
|
288
|
+
file = _read_auth_file(self._path)
|
|
289
|
+
accounts = file.get(provider)
|
|
290
|
+
if accounts is None:
|
|
291
|
+
return False
|
|
292
|
+
|
|
293
|
+
if account is None:
|
|
294
|
+
del file[provider]
|
|
295
|
+
_write_auth_file(self._path, file)
|
|
296
|
+
return True
|
|
297
|
+
|
|
298
|
+
if account not in accounts:
|
|
299
|
+
return False
|
|
300
|
+
was_default = accounts[account].get("isDefault") is True
|
|
301
|
+
del accounts[account]
|
|
302
|
+
|
|
303
|
+
# Promote a surviving account to default if the removed one was it.
|
|
304
|
+
survivors = list(accounts.keys())
|
|
305
|
+
if was_default and survivors:
|
|
306
|
+
first = survivors[0]
|
|
307
|
+
accounts[first] = {**accounts[first], "isDefault": True}
|
|
308
|
+
if not survivors:
|
|
309
|
+
del file[provider]
|
|
310
|
+
else:
|
|
311
|
+
file[provider] = accounts
|
|
312
|
+
_write_auth_file(self._path, file)
|
|
313
|
+
return True
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def create_auth_vault(auth_path: str | os.PathLike[str]) -> AuthVault:
|
|
317
|
+
"""Build a disk-backed :class:`~induscode.launch.AuthVault` persisting to
|
|
318
|
+
``auth_path``.
|
|
319
|
+
|
|
320
|
+
:param auth_path: absolute path of the JSON credential store
|
|
321
|
+
(e.g. ``auth.json``)
|
|
322
|
+
"""
|
|
323
|
+
return DiskAuthVault(auth_path)
|