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,369 @@
1
+ """The invocation reader — a generic, table-driven argv parser over
2
+ :data:`~.flags.FLAG_SPECS`.
3
+
4
+ This is the application's full command-line grammar (the boot layer carries
5
+ only a thin routing parser). It walks a sliced ``argv`` once and folds every
6
+ token into a fully-typed :class:`~induscode.launch.contract.Invocation`:
7
+ recognised flags bind through the declarative flag table, ``@file`` references
8
+ collect for the attachment gatherer, and remaining positionals become the
9
+ prompt and the positional tail.
10
+
11
+ The grammar is deliberately textbook and entirely data-driven — there is no
12
+ per-flag branch. One index over the table maps every canonical name and alias
13
+ to its row; the loop applies whichever row a token resolves to:
14
+
15
+ - **``--name=value``** and **``--name value``** both bind a ``string`` /
16
+ ``number`` / ``list`` flag; an inline ``=value`` wins, otherwise the
17
+ following token is consumed (unless it is itself a flag, in which case the
18
+ value is empty).
19
+ - **longest-alias match** resolves the canonical name, so a longer spelling
20
+ is never shadowed by a shorter prefix (whole tokens match whole index keys).
21
+ - **``--``** terminates option parsing; every subsequent token is positional.
22
+ - **clustered short booleans** (``-pi``) expand to each single-letter switch.
23
+ - **``list`` flags accumulate** across repetition and split a single
24
+ comma-separated token into elements.
25
+ - **``@file``** tokens are recorded as attachment references rather than
26
+ positionals.
27
+
28
+ The parse is total: an unrecognised ``--flag`` is tolerated as a boolean
29
+ switch in the loose ``Invocation.flags`` bag (the extension-flag escape hatch)
30
+ rather than rejected, so nothing is silently dropped. Strong typing of the
31
+ named fields, and mode derivation, happen in one final fold.
32
+
33
+ Port note: the framework parser engine (``indusagi.shell_app.invocation``)
34
+ raises on unknown flags and has no list-kind / ``@file`` / cluster support, so
35
+ this table-driven reader is ported whole rather than silently adopting the
36
+ engine (analysis 04 risk-5).
37
+ """
38
+
39
+ from __future__ import annotations
40
+
41
+ import math
42
+ from dataclasses import dataclass, field
43
+ from typing import Final, Mapping, Sequence
44
+
45
+ from ..contract import (
46
+ FlagValue,
47
+ Invocation,
48
+ OutputMode,
49
+ ThinkingEffort,
50
+ ToolName,
51
+ is_thinking_effort,
52
+ is_tool_name,
53
+ )
54
+ from .flags import FLAG_SPECS, GroupedFlagSpec
55
+
56
+ __all__ = ["read_file_references", "read_invocation"]
57
+
58
+
59
+ def _is_flag_token(token: str) -> bool:
60
+ """Whether a token is a flag (``-x`` / ``--x``) rather than a positional
61
+ or the ``-`` stdin convention."""
62
+ return len(token) > 1 and token.startswith("-")
63
+
64
+
65
+ def _is_long_token(token: str) -> bool:
66
+ """Whether a token is a long flag (``--x``), eligible for inline
67
+ ``=value`` and the ``--`` terminator."""
68
+ return token.startswith("--")
69
+
70
+
71
+ def _is_short_cluster(token: str) -> bool:
72
+ """Whether a token is a clustered short switch run (``-pi``), excluding
73
+ ``-`` and ``--``."""
74
+ return len(token) > 1 and token[0] == "-" and token[1] != "-"
75
+
76
+
77
+ def _flag_key(name: str) -> str:
78
+ """Strip the leading dashes from a canonical flag name to get its
79
+ flag-bag key."""
80
+ return name.lstrip("-")
81
+
82
+
83
+ def _build_token_index() -> dict[str, GroupedFlagSpec]:
84
+ """Build the token index over the table: every canonical name and every
85
+ alias maps to its owning row. The longest-alias rule falls out of matching
86
+ whole tokens against whole keys (a longer spelling and a shorter one are
87
+ distinct keys, never prefixes of one lookup)."""
88
+ index: dict[str, GroupedFlagSpec] = {}
89
+ for spec in FLAG_SPECS:
90
+ index[spec.name] = spec
91
+ for alias in spec.aliases:
92
+ index[alias] = spec
93
+ return index
94
+
95
+
96
+ #: The single shared token index; the table is frozen, so one build suffices.
97
+ _TOKEN_INDEX: Final[dict[str, GroupedFlagSpec]] = _build_token_index()
98
+
99
+
100
+ def _build_short_boolean_set() -> set[str]:
101
+ """The set of single-letter aliases that name a boolean flag, for cluster
102
+ expansion."""
103
+ letters: set[str] = set()
104
+ for spec in FLAG_SPECS:
105
+ if spec.kind != "boolean":
106
+ continue
107
+ for alias in spec.aliases:
108
+ if len(alias) == 2 and alias[0] == "-" and alias[1] != "-":
109
+ letters.add(alias[1])
110
+ return letters
111
+
112
+
113
+ #: Single-letter boolean switches eligible to appear in a ``-pi`` cluster.
114
+ _SHORT_BOOLEANS: Final[set[str]] = _build_short_boolean_set()
115
+
116
+
117
+ def _split_inline(token: str) -> tuple[str, str | None]:
118
+ """Split a long token into its flag name and optional inline ``=value``."""
119
+ if _is_long_token(token):
120
+ eq = token.find("=")
121
+ if eq != -1:
122
+ return token[:eq], token[eq + 1 :]
123
+ return token, None
124
+
125
+
126
+ def _split_list(value: str) -> list[str]:
127
+ """Split a ``list`` flag value into its comma-separated elements, dropping
128
+ blanks."""
129
+ return [part.strip() for part in value.split(",") if part.strip()]
130
+
131
+
132
+ def _coerce_scalar(kind: str, raw: str) -> FlagValue:
133
+ """Coerce a raw value token to the runtime type its kind fixes. Numbers
134
+ parse with JS ``Number`` semantics: the empty string is ``0`` and an
135
+ unparseable number yields ``NaN``, which the named-field fold below treats
136
+ as absent."""
137
+ if kind == "number":
138
+ if raw.strip() == "":
139
+ return 0.0
140
+ try:
141
+ return float(raw)
142
+ except ValueError:
143
+ return math.nan
144
+ return raw
145
+
146
+
147
+ @dataclass(slots=True)
148
+ class _ParseState:
149
+ """The mutable accumulator threaded through the parse, before the typed
150
+ fold."""
151
+
152
+ # The loose flag bag, keyed by canonical flag name (dashes stripped).
153
+ flags: dict[str, FlagValue] = field(default_factory=dict)
154
+ # Positional tokens (after the prompt) and, with `--`, every later token.
155
+ positionals: list[str] = field(default_factory=list)
156
+ # `@file` references collected for the attachment gatherer.
157
+ file_refs: list[str] = field(default_factory=list)
158
+ # The first positional becomes the prompt; later ones go to positionals.
159
+ prompt: str | None = None
160
+
161
+
162
+ def _push_positional(state: _ParseState, token: str) -> None:
163
+ """Record a positional token: the first becomes the prompt, the rest
164
+ accumulate."""
165
+ if state.prompt is None:
166
+ state.prompt = token
167
+ else:
168
+ state.positionals.append(token)
169
+
170
+
171
+ def _accumulate_list(state: _ParseState, key: str, items: list[str]) -> None:
172
+ """Append elements to a ``list`` flag, preserving prior accumulation."""
173
+ prior = state.flags.get(key)
174
+ base = prior if isinstance(prior, list) else []
175
+ state.flags[key] = [*base, *items]
176
+
177
+
178
+ def _apply_flag(
179
+ spec: GroupedFlagSpec,
180
+ inline: str | None,
181
+ argv: Sequence[str],
182
+ i: int,
183
+ state: _ParseState,
184
+ ) -> int:
185
+ """Apply one resolved flag row at position ``i``, returning the index of
186
+ the last token it consumed. A value flag may consume the following token;
187
+ a boolean consumes only itself."""
188
+ key = _flag_key(spec.name)
189
+
190
+ if spec.kind == "boolean":
191
+ state.flags[key] = True
192
+ return i
193
+
194
+ # Value-bearing flag: take the inline `=value`, else the next non-flag
195
+ # token.
196
+ value = inline
197
+ consumed = i
198
+ if value is None and i + 1 < len(argv) and not _is_flag_token(argv[i + 1]):
199
+ value = argv[i + 1]
200
+ consumed = i + 1
201
+ raw = value if value is not None else ""
202
+
203
+ if spec.kind == "list":
204
+ _accumulate_list(state, key, _split_list(raw))
205
+ else:
206
+ state.flags[key] = _coerce_scalar(spec.kind, raw)
207
+ return consumed
208
+
209
+
210
+ def _apply_short_cluster(token: str, state: _ParseState) -> bool:
211
+ """Expand a clustered short-boolean token (``-pi``) into its component
212
+ switches, setting each in the flag bag. Returns False if any letter is not
213
+ a known short boolean, so the caller can fall back to treating the token
214
+ as an unknown flag."""
215
+ letters = list(token[1:])
216
+ if not all(letter in _SHORT_BOOLEANS for letter in letters):
217
+ return False
218
+ for letter in letters:
219
+ spec = _TOKEN_INDEX.get(f"-{letter}")
220
+ if spec is not None:
221
+ state.flags[_flag_key(spec.name)] = True
222
+ return True
223
+
224
+
225
+ def _read_string(flags: Mapping[str, FlagValue], key: str) -> str | None:
226
+ """Read a string flag-bag entry, or None when absent / non-string /
227
+ empty."""
228
+ value = flags.get(key)
229
+ return value if isinstance(value, str) and len(value) > 0 else None
230
+
231
+
232
+ def _read_list(flags: Mapping[str, FlagValue], key: str) -> list[str] | None:
233
+ """Read a list flag-bag entry as a string list, or None when absent."""
234
+ value = flags.get(key)
235
+ return value if isinstance(value, list) else None
236
+
237
+
238
+ def _read_bool(flags: Mapping[str, FlagValue], key: str) -> bool:
239
+ """Whether a boolean flag-bag entry is set."""
240
+ return flags.get(key) is True
241
+
242
+
243
+ def _derive_mode(flags: Mapping[str, FlagValue]) -> OutputMode:
244
+ """Derive the resolved :data:`OutputMode`. The headless line protocol
245
+ (``--json`` / ``--rpc``) wins over a one-shot print, which in turn implies
246
+ the non-interactive ``json`` result mode unless the interactive switch
247
+ overrides it; with neither, the mode is the interactive ``text``
248
+ session."""
249
+ if _read_bool(flags, "json"):
250
+ return "rpc"
251
+ if _read_bool(flags, "print") and not _read_bool(flags, "interactive"):
252
+ return "json"
253
+ return "text"
254
+
255
+
256
+ def _resolve_thinking(flags: Mapping[str, FlagValue]) -> ThinkingEffort | None:
257
+ """Resolve the requested reasoning effort, ignoring an unrecognised
258
+ value."""
259
+ raw = _read_string(flags, "thinking")
260
+ if raw is not None and is_thinking_effort(raw):
261
+ return raw # type: ignore[return-value] # narrowed by the guard
262
+ return None
263
+
264
+
265
+ def _resolve_tools(flags: Mapping[str, FlagValue]) -> tuple[ToolName, ...] | None:
266
+ """Resolve the explicit tool allow-list, keeping only recognised tool
267
+ names."""
268
+ raw = _read_list(flags, "tools")
269
+ if raw is None:
270
+ return None
271
+ return tuple(name for name in raw if is_tool_name(name)) # type: ignore[misc]
272
+
273
+
274
+ def read_invocation(argv: Sequence[str]) -> Invocation:
275
+ """Read a sliced ``argv`` into the fully-parsed
276
+ :class:`~induscode.launch.contract.Invocation`.
277
+
278
+ Walks the tokens once via the table index, accumulates into a parse state,
279
+ then folds that into the strongly-typed result: the loose ``flags`` bag is
280
+ retained for round-tripping and extension flags, and the named fields are
281
+ derived from it with per-kind coercion and validation. The ``attachments``
282
+ field is left unset here — the file references are surfaced for the
283
+ attachment gatherer to expand separately (:func:`read_file_references`).
284
+
285
+ :param argv: the already-sliced argument vector (no interpreter / script
286
+ path)
287
+ """
288
+ state = _ParseState()
289
+ options_terminated = False
290
+
291
+ i = 0
292
+ while i < len(argv):
293
+ token = argv[i]
294
+
295
+ if options_terminated:
296
+ _push_positional(state, token)
297
+ i += 1
298
+ continue
299
+
300
+ if token == "--":
301
+ options_terminated = True
302
+ i += 1
303
+ continue
304
+
305
+ if token.startswith("@") and len(token) > 1:
306
+ state.file_refs.append(token[1:])
307
+ i += 1
308
+ continue
309
+
310
+ if not _is_flag_token(token):
311
+ _push_positional(state, token)
312
+ i += 1
313
+ continue
314
+
315
+ name, inline = _split_inline(token)
316
+ spec = _TOKEN_INDEX.get(name)
317
+ if spec is not None:
318
+ i = _apply_flag(spec, inline, argv, i, state) + 1
319
+ continue
320
+
321
+ # Unrecognised short cluster: expand if every letter is a known
322
+ # switch.
323
+ if _is_short_cluster(token) and _apply_short_cluster(token, state):
324
+ i += 1
325
+ continue
326
+
327
+ # Unknown flag: tolerate as a boolean switch (the extension-flag
328
+ # hatch); an inline `=value` keeps its value.
329
+ state.flags[_flag_key(name)] = inline if inline is not None else True
330
+ i += 1
331
+
332
+ flags = state.flags
333
+ return Invocation(
334
+ mode=_derive_mode(flags),
335
+ prompt=state.prompt,
336
+ flags=flags,
337
+ positionals=tuple(state.positionals),
338
+ model=_read_string(flags, "model"),
339
+ account=_read_string(flags, "account"),
340
+ cwd=_read_string(flags, "cwd"),
341
+ system=_read_string(flags, "system"),
342
+ append_system=_read_string(flags, "append-system"),
343
+ thinking=_resolve_thinking(flags),
344
+ tools=_resolve_tools(flags),
345
+ no_tools=_read_bool(flags, "no-tools"),
346
+ mcp=tuple(_read_list(flags, "mcp") or []),
347
+ print=_read_bool(flags, "print"),
348
+ interactive=_read_bool(flags, "interactive"),
349
+ help=_read_bool(flags, "help"),
350
+ version=_read_bool(flags, "version"),
351
+ )
352
+
353
+
354
+ def read_file_references(argv: Sequence[str]) -> list[str]:
355
+ """The ``@file`` references collected from an argv, exposed for the
356
+ attachment gatherer. A thin re-walk of the reader so the file references
357
+ can be obtained without re-deriving the whole parse where only attachments
358
+ are needed."""
359
+ refs: list[str] = []
360
+ options_terminated = False
361
+ for token in argv:
362
+ if options_terminated:
363
+ continue
364
+ if token == "--":
365
+ options_terminated = True
366
+ continue
367
+ if token.startswith("@") and len(token) > 1:
368
+ refs.append(token[1:])
369
+ return refs
@@ -0,0 +1,110 @@
1
+ """The usage renderer — generated entirely from :data:`~.flags.FLAG_SPECS`.
2
+
3
+ :func:`render_usage` produces the ``--help`` banner by walking the same
4
+ declarative flag table the reader parses against. There is no second,
5
+ hand-maintained help string; every option line is synthesised from its row
6
+ (its canonical name, its aliases, its value placeholder, and its one-line
7
+ description), grouped by the editorial :data:`~.flags.FLAG_GROUPS` sections.
8
+ Adding a flag to the table therefore adds it to the help with no further
9
+ edit, and the two can never disagree about what exists.
10
+
11
+ The renderer is pure and total: it reads only the table and returns a string.
12
+
13
+ Port note: the synopsis names the Python console script (``pindus``); the TS
14
+ build printed its own bin name there. Everything else is the TS layout
15
+ verbatim (two-space indent, 28-column signature padding, the trailing
16
+ ``@file`` / ``--`` arguments note).
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from typing import Final
22
+
23
+ from .flags import FLAG_GROUPS, FLAG_SPECS, FlagGroup, GroupedFlagSpec
24
+
25
+ __all__ = ["render_usage"]
26
+
27
+
28
+ #: Two-space indent for option lines within a section.
29
+ _INDENT: Final[str] = " "
30
+
31
+ #: Minimum column width the description is padded out to, for alignment.
32
+ _SIGNATURE_COLUMN: Final[int] = 28
33
+
34
+
35
+ def _placeholder(spec: GroupedFlagSpec) -> str:
36
+ """The value placeholder shown after a value-bearing flag in the usage
37
+ text. Boolean switches take no placeholder; value, number, and list flags
38
+ each get a shape hint (a list shows the repeatable / comma form)."""
39
+ match spec.kind:
40
+ case "boolean":
41
+ return ""
42
+ case "number":
43
+ return " <n>"
44
+ case "list":
45
+ return " <a,b>"
46
+ case _: # "string" and anything future
47
+ return " <value>"
48
+
49
+
50
+ def _signature(spec: GroupedFlagSpec) -> str:
51
+ """Render the left "signature" column for a flag: its canonical name, then
52
+ any aliases in parentheses, then the value placeholder. e.g.
53
+ ``--print (-p)`` or ``--model (-m) <value>``."""
54
+ alias_part = f" ({', '.join(spec.aliases)})" if spec.aliases else ""
55
+ return f"{spec.name}{alias_part}{_placeholder(spec)}"
56
+
57
+
58
+ def _option_line(spec: GroupedFlagSpec) -> str:
59
+ """Render one option line: indented signature, padded, then its
60
+ description."""
61
+ sig = _signature(spec)
62
+ pad = " " * (_SIGNATURE_COLUMN - len(sig)) if len(sig) < _SIGNATURE_COLUMN else " "
63
+ return f"{_INDENT}{sig}{pad}{spec.describe}"
64
+
65
+
66
+ def _rows_for_group(group: FlagGroup) -> list[GroupedFlagSpec]:
67
+ """The flag rows filed under one group, in their table declaration
68
+ order."""
69
+ return [spec for spec in FLAG_SPECS if spec.group == group]
70
+
71
+
72
+ def render_usage() -> str:
73
+ """Render the full usage banner.
74
+
75
+ Emits a synopsis line, then one section per :data:`~.flags.FLAG_GROUPS`
76
+ entry that has any rows, then a short note on the two positional
77
+ conventions the reader honours but that are not flags: the ``@file``
78
+ attachment syntax and the ``--`` option terminator. Every option line is
79
+ derived from the table; this function holds no per-flag knowledge of its
80
+ own.
81
+
82
+ :returns: the complete multi-line usage string (no trailing newline)
83
+ """
84
+ lines: list[str] = []
85
+
86
+ lines.append("Usage: pindus [options] [prompt] [@file ...]")
87
+ lines.append("")
88
+ lines.append("A terminal-first AI coding agent.")
89
+
90
+ for group in FLAG_GROUPS:
91
+ rows = _rows_for_group(group.id)
92
+ if not rows:
93
+ continue
94
+ lines.append("")
95
+ lines.append(f"{group.title}:")
96
+ for spec in rows:
97
+ lines.append(_option_line(spec))
98
+
99
+ lines.append("")
100
+ lines.append("Arguments:")
101
+ lines.append(
102
+ f"{_INDENT}@file".ljust(_SIGNATURE_COLUMN + len(_INDENT))
103
+ + "Attach a text or image file to the first message."
104
+ )
105
+ lines.append(
106
+ f"{_INDENT}--".ljust(_SIGNATURE_COLUMN + len(_INDENT))
107
+ + "Stop parsing options; treat every later token as a positional."
108
+ )
109
+
110
+ return "\n".join(lines)