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 +19 -0
- facade/__main__.py +4 -0
- facade/autostart.py +375 -0
- facade/branding.py +275 -0
- facade/bridge.py +1861 -0
- facade/cli.py +1358 -0
- facade/config.py +132 -0
- facade/connect.py +571 -0
- facade/devicelogin.py +111 -0
- facade/firestore_rest.py +424 -0
- facade/logsetup.py +94 -0
- facade/prefs.py +216 -0
- facade/runview.py +90 -0
- facade/session.py +259 -0
- facade/skill/SKILL.md +237 -0
- facade/skill/scripts/sr.py +736 -0
- facade/skill/scripts/sr_attention_poll.py +216 -0
- facade/store.py +144 -0
- facade/web/icons/chatgpt.png +0 -0
- facade/web/icons/claude.png +0 -0
- facade/web/icons/notebooklm.png +0 -0
- facade/web/login.html +535 -0
- superresearch_agent-0.1.0.dist-info/METADATA +241 -0
- superresearch_agent-0.1.0.dist-info/RECORD +27 -0
- superresearch_agent-0.1.0.dist-info/WHEEL +5 -0
- superresearch_agent-0.1.0.dist-info/entry_points.txt +3 -0
- superresearch_agent-0.1.0.dist-info/top_level.txt +1 -0
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
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("&", "&").replace("<", "<").replace(">", ">")
|
|
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")
|