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,299 @@
|
|
|
1
|
+
"""Package command — :func:`run_package_command`.
|
|
2
|
+
|
|
3
|
+
The top-level ``install`` / ``remove`` / ``update`` / ``list`` / ``config``
|
|
4
|
+
surface that manages the extension-package sources the launcher loads. A
|
|
5
|
+
source is a plain string the loader understands — an ``npm:`` spec, a
|
|
6
|
+
``git:`` / ``https:`` repo, or a bare local path — and the configured set
|
|
7
|
+
lives in the user settings under the ``extensionPackages`` key, persisted
|
|
8
|
+
through the :class:`~induscode.settings.PreferenceStore`.
|
|
9
|
+
|
|
10
|
+
The command owns the first positional token only: it returns
|
|
11
|
+
``handled=False`` when that token is not one of the recognised subcommands,
|
|
12
|
+
so the orchestrator can call this first and fall through to a normal launch
|
|
13
|
+
on a miss. It never raises for an expected failure — a bad argument is
|
|
14
|
+
reported as a printed line and a non-zero :attr:`PackageResult.code`.
|
|
15
|
+
|
|
16
|
+
I/O is injected. The default :class:`PackageIo` wraps stdout / stderr, but
|
|
17
|
+
tests drive the whole flow over an in-memory stand-in and a store pinned to a
|
|
18
|
+
temp directory, so the real home is never touched.
|
|
19
|
+
|
|
20
|
+
(Port of TS ``src/launch/packages.ts``.)
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import json
|
|
26
|
+
import re
|
|
27
|
+
import sys
|
|
28
|
+
from collections.abc import Sequence
|
|
29
|
+
from dataclasses import dataclass
|
|
30
|
+
from typing import Final, Literal, Protocol, TypeAlias
|
|
31
|
+
|
|
32
|
+
from ..settings import FIELD_TO_JSON_KEY, SETTING_KEYS, PreferenceStore
|
|
33
|
+
|
|
34
|
+
__all__ = [
|
|
35
|
+
"PACKAGE_COMMANDS",
|
|
36
|
+
"PackageCommand",
|
|
37
|
+
"PackageIo",
|
|
38
|
+
"PackageResult",
|
|
39
|
+
"default_package_io",
|
|
40
|
+
"run_package_command",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# ---------------------------------------------------------------------------
|
|
45
|
+
# Subcommand vocabulary
|
|
46
|
+
# ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
#: The five subcommands the package command recognises as the first token.
|
|
49
|
+
PackageCommand: TypeAlias = Literal["install", "remove", "update", "list", "config"]
|
|
50
|
+
|
|
51
|
+
#: Every :data:`PackageCommand`, as a frozen tuple for membership tests.
|
|
52
|
+
PACKAGE_COMMANDS: Final[tuple[PackageCommand, ...]] = (
|
|
53
|
+
"install",
|
|
54
|
+
"remove",
|
|
55
|
+
"update",
|
|
56
|
+
"list",
|
|
57
|
+
"config",
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _as_package_command(token: str | None) -> PackageCommand | None:
|
|
62
|
+
"""Narrow an arbitrary token to a known :data:`PackageCommand`."""
|
|
63
|
+
if token is not None and token in PACKAGE_COMMANDS:
|
|
64
|
+
return token # type: ignore[return-value] # narrowed by membership
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# ---------------------------------------------------------------------------
|
|
69
|
+
# Injected I/O
|
|
70
|
+
# ---------------------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class PackageIo(Protocol):
|
|
74
|
+
"""The console seam the package command writes through. Pinned to a
|
|
75
|
+
normal line and an error line so a test can capture both streams without a
|
|
76
|
+
real terminal."""
|
|
77
|
+
|
|
78
|
+
def print(self, line: str) -> None:
|
|
79
|
+
"""Emit one line of human-facing text to the standard stream."""
|
|
80
|
+
...
|
|
81
|
+
|
|
82
|
+
def print_error(self, line: str) -> None:
|
|
83
|
+
"""Emit one line to the error stream."""
|
|
84
|
+
...
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class _DefaultPackageIo:
|
|
88
|
+
"""The default :class:`PackageIo` backed by stdout / stderr."""
|
|
89
|
+
|
|
90
|
+
def print(self, line: str) -> None:
|
|
91
|
+
sys.stdout.write(line if line.endswith("\n") else f"{line}\n")
|
|
92
|
+
|
|
93
|
+
def print_error(self, line: str) -> None:
|
|
94
|
+
sys.stderr.write(line if line.endswith("\n") else f"{line}\n")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def default_package_io() -> PackageIo:
|
|
98
|
+
"""Build the live terminal-backed :class:`PackageIo`."""
|
|
99
|
+
return _DefaultPackageIo()
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# ---------------------------------------------------------------------------
|
|
103
|
+
# Command surface
|
|
104
|
+
# ---------------------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@dataclass(frozen=True, slots=True)
|
|
108
|
+
class PackageResult:
|
|
109
|
+
"""The outcome of :func:`run_package_command`.
|
|
110
|
+
|
|
111
|
+
:attr:`handled` reports whether the first token was a recognised
|
|
112
|
+
subcommand; when ``False``, the caller proceeds to a normal launch
|
|
113
|
+
unchanged. :attr:`code` is the process exit code for a handled command —
|
|
114
|
+
zero on success, non-zero when a required argument was missing or
|
|
115
|
+
invalid."""
|
|
116
|
+
|
|
117
|
+
# Whether this invocation was a package subcommand.
|
|
118
|
+
handled: bool
|
|
119
|
+
# The subcommand that ran, when handled is True.
|
|
120
|
+
command: PackageCommand | None = None
|
|
121
|
+
# Process exit code for a handled command (zero on success).
|
|
122
|
+
code: int | None = None
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
#: The settings key the configured package sources are stored under.
|
|
126
|
+
_PACKAGES_KEY: Final[str] = "extensionPackages"
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _source_key(source: str) -> str:
|
|
130
|
+
"""Classify a source for equality so two spellings of the same package
|
|
131
|
+
compare equal: an ``npm:`` spec ignores any trailing ``@version``, a git
|
|
132
|
+
url drops its scheme and ``.git`` suffix, and a local path matches
|
|
133
|
+
verbatim."""
|
|
134
|
+
trimmed = source.strip()
|
|
135
|
+
if trimmed.startswith("npm:"):
|
|
136
|
+
spec = trimmed[len("npm:") :]
|
|
137
|
+
at = spec.rfind("@")
|
|
138
|
+
# Keep a leading scope `@`; only a later `@` marks a version.
|
|
139
|
+
name = spec[:at] if at > 0 else spec
|
|
140
|
+
return f"npm:{name}"
|
|
141
|
+
gitlike = (
|
|
142
|
+
trimmed.startswith("git:")
|
|
143
|
+
or trimmed.startswith("http://")
|
|
144
|
+
or trimmed.startswith("https://")
|
|
145
|
+
)
|
|
146
|
+
if gitlike:
|
|
147
|
+
without_scheme = re.sub(r"^git:", "", trimmed)
|
|
148
|
+
without_scheme = re.sub(r"^https?://", "", without_scheme)
|
|
149
|
+
without_version = without_scheme.split("@")[0]
|
|
150
|
+
without_suffix = re.sub(r"\.git$", "", without_version)
|
|
151
|
+
return f"git:{without_suffix}"
|
|
152
|
+
return f"local:{trimmed}"
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _sources_match(a: str, b: str) -> bool:
|
|
156
|
+
"""Whether two sources name the same package under :func:`_source_key`."""
|
|
157
|
+
return _source_key(a) == _source_key(b)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _read_sources(store: PreferenceStore) -> list[str]:
|
|
161
|
+
"""Read the configured package sources from the resolved settings."""
|
|
162
|
+
value = store.get(_PACKAGES_KEY)
|
|
163
|
+
return [str(item) for item in value] if isinstance(value, (list, tuple)) else []
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _write_sources(store: PreferenceStore, sources: list[str]) -> None:
|
|
167
|
+
"""Persist a package-source list through the store's project tier."""
|
|
168
|
+
store.set(_PACKAGES_KEY, sources)
|
|
169
|
+
store.save()
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _read_source(rest: Sequence[str]) -> str | None:
|
|
173
|
+
"""Pull the source argument from a subcommand tail, skipping any switches.
|
|
174
|
+
The first bare (non-``-``) token is the source."""
|
|
175
|
+
return next((token for token in rest if not token.startswith("-")), None)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
async def run_package_command(
|
|
179
|
+
argv: Sequence[str],
|
|
180
|
+
*,
|
|
181
|
+
store: PreferenceStore,
|
|
182
|
+
io: PackageIo | None = None,
|
|
183
|
+
) -> PackageResult:
|
|
184
|
+
"""Run the package command.
|
|
185
|
+
|
|
186
|
+
Returns ``handled=False`` immediately when ``argv[0]`` is not a recognised
|
|
187
|
+
subcommand, so the orchestrator can call this first and fall through on a
|
|
188
|
+
miss. Otherwise it performs the subcommand against the injected store and
|
|
189
|
+
resolves a :class:`PackageResult` — never raising for an expected failure.
|
|
190
|
+
|
|
191
|
+
:param argv: the raw token list following the program name
|
|
192
|
+
:param store: the settings store the package sources read from / write to
|
|
193
|
+
:param io: the console seam; defaults to stdout / stderr
|
|
194
|
+
"""
|
|
195
|
+
command = _as_package_command(argv[0] if len(argv) > 0 else None)
|
|
196
|
+
if command is None:
|
|
197
|
+
return PackageResult(handled=False)
|
|
198
|
+
|
|
199
|
+
live_io = io if io is not None else default_package_io()
|
|
200
|
+
rest = list(argv[1:])
|
|
201
|
+
|
|
202
|
+
match command:
|
|
203
|
+
case "install":
|
|
204
|
+
return _do_install(rest, store, live_io)
|
|
205
|
+
case "remove":
|
|
206
|
+
return _do_remove(rest, store, live_io)
|
|
207
|
+
case "update":
|
|
208
|
+
return _do_update(store, live_io)
|
|
209
|
+
case "list":
|
|
210
|
+
return _do_list(store, live_io)
|
|
211
|
+
case "config":
|
|
212
|
+
return _do_config(store, live_io)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _do_install(
|
|
216
|
+
rest: Sequence[str], store: PreferenceStore, io: PackageIo
|
|
217
|
+
) -> PackageResult:
|
|
218
|
+
"""Add a package source to the configured set (idempotent under
|
|
219
|
+
:func:`_sources_match`)."""
|
|
220
|
+
source = _read_source(rest)
|
|
221
|
+
if source is None:
|
|
222
|
+
io.print_error(
|
|
223
|
+
"install: a package source is required (npm:, git:, https:, or a local path)."
|
|
224
|
+
)
|
|
225
|
+
return PackageResult(handled=True, command="install", code=1)
|
|
226
|
+
|
|
227
|
+
sources = _read_sources(store)
|
|
228
|
+
if any(_sources_match(existing, source) for existing in sources):
|
|
229
|
+
io.print(f"Already installed: {source}")
|
|
230
|
+
return PackageResult(handled=True, command="install", code=0)
|
|
231
|
+
|
|
232
|
+
_write_sources(store, [*sources, source])
|
|
233
|
+
io.print(f"Installed {source}")
|
|
234
|
+
return PackageResult(handled=True, command="install", code=0)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _do_remove(
|
|
238
|
+
rest: Sequence[str], store: PreferenceStore, io: PackageIo
|
|
239
|
+
) -> PackageResult:
|
|
240
|
+
"""Remove a package source from the configured set."""
|
|
241
|
+
source = _read_source(rest)
|
|
242
|
+
if source is None:
|
|
243
|
+
io.print_error("remove: a package source is required.")
|
|
244
|
+
return PackageResult(handled=True, command="remove", code=1)
|
|
245
|
+
|
|
246
|
+
sources = _read_sources(store)
|
|
247
|
+
remaining = [existing for existing in sources if not _sources_match(existing, source)]
|
|
248
|
+
if len(remaining) == len(sources):
|
|
249
|
+
io.print_error(f"remove: not installed: {source}")
|
|
250
|
+
return PackageResult(handled=True, command="remove", code=1)
|
|
251
|
+
|
|
252
|
+
_write_sources(store, remaining)
|
|
253
|
+
io.print(f"Removed {source}")
|
|
254
|
+
return PackageResult(handled=True, command="remove", code=0)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _do_update(store: PreferenceStore, io: PackageIo) -> PackageResult:
|
|
258
|
+
"""Re-read the configured sources and report them. The launcher resolves
|
|
259
|
+
package sources lazily at startup, so there is no separate fetch step to
|
|
260
|
+
drive here: ``update`` re-reads the persisted set and confirms what is
|
|
261
|
+
configured."""
|
|
262
|
+
store.reload()
|
|
263
|
+
sources = _read_sources(store)
|
|
264
|
+
if not sources:
|
|
265
|
+
io.print("No packages to update.")
|
|
266
|
+
return PackageResult(handled=True, command="update", code=0)
|
|
267
|
+
plural = "" if len(sources) == 1 else "s"
|
|
268
|
+
io.print(f"Refreshed {len(sources)} package source{plural}.")
|
|
269
|
+
for source in sources:
|
|
270
|
+
io.print(f" {source}")
|
|
271
|
+
return PackageResult(handled=True, command="update", code=0)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _do_list(store: PreferenceStore, io: PackageIo) -> PackageResult:
|
|
275
|
+
"""Print the configured package sources."""
|
|
276
|
+
sources = _read_sources(store)
|
|
277
|
+
if not sources:
|
|
278
|
+
io.print("No packages installed.")
|
|
279
|
+
return PackageResult(handled=True, command="list", code=0)
|
|
280
|
+
io.print("Installed packages:")
|
|
281
|
+
for source in sources:
|
|
282
|
+
io.print(f" {source}")
|
|
283
|
+
return PackageResult(handled=True, command="list", code=0)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _do_config(store: PreferenceStore, io: PackageIo) -> PackageResult:
|
|
287
|
+
"""Print the resolved settings file locations and their merged contents
|
|
288
|
+
(camelCase JSON keys, matching the on-disk spelling)."""
|
|
289
|
+
locations = store.paths()
|
|
290
|
+
io.print(f"Global settings: {locations.global_path}")
|
|
291
|
+
io.print(f"Project settings: {locations.project_path}")
|
|
292
|
+
io.print("Resolved settings:")
|
|
293
|
+
snapshot = store.snapshot()
|
|
294
|
+
payload = {
|
|
295
|
+
FIELD_TO_JSON_KEY.get(field, field): getattr(snapshot, field)
|
|
296
|
+
for field in SETTING_KEYS
|
|
297
|
+
}
|
|
298
|
+
io.print(json.dumps(payload, indent=2, default=list))
|
|
299
|
+
return PackageResult(handled=True, command="config", code=0)
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
"""Launch pickers — :func:`pick_resume_target` and :func:`browse_settings`.
|
|
2
|
+
|
|
3
|
+
Two thin, injectable selection surfaces:
|
|
4
|
+
|
|
5
|
+
- :func:`pick_resume_target` loads the resumable sessions (current cwd plus
|
|
6
|
+
every directory), de-duplicates by path, sorts newest-first, and lets the
|
|
7
|
+
user choose one through an injectable picker. On a non-interactive stdin it
|
|
8
|
+
skips the picker and resolves the newest session, so a piped invocation
|
|
9
|
+
still resumes deterministically.
|
|
10
|
+
|
|
11
|
+
- :func:`browse_settings` prints a plain listing of the resolved settings and
|
|
12
|
+
the discovered user resources, then waits for a keypress. No TUI — a flat
|
|
13
|
+
console dump the user reads and dismisses.
|
|
14
|
+
|
|
15
|
+
Both keep their effectful pieces (the picker mount, the input pause, the TTY
|
|
16
|
+
probe) behind injectable seams so the selection logic is unit-testable without
|
|
17
|
+
a terminal.
|
|
18
|
+
|
|
19
|
+
Port notes
|
|
20
|
+
----------
|
|
21
|
+
- The TS picker mounted the framework React-Ink ``StartupSessionPicker``. The
|
|
22
|
+
Python framework's picker is a Textual screen
|
|
23
|
+
(``indusagi.react_ink.components.dialogs.startup_picker``) that must live
|
|
24
|
+
inside a running Textual app. The **default** :class:`ResumeDeps` in this
|
|
25
|
+
module keeps the live TTY probe but its ``mount_picker`` raises by design —
|
|
26
|
+
the launch layer is terminal-agnostic and owns no Textual mount. The boot
|
|
27
|
+
``repl_runner`` injects a Textual-backed :class:`ResumeDeps`
|
|
28
|
+
(``induscode.console.resume_picker.default_startup_resume_deps``) that runs
|
|
29
|
+
the real picker before the console mounts, so ``pindus --resume`` on a TTY
|
|
30
|
+
shows the session list and resumes the chosen session — the same UX the TS
|
|
31
|
+
Ink mount gave. The **non-TTY newest-session fast path is fully live** and
|
|
32
|
+
is what oneshot/link runs exercise; it never reaches ``mount_picker``.
|
|
33
|
+
|
|
34
|
+
(Port of TS ``src/launch/pickers.ts``.)
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
import asyncio
|
|
40
|
+
import sys
|
|
41
|
+
from collections.abc import Sequence
|
|
42
|
+
from dataclasses import dataclass
|
|
43
|
+
from typing import Protocol
|
|
44
|
+
|
|
45
|
+
from .contract import (
|
|
46
|
+
ResumeFault,
|
|
47
|
+
ResumeRef,
|
|
48
|
+
SessionLoader,
|
|
49
|
+
SettingsBrowseOptions,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
__all__ = [
|
|
53
|
+
"ResumeDeps",
|
|
54
|
+
"ResumeOutcome",
|
|
55
|
+
"SettingsBrowseIo",
|
|
56
|
+
"browse_settings",
|
|
57
|
+
"default_resume_deps",
|
|
58
|
+
"default_settings_browse_io",
|
|
59
|
+
"merge_sessions",
|
|
60
|
+
"pick_resume_target",
|
|
61
|
+
"render_settings_listing",
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# ---------------------------------------------------------------------------
|
|
66
|
+
# Resume picker
|
|
67
|
+
# ---------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class ResumeDeps(Protocol):
|
|
71
|
+
"""The effectful seams :func:`pick_resume_target` depends on. Injected so
|
|
72
|
+
a test can report a non-interactive terminal and stub the picker mount."""
|
|
73
|
+
|
|
74
|
+
def is_interactive(self) -> bool:
|
|
75
|
+
"""Whether stdin is an interactive terminal (drives the non-TTY
|
|
76
|
+
fast-path)."""
|
|
77
|
+
...
|
|
78
|
+
|
|
79
|
+
async def mount_picker(
|
|
80
|
+
self, sessions: Sequence[ResumeRef], total_count: int
|
|
81
|
+
) -> ResumeRef | None:
|
|
82
|
+
"""Mount the picker over the session list and resolve with the chosen
|
|
83
|
+
session, or ``None`` when the user dismisses it."""
|
|
84
|
+
...
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass(frozen=True, slots=True)
|
|
88
|
+
class _DefaultResumeDeps:
|
|
89
|
+
"""The launch-layer default :class:`ResumeDeps`: a real TTY probe; the
|
|
90
|
+
picker mount is the console runner's seam (see the module docstring) and
|
|
91
|
+
raises because the launch layer owns no Textual mount. The boot
|
|
92
|
+
``repl_runner`` injects a Textual-backed :class:`ResumeDeps` on the
|
|
93
|
+
interactive path, so this raising mount is reached only when a caller
|
|
94
|
+
drives an interactive resume *without* supplying real deps."""
|
|
95
|
+
|
|
96
|
+
def is_interactive(self) -> bool:
|
|
97
|
+
return bool(sys.stdin.isatty() and sys.stdout.isatty())
|
|
98
|
+
|
|
99
|
+
async def mount_picker(
|
|
100
|
+
self, sessions: Sequence[ResumeRef], total_count: int
|
|
101
|
+
) -> ResumeRef | None:
|
|
102
|
+
del sessions, total_count
|
|
103
|
+
# The Textual StartupSessionPicker must run inside an app; the repl
|
|
104
|
+
# runner supplies real deps for that. Raising here lands in
|
|
105
|
+
# pick_resume_target's typed-fault fallback, matching the TS behaviour
|
|
106
|
+
# for a picker that fails to mount.
|
|
107
|
+
raise RuntimeError(
|
|
108
|
+
"The interactive session picker is wired by the console runner."
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def default_resume_deps() -> ResumeDeps:
|
|
113
|
+
"""Build the live :class:`ResumeDeps` (TTY probe + console-owned picker
|
|
114
|
+
seam)."""
|
|
115
|
+
return _DefaultResumeDeps()
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def merge_sessions(
|
|
119
|
+
current: Sequence[ResumeRef],
|
|
120
|
+
all_sessions: Sequence[ResumeRef],
|
|
121
|
+
) -> list[ResumeRef]:
|
|
122
|
+
"""Merge two session lists into one newest-first list de-duplicated by
|
|
123
|
+
path.
|
|
124
|
+
|
|
125
|
+
Earlier entries win the path key, then the merged set is sorted by
|
|
126
|
+
``lastModified`` descending. Pure; the picker and the fast-path both
|
|
127
|
+
consume it."""
|
|
128
|
+
by_path: dict[str, ResumeRef] = {}
|
|
129
|
+
for session in [*current, *all_sessions]:
|
|
130
|
+
if session.path not in by_path:
|
|
131
|
+
by_path[session.path] = session
|
|
132
|
+
return sorted(by_path.values(), key=lambda s: s.lastModified, reverse=True)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@dataclass(frozen=True, slots=True)
|
|
136
|
+
class ResumeOutcome:
|
|
137
|
+
"""The outcome of :func:`pick_resume_target`: the chosen session path, or
|
|
138
|
+
``None`` when there was nothing to resume or the user dismissed the
|
|
139
|
+
picker. A typed :class:`~.contract.ResumeFault` is returned (never
|
|
140
|
+
raised) when the load or mount failed."""
|
|
141
|
+
|
|
142
|
+
# The selected session file path, or None for none.
|
|
143
|
+
path: str | None
|
|
144
|
+
# A typed failure, present only when the resume flow could not run.
|
|
145
|
+
fault: ResumeFault | None = None
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
async def pick_resume_target(
|
|
149
|
+
load_current: SessionLoader,
|
|
150
|
+
load_all: SessionLoader,
|
|
151
|
+
deps: ResumeDeps | None = None,
|
|
152
|
+
) -> ResumeOutcome:
|
|
153
|
+
"""Choose a session to resume.
|
|
154
|
+
|
|
155
|
+
Loads the current-directory and all-directory session lists, merges them
|
|
156
|
+
(de-dupe by path, newest first), and either mounts the injected picker or
|
|
157
|
+
— on a non-interactive stdin — resolves the newest session directly. An
|
|
158
|
+
empty merged list resolves to ``ResumeOutcome(path=None)``.
|
|
159
|
+
|
|
160
|
+
:param load_current: loader for sessions started in the active cwd
|
|
161
|
+
:param load_all: loader for sessions across every directory
|
|
162
|
+
:param deps: injectable TTY probe and picker mount (defaults to live)
|
|
163
|
+
"""
|
|
164
|
+
live_deps = deps if deps is not None else default_resume_deps()
|
|
165
|
+
try:
|
|
166
|
+
current, all_sessions = await asyncio.gather(load_current(), load_all())
|
|
167
|
+
except Exception as cause:
|
|
168
|
+
return ResumeOutcome(
|
|
169
|
+
path=None,
|
|
170
|
+
fault=ResumeFault(message="Failed to load resumable sessions.", cause=cause),
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
sessions = merge_sessions(current, all_sessions)
|
|
174
|
+
if not sessions:
|
|
175
|
+
return ResumeOutcome(path=None)
|
|
176
|
+
|
|
177
|
+
if not live_deps.is_interactive():
|
|
178
|
+
return ResumeOutcome(path=sessions[0].path)
|
|
179
|
+
|
|
180
|
+
try:
|
|
181
|
+
chosen = await live_deps.mount_picker(sessions, len(sessions))
|
|
182
|
+
return ResumeOutcome(path=None if chosen is None else chosen.path)
|
|
183
|
+
except Exception as cause:
|
|
184
|
+
return ResumeOutcome(
|
|
185
|
+
path=None,
|
|
186
|
+
fault=ResumeFault(message="The session picker failed to start.", cause=cause),
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
# ---------------------------------------------------------------------------
|
|
191
|
+
# Settings browser
|
|
192
|
+
# ---------------------------------------------------------------------------
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
class SettingsBrowseIo(Protocol):
|
|
196
|
+
"""The effectful seams :func:`browse_settings` depends on: a line sink and
|
|
197
|
+
a pause that waits for the user to press a key. Injected so a test
|
|
198
|
+
captures the listing and resolves the pause immediately."""
|
|
199
|
+
|
|
200
|
+
def print(self, line: str) -> None:
|
|
201
|
+
"""Emit one line of the listing."""
|
|
202
|
+
...
|
|
203
|
+
|
|
204
|
+
async def pause(self, prompt: str) -> None:
|
|
205
|
+
"""Wait for the user to dismiss the listing (e.g. press Enter)."""
|
|
206
|
+
...
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
class _DefaultSettingsBrowseIo:
|
|
210
|
+
"""The live :class:`SettingsBrowseIo`: stdout plus an :func:`input`
|
|
211
|
+
pause."""
|
|
212
|
+
|
|
213
|
+
def print(self, line: str) -> None:
|
|
214
|
+
sys.stdout.write(line if line.endswith("\n") else line + "\n")
|
|
215
|
+
|
|
216
|
+
async def pause(self, prompt: str) -> None:
|
|
217
|
+
await asyncio.to_thread(input, prompt)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def default_settings_browse_io() -> SettingsBrowseIo:
|
|
221
|
+
"""Build the live stdout/input-backed :class:`SettingsBrowseIo`."""
|
|
222
|
+
return _DefaultSettingsBrowseIo()
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _render_section(title: str, paths: Sequence[str]) -> list[str]:
|
|
226
|
+
"""Build the listing lines for one named resource category. Pure."""
|
|
227
|
+
if not paths:
|
|
228
|
+
return [f"{title}: (none)"]
|
|
229
|
+
return [f"{title}:", *(f" - {path}" for path in paths)]
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def render_settings_listing(opts: SettingsBrowseOptions) -> list[str]:
|
|
233
|
+
"""Build the entire settings listing as a list of lines. Pure — it
|
|
234
|
+
consumes the resolved settings, the active cwd, the profile directory, and
|
|
235
|
+
the grouped resource paths, and produces the flat dump
|
|
236
|
+
:func:`browse_settings` prints. Kept separate so a test can assert the
|
|
237
|
+
content without an io seam."""
|
|
238
|
+
lines: list[str] = []
|
|
239
|
+
lines.append("Configuration")
|
|
240
|
+
lines.append(f" Working directory: {opts.cwd}")
|
|
241
|
+
lines.append(f" Profile directory: {opts.profile_dir}")
|
|
242
|
+
lines.append("")
|
|
243
|
+
|
|
244
|
+
lines.append("Settings")
|
|
245
|
+
default_model = (
|
|
246
|
+
opts.settings.default_model
|
|
247
|
+
if opts.settings.default_model is not None
|
|
248
|
+
else "(unset)"
|
|
249
|
+
)
|
|
250
|
+
lines.append(f" Default model: {default_model}")
|
|
251
|
+
lines.append(
|
|
252
|
+
" System prompt: "
|
|
253
|
+
+ ("(custom)" if opts.settings.system_prompt is not None else "(default)")
|
|
254
|
+
)
|
|
255
|
+
servers = opts.settings.mcp_servers
|
|
256
|
+
server_count = len(servers) if servers is not None else 0
|
|
257
|
+
lines.append(f" MCP servers: {server_count}")
|
|
258
|
+
lines.append("")
|
|
259
|
+
|
|
260
|
+
categories = sorted(opts.resolved_paths.keys())
|
|
261
|
+
if not categories:
|
|
262
|
+
lines.append("Resources: (none discovered)")
|
|
263
|
+
else:
|
|
264
|
+
lines.append("Resources")
|
|
265
|
+
for category in categories:
|
|
266
|
+
lines.extend(
|
|
267
|
+
_render_section(f" {category}", list(opts.resolved_paths[category]))
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
return lines
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
async def browse_settings(
|
|
274
|
+
opts: SettingsBrowseOptions,
|
|
275
|
+
io: SettingsBrowseIo | None = None,
|
|
276
|
+
) -> None:
|
|
277
|
+
"""Print the settings / resource listing and wait for the user to dismiss
|
|
278
|
+
it.
|
|
279
|
+
|
|
280
|
+
A plain console dump — no TUI. It renders the resolved settings summary
|
|
281
|
+
and the discovered resources grouped by category, then pauses for a
|
|
282
|
+
keypress so the listing stays on screen.
|
|
283
|
+
|
|
284
|
+
:param opts: the resolved settings, resource paths, cwd, and profile dir
|
|
285
|
+
:param io: the injectable line sink and pause (defaults to stdout + input)
|
|
286
|
+
"""
|
|
287
|
+
live_io = io if io is not None else default_settings_browse_io()
|
|
288
|
+
for line in render_settings_listing(opts):
|
|
289
|
+
live_io.print(line)
|
|
290
|
+
live_io.print("")
|
|
291
|
+
await live_io.pause("Press Enter to continue. ")
|
induscode/py.typed
ADDED
|
File without changes
|