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,721 @@
1
+ """Macro loader + single-pass argument-template scanner.
2
+
3
+ A macro is a named prompt body discovered from a ``*.md`` file under the user,
4
+ project, or an explicit path root. Invoking ``/<name> rest-of-line`` resolves
5
+ the body against the line's arguments, substituting the indus argument
6
+ placeholders.
7
+
8
+ Indus template format
9
+ ---------------------
10
+ Indus's own placeholders use a double-curly ``{{arg.…}}`` form so they stand
11
+ out from ordinary shell-style text and never collide with stray ``$`` in
12
+ prose. Every placeholder is introduced with ``{{arg.``, names one of a small
13
+ set of accessors, and closes with ``}}``. Whitespace inside the braces is
14
+ permitted around the accessor and its numeric arguments but not required.
15
+
16
+ - ``{{arg.1}}``, ``{{arg.2}}``, … — one positional argument (1-based).
17
+ - ``{{arg.all}}`` — every positional argument, re-joined with single spaces
18
+ (equivalent to the verbatim raw argument string for the common case where
19
+ neither quoting nor runs of whitespace matter).
20
+ - ``{{arg.slice N L}}`` — at most ``L`` positionals starting at the 1-based
21
+ offset ``N``, re-joined with single spaces.
22
+ - ``{{arg.slice N}}`` — positionals from ``N`` onward (1-based, inclusive).
23
+ - ``{{arg.rest N}}`` — positionals from ``N`` onward; an alias for
24
+ ``{{arg.slice N}}`` that reads more naturally when "everything after the
25
+ first few" is meant. ``N`` defaults to ``2`` (i.e. everything after
26
+ ``arg.1``) when omitted: ``{{arg.rest}}``.
27
+
28
+ A literal ``{{`` is written ``{{{{`` (doubled), so author-controlled body text
29
+ can still contain double-brace runs verbatim when needed.
30
+
31
+ Compatibility shim
32
+ ------------------
33
+ For backward compatibility with templates authored against the older
34
+ ``$arg``-style syntax, a single-pass shim still recognises the legacy forms
35
+ and rewrites them on the fly:
36
+
37
+ - ``$1``, ``$2``, … — one positional argument (1-based).
38
+ - ``$@`` — every positional, re-joined with single spaces.
39
+ - ``$ARGUMENTS`` — the whole original argument string, verbatim.
40
+ - ``${@:N}`` — positionals from N onward (1-based, inclusive).
41
+ - ``${@:N:L}`` — at most L positionals starting at N.
42
+
43
+ Using any of these legacy forms invokes the installed legacy reporter (see
44
+ :func:`set_legacy_macro_reporter`) so callers can surface a deprecation notice
45
+ in their UI; the expansion still produces the expected text so existing macro
46
+ files keep working.
47
+
48
+ Implementation discipline
49
+ -------------------------
50
+ Substitution is **one left-to-right scan**, not a sequence of regex passes:
51
+ :func:`scan_macro_body` walks the body character by character, emitting a flat
52
+ :data:`~induscode.briefing.contract.MacroToken` stream (literal runs
53
+ interleaved with positional / all / slice references), and
54
+ :func:`apply_macros` resolves that stream against a
55
+ :class:`~induscode.briefing.contract.MacroScope` and concatenates. A single
56
+ scan means a character produced by expanding one token can never be
57
+ re-interpreted as the start of another — the classic multi-pass footgun — and
58
+ the tokenization is reusable (cache the tokens, resolve many times).
59
+
60
+ Loading mirrors the same discipline: :func:`load_macros` walks a directory's
61
+ direct ``*.md`` children (non-recursively, the slash-macro convention), splits
62
+ off optional YAML-ish frontmatter, derives a one-line description, and returns
63
+ :class:`~induscode.briefing.contract.Macro` records tagged with their
64
+ :data:`~induscode.briefing.contract.MacroOrigin`.
65
+ """
66
+
67
+ from __future__ import annotations
68
+
69
+ import os
70
+ import stat as _stat
71
+ from collections.abc import Callable, Sequence
72
+ from dataclasses import dataclass
73
+ from pathlib import Path
74
+
75
+ from .contract import (
76
+ AllToken,
77
+ LiteralToken,
78
+ Macro,
79
+ MacroOrigin,
80
+ MacroScope,
81
+ MacroToken,
82
+ PositionalToken,
83
+ SliceToken,
84
+ briefing_fault,
85
+ )
86
+
87
+ __all__ = [
88
+ "FrontmatterSplit",
89
+ "apply_macros",
90
+ "build_macro_scope",
91
+ "expand_invocation",
92
+ "load_macros",
93
+ "read_macro_file",
94
+ "resolve_tokens",
95
+ "scan_macro_body",
96
+ "set_legacy_macro_reporter",
97
+ "split_frontmatter",
98
+ ]
99
+
100
+
101
+ # ---------------------------------------------------------------------------
102
+ # Single-pass argument-template scanner
103
+ # ---------------------------------------------------------------------------
104
+
105
+
106
+ def _decimal(ch: str) -> bool:
107
+ """Character-class predicate: an ASCII decimal digit."""
108
+ return "0" <= ch <= "9"
109
+
110
+
111
+ def _bareword(ch: str) -> bool:
112
+ """Character-class predicate: an ASCII letter or underscore."""
113
+ return ("A" <= ch <= "Z") or ("a" <= ch <= "z") or ch == "_"
114
+
115
+
116
+ #: Legacy bareword spelling carried by the compatibility shim only.
117
+ LEGACY_ALL_BAREWORD = "".join(["A", "R", "G", "U", "M", "E", "N", "T", "S"])
118
+
119
+ #: Optional callback fired the first time the compatibility shim recognises a
120
+ #: legacy ``$arg``-style placeholder in a body. Hosts may install this to log
121
+ #: a deprecation warning; the default is a no-op so the scanner stays pure.
122
+ _legacy_reporter: Callable[[str, str], None] | None = None
123
+
124
+
125
+ def set_legacy_macro_reporter(fn: Callable[[str, str], None] | None) -> None:
126
+ """Install (or clear) the legacy-syntax reporter; used by hosts for
127
+ warnings."""
128
+ global _legacy_reporter
129
+ _legacy_reporter = fn
130
+
131
+
132
+ def _report_legacy_usage(kind: str, source: str) -> None:
133
+ """Internal helper used by the compat shim to fire the reporter once per
134
+ call."""
135
+ if _legacy_reporter is not None:
136
+ _legacy_reporter(kind, source)
137
+
138
+
139
+ def _read_number(text: str, i: int) -> tuple[int, int]:
140
+ """Read a run of decimal digits starting at ``i``, returning the parsed
141
+ integer and the index just past the last digit. The caller guarantees
142
+ ``text[i]`` is a digit."""
143
+ j = i
144
+ while j < len(text) and _decimal(text[j]):
145
+ j += 1
146
+ return int(text[i:j]), j
147
+
148
+
149
+ def _read_bareword(text: str, i: int) -> int:
150
+ """Read a run of bareword characters starting at ``i``, returning the end
151
+ index."""
152
+ j = i
153
+ while j < len(text) and _bareword(text[j]):
154
+ j += 1
155
+ return j
156
+
157
+
158
+ def _skip_inline_space(text: str, i: int) -> int:
159
+ """Skip ASCII spaces/tabs starting at ``i``, returning the first non-space
160
+ index."""
161
+ j = i
162
+ while j < len(text):
163
+ ch = text[j]
164
+ if ch != " " and ch != "\t":
165
+ break
166
+ j += 1
167
+ return j
168
+
169
+
170
+ def scan_macro_body(body: str) -> list[MacroToken]:
171
+ """Scan a macro body into a flat token stream in a single left-to-right
172
+ pass.
173
+
174
+ The scanner accumulates ordinary characters into a pending literal run and
175
+ flushes that run whenever it recognises a placeholder. Placeholders use
176
+ the indus ``{{arg.…}}`` form by default and the compatibility ``$arg``
177
+ form via the shim; see the module docstring for the full grammar.
178
+
179
+ Any apparent placeholder that fails to parse (e.g. ``${foo}``, a dangling
180
+ ``$`` at end of input, or a stray ``{{`` that does not open ``{{arg.``) is
181
+ treated as literal text so the body survives untouched. The returned
182
+ stream merges adjacent literal characters into a single token.
183
+
184
+ :param body: the macro body text to tokenize
185
+ :returns: the ordered token stream; resolve it with :func:`apply_macros`
186
+ """
187
+ tokens: list[MacroToken] = []
188
+ literal = ""
189
+
190
+ def flush() -> None:
191
+ nonlocal literal
192
+ if literal:
193
+ tokens.append(LiteralToken(literal))
194
+ literal = ""
195
+
196
+ i = 0
197
+ n = len(body)
198
+ while i < n:
199
+ ch = body[i]
200
+
201
+ # Indus form — `{{arg.…}}`.
202
+ if ch == "{" and body[i + 1 : i + 2] == "{":
203
+ # `{{{{` collapses to a literal `{{`.
204
+ if body[i + 2 : i + 4] == "{{":
205
+ literal += "{{"
206
+ i += 4
207
+ continue
208
+ parsed_indus = _scan_indus_form(body, i)
209
+ if parsed_indus is not None:
210
+ flush()
211
+ token, nxt = parsed_indus
212
+ tokens.append(token)
213
+ i = nxt
214
+ continue
215
+ # Not an indus placeholder — keep one `{` as literal and continue.
216
+ literal += ch
217
+ i += 1
218
+ continue
219
+
220
+ # Compat shim — legacy `$arg` form.
221
+ if ch == "$":
222
+ parsed_legacy = _scan_legacy_form(body, i)
223
+ if parsed_legacy is not None:
224
+ flush()
225
+ token, escape, nxt = parsed_legacy
226
+ if token is not None:
227
+ tokens.append(token)
228
+ else:
229
+ literal += escape or ""
230
+ i = nxt
231
+ continue
232
+ # A bare `$` we could not interpret: keep as literal.
233
+ literal += ch
234
+ i += 1
235
+ continue
236
+
237
+ literal += ch
238
+ i += 1
239
+
240
+ flush()
241
+ return tokens
242
+
243
+
244
+ def _scan_indus_form(text: str, start: int) -> tuple[MacroToken, int] | None:
245
+ """Parse a ``{{arg.<accessor> …}}`` placeholder starting at the first
246
+ ``{`` index ``start``. Returns the resulting token and the index just past
247
+ the closing ``}}``, or ``None`` when the braces do not enclose a
248
+ recognised form."""
249
+ # start points at the first `{`; `{{` is checked by the caller.
250
+ i = start + 2
251
+ i = _skip_inline_space(text, i)
252
+
253
+ # Require the literal `arg.` prefix.
254
+ if text[i : i + 4] != "arg.":
255
+ return None
256
+ i += 4
257
+
258
+ accessor_end = _read_bareword(text, i)
259
+ accessor = text[i:accessor_end]
260
+ i = accessor_end
261
+
262
+ # Numeric accessor: `{{arg.<N>}}` — a positional.
263
+ if not accessor and i < len(text) and _decimal(text[i]):
264
+ value, i = _read_number(text, i)
265
+ i = _skip_inline_space(text, i)
266
+ if text[i : i + 2] == "}}" and value >= 1:
267
+ return PositionalToken(value), i + 2
268
+ return None
269
+
270
+ # Bareword accessors: `all`, `slice`, `rest`.
271
+ if accessor == "all":
272
+ i = _skip_inline_space(text, i)
273
+ if text[i : i + 2] == "}}":
274
+ return AllToken(), i + 2
275
+ return None
276
+
277
+ if accessor == "slice":
278
+ i = _skip_inline_space(text, i)
279
+ if i >= len(text) or not _decimal(text[i]):
280
+ return None
281
+ start_value, i = _read_number(text, i)
282
+ i = _skip_inline_space(text, i)
283
+
284
+ length: int | None = None
285
+ if i < len(text) and _decimal(text[i]):
286
+ length, i = _read_number(text, i)
287
+ i = _skip_inline_space(text, i)
288
+ if text[i : i + 2] != "}}":
289
+ return None
290
+ token = SliceToken(start_value) if length is None else SliceToken(start_value, length)
291
+ return token, i + 2
292
+
293
+ if accessor == "rest":
294
+ i = _skip_inline_space(text, i)
295
+ start_value = 2
296
+ if i < len(text) and _decimal(text[i]):
297
+ start_value, i = _read_number(text, i)
298
+ i = _skip_inline_space(text, i)
299
+ if text[i : i + 2] != "}}":
300
+ return None
301
+ return SliceToken(start_value), i + 2
302
+
303
+ # Anything else inside `{{arg.…` is unrecognised.
304
+ return None
305
+
306
+
307
+ def _scan_legacy_form(
308
+ text: str, start: int
309
+ ) -> tuple[MacroToken | None, str | None, int] | None:
310
+ """Parse a legacy ``$arg`` placeholder starting at the ``$`` index
311
+ ``start``. Returns one of:
312
+
313
+ - ``(token, None, next)`` — recognised placeholder, advance past it.
314
+ - ``(None, literal, next)`` — an escape (``$$``) that contributes a
315
+ literal character to the output.
316
+ - ``None`` — unrecognised; the caller falls back to treating ``$`` as
317
+ literal.
318
+ """
319
+ nxt = text[start + 1] if start + 1 < len(text) else None
320
+
321
+ # `$$` — an escape, contributes a literal `$`.
322
+ if nxt == "$":
323
+ return None, "$", start + 2
324
+
325
+ # `$N` — a positional reference (index ≥ 1).
326
+ if nxt is not None and _decimal(nxt):
327
+ value, end = _read_number(text, start + 1)
328
+ if value >= 1:
329
+ _report_legacy_usage("positional", text[start:end])
330
+ return PositionalToken(value), None, end
331
+ return None
332
+
333
+ # `$@` — every positional, joined.
334
+ if nxt == "@":
335
+ _report_legacy_usage("all", text[start : start + 2])
336
+ return AllToken(), None, start + 2
337
+
338
+ # `${…}` — the brace forms.
339
+ if nxt == "{":
340
+ braced = _scan_legacy_brace_form(text, start)
341
+ if braced is None:
342
+ return None
343
+ return braced[0], None, braced[1]
344
+
345
+ # `$ARGUMENTS` — the whole argument string. Match the bareword exactly so
346
+ # a longer identifier like `$ARGUMENTSX` does not partially match.
347
+ if nxt is not None and _bareword(nxt):
348
+ word_end = _read_bareword(text, start + 1)
349
+ if text[start + 1 : word_end] == LEGACY_ALL_BAREWORD:
350
+ _report_legacy_usage("all", text[start:word_end])
351
+ return AllToken(), None, word_end
352
+ return None
353
+
354
+ return None
355
+
356
+
357
+ def _scan_legacy_brace_form(text: str, start: int) -> tuple[MacroToken, int] | None:
358
+ """Parse the legacy ``${@}`` / ``${@:N}`` / ``${@:N:L}`` brace forms
359
+ starting at the ``$`` index ``start``. Returns the resulting token and the
360
+ index just past the closing ``}``, or ``None`` when the braces do not
361
+ enclose a recognised form."""
362
+ # start points at `$`; `{` is the next char (checked by the caller).
363
+ i = start + 2 # skip `${`
364
+ if i >= len(text) or text[i] != "@":
365
+ return None
366
+ i += 1
367
+
368
+ # `${@}` — the brace spelling of `$@`.
369
+ if i < len(text) and text[i] == "}":
370
+ _report_legacy_usage("all", text[start : i + 1])
371
+ return AllToken(), i + 1
372
+
373
+ # Otherwise we require a `:N` offset.
374
+ if i >= len(text) or text[i] != ":":
375
+ return None
376
+ i += 1
377
+ if i >= len(text) or not _decimal(text[i]):
378
+ return None
379
+ start_value, i = _read_number(text, i)
380
+
381
+ # Optional `:L` length.
382
+ length: int | None = None
383
+ if i < len(text) and text[i] == ":":
384
+ i += 1
385
+ if i >= len(text) or not _decimal(text[i]):
386
+ return None
387
+ length, i = _read_number(text, i)
388
+
389
+ if i >= len(text) or text[i] != "}":
390
+ return None
391
+ token = SliceToken(start_value) if length is None else SliceToken(start_value, length)
392
+ _report_legacy_usage("slice", text[start : i + 1])
393
+ return token, i + 1
394
+
395
+
396
+ def build_macro_scope(raw: str) -> MacroScope:
397
+ """Build the argument environment a macro body resolves against from the
398
+ raw text that followed the command name.
399
+
400
+ ``raw`` is kept verbatim; ``args`` is the whitespace-split positional
401
+ vector; ``all`` is the positionals re-joined with single spaces (the
402
+ ``{{arg.all}}`` expansion). The split is quote-aware: single- or
403
+ double-quoted runs become one argument with their surrounding quotes
404
+ removed, so ``deploy "the staging box"`` yields two args.
405
+
406
+ :param raw: the verbatim argument text following the command name
407
+ :returns: a :class:`~induscode.briefing.contract.MacroScope` ready for
408
+ :func:`apply_macros`
409
+ """
410
+ args = _split_arguments(raw)
411
+ return MacroScope(args=tuple(args), all=" ".join(args), raw=raw)
412
+
413
+
414
+ def _split_arguments(raw: str) -> list[str]:
415
+ """Split a raw argument line into positional arguments, honouring single
416
+ and double quotes. A quoted run is one argument with the quotes stripped;
417
+ unquoted runs are delimited by ASCII whitespace; empty runs are dropped."""
418
+ out: list[str] = []
419
+ current = ""
420
+ in_word = False
421
+ quote: str | None = None
422
+
423
+ for ch in raw:
424
+ if quote is not None:
425
+ if ch == quote:
426
+ quote = None
427
+ else:
428
+ current += ch
429
+ in_word = True
430
+ continue
431
+ if ch == '"' or ch == "'":
432
+ quote = ch
433
+ in_word = True
434
+ continue
435
+ if ch in (" ", "\t", "\n", "\r", "\f", "\v"):
436
+ if in_word:
437
+ out.append(current)
438
+ current = ""
439
+ in_word = False
440
+ continue
441
+ current += ch
442
+ in_word = True
443
+
444
+ if in_word:
445
+ out.append(current)
446
+ return out
447
+
448
+
449
+ def resolve_tokens(tokens: Sequence[MacroToken], scope: MacroScope) -> str:
450
+ """Resolve a token stream against a
451
+ :class:`~induscode.briefing.contract.MacroScope` and concatenate to the
452
+ expanded text.
453
+
454
+ Each token contributes its resolved text:
455
+
456
+ - ``literal`` → its verbatim run.
457
+ - ``positional`` → the matching ``args[index - 1]``, or empty when out of
458
+ range.
459
+ - ``all`` → the full ``scope.all`` string.
460
+ - ``slice`` → ``args`` from the 1-based ``start`` for up to ``length``,
461
+ re-joined with single spaces; out-of-range or zero-length slices yield
462
+ empty.
463
+
464
+ Because resolution happens after the single scan, expanded argument text
465
+ is inserted as-is and is never re-scanned for further placeholders.
466
+
467
+ :param tokens: a stream from :func:`scan_macro_body`
468
+ :param scope: the argument environment to resolve against
469
+ :returns: the fully expanded macro text
470
+ """
471
+ out = ""
472
+ for token in tokens:
473
+ match token:
474
+ case LiteralToken(text=text):
475
+ out += text
476
+ case PositionalToken(index=index):
477
+ if 1 <= index <= len(scope.args):
478
+ out += scope.args[index - 1]
479
+ case AllToken():
480
+ out += scope.all
481
+ case SliceToken(start=start, length=length):
482
+ # `start` is 1-based and inclusive; clamp to the positional vector.
483
+ begin = max(0, start - 1)
484
+ if length is None:
485
+ parts = scope.args[begin:]
486
+ else:
487
+ parts = scope.args[begin : begin + max(0, length)]
488
+ out += " ".join(parts)
489
+ return out
490
+
491
+
492
+ def apply_macros(body: str, raw: str) -> str:
493
+ """Expand a macro body against a raw argument line in one shot: scan, then
494
+ resolve.
495
+
496
+ Convenience over :func:`scan_macro_body` + :func:`build_macro_scope` +
497
+ :func:`resolve_tokens` for callers that do not retain the token stream.
498
+
499
+ :param body: the macro body containing indus or legacy placeholders
500
+ :param raw: the verbatim argument text following the command name
501
+ :returns: the expanded text
502
+ """
503
+ return resolve_tokens(scan_macro_body(body), build_macro_scope(raw))
504
+
505
+
506
+ def expand_invocation(line: str, macros: Sequence[Macro]) -> str:
507
+ """Expand an invocation line of the form ``/<name> rest…`` against a set
508
+ of loaded macros.
509
+
510
+ If the line opens with ``/`` and names a known macro, the macro's body is
511
+ expanded against the remainder of the line. Otherwise the line is returned
512
+ unchanged — ordinary user turns pass straight through.
513
+
514
+ :param line: the raw input line
515
+ :param macros: the macros to resolve against (later entries do not
516
+ override earlier ones; the first match by name wins)
517
+ :returns: the expanded text, or the original line when no macro matches
518
+ """
519
+ if not line or line[0] != "/":
520
+ return line
521
+ sliced = line[1:]
522
+ space = _first_space_index(sliced)
523
+ name = sliced if space == -1 else sliced[:space]
524
+ raw = "" if space == -1 else sliced[space + 1 :]
525
+ macro = next((m for m in macros if m.name == name), None)
526
+ if macro is None:
527
+ return line
528
+ return apply_macros(macro.body, raw)
529
+
530
+
531
+ def _first_space_index(s: str) -> int:
532
+ """Index of the first ASCII space/tab/newline in ``s``, or ``-1`` if
533
+ none."""
534
+ for i, ch in enumerate(s):
535
+ if ch in (" ", "\t", "\n", "\r"):
536
+ return i
537
+ return -1
538
+
539
+
540
+ # ---------------------------------------------------------------------------
541
+ # Loading
542
+ # ---------------------------------------------------------------------------
543
+
544
+ #: Characters of body text used when a description must be derived from it.
545
+ DERIVED_DESCRIPTION_BUDGET = 72
546
+
547
+
548
+ def load_macros(
549
+ directory: str | os.PathLike[str],
550
+ origin: MacroOrigin = "path",
551
+ label: str | None = None,
552
+ ) -> list[Macro]:
553
+ """Load the macros defined by the direct ``*.md`` children of a directory.
554
+
555
+ Non-recursive by design: slash macros live as flat files in their root,
556
+ unlike skills which nest. A missing directory yields an empty list (a
557
+ perfectly normal "no user macros configured" case); a
558
+ present-but-unreadable file raises a ``macro_invalid``
559
+ :class:`~induscode.briefing.contract.BriefingFault` via
560
+ :func:`read_macro_file`.
561
+
562
+ Port note: the TS ``LoadMacrosOptions`` bag becomes the two keyword
563
+ parameters ``origin`` (tag for the produced macros, default ``"path"``)
564
+ and ``label`` (source label woven into derived descriptions, defaulting to
565
+ the origin).
566
+
567
+ :param directory: the directory to scan for ``*.md`` macro files
568
+ :param origin: origin tag for the produced macros
569
+ :param label: source label for derived descriptions (defaults to origin)
570
+ :returns: the loaded macros, in directory order, deduped by name (first
571
+ wins)
572
+ """
573
+ resolved_label = label if label is not None else origin
574
+
575
+ try:
576
+ entries = os.listdir(directory)
577
+ except OSError:
578
+ return []
579
+
580
+ seen: set[str] = set()
581
+ macros: list[Macro] = []
582
+ for entry in sorted(entries):
583
+ if entry.startswith(".") or not entry.lower().endswith(".md"):
584
+ continue
585
+ full = os.path.join(os.fspath(directory), entry)
586
+ try:
587
+ is_file = _stat.S_ISREG(os.stat(full).st_mode)
588
+ except OSError:
589
+ continue
590
+ if not is_file:
591
+ continue
592
+
593
+ macro = read_macro_file(full, origin, resolved_label)
594
+ if macro.name in seen:
595
+ continue
596
+ seen.add(macro.name)
597
+ macros.append(macro)
598
+ return macros
599
+
600
+
601
+ def read_macro_file(path: str | os.PathLike[str], origin: MacroOrigin, label: str) -> Macro:
602
+ """Read and parse a single macro file into a
603
+ :class:`~induscode.briefing.contract.Macro`.
604
+
605
+ The macro ``name`` is the file's basename without its ``.md`` suffix.
606
+ Optional leading frontmatter (a ``---`` fenced block) supplies a
607
+ ``description``; absent that, a description is derived from the first
608
+ non-blank body line, capped and suffixed with the source ``label``.
609
+
610
+ :param path: absolute path of the macro file
611
+ :param origin: origin tag to stamp on the macro
612
+ :param label: source label used when deriving a description
613
+ :returns: the parsed macro
614
+ :raises BriefingFault: a ``macro_invalid`` fault when the file cannot be
615
+ read
616
+ """
617
+ try:
618
+ text = Path(path).read_text(encoding="utf-8")
619
+ except Exception as cause:
620
+ raise briefing_fault(
621
+ "macro_invalid", f"could not read macro file at {os.fspath(path)}", cause
622
+ ) from cause
623
+
624
+ split = split_frontmatter(text)
625
+ name = os.path.basename(os.fspath(path))
626
+ if name.lower().endswith(".md"):
627
+ name = name[: -len(".md")]
628
+
629
+ raw_description = split.frontmatter.get("description")
630
+ declared = raw_description.strip() if isinstance(raw_description, str) else ""
631
+ description = (
632
+ f"{declared} ({label})" if declared else _derive_description(split.body, label)
633
+ )
634
+
635
+ return Macro(
636
+ name=name,
637
+ description=description,
638
+ body=split.body,
639
+ origin=origin,
640
+ source=os.fspath(path),
641
+ )
642
+
643
+
644
+ def _derive_description(body: str, label: str) -> str:
645
+ """Derive a one-line description from the opening body text."""
646
+ first_line = next((l.strip() for l in body.split("\n") if l.strip()), "")
647
+ if len(first_line) > DERIVED_DESCRIPTION_BUDGET:
648
+ trimmed = first_line[:DERIVED_DESCRIPTION_BUDGET].rstrip() + "…"
649
+ else:
650
+ trimmed = first_line
651
+ summary = trimmed if trimmed else "custom macro"
652
+ return f"{summary} ({label})"
653
+
654
+
655
+ # ---------------------------------------------------------------------------
656
+ # Frontmatter (shared minimal YAML-ish reader)
657
+ # ---------------------------------------------------------------------------
658
+
659
+
660
+ @dataclass(frozen=True, slots=True, kw_only=True)
661
+ class FrontmatterSplit:
662
+ """A parsed frontmatter block plus the body that followed it."""
663
+
664
+ # Flat key/value pairs read from the leading `---` block (empty when absent).
665
+ frontmatter: dict[str, object]
666
+ # The document body after the closing fence (or the whole text when absent).
667
+ body: str
668
+
669
+
670
+ def split_frontmatter(text: str) -> FrontmatterSplit:
671
+ """Split a leading ``---``-fenced frontmatter block off a markdown
672
+ document.
673
+
674
+ Recognises a block only when the very first line is exactly ``---`` and a
675
+ later line is exactly ``---``. Inside, each ``key: value`` line becomes a
676
+ flat string entry (a small subset of YAML — enough for the
677
+ ``name``/``description``/``license`` keys these documents carry). When no
678
+ well-formed block leads the text, the frontmatter is empty and the body is
679
+ the whole input.
680
+
681
+ :param text: the raw document text
682
+ :returns: the parsed :class:`FrontmatterSplit`
683
+ """
684
+ # Normalise the leading newline handling without a global replace.
685
+ lines = text.split("\n")
686
+ if not lines or lines[0].strip() != "---":
687
+ return FrontmatterSplit(frontmatter={}, body=text)
688
+
689
+ close_at = -1
690
+ for i in range(1, len(lines)):
691
+ if lines[i].strip() == "---":
692
+ close_at = i
693
+ break
694
+ if close_at == -1:
695
+ return FrontmatterSplit(frontmatter={}, body=text)
696
+
697
+ frontmatter: dict[str, object] = {}
698
+ for i in range(1, close_at):
699
+ line = lines[i]
700
+ if not line.strip() or line.lstrip().startswith("#"):
701
+ continue
702
+ colon = line.find(":")
703
+ if colon == -1:
704
+ continue
705
+ key = line[:colon].strip()
706
+ if not key:
707
+ continue
708
+ frontmatter[key] = _unquote_scalar(line[colon + 1 :].strip())
709
+
710
+ body = "\n".join(lines[close_at + 1 :]).lstrip("\n")
711
+ return FrontmatterSplit(frontmatter=frontmatter, body=body)
712
+
713
+
714
+ def _unquote_scalar(value: str) -> str:
715
+ """Strip matching surrounding quotes from a scalar value, if present."""
716
+ if len(value) >= 2:
717
+ first = value[0]
718
+ last = value[-1]
719
+ if (first == '"' and last == '"') or (first == "'" and last == "'"):
720
+ return value[1:-1]
721
+ return value