superresearch-agent 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.
facade/__init__.py ADDED
@@ -0,0 +1,19 @@
1
+ """research-facade — the Super Agent bridge.
2
+
3
+ A standalone, account-authed client on Super Research's normal Firestore
4
+ plane. It lets a chat runtime (Hermes / OpenClaw) drive Super Research as a
5
+ *headless session of the user's account*: the user signs in once with Google
6
+ (`/login`), and the bridge then enqueues research runs on the account's
7
+ existing devices. Runs surface in the web app as normal chats.
8
+
9
+ Hard boundaries (the "nothing breaks" contract):
10
+ * This package NEVER imports or mutates research-automate or research-app.
11
+ * It uses its OWN secret store namespace ("super-agent"), never the device
12
+ daemon's keystore ("super-research") — so refresh-token rotation here can
13
+ never disturb a paired device.
14
+ * It writes only what a normal account client may write (research docs +
15
+ device-queue start docs), all gated by the existing Firestore rules.
16
+ * Research-only: it can never control devices (add/remove/pair/share).
17
+ """
18
+
19
+ __version__ = "0.0.1"
facade/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
facade/autostart.py ADDED
@@ -0,0 +1,375 @@
1
+ """Keep the host bridge up across logon — the engine behind `agent resurrect` /
2
+ `agent retire`. Cross-platform, one install/uninstall/status/start_detached API
3
+ dispatched by OS:
4
+
5
+ • Windows — a **Scheduled Task** (`SuperAgentBridge`) that launches the bridge
6
+ WINDOWLESS (pythonw.exe) at logon. The proven, shipping path.
7
+ • Linux — a **systemd --user** service (`super-agent-bridge.service`).
8
+ • macOS — a **launchd LaunchAgent** (`io.superresearch.agent-bridge`).
9
+
10
+ All three launch the SAME tiny generated launcher (~/.super-agent/bridge_launcher.py)
11
+ which puts the agent package on sys.path and calls the `serve` entry point, so the
12
+ bridge resumes cleanly after a reboot (the account session + device selection persist
13
+ in keyring + prefs).
14
+
15
+ The per-OS argv / unit / plist are built by pure functions (unit-testable); only
16
+ install / uninstall / status / start_detached shell out.
17
+
18
+ NOTE: the Windows path is the validated one. The Linux (systemd) and macOS (launchd)
19
+ paths are unit-tested at the generation + dispatch level but have NOT yet been
20
+ validated end-to-end on a live Linux/macOS host.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import subprocess
26
+ import sys
27
+ from pathlib import Path
28
+
29
+ from . import config
30
+
31
+ TASK_NAME = "SuperAgentBridge" # Windows Scheduled Task name
32
+ SYSTEMD_UNIT = "super-agent-bridge.service" # Linux systemd --user unit
33
+ LAUNCHD_LABEL = "io.superresearch.agent-bridge" # macOS launchd LaunchAgent label
34
+
35
+
36
+ # ── platform ───────────────────────────────────────────────────────────────────
37
+
38
+ def is_windows() -> bool:
39
+ return sys.platform.startswith("win")
40
+
41
+
42
+ def is_macos() -> bool:
43
+ return sys.platform == "darwin"
44
+
45
+
46
+ def is_linux() -> bool:
47
+ return sys.platform.startswith("linux")
48
+
49
+
50
+ def platform_label() -> str:
51
+ if is_windows():
52
+ return "Windows"
53
+ if is_macos():
54
+ return "macOS"
55
+ if is_linux():
56
+ return "Linux"
57
+ return sys.platform
58
+
59
+
60
+ # ── shared launcher (every OS runs this) ─────────────────────────────────────
61
+
62
+ def agent_dir() -> Path:
63
+ """The dir that contains the ``facade`` package (so ``import facade`` works
64
+ even when the service launches with a cwd like C:\\Windows\\System32 or /)."""
65
+ return Path(__file__).resolve().parent.parent
66
+
67
+
68
+ def pythonw_exe() -> str:
69
+ """No-console interpreter (pythonw.exe) sibling of the current interpreter on
70
+ Windows; sys.executable elsewhere (POSIX has no windowless variant — the
71
+ service manager detaches it)."""
72
+ cur = Path(sys.executable)
73
+ if cur.name.lower() == "python.exe":
74
+ sib = cur.parent / "pythonw.exe"
75
+ if sib.exists():
76
+ return str(sib)
77
+ return str(cur)
78
+
79
+
80
+ def launcher_path() -> Path:
81
+ """The generated launcher script the service / detached start run."""
82
+ return config.store_dir() / "bridge_launcher.py"
83
+
84
+
85
+ def launcher_source(agentdir: Path | None = None) -> str:
86
+ """Python source for the launcher: inject the agent dir on sys.path, then call
87
+ the `serve` entry point. ``repr`` quotes the path safely (handles Windows
88
+ backslashes), so there are no schtasks-quoting hazards."""
89
+ d = str(agentdir or agent_dir())
90
+ return (
91
+ "# Auto-generated by `agent resurrect` — launches the Super Agent bridge.\n"
92
+ "# Safe to delete; `agent retire` removes it.\n"
93
+ "import sys\n"
94
+ f"sys.path.insert(0, {d!r})\n"
95
+ "from facade.cli import main\n"
96
+ "raise SystemExit(main(['serve']))\n"
97
+ )
98
+
99
+
100
+ def write_launcher(agentdir: Path | None = None) -> Path:
101
+ """Write (refresh) the launcher script; return its path."""
102
+ p = launcher_path()
103
+ p.parent.mkdir(parents=True, exist_ok=True)
104
+ p.write_text(launcher_source(agentdir), encoding="utf-8")
105
+ return p
106
+
107
+
108
+ def _rm_launcher() -> None:
109
+ try:
110
+ launcher_path().unlink(missing_ok=True)
111
+ except OSError:
112
+ pass
113
+
114
+
115
+ def _exec(argv: list[str]) -> tuple[bool, str]:
116
+ no_window = getattr(subprocess, "CREATE_NO_WINDOW", 0x08000000) if is_windows() else 0
117
+ try:
118
+ proc = subprocess.run(argv, capture_output=True, text=True, encoding="utf-8",
119
+ errors="replace", timeout=30, creationflags=no_window)
120
+ except (OSError, subprocess.SubprocessError) as e: # pragma: no cover - env-specific
121
+ return False, str(e)
122
+ out = (proc.stdout or "") + (proc.stderr or "")
123
+ return proc.returncode == 0, out.strip()
124
+
125
+
126
+ # ── Windows: Scheduled Task (validated) ──────────────────────────────────────
127
+
128
+ def run_command(exe: str | None = None, launcher: Path | None = None) -> str:
129
+ """The /TR command line: ``"pythonw.exe" "<launcher>"`` (both quoted)."""
130
+ return f'"{exe or pythonw_exe()}" "{launcher or launcher_path()}"'
131
+
132
+
133
+ def install_argv(task_name: str = TASK_NAME, command: str | None = None) -> list[str]:
134
+ # ONLOGON so it starts at sign-in; LIMITED run level (no elevation); /F
135
+ # overwrites (idempotent re-install). /IT (interactive token) is LOAD-BEARING:
136
+ # without it schtasks makes an S4U task whose logon token cannot decrypt the
137
+ # per-user DPAPI Credential Locker — keyring.get_password() returns nothing and
138
+ # the rehydrated bridge comes up unauthenticated after a reboot, breaking the
139
+ # "resumes cleanly without re-login" promise. pythonw.exe stays windowless
140
+ # regardless of /IT.
141
+ return ["schtasks", "/Create", "/TN", task_name, "/TR", command or run_command(),
142
+ "/SC", "ONLOGON", "/RL", "LIMITED", "/IT", "/F"]
143
+
144
+
145
+ def uninstall_argv(task_name: str = TASK_NAME) -> list[str]:
146
+ return ["schtasks", "/Delete", "/TN", task_name, "/F"]
147
+
148
+
149
+ def status_argv(task_name: str = TASK_NAME) -> list[str]:
150
+ return ["schtasks", "/Query", "/TN", task_name]
151
+
152
+
153
+ def _win_start(exe: str | None = None, launcher: Path | None = None) -> tuple[bool, str]:
154
+ """Start the bridge NOW — windowless + detached, so it survives this process.
155
+ Mirrors research.py's detached daemon-loop spawn."""
156
+ exe = exe or pythonw_exe()
157
+ p = launcher or write_launcher()
158
+ detached = getattr(subprocess, "DETACHED_PROCESS", 0x00000008)
159
+ newgroup = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0x00000200)
160
+ no_window = getattr(subprocess, "CREATE_NO_WINDOW", 0x08000000)
161
+ try:
162
+ subprocess.Popen(
163
+ [exe, str(p)],
164
+ creationflags=detached | newgroup | no_window,
165
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
166
+ stdin=subprocess.DEVNULL, close_fds=True,
167
+ )
168
+ except (OSError, ValueError) as e: # pragma: no cover - env-specific
169
+ return False, str(e)
170
+ return True, ""
171
+
172
+
173
+ # ── Linux: systemd --user service ─────────────────────────────────────────────
174
+
175
+ def systemd_unit_path() -> Path:
176
+ return Path.home() / ".config" / "systemd" / "user" / SYSTEMD_UNIT
177
+
178
+
179
+ def systemd_unit_source(exe: str | None = None, launcher: Path | None = None) -> str:
180
+ e = exe or sys.executable
181
+ p = str(launcher or launcher_path())
182
+ return (
183
+ "[Unit]\n"
184
+ "Description=Super Agent bridge (Super Research)\n"
185
+ "After=network-online.target\n"
186
+ "\n"
187
+ "[Service]\n"
188
+ "Type=simple\n"
189
+ f'ExecStart="{e}" "{p}"\n' # quoted — systemd splits on spaces otherwise
190
+ "Restart=always\n"
191
+ "RestartSec=5\n"
192
+ "\n"
193
+ "[Install]\n"
194
+ "WantedBy=default.target\n"
195
+ )
196
+
197
+
198
+ def systemctl_argv(*verbs: str) -> list[str]:
199
+ return ["systemctl", "--user", *verbs]
200
+
201
+
202
+ def _write_systemd_unit() -> Path:
203
+ write_launcher()
204
+ up = systemd_unit_path()
205
+ up.parent.mkdir(parents=True, exist_ok=True)
206
+ up.write_text(systemd_unit_source(), encoding="utf-8")
207
+ return up
208
+
209
+
210
+ def _linux_install() -> tuple[bool, str]:
211
+ _write_systemd_unit()
212
+ ok, out = _exec(systemctl_argv("daemon-reload"))
213
+ if not ok:
214
+ return ok, out
215
+ return _exec(systemctl_argv("enable", SYSTEMD_UNIT))
216
+
217
+
218
+ def _linux_uninstall() -> tuple[bool, str]:
219
+ ok, out = _exec(systemctl_argv("disable", "--now", SYSTEMD_UNIT))
220
+ try:
221
+ systemd_unit_path().unlink(missing_ok=True)
222
+ except OSError:
223
+ pass
224
+ _rm_launcher()
225
+ if not ok:
226
+ return ok, out
227
+ # disable succeeded → the result that matters now is daemon-reload; surface its
228
+ # failure (with its own message) rather than swallowing it.
229
+ return _exec(systemctl_argv("daemon-reload"))
230
+
231
+
232
+ def _linux_status() -> tuple[bool, str]:
233
+ return _exec(systemctl_argv("is-enabled", SYSTEMD_UNIT))
234
+
235
+
236
+ def _linux_start() -> tuple[bool, str]:
237
+ return _exec(systemctl_argv("start", SYSTEMD_UNIT))
238
+
239
+
240
+ # ── macOS: launchd LaunchAgent ───────────────────────────────────────────────
241
+
242
+ def launchd_plist_path() -> Path:
243
+ return Path.home() / "Library" / "LaunchAgents" / f"{LAUNCHD_LABEL}.plist"
244
+
245
+
246
+ def _xml_escape(s: str) -> str:
247
+ return s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
248
+
249
+
250
+ def launchd_plist_source(exe: str | None = None, launcher: Path | None = None) -> str:
251
+ e = _xml_escape(exe or sys.executable)
252
+ p = _xml_escape(str(launcher or launcher_path()))
253
+ return (
254
+ '<?xml version="1.0" encoding="UTF-8"?>\n'
255
+ '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" '
256
+ '"http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n'
257
+ '<plist version="1.0">\n'
258
+ '<dict>\n'
259
+ f' <key>Label</key><string>{LAUNCHD_LABEL}</string>\n'
260
+ ' <key>ProgramArguments</key>\n'
261
+ ' <array>\n'
262
+ f' <string>{e}</string>\n'
263
+ f' <string>{p}</string>\n'
264
+ ' </array>\n'
265
+ ' <key>RunAtLoad</key><true/>\n'
266
+ ' <key>KeepAlive</key><true/>\n'
267
+ '</dict>\n'
268
+ '</plist>\n'
269
+ )
270
+
271
+
272
+ def launchctl_argv(*verbs: str) -> list[str]:
273
+ return ["launchctl", *verbs]
274
+
275
+
276
+ def _darwin_install() -> tuple[bool, str]:
277
+ write_launcher()
278
+ pl = launchd_plist_path()
279
+ pl.parent.mkdir(parents=True, exist_ok=True)
280
+ pl.write_text(launchd_plist_source(), encoding="utf-8")
281
+ return _exec(launchctl_argv("load", "-w", str(pl)))
282
+
283
+
284
+ def _darwin_uninstall() -> tuple[bool, str]:
285
+ pl = launchd_plist_path()
286
+ ok, out = _exec(launchctl_argv("unload", "-w", str(pl)))
287
+ try:
288
+ pl.unlink(missing_ok=True)
289
+ except OSError:
290
+ pass
291
+ _rm_launcher()
292
+ return ok, out
293
+
294
+
295
+ def _darwin_status() -> tuple[bool, str]:
296
+ return _exec(launchctl_argv("list", LAUNCHD_LABEL))
297
+
298
+
299
+ def _darwin_start() -> tuple[bool, str]:
300
+ return _exec(launchctl_argv("start", LAUNCHD_LABEL))
301
+
302
+
303
+ # ── dispatch (public API) ─────────────────────────────────────────────────────
304
+
305
+ def _unsupported() -> tuple[bool, str]:
306
+ return (False, f"run-on-startup isn't supported on this platform ({sys.platform})")
307
+
308
+
309
+ def supported() -> bool:
310
+ """Whether run-on-startup pinning is implemented for this OS."""
311
+ return is_windows() or is_linux() or is_macos()
312
+
313
+
314
+ def kind_label() -> str:
315
+ """What the login-pin is on this OS (for user-facing copy)."""
316
+ if is_windows():
317
+ return "Scheduled Task"
318
+ if is_linux():
319
+ return "systemd --user service"
320
+ if is_macos():
321
+ return "launchd LaunchAgent"
322
+ return "service"
323
+
324
+
325
+ def install(task_name: str = TASK_NAME) -> tuple[bool, str]:
326
+ """Pin the bridge to start on login — Windows Scheduled Task / Linux systemd
327
+ --user / macOS launchd. (``task_name`` only affects the Windows task name.)"""
328
+ if is_windows():
329
+ write_launcher()
330
+ return _exec(install_argv(task_name))
331
+ if is_linux():
332
+ return _linux_install()
333
+ if is_macos():
334
+ return _darwin_install()
335
+ return _unsupported()
336
+
337
+
338
+ def uninstall(task_name: str = TASK_NAME) -> tuple[bool, str]:
339
+ """Remove the login pin + the generated launcher (best-effort)."""
340
+ if is_windows():
341
+ ok, out = _exec(uninstall_argv(task_name))
342
+ _rm_launcher()
343
+ return ok, out
344
+ if is_linux():
345
+ return _linux_uninstall()
346
+ if is_macos():
347
+ return _darwin_uninstall()
348
+ return _unsupported()
349
+
350
+
351
+ def status(task_name: str = TASK_NAME) -> tuple[bool, str]:
352
+ if is_windows():
353
+ return _exec(status_argv(task_name))
354
+ if is_linux():
355
+ return _linux_status()
356
+ if is_macos():
357
+ return _darwin_status()
358
+ return _unsupported()
359
+
360
+
361
+ def is_installed(task_name: str = TASK_NAME) -> bool:
362
+ """Whether the login pin currently exists (False on an unsupported OS)."""
363
+ return status(task_name)[0]
364
+
365
+
366
+ def start_detached(exe: str | None = None, launcher: Path | None = None) -> tuple[bool, str]:
367
+ """Start the bridge NOW in the background (windowless on Windows; via the
368
+ service manager on Linux/macOS)."""
369
+ if is_windows():
370
+ return _win_start(exe, launcher)
371
+ if is_linux():
372
+ return _linux_start()
373
+ if is_macos():
374
+ return _darwin_start()
375
+ return _unsupported()
facade/branding.py ADDED
@@ -0,0 +1,275 @@
1
+ """Branded terminal UI for the Super Agent CLI.
2
+
3
+ A self-contained mirror of the backend's `--pair` aesthetic (research.py) so the
4
+ agent's interactive screens read as the same "Super Research" product. This
5
+ module deliberately imports NOTHING from the app (research.py) — the facade
6
+ stays an isolated sub-package (see test_app_plane_unchanged).
7
+
8
+ Pure rendering + a tiny `input()`-based picker. No network, no side effects
9
+ beyond stdout/stdin, so cli.py remains the only place that orchestrates I/O.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import itertools
15
+ import os
16
+ import shutil
17
+ import sys
18
+ import threading
19
+
20
+ # ── Color support (tty + Windows VT enable) — mirrors research.py ───────────
21
+ _USE_COLOR = False
22
+ try:
23
+ if sys.stdout.isatty() and "NO_COLOR" not in os.environ:
24
+ _USE_COLOR = True
25
+ if sys.platform == "win32":
26
+ # Enable ANSI escape processing on Win10+ consoles.
27
+ import ctypes
28
+
29
+ try:
30
+ _k32 = ctypes.windll.kernel32
31
+ _k32.SetConsoleMode(_k32.GetStdHandle(-11), 7)
32
+ except Exception:
33
+ pass
34
+ except Exception:
35
+ _USE_COLOR = False
36
+
37
+ # 256-color palette matching the app's "Super Research" brand.
38
+ _ACCENT = "\033[38;5;75m" # bright blue — matches the wordmark
39
+ _DIM = "\033[38;5;244m" # muted grey — auxiliary lines
40
+ _OK = "\033[38;5;108m" # muted green — success marks
41
+ _WARN = "\033[38;5;214m" # amber — warnings
42
+ _BRIGHT = "\033[38;5;231m" # glowing white
43
+ _RED = "\033[38;5;160m" # deep red — failure marks
44
+ _BOLD = "\033[1m"
45
+ _RESET = "\033[0m"
46
+
47
+ _SIGIL = "◆"
48
+ MARK_OK = "✓"
49
+ MARK_WARN = "⚠"
50
+ MARK_NO = "✗"
51
+ ARROW = "→"
52
+ CARET = "›"
53
+
54
+
55
+ def c(color: str, text: str) -> str:
56
+ """Wrap text in an ANSI color (no-op when color is disabled)."""
57
+ return f"{color}{text}{_RESET}" if _USE_COLOR else text
58
+
59
+
60
+ def rgb(r: int, g: int, b: int) -> str:
61
+ """TrueColor foreground escape — used for the runtime brand marks
62
+ (Hermes gold, OpenClaw orange). Degrades to no color off-tty."""
63
+ return f"\033[38;2;{r};{g};{b}m"
64
+
65
+
66
+ def rule(char: str = "─", color: str = _DIM, max_width: int = 62) -> str:
67
+ """Width-aware horizontal rule (sizes to the terminal, capped)."""
68
+ cols = shutil.get_terminal_size(fallback=(80, 24)).columns
69
+ width = max(10, min(max_width, cols - 4))
70
+ return c(color, char * width)
71
+
72
+
73
+ def header(tagline: str, gloss: str, *, tagline_color: str | None = None) -> None:
74
+ """The shared SUPER RESEARCH banner + a ◆ tagline · gloss line, so every
75
+ agent subcommand wears the same crown as `--pair` / `--resurrect`."""
76
+ tc = tagline_color or (_BOLD + _ACCENT)
77
+ bar = rule("━")
78
+ print()
79
+ print(f" {bar}")
80
+ print()
81
+ print(f" {c(_BOLD + _ACCENT, 'SUPER')} {c(_BOLD, 'RESEARCH')}")
82
+ print(
83
+ f" {c(tc, _SIGIL)} {c(tc, tagline)} "
84
+ f"{c(_DIM, '·')} {c(_DIM, gloss)}"
85
+ )
86
+ print()
87
+ print(f" {bar}")
88
+
89
+
90
+ def step_arc(steps: list[str]) -> None:
91
+ """Compact preview of the whole step sequence (like --pair's 'Five steps')."""
92
+ parts = []
93
+ for i, name in enumerate(steps, 1):
94
+ parts.append(f"{c(_ACCENT, str(i))} {name}")
95
+ print()
96
+ print(f" {c(_DIM, 'Steps:')} " + f" {c(_DIM, ARROW)} ".join(parts))
97
+
98
+
99
+ def step(n: int, total: int, title: str) -> None:
100
+ """Section header for one step inside a subcommand."""
101
+ print()
102
+ print(f" {c(_ACCENT + _BOLD, f'[{n}/{total}]')} {c(_BOLD, title)}")
103
+ print(f" {rule(max_width=58)}")
104
+
105
+
106
+ def ok(msg: str) -> None:
107
+ print(f" {c(_OK, MARK_OK)} {msg}")
108
+
109
+
110
+ def warn(msg: str) -> None:
111
+ print(f" {c(_WARN, MARK_WARN)} {msg}")
112
+
113
+
114
+ def no(msg: str) -> None:
115
+ print(f" {c(_RED, MARK_NO)} {msg}")
116
+
117
+
118
+ def dim(msg: str) -> None:
119
+ print(f" {c(_DIM, msg)}")
120
+
121
+
122
+ def line(msg: str = "") -> None:
123
+ print(f" {msg}" if msg else "")
124
+
125
+
126
+ def brand_mark(icon: str, color_rgb: tuple[int, int, int], label: str, suffix: str = "") -> str:
127
+ """A runtime brand chip: a full-glow brand-tinted glyph + a bold WHITE name —
128
+ only the SYMBOL carries the brand color, the text reads as normal bold text.
129
+ The tint lands on vector glyphs (⚚ → gold); emoji (🦞) ignore ANSI foreground
130
+ and keep their own native color — which is the intent.
131
+ e.g. ⚚ Hermes · WSL · Ubuntu-24.04"""
132
+ glyph = c(rgb(*color_rgb) + _BOLD, icon) # symbol: full-on glow (bold + brand color)
133
+ name = c(_BOLD + _BRIGHT, label) # name: bold white (normal-text look); only the glyph glows
134
+ tail = f" {c(_DIM, suffix)}" if suffix else ""
135
+ return f"{glyph} {name}{tail}"
136
+
137
+
138
+ def channels(items: list[tuple[str, tuple[int, int, int]]]) -> None:
139
+ """A row of the chat channels Super Research reaches, under the header — each
140
+ the channel NAME in its exact brand color (TrueColor), no glyph.
141
+
142
+ A terminal can't render the apps' real SVG logos (the way the web auth page
143
+ does), and stand-in emoji/vector glyphs read as subpar — so we show clean
144
+ brand-colored wordmarks instead, spaced apart (the colors do the separating;
145
+ no glyph or punctuation that could mojibake). items = (name, (r,g,b))."""
146
+ if not items:
147
+ return
148
+ chips = [c(rgb(*col), name) for name, col in items]
149
+ print()
150
+ print(f" {c(_DIM, 'reach it from')} " + " ".join(chips))
151
+
152
+
153
+ def next_actions(items: list[tuple[str, str]]) -> None:
154
+ """Compact 'Next' block — 2-3 likely follow-up commands with one-liners."""
155
+ if not items:
156
+ return
157
+ bar = rule("┈")
158
+ width = min(max(len(cmd) for cmd, _ in items), 44)
159
+ print()
160
+ print(f" {bar}")
161
+ print(f" {c(_DIM, 'Next')}")
162
+ for cmd, desc in items:
163
+ print(f" {c(_ACCENT, ARROW)} {c(_BOLD, cmd.ljust(width))} {c(_DIM, desc)}")
164
+ print()
165
+
166
+
167
+ def next_grouped(groups: list[tuple[str, list[tuple[str, str]]]]) -> None:
168
+ """Closing 'Next' block split into labelled groups — e.g. terminal commands
169
+ vs in-chat slash commands — so the user can tell them apart. Empty groups are
170
+ dropped. groups = [(group_label, [(cmd, desc), …]), …]."""
171
+ groups = [(lbl, items) for lbl, items in groups if items]
172
+ if not groups:
173
+ return
174
+ bar = rule("┈")
175
+ all_cmds = [cmd for _, items in groups for cmd, _ in items]
176
+ width = min(max((len(cmd) for cmd in all_cmds), default=0), 40)
177
+ print()
178
+ print(f" {bar}")
179
+ print(f" {c(_DIM, 'Next')}")
180
+ for label, items in groups:
181
+ print(f" {c(_BOLD + _ACCENT, label)}")
182
+ for cmd, desc in items:
183
+ print(f" {c(_ACCENT, ARROW)} {c(_BOLD, cmd.ljust(width))} {c(_DIM, desc)}")
184
+ print()
185
+
186
+
187
+ def ask(prompt: str, default: str = "", *, cancel_on_interrupt: bool = False) -> str | None:
188
+ """Blocking prompt with a branded caret.
189
+
190
+ On EOF/Ctrl-C: returns None when ``cancel_on_interrupt`` (so a caller can
191
+ treat an abort as cancel — distinct from an empty Enter that accepts the
192
+ default), otherwise returns ``default`` (the convenient behavior for
193
+ confirm-style prompts)."""
194
+ try:
195
+ ans = input(f" {c(_ACCENT, CARET)} {prompt} ").strip()
196
+ return ans or default
197
+ except (EOFError, KeyboardInterrupt):
198
+ print()
199
+ return None if cancel_on_interrupt else default
200
+
201
+
202
+ # ── progress spinner ────────────────────────────────────────────────────────
203
+ # Braille frames — smooth, single-column, widely rendered (same family the app's
204
+ # CLI uses). Animated only on a real TTY; everywhere else we degrade to a static
205
+ # line so a blocking step never just looks hung (and piped output stays clean).
206
+ _SPIN_FRAMES = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
207
+ _FRAME_SECONDS = 0.08
208
+
209
+
210
+ class _Spinner:
211
+ """A branded progress spinner for one blocking step that does NOT itself print.
212
+
213
+ Use as a context manager around the blocking call::
214
+
215
+ with branding.spinner("Installing the skill"):
216
+ connect.install(...)
217
+
218
+ On a TTY it animates a brand-tinted glyph on one rewritten line, then clears
219
+ it on exit so the caller's success/failure line prints clean. When stdout
220
+ isn't a TTY (piped/captured/windowless) it prints ONE static line instead —
221
+ never carriage-return spam. Best-effort and exception-safe: __exit__ always
222
+ clears the line and restores the cursor, including on Ctrl-C."""
223
+
224
+ def __init__(self, message: str, *, color: str = _ACCENT) -> None:
225
+ self.message = message
226
+ self.color = color
227
+ self._stop = threading.Event()
228
+ self._thread: threading.Thread | None = None
229
+ # Animate only when we can both color AND own a live terminal line.
230
+ self._animate = _USE_COLOR and sys.stdout.isatty()
231
+
232
+ def _run(self) -> None:
233
+ sys.stdout.write("\033[?25l") # hide the cursor while spinning
234
+ sys.stdout.flush()
235
+ for frame in itertools.cycle(_SPIN_FRAMES):
236
+ if self._stop.is_set():
237
+ break
238
+ sys.stdout.write(f"\r {self.color}{frame}{_RESET} {_DIM}{self.message}…{_RESET} ")
239
+ sys.stdout.flush()
240
+ self._stop.wait(_FRAME_SECONDS)
241
+
242
+ def __enter__(self) -> "_Spinner":
243
+ if self._animate:
244
+ self._thread = threading.Thread(target=self._run, daemon=True)
245
+ self._thread.start()
246
+ else:
247
+ print(f" {self.message}…")
248
+ return self
249
+
250
+ def __exit__(self, exc_type, exc, tb) -> bool:
251
+ if self._thread is not None:
252
+ self._stop.set()
253
+ self._thread.join(timeout=1.0)
254
+ sys.stdout.write("\r\033[K\033[?25h") # clear the line + restore the cursor
255
+ sys.stdout.flush()
256
+ return False # never suppress the wrapped block's exception
257
+
258
+
259
+ def spinner(message: str, *, color: str = _ACCENT) -> _Spinner:
260
+ """A branded progress spinner — `with spinner("…"): blocking_call()`.
261
+ Animates on a TTY, degrades to a static line otherwise (see _Spinner)."""
262
+ return _Spinner(message, color=color)
263
+
264
+
265
+ def confirm(prompt: str, default: bool = True) -> bool:
266
+ """Yes/No prompt. A bare Enter takes `default` (and sets the [Y/n] hint). A
267
+ Ctrl-C / EOF returns False — an interrupt must NEVER silently proceed as a
268
+ default 'yes' (e.g. it must not trigger an install or run a command in WSL)."""
269
+ hint = "[Y/n]" if default else "[y/N]"
270
+ ans = ask(f"{prompt} {c(_DIM, hint)}", cancel_on_interrupt=True)
271
+ if ans is None: # Ctrl-C / EOF → abort, not the default
272
+ return False
273
+ if not ans: # bare Enter → the default
274
+ return default
275
+ return ans.lower() in ("y", "yes")