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,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