@tsconfigdotjson/vibe-os 0.0.0-stage → 0.1.0

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 (63) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +928 -2
  3. package/THIRD_PARTY_NOTICES.md +2467 -0
  4. package/bin/vibe-os.mjs +13 -0
  5. package/dist/web/apple-touch-icon.png +0 -0
  6. package/dist/web/assets/index-B77Qk8B5.js +35 -0
  7. package/dist/web/assets/index-B77Qk8B5.js.br +0 -0
  8. package/dist/web/assets/index-B77Qk8B5.js.gz +0 -0
  9. package/dist/web/assets/index-CH5Em-Mo.css +1 -0
  10. package/dist/web/assets/index-CH5Em-Mo.css.br +0 -0
  11. package/dist/web/assets/index-CH5Em-Mo.css.gz +0 -0
  12. package/dist/web/icon-192.png +0 -0
  13. package/dist/web/icon-512.png +0 -0
  14. package/dist/web/icon-app.svg +19 -0
  15. package/dist/web/icon-maskable-512.png +0 -0
  16. package/dist/web/icon-maskable.svg +13 -0
  17. package/dist/web/icon.svg +15 -0
  18. package/dist/web/index.html +36 -0
  19. package/dist/web/ssh.wasm +0 -0
  20. package/dist/web/ssh.wasm.br +0 -0
  21. package/dist/web/ssh.wasm.gz +0 -0
  22. package/dist/web/sw.js +29 -0
  23. package/dist/web/wasm_exec.js +575 -0
  24. package/dist/web/wasm_exec.js.br +0 -0
  25. package/dist/web/wasm_exec.js.gz +0 -0
  26. package/package.json +82 -4
  27. package/scripts/compile.ts +62 -0
  28. package/scripts/compress.mjs +54 -0
  29. package/scripts/fetch-wasm.mjs +242 -0
  30. package/scripts/gen-assets.ts +76 -0
  31. package/scripts/lib/walk.ts +18 -0
  32. package/scripts/notices.ts +189 -0
  33. package/server/acme.ts +182 -0
  34. package/server/api.ts +515 -0
  35. package/server/assets.generated.ts +53 -0
  36. package/server/attach.ts +524 -0
  37. package/server/auth.ts +194 -0
  38. package/server/bridge.ts +145 -0
  39. package/server/browser.ts +519 -0
  40. package/server/cli.ts +843 -0
  41. package/server/config.ts +362 -0
  42. package/server/db.ts +262 -0
  43. package/server/doctor.ts +966 -0
  44. package/server/fsx.ts +88 -0
  45. package/server/harness.ts +654 -0
  46. package/server/http.ts +53 -0
  47. package/server/index.ts +469 -0
  48. package/server/log.ts +40 -0
  49. package/server/main.ts +7 -0
  50. package/server/mcp.ts +291 -0
  51. package/server/preflight.ts +87 -0
  52. package/server/profiles.ts +238 -0
  53. package/server/projects.ts +721 -0
  54. package/server/reaper.ts +303 -0
  55. package/server/runtime.ts +64 -0
  56. package/server/session.ts +330 -0
  57. package/server/ssh-ca.ts +368 -0
  58. package/server/static.ts +147 -0
  59. package/server/wallpapers.ts +248 -0
  60. package/server/windows.ts +237 -0
  61. package/shared/args.ts +91 -0
  62. package/shared/grid.ts +32 -0
  63. package/shared/wire.ts +226 -0
