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