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,949 @@
1
+ """Integration slash commands — auth launchers, the external bridges, and the
2
+ clipboard / export / share I/O verbs.
3
+
4
+ Port of TS ``src/console/slash/commands/integrations.ts``. This group is the
5
+ console's seam onto everything that lives *outside* a single turn: signing in
6
+ and out of a provider, wiring the MCP and memory surfaces, connecting an
7
+ external SaaS bridge, and moving the transcript out of the terminal
8
+ (clipboard, HTML export, a share link). The handlers do real work where a
9
+ backing capability exists — they render the live transcript to HTML through
10
+ the :func:`~induscode.transcript_export.publish_transcript` publisher, drive
11
+ an ``MCPClientPool`` over the workspace MCP config, reflect the
12
+ working-memory card from the capability deck, and reach the Composio catalog
13
+ through the SaaS gateway — and fall back to an *informative* status (never a
14
+ dead placeholder) when a backend is unreachable in the current environment.
15
+
16
+ The two family commands (``/memory``, ``/composio``) are built from
17
+ :class:`~induscode.console_slash.SubCommand` tables via
18
+ :func:`~induscode.console_slash.family_runner`, so their verb dispatch is a
19
+ lookup, not an ``if``-ladder. Overlay surfaces (``mcp``, ``memory``,
20
+ ``composio``) carry the *gathered* text into the plugin modal payload
21
+ (``{"surface": …, "title": …, "text": …}``), so the dialog renders the real
22
+ state the command collected rather than a fixed line.
23
+
24
+ Port deltas, all locked by the plan's cross-cutting rules:
25
+
26
+ - **Uniform async** (rule 3): every ``run`` is ``async def`` and the
27
+ dispatcher awaits it. The TS ``void promise.then(...)`` chains on the
28
+ Composio verbs become *tracked* ``asyncio`` tasks on the per-console
29
+ holder's task set — never bare tasks; each settle coroutine swallows its
30
+ own failure into the overlay text exactly as the TS ``.catch`` did.
31
+ - **No module-level singletons** (rule 4): the TS module globals
32
+ (``mcpPool``/``mcpServers``, ``composioGateway``/``composioResolved``,
33
+ ``memoryEnabled``) become fields on one per-console
34
+ :class:`IntegrationsRuntime` holder with a ``reset()`` for tests. The
35
+ command rows are minted by :func:`build_integration_commands` against one
36
+ holder, so each console owns its own bridge state.
37
+ - **Framework surface mapping** (analysis 03 §5): ``indusagi.mcp`` keeps the
38
+ camelCase pool surface (``loadMCPConfig`` / ``connectAll`` /
39
+ ``listAllTools`` / ``isConnected`` / ``disconnectAll``); the SaaS gateway
40
+ moved to ``indusagi.connectors`` with a snake_case factory
41
+ (``create_composio_gateway(api_key=…)``) and snake_case report fields
42
+ (``enabled_tools`` / ``auth_url`` / ``account_id`` for the TS
43
+ ``enabledTools`` / ``authUrl`` / ``accountId``).
44
+ """
45
+
46
+ from __future__ import annotations
47
+
48
+ import asyncio
49
+ import os
50
+ import re
51
+ import shutil
52
+ import subprocess
53
+ from collections.abc import Mapping, Sequence
54
+ from dataclasses import dataclass, field
55
+ from datetime import datetime, timezone
56
+ from pathlib import Path
57
+ from typing import Any, Final
58
+
59
+ from indusagi.connectors import SaasGateway, create_composio_gateway
60
+ from indusagi.mcp import (
61
+ MCPClientPool,
62
+ MCPClientPoolOptions,
63
+ MCPConnectionOptions,
64
+ MCPToolDefinition,
65
+ loadMCPConfig,
66
+ )
67
+ from indusagi.react_ink import StatusMessage
68
+
69
+ from induscode.capability_deck import APP_NOVEL_CARDS, memory_card
70
+ from induscode.console_slash import (
71
+ FAMILY,
72
+ HANDLED,
73
+ SlashCommand,
74
+ SlashContext,
75
+ SlashOutcome,
76
+ SubCommand,
77
+ family_runner,
78
+ info,
79
+ warn,
80
+ )
81
+ from induscode.transcript_export import (
82
+ PublishEntry,
83
+ PublishMessage,
84
+ PublishOptions,
85
+ PublishRole,
86
+ publish_transcript,
87
+ )
88
+
89
+ __all__ = [
90
+ "COMPOSIO_ENV_KEY",
91
+ "COMPOSIO_NO_KEY",
92
+ "IntegrationsRuntime",
93
+ "build_integration_commands",
94
+ ]
95
+
96
+
97
+ # ---------------------------------------------------------------------------
98
+ # The per-console runtime holder (plan cross-cutting rule 4)
99
+ # ---------------------------------------------------------------------------
100
+
101
+
102
+ @dataclass
103
+ class IntegrationsRuntime:
104
+ """The mutable bridge state one console's integration commands share.
105
+
106
+ The TS build kept this state in module globals so a ``/mcp connect``
107
+ followed by a later ``/mcp status`` acted on the same live pool; the
108
+ Python port threads one holder through the command closures instead, so
109
+ every console owns its own connections and tests get isolation via a
110
+ fresh holder (or :meth:`reset`).
111
+ """
112
+
113
+ # The console's MCP client pool, lazily built from the workspace config.
114
+ mcp_pool: MCPClientPool | None = None
115
+ # The server configs the live pool was built from, for `status`.
116
+ mcp_servers: list[MCPConnectionOptions] = field(default_factory=list)
117
+ # The Composio gateway, built once from the env key (None = no key).
118
+ composio_gateway: SaasGateway | None = None
119
+ # Whether a gateway build has already been attempted (caches the miss).
120
+ composio_resolved: bool = False
121
+ # parity: /memory on|off flips this *reporting* flag only — it does not
122
+ # remove the memory tool from the live deck. TS quirk kept verbatim
123
+ # (plan rule 10); a real detach needs an explicit waiver first.
124
+ memory_enabled: bool = True
125
+ # Strong refs to in-flight settle tasks (rule 3: tracked, never bare).
126
+ tasks: set[asyncio.Task[None]] = field(default_factory=set)
127
+
128
+ def reset(self) -> None:
129
+ """Drop every cached bridge handle and restore the defaults (tests)."""
130
+ self.mcp_pool = None
131
+ self.mcp_servers = []
132
+ self.composio_gateway = None
133
+ self.composio_resolved = False
134
+ self.memory_enabled = True
135
+ for task in self.tasks:
136
+ task.cancel()
137
+ self.tasks.clear()
138
+
139
+
140
+ def _spawn(runtime: IntegrationsRuntime, ctx: SlashContext, coro: Any) -> None:
141
+ """Run a settle coroutine as a tracked background task.
142
+
143
+ The coroutine is expected to handle its own failures (each Composio
144
+ settle path catches and reports through the overlay); a leak past that is
145
+ swallowed into a status warn rather than crashing the console — never a
146
+ bare task, never an unobserved exception (plan rule 3).
147
+ """
148
+ task = asyncio.get_running_loop().create_task(coro)
149
+ runtime.tasks.add(task)
150
+
151
+ def _settled(done: asyncio.Task[None]) -> None:
152
+ runtime.tasks.discard(done)
153
+ if done.cancelled():
154
+ return
155
+ fault = done.exception()
156
+ if fault is not None:
157
+ ctx.set_status(warn(f"Background integration task failed: {fault}"))
158
+
159
+ task.add_done_callback(_settled)
160
+
161
+
162
+ # ---------------------------------------------------------------------------
163
+ # Duck reads over framework messages
164
+ # ---------------------------------------------------------------------------
165
+
166
+
167
+ def _read(message: object, key: str) -> object | None:
168
+ """Read one structural field off a framework message — attribute access
169
+ for the framework dataclasses, key access for plain-dict stand-ins."""
170
+ if isinstance(message, Mapping):
171
+ return message.get(key)
172
+ return getattr(message, key, None)
173
+
174
+
175
+ # ---------------------------------------------------------------------------
176
+ # Clipboard plumbing
177
+ # ---------------------------------------------------------------------------
178
+
179
+
180
+ @dataclass(frozen=True, slots=True)
181
+ class _ClipboardWriter:
182
+ """One platform clipboard writer candidate."""
183
+
184
+ bin: str
185
+ args: tuple[str, ...]
186
+
187
+
188
+ #: The platform clipboard writers, tried in order until one accepts the text
189
+ #: on stdin. The first entry whose binary exists and exits cleanly wins:
190
+ #: - ``pbcopy`` — macOS.
191
+ #: - ``clip`` — Windows.
192
+ #: - ``xclip`` — X11 (selection set to the system clipboard).
193
+ #: - ``wl-copy`` — Wayland.
194
+ CLIPBOARD_WRITERS: Final[tuple[_ClipboardWriter, ...]] = (
195
+ _ClipboardWriter(bin="pbcopy", args=()),
196
+ _ClipboardWriter(bin="clip", args=()),
197
+ _ClipboardWriter(bin="xclip", args=("-selection", "clipboard")),
198
+ _ClipboardWriter(bin="wl-copy", args=()),
199
+ )
200
+
201
+
202
+ def _write_clipboard(text: str) -> bool:
203
+ """Best-effort write of ``text`` to the OS clipboard.
204
+
205
+ Walks :data:`CLIPBOARD_WRITERS`, feeding the text on stdin to each
206
+ candidate; returns ``True`` on the first writer that is present and exits
207
+ with status 0. A missing binary is skipped via :func:`shutil.which` (the
208
+ Python rendering of the TS swallowed ``ENOENT``); returns ``False`` when
209
+ no writer succeeds.
210
+
211
+ :param text: the string to place on the clipboard
212
+ """
213
+ for writer in CLIPBOARD_WRITERS:
214
+ if shutil.which(writer.bin) is None:
215
+ continue
216
+ try:
217
+ result = subprocess.run(
218
+ [writer.bin, *writer.args],
219
+ input=text,
220
+ capture_output=True,
221
+ encoding="utf-8",
222
+ )
223
+ except OSError:
224
+ # Writer is unavailable on this platform; fall through to the next.
225
+ continue
226
+ if result.returncode == 0:
227
+ return True
228
+ return False
229
+
230
+
231
+ # ---------------------------------------------------------------------------
232
+ # Transcript text extraction
233
+ # ---------------------------------------------------------------------------
234
+
235
+
236
+ def _block_text(block: object) -> str | None:
237
+ """The text of a ``text``-typed content block, or ``None`` otherwise."""
238
+ block_type = _read(block, "type")
239
+ text = _read(block, "text")
240
+ if block_type == "text" and isinstance(text, str):
241
+ return text
242
+ return None
243
+
244
+
245
+ def _last_assistant_text(messages: Sequence[object]) -> str:
246
+ """Pull the plain text out of the most recent assistant turn.
247
+
248
+ Scans newest-first for a message in the assistant role and concatenates
249
+ the text of its text blocks (reasoning and tool-call blocks are skipped,
250
+ since the clipboard wants the prose). Returns an empty string when there
251
+ is no assistant turn yet or it carried no text — the caller decides how
252
+ to surface that.
253
+
254
+ :param messages: the live transcript messages, oldest first
255
+ """
256
+ for message in reversed(messages):
257
+ if _read(message, "role") != "assistant":
258
+ continue
259
+ content = _read(message, "content")
260
+ if isinstance(content, str) or not isinstance(content, Sequence):
261
+ continue
262
+ parts = [text for block in content if (text := _block_text(block)) is not None]
263
+ return "".join(parts).strip()
264
+ return ""
265
+
266
+
267
+ # ---------------------------------------------------------------------------
268
+ # Transcript HTML export
269
+ # ---------------------------------------------------------------------------
270
+
271
+ #: The transcript roles the publisher knows how to attribute.
272
+ PUBLISH_ROLES: Final[frozenset[str]] = frozenset(
273
+ {"user", "assistant", "tool", "system", "condense", "note"}
274
+ )
275
+
276
+
277
+ def _to_publish_role(role: str) -> PublishRole:
278
+ """Map a framework message role to a publisher role, folding any
279
+ unrecognised role into ``note`` so an exotic message still renders."""
280
+ if role in PUBLISH_ROLES:
281
+ return role # type: ignore[return-value] # narrowed by the set check
282
+ return "note"
283
+
284
+
285
+ def _to_publish_entries(messages: Sequence[object]) -> list[PublishEntry]:
286
+ """Project the live transcript onto the :class:`PublishEntry` list the
287
+ :func:`publish_transcript` publisher consumes. The framework message is
288
+ read structurally — only ``role``, ``content``, and the tool-result
289
+ linkage — so the publisher's own narrow ``PublishMessage`` view is
290
+ satisfied without redeclaring the framework union."""
291
+ entries: list[PublishEntry] = []
292
+ for message in messages:
293
+ role = _read(message, "role")
294
+ role_text = role if isinstance(role, str) else ""
295
+ content = _read(message, "content")
296
+ if isinstance(content, str) or isinstance(content, Sequence):
297
+ published: Any = content
298
+ else:
299
+ published = ""
300
+ tool_call_id = _read(message, "toolCallId")
301
+ tool_name = _read(message, "toolName")
302
+ entries.append(
303
+ PublishEntry(
304
+ role=_to_publish_role(role_text),
305
+ message=PublishMessage(
306
+ role=role_text,
307
+ content=published,
308
+ toolCallId=tool_call_id if isinstance(tool_call_id, str) else None,
309
+ toolName=tool_name if isinstance(tool_name, str) else None,
310
+ ),
311
+ )
312
+ )
313
+ return entries
314
+
315
+
316
+ def _default_export_path() -> str:
317
+ """Build the default ``transcript-<timestamp>.html`` path under the
318
+ workspace (the TS ISO stamp with ``[:.]`` folded to ``-``, ``T`` to
319
+ ``_``, and the trailing ``Z`` dropped)."""
320
+ now = datetime.now(timezone.utc)
321
+ iso = now.strftime("%Y-%m-%dT%H:%M:%S.") + f"{now.microsecond // 1000:03d}Z"
322
+ stamp = re.sub(r"[:.]", "-", iso).replace("T", "_")
323
+ stamp = re.sub(r"Z$", "", stamp)
324
+ return os.path.abspath(os.path.join(os.getcwd(), f"transcript-{stamp}.html"))
325
+
326
+
327
+ @dataclass(frozen=True, slots=True)
328
+ class _ExportReport:
329
+ """The outcome of one HTML export — the absolute path written on
330
+ success, or a typed failure carrying a human message."""
331
+
332
+ ok: bool
333
+ path: str = ""
334
+ reason: str = ""
335
+
336
+
337
+ def _export_transcript_html(ctx: SlashContext, target: str) -> _ExportReport:
338
+ """Render the live transcript to a standalone HTML document and write it
339
+ to disk.
340
+
341
+ The transcript is read from the conductor at call time and published
342
+ through the transcript-export subsystem; the only I/O here is the single
343
+ file write.
344
+
345
+ :param ctx: the slash context (the conductor is read for live messages)
346
+ :param target: an optional output path; a timestamped default otherwise
347
+ """
348
+ messages = ctx.conductor.messages()
349
+ if len(messages) == 0:
350
+ return _ExportReport(
351
+ ok=False, reason="Nothing to export yet — the transcript is empty."
352
+ )
353
+ path = (
354
+ os.path.abspath(os.path.join(os.getcwd(), target))
355
+ if len(target) > 0
356
+ else _default_export_path()
357
+ )
358
+ try:
359
+ html = publish_transcript(
360
+ _to_publish_entries(messages), PublishOptions(title="Session Transcript")
361
+ )
362
+ Path(path).write_text(html, encoding="utf-8")
363
+ return _ExportReport(ok=True, path=path)
364
+ except Exception as cause:
365
+ return _ExportReport(
366
+ ok=False, reason=f"Could not write the HTML export: {cause}"
367
+ )
368
+
369
+
370
+ # ---------------------------------------------------------------------------
371
+ # MCP pool
372
+ # ---------------------------------------------------------------------------
373
+
374
+
375
+ def _load_mcp_pool(runtime: IntegrationsRuntime) -> MCPClientPool:
376
+ """(Re)load the workspace MCP config and rebuild the pool from it."""
377
+ runtime.mcp_servers = loadMCPConfig(os.getcwd())
378
+ runtime.mcp_pool = MCPClientPool(MCPClientPoolOptions(servers=runtime.mcp_servers))
379
+ return runtime.mcp_pool
380
+
381
+
382
+ def _ensure_mcp_pool(runtime: IntegrationsRuntime) -> MCPClientPool:
383
+ """The live pool, building one from config on first use."""
384
+ if runtime.mcp_pool is not None:
385
+ return runtime.mcp_pool
386
+ return _load_mcp_pool(runtime)
387
+
388
+
389
+ async def _gather_mcp_status(runtime: IntegrationsRuntime) -> str:
390
+ """Gather a human-readable status block describing the configured MCP
391
+ servers, their live connection state, and the tool names each currently
392
+ exposes."""
393
+ if len(runtime.mcp_servers) == 0 and runtime.mcp_pool is None:
394
+ runtime.mcp_servers = loadMCPConfig(os.getcwd())
395
+ if len(runtime.mcp_servers) == 0:
396
+ return (
397
+ "No MCP servers are configured.\n"
398
+ "Add one to .indusvx/mcp.json, then run /mcp connect."
399
+ )
400
+
401
+ pool = runtime.mcp_pool
402
+ lines: list[str] = [f"Configured MCP servers ({len(runtime.mcp_servers)}):"]
403
+
404
+ tools_by_server: dict[str, list[MCPToolDefinition]] = {}
405
+ if pool is not None:
406
+ try:
407
+ tools_by_server = await pool.listAllTools()
408
+ except Exception:
409
+ tools_by_server = {}
410
+
411
+ for server in runtime.mcp_servers:
412
+ connected = pool.isConnected(server.name) if pool is not None else False
413
+ state = "connected" if connected else "not connected"
414
+ lines.append(f" • {server.name} — {state}")
415
+ tools = tools_by_server.get(server.name, [])
416
+ if len(tools) > 0:
417
+ lines.append(f" tools: {', '.join(t.name for t in tools)}")
418
+ return "\n".join(lines)
419
+
420
+
421
+ # ---------------------------------------------------------------------------
422
+ # MCP command
423
+ # ---------------------------------------------------------------------------
424
+
425
+
426
+ def _open_mcp_overlay(ctx: SlashContext, text: str) -> None:
427
+ """Open the MCP plugin overlay populated with the gathered status text."""
428
+ ctx.open_modal("plugin", {"surface": "mcp", "title": "MCP servers", "text": text})
429
+
430
+
431
+ async def _run_mcp(ctx: SlashContext, runtime: IntegrationsRuntime) -> SlashOutcome:
432
+ """Run the ``/mcp`` verb against the workspace MCP pool."""
433
+ verb = ctx.args.strip().lower() or "status"
434
+
435
+ try:
436
+ if verb in ("connect", "reconnect"):
437
+ pool = (
438
+ _load_mcp_pool(runtime)
439
+ if verb == "reconnect"
440
+ else _ensure_mcp_pool(runtime)
441
+ )
442
+ await pool.connectAll()
443
+ text = await _gather_mcp_status(runtime)
444
+ ctx.set_status(info(f"MCP {verb}ed."))
445
+ _open_mcp_overlay(ctx, text)
446
+ return HANDLED
447
+ if verb == "disconnect":
448
+ if runtime.mcp_pool is None:
449
+ ctx.set_status(info("No MCP connections are open."))
450
+ return HANDLED
451
+ await runtime.mcp_pool.disconnectAll()
452
+ ctx.set_status(info("Disconnected from all MCP servers."))
453
+ _open_mcp_overlay(ctx, await _gather_mcp_status(runtime))
454
+ return HANDLED
455
+ if verb == "tools":
456
+ pool = _ensure_mcp_pool(runtime)
457
+ tools_by_server: dict[str, list[MCPToolDefinition]] = {}
458
+ try:
459
+ tools_by_server = await pool.listAllTools()
460
+ except Exception:
461
+ tools_by_server = {}
462
+ names = [t.name for tools in tools_by_server.values() for t in tools]
463
+ text = (
464
+ "No MCP tools are loaded. Run /mcp connect first."
465
+ if len(names) == 0
466
+ else f"Loaded MCP tools ({len(names)}):\n " + "\n ".join(names)
467
+ )
468
+ _open_mcp_overlay(ctx, text)
469
+ return HANDLED
470
+ if verb in ("status", ""):
471
+ _open_mcp_overlay(ctx, await _gather_mcp_status(runtime))
472
+ return HANDLED
473
+ ctx.set_status(
474
+ warn("/mcp expects: connect, reconnect, disconnect, tools, or status.")
475
+ )
476
+ return HANDLED
477
+ except Exception as cause:
478
+ ctx.set_status(warn(f"MCP {verb} failed: {cause}"))
479
+ return HANDLED
480
+
481
+
482
+ # ---------------------------------------------------------------------------
483
+ # Memory family
484
+ # ---------------------------------------------------------------------------
485
+
486
+
487
+ def _memory_tool_names() -> list[str]:
488
+ """The model-facing tool name(s) the memory card contributes.
489
+
490
+ The card builds a single capability the model calls as ``memory``; the
491
+ card id is the deck-side handle for the same capability.
492
+ """
493
+ return list(dict.fromkeys([str(memory_card.id), "memory"]))
494
+
495
+
496
+ def _memory_present() -> bool:
497
+ """Whether the working-memory card is part of the app's novel set."""
498
+ return any(card.id == memory_card.id for card in APP_NOVEL_CARDS)
499
+
500
+
501
+ def _gather_memory_status(runtime: IntegrationsRuntime) -> str:
502
+ """Build the gathered memory status text for the overlay / status line."""
503
+ if not _memory_present():
504
+ return "The working-memory capability is not registered in this build."
505
+ lines = [
506
+ f"Working memory: {'active' if runtime.memory_enabled else 'inactive'}.",
507
+ f"Tool: {', '.join(_memory_tool_names())}.",
508
+ memory_card.summary,
509
+ ]
510
+ return "\n".join(lines)
511
+
512
+
513
+ def _open_memory_overlay(ctx: SlashContext, text: str) -> None:
514
+ """Open the memory plugin overlay populated with the gathered text."""
515
+ ctx.open_modal(
516
+ "plugin", {"surface": "memory", "title": "Working memory", "text": text}
517
+ )
518
+
519
+
520
+ def _memory_subs(runtime: IntegrationsRuntime) -> tuple[SubCommand, ...]:
521
+ """The ``/memory`` sub-command table over one console's holder. Every
522
+ verb reflects (or best-effort toggles) the real working-memory card from
523
+ the capability deck — no dead placeholder."""
524
+
525
+ async def status(ctx: SlashContext, rest: str) -> SlashOutcome:
526
+ _open_memory_overlay(ctx, _gather_memory_status(runtime))
527
+ return HANDLED
528
+
529
+ async def on(ctx: SlashContext, rest: str) -> SlashOutcome:
530
+ if not _memory_present():
531
+ ctx.set_status(warn("No working-memory capability to enable in this build."))
532
+ return HANDLED
533
+ runtime.memory_enabled = True
534
+ ctx.set_status(info("Working memory marked active."))
535
+ _open_memory_overlay(ctx, _gather_memory_status(runtime))
536
+ return HANDLED
537
+
538
+ async def off(ctx: SlashContext, rest: str) -> SlashOutcome:
539
+ if not _memory_present():
540
+ ctx.set_status(warn("No working-memory capability to disable in this build."))
541
+ return HANDLED
542
+ runtime.memory_enabled = False
543
+ ctx.set_status(info("Working memory marked inactive."))
544
+ _open_memory_overlay(ctx, _gather_memory_status(runtime))
545
+ return HANDLED
546
+
547
+ async def tools(ctx: SlashContext, rest: str) -> SlashOutcome:
548
+ text = (
549
+ "Working-memory tools:\n " + "\n ".join(_memory_tool_names())
550
+ if _memory_present()
551
+ else "No working-memory capability is registered in this build."
552
+ )
553
+ _open_memory_overlay(ctx, text)
554
+ return HANDLED
555
+
556
+ return (
557
+ SubCommand(verb="status", describe="show memory state + tool", run=status),
558
+ SubCommand(verb="on", describe="mark memory active", run=on),
559
+ SubCommand(verb="off", describe="mark memory inactive", run=off),
560
+ SubCommand(verb="tools", describe="list memory tool names", run=tools),
561
+ )
562
+
563
+
564
+ # ---------------------------------------------------------------------------
565
+ # Composio family
566
+ # ---------------------------------------------------------------------------
567
+
568
+ #: The conventional environment variable carrying a Composio API key.
569
+ COMPOSIO_ENV_KEY: Final = "COMPOSIO_API_KEY"
570
+
571
+ #: The status line shown when no Composio key is configured.
572
+ COMPOSIO_NO_KEY: Final = (
573
+ f"Composio is not configured. Export {COMPOSIO_ENV_KEY} with a Composio API key, "
574
+ "then re-run /composio connect."
575
+ )
576
+
577
+
578
+ def _ensure_composio_gateway(runtime: IntegrationsRuntime) -> SaasGateway | None:
579
+ """Resolve the Composio gateway, building it from the env key on first
580
+ use (and caching the miss so repeated verbs do not re-probe the env)."""
581
+ if runtime.composio_resolved:
582
+ return runtime.composio_gateway
583
+ runtime.composio_resolved = True
584
+ api_key = (os.environ.get(COMPOSIO_ENV_KEY) or "").strip()
585
+ if len(api_key) == 0:
586
+ runtime.composio_gateway = None
587
+ return None
588
+ try:
589
+ runtime.composio_gateway = create_composio_gateway(api_key=api_key)
590
+ except Exception:
591
+ runtime.composio_gateway = None
592
+ return runtime.composio_gateway
593
+
594
+
595
+ def _open_composio_overlay(ctx: SlashContext, text: str) -> None:
596
+ """Open the Composio plugin overlay populated with gathered text."""
597
+ ctx.open_modal("plugin", {"surface": "composio", "title": "Composio", "text": text})
598
+
599
+
600
+ async def _gather_composio_status(gateway: SaasGateway) -> str:
601
+ """Gather a status block of connected accounts + in-scope tool names."""
602
+ report = await gateway.status()
603
+ lines: list[str] = []
604
+ if len(report.accounts) == 0:
605
+ lines.append("No connected accounts.")
606
+ else:
607
+ lines.append(f"Connected accounts ({len(report.accounts)}):")
608
+ for account in report.accounts:
609
+ lines.append(f" • {account.toolkit} — {account.status} ({account.id})")
610
+ lines.append(
611
+ "No remote tools enabled yet. Run /composio enable <toolkit>."
612
+ if len(report.enabled_tools) == 0
613
+ else f"Enabled tools ({len(report.enabled_tools)}): "
614
+ + ", ".join(report.enabled_tools)
615
+ )
616
+ return "\n".join(lines)
617
+
618
+
619
+ def _composio_subs(runtime: IntegrationsRuntime) -> tuple[SubCommand, ...]:
620
+ """The ``/composio`` sub-command table over one console's holder. Each
621
+ verb reaches the real Composio catalog through the SaaS gateway when a
622
+ key is configured, and reports the missing-key state specifically
623
+ otherwise. The connect/enable flows settle in tracked background tasks
624
+ that open the overlay on completion (the TS fire-and-forget promises)."""
625
+
626
+ async def status(ctx: SlashContext, rest: str) -> SlashOutcome:
627
+ gateway = _ensure_composio_gateway(runtime)
628
+ if gateway is None:
629
+ ctx.set_status(warn("Composio is not configured."))
630
+ _open_composio_overlay(ctx, COMPOSIO_NO_KEY)
631
+ return HANDLED
632
+
633
+ async def settle() -> None:
634
+ try:
635
+ text = await _gather_composio_status(gateway)
636
+ except Exception as cause:
637
+ _open_composio_overlay(ctx, f"Could not read Composio status: {cause}")
638
+ return
639
+ _open_composio_overlay(ctx, text)
640
+
641
+ _spawn(runtime, ctx, settle())
642
+ return HANDLED
643
+
644
+ async def accounts(ctx: SlashContext, rest: str) -> SlashOutcome:
645
+ gateway = _ensure_composio_gateway(runtime)
646
+ if gateway is None:
647
+ _open_composio_overlay(ctx, COMPOSIO_NO_KEY)
648
+ return HANDLED
649
+
650
+ async def settle() -> None:
651
+ try:
652
+ report = await gateway.status()
653
+ except Exception as cause:
654
+ _open_composio_overlay(ctx, f"Could not list accounts: {cause}")
655
+ return
656
+ text = (
657
+ "No connected Composio accounts."
658
+ if len(report.accounts) == 0
659
+ else f"Connected accounts ({len(report.accounts)}):\n"
660
+ + "\n".join(
661
+ f" • {a.toolkit} — {a.status} ({a.id})" for a in report.accounts
662
+ )
663
+ )
664
+ _open_composio_overlay(ctx, text)
665
+
666
+ _spawn(runtime, ctx, settle())
667
+ return HANDLED
668
+
669
+ async def tools(ctx: SlashContext, rest: str) -> SlashOutcome:
670
+ gateway = _ensure_composio_gateway(runtime)
671
+ if gateway is None:
672
+ _open_composio_overlay(ctx, COMPOSIO_NO_KEY)
673
+ return HANDLED
674
+
675
+ async def settle() -> None:
676
+ try:
677
+ report = await gateway.status()
678
+ except Exception as cause:
679
+ _open_composio_overlay(ctx, f"Could not list tools: {cause}")
680
+ return
681
+ text = (
682
+ "No Composio tools enabled. Run /composio enable <toolkit>."
683
+ if len(report.enabled_tools) == 0
684
+ else f"Enabled Composio tools ({len(report.enabled_tools)}):\n "
685
+ + "\n ".join(report.enabled_tools)
686
+ )
687
+ _open_composio_overlay(ctx, text)
688
+
689
+ _spawn(runtime, ctx, settle())
690
+ return HANDLED
691
+
692
+ async def connect(ctx: SlashContext, rest: str) -> SlashOutcome:
693
+ toolkit = rest.strip()
694
+ if len(toolkit) == 0:
695
+ ctx.set_status(warn("/composio connect expects: <toolkit> to link."))
696
+ return HANDLED
697
+ gateway = _ensure_composio_gateway(runtime)
698
+ if gateway is None:
699
+ _open_composio_overlay(ctx, COMPOSIO_NO_KEY)
700
+ return HANDLED
701
+ ctx.set_status(info(f"Starting Composio connect for {toolkit}..."))
702
+
703
+ async def settle() -> None:
704
+ try:
705
+ report = await gateway.connect(toolkit)
706
+ except Exception as cause:
707
+ _open_composio_overlay(ctx, f"Composio connect failed: {cause}")
708
+ return
709
+ lines = [f"Connect {toolkit}: {report.action}."]
710
+ if report.auth_url:
711
+ lines.append(f"Authorize at: {report.auth_url}")
712
+ if report.account_id:
713
+ lines.append(f"Account: {report.account_id}")
714
+ if report.reason:
715
+ lines.append(f"Reason: {report.reason}")
716
+ _open_composio_overlay(ctx, "\n".join(lines))
717
+
718
+ _spawn(runtime, ctx, settle())
719
+ return HANDLED
720
+
721
+ async def enable(ctx: SlashContext, rest: str) -> SlashOutcome:
722
+ toolkit = rest.strip()
723
+ if len(toolkit) == 0:
724
+ ctx.set_status(warn("/composio enable expects: <toolkit> to hydrate."))
725
+ return HANDLED
726
+ gateway = _ensure_composio_gateway(runtime)
727
+ if gateway is None:
728
+ _open_composio_overlay(ctx, COMPOSIO_NO_KEY)
729
+ return HANDLED
730
+ ctx.set_status(info(f"Enabling Composio toolkit {toolkit}..."))
731
+
732
+ async def settle() -> None:
733
+ try:
734
+ report = await gateway.enable(toolkit)
735
+ except Exception as cause:
736
+ _open_composio_overlay(ctx, f"Composio enable failed: {cause}")
737
+ return
738
+ text = (
739
+ f"Toolkit {toolkit} exposed no tools."
740
+ if len(report.hydrated) == 0
741
+ else f"Enabled {report.toolkit} ({len(report.hydrated)} tools"
742
+ + (", cached" if report.cached else "")
743
+ + "):\n "
744
+ + "\n ".join(report.hydrated)
745
+ )
746
+ _open_composio_overlay(ctx, text)
747
+
748
+ _spawn(runtime, ctx, settle())
749
+ return HANDLED
750
+
751
+ return (
752
+ SubCommand(verb="status", describe="show accounts + tools", run=status),
753
+ SubCommand(verb="accounts", describe="list connected accounts", run=accounts),
754
+ SubCommand(verb="tools", describe="list enabled tool names", run=tools),
755
+ SubCommand(verb="connect", describe="link a toolkit", run=connect),
756
+ SubCommand(verb="enable", describe="hydrate a toolkit's tools", run=enable),
757
+ )
758
+
759
+
760
+ # ---------------------------------------------------------------------------
761
+ # Direct-action handlers
762
+ # ---------------------------------------------------------------------------
763
+
764
+
765
+ async def _run_copy(ctx: SlashContext) -> SlashOutcome:
766
+ """``/copy`` — place the last assistant turn's text on the OS clipboard.
767
+
768
+ Reads the live transcript through the conductor, extracts the newest
769
+ assistant prose, and writes it through the best-effort clipboard walk.
770
+ Warns when there is nothing to copy, and again when no platform writer
771
+ accepted the text; otherwise confirms with an info toast.
772
+ """
773
+ text = _last_assistant_text(ctx.conductor.messages())
774
+ if len(text) == 0:
775
+ ctx.set_status(warn("Nothing to copy yet — no assistant reply on screen."))
776
+ return HANDLED
777
+ if not _write_clipboard(text):
778
+ ctx.set_status(warn("Could not reach a clipboard tool on this system."))
779
+ return HANDLED
780
+ ctx.set_status(info("Copied the last reply to your clipboard."))
781
+ return HANDLED
782
+
783
+
784
+ async def _run_export(ctx: SlashContext) -> SlashOutcome:
785
+ """``/export [path]`` — render the live transcript to a standalone HTML
786
+ document and write it to disk, reporting the absolute path written."""
787
+ result = _export_transcript_html(ctx, ctx.args.strip())
788
+ if not result.ok:
789
+ ctx.set_status(warn(result.reason))
790
+ return HANDLED
791
+ ctx.set_status(
792
+ StatusMessage(kind="success", text=f"Exported transcript to {result.path}")
793
+ )
794
+ return HANDLED
795
+
796
+
797
+ #: The pattern over ``gh`` stderr that suggests an authentication problem.
798
+ _GH_AUTH_HINT: Final = re.compile(r"auth|logged in|gh auth login", re.IGNORECASE)
799
+
800
+
801
+ async def _run_share(ctx: SlashContext) -> SlashOutcome:
802
+ """``/share`` — export the transcript to HTML, then publish it as a
803
+ secret GitHub gist via the ``gh`` CLI. On success the gist URL is
804
+ reported; when ``gh`` is missing or unauthenticated, that is reported
805
+ specifically with the local HTML path as a fallback the user can open or
806
+ upload manually."""
807
+ exported = _export_transcript_html(ctx, "")
808
+ if not exported.ok:
809
+ ctx.set_status(warn(exported.reason))
810
+ return HANDLED
811
+
812
+ try:
813
+ result = subprocess.run(
814
+ ["gh", "gist", "create", exported.path, "--public=false"],
815
+ capture_output=True,
816
+ encoding="utf-8",
817
+ )
818
+ except (FileNotFoundError, OSError):
819
+ ctx.set_status(
820
+ warn(
821
+ f"GitHub CLI (gh) is not installed. Saved HTML locally at {exported.path}"
822
+ )
823
+ )
824
+ return HANDLED
825
+
826
+ if result.returncode != 0:
827
+ stderr = (result.stderr or "").strip()
828
+ auth_hint = (
829
+ "Run `gh auth login` to authenticate, then retry."
830
+ if _GH_AUTH_HINT.search(stderr)
831
+ else stderr or "gh gist create failed."
832
+ )
833
+ ctx.set_status(
834
+ warn(f"Share failed: {auth_hint} Saved HTML locally at {exported.path}")
835
+ )
836
+ return HANDLED
837
+
838
+ pieces = (result.stdout or "").strip().split()
839
+ url = pieces[0] if pieces else ""
840
+ ctx.set_status(
841
+ StatusMessage(
842
+ kind="success",
843
+ text=f"Shared as secret gist: {url}"
844
+ if len(url) > 0
845
+ else f"Shared. Local HTML: {exported.path}",
846
+ )
847
+ )
848
+ return HANDLED
849
+
850
+
851
+ # ---------------------------------------------------------------------------
852
+ # Login routing
853
+ # ---------------------------------------------------------------------------
854
+
855
+
856
+ async def _run_login(ctx: SlashContext) -> SlashOutcome:
857
+ """``/login [provider]`` — open the sign-in surface. When a provider name
858
+ is supplied it is threaded into the sign-in payload so the overlay routes
859
+ straight to that provider's entry flow instead of the generic picker."""
860
+ provider = ctx.args.strip().lower()
861
+ if len(provider) > 0:
862
+ ctx.open_modal("signIn", {"providerId": provider})
863
+ else:
864
+ ctx.open_modal("signIn")
865
+ return HANDLED
866
+
867
+
868
+ async def _run_logout(ctx: SlashContext) -> SlashOutcome:
869
+ """``/logout`` — open the sign-out confirmation."""
870
+ ctx.open_modal("signOut")
871
+ return HANDLED
872
+
873
+
874
+ # ---------------------------------------------------------------------------
875
+ # The exported group
876
+ # ---------------------------------------------------------------------------
877
+
878
+
879
+ def build_integration_commands(
880
+ runtime: IntegrationsRuntime | None = None,
881
+ ) -> tuple[SlashCommand, ...]:
882
+ """Mint the integration command group over one console's runtime holder.
883
+
884
+ The integration command group: auth launchers, the MCP/memory/composio
885
+ bridges, and the clipboard / export / share I/O verbs. Appended to the
886
+ slash catalog by the assembler (``builtins.build_catalog``) alongside the
887
+ other groups. Each call binds the rows to the given (or a fresh)
888
+ :class:`IntegrationsRuntime`, replacing the TS module-global state with
889
+ per-console state (plan cross-cutting rule 4).
890
+
891
+ :param runtime: the holder the bridge verbs share; fresh when omitted
892
+ """
893
+ holder = runtime if runtime is not None else IntegrationsRuntime()
894
+
895
+ run_memory = family_runner(FAMILY.memory, _memory_subs(holder))
896
+ run_composio = family_runner(FAMILY.composio, _composio_subs(holder))
897
+
898
+ async def run_mcp(ctx: SlashContext) -> SlashOutcome:
899
+ return await _run_mcp(ctx, holder)
900
+
901
+ return (
902
+ SlashCommand(
903
+ name="login",
904
+ summary="Sign in to a model provider.",
905
+ run=_run_login,
906
+ takes_args=True,
907
+ ),
908
+ SlashCommand(
909
+ name="logout",
910
+ summary="Sign out of a model provider.",
911
+ run=_run_logout,
912
+ ),
913
+ SlashCommand(
914
+ name="mcp",
915
+ summary="Manage MCP servers and their tools.",
916
+ run=run_mcp,
917
+ takes_args=True,
918
+ ),
919
+ SlashCommand(
920
+ name="memory",
921
+ summary="Inspect and toggle the working-memory capability.",
922
+ run=run_memory,
923
+ family=FAMILY.memory,
924
+ takes_args=True,
925
+ ),
926
+ SlashCommand(
927
+ name="composio",
928
+ summary="Connect and inspect Composio app bridges.",
929
+ run=run_composio,
930
+ family=FAMILY.composio,
931
+ takes_args=True,
932
+ ),
933
+ SlashCommand(
934
+ name="copy",
935
+ summary="Copy the last reply to the clipboard.",
936
+ run=_run_copy,
937
+ ),
938
+ SlashCommand(
939
+ name="export",
940
+ summary="Export the transcript to an HTML file.",
941
+ run=_run_export,
942
+ takes_args=True,
943
+ ),
944
+ SlashCommand(
945
+ name="share",
946
+ summary="Share the session as a secret GitHub gist.",
947
+ run=_run_share,
948
+ ),
949
+ )