induscode 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (167) hide show
  1. induscode/__init__.py +56 -0
  2. induscode/addons/__init__.py +176 -0
  3. induscode/addons/contract.py +923 -0
  4. induscode/addons/dispatch/__init__.py +43 -0
  5. induscode/addons/dispatch/event_dispatcher.py +348 -0
  6. induscode/addons/dispatch/tool_interceptor.py +349 -0
  7. induscode/addons/host.py +469 -0
  8. induscode/addons/loader.py +314 -0
  9. induscode/addons/manifest.py +232 -0
  10. induscode/addons/surface.py +199 -0
  11. induscode/boot/__init__.py +108 -0
  12. induscode/boot/auth_vault.py +323 -0
  13. induscode/boot/boot.py +210 -0
  14. induscode/boot/contract.py +223 -0
  15. induscode/boot/invocation.py +117 -0
  16. induscode/boot/runners/__init__.py +42 -0
  17. induscode/boot/runners/link_runner.py +82 -0
  18. induscode/boot/runners/oneshot_runner.py +85 -0
  19. induscode/boot/runners/registry.py +46 -0
  20. induscode/boot/runners/repl_runner.py +340 -0
  21. induscode/boot/runners/session.py +549 -0
  22. induscode/boot/stages.py +198 -0
  23. induscode/boot/upgrade/__init__.py +36 -0
  24. induscode/boot/upgrade/apply.py +125 -0
  25. induscode/boot/upgrade/upgrades.py +136 -0
  26. induscode/briefing/__init__.py +115 -0
  27. induscode/briefing/compose.py +414 -0
  28. induscode/briefing/contract.py +528 -0
  29. induscode/briefing/macros.py +721 -0
  30. induscode/briefing/skills.py +417 -0
  31. induscode/capability_deck/__init__.py +233 -0
  32. induscode/capability_deck/bridge_ledger/__init__.py +66 -0
  33. induscode/capability_deck/bridge_ledger/key.py +181 -0
  34. induscode/capability_deck/bridge_ledger/ledger.py +276 -0
  35. induscode/capability_deck/bridge_ledger/network.py +336 -0
  36. induscode/capability_deck/builtin_bridge.py +358 -0
  37. induscode/capability_deck/cards/__init__.py +116 -0
  38. induscode/capability_deck/cards/bg_process.py +482 -0
  39. induscode/capability_deck/cards/memory.py +226 -0
  40. induscode/capability_deck/cards/saas.py +280 -0
  41. induscode/capability_deck/cards/task.py +256 -0
  42. induscode/capability_deck/cards/todo.py +312 -0
  43. induscode/capability_deck/contract.py +450 -0
  44. induscode/capability_deck/manifest.py +126 -0
  45. induscode/capability_deck/provision.py +217 -0
  46. induscode/channels/__init__.py +146 -0
  47. induscode/channels/contract.py +585 -0
  48. induscode/channels/framer.py +132 -0
  49. induscode/channels/link/__init__.py +50 -0
  50. induscode/channels/link/dialog.py +246 -0
  51. induscode/channels/link/driver.py +308 -0
  52. induscode/channels/link/server.py +217 -0
  53. induscode/channels/oneshot.py +178 -0
  54. induscode/channels/ops.py +140 -0
  55. induscode/channels/session_ops.py +172 -0
  56. induscode/conductor/__init__.py +240 -0
  57. induscode/conductor/catalog.py +309 -0
  58. induscode/conductor/conductor.py +1084 -0
  59. induscode/conductor/contract.py +1035 -0
  60. induscode/conductor/matcher.py +291 -0
  61. induscode/conductor/serialize.py +575 -0
  62. induscode/conductor/signal_hub.py +382 -0
  63. induscode/conductor/skill_parse.py +294 -0
  64. induscode/conductor/transcript_store.py +449 -0
  65. induscode/console/__init__.py +236 -0
  66. induscode/console/app.py +1677 -0
  67. induscode/console/components/__init__.py +62 -0
  68. induscode/console/components/banner.py +499 -0
  69. induscode/console/components/banner_sweep.py +188 -0
  70. induscode/console/components/emblem.py +181 -0
  71. induscode/console/components/status_bar.py +102 -0
  72. induscode/console/contract.py +836 -0
  73. induscode/console/input/__init__.py +107 -0
  74. induscode/console/input/chord.py +197 -0
  75. induscode/console/input/dir_reader.py +113 -0
  76. induscode/console/input/intents.py +258 -0
  77. induscode/console/input/providers.py +469 -0
  78. induscode/console/mount.py +137 -0
  79. induscode/console/overlays/__init__.py +94 -0
  80. induscode/console/overlays/auth.py +503 -0
  81. induscode/console/overlays/pickers.py +526 -0
  82. induscode/console/overlays/router.py +129 -0
  83. induscode/console/overlays/sessions.py +232 -0
  84. induscode/console/reducer.py +145 -0
  85. induscode/console/resume_picker.py +156 -0
  86. induscode/console/slash_commands/__init__.py +78 -0
  87. induscode/console/slash_commands/builtins.py +254 -0
  88. induscode/console/slash_commands/dynamic.py +217 -0
  89. induscode/console/slash_commands/integrations.py +949 -0
  90. induscode/console/slash_commands/transcript.py +404 -0
  91. induscode/console/slash_commands/workbench.py +430 -0
  92. induscode/console/startup.py +434 -0
  93. induscode/console/theme/__init__.py +44 -0
  94. induscode/console/theme/adapter.py +168 -0
  95. induscode/console/theme/palette.py +128 -0
  96. induscode/console/theme/resolve.py +123 -0
  97. induscode/console/theme/tokens.py +185 -0
  98. induscode/console_slash/__init__.py +111 -0
  99. induscode/console_slash/contract.py +185 -0
  100. induscode/console_slash/registry.py +140 -0
  101. induscode/console_slash/resolve.py +194 -0
  102. induscode/console_slash/shared.py +172 -0
  103. induscode/entry.py +108 -0
  104. induscode/insight/__init__.py +153 -0
  105. induscode/insight/collector.py +73 -0
  106. induscode/insight/replay.py +305 -0
  107. induscode/insight/wrapper.py +1115 -0
  108. induscode/kit/__init__.py +82 -0
  109. induscode/kit/clipboard_image.py +215 -0
  110. induscode/kit/external_editor.py +120 -0
  111. induscode/kit/image.py +188 -0
  112. induscode/kit/shell.py +89 -0
  113. induscode/kit/tool_fetch.py +288 -0
  114. induscode/launch/__init__.py +224 -0
  115. induscode/launch/catalog.py +310 -0
  116. induscode/launch/contract.py +569 -0
  117. induscode/launch/credentials.py +852 -0
  118. induscode/launch/invocation/__init__.py +39 -0
  119. induscode/launch/invocation/attachments.py +281 -0
  120. induscode/launch/invocation/flags.py +210 -0
  121. induscode/launch/invocation/read.py +369 -0
  122. induscode/launch/invocation/usage.py +110 -0
  123. induscode/launch/oauth.py +808 -0
  124. induscode/launch/packages.py +299 -0
  125. induscode/launch/pickers.py +291 -0
  126. induscode/py.typed +0 -0
  127. induscode/runtime_bridge/__init__.py +166 -0
  128. induscode/runtime_bridge/bridges/__init__.py +66 -0
  129. induscode/runtime_bridge/bridges/_drive.py +268 -0
  130. induscode/runtime_bridge/bridges/builtins.py +177 -0
  131. induscode/runtime_bridge/bridges/claude_cli.py +198 -0
  132. induscode/runtime_bridge/bridges/codex_cli.py +203 -0
  133. induscode/runtime_bridge/bridges/indusagi_cli.py +217 -0
  134. induscode/runtime_bridge/broker.py +397 -0
  135. induscode/runtime_bridge/contract.py +734 -0
  136. induscode/runtime_bridge/sink.py +351 -0
  137. induscode/sessions/__init__.py +25 -0
  138. induscode/sessions/contract.py +119 -0
  139. induscode/sessions/library.py +350 -0
  140. induscode/settings/__init__.py +47 -0
  141. induscode/settings/contract.py +313 -0
  142. induscode/settings/manager.py +268 -0
  143. induscode/transcript_export/__init__.py +109 -0
  144. induscode/transcript_export/contract.py +522 -0
  145. induscode/transcript_export/publish.py +455 -0
  146. induscode/transcript_export/sgr.py +566 -0
  147. induscode/transcript_export/template.py +319 -0
  148. induscode/transcript_export/theme_bridge.py +325 -0
  149. induscode/window_budget/__init__.py +76 -0
  150. induscode/window_budget/budget/__init__.py +26 -0
  151. induscode/window_budget/budget/estimate.py +273 -0
  152. induscode/window_budget/budget/gate.py +60 -0
  153. induscode/window_budget/budget/slice.py +145 -0
  154. induscode/window_budget/condenser.py +170 -0
  155. induscode/window_budget/contract.py +329 -0
  156. induscode/window_budget/summarize/__init__.py +33 -0
  157. induscode/window_budget/summarize/condense.py +212 -0
  158. induscode/window_budget/summarize/prompt.py +241 -0
  159. induscode/workspace/__init__.py +30 -0
  160. induscode/workspace/brand.py +96 -0
  161. induscode/workspace/locator.py +269 -0
  162. induscode-0.1.0.dist-info/METADATA +97 -0
  163. induscode-0.1.0.dist-info/RECORD +167 -0
  164. induscode-0.1.0.dist-info/WHEEL +4 -0
  165. induscode-0.1.0.dist-info/entry_points.txt +3 -0
  166. induscode-0.1.0.dist-info/licenses/CREDITS.md +22 -0
  167. induscode-0.1.0.dist-info/licenses/NOTICE +7 -0
@@ -0,0 +1,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)