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,185 @@
|
|
|
1
|
+
"""Slash framework contracts — the frozen types the registry, resolver, and
|
|
2
|
+
every command group are written against.
|
|
3
|
+
|
|
4
|
+
Port of the slash portion of TS ``src/console/contract.ts`` (the
|
|
5
|
+
``SlashOutcome`` / ``SlashContext`` / ``SlashCommand`` / ``SlashRegistry``
|
|
6
|
+
types). Discriminated unions become frozen dataclasses carrying ``ClassVar``
|
|
7
|
+
``Literal`` kind tags (the framework house style); the rest are mechanical
|
|
8
|
+
snake_case renames.
|
|
9
|
+
|
|
10
|
+
Two deliberate deltas from the TS shape, both locked by the port plan:
|
|
11
|
+
|
|
12
|
+
- **All ``run`` callables are async.** TS allowed
|
|
13
|
+
``SlashOutcome | Promise<SlashOutcome>``; the Python dispatcher awaits one
|
|
14
|
+
uniform calling convention (``async def run(ctx) -> SlashOutcome``), which
|
|
15
|
+
makes busy→info status sequences deterministic and lets tests flush with
|
|
16
|
+
``await asyncio.sleep(0)`` instead of ``setTimeout(0)``.
|
|
17
|
+
- **:class:`SlashContext` is a dataclass of callables**, not an interface of
|
|
18
|
+
methods: a scripted recorder test builds one from plain lambdas, mirroring
|
|
19
|
+
the TS recorder-fake ergonomics 1:1.
|
|
20
|
+
|
|
21
|
+
Cross-milestone notes: ``conductor`` is typed :data:`typing.Any` until the
|
|
22
|
+
``SessionConductor`` protocol lands with M2 (``induscode.conductor``); the
|
|
23
|
+
``dispatch`` event union and the ``ModalKind`` literal belong to the M5
|
|
24
|
+
console contract — here ``open_modal`` takes the kind as ``str`` through the
|
|
25
|
+
:class:`OpenModal` protocol so the framework stays a pure M1 leaf.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
from collections.abc import Awaitable, Callable, Mapping
|
|
31
|
+
from dataclasses import dataclass
|
|
32
|
+
from typing import Any, ClassVar, Literal, Protocol, TypeAlias
|
|
33
|
+
|
|
34
|
+
from indusagi.react_ink import StatusMessage, UiDisplayBlock
|
|
35
|
+
|
|
36
|
+
__all__ = [
|
|
37
|
+
"Handled",
|
|
38
|
+
"OpenModal",
|
|
39
|
+
"Prompt",
|
|
40
|
+
"SlashCommand",
|
|
41
|
+
"SlashContext",
|
|
42
|
+
"SlashOutcome",
|
|
43
|
+
"SlashRegistry",
|
|
44
|
+
"SlashRun",
|
|
45
|
+
"Unknown",
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# ---------------------------------------------------------------------------
|
|
50
|
+
# SlashOutcome — what a handler reports back to the dispatcher
|
|
51
|
+
# ---------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True, slots=True)
|
|
55
|
+
class Handled:
|
|
56
|
+
"""The command ran; nothing further is needed."""
|
|
57
|
+
|
|
58
|
+
kind: ClassVar[Literal["handled"]] = "handled"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True, slots=True)
|
|
62
|
+
class Prompt:
|
|
63
|
+
"""The command produced text to be submitted as a normal turn."""
|
|
64
|
+
|
|
65
|
+
kind: ClassVar[Literal["prompt"]] = "prompt"
|
|
66
|
+
text: str
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass(frozen=True, slots=True)
|
|
70
|
+
class Unknown:
|
|
71
|
+
"""The command name did not match; the dispatcher falls through to
|
|
72
|
+
templates / plugin commands / a literal prompt."""
|
|
73
|
+
|
|
74
|
+
kind: ClassVar[Literal["unknown"]] = "unknown"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
#: The result a :attr:`SlashCommand.run` resolves to (TS ``SlashOutcome``).
|
|
78
|
+
SlashOutcome: TypeAlias = Handled | Prompt | Unknown
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# ---------------------------------------------------------------------------
|
|
82
|
+
# SlashContext — the command's whole world
|
|
83
|
+
# ---------------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class OpenModal(Protocol):
|
|
87
|
+
"""Raise a modal overlay: ``open_modal(kind, payload=None)``.
|
|
88
|
+
|
|
89
|
+
``kind`` is one of the console's ``ModalKind`` literals (M5 contract);
|
|
90
|
+
``payload`` is opaque at this layer — each dialog narrows it at its own
|
|
91
|
+
boundary, exactly as the TS contract left it ``unknown``.
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
def __call__(self, kind: str, payload: object | None = None) -> None: ...
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass(frozen=True, slots=True)
|
|
98
|
+
class SlashContext:
|
|
99
|
+
"""The capabilities a :attr:`SlashCommand.run` is handed to act on the
|
|
100
|
+
console (TS ``SlashContext``).
|
|
101
|
+
|
|
102
|
+
The context is the command's whole world: it drives the session
|
|
103
|
+
conductor, mutates UI state through ``dispatch``, raises overlays, and
|
|
104
|
+
surfaces status. Keeping every effect behind this object keeps handlers
|
|
105
|
+
pure with respect to the TUI host and lets tests pass a scripted fake
|
|
106
|
+
built from plain recording lambdas.
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
# The raw argument string after the command name (may be empty).
|
|
110
|
+
args: str
|
|
111
|
+
# The session this console drives (SessionConductor; protocol lands M2).
|
|
112
|
+
conductor: Any
|
|
113
|
+
# Dispatch a reducer event to mutate console state (ConsoleEvent union
|
|
114
|
+
# lands with the M5 console contract; opaque here).
|
|
115
|
+
dispatch: Callable[[Any], None]
|
|
116
|
+
# Raise a modal overlay.
|
|
117
|
+
open_modal: OpenModal
|
|
118
|
+
# Drop the active overlay.
|
|
119
|
+
close_modal: Callable[[], None]
|
|
120
|
+
# Ask the host process to leave the interactive console (/quit, /exit).
|
|
121
|
+
request_exit: Callable[[], None]
|
|
122
|
+
# Show a transient status message.
|
|
123
|
+
set_status: Callable[[StatusMessage], None]
|
|
124
|
+
# Replace the composer buffer (e.g. to pre-fill a follow-up).
|
|
125
|
+
set_buffer: Callable[[str], None]
|
|
126
|
+
# Append an out-of-band display block.
|
|
127
|
+
append_block: Callable[[UiDisplayBlock], None]
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# ---------------------------------------------------------------------------
|
|
131
|
+
# SlashCommand — one row in the registry
|
|
132
|
+
# ---------------------------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
#: The uniform handler signature: every command is ``async`` and the
|
|
136
|
+
#: dispatcher awaits it (locked cross-cutting rule; see module docstring).
|
|
137
|
+
SlashRun: TypeAlias = Callable[["SlashContext"], Awaitable[SlashOutcome]]
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@dataclass(frozen=True, slots=True)
|
|
141
|
+
class SlashCommand:
|
|
142
|
+
"""One row in the slash registry (TS ``SlashCommand``).
|
|
143
|
+
|
|
144
|
+
A command is pure data plus a ``run``: ``name`` is its canonical token
|
|
145
|
+
(without the leading slash), ``summary`` is the one-line description the
|
|
146
|
+
completion window shows, ``aliases`` are alternate tokens, and ``family``
|
|
147
|
+
groups related commands for listing. ``run`` performs the effect through
|
|
148
|
+
a :class:`SlashContext`.
|
|
149
|
+
"""
|
|
150
|
+
|
|
151
|
+
# Canonical command token, without the leading slash.
|
|
152
|
+
name: str
|
|
153
|
+
# One-line description shown in the completion window.
|
|
154
|
+
summary: str
|
|
155
|
+
# Execute the command against the console (always awaited).
|
|
156
|
+
run: SlashRun
|
|
157
|
+
# Alternate tokens that resolve to this command.
|
|
158
|
+
aliases: tuple[str, ...] = ()
|
|
159
|
+
# The family this command belongs to, for grouped listing.
|
|
160
|
+
family: str | None = None
|
|
161
|
+
# Whether the command accepts a trailing argument string.
|
|
162
|
+
takes_args: bool = False
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
# ---------------------------------------------------------------------------
|
|
166
|
+
# SlashRegistry — the resolved command table
|
|
167
|
+
# ---------------------------------------------------------------------------
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@dataclass(frozen=True, slots=True)
|
|
171
|
+
class SlashRegistry:
|
|
172
|
+
"""The registry the dispatcher resolves a typed slash line against
|
|
173
|
+
(TS ``SlashRegistry``).
|
|
174
|
+
|
|
175
|
+
A flat, ordered list of :class:`SlashCommand` rows plus a derived index.
|
|
176
|
+
The dispatcher walks the list once (the index is the fast path for
|
|
177
|
+
exact/alias lookups), so adding a command is appending a row — never
|
|
178
|
+
editing a branch. Built by :func:`induscode.console_slash.build_registry`;
|
|
179
|
+
never assembled by hand.
|
|
180
|
+
"""
|
|
181
|
+
|
|
182
|
+
# Every registered command, in listing order.
|
|
183
|
+
commands: tuple[SlashCommand, ...]
|
|
184
|
+
# Name/alias → command, for O(1) exact resolution (read-only mapping).
|
|
185
|
+
index: Mapping[str, SlashCommand]
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Slash registry assembly — fold a command list into the resolved registry.
|
|
2
|
+
|
|
3
|
+
Port of TS ``src/console/slash/registry.ts``. The slash subsystem keeps
|
|
4
|
+
exactly one hand-maintained list of commands (the catalog, assembled by the
|
|
5
|
+
M5 console's explicit ``build_catalog``); everything a consumer needs to
|
|
6
|
+
resolve, complete, or list a command is *derived* from that list here:
|
|
7
|
+
|
|
8
|
+
- :func:`build_registry` indexes the list by canonical name *and* by every
|
|
9
|
+
alias, so :func:`induscode.console_slash.resolve.resolve_slash` is an O(1)
|
|
10
|
+
mapping lookup rather than a scan. It also guards the one invariant a flat
|
|
11
|
+
list cannot: two rows must not claim the same token (a duplicate name or
|
|
12
|
+
alias is a programming error, surfaced loudly at assembly time, not
|
|
13
|
+
silently shadowed).
|
|
14
|
+
- :func:`match_prefix` powers the completion window: given the partial token
|
|
15
|
+
the user has typed, it returns the rows whose name or alias starts with it,
|
|
16
|
+
in registry order.
|
|
17
|
+
- :func:`commands_in_family` / :func:`list_families` project the ``family``
|
|
18
|
+
field for grouped listing (the ``/help`` overlay, the completion grouping).
|
|
19
|
+
|
|
20
|
+
No effects, no TUI, no conductor — the registry is a value, and these are
|
|
21
|
+
pure derivations over it. The dispatcher consumes the registry; it is not
|
|
22
|
+
built here.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
from collections.abc import Sequence
|
|
28
|
+
from types import MappingProxyType
|
|
29
|
+
|
|
30
|
+
from .contract import SlashCommand, SlashRegistry
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
"build_registry",
|
|
34
|
+
"commands_in_family",
|
|
35
|
+
"find_command",
|
|
36
|
+
"list_families",
|
|
37
|
+
"match_prefix",
|
|
38
|
+
"tokens_of",
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def tokens_of(command: SlashCommand) -> list[str]:
|
|
43
|
+
"""Every token a command answers to: its canonical name plus any aliases.
|
|
44
|
+
|
|
45
|
+
The single place name/alias enumeration happens, so the index builder and
|
|
46
|
+
the prefix matcher agree on what counts as "a token for this command".
|
|
47
|
+
|
|
48
|
+
:param command: the command to enumerate tokens for
|
|
49
|
+
"""
|
|
50
|
+
return [command.name, *command.aliases]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def build_registry(commands: Sequence[SlashCommand]) -> SlashRegistry:
|
|
54
|
+
"""Fold an ordered command list into a resolved :class:`SlashRegistry`.
|
|
55
|
+
|
|
56
|
+
Preserves the input order in :attr:`SlashRegistry.commands` (it is the
|
|
57
|
+
listing order the completion window and ``/help`` render in) and derives
|
|
58
|
+
the :attr:`SlashRegistry.index` by mapping every token — canonical and
|
|
59
|
+
alias — to its owning command. Tokens are lower-cased on the way in so
|
|
60
|
+
resolution can be case-insensitive without the resolver re-normalising.
|
|
61
|
+
|
|
62
|
+
Raises :class:`ValueError` when two rows claim the same token: a flat
|
|
63
|
+
list cannot express "these names are disjoint", so the invariant is
|
|
64
|
+
enforced here, at the one point the table is assembled, instead of
|
|
65
|
+
letting a later row silently shadow an earlier one.
|
|
66
|
+
|
|
67
|
+
:param commands: the ordered command list (the single source of truth)
|
|
68
|
+
"""
|
|
69
|
+
index: dict[str, SlashCommand] = {}
|
|
70
|
+
for command in commands:
|
|
71
|
+
for token in tokens_of(command):
|
|
72
|
+
key = token.lower()
|
|
73
|
+
existing = index.get(key)
|
|
74
|
+
if existing is not None and existing is not command:
|
|
75
|
+
raise ValueError(
|
|
76
|
+
f'slash registry: token "{key}" is claimed by both '
|
|
77
|
+
f'"{existing.name}" and "{command.name}"'
|
|
78
|
+
)
|
|
79
|
+
index[key] = command
|
|
80
|
+
return SlashRegistry(commands=tuple(commands), index=MappingProxyType(index))
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def find_command(registry: SlashRegistry, token: str) -> SlashCommand | None:
|
|
84
|
+
"""Resolve a single token (canonical or alias) to its command, or ``None``.
|
|
85
|
+
|
|
86
|
+
A thin, case-insensitive read of :attr:`SlashRegistry.index` so call
|
|
87
|
+
sites do not re-implement the lower-casing the registry index expects.
|
|
88
|
+
|
|
89
|
+
:param registry: the registry to read
|
|
90
|
+
:param token: a canonical name or alias, with or without a leading slash
|
|
91
|
+
"""
|
|
92
|
+
bare = token[1:] if token.startswith("/") else token
|
|
93
|
+
return registry.index.get(bare.lower())
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def match_prefix(registry: SlashRegistry, partial: str) -> list[SlashCommand]:
|
|
97
|
+
"""The commands whose name or alias begins with a partial token, in
|
|
98
|
+
registry order — the candidate set the completion window renders.
|
|
99
|
+
|
|
100
|
+
Matching is case-insensitive and prefix-based against *every* token a
|
|
101
|
+
command answers to, but each command appears at most once even when
|
|
102
|
+
several of its tokens match. An empty partial returns the whole list (the
|
|
103
|
+
bare-``/`` case where the user has opened the completion window but typed
|
|
104
|
+
nothing yet).
|
|
105
|
+
|
|
106
|
+
:param registry: the registry to search
|
|
107
|
+
:param partial: the partial command token (without a leading slash)
|
|
108
|
+
"""
|
|
109
|
+
needle = partial.lower()
|
|
110
|
+
if len(needle) == 0:
|
|
111
|
+
return list(registry.commands)
|
|
112
|
+
return [
|
|
113
|
+
command
|
|
114
|
+
for command in registry.commands
|
|
115
|
+
if any(token.lower().startswith(needle) for token in tokens_of(command))
|
|
116
|
+
]
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def list_families(registry: SlashRegistry) -> list[str]:
|
|
120
|
+
"""The distinct family labels present in the registry, in first-seen order.
|
|
121
|
+
|
|
122
|
+
Commands without a ``family`` are omitted; the result drives the grouped
|
|
123
|
+
headings in ``/help`` and the completion window.
|
|
124
|
+
|
|
125
|
+
:param registry: the registry to scan
|
|
126
|
+
"""
|
|
127
|
+
seen: list[str] = []
|
|
128
|
+
for command in registry.commands:
|
|
129
|
+
if command.family is not None and command.family not in seen:
|
|
130
|
+
seen.append(command.family)
|
|
131
|
+
return seen
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def commands_in_family(registry: SlashRegistry, family: str) -> list[SlashCommand]:
|
|
135
|
+
"""The commands belonging to a family, in registry order.
|
|
136
|
+
|
|
137
|
+
:param registry: the registry to filter
|
|
138
|
+
:param family: the family label to select
|
|
139
|
+
"""
|
|
140
|
+
return [command for command in registry.commands if command.family == family]
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""Slash line resolution — parse a typed line, then match it against the registry.
|
|
2
|
+
|
|
3
|
+
Port of TS ``src/console/slash/resolve.ts``. This module is the pure front
|
|
4
|
+
half of the slash subsystem: it takes the raw string a user typed into the
|
|
5
|
+
composer and decides three things, in order:
|
|
6
|
+
|
|
7
|
+
1. **Is this a slash line at all?** A line is a slash invocation only when
|
|
8
|
+
its first non-blank character is ``/`` and what follows is a
|
|
9
|
+
command-shaped token (so a bare ``/`` or a ``/path/to/file`` literal is
|
|
10
|
+
*not* hijacked as a command). :func:`parse_slash` reports this without
|
|
11
|
+
consulting the registry.
|
|
12
|
+
2. **Which command does the token resolve to?** :func:`resolve_slash` folds
|
|
13
|
+
a :class:`~induscode.console_slash.contract.SlashRegistry` in: it splits
|
|
14
|
+
the line into a name and a trailing argument string, then resolves the
|
|
15
|
+
name (and its aliases) through the registry's index. Resolution is
|
|
16
|
+
case-insensitive on the command token.
|
|
17
|
+
3. **What should the dispatcher do?** The result is a discriminated
|
|
18
|
+
:data:`SlashResolution`: :class:`NotSlash` (treat as a normal prompt /
|
|
19
|
+
bash escape), :class:`Match` (run this command with these args), or
|
|
20
|
+
:class:`Miss` (it looked like a command but no row owns the token — fall
|
|
21
|
+
through to templates, plugin commands, or a literal prompt).
|
|
22
|
+
|
|
23
|
+
Nothing here touches the TUI, the conductor, or I/O — the whole module is a
|
|
24
|
+
pair of pure functions over a string and a registry. The effectful half
|
|
25
|
+
(actually calling a command's ``run``) is the dispatcher's job.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import re
|
|
31
|
+
from dataclasses import dataclass
|
|
32
|
+
from typing import ClassVar, Final, Literal, TypeAlias
|
|
33
|
+
|
|
34
|
+
from .contract import SlashCommand, SlashRegistry
|
|
35
|
+
|
|
36
|
+
__all__ = [
|
|
37
|
+
"Match",
|
|
38
|
+
"Miss",
|
|
39
|
+
"NotSlash",
|
|
40
|
+
"SLASH_PREFIX",
|
|
41
|
+
"SlashLine",
|
|
42
|
+
"SlashResolution",
|
|
43
|
+
"looks_like_slash",
|
|
44
|
+
"parse_slash",
|
|
45
|
+
"resolve_slash",
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# ---------------------------------------------------------------------------
|
|
50
|
+
# The parsed shape of a slash line
|
|
51
|
+
# ---------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
#: The leading character that marks a line as a slash invocation.
|
|
55
|
+
#:
|
|
56
|
+
#: A single constant so the prefix is defined in exactly one place and the
|
|
57
|
+
#: tests assert against the same literal the parser reads.
|
|
58
|
+
SLASH_PREFIX: Final = "/"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True, slots=True)
|
|
62
|
+
class SlashLine:
|
|
63
|
+
"""The lexical split of a slash line into its command token and argument
|
|
64
|
+
tail.
|
|
65
|
+
|
|
66
|
+
Produced by :func:`parse_slash` before the registry is consulted, so the
|
|
67
|
+
two concerns — "did the user type a command-shaped line" and "does a
|
|
68
|
+
command own that token" — stay separable. ``name`` is the bare token with
|
|
69
|
+
no leading slash and folded to lower case for case-insensitive matching;
|
|
70
|
+
``args`` is everything after the first run of whitespace, with the single
|
|
71
|
+
separating space removed but interior spacing preserved.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
# The command token, lower-cased, without the leading slash.
|
|
75
|
+
name: str
|
|
76
|
+
# The trailing argument string (may be empty), interior spacing preserved.
|
|
77
|
+
args: str
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
#: The shape a command token must have: it starts with an ASCII letter and
|
|
81
|
+
#: then carries only letters, digits, hyphens, or a single namespacing colon
|
|
82
|
+
#: (e.g. ``model``, ``scoped-models``, ``skill:lint``). A second slash
|
|
83
|
+
#: anywhere in the token disqualifies the line, which is what tells a command
|
|
84
|
+
#: apart from a filesystem path. The token runs to the first whitespace or
|
|
85
|
+
#: end-of-line. (Pattern kept verbatim from the TS source.)
|
|
86
|
+
_COMMAND_TOKEN: Final = re.compile(r"^[a-zA-Z][a-zA-Z0-9:-]*(?:\s|$)")
|
|
87
|
+
|
|
88
|
+
#: First-whitespace finder for the token/args split (TS ``body.search(/\s/)``).
|
|
89
|
+
_WHITESPACE: Final = re.compile(r"\s")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def looks_like_slash(input: str) -> bool:
|
|
93
|
+
"""Whether a typed line is shaped like a slash command at all.
|
|
94
|
+
|
|
95
|
+
A line qualifies only when, after trimming leading blanks, it begins with
|
|
96
|
+
the :data:`SLASH_PREFIX` *and* what follows up to the first whitespace is
|
|
97
|
+
a clean command token. This deliberately rejects a lone ``/``, a
|
|
98
|
+
``//comment``, and a ``/usr/local/bin`` style path (whose embedded slash
|
|
99
|
+
breaks the token shape) so those reach the prompt untouched.
|
|
100
|
+
|
|
101
|
+
:param input: the raw composer line
|
|
102
|
+
"""
|
|
103
|
+
trimmed = input.lstrip()
|
|
104
|
+
if not trimmed.startswith(SLASH_PREFIX):
|
|
105
|
+
return False
|
|
106
|
+
return _COMMAND_TOKEN.match(trimmed[len(SLASH_PREFIX) :]) is not None
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def parse_slash(input: str) -> SlashLine | None:
|
|
110
|
+
"""Split a slash line into its :class:`SlashLine` token/argument pair, or
|
|
111
|
+
``None`` when the line is not shaped like a command.
|
|
112
|
+
|
|
113
|
+
The token runs from just after the slash up to the first whitespace (or
|
|
114
|
+
end-of-line); the argument tail is whatever follows that whitespace,
|
|
115
|
+
trimmed only at its outer edges. The token is lower-cased so resolution
|
|
116
|
+
can be case-insensitive without the caller pre-normalising.
|
|
117
|
+
|
|
118
|
+
:param input: the raw composer line
|
|
119
|
+
"""
|
|
120
|
+
if not looks_like_slash(input):
|
|
121
|
+
return None
|
|
122
|
+
body = input.lstrip()[len(SLASH_PREFIX) :]
|
|
123
|
+
gap = _WHITESPACE.search(body)
|
|
124
|
+
if gap is None:
|
|
125
|
+
return SlashLine(name=body.lower(), args="")
|
|
126
|
+
return SlashLine(
|
|
127
|
+
name=body[: gap.start()].lower(),
|
|
128
|
+
args=body[gap.start() + 1 :].strip(),
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
# ---------------------------------------------------------------------------
|
|
133
|
+
# Resolving a parsed line against the registry
|
|
134
|
+
# ---------------------------------------------------------------------------
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@dataclass(frozen=True, slots=True)
|
|
138
|
+
class NotSlash:
|
|
139
|
+
"""The line is not a command invocation; the dispatcher should hand it on
|
|
140
|
+
as a normal prompt (or a ``!``/``!!`` bash escape)."""
|
|
141
|
+
|
|
142
|
+
kind: ClassVar[Literal["not-slash"]] = "not-slash"
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@dataclass(frozen=True, slots=True)
|
|
146
|
+
class Match:
|
|
147
|
+
"""The token resolved to ``command``; run it with ``args`` (the raw
|
|
148
|
+
trailing string) via a
|
|
149
|
+
:class:`~induscode.console_slash.contract.SlashContext`."""
|
|
150
|
+
|
|
151
|
+
kind: ClassVar[Literal["match"]] = "match"
|
|
152
|
+
command: SlashCommand
|
|
153
|
+
args: str
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@dataclass(frozen=True, slots=True)
|
|
157
|
+
class Miss:
|
|
158
|
+
"""The line *was* command-shaped but no row owns the token; the dispatcher
|
|
159
|
+
falls through to templates / plugin commands / a literal prompt. The
|
|
160
|
+
offending ``name`` is reported so a caller can surface "unknown command"."""
|
|
161
|
+
|
|
162
|
+
kind: ClassVar[Literal["miss"]] = "miss"
|
|
163
|
+
name: str
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
#: The outcome of resolving a typed line against a registry
|
|
167
|
+
#: (TS ``SlashResolution``).
|
|
168
|
+
SlashResolution: TypeAlias = NotSlash | Match | Miss
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def resolve_slash(input: str, registry: SlashRegistry) -> SlashResolution:
|
|
172
|
+
"""Resolve a raw composer line against the registry to a
|
|
173
|
+
:data:`SlashResolution`.
|
|
174
|
+
|
|
175
|
+
Parses the line (:func:`parse_slash`); a non-command line resolves to
|
|
176
|
+
:class:`NotSlash`. A command-shaped line's token is looked up in the
|
|
177
|
+
registry index (which already carries both canonical names and aliases),
|
|
178
|
+
yielding :class:`Match` with the owning command and the raw argument
|
|
179
|
+
string, or :class:`Miss` when no row claims the token.
|
|
180
|
+
|
|
181
|
+
This is the single entry point a dispatcher calls; it performs no effects.
|
|
182
|
+
|
|
183
|
+
:param input: the raw composer line
|
|
184
|
+
:param registry: the registry to resolve against
|
|
185
|
+
"""
|
|
186
|
+
line = parse_slash(input)
|
|
187
|
+
if line is None:
|
|
188
|
+
return NotSlash()
|
|
189
|
+
|
|
190
|
+
command = registry.index.get(line.name)
|
|
191
|
+
if command is None:
|
|
192
|
+
return Miss(name=line.name)
|
|
193
|
+
|
|
194
|
+
return Match(command=command, args=line.args)
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""Shared slash-command helpers — the small, pure toolkit every command group
|
|
2
|
+
is written against.
|
|
3
|
+
|
|
4
|
+
Port of TS ``src/console/slash/commands/shared.ts``. The console's slash
|
|
5
|
+
catalog is split into topic groups (transcript control, the workbench
|
|
6
|
+
pickers, the integration bridges — they land with M5). Each group is its own
|
|
7
|
+
module exporting a ``list[SlashCommand]``; this file holds the handful of
|
|
8
|
+
helpers they all lean on so the groups stay free of duplicated wiring and
|
|
9
|
+
the family sub-command dispatch lives in exactly one place.
|
|
10
|
+
|
|
11
|
+
Two stances every group keeps:
|
|
12
|
+
|
|
13
|
+
1. **Handlers are thin.** A ``run(ctx)`` never reaches into the TUI, the
|
|
14
|
+
filesystem, or a provider SDK; every effect goes through the injected
|
|
15
|
+
:class:`~induscode.console_slash.contract.SlashContext`. The handler
|
|
16
|
+
decides *which* effect to raise.
|
|
17
|
+
2. **Families are tables, not ladders.** A command with sub-verbs (e.g.
|
|
18
|
+
``/memory status|on|off``) is built from a :class:`SubCommand` table via
|
|
19
|
+
:func:`family_runner`, never a hand-written ``if``-chain.
|
|
20
|
+
|
|
21
|
+
Per the locked calling convention, every generated ``run`` — including a
|
|
22
|
+
:class:`SubCommand`'s — is ``async`` and awaited by the dispatcher.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import re
|
|
28
|
+
from collections.abc import Awaitable, Callable, Sequence
|
|
29
|
+
from dataclasses import dataclass
|
|
30
|
+
from typing import Final, NamedTuple
|
|
31
|
+
|
|
32
|
+
from indusagi.react_ink import StatusMessage
|
|
33
|
+
|
|
34
|
+
from .contract import Handled, SlashContext, SlashOutcome, SlashRun
|
|
35
|
+
|
|
36
|
+
__all__ = [
|
|
37
|
+
"FAMILY",
|
|
38
|
+
"FamilyLabels",
|
|
39
|
+
"HANDLED",
|
|
40
|
+
"SubCommand",
|
|
41
|
+
"VerbSplit",
|
|
42
|
+
"family_runner",
|
|
43
|
+
"info",
|
|
44
|
+
"split_verb",
|
|
45
|
+
"warn",
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# ---------------------------------------------------------------------------
|
|
50
|
+
# Family labels
|
|
51
|
+
# ---------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True, slots=True)
|
|
55
|
+
class FamilyLabels:
|
|
56
|
+
"""The family labels the catalog groups commands under for ``/help`` and
|
|
57
|
+
completion. Named constants (not bare strings scattered across rows) so
|
|
58
|
+
the grouping a command declares and the grouping the help view renders
|
|
59
|
+
cannot drift apart."""
|
|
60
|
+
|
|
61
|
+
composio: str = "composio"
|
|
62
|
+
memory: str = "memory"
|
|
63
|
+
scoped_models: str = "models-for"
|
|
64
|
+
# parity: `theme` is declared but the TS catalog ships no /theme command
|
|
65
|
+
# (the scheme picker is reached via the settings overlay). Kept verbatim.
|
|
66
|
+
theme: str = "theme"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
#: The one shared label record (TS ``FAMILY``).
|
|
70
|
+
FAMILY: Final = FamilyLabels()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# ---------------------------------------------------------------------------
|
|
74
|
+
# Outcome + status helpers
|
|
75
|
+
# ---------------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
#: The settled "command ran, nothing more to do" outcome.
|
|
79
|
+
HANDLED: Final[SlashOutcome] = Handled()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def info(text: str) -> StatusMessage:
|
|
83
|
+
"""Mint an info-tone status toast."""
|
|
84
|
+
return StatusMessage(kind="info", text=text)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def warn(text: str) -> StatusMessage:
|
|
88
|
+
"""Mint a warning-tone status toast (used for usage hints on bad args)."""
|
|
89
|
+
return StatusMessage(kind="warning", text=text)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# ---------------------------------------------------------------------------
|
|
93
|
+
# Family sub-command dispatch (table, not ladder)
|
|
94
|
+
# ---------------------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class VerbSplit(NamedTuple):
|
|
98
|
+
"""The (verb, rest) pair :func:`split_verb` returns; unpacks in place."""
|
|
99
|
+
|
|
100
|
+
verb: str
|
|
101
|
+
rest: str
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
#: First-whitespace finder for the verb/rest split (TS ``trimmed.search(/\s/)``).
|
|
105
|
+
_WHITESPACE: Final = re.compile(r"\s")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def split_verb(args: str) -> VerbSplit:
|
|
109
|
+
"""Split a family command's argument string into a leading sub-command
|
|
110
|
+
verb and the remainder, both trimmed. An empty argument string yields an
|
|
111
|
+
empty verb so a family command can show its own usage when invoked bare.
|
|
112
|
+
|
|
113
|
+
:param args: the raw trailing argument string from ``SlashContext.args``
|
|
114
|
+
"""
|
|
115
|
+
trimmed = args.strip()
|
|
116
|
+
if len(trimmed) == 0:
|
|
117
|
+
return VerbSplit(verb="", rest="")
|
|
118
|
+
gap = _WHITESPACE.search(trimmed)
|
|
119
|
+
if gap is None:
|
|
120
|
+
return VerbSplit(verb=trimmed.lower(), rest="")
|
|
121
|
+
return VerbSplit(
|
|
122
|
+
verb=trimmed[: gap.start()].lower(),
|
|
123
|
+
rest=trimmed[gap.start() + 1 :].strip(),
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@dataclass(frozen=True, slots=True)
|
|
128
|
+
class SubCommand:
|
|
129
|
+
"""One sub-command of a command family — its verb, a one-line description
|
|
130
|
+
for the usage list, and the thin action it runs through the
|
|
131
|
+
:class:`~induscode.console_slash.contract.SlashContext`."""
|
|
132
|
+
|
|
133
|
+
# The sub-command verb, lower-cased (e.g. `add`, `list`, `clear`).
|
|
134
|
+
verb: str
|
|
135
|
+
# One-line description rendered in the family's usage toast.
|
|
136
|
+
describe: str
|
|
137
|
+
# Run the sub-command; `rest` is the argument tail after the verb.
|
|
138
|
+
run: Callable[[SlashContext, str], Awaitable[SlashOutcome]]
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def family_runner(family: str, subs: Sequence[SubCommand]) -> SlashRun:
|
|
142
|
+
"""Build a family command's ``run`` from a sub-command table.
|
|
143
|
+
|
|
144
|
+
The family handler is a single thin coroutine function whose branch
|
|
145
|
+
structure is *generated* from the table: it splits the verb, looks it up,
|
|
146
|
+
and awaits the matching action — falling back to a usage toast (assembled
|
|
147
|
+
from the table's descriptions) when the verb is missing or unknown.
|
|
148
|
+
|
|
149
|
+
:param family: the family label, used in the usage header
|
|
150
|
+
:param subs: the sub-command table for this family
|
|
151
|
+
"""
|
|
152
|
+
table = {sub.verb: sub for sub in subs}
|
|
153
|
+
|
|
154
|
+
def usage() -> StatusMessage:
|
|
155
|
+
return warn(
|
|
156
|
+
f"/{family} expects: "
|
|
157
|
+
+ ", ".join(f"{sub.verb} ({sub.describe})" for sub in subs)
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
async def run(ctx: SlashContext) -> SlashOutcome:
|
|
161
|
+
verb, rest = split_verb(ctx.args)
|
|
162
|
+
if len(verb) == 0:
|
|
163
|
+
ctx.set_status(usage())
|
|
164
|
+
return HANDLED
|
|
165
|
+
sub = table.get(verb)
|
|
166
|
+
if sub is None:
|
|
167
|
+
ctx.set_status(warn(f'/{family}: unrecognised action "{verb}".'))
|
|
168
|
+
ctx.set_status(usage())
|
|
169
|
+
return HANDLED
|
|
170
|
+
return await sub.run(ctx, rest)
|
|
171
|
+
|
|
172
|
+
return run
|