@@ -0,0 +1,654 @@
1
+ // What the agent CLIs on this machine can be told to do.
2
+ //
3
+ // The profile editor offers models and permission modes as dropdowns, which
4
+ // means something has to know what the valid values are. Hardcoding them would
5
+ // be wrong within a release or two — Claude ships new models faster than this
6
+ // project ships anything — so they are read off the binary that is actually
7
+ // installed. Update Claude Code and the dropdowns follow.
8
+ //
9
+ // Hermes answers a narrower version of the same question. It has no listing
10
+ // mode at all (`hermes model` is an interactive wizard), so what is discoverable
11
+ // is its own configuration: which providers it has been set up with, what it
12
+ // would use by default, and where its browser tools will land.
13
+ //
14
+ // Cursor sits between the two. `cursor-agent models` is a real listing mode,
15
+ // but it lists the account's models rather than the binary's — so the answer
16
+ // exists only while the box is logged in, and an empty list means "could not
17
+ // read" rather than "nothing to offer".
18
+
19
+ import { execFile } from "node:child_process";
20
+ import { readFile, stat } from "node:fs/promises";
21
+ import { promisify } from "node:util";
22
+ import { CHROME_UNIT_PATH, cdpUrlMatches, portsFromUnits } from "./browser.ts";
23
+ import { log } from "./log.ts";
24
+
25
+ const run = promisify(execFile);
26
+
27
+ import type { CursorInfo, HarnessInfo, HermesInfo } from "../shared/wire.ts";
28
+
29
+ export type { CursorInfo, HarnessInfo, HermesInfo };
30
+
31
+ /**
32
+ * The effort ladder as of writing, used only when the binary cannot be read.
33
+ *
34
+ * Unlike the model aliases, this is not merged with what was discovered. Effort
35
+ * is ordinal — the list *is* the scale, low to max — so a union of two sources
36
+ * would put a newly-added level in the wrong place and quietly mislabel how
37
+ * hard a role thinks. Either the binary's order or this one, never a blend.
38
+ */
39
+ const KNOWN_EFFORT = ["low", "medium", "high", "xhigh", "max"];
40
+
41
+ const EMPTY: HarnessInfo = {
42
+ available: false,
43
+ version: null,
44
+ aliases: [],
45
+ models: [],
46
+ permissionModes: [],
47
+ effortLevels: KNOWN_EFFORT,
48
+ };
49
+
50
+ /**
51
+ * Aliases the CLI documents but which do not appear as model ids.
52
+ *
53
+ * Kept as a floor rather than the source of truth: whatever is found in the
54
+ * binary is merged over the top, so a new tier appears on its own.
55
+ */
56
+ const KNOWN_ALIASES = [
57
+ "default",
58
+ "opus",
59
+ "sonnet",
60
+ "haiku",
61
+ "fable",
62
+ "opusplan",
63
+ ];
64
+
65
+ /**
66
+ * Where the native installers put things, when PATH does not say so.
67
+ *
68
+ * A login shell picks `~/.local/bin` up from `.profile`; a systemd service does
69
+ * not, and vibe-os is meant to run as one. Without this the server cannot find
70
+ * a Claude that the harness will happily launch a moment later, because the
71
+ * harness runs under a login shell and the server does not — so the dropdowns
72
+ * come up empty on exactly the deployment the docs recommend.
73
+ */
74
+ const CLAUDE_PATHS = [
75
+ `${process.env.HOME ?? ""}/.local/bin/claude`,
76
+ "/usr/local/bin/claude",
77
+ "/opt/claude/.local/bin/claude",
78
+ ];
79
+
80
+ /**
81
+ * The same problem for Hermes, whose installer offers two layouts.
82
+ *
83
+ * `/usr/local/bin/hermes` is its FHS default, chosen to match Claude Code and
84
+ * the Codex CLI; `~/.local/bin` is what it uses when it cannot write there.
85
+ */
86
+ const HERMES_PATHS = [
87
+ `${process.env.HOME ?? ""}/.local/bin/hermes`,
88
+ "/usr/local/bin/hermes",
89
+ ];
90
+
91
+ /**
92
+ * And again for the two commands that make Browser Use mode engage.
93
+ *
94
+ * `uv`'s installer writes to `~/.local/bin` and nowhere else, which is the same
95
+ * directory that neither a systemd service nor an `ssh host 'command'` has on
96
+ * PATH. Without this the check reported a missing uvx on a box that had one, and
97
+ * offered to install what was already installed.
98
+ */
99
+ const home = process.env.HOME ?? "";
100
+ const BROWSER_USE_PATHS = [
101
+ `${home}/.local/bin/browser-use`,
102
+ "/usr/local/bin/browser-use",
103
+ ];
104
+ const UVX_PATHS = [`${home}/.local/bin/uvx`, "/usr/local/bin/uvx"];
105
+
106
+ /**
107
+ * The commands that install what Hermes needs, named where they are checked.
108
+ *
109
+ * doctor prints them under a failed check and `connect-hermes` prints the first
110
+ * one when there is nothing to configure. Both used to be about to grow their
111
+ * own copy of a URL that has to be right.
112
+ */
113
+ export const HERMES_INSTALL =
114
+ "curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash";
115
+ export const UV_INSTALL = "curl -LsSf https://astral.sh/uv/install.sh | sh";
116
+
117
+ /** Resolves a command through any symlinks to the real executable. */
118
+ async function resolveBinary(
119
+ command: string,
120
+ extraPaths: string[],
121
+ ): Promise<string | null> {
122
+ const candidates: string[] = [];
123
+ try {
124
+ const { stdout } = await run(
125
+ "/bin/sh",
126
+ ["-c", 'command -v "$1"', "sh", command],
127
+ { timeout: 5_000 },
128
+ );
129
+ if (stdout.trim()) candidates.push(stdout.trim());
130
+ } catch {
131
+ // not on PATH — the explicit locations below may still have it
132
+ }
133
+ candidates.push(...extraPaths.filter(Boolean));
134
+
135
+ for (const candidate of candidates) {
136
+ try {
137
+ await stat(candidate);
138
+ const { stdout: real } = await run(
139
+ "/bin/sh",
140
+ ["-c", `readlink -f ${JSON.stringify(candidate)}`],
141
+ {
142
+ timeout: 5_000,
143
+ },
144
+ );
145
+ return real.trim() || candidate;
146
+ } catch {
147
+ // next
148
+ }
149
+ }
150
+ return null;
151
+ }
152
+
153
+ /**
154
+ * Model ids embedded in the executable.
155
+ *
156
+ * grep rather than reading the file in-process: the binary is ~270MB and the
157
+ * answer is a few hundred bytes, so streaming it through a matcher costs
158
+ * nothing while `readFile` would cost the whole thing in memory. Measured at
159
+ * about 0.13s, which is why this is done once at startup rather than cached to
160
+ * disk or fetched over the network.
161
+ */
162
+ async function embeddedModels(binary: string): Promise<string[]> {
163
+ try {
164
+ const { stdout } = await run(
165
+ "grep",
166
+ ["-aoE", "claude-(opus|sonnet|haiku|fable)-[0-9]+(-[0-9]+)?", binary],
167
+ { timeout: 30_000, maxBuffer: 4 * 1024 * 1024 },
168
+ );
169
+ const seen = new Set(
170
+ stdout
171
+ .split("\n")
172
+ .map((l) => l.trim())
173
+ .filter(Boolean),
174
+ );
175
+ // Newest first: people reach for the latest far more often than a pin to
176
+ // something old, and a sorted-ascending list buries it.
177
+ return [...seen].sort((a, b) =>
178
+ b.localeCompare(a, "en", { numeric: true }),
179
+ );
180
+ } catch {
181
+ return [];
182
+ }
183
+ }
184
+
185
+ /**
186
+ * `claude --help`, or empty if it cannot be run.
187
+ *
188
+ * Read once and handed to every parser below. Each `claude` invocation costs a
189
+ * couple of seconds of its own startup, and the help text answers more than one
190
+ * question — asking twice would double the slowest part of discovery to learn
191
+ * nothing new.
192
+ */
193
+ async function helpText(binary: string): Promise<string> {
194
+ try {
195
+ const { stdout } = await run(binary, ["--help"], {
196
+ timeout: 15_000,
197
+ maxBuffer: 4 * 1024 * 1024,
198
+ });
199
+ return stdout;
200
+ } catch {
201
+ return "";
202
+ }
203
+ }
204
+
205
+ /** The choices `--permission-mode` lists in its own help output. */
206
+ function permissionModes(help: string): string[] {
207
+ // Help text wraps, so the list is matched across newlines and whitespace.
208
+ const section =
209
+ /--permission-mode[\s\S]{0,400}?\(choices:([\s\S]{0,300}?)\)/.exec(help);
210
+ if (!section) return [];
211
+ return [...section[1].matchAll(/"([^"]+)"/g)].map((m) => m[1]);
212
+ }
213
+
214
+ /**
215
+ * The levels `--effort` lists in its own help output.
216
+ *
217
+ * `--permission-mode` prints a `(choices: "a", "b")` block this can anchor on;
218
+ * `--effort` prints a bare `(low, medium, high, xhigh, max)`, so the only
219
+ * anchor available is "the first parenthesis after the flag". That is loose
220
+ * enough to catch a sentence in some future release, which is what the shape
221
+ * check is for: every token has to look like a level, or the whole match is
222
+ * discarded and the caller falls back to the known ladder. Half a list read out
223
+ * of a paragraph would be worse than not reading one at all.
224
+ */
225
+ function effortLevels(help: string): string[] {
226
+ const section = /--effort[\s\S]{0,400}?\(([^)]{0,200})\)/.exec(help);
227
+ if (!section) return [];
228
+ const levels = section[1].split(",").map((s) => s.trim());
229
+ if (levels.length < 2 || !levels.every((l) => /^[a-z][a-z0-9-]*$/.test(l)))
230
+ return [];
231
+ return levels;
232
+ }
233
+
234
+ let cached: Promise<HarnessInfo> | null = null;
235
+
236
+ /**
237
+ * Everything the editor needs to offer, discovered once.
238
+ *
239
+ * Cached for the life of the process: it describes an installed binary, which
240
+ * does not change under a running server, and a restart is already what
241
+ * happens when someone upgrades it.
242
+ */
243
+ export function discoverClaude(): Promise<HarnessInfo> {
244
+ cached ??= (async (): Promise<HarnessInfo> => {
245
+ const binary = await resolveBinary("claude", CLAUDE_PATHS);
246
+ if (!binary) {
247
+ log.debug(
248
+ "claude is not on PATH — the profile editor will offer aliases only",
249
+ );
250
+ return { ...EMPTY, aliases: KNOWN_ALIASES };
251
+ }
252
+
253
+ // Concurrently, because each `claude` invocation costs a couple of seconds
254
+ // of its own startup and there is no reason to pay for them in series.
255
+ const [version, models, help] = await Promise.all([
256
+ run(binary, ["--version"], { timeout: 15_000 })
257
+ .then(({ stdout }) => stdout.trim().split(/\s+/)[0] ?? null)
258
+ .catch(() => null),
259
+ embeddedModels(binary),
260
+ helpText(binary),
261
+ ]);
262
+
263
+ const modes = permissionModes(help);
264
+ const effort = effortLevels(help);
265
+
266
+ // A tier that shows up in the binary but not in the list above still gets
267
+ // an alias, because that is how Claude names them.
268
+ const tiers = new Set(models.map((m) => m.split("-")[1]).filter(Boolean));
269
+ const aliases = [...new Set([...KNOWN_ALIASES, ...tiers])];
270
+
271
+ log.debug(
272
+ `claude ${version ?? "?"}: ${models.length} models, ${modes.length} permission modes, ` +
273
+ `${effort.length > 0 ? effort.join("/") : "no"} effort levels`,
274
+ );
275
+ return {
276
+ available: true,
277
+ version,
278
+ aliases,
279
+ models,
280
+ permissionModes: modes,
281
+ effortLevels: effort.length > 0 ? effort : KNOWN_EFFORT,
282
+ };
283
+ })();
284
+ return cached;
285
+ }
286
+
287
+ // ── Hermes ───────────────────────────────────────────────────────────────────
288
+
289
+ /**
290
+ * The reasoning ladder as of writing, used only when the help cannot be read.
291
+ *
292
+ * Not merged with what was discovered, for the reason `KNOWN_EFFORT` gives: the
293
+ * list is the scale, so a union of two sources would put a newly-added level in
294
+ * the wrong place and mislabel how hard a role thinks.
295
+ */
296
+ const KNOWN_REASONING = [
297
+ "none",
298
+ "minimal",
299
+ "low",
300
+ "medium",
301
+ "high",
302
+ "xhigh",
303
+ "max",
304
+ "ultra",
305
+ ];
306
+
307
+ const NO_HERMES: HermesInfo = {
308
+ available: false,
309
+ version: null,
310
+ providers: [],
311
+ defaultProvider: null,
312
+ defaultModel: null,
313
+ reasoningLevels: KNOWN_REASONING,
314
+ browser: {
315
+ cdpUrl: null,
316
+ backend: null,
317
+ cdpPort: null,
318
+ connected: false,
319
+ browserUse: false,
320
+ },
321
+ };
322
+
323
+ /**
324
+ * One `hermes config get <key> --json`, parsed, or null.
325
+ *
326
+ * The one machine-readable read Hermes offers. Everything downstream treats a
327
+ * null as "could not tell", never as "not set": a key that has never been
328
+ * written, a build that spells it differently and a Python traceback all arrive
329
+ * here the same way, and only the first of those is worth reporting as a fact.
330
+ */
331
+ async function hermesConfig(
332
+ binary: string,
333
+ key: string,
334
+ ): Promise<Record<string, unknown> | null> {
335
+ try {
336
+ const { stdout } = await run(binary, ["config", "get", key, "--json"], {
337
+ timeout: 20_000,
338
+ maxBuffer: 4 * 1024 * 1024,
339
+ });
340
+ const parsed: unknown = JSON.parse(stdout);
341
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed)
342
+ ? (parsed as Record<string, unknown>)
343
+ : null;
344
+ } catch {
345
+ return null;
346
+ }
347
+ }
348
+
349
+ /**
350
+ * The levels `--reasoning` lists in its own help output.
351
+ *
352
+ * The same job `effortLevels` does for Claude, against a different sentence:
353
+ * Hermes writes "Reasoning effort for this session: none, minimal, low, medium,
354
+ * high, xhigh, max, or ultra." across two wrapped lines, so the anchor is the
355
+ * first colon after the flag and the terminator is the full stop.
356
+ *
357
+ * Same shape check, for the same reason. That anchor is loose enough to catch a
358
+ * paragraph in some future release, so every token has to look like a level or
359
+ * the whole match is discarded and the caller falls back to the known ladder.
360
+ */
361
+ export function reasoningLevels(help: string): string[] {
362
+ const section = /--reasoning[\s\S]{0,200}?:\s*([\s\S]{0,200}?)\./.exec(help);
363
+ if (!section) return [];
364
+ const levels = section[1]
365
+ .split(/,|\bor\b/)
366
+ .map((s) => s.trim())
367
+ .filter(Boolean);
368
+ if (levels.length < 2 || !levels.every((l) => /^[a-z][a-z0-9-]*$/.test(l)))
369
+ return [];
370
+ return levels;
371
+ }
372
+
373
+ /**
374
+ * The version out of `hermes --version`, which is not one line.
375
+ *
376
+ * Claude prints `1.2.3 (Claude Code)` and the first token is the answer. Hermes
377
+ * prints a five-line block — its own version, the install directory, the Python
378
+ * it built against, the OpenAI SDK, and a sentence suggesting `hermes version` —
379
+ * so taking the last token of the whole thing yields "status." and taking the
380
+ * first yields "Hermes". Only the first line is about the agent, and the answer
381
+ * is the version-shaped token in it.
382
+ */
383
+ export function hermesVersion(stdout: string): string | null {
384
+ const first = stdout.split("\n")[0] ?? "";
385
+ const match = /\bv?(\d+(?:\.\d+)+[^\s()]*)/.exec(first);
386
+ return match ? match[1] : null;
387
+ }
388
+
389
+ /** A field of a config block, when it is a non-empty string. */
390
+ function str(
391
+ block: Record<string, unknown> | null,
392
+ key: string,
393
+ ): string | null {
394
+ const value = block?.[key];
395
+ return typeof value === "string" && value.trim() ? value.trim() : null;
396
+ }
397
+
398
+ /**
399
+ * The CDP port the installed Chrome unit actually opened.
400
+ *
401
+ * Read from the unit rather than from a default, for the same reason doctor
402
+ * does it: `install-browser` may have been given `--cdp-port` months ago, and
403
+ * comparing a configured URL against a port nothing is listening on would call a
404
+ * working box broken.
405
+ */
406
+ async function installedCdpPort(): Promise<number | null> {
407
+ const unit = await readFile(CHROME_UNIT_PATH, "utf8").catch(() => null);
408
+ if (!unit) return null;
409
+ return portsFromUnits({ chrome: unit }).cdpPort;
410
+ }
411
+
412
+ let hermesCached: Promise<HermesInfo> | null = null;
413
+
414
+ /**
415
+ * Everything the Hermes side of the editor needs, discovered once.
416
+ *
417
+ * Deliberately thinner than the Claude side. There is no model list here and
418
+ * there is not going to be one: `hermes model` is an interactive wizard, and a
419
+ * hardcoded list would be a guess about somebody else's account. What is real is
420
+ * what the box is configured for, so that is what is offered, and the model and
421
+ * provider fields stay free text with the discovered values as suggestions.
422
+ *
423
+ * No floor of well-known provider names either, unlike `KNOWN_ALIASES` above.
424
+ * A Claude alias works wherever Claude is installed; a Hermes provider id is
425
+ * account configuration, so offering one that is not on this box would suggest a
426
+ * choice that can only fail at launch.
427
+ */
428
+ export function discoverHermes(): Promise<HermesInfo> {
429
+ hermesCached ??= (async (): Promise<HermesInfo> => {
430
+ const binary = await resolveBinary("hermes", HERMES_PATHS);
431
+ if (!binary) {
432
+ log.debug("hermes is not on PATH — Hermes profiles will not launch");
433
+ return NO_HERMES;
434
+ }
435
+
436
+ // Concurrently: `hermes` is Python, so each invocation costs seconds of
437
+ // interpreter and import time before it does anything.
438
+ const [version, help, model, browser, providers, browserUse, cdpPort] =
439
+ await Promise.all([
440
+ run(binary, ["--version"], { timeout: 30_000 })
441
+ .then(({ stdout }) => hermesVersion(stdout))
442
+ .catch(() => null),
443
+ // `chat`, not the bare command: `--reasoning` is a flag of that
444
+ // subcommand, which is also the one a profile actually launches.
445
+ run(binary, ["chat", "--help"], {
446
+ timeout: 30_000,
447
+ maxBuffer: 4 * 1024 * 1024,
448
+ })
449
+ .then(({ stdout }) => stdout)
450
+ .catch(() => ""),
451
+ hermesConfig(binary, "model"),
452
+ hermesConfig(binary, "browser"),
453
+ hermesConfig(binary, "providers"),
454
+ // Presence, never a run. A cold `uvx browser-use` downloads the package
455
+ // before it prints anything, which would hold up startup for minutes on
456
+ // the one box where the answer matters least.
457
+ Promise.all([
458
+ resolveBinary("browser-use", BROWSER_USE_PATHS),
459
+ resolveBinary("uvx", UVX_PATHS),
460
+ ]).then(([direct, viaUvx]) => Boolean(direct || viaUvx)),
461
+ installedCdpPort(),
462
+ ]);
463
+
464
+ const defaultProvider = str(model, "provider");
465
+ const names = [
466
+ ...Object.keys(providers ?? {}).filter((n) => n.trim()),
467
+ ...(defaultProvider ? [defaultProvider] : []),
468
+ ];
469
+ const cdpUrl = str(browser, "cdp_url");
470
+ const reasoning = reasoningLevels(help);
471
+
472
+ log.debug(
473
+ `hermes ${version ?? "?"}: ${names.length} providers, ` +
474
+ `${reasoning.length > 0 ? reasoning.join("/") : "no"} reasoning levels, ` +
475
+ `browser ${cdpUrl ?? "unset"}, browser-use ${browserUse ? "runnable" : "missing"}`,
476
+ );
477
+
478
+ return {
479
+ available: true,
480
+ version,
481
+ providers: [...new Set(names)].sort(),
482
+ defaultProvider,
483
+ defaultModel: str(model, "default"),
484
+ reasoningLevels: reasoning.length > 0 ? reasoning : KNOWN_REASONING,
485
+ browser: {
486
+ cdpUrl,
487
+ backend: str(browser, "backend"),
488
+ cdpPort,
489
+ connected: cdpPort !== null && cdpUrlMatches(cdpUrl, cdpPort),
490
+ browserUse,
491
+ },
492
+ };
493
+ })();
494
+ return hermesCached;
495
+ }
496
+
497
+ // ── Cursor ───────────────────────────────────────────────────────────────────
498
+
499
+ /**
500
+ * Where Cursor's installer puts things, when PATH does not say so.
501
+ *
502
+ * `curl https://cursor.com/install | bash` unpacks a versioned bundle under
503
+ * `~/.local/share/cursor-agent/versions/` and symlinks `cursor-agent` (and a
504
+ * bare `agent`) into `~/.local/bin` — the same directory that neither a systemd
505
+ * service nor an `ssh host 'command'` has on PATH, for the same reason the
506
+ * Claude and Hermes lists above exist. `cursor-agent` is the name checked, not
507
+ * `agent`: both point at the same binary, and only one of them is unambiguous.
508
+ */
509
+ const CURSOR_PATHS = [
510
+ `${home}/.local/bin/cursor-agent`,
511
+ "/usr/local/bin/cursor-agent",
512
+ ];
513
+
514
+ /** The command that installs it, named where doctor checks for it. */
515
+ export const CURSOR_INSTALL = "curl https://cursor.com/install -fsS | bash";
516
+
517
+ const NO_CURSOR: CursorInfo = {
518
+ available: false,
519
+ version: null,
520
+ models: [],
521
+ defaultModel: null,
522
+ loggedIn: null,
523
+ };
524
+
525
+ /**
526
+ * The version out of `cursor-agent --version`.
527
+ *
528
+ * Cursor's CLI versions are date-shaped — `2026.08.11-e8db854`, one line and
529
+ * nothing else on a real install — rather than semver, which is still "digits,
530
+ * dots, then whatever" and the same token shape
531
+ * `hermesVersion` reads. Kept to the first line for the same reason as there:
532
+ * only the first line is a claim about the binary itself, and a wrapper or
533
+ * update notice printed after it must not become the answer.
534
+ */
535
+ export function cursorVersion(stdout: string): string | null {
536
+ const first = stdout.split("\n").find((l) => l.trim()) ?? "";
537
+ const match = /\bv?(\d+(?:\.\d+)+[^\s()]*)/.exec(first);
538
+ return match ? match[1] : null;
539
+ }
540
+
541
+ /**
542
+ * Model ids out of `cursor-agent models`.
543
+ *
544
+ * The format is verbatim from a real box: an `Available models` header, then
545
+ * one `id - Display Name` per line, then a `Tip:` sentence about `--model`.
546
+ * The default is marked inside the display half — `auto - Auto (default)`.
547
+ *
548
+ * The id is the machine half and the only part kept: it is what `--model`
549
+ * takes, and the display name repeats it in prose. A line has to open with an
550
+ * id-shaped token followed by the ` - ` separator (or nothing at all), which
551
+ * is what keeps the header and the tip out — both contain spaces where the
552
+ * separator would have to be. Half-reading prose would put "Tip" in a model
553
+ * dropdown, which is worse than missing a model.
554
+ *
555
+ * Order is kept as printed. Cursor leads with `auto` and its recommendations,
556
+ * which is a better sort than alphabetical for a 200-entry list.
557
+ */
558
+ export function parseCursorModels(stdout: string): {
559
+ models: string[];
560
+ defaultModel: string | null;
561
+ } {
562
+ const models: string[] = [];
563
+ let defaultModel: string | null = null;
564
+ for (const raw of stdout.split("\n")) {
565
+ const line = raw.trim();
566
+ if (!line) continue;
567
+ const match = /^([A-Za-z0-9][\w./-]*)(?:\s+-\s+(.*))?$/.exec(line);
568
+ if (!match) continue;
569
+ const [, id, label] = match;
570
+ if (!models.includes(id)) models.push(id);
571
+ // The marker word can share its parens: one account prints `(default)`,
572
+ // another `(current, default)`, so the test is for the word, anchored
573
+ // inside a parenthesised note rather than to the whole note.
574
+ if (
575
+ label &&
576
+ /\([^)]*\b(default|current|selected)\b[^)]*\)/i.test(label) &&
577
+ !defaultModel
578
+ )
579
+ defaultModel = id;
580
+ }
581
+ return { models, defaultModel };
582
+ }
583
+
584
+ /**
585
+ * What `cursor-agent status` says about being logged in, when it says anything.
586
+ *
587
+ * "Not logged in" contains "logged in", so the negative is checked first. A
588
+ * status this cannot read returns null rather than false: only a definite "no"
589
+ * should make the editor warn that a window will sit at a login prompt.
590
+ */
591
+ export function cursorLoggedIn(stdout: string): boolean | null {
592
+ const text = stdout.toLowerCase();
593
+ if (
594
+ /not\s+logged\s+in|logged\s+out|unauthenticated|no.*credentials/.test(text)
595
+ )
596
+ return false;
597
+ if (/logged\s+in|signed\s+in|authenticated/.test(text)) return true;
598
+ return null;
599
+ }
600
+
601
+ let cursorCached: Promise<CursorInfo> | null = null;
602
+
603
+ /**
604
+ * Everything the Cursor side of the editor needs, discovered once.
605
+ *
606
+ * Between the other two in what it can know. Unlike Hermes there is a listing
607
+ * mode — `cursor-agent models` — so the model field gets real suggestions. But
608
+ * the list is the account's rather than the binary's: logged out, or offline,
609
+ * it cannot be read, so an empty list is "could not tell" and the field stays
610
+ * free text. No floor of well-known model names, for the reason the Hermes
611
+ * provider list has none: offering a model this account does not have would
612
+ * suggest a choice that can only fail after launch.
613
+ */
614
+ export function discoverCursor(): Promise<CursorInfo> {
615
+ cursorCached ??= (async (): Promise<CursorInfo> => {
616
+ const binary = await resolveBinary("cursor-agent", CURSOR_PATHS);
617
+ if (!binary) {
618
+ log.debug(
619
+ "cursor-agent is not on PATH — Cursor profiles will not launch",
620
+ );
621
+ return NO_CURSOR;
622
+ }
623
+
624
+ // Concurrently, as ever: `models` and `status` may each go to the network,
625
+ // and there is no reason to pay for the round trips in series.
626
+ const [version, listed, loggedIn] = await Promise.all([
627
+ run(binary, ["--version"], { timeout: 15_000 })
628
+ .then(({ stdout }) => cursorVersion(stdout))
629
+ .catch(() => null),
630
+ run(binary, ["models"], { timeout: 30_000, maxBuffer: 4 * 1024 * 1024 })
631
+ .then(({ stdout }) => parseCursorModels(stdout))
632
+ // Logged out or offline it exits complaining; that is "could not
633
+ // read", which the empty list already says.
634
+ .catch(() => ({ models: [], defaultModel: null })),
635
+ run(binary, ["status"], { timeout: 30_000, maxBuffer: 4 * 1024 * 1024 })
636
+ .then(({ stdout }) => cursorLoggedIn(stdout))
637
+ .catch(() => null),
638
+ ]);
639
+
640
+ log.debug(
641
+ `cursor-agent ${version ?? "?"}: ${listed.models.length} models, ` +
642
+ `logged ${loggedIn === null ? "in?" : loggedIn ? "in" : "out"}`,
643
+ );
644
+
645
+ return {
646
+ available: true,
647
+ version,
648
+ models: listed.models,
649
+ defaultModel: listed.defaultModel,
650
+ loggedIn,
651
+ };
652
+ })();
653
+ return cursorCached;
654
+ }