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,254 @@
1
+ """The built-in slash catalog — the single ordered source of truth.
2
+
3
+ Port of TS ``src/console/slash/builtins.ts``. The catalog is assembled from
4
+ three static topic groups, each its own module in this package: transcript /
5
+ session control, the workbench pickers, and the integration bridges. On top
6
+ of those it splices in the *dynamic* rows — one ``/skill:<name>`` per
7
+ discovered Agent-Skills capability card and one ``/<template-name>`` per
8
+ discovered prompt-template macro — produced by
9
+ :func:`~induscode.console.slash_commands.dynamic.build_dynamic_commands`.
10
+
11
+ There is no command ``if``-ladder anywhere: adding a static command is
12
+ appending a row to one of the groups, and the dynamic rows are discovered
13
+ from the standard skill/macro roots (returning nothing, never an error, when
14
+ those roots are absent). The registry index, the completion matcher, and the
15
+ family grouping are all *derived* from the assembled catalog (see
16
+ :mod:`induscode.console_slash.registry`).
17
+
18
+ Listing order is intentional: transcript controls first (the verbs a new
19
+ user meets), then the workbench pickers, then the integration bridges, and
20
+ finally the discovered skills and templates.
21
+
22
+ Port deltas, locked by the plan (cross-cutting rule 4 / analysis 03 §6.5):
23
+
24
+ - **Assembly is the explicit** :func:`build_catalog` ``(cwd, home)``
25
+ function — the TS module ran discovery at import time; import-time fs
26
+ scans are banned here, and the explicit roots make the discovery testable
27
+ against a temp tree.
28
+ - **The defaults are lazy.** ``SLASH_COMMANDS`` and
29
+ ``DEFAULT_SLASH_REGISTRY`` remain importable for parity, but they are
30
+ minted on first attribute access via module ``__getattr__`` (and cached so
31
+ the TS identity pins — ``DEFAULT_SLASH_REGISTRY.commands is
32
+ SLASH_COMMANDS`` — keep holding). :func:`reset_default_registry` drops the
33
+ cache for tests.
34
+ - **Per-console bridge state**: the integrations group is minted against an
35
+ :class:`~induscode.console.slash_commands.integrations.IntegrationsRuntime`
36
+ holder per :func:`build_catalog` call, replacing the TS module globals.
37
+ - Building the default registry also installs it as the ``/help`` catalog
38
+ provider (the late-bound provider that replaced the TS dynamic-import
39
+ cycle-breaker — see :mod:`.workbench`), so ``/help`` lists the dynamic
40
+ rows exactly as the TS ``SLASH_COMMANDS`` listing did.
41
+ """
42
+
43
+ from __future__ import annotations
44
+
45
+ import os
46
+ from dataclasses import dataclass
47
+ from typing import Final
48
+
49
+ from induscode.briefing import Macro, MacroOrigin, SkillRoot, gather_skill_cards, load_macros
50
+ from induscode.console_slash import SlashCommand, SlashRegistry, build_registry
51
+
52
+ from .dynamic import DynamicCommandSources, build_dynamic_commands
53
+ from .integrations import IntegrationsRuntime, build_integration_commands
54
+ from .transcript import transcript_commands
55
+ from .workbench import set_help_registry_provider, workbench_commands
56
+
57
+ __all__ = [
58
+ "DEFAULT_SLASH_REGISTRY",
59
+ "DynamicRoots",
60
+ "MacroRoot",
61
+ "PROJECT_DIR",
62
+ "SLASH_COMMANDS",
63
+ "build_catalog",
64
+ "build_default_registry",
65
+ "discover_dynamic_sources",
66
+ "dynamic_roots",
67
+ "reset_default_registry",
68
+ ]
69
+
70
+
71
+ # ---------------------------------------------------------------------------
72
+ # Dynamic discovery
73
+ # ---------------------------------------------------------------------------
74
+
75
+ #: The per-project settings directory dynamic roots are resolved under.
76
+ PROJECT_DIR: Final = ".indusagi"
77
+
78
+
79
+ @dataclass(frozen=True, slots=True)
80
+ class MacroRoot:
81
+ """One prompt-template root to scan, with its origin tag and the label
82
+ woven into derived descriptions."""
83
+
84
+ dir: str
85
+ origin: MacroOrigin
86
+ label: str
87
+
88
+
89
+ @dataclass(frozen=True, slots=True)
90
+ class DynamicRoots:
91
+ """The skill and macro roots to scan, in precedence order."""
92
+
93
+ skills: tuple[SkillRoot, ...]
94
+ macros: tuple[MacroRoot, ...]
95
+
96
+
97
+ def dynamic_roots(cwd: str, home: str) -> DynamicRoots:
98
+ """The skill and macro roots to scan, in precedence order (project
99
+ before user so a project-local card or template shadows a user-global
100
+ one of the same name). Every root is optional; a missing directory
101
+ simply yields nothing.
102
+
103
+ :param cwd: the workspace directory (project roots resolve under it)
104
+ :param home: the user's home directory (user roots resolve under it)
105
+ """
106
+ return DynamicRoots(
107
+ skills=(
108
+ SkillRoot(dir=os.path.join(cwd, PROJECT_DIR, "skills"), origin="project"),
109
+ SkillRoot(dir=os.path.join(home, PROJECT_DIR, "skills"), origin="user"),
110
+ ),
111
+ macros=(
112
+ MacroRoot(
113
+ dir=os.path.join(cwd, PROJECT_DIR, "commands"),
114
+ origin="project",
115
+ label="project",
116
+ ),
117
+ MacroRoot(
118
+ dir=os.path.join(home, PROJECT_DIR, "commands"),
119
+ origin="user",
120
+ label="user",
121
+ ),
122
+ ),
123
+ )
124
+
125
+
126
+ def discover_dynamic_sources(cwd: str, home: str) -> DynamicCommandSources:
127
+ """Discover the skill cards and prompt-template macros available on disk.
128
+
129
+ Never throws: the underlying loaders treat a missing or unreadable root
130
+ as an empty result, so this returns whatever was found and an empty set
131
+ otherwise. Templates are deduped by name across roots (first root wins —
132
+ project shadows user).
133
+
134
+ :param cwd: the workspace directory
135
+ :param home: the user's home directory
136
+ """
137
+ roots = dynamic_roots(cwd, home)
138
+
139
+ try:
140
+ skills = tuple(gather_skill_cards(roots.skills).cards)
141
+ except Exception:
142
+ skills = ()
143
+
144
+ templates: list[Macro] = []
145
+ claimed: set[str] = set()
146
+ for root in roots.macros:
147
+ try:
148
+ loaded = load_macros(root.dir, origin=root.origin, label=root.label)
149
+ except Exception:
150
+ loaded = []
151
+ for macro in loaded:
152
+ if macro.name in claimed:
153
+ continue
154
+ claimed.add(macro.name)
155
+ templates.append(macro)
156
+
157
+ return DynamicCommandSources(skills=skills, templates=tuple(templates))
158
+
159
+
160
+ # ---------------------------------------------------------------------------
161
+ # The catalog
162
+ # ---------------------------------------------------------------------------
163
+
164
+
165
+ def build_catalog(
166
+ cwd: str,
167
+ home: str,
168
+ *,
169
+ integrations: IntegrationsRuntime | None = None,
170
+ ) -> tuple[SlashCommand, ...]:
171
+ """Assemble the full command catalog — every row in listing order.
172
+
173
+ The static command groups come first (transcript → workbench →
174
+ integrations), then the dynamic rows discovered under ``cwd``/``home``.
175
+ Every token (name + aliases) the static catalog already claims is
176
+ reserved: a discovered skill or template whose token would collide with
177
+ one of them is dropped, so splicing the dynamic rows can never make
178
+ ``build_registry`` raise on a duplicate.
179
+
180
+ Consumers fold the result into a
181
+ :class:`~induscode.console_slash.SlashRegistry` via ``build_registry``;
182
+ the index by name/alias, the family grouping, and the completion matcher
183
+ are all derived from this one tuple.
184
+
185
+ :param cwd: the workspace directory dynamic roots resolve under
186
+ :param home: the user's home directory for user-global roots
187
+ :param integrations: the per-console bridge-state holder; fresh when
188
+ omitted
189
+ """
190
+ static_commands: tuple[SlashCommand, ...] = (
191
+ *transcript_commands,
192
+ *workbench_commands,
193
+ *build_integration_commands(integrations),
194
+ )
195
+
196
+ reserved_tokens: set[str] = set()
197
+ for command in static_commands:
198
+ reserved_tokens.add(command.name.lower())
199
+ for alias in command.aliases:
200
+ reserved_tokens.add(alias.lower())
201
+
202
+ dynamic_commands = tuple(
203
+ command
204
+ for command in build_dynamic_commands(discover_dynamic_sources(cwd, home))
205
+ if command.name.lower() not in reserved_tokens
206
+ )
207
+
208
+ return (*static_commands, *dynamic_commands)
209
+
210
+
211
+ # ---------------------------------------------------------------------------
212
+ # The lazy defaults (TS `SLASH_COMMANDS` / `DEFAULT_SLASH_REGISTRY`)
213
+ # ---------------------------------------------------------------------------
214
+
215
+ _catalog_cache: tuple[SlashCommand, ...] | None = None
216
+ _registry_cache: SlashRegistry | None = None
217
+
218
+
219
+ def build_default_registry() -> SlashRegistry:
220
+ """The process-default registry, assembled once from the real cwd/home
221
+ roots and cached (the Python rendering of the TS module-level
222
+ ``DEFAULT_SLASH_REGISTRY``).
223
+
224
+ First build also installs the assembled catalog as the ``/help``
225
+ provider so the palette lists the dynamic rows too.
226
+ """
227
+ global _catalog_cache, _registry_cache
228
+ if _registry_cache is None:
229
+ if _catalog_cache is None:
230
+ _catalog_cache = build_catalog(os.getcwd(), os.path.expanduser("~"))
231
+ registry = build_registry(_catalog_cache)
232
+ set_help_registry_provider(lambda: registry.commands)
233
+ _registry_cache = registry
234
+ return _registry_cache
235
+
236
+
237
+ def reset_default_registry() -> None:
238
+ """Drop the cached default catalog/registry and the ``/help`` provider
239
+ (tests; a fresh access re-discovers against the then-current cwd)."""
240
+ global _catalog_cache, _registry_cache
241
+ _catalog_cache = None
242
+ _registry_cache = None
243
+ set_help_registry_provider(None)
244
+
245
+
246
+ def __getattr__(name: str) -> object:
247
+ """Lazy module attributes: the TS import-time catalog constants, minted
248
+ on first access (no import-time fs scans — plan cross-cutting rule 4)."""
249
+ if name == "SLASH_COMMANDS":
250
+ build_default_registry()
251
+ return _catalog_cache
252
+ if name == "DEFAULT_SLASH_REGISTRY":
253
+ return build_default_registry()
254
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1,217 @@
1
+ """Dynamic slash commands — capability cards and prompt templates as registry
2
+ rows.
3
+
4
+ Port of TS ``src/console/slash/commands/dynamic.ts``. The static catalog
5
+ (``.builtins``) is hand-maintained, but two command families are *discovered*
6
+ rather than written by hand: the Agent-Skills capability cards
7
+ (:class:`~induscode.briefing.SkillCard` rows loaded from ``SKILL.md`` files)
8
+ and the user/project prompt templates (:class:`~induscode.briefing.Macro`
9
+ rows loaded from ``*.md`` macro files). This module turns whatever was
10
+ discovered into ordinary :class:`~induscode.console_slash.SlashCommand` rows
11
+ so they sit in the same registry as the built-ins, complete the same way,
12
+ and resolve through the same dispatcher.
13
+
14
+ Two row shapes are produced:
15
+
16
+ - ``/skill:<name>`` — invoking it submits an Agent-Skills *invocation block*
17
+ (``<skill name="…" location="…">body</skill>``) as a normal turn, which is
18
+ exactly what the agent loop recognises to load and run that skill. The
19
+ trailing argument string becomes the skill body, so ``/skill:commit tidy
20
+ the diff`` runs the ``commit`` card against "tidy the diff".
21
+ - ``/<template-name>`` — invoking it expands the macro body against the
22
+ trailing arguments (the single-pass ``$arg`` model from the briefing
23
+ layer) and submits the result as a normal turn.
24
+
25
+ Both return a :class:`~induscode.console_slash.Prompt` outcome so the
26
+ dispatcher hands the produced text to the conductor as a turn; neither
27
+ reaches into a backend the console does not own. When nothing was
28
+ discovered, :func:`build_dynamic_commands` returns an empty list, so
29
+ splicing it into the catalog is always safe.
30
+
31
+ Port delta (locked): per the uniform calling convention every generated
32
+ ``run`` is ``async def``; the TS optional-fields source bag becomes the
33
+ frozen :class:`DynamicCommandSources` dataclass with empty-tuple defaults.
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ import re
39
+ from dataclasses import dataclass
40
+ from typing import Final
41
+
42
+ from induscode.briefing import Macro, SkillCard, apply_macros
43
+ from induscode.console_slash import Prompt, SlashCommand, SlashContext, SlashOutcome
44
+
45
+ __all__ = [
46
+ "DynamicCommandSources",
47
+ "build_dynamic_commands",
48
+ "clamp_summary",
49
+ "skill_invocation_block",
50
+ ]
51
+
52
+
53
+ # ---------------------------------------------------------------------------
54
+ # Sources
55
+ # ---------------------------------------------------------------------------
56
+
57
+
58
+ @dataclass(frozen=True, slots=True)
59
+ class DynamicCommandSources:
60
+ """The discovered material a dynamic catalog is built from.
61
+
62
+ Both fields default to empty: a caller that only loaded skills passes
63
+ ``DynamicCommandSources(skills=…)``, one that only loaded templates
64
+ passes ``DynamicCommandSources(templates=…)``, and a caller with neither
65
+ passes nothing and gets back ``[]``.
66
+ """
67
+
68
+ # Capability cards discovered from `SKILL.md` files.
69
+ skills: tuple[SkillCard, ...] = ()
70
+ # Prompt templates discovered from `*.md` macro files.
71
+ templates: tuple[Macro, ...] = ()
72
+
73
+
74
+ #: The namespace prefix every skill command answers to.
75
+ SKILL_PREFIX: Final = "skill:"
76
+
77
+ #: Trim a one-line summary down to a length the completion window can show.
78
+ SUMMARY_BUDGET: Final = 88
79
+
80
+ #: Whitespace-run collapser for the one-line summary.
81
+ _WHITESPACE_RUNS: Final = re.compile(r"\s+")
82
+
83
+
84
+ def clamp_summary(text: str) -> str:
85
+ """Cap a description to :data:`SUMMARY_BUDGET`, suffixing an ellipsis
86
+ when cut."""
87
+ one_line = _WHITESPACE_RUNS.sub(" ", text).strip()
88
+ if len(one_line) <= SUMMARY_BUDGET:
89
+ return one_line
90
+ return one_line[: SUMMARY_BUDGET - 1].rstrip() + "…"
91
+
92
+
93
+ # ---------------------------------------------------------------------------
94
+ # Skill-invocation block
95
+ # ---------------------------------------------------------------------------
96
+
97
+
98
+ def _escape_attr(value: str) -> str:
99
+ """Escape the XML attribute-significant characters for the opener tag."""
100
+ return (
101
+ value.replace("&", "&amp;")
102
+ .replace("<", "&lt;")
103
+ .replace(">", "&gt;")
104
+ .replace('"', "&quot;")
105
+ )
106
+
107
+
108
+ def skill_invocation_block(card: SkillCard, body: str) -> str:
109
+ """Render the Agent-Skills invocation block for a card and an optional
110
+ body.
111
+
112
+ The opener carries the card ``name`` and its on-disk ``location`` so the
113
+ agent loop can locate and read the full instructions; the body (the
114
+ user's trailing argument) sits between the opener and ``</skill>``. A
115
+ bare invocation produces a block with an empty body — the skill still
116
+ runs, just without a task message.
117
+
118
+ :param card: the capability card to invoke
119
+ :param body: the trailing user message the skill should act on (may be
120
+ empty)
121
+ """
122
+ opener = (
123
+ f'<skill name="{_escape_attr(card.name)}" '
124
+ f'location="{_escape_attr(card.location)}">'
125
+ )
126
+ return f"{opener}\n{body}\n</skill>"
127
+
128
+
129
+ def _skill_command(card: SkillCard) -> SlashCommand:
130
+ """Build the ``/skill:<name>`` row for one capability card.
131
+
132
+ Its ``run`` mints the invocation block against the trailing argument
133
+ string and returns it as a ``prompt`` outcome, so the dispatcher submits
134
+ it as a turn.
135
+
136
+ :param card: the capability card to expose
137
+ """
138
+
139
+ async def run(ctx: SlashContext) -> SlashOutcome:
140
+ body = ctx.args.strip()
141
+ return Prompt(text=skill_invocation_block(card, body))
142
+
143
+ return SlashCommand(
144
+ name=f"{SKILL_PREFIX}{card.name}",
145
+ summary=clamp_summary(card.description),
146
+ run=run,
147
+ family="skill",
148
+ takes_args=True,
149
+ )
150
+
151
+
152
+ # ---------------------------------------------------------------------------
153
+ # Prompt templates
154
+ # ---------------------------------------------------------------------------
155
+
156
+
157
+ def _template_command(macro: Macro) -> SlashCommand:
158
+ """Build the ``/<template-name>`` row for one prompt-template macro.
159
+
160
+ Its ``run`` expands the macro body against the trailing argument string
161
+ via the single-pass ``$arg`` resolver and returns the expansion as a
162
+ ``prompt`` outcome.
163
+
164
+ :param macro: the prompt template to expose
165
+ """
166
+
167
+ async def run(ctx: SlashContext) -> SlashOutcome:
168
+ return Prompt(text=apply_macros(macro.body, ctx.args))
169
+
170
+ return SlashCommand(
171
+ name=macro.name,
172
+ summary=clamp_summary(macro.description),
173
+ run=run,
174
+ family="template",
175
+ takes_args=True,
176
+ )
177
+
178
+
179
+ # ---------------------------------------------------------------------------
180
+ # Assembly
181
+ # ---------------------------------------------------------------------------
182
+
183
+
184
+ def build_dynamic_commands(
185
+ sources: DynamicCommandSources | None = None,
186
+ ) -> list[SlashCommand]:
187
+ """Project discovered skills and templates into command rows.
188
+
189
+ Skill rows are listed first (namespaced under ``skill:``), then the
190
+ template rows. A name claimed by an earlier row — including any of the
191
+ static built-ins a caller will concatenate these ahead of — is the
192
+ registry's problem to reject at assembly time; this function only
193
+ suppresses *self*-collisions so a template whose name duplicates another
194
+ template (first wins) cannot crash the build. When no sources are
195
+ supplied the result is an empty list, never an error.
196
+
197
+ :param sources: the discovered skills and templates
198
+ :returns: the dynamic command rows, skills first then templates
199
+ """
200
+ resolved = sources if sources is not None else DynamicCommandSources()
201
+ rows: list[SlashCommand] = []
202
+ claimed: set[str] = set()
203
+
204
+ for card in resolved.skills:
205
+ command = _skill_command(card)
206
+ if command.name in claimed:
207
+ continue
208
+ claimed.add(command.name)
209
+ rows.append(command)
210
+
211
+ for macro in resolved.templates:
212
+ if macro.name in claimed:
213
+ continue
214
+ claimed.add(macro.name)
215
+ rows.append(_template_command(macro))
216
+
217
+ return rows