waitspin 0.1.2 → 0.1.4

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.
package/dist/cli.js CHANGED
@@ -1,12 +1,25 @@
1
1
  #!/usr/bin/env node
2
+ import { execFile } from "node:child_process";
2
3
  import { randomUUID } from "node:crypto";
3
4
  import { realpathSync } from "node:fs";
4
5
  import { constants as fsConstants } from "node:fs";
5
- import { access, cp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
6
+ import { access, chmod, cp, mkdir, readFile, rename, rm, writeFile, } from "node:fs/promises";
6
7
  import os from "node:os";
7
8
  import path from "node:path";
8
9
  const DEFAULT_BASE_URL = process.env.WAITSPIN_BASE_URL?.trim() || "https://api.waitspin.com";
10
+ const PRODUCTION_API_ORIGIN = "https://api.waitspin.com";
9
11
  const REQUEST_TIMEOUT_MS = 30_000;
12
+ const DEV_API_BASE_OPT_IN_ENV = "WAITSPIN_ALLOW_DEV_API_BASE";
13
+ const DEV_EXTENSION_ASSETS_OPT_IN_ENV = "WAITSPIN_ALLOW_DEV_EXTENSION_ASSETS";
14
+ const CLAUDE_CODE_BIN_ENV = "WAITSPIN_CLAUDE_CODE_BIN";
15
+ const CLAUDE_CODE_MIN_VERSION = "2.1.97";
16
+ const CLAUDE_CODE_PUBLISHER_TARGET = "claude-code";
17
+ const CLAUDE_CODE_REFRESH_INTERVAL_SECONDS = 5;
18
+ const MIMOCODE_BIN_ENV = "WAITSPIN_MIMOCODE_BIN";
19
+ const MIMOCODE_DEFAULT_BIN = "mimo";
20
+ const OPENCODE_PUBLISHER_TARGET = "opencode";
21
+ const OPENCODE_BIN_ENV = "WAITSPIN_OPENCODE_BIN";
22
+ const OPENCODE_DEFAULT_BIN = "opencode";
10
23
  const extensionTargets = {
11
24
  vscode: "vscode",
12
25
  };
@@ -26,11 +39,22 @@ export function usageText() {
26
39
  " waitspin extension install [--target vscode] [--base-url URL] [--api-key KEY] [--dry-run]",
27
40
  " waitspin extension status [--target vscode]",
28
41
  " waitspin extension uninstall [--target vscode] [--dry-run]",
42
+ " waitspin install --all [--api-key KEY] [--compose-existing] [--dry-run]",
43
+ " waitspin status --all",
44
+ " waitspin claude-code install [--api-key KEY] [--compose-existing] [--dry-run]",
45
+ " waitspin claude-code status",
46
+ " waitspin claude-code uninstall [--dry-run]",
47
+ " waitspin mimocode install [--api-key KEY] [--dry-run]",
48
+ " waitspin mimocode status",
49
+ " waitspin mimocode uninstall [--dry-run]",
50
+ " waitspin opencode install [--api-key KEY] [--dry-run]",
51
+ " waitspin opencode status",
52
+ " waitspin opencode uninstall [--dry-run]",
29
53
  "",
30
54
  "Defaults:",
31
55
  " API base: https://api.waitspin.com",
32
56
  " API key: WAITSPIN_API_KEY env var",
33
- " Public extension target: VS Code status-bar fallback",
57
+ " Public publisher targets: status-bar-fallback, claude-code, mimocode, opencode",
34
58
  ].join("\n") + "\n");
35
59
  }
36
60
  function usage(exitCode = 1) {
@@ -67,7 +91,11 @@ function parseArgs(argv) {
67
91
  }
68
92
  if (key === "dry-run" ||
69
93
  key === "allow-debug-auto-verify" ||
70
- key === "confirm-test-transfer") {
94
+ key === "allow-dev-api-base" ||
95
+ key === "allow-dev-extension-assets" ||
96
+ key === "confirm-test-transfer" ||
97
+ key === "compose-existing" ||
98
+ key === "all") {
71
99
  flags.set(key, ["true"]);
72
100
  continue;
73
101
  }
@@ -99,6 +127,51 @@ function booleanFlag(flags, name) {
99
127
  function resolveBaseUrl(flags) {
100
128
  return optionalFlag(flags, "base-url") || DEFAULT_BASE_URL;
101
129
  }
130
+ function allowDevApiBase(flags) {
131
+ return (booleanFlag(flags, "allow-dev-api-base") ||
132
+ process.env[DEV_API_BASE_OPT_IN_ENV] === "1");
133
+ }
134
+ function allowDevExtensionAssets(flags) {
135
+ return (booleanFlag(flags, "allow-dev-extension-assets") ||
136
+ process.env[DEV_EXTENSION_ASSETS_OPT_IN_ENV] === "1");
137
+ }
138
+ function isLoopbackApiHostname(hostname) {
139
+ const normalized = hostname.toLowerCase().replace(/\.$/, "");
140
+ return (normalized === "localhost" ||
141
+ normalized === "::1" ||
142
+ normalized === "[::1]" ||
143
+ /^127(?:\.\d{1,3}){3}$/.test(normalized));
144
+ }
145
+ function normalizeOriginUrl(value) {
146
+ let parsed;
147
+ try {
148
+ parsed = new URL(value);
149
+ }
150
+ catch {
151
+ throw new Error("Invalid WaitSpin API base URL. Use an http(s) origin without credentials, path, query, or fragment.");
152
+ }
153
+ if ((parsed.protocol !== "https:" && parsed.protocol !== "http:") ||
154
+ parsed.username ||
155
+ parsed.password ||
156
+ parsed.search ||
157
+ parsed.hash ||
158
+ (parsed.pathname !== "" && parsed.pathname !== "/")) {
159
+ throw new Error("Invalid WaitSpin API base URL. Use an http(s) origin without credentials, path, query, or fragment.");
160
+ }
161
+ return parsed;
162
+ }
163
+ function resolveCredentialedBaseUrl(flags) {
164
+ const parsed = normalizeOriginUrl(resolveBaseUrl(flags));
165
+ if (parsed.protocol === "https:" && parsed.origin === PRODUCTION_API_ORIGIN) {
166
+ return parsed.origin;
167
+ }
168
+ if (allowDevApiBase(flags) &&
169
+ (parsed.protocol === "http:" || parsed.protocol === "https:") &&
170
+ isLoopbackApiHostname(parsed.hostname)) {
171
+ return parsed.origin;
172
+ }
173
+ throw new Error(`Refusing to send WaitSpin credentials to a non-production API origin. Use ${PRODUCTION_API_ORIGIN} or set ${DEV_API_BASE_OPT_IN_ENV}=1 / --allow-dev-api-base with a loopback http(s) origin for local development.`);
174
+ }
102
175
  function resolveApiKey(flags) {
103
176
  return optionalFlag(flags, "api-key") || process.env.WAITSPIN_API_KEY?.trim();
104
177
  }
@@ -118,7 +191,11 @@ function resolveKeyIntendedUse(flags) {
118
191
  function requireApiKey(flags) {
119
192
  const apiKey = resolveApiKey(flags);
120
193
  if (!apiKey) {
121
- throw new Error("Missing API key. Set WAITSPIN_API_KEY or pass --api-key");
194
+ throw new Error([
195
+ "Missing API key. Set WAITSPIN_API_KEY or pass --api-key.",
196
+ "Next command:",
197
+ " export WAITSPIN_API_KEY='PASTE_KEY_HERE'",
198
+ ].join("\n"));
122
199
  }
123
200
  return apiKey;
124
201
  }
@@ -153,8 +230,76 @@ async function requestJson(input, init) {
153
230
  }
154
231
  return { status: response.status, body: payload };
155
232
  }
156
- function printJson(value) {
233
+ let jsonPrinter = (value) => {
157
234
  process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
235
+ };
236
+ function printJson(value) {
237
+ jsonPrinter(value);
238
+ }
239
+ async function capturePrintedJson(fn) {
240
+ const previousPrinter = jsonPrinter;
241
+ let captured;
242
+ jsonPrinter = (value) => {
243
+ captured = value;
244
+ };
245
+ try {
246
+ await fn();
247
+ }
248
+ finally {
249
+ jsonPrinter = previousPrinter;
250
+ }
251
+ return captured;
252
+ }
253
+ function keyProfileFromIntendedUse(intendedUse) {
254
+ if (intendedUse === "key_profile:publisher_extension") {
255
+ return "publisher_extension";
256
+ }
257
+ if (intendedUse === "key_profile:control") {
258
+ return "control";
259
+ }
260
+ return null;
261
+ }
262
+ function keyProfileFlagFromIntendedUse(intendedUse) {
263
+ const profile = keyProfileFromIntendedUse(intendedUse);
264
+ if (profile === "publisher_extension") {
265
+ return " --key-profile publisher-extension";
266
+ }
267
+ if (profile === "control") {
268
+ return " --key-profile control";
269
+ }
270
+ return "";
271
+ }
272
+ function waitspinInitVerifyCommand(input) {
273
+ return `waitspin init --email ${input.email} --code CODE_FROM_EMAIL${keyProfileFlagFromIntendedUse(input.intendedUse)}`;
274
+ }
275
+ function nextCommandsForVerifiedKey(input) {
276
+ const scopes = input.scopes ?? [];
277
+ const publisherKey = keyProfileFromIntendedUse(input.intendedUse) === "publisher_extension" ||
278
+ (scopes.includes("publishers:write") &&
279
+ scopes.includes("serve:read") &&
280
+ scopes.includes("events:write") &&
281
+ !scopes.includes("campaigns:write"));
282
+ if (publisherKey) {
283
+ return {
284
+ next: "install_publisher_target",
285
+ next_commands: [
286
+ "export WAITSPIN_API_KEY='PASTE_KEY_HERE'",
287
+ "waitspin install --all --dry-run --compose-existing",
288
+ "waitspin install --all --compose-existing",
289
+ "waitspin status --all",
290
+ ],
291
+ human_message: "Use this publisher-extension key only for publisher install, serve polling, and impressions. Rotate it if it appears in logs.",
292
+ };
293
+ }
294
+ return {
295
+ next: "create_campaign_or_inspect_market",
296
+ next_commands: [
297
+ "export WAITSPIN_API_KEY='PASTE_KEY_HERE'",
298
+ "waitspin market",
299
+ 'waitspin bid create --line "Your ad" --url https://example.com --price-per-block 500 --blocks 1',
300
+ ],
301
+ human_message: "Use this control key for advertiser and wallet commands. Keep it out of shell history and logs.",
302
+ };
158
303
  }
159
304
  async function runInit(flags) {
160
305
  const baseUrl = resolveBaseUrl(flags);
@@ -172,7 +317,15 @@ async function runInit(flags) {
172
317
  ...(intendedUse ? { intended_use: intendedUse } : {}),
173
318
  }),
174
319
  });
175
- printJson({ ok: true, base_url: baseUrl, ...body });
320
+ printJson({
321
+ ok: true,
322
+ base_url: baseUrl,
323
+ ...body,
324
+ ...nextCommandsForVerifiedKey({
325
+ intendedUse,
326
+ scopes: body?.scopes,
327
+ }),
328
+ });
176
329
  return;
177
330
  }
178
331
  const { body: requestResult } = await requestJson(`${baseUrl}/v1/keys/request`, {
@@ -189,10 +342,13 @@ async function runInit(flags) {
189
342
  if (!code || !allowDebugAutoVerify) {
190
343
  printJson({
191
344
  ok: true,
192
- next: "check_email_for_code",
345
+ next: "enter_email_code",
193
346
  delivery: requestResult?.delivery,
194
347
  email,
195
- debug_code_available: Boolean(code),
348
+ expires_in_seconds: requestResult?.expires_in_seconds ?? 900,
349
+ next_command: waitspinInitVerifyCommand({ email, intendedUse }),
350
+ human_message: "Check your email for the 6-digit WaitSpin code. Paste it as CODE_FROM_EMAIL.",
351
+ ...(code ? { debug_code_available: true } : {}),
196
352
  });
197
353
  return;
198
354
  }
@@ -205,10 +361,18 @@ async function runInit(flags) {
205
361
  ...(intendedUse ? { intended_use: intendedUse } : {}),
206
362
  }),
207
363
  });
208
- printJson({ ok: true, base_url: baseUrl, ...verifyResult });
364
+ printJson({
365
+ ok: true,
366
+ base_url: baseUrl,
367
+ ...verifyResult,
368
+ ...nextCommandsForVerifiedKey({
369
+ intendedUse,
370
+ scopes: verifyResult?.scopes,
371
+ }),
372
+ });
209
373
  }
210
374
  async function runBidCreate(flags) {
211
- const baseUrl = resolveBaseUrl(flags);
375
+ const baseUrl = resolveCredentialedBaseUrl(flags);
212
376
  const apiKey = requireApiKey(flags);
213
377
  const adLine = requireFlag(flags, "line");
214
378
  const destinationUrl = requireFlag(flags, "url");
@@ -237,7 +401,7 @@ async function runBidCreate(flags) {
237
401
  printJson({ ok: true, ...body });
238
402
  }
239
403
  async function runBidsList(flags) {
240
- const baseUrl = resolveBaseUrl(flags);
404
+ const baseUrl = resolveCredentialedBaseUrl(flags);
241
405
  const apiKey = requireApiKey(flags);
242
406
  const { body } = await requestJson(`${baseUrl}/v1/campaigns`, {
243
407
  method: "GET",
@@ -248,9 +412,9 @@ async function runBidsList(flags) {
248
412
  async function runBidCheckout(flags, positionals) {
249
413
  const campaignId = positionals[0]?.trim();
250
414
  if (!campaignId) {
251
- throw new Error("Usage: waitspin bid checkout <campaign-id>");
415
+ throw new Error("Usage: waitspin bid checkout CAMPAIGN_ID");
252
416
  }
253
- const baseUrl = resolveBaseUrl(flags);
417
+ const baseUrl = resolveCredentialedBaseUrl(flags);
254
418
  const apiKey = requireApiKey(flags);
255
419
  const { body } = await requestJson(`${baseUrl}/v1/blocks/checkout`, {
256
420
  method: "POST",
@@ -266,7 +430,7 @@ async function runBidCheckout(flags, positionals) {
266
430
  checkout_disclosure: {
267
431
  terms_url: "https://waitspin.com/waitspin/terms",
268
432
  privacy_url: "https://waitspin.com/waitspin/privacy",
269
- refund_policy: "Unused prepaid blocks are account credit unless a cash refund is required by law or approved through support review. Self-serve cash refund requests are not shipped.",
433
+ refund_policy: "Unused prepaid block handling is support-reviewed. No automated account-credit balance, redemption flow, or self-serve cash refund request flow is shipped.",
270
434
  },
271
435
  });
272
436
  }
@@ -276,7 +440,7 @@ async function runMarket(flags) {
276
440
  printJson({ ok: true, ...body });
277
441
  }
278
442
  async function runWalletStatus(flags) {
279
- const baseUrl = resolveBaseUrl(flags);
443
+ const baseUrl = resolveCredentialedBaseUrl(flags);
280
444
  const apiKey = requireApiKey(flags);
281
445
  const { body } = await requestJson(`${baseUrl}/v1/wallet/status`, {
282
446
  method: "GET",
@@ -285,7 +449,7 @@ async function runWalletStatus(flags) {
285
449
  printJson({ ok: true, ...body });
286
450
  }
287
451
  async function runWalletConnect(flags) {
288
- const baseUrl = resolveBaseUrl(flags);
452
+ const baseUrl = resolveCredentialedBaseUrl(flags);
289
453
  const apiKey = requireApiKey(flags);
290
454
  const { body } = await requestJson(`${baseUrl}/v1/wallet/connect`, {
291
455
  method: "POST",
@@ -294,7 +458,7 @@ async function runWalletConnect(flags) {
294
458
  printJson({ ok: true, ...body });
295
459
  }
296
460
  async function runWalletLedger(flags) {
297
- const baseUrl = resolveBaseUrl(flags);
461
+ const baseUrl = resolveCredentialedBaseUrl(flags);
298
462
  const apiKey = requireApiKey(flags);
299
463
  const limit = optionalFlag(flags, "limit");
300
464
  const url = new URL(`${baseUrl}/v1/wallet/ledger`);
@@ -308,7 +472,7 @@ async function runWalletLedger(flags) {
308
472
  printJson({ ok: true, ...body });
309
473
  }
310
474
  async function runWalletPayout(flags) {
311
- const baseUrl = resolveBaseUrl(flags);
475
+ const baseUrl = resolveCredentialedBaseUrl(flags);
312
476
  const apiKey = requireApiKey(flags);
313
477
  const dryRun = booleanFlag(flags, "dry-run");
314
478
  const confirmTestTransfer = booleanFlag(flags, "confirm-test-transfer");
@@ -342,7 +506,16 @@ function extensionInstallDir(_target) {
342
506
  return path.join(os.homedir(), ".vscode", "extensions");
343
507
  }
344
508
  function vscodeExtensionInstallDir(manifest) {
345
- return path.join(extensionInstallDir("vscode"), `waitspin.${manifest.name}-${manifest.version}`);
509
+ const extensionRoot = path.resolve(extensionInstallDir("vscode"));
510
+ const installName = `${manifest.publisher}.${manifest.name}-${manifest.version}`;
511
+ if (!/^[a-z0-9][a-z0-9.-]*$/.test(installName)) {
512
+ throw new Error("Refusing to install extension with unsafe manifest name.");
513
+ }
514
+ const installPath = path.resolve(extensionRoot, installName);
515
+ if (!installPath.startsWith(`${extensionRoot}${path.sep}`)) {
516
+ throw new Error("Refusing to install extension outside the VS Code root.");
517
+ }
518
+ return installPath;
346
519
  }
347
520
  function installStatePath(target) {
348
521
  return path.join(os.homedir(), ".waitspin", `${target}-install.json`);
@@ -389,40 +562,92 @@ function assertManagedVscodeExtensionPath(input) {
389
562
  return resolved;
390
563
  }
391
564
  async function registerPublisherInstall(input) {
392
- const { body } = await requestJson(`${input.baseUrl}/v1/publishers/register`, {
393
- method: "POST",
394
- headers: {
395
- "Content-Type": "application/json",
396
- Authorization: `Bearer ${input.apiKey}`,
397
- },
398
- body: JSON.stringify({
399
- install_id: input.installId,
400
- target: input.target,
401
- }),
402
- });
565
+ let body = null;
566
+ try {
567
+ ({ body } = await requestJson(`${input.baseUrl}/v1/publishers/register`, {
568
+ method: "POST",
569
+ headers: {
570
+ "Content-Type": "application/json",
571
+ Authorization: `Bearer ${input.apiKey}`,
572
+ },
573
+ body: JSON.stringify({
574
+ install_id: input.installId,
575
+ target: input.target,
576
+ }),
577
+ }));
578
+ }
579
+ catch (error) {
580
+ if (input.target === CLAUDE_CODE_PUBLISHER_TARGET &&
581
+ error instanceof WaitSpinCliHttpError &&
582
+ error.status === 400 &&
583
+ /status-bar-fallback|Invalid input|Validation error/.test(error.message)) {
584
+ throw new Error([
585
+ 'WaitSpin API rejected target "claude-code".',
586
+ "Your local CLI supports Claude Code, but the selected API base does not.",
587
+ "",
588
+ "For production:",
589
+ " deploy the backend target allowlist, then rerun:",
590
+ " waitspin claude-code install --compose-existing",
591
+ "",
592
+ "For developer-local acceptance only, use an explicit loopback API with --allow-dev-api-base.",
593
+ ].join("\n"));
594
+ }
595
+ if (input.target === OPENCODE_PUBLISHER_TARGET &&
596
+ error instanceof WaitSpinCliHttpError &&
597
+ error.status === 400 &&
598
+ /Invalid input|Validation error/.test(error.message)) {
599
+ throw new Error([
600
+ `WaitSpin API rejected target "${OPENCODE_PUBLISHER_TARGET}".`,
601
+ "Your local CLI supports OpenCode, but the selected API base does not.",
602
+ "",
603
+ "For production:",
604
+ " deploy the backend target allowlist, then rerun:",
605
+ ` waitspin ${OPENCODE_PUBLISHER_TARGET} install`,
606
+ "",
607
+ "For developer-local acceptance only, use an explicit loopback API with --allow-dev-api-base.",
608
+ ].join("\n"));
609
+ }
610
+ throw error;
611
+ }
403
612
  if (!body) {
404
613
  throw new Error("Publisher registration returned empty body");
405
614
  }
406
615
  return body;
407
616
  }
408
- async function resolveExtensionDir() {
617
+ async function resolveExtensionDir(flags) {
409
618
  const packageRoot = resolveCliPackageRoot();
410
619
  const candidates = [
411
- path.join(process.cwd(), "packages/waitspin/assets/waitspin-vscode"),
412
- path.join(process.cwd(), "extensions/waitspin-vscode"),
413
620
  path.join(packageRoot, "assets", "waitspin-vscode"),
414
- path.join(packageRoot, "../../extensions/waitspin-vscode"),
415
621
  ];
622
+ if (allowDevExtensionAssets(flags)) {
623
+ candidates.push(path.join(packageRoot, "../../extensions/waitspin-vscode"));
624
+ }
416
625
  for (const candidate of candidates) {
626
+ const resolved = path.resolve(candidate);
417
627
  try {
418
- await access(path.join(candidate, "package.json"), fsConstants.F_OK);
419
- return candidate;
628
+ await access(path.join(resolved, "package.json"), fsConstants.F_OK);
629
+ return resolved;
420
630
  }
421
631
  catch {
422
632
  continue;
423
633
  }
424
634
  }
425
- throw new Error("WaitSpin extension package not found. Install from the WaitSpin checkout or use a build that ships assets/waitspin-vscode.");
635
+ throw new Error(`WaitSpin extension package not found. Use a build that ships assets/waitspin-vscode or set ${DEV_EXTENSION_ASSETS_OPT_IN_ENV}=1 / --allow-dev-extension-assets from a trusted checkout.`);
636
+ }
637
+ function parseVscodeExtensionManifest(value) {
638
+ if (!value || typeof value !== "object") {
639
+ throw new Error("Extension manifest must be a JSON object.");
640
+ }
641
+ const manifest = value;
642
+ const name = typeof manifest.name === "string" ? manifest.name.trim() : "";
643
+ const publisher = typeof manifest.publisher === "string" ? manifest.publisher.trim() : "";
644
+ const version = typeof manifest.version === "string" ? manifest.version.trim() : "";
645
+ if (name !== "waitspin-vscode" ||
646
+ publisher !== "waitspin" ||
647
+ !/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(version)) {
648
+ throw new Error("Unexpected WaitSpin VS Code extension manifest. Refusing to construct install path.");
649
+ }
650
+ return { name, publisher, version };
426
651
  }
427
652
  function resolveCliPackageRoot() {
428
653
  if (!process.argv[1])
@@ -451,17 +676,18 @@ async function installVscodeExtensionRuntime(input) {
451
676
  }
452
677
  export async function runExtensionInstall(flags) {
453
678
  const target = resolveExtensionTarget(flags);
454
- const baseUrl = resolveBaseUrl(flags);
679
+ const baseUrl = resolveCredentialedBaseUrl(flags);
455
680
  const publisherTarget = publisherTargetForExtension(target);
456
- const extensionDir = await resolveExtensionDir();
681
+ const extensionDir = await resolveExtensionDir(flags);
457
682
  const manifestPath = path.join(extensionDir, "package.json");
458
- let manifest;
683
+ let manifestJson;
459
684
  try {
460
- manifest = JSON.parse(await readFile(manifestPath, "utf8"));
685
+ manifestJson = JSON.parse(await readFile(manifestPath, "utf8"));
461
686
  }
462
687
  catch {
463
688
  throw new Error(`Extension package not found at ${extensionDir}.`);
464
689
  }
690
+ const manifest = parseVscodeExtensionManifest(manifestJson);
465
691
  const installDir = extensionInstallDir(target);
466
692
  const statePath = installStatePath(target);
467
693
  const existingState = await loadInstallState(statePath);
@@ -482,10 +708,9 @@ export async function runExtensionInstall(flags) {
482
708
  set_vscode_settings: {
483
709
  "waitspin.installId": installId,
484
710
  },
485
- credential_storage: "The VS Code extension migrates waitspin.apiKey user settings into SecretStorage; WAITSPIN_API_KEY remains env-only.",
711
+ credential_storage: "The VS Code extension migrates a one-time waitspin.apiKey user setting into SecretStorage; runtime polling reads SecretStorage only.",
486
712
  optional_bootstrap_env: {
487
713
  WAITSPIN_INSTALL_ID: installId,
488
- WAITSPIN_API_KEY: "<temporary publisher-extension key for first activation>",
489
714
  },
490
715
  },
491
716
  };
@@ -612,54 +837,2153 @@ export async function runExtensionUninstall(flags) {
612
837
  install_marker_error: installedPathStatus.error,
613
838
  });
614
839
  }
615
- export async function main(argv = process.argv.slice(2)) {
616
- const { command, flags, positionals } = parseArgs(argv);
617
- if (command === "init") {
618
- await runInit(flags);
619
- return;
840
+ function claudeCodeInstallDir() {
841
+ return path.join(os.homedir(), ".waitspin");
842
+ }
843
+ function claudeCodeStatePath() {
844
+ return path.join(claudeCodeInstallDir(), "claude-code-install.json");
845
+ }
846
+ function claudeCodeRuntimePath() {
847
+ return path.join(claudeCodeInstallDir(), "claude-code-statusline.mjs");
848
+ }
849
+ function claudeCodeCachePath() {
850
+ return path.join(claudeCodeInstallDir(), "claude-code-statusline-cache.json");
851
+ }
852
+ function claudeCodeSettingsPath() {
853
+ return path.join(os.homedir(), ".claude", "settings.json");
854
+ }
855
+ function claudeCodeProjectSettingsCandidates(startDir = process.cwd()) {
856
+ const candidates = [];
857
+ const homeDir = path.resolve(os.homedir());
858
+ let currentDir = path.resolve(startDir);
859
+ while (currentDir !== homeDir) {
860
+ candidates.push({
861
+ scope: "local",
862
+ path: path.join(currentDir, ".claude", "settings.local.json"),
863
+ });
864
+ candidates.push({
865
+ scope: "project",
866
+ path: path.join(currentDir, ".claude", "settings.json"),
867
+ });
868
+ const parentDir = path.dirname(currentDir);
869
+ if (parentDir === currentDir)
870
+ break;
871
+ currentDir = parentDir;
620
872
  }
621
- if (command === "bid" && positionals[0] === "create") {
622
- await runBidCreate(flags);
623
- return;
873
+ return candidates;
874
+ }
875
+ function claudeCodeBinary() {
876
+ return process.env[CLAUDE_CODE_BIN_ENV]?.trim() || "claude";
877
+ }
878
+ function execFileText(file, args, options) {
879
+ return new Promise((resolve, reject) => {
880
+ execFile(file, args, options, (error, stdout, stderr) => {
881
+ if (error) {
882
+ reject(error);
883
+ return;
884
+ }
885
+ resolve({
886
+ stdout: String(stdout || ""),
887
+ stderr: String(stderr || ""),
888
+ });
889
+ });
890
+ });
891
+ }
892
+ function shellQuote(value) {
893
+ return `'${value.replace(/'/g, "'\\''")}'`;
894
+ }
895
+ function windowsPath(value) {
896
+ return value.replace(/\\/g, "/");
897
+ }
898
+ function powerShellQuote(value) {
899
+ return `'${windowsPath(value).replace(/'/g, "''")}'`;
900
+ }
901
+ function powerShellCommandArgument(value) {
902
+ return `"${value.replace(/[`"$]/g, "`$&")}"`;
903
+ }
904
+ function claudeCodeStatusLineCommand(input) {
905
+ if (process.platform === "win32") {
906
+ const command = [
907
+ "&",
908
+ powerShellQuote(process.execPath),
909
+ powerShellQuote(input.runtimePath),
910
+ "--state",
911
+ powerShellQuote(input.statePath),
912
+ ].join(" ");
913
+ return [
914
+ "powershell",
915
+ "-NoProfile",
916
+ "-NonInteractive",
917
+ "-ExecutionPolicy",
918
+ "Bypass",
919
+ "-Command",
920
+ powerShellCommandArgument(command),
921
+ ].join(" ");
624
922
  }
625
- if (command === "bids" && positionals[0] === "list") {
626
- await runBidsList(flags);
627
- return;
923
+ return `${shellQuote(process.execPath)} ${shellQuote(input.runtimePath)} --state ${shellQuote(input.statePath)}`;
924
+ }
925
+ function managedClaudeCodeStatusLine(input) {
926
+ return {
927
+ type: "command",
928
+ command: claudeCodeStatusLineCommand(input),
929
+ padding: 2,
930
+ refreshInterval: CLAUDE_CODE_REFRESH_INTERVAL_SECONDS,
931
+ };
932
+ }
933
+ function parseVersionParts(value) {
934
+ const match = value.match(/(\d+)\.(\d+)\.(\d+)/);
935
+ if (!match)
936
+ return null;
937
+ return [Number(match[1]), Number(match[2]), Number(match[3])];
938
+ }
939
+ function isVersionAtLeast(value, minimum) {
940
+ const current = parseVersionParts(value);
941
+ const target = parseVersionParts(minimum);
942
+ if (!current || !target)
943
+ return false;
944
+ for (let index = 0; index < target.length; index += 1) {
945
+ if (current[index] > target[index])
946
+ return true;
947
+ if (current[index] < target[index])
948
+ return false;
628
949
  }
629
- if (command === "bid" && positionals[0] === "checkout") {
630
- await runBidCheckout(flags, positionals.slice(1));
631
- return;
950
+ return true;
951
+ }
952
+ async function readClaudeCodeVersion() {
953
+ try {
954
+ const result = await execFileText(claudeCodeBinary(), ["--version"], {
955
+ encoding: "utf8",
956
+ timeout: 5_000,
957
+ });
958
+ return `${result.stdout || result.stderr || ""}`.trim();
632
959
  }
633
- if (command === "market") {
634
- await runMarket(flags);
635
- return;
960
+ catch (error) {
961
+ throw new Error(`Unable to run Claude Code. Install Claude Code or set ${CLAUDE_CODE_BIN_ENV} to its executable path before installing WaitSpin Claude Code support.`, { cause: error });
636
962
  }
637
- if (command === "wallet" && positionals[0] === "status") {
638
- await runWalletStatus(flags);
639
- return;
963
+ }
964
+ async function assertSupportedClaudeCodeVersion() {
965
+ const version = await readClaudeCodeVersion();
966
+ if (!isVersionAtLeast(version, CLAUDE_CODE_MIN_VERSION)) {
967
+ throw new Error(`Unsupported Claude Code version: ${version || "unknown"}. WaitSpin Claude Code statusline support requires Claude Code ${CLAUDE_CODE_MIN_VERSION} or newer.`);
640
968
  }
641
- if (command === "wallet" && positionals[0] === "connect") {
642
- await runWalletConnect(flags);
643
- return;
969
+ return version;
970
+ }
971
+ function isErrno(error, code) {
972
+ return (typeof error === "object" &&
973
+ error !== null &&
974
+ "code" in error &&
975
+ error.code === code);
976
+ }
977
+ async function readJsonObjectFile(filePath) {
978
+ try {
979
+ const raw = await readFile(filePath, "utf8");
980
+ if (!raw.trim())
981
+ return {};
982
+ const parsed = JSON.parse(raw);
983
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
984
+ throw new Error(`${filePath} must contain a JSON object.`);
985
+ }
986
+ return parsed;
644
987
  }
645
- if (command === "wallet" && positionals[0] === "ledger") {
646
- await runWalletLedger(flags);
647
- return;
988
+ catch (error) {
989
+ if (isErrno(error, "ENOENT")) {
990
+ return null;
991
+ }
992
+ throw error;
648
993
  }
649
- if (command === "wallet" && positionals[0] === "payout") {
650
- await runWalletPayout(flags);
651
- return;
994
+ }
995
+ async function writeJsonObjectFile(filePath, value, mode) {
996
+ await mkdir(path.dirname(filePath), { recursive: true });
997
+ await writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, {
998
+ encoding: "utf8",
999
+ mode,
1000
+ });
1001
+ if (mode) {
1002
+ await chmod(filePath, mode);
652
1003
  }
653
- if (command === "extension" && positionals[0] === "install") {
654
- await runExtensionInstall(flags);
1004
+ }
1005
+ async function loadClaudeCodeSettings() {
1006
+ const parsed = await readJsonObjectFile(claudeCodeSettingsPath());
1007
+ return (parsed ?? {});
1008
+ }
1009
+ async function findClaudeCodeScopedStatusLine() {
1010
+ for (const candidate of claudeCodeProjectSettingsCandidates()) {
1011
+ const parsed = await readJsonObjectFile(candidate.path);
1012
+ if (parsed &&
1013
+ Object.prototype.hasOwnProperty.call(parsed, "statusLine")) {
1014
+ return {
1015
+ scope: candidate.scope,
1016
+ path: candidate.path,
1017
+ statusLine: parsed.statusLine,
1018
+ };
1019
+ }
1020
+ }
1021
+ return null;
1022
+ }
1023
+ async function loadClaudeCodeInstallState() {
1024
+ const parsed = await readJsonObjectFile(claudeCodeStatePath());
1025
+ if (!parsed?.install_id || parsed.target !== CLAUDE_CODE_PUBLISHER_TARGET) {
1026
+ return null;
1027
+ }
1028
+ return parsed;
1029
+ }
1030
+ function isCommandStatusLine(value) {
1031
+ return (Boolean(value) &&
1032
+ typeof value === "object" &&
1033
+ !Array.isArray(value) &&
1034
+ value.type === "command" &&
1035
+ typeof value.command === "string" &&
1036
+ value.command.trim().length > 0);
1037
+ }
1038
+ function statusLineEquals(left, right) {
1039
+ if (isCommandStatusLine(left) && isCommandStatusLine(right)) {
1040
+ return (left.type === right.type &&
1041
+ left.command === right.command &&
1042
+ left.padding === right.padding &&
1043
+ left.refreshInterval === right.refreshInterval);
1044
+ }
1045
+ return JSON.stringify(left ?? null) === JSON.stringify(right ?? null);
1046
+ }
1047
+ function claudeCodeScopedStatusLineBlocker(scopedStatusLine, managedStatusLine) {
1048
+ if (!scopedStatusLine)
1049
+ return null;
1050
+ if (statusLineEquals(scopedStatusLine.statusLine, managedStatusLine)) {
1051
+ return null;
1052
+ }
1053
+ return `Claude Code ${scopedStatusLine.scope} settings at ${scopedStatusLine.path} define a higher-priority statusLine. User-level WaitSpin install would not be used in this project. Remove or compose that statusLine first, then retry.`;
1054
+ }
1055
+ function resolveClaudeCodeSettingsUpdate(input) {
1056
+ const current = input.settings.statusLine;
1057
+ const isAlreadyManaged = statusLineEquals(current, input.managedStatusLine) ||
1058
+ (input.existingState?.managed_status_line &&
1059
+ statusLineEquals(current, input.existingState.managed_status_line));
1060
+ if (!current || isAlreadyManaged) {
1061
+ return {
1062
+ nextSettings: { ...input.settings, statusLine: input.managedStatusLine },
1063
+ previousStatusLine: input.existingState?.previous_status_line,
1064
+ composedExistingStatusLine: Boolean(input.existingState?.composed_existing_status_line),
1065
+ action: isAlreadyManaged ? "refresh-managed" : "install",
1066
+ };
1067
+ }
1068
+ if (!input.composeExisting) {
1069
+ throw new Error("Claude Code already has a statusLine configured. Re-run with --compose-existing to preserve it and append the WaitSpin sponsor line, or remove it first.");
1070
+ }
1071
+ if (!isCommandStatusLine(current)) {
1072
+ throw new Error("Claude Code statusLine exists but is not a command status line; refusing to compose because restore would be ambiguous.");
1073
+ }
1074
+ if (current.command === input.managedStatusLine.command) {
1075
+ return {
1076
+ nextSettings: { ...input.settings, statusLine: input.managedStatusLine },
1077
+ composedExistingStatusLine: false,
1078
+ action: "refresh-managed",
1079
+ };
1080
+ }
1081
+ return {
1082
+ nextSettings: { ...input.settings, statusLine: input.managedStatusLine },
1083
+ previousStatusLine: current,
1084
+ composedExistingStatusLine: true,
1085
+ action: "compose-existing",
1086
+ };
1087
+ }
1088
+ function redactedClaudeCodeState(state) {
1089
+ if (!state)
1090
+ return null;
1091
+ return {
1092
+ target: state.target,
1093
+ install_id: state.install_id,
1094
+ publisher_id: state.publisher_id,
1095
+ publisher_target: state.publisher_target,
1096
+ registered_at: state.registered_at,
1097
+ base_url: state.base_url,
1098
+ runtime_path: state.runtime_path,
1099
+ cache_path: state.cache_path,
1100
+ settings_path: state.settings_path,
1101
+ composed_existing_status_line: Boolean(state.composed_existing_status_line),
1102
+ has_previous_status_line: state.previous_status_line !== undefined,
1103
+ claude_version: state.claude_version,
1104
+ installed_at: state.installed_at,
1105
+ api_key_present: Boolean(state.api_key),
1106
+ };
1107
+ }
1108
+ function claudeCodeStatuslineRuntimeSource() {
1109
+ return String.raw `#!/usr/bin/env node
1110
+ import { spawn } from "node:child_process";
1111
+ import {
1112
+ mkdir,
1113
+ readFile,
1114
+ rename,
1115
+ rm,
1116
+ stat,
1117
+ writeFile,
1118
+ } from "node:fs/promises";
1119
+
1120
+ const FETCH_INTERVAL_MS = 15_000;
1121
+ const FETCH_TIMEOUT_MS = 2_500;
1122
+ const PREVIOUS_TIMEOUT_MS = 1_000;
1123
+ const MAX_ACTIVE_AGE_MS = 60_000;
1124
+ const LOCK_RETRY_MS = 40;
1125
+ const LOCK_TIMEOUT_MS = 2_000;
1126
+ const LOCK_STALE_MS = 10_000;
1127
+
1128
+ function argValue(name) {
1129
+ const index = process.argv.indexOf(name);
1130
+ return index >= 0 ? process.argv[index + 1] : undefined;
1131
+ }
1132
+
1133
+ async function readStdin() {
1134
+ const chunks = [];
1135
+ for await (const chunk of process.stdin) chunks.push(Buffer.from(chunk));
1136
+ return Buffer.concat(chunks).toString("utf8");
1137
+ }
1138
+
1139
+ async function readJson(filePath, fallback) {
1140
+ try {
1141
+ return JSON.parse(await readFile(filePath, "utf8"));
1142
+ } catch {
1143
+ return fallback;
1144
+ }
1145
+ }
1146
+
1147
+ async function writeJson(filePath, value) {
1148
+ const tmp = filePath + "." + process.pid + ".tmp";
1149
+ await writeFile(tmp, JSON.stringify(value, null, 2) + "\n", {
1150
+ encoding: "utf8",
1151
+ mode: 0o600,
1152
+ });
1153
+ await rename(tmp, filePath);
1154
+ }
1155
+
1156
+ function sleep(ms) {
1157
+ return new Promise((resolve) => setTimeout(resolve, ms));
1158
+ }
1159
+
1160
+ async function acquireCacheLock(cachePath) {
1161
+ const lockPath = cachePath + ".lock";
1162
+ const startedAt = Date.now();
1163
+
1164
+ while (Date.now() - startedAt < LOCK_TIMEOUT_MS) {
1165
+ try {
1166
+ await mkdir(lockPath);
1167
+ return async () => {
1168
+ await rm(lockPath, { recursive: true, force: true });
1169
+ };
1170
+ } catch {
1171
+ try {
1172
+ const lockStat = await stat(lockPath);
1173
+ if (Date.now() - lockStat.mtimeMs > LOCK_STALE_MS) {
1174
+ await rm(lockPath, { recursive: true, force: true });
1175
+ continue;
1176
+ }
1177
+ } catch {
1178
+ // Another process may have released the lock between mkdir/stat.
1179
+ }
1180
+ await sleep(LOCK_RETRY_MS);
1181
+ }
1182
+ }
1183
+
1184
+ throw new Error("Timed out waiting for WaitSpin statusline cache lock.");
1185
+ }
1186
+
1187
+ async function withCacheLock(cachePath, callback) {
1188
+ const release = await acquireCacheLock(cachePath);
1189
+ try {
1190
+ return await callback();
1191
+ } finally {
1192
+ await release();
1193
+ }
1194
+ }
1195
+
1196
+ function cleanLine(value) {
1197
+ return String(value || "")
1198
+ .replace(
1199
+ /(?:\u001B\[[0-?]*[ -/]*[@-~]|\u001B\][^\u0007]*(?:\u0007|\u001B\\)|\u001B[P^_][\s\S]*?\u001B\\|\u001B[@-Z\\-_]|\u009B[0-?]*[ -/]*[@-~])/g,
1200
+ " ",
1201
+ )
1202
+ .replace(/[\r\n\u0000-\u001F\u007F-\u009F]/g, " ")
1203
+ .replace(/\s+/g, " ")
1204
+ .trim()
1205
+ .slice(0, 120);
1206
+ }
1207
+
1208
+ async function runPreviousStatusLine(command, input) {
1209
+ if (!command) return "";
1210
+ return await new Promise((resolve) => {
1211
+ const child = spawn(command, {
1212
+ shell: true,
1213
+ stdio: ["pipe", "pipe", "ignore"],
1214
+ env: process.env,
1215
+ });
1216
+ let stdout = "";
1217
+ let settled = false;
1218
+ let killTimer = null;
1219
+ function finish(value) {
1220
+ if (settled) return;
1221
+ settled = true;
1222
+ clearTimeout(timer);
1223
+ if (killTimer) clearTimeout(killTimer);
1224
+ resolve(value);
1225
+ }
1226
+ const timer = setTimeout(() => {
1227
+ child.stdout.destroy();
1228
+ child.stdin.destroy();
1229
+ child.kill("SIGTERM");
1230
+ killTimer = setTimeout(() => {
1231
+ child.kill("SIGKILL");
1232
+ }, 100);
1233
+ killTimer.unref?.();
1234
+ child.unref?.();
1235
+ finish("");
1236
+ }, PREVIOUS_TIMEOUT_MS);
1237
+ timer.unref?.();
1238
+ child.stdout.on("data", (chunk) => {
1239
+ stdout += chunk.toString("utf8");
1240
+ if (stdout.length > 4000) stdout = stdout.slice(0, 4000);
1241
+ });
1242
+ child.on("error", () => {
1243
+ finish("");
1244
+ });
1245
+ child.on("close", () => {
1246
+ finish(stdout.trimEnd());
1247
+ });
1248
+ child.stdin.end(input);
1249
+ });
1250
+ }
1251
+
1252
+ async function waitspinFetch(url, init) {
1253
+ const controller = new AbortController();
1254
+ const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
1255
+ try {
1256
+ return await fetch(url, { ...init, signal: controller.signal });
1257
+ } finally {
1258
+ clearTimeout(timeout);
1259
+ }
1260
+ }
1261
+
1262
+ function parseServe(payload) {
1263
+ if (!payload || typeof payload !== "object") return null;
1264
+ const creative = payload.creative;
1265
+ if (!creative || typeof creative !== "object") return null;
1266
+ const line = cleanLine(creative.line);
1267
+ if (!line) return null;
1268
+ if (
1269
+ typeof payload.serve_id !== "string" ||
1270
+ typeof payload.serve_receipt !== "string"
1271
+ ) {
1272
+ return null;
1273
+ }
1274
+ const parsedExpiresAt = Date.parse(payload.expires_at || "");
1275
+ return {
1276
+ serveId: payload.serve_id,
1277
+ serveReceipt: payload.serve_receipt,
1278
+ line,
1279
+ shownAt: Date.now(),
1280
+ expiresAtMs: Number.isFinite(parsedExpiresAt)
1281
+ ? parsedExpiresAt
1282
+ : Date.now() + MAX_ACTIVE_AGE_MS,
1283
+ minVisibleMs:
1284
+ typeof payload.min_visible_ms === "number" && payload.min_visible_ms >= 5000
1285
+ ? payload.min_visible_ms
1286
+ : 5000,
1287
+ impressionRecorded: false,
1288
+ };
1289
+ }
1290
+
1291
+ function serveIsExpired(serve) {
1292
+ return (
1293
+ Date.now() >= (serve.expiresAtMs || 0) ||
1294
+ Date.now() - serve.shownAt > MAX_ACTIVE_AGE_MS
1295
+ );
1296
+ }
1297
+
1298
+ async function fetchNextServe(state, session) {
1299
+ const response = await waitspinFetch(state.base_url + "/v1/serve/next", {
1300
+ method: "POST",
1301
+ headers: {
1302
+ Authorization: "Bearer " + state.api_key,
1303
+ "Content-Type": "application/json",
1304
+ },
1305
+ body: JSON.stringify({ install_id: state.install_id }),
1306
+ });
1307
+ session.lastFetchAt = Date.now();
1308
+ if (response.status === 204) {
1309
+ session.activeServe = null;
1310
+ return;
1311
+ }
1312
+ if (!response.ok) return;
1313
+ const parsed = parseServe(await response.json());
1314
+ if (parsed) session.activeServe = parsed;
1315
+ }
1316
+
1317
+ async function recordImpression(state, session) {
1318
+ const serve = session.activeServe;
1319
+ if (!serve || serve.impressionRecorded) return;
1320
+ const visibleMs = Date.now() - serve.shownAt;
1321
+ if (visibleMs < serve.minVisibleMs) return;
1322
+ const response = await waitspinFetch(state.base_url + "/v1/events/impression", {
1323
+ method: "POST",
1324
+ headers: {
1325
+ Authorization: "Bearer " + state.api_key,
1326
+ "Content-Type": "application/json",
1327
+ },
1328
+ body: JSON.stringify({
1329
+ serve_id: serve.serveId,
1330
+ serve_receipt: serve.serveReceipt,
1331
+ install_id: state.install_id,
1332
+ visible_ms: Math.max(visibleMs, serve.minVisibleMs),
1333
+ }),
1334
+ });
1335
+ if (response.ok) serve.impressionRecorded = true;
1336
+ }
1337
+
1338
+ function sessionKey(inputJson) {
1339
+ return (
1340
+ inputJson.session_id ||
1341
+ inputJson.transcript_path ||
1342
+ inputJson.cwd ||
1343
+ "claude-code"
1344
+ );
1345
+ }
1346
+
1347
+ function pruneSessions(cache) {
1348
+ const cutoff = Date.now() - 24 * 60 * 60 * 1000;
1349
+ for (const [key, value] of Object.entries(cache.sessions || {})) {
1350
+ if ((value.lastSeenAt || 0) < cutoff) delete cache.sessions[key];
1351
+ }
1352
+ }
1353
+
1354
+ async function main() {
1355
+ const statePath = argValue("--state");
1356
+ if (!statePath) return;
1357
+ const stdin = await readStdin();
1358
+ let inputJson = {};
1359
+ try {
1360
+ inputJson = stdin.trim() ? JSON.parse(stdin) : {};
1361
+ } catch {
1362
+ inputJson = {};
1363
+ }
1364
+ const state = await readJson(statePath, null);
1365
+ if (!state?.api_key || !state.install_id || !state.base_url || !state.cache_path) {
1366
+ return;
1367
+ }
1368
+
1369
+ const previous = await runPreviousStatusLine(
1370
+ state.previous_status_line?.type === "command"
1371
+ ? state.previous_status_line.command
1372
+ : "",
1373
+ stdin,
1374
+ );
1375
+ let renderedSession = null;
1376
+
1377
+ try {
1378
+ renderedSession = await withCacheLock(state.cache_path, async () => {
1379
+ const cache = await readJson(state.cache_path, { sessions: {} });
1380
+ if (!cache.sessions || typeof cache.sessions !== "object") cache.sessions = {};
1381
+ const key = sessionKey(inputJson);
1382
+ const session = cache.sessions[key] || {};
1383
+ session.lastSeenAt = Date.now();
1384
+ cache.sessions[key] = session;
1385
+
1386
+ if (session.activeServe && serveIsExpired(session.activeServe)) {
1387
+ session.activeServe = null;
1388
+ }
1389
+ await recordImpression(state, session);
1390
+ const shouldFetchNext = !session.activeServe
1391
+ ? Date.now() - (session.lastFetchAt || 0) >= FETCH_INTERVAL_MS
1392
+ : session.activeServe.impressionRecorded &&
1393
+ Date.now() - (session.lastFetchAt || 0) >= FETCH_INTERVAL_MS;
1394
+ if (shouldFetchNext) {
1395
+ await fetchNextServe(state, session);
1396
+ }
1397
+ pruneSessions(cache);
1398
+ await writeJson(state.cache_path, cache);
1399
+ return session;
1400
+ });
1401
+ } catch {
1402
+ // Statusline rendering must never interrupt Claude Code.
1403
+ }
1404
+
1405
+ const sponsor = renderedSession?.activeServe?.line
1406
+ ? "Sponsored: " + renderedSession.activeServe.line
1407
+ : "";
1408
+ const lines = [previous, sponsor].filter(Boolean);
1409
+ if (lines.length > 0) process.stdout.write(lines.join("\n"));
1410
+ }
1411
+
1412
+ main().catch(() => {});
1413
+ `;
1414
+ }
1415
+ async function writeClaudeCodeRuntime(runtimePath) {
1416
+ await mkdir(path.dirname(runtimePath), { recursive: true });
1417
+ await writeFile(runtimePath, claudeCodeStatuslineRuntimeSource(), {
1418
+ encoding: "utf8",
1419
+ mode: 0o755,
1420
+ });
1421
+ await chmod(runtimePath, 0o755);
1422
+ }
1423
+ function assertSafeClaudeCodeManagedPath(filePath) {
1424
+ const installRoot = path.resolve(claudeCodeInstallDir());
1425
+ const resolved = path.resolve(filePath);
1426
+ if (!resolved.startsWith(`${installRoot}${path.sep}`) ||
1427
+ !path.basename(resolved).startsWith("claude-code-")) {
1428
+ throw new Error("Refusing to manage a Claude Code WaitSpin file outside ~/.waitspin.");
1429
+ }
1430
+ return resolved;
1431
+ }
1432
+ export async function runClaudeCodeInstall(flags) {
1433
+ const dryRun = booleanFlag(flags, "dry-run");
1434
+ const baseUrl = resolveCredentialedBaseUrl(flags);
1435
+ const statePath = claudeCodeStatePath();
1436
+ const runtimePath = claudeCodeRuntimePath();
1437
+ const cachePath = claudeCodeCachePath();
1438
+ const settingsPath = claudeCodeSettingsPath();
1439
+ const existingState = await loadClaudeCodeInstallState();
1440
+ const installId = existingState?.install_id || generateInstallId();
1441
+ const managedStatusLine = managedClaudeCodeStatusLine({
1442
+ runtimePath,
1443
+ statePath,
1444
+ });
1445
+ const settings = await loadClaudeCodeSettings();
1446
+ const scopedStatusLine = await findClaudeCodeScopedStatusLine();
1447
+ const scopedStatusLineBlockedReason = claudeCodeScopedStatusLineBlocker(scopedStatusLine, managedStatusLine);
1448
+ let settingsUpdate = null;
1449
+ let settingsBlockedReason = null;
1450
+ try {
1451
+ settingsUpdate = resolveClaudeCodeSettingsUpdate({
1452
+ settings,
1453
+ managedStatusLine,
1454
+ existingState,
1455
+ composeExisting: booleanFlag(flags, "compose-existing"),
1456
+ });
1457
+ }
1458
+ catch (error) {
1459
+ if (!dryRun)
1460
+ throw error;
1461
+ settingsBlockedReason =
1462
+ error instanceof Error ? error.message : String(error);
1463
+ }
1464
+ if (scopedStatusLineBlockedReason && !dryRun) {
1465
+ throw new Error(scopedStatusLineBlockedReason);
1466
+ }
1467
+ const summary = {
1468
+ ok: true,
1469
+ target: CLAUDE_CODE_PUBLISHER_TARGET,
1470
+ mode: "statusline-command",
1471
+ install_id: installId,
1472
+ publisher_target: CLAUDE_CODE_PUBLISHER_TARGET,
1473
+ state_path: statePath,
1474
+ runtime_path: runtimePath,
1475
+ cache_path: cachePath,
1476
+ settings_path: settingsPath,
1477
+ settings_action: settingsUpdate?.action ?? "blocked",
1478
+ composed_existing_status_line: settingsUpdate?.composedExistingStatusLine ?? false,
1479
+ note: "Installs WaitSpin through Claude Code's official statusLine.command surface.",
1480
+ next: "check_status",
1481
+ next_command: "waitspin claude-code status",
1482
+ };
1483
+ if (dryRun) {
1484
+ const blockedReason = settingsBlockedReason ?? scopedStatusLineBlockedReason;
1485
+ printJson({
1486
+ ...summary,
1487
+ dry_run: true,
1488
+ publisher_registered: false,
1489
+ has_existing_status_line: Boolean(settings.statusLine),
1490
+ ...(scopedStatusLine
1491
+ ? {
1492
+ scoped_status_line: {
1493
+ scope: scopedStatusLine.scope,
1494
+ path: scopedStatusLine.path,
1495
+ overrides_user_settings: Boolean(scopedStatusLineBlockedReason),
1496
+ },
1497
+ }
1498
+ : {}),
1499
+ ...(blockedReason
1500
+ ? {
1501
+ would_fail: true,
1502
+ settings_blocked_reason: blockedReason,
1503
+ next: settingsBlockedReason
1504
+ ? "resolve_status_line_conflict"
1505
+ : "resolve_project_status_line_override",
1506
+ ...(settingsBlockedReason
1507
+ ? {
1508
+ next_command: "waitspin claude-code install --compose-existing",
1509
+ }
1510
+ : {
1511
+ human_message: "Current project/local Claude Code settings override user-level statusLine. Remove or compose that statusLine before installing WaitSpin for this project.",
1512
+ }),
1513
+ }
1514
+ : {}),
1515
+ });
655
1516
  return;
656
1517
  }
657
- if (command === "extension" && positionals[0] === "status") {
658
- await runExtensionStatus(flags);
1518
+ if (!settingsUpdate) {
1519
+ throw new Error("Unable to resolve Claude Code settings update.");
1520
+ }
1521
+ const claudeVersion = await assertSupportedClaudeCodeVersion();
1522
+ const apiKey = requireApiKey(flags);
1523
+ const registration = await registerPublisherInstall({
1524
+ baseUrl,
1525
+ apiKey,
1526
+ installId,
1527
+ target: CLAUDE_CODE_PUBLISHER_TARGET,
1528
+ });
1529
+ const installedAt = new Date().toISOString();
1530
+ const installState = {
1531
+ target: CLAUDE_CODE_PUBLISHER_TARGET,
1532
+ install_id: registration.install_id,
1533
+ publisher_id: registration.publisher_id,
1534
+ publisher_target: registration.target,
1535
+ registered_at: installedAt,
1536
+ base_url: baseUrl,
1537
+ api_key: apiKey,
1538
+ runtime_path: runtimePath,
1539
+ cache_path: cachePath,
1540
+ settings_path: settingsPath,
1541
+ managed_status_line: managedStatusLine,
1542
+ previous_status_line: settingsUpdate.previousStatusLine,
1543
+ composed_existing_status_line: settingsUpdate.composedExistingStatusLine,
1544
+ claude_version: claudeVersion,
1545
+ installed_at: installedAt,
1546
+ };
1547
+ try {
1548
+ await writeClaudeCodeRuntime(runtimePath);
1549
+ await writeJsonObjectFile(statePath, installState, 0o600);
1550
+ await writeJsonObjectFile(settingsPath, settingsUpdate.nextSettings);
1551
+ }
1552
+ catch (error) {
1553
+ if (existingState) {
1554
+ await writeJsonObjectFile(statePath, existingState, 0o600).catch(() => { });
1555
+ }
1556
+ else {
1557
+ await Promise.resolve(rm(statePath, { force: true, recursive: true })).catch(() => { });
1558
+ await Promise.resolve(rm(runtimePath, { force: true, recursive: true })).catch(() => { });
1559
+ }
1560
+ throw error;
1561
+ }
1562
+ printJson({
1563
+ ...summary,
1564
+ ...redactedClaudeCodeState(installState),
1565
+ publisher_registered: true,
1566
+ claude_version: claudeVersion,
1567
+ next: "launch_claude_code",
1568
+ next_command: "claude",
1569
+ acceptance_hint: "Keep the sponsored line visible for at least 5 seconds, then verify an impression.",
1570
+ });
1571
+ }
1572
+ export async function runClaudeCodeStatus() {
1573
+ const state = await loadClaudeCodeInstallState();
1574
+ const settings = await loadClaudeCodeSettings();
1575
+ const managedStatusLine = state?.managed_status_line ?? null;
1576
+ const currentStatusLine = settings.statusLine;
1577
+ const statusLineConfigured = Boolean(managedStatusLine && statusLineEquals(currentStatusLine, managedStatusLine));
1578
+ const scopedStatusLine = await findClaudeCodeScopedStatusLine();
1579
+ const scopedStatusLineOverridden = Boolean(managedStatusLine &&
1580
+ scopedStatusLine &&
1581
+ !statusLineEquals(scopedStatusLine.statusLine, managedStatusLine));
1582
+ const effectiveStatusLineConfigured = statusLineConfigured && !scopedStatusLineOverridden;
1583
+ const runtimeInstalled = state
1584
+ ? await pathExists(assertSafeClaudeCodeManagedPath(state.runtime_path))
1585
+ : false;
1586
+ const installed = Boolean(state && runtimeInstalled && effectiveStatusLineConfigured);
1587
+ printJson({
1588
+ ok: true,
1589
+ target: CLAUDE_CODE_PUBLISHER_TARGET,
1590
+ mode: "statusline-command",
1591
+ installed,
1592
+ publisher_registered: Boolean(state?.publisher_id),
1593
+ install_id: state?.install_id ?? null,
1594
+ publisher_id: state?.publisher_id ?? null,
1595
+ publisher_target: state?.publisher_target ?? CLAUDE_CODE_PUBLISHER_TARGET,
1596
+ state_path: claudeCodeStatePath(),
1597
+ runtime_path: state?.runtime_path ?? claudeCodeRuntimePath(),
1598
+ cache_path: state?.cache_path ?? claudeCodeCachePath(),
1599
+ settings_path: claudeCodeSettingsPath(),
1600
+ runtime_installed: runtimeInstalled,
1601
+ status_line_configured: statusLineConfigured,
1602
+ effective_status_line_configured: effectiveStatusLineConfigured,
1603
+ status_line_overridden: scopedStatusLineOverridden,
1604
+ ...(scopedStatusLineOverridden && scopedStatusLine
1605
+ ? {
1606
+ status_line_override_scope: scopedStatusLine.scope,
1607
+ status_line_override_path: scopedStatusLine.path,
1608
+ }
1609
+ : {}),
1610
+ composed_existing_status_line: Boolean(state?.composed_existing_status_line),
1611
+ has_previous_status_line: Boolean(state?.previous_status_line),
1612
+ claude_version: state?.claude_version ?? null,
1613
+ ...(installed
1614
+ ? {
1615
+ next: "launch_claude_code",
1616
+ next_command: "claude",
1617
+ acceptance_hint: "Keep the sponsored line visible for at least 5 seconds, then verify an impression.",
1618
+ }
1619
+ : {
1620
+ next: "install_claude_code",
1621
+ next_command: "waitspin claude-code install --compose-existing",
1622
+ human_message: scopedStatusLineOverridden
1623
+ ? "Claude Code WaitSpin support is installed in user settings, but a higher-priority project/local statusLine overrides it in this directory."
1624
+ : "Claude Code WaitSpin statusline support is not installed for this user.",
1625
+ }),
1626
+ });
1627
+ }
1628
+ export async function runClaudeCodeUninstall(flags) {
1629
+ const dryRun = booleanFlag(flags, "dry-run");
1630
+ const state = await loadClaudeCodeInstallState();
1631
+ const settings = await loadClaudeCodeSettings();
1632
+ const declaredRemovePaths = state
1633
+ ? [
1634
+ state.runtime_path,
1635
+ state.cache_path,
1636
+ claudeCodeStatePath(),
1637
+ ]
1638
+ : [claudeCodeStatePath()];
1639
+ let nextSettings = null;
1640
+ let settingsAction = "not-managed";
1641
+ let settingsWarning = null;
1642
+ if (state?.managed_status_line) {
1643
+ if (!statusLineEquals(settings.statusLine, state.managed_status_line)) {
1644
+ settingsAction = "skip-user-settings";
1645
+ settingsWarning =
1646
+ "Claude Code statusLine is no longer the WaitSpin managed command; leaving user settings unchanged while removing WaitSpin-managed files.";
1647
+ }
1648
+ else {
1649
+ nextSettings = { ...settings };
1650
+ if (state.previous_status_line !== undefined) {
1651
+ nextSettings.statusLine = state.previous_status_line;
1652
+ settingsAction = "restore-previous";
1653
+ }
1654
+ else {
1655
+ delete nextSettings.statusLine;
1656
+ settingsAction = "remove-managed";
1657
+ }
1658
+ }
1659
+ }
1660
+ if (dryRun) {
1661
+ printJson({
1662
+ ok: true,
1663
+ target: CLAUDE_CODE_PUBLISHER_TARGET,
1664
+ dry_run: true,
1665
+ installed: Boolean(state),
1666
+ settings_action: settingsAction,
1667
+ would_remove: declaredRemovePaths,
1668
+ path_validation: state ? "deferred_until_apply" : "not_needed",
1669
+ ...(settingsWarning
1670
+ ? {
1671
+ settings_warning: settingsWarning,
1672
+ }
1673
+ : {}),
1674
+ });
659
1675
  return;
660
1676
  }
661
- if (command === "extension" && positionals[0] === "uninstall") {
662
- await runExtensionUninstall(flags);
1677
+ const removePaths = state
1678
+ ? [
1679
+ assertSafeClaudeCodeManagedPath(state.runtime_path),
1680
+ assertSafeClaudeCodeManagedPath(state.cache_path),
1681
+ claudeCodeStatePath(),
1682
+ ]
1683
+ : [claudeCodeStatePath()];
1684
+ if (nextSettings) {
1685
+ await writeJsonObjectFile(claudeCodeSettingsPath(), nextSettings);
1686
+ }
1687
+ await Promise.all(removePaths.map((filePath) => rm(filePath, { force: true, recursive: true })));
1688
+ printJson({
1689
+ ok: true,
1690
+ target: CLAUDE_CODE_PUBLISHER_TARGET,
1691
+ uninstalled: true,
1692
+ settings_action: settingsAction,
1693
+ removed: removePaths,
1694
+ ...(settingsWarning ? { settings_warning: settingsWarning } : {}),
1695
+ });
1696
+ }
1697
+ // --- MiMo Code Publisher Support ---
1698
+ const MIMOCODE_PUBLISHER_TARGET = "mimocode";
1699
+ function miMoCodeInstallDir() {
1700
+ return path.join(os.homedir(), ".waitspin");
1701
+ }
1702
+ function miMoCodeStatePath() {
1703
+ return path.join(miMoCodeInstallDir(), "mimocode-statusline.json");
1704
+ }
1705
+ function miMoCodeCachePath() {
1706
+ return path.join(miMoCodeInstallDir(), "mimocode-statusline-cache.json");
1707
+ }
1708
+ function miMoCodeRuntimePath() {
1709
+ return path.join(os.homedir(), ".local", "bin", "waitspin-mimocode-runtime");
1710
+ }
1711
+ function miMoCodeBashrcPath() {
1712
+ return path.join(os.homedir(), ".bashrc");
1713
+ }
1714
+ const MIMOCODE_BASHRC_MARKER = "# WaitSpin MiMo Code statusline hook";
1715
+ const MIMOCODE_BASHRC_END_MARKER = "# End WaitSpin MiMo Code statusline hook";
1716
+ function miMoCodeStatuslineRuntimeSource() {
1717
+ return String.raw `#!/usr/bin/env node
1718
+ import {
1719
+ mkdir,
1720
+ readFile,
1721
+ rename,
1722
+ rm,
1723
+ stat,
1724
+ writeFile,
1725
+ } from "node:fs/promises";
1726
+ import os from "node:os";
1727
+ import path from "node:path";
1728
+
1729
+ const FETCH_INTERVAL_MS = 15_000;
1730
+ const FETCH_TIMEOUT_MS = 2_500;
1731
+ const MAX_ACTIVE_AGE_MS = 60_000;
1732
+ const LOCK_RETRY_MS = 40;
1733
+ const LOCK_TIMEOUT_MS = 2_000;
1734
+ const LOCK_STALE_MS = 10_000;
1735
+
1736
+ const STATE_DIR = path.join(os.homedir(), ".waitspin");
1737
+ const STATE_FILE = path.join(STATE_DIR, "mimocode-statusline.json");
1738
+ const DEFAULT_CACHE_FILE = path.join(STATE_DIR, "mimocode-statusline-cache.json");
1739
+
1740
+ async function readJson(filePath, fallback) {
1741
+ try {
1742
+ return JSON.parse(await readFile(filePath, "utf8"));
1743
+ } catch {
1744
+ return fallback;
1745
+ }
1746
+ }
1747
+
1748
+ async function writeJson(filePath, value) {
1749
+ await mkdir(path.dirname(filePath), { recursive: true });
1750
+ const tmp = filePath + "." + process.pid + ".tmp";
1751
+ await writeFile(tmp, JSON.stringify(value, null, 2) + "\n", {
1752
+ encoding: "utf8",
1753
+ mode: 0o600,
1754
+ });
1755
+ await rename(tmp, filePath);
1756
+ }
1757
+
1758
+ function sleep(ms) {
1759
+ return new Promise((resolve) => setTimeout(resolve, ms));
1760
+ }
1761
+
1762
+ async function acquireCacheLock(cachePath) {
1763
+ await mkdir(path.dirname(cachePath), { recursive: true });
1764
+ const lockPath = cachePath + ".lock";
1765
+ const startedAt = Date.now();
1766
+
1767
+ while (Date.now() - startedAt < LOCK_TIMEOUT_MS) {
1768
+ try {
1769
+ await mkdir(lockPath);
1770
+ return async () => {
1771
+ await rm(lockPath, { recursive: true, force: true });
1772
+ };
1773
+ } catch {
1774
+ try {
1775
+ const lockStat = await stat(lockPath);
1776
+ if (Date.now() - lockStat.mtimeMs > LOCK_STALE_MS) {
1777
+ await rm(lockPath, { recursive: true, force: true });
1778
+ continue;
1779
+ }
1780
+ } catch {
1781
+ // Another process may have released the lock between mkdir/stat.
1782
+ }
1783
+ await sleep(LOCK_RETRY_MS);
1784
+ }
1785
+ }
1786
+
1787
+ throw new Error("Timed out waiting for WaitSpin MiMo cache lock.");
1788
+ }
1789
+
1790
+ async function withCacheLock(cachePath, callback) {
1791
+ const release = await acquireCacheLock(cachePath);
1792
+ try {
1793
+ return await callback();
1794
+ } finally {
1795
+ await release();
1796
+ }
1797
+ }
1798
+
1799
+ function cleanLine(value) {
1800
+ return String(value || "")
1801
+ .replace(
1802
+ /(?:\u001B\[[0-?]*[ -/]*[@-~]|\u001B\][^\u0007]*(?:\u0007|\u001B\\)|\u001B[P^_][\s\S]*?\u001B\\|\u001B[@-Z\\-_]|\u009B[0-?]*[ -/]*[@-~])/g,
1803
+ " ",
1804
+ )
1805
+ .replace(/[\r\n\u0000-\u001F\u007F-\u009F]/g, " ")
1806
+ .replace(/\s+/g, " ")
1807
+ .trim()
1808
+ .slice(0, 120);
1809
+ }
1810
+
1811
+ async function waitspinFetch(url, init) {
1812
+ const controller = new AbortController();
1813
+ const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
1814
+ try {
1815
+ return await fetch(url, { ...init, signal: controller.signal });
1816
+ } finally {
1817
+ clearTimeout(timeout);
1818
+ }
1819
+ }
1820
+
1821
+ function parseServe(payload) {
1822
+ if (!payload || typeof payload !== "object") return null;
1823
+ const creative = payload.creative;
1824
+ if (!creative || typeof creative !== "object") return null;
1825
+ const line = cleanLine(creative.line);
1826
+ if (!line) return null;
1827
+ if (
1828
+ typeof payload.serve_id !== "string" ||
1829
+ typeof payload.serve_receipt !== "string"
1830
+ ) {
1831
+ return null;
1832
+ }
1833
+ const parsedExpiresAt = Date.parse(payload.expires_at || "");
1834
+ return {
1835
+ serveId: payload.serve_id,
1836
+ serveReceipt: payload.serve_receipt,
1837
+ line,
1838
+ shownAt: Date.now(),
1839
+ expiresAtMs: Number.isFinite(parsedExpiresAt)
1840
+ ? parsedExpiresAt
1841
+ : Date.now() + MAX_ACTIVE_AGE_MS,
1842
+ minVisibleMs:
1843
+ typeof payload.min_visible_ms === "number" && payload.min_visible_ms >= 5000
1844
+ ? payload.min_visible_ms
1845
+ : 5000,
1846
+ impressionRecorded: false,
1847
+ };
1848
+ }
1849
+
1850
+ function serveIsExpired(serve) {
1851
+ return (
1852
+ Date.now() >= (serve.expiresAtMs || 0) ||
1853
+ Date.now() - serve.shownAt > MAX_ACTIVE_AGE_MS
1854
+ );
1855
+ }
1856
+
1857
+ async function fetchNextServe(state, cache) {
1858
+ const response = await waitspinFetch(state.base_url + "/v1/serve/next", {
1859
+ method: "POST",
1860
+ headers: {
1861
+ Authorization: "Bearer " + state.api_key,
1862
+ "Content-Type": "application/json",
1863
+ },
1864
+ body: JSON.stringify({ install_id: state.install_id }),
1865
+ });
1866
+ cache.lastFetchAt = Date.now();
1867
+ if (response.status === 204) {
1868
+ cache.activeServe = null;
1869
+ return;
1870
+ }
1871
+ if (!response.ok) return;
1872
+ const parsed = parseServe(await response.json());
1873
+ if (parsed) cache.activeServe = parsed;
1874
+ }
1875
+
1876
+ async function recordImpression(state, cache) {
1877
+ const serve = cache.activeServe;
1878
+ if (!serve || serve.impressionRecorded) return;
1879
+ const visibleMs = Date.now() - serve.shownAt;
1880
+ if (visibleMs < serve.minVisibleMs) return;
1881
+ const response = await waitspinFetch(state.base_url + "/v1/events/impression", {
1882
+ method: "POST",
1883
+ headers: {
1884
+ Authorization: "Bearer " + state.api_key,
1885
+ "Content-Type": "application/json",
1886
+ },
1887
+ body: JSON.stringify({
1888
+ serve_id: serve.serveId,
1889
+ serve_receipt: serve.serveReceipt,
1890
+ install_id: state.install_id,
1891
+ visible_ms: Math.max(visibleMs, serve.minVisibleMs),
1892
+ }),
1893
+ });
1894
+ if (response.ok) {
1895
+ serve.impressionRecorded = true;
1896
+ } else if ([400, 404, 409, 410].includes(response.status)) {
1897
+ cache.activeServe = null;
1898
+ }
1899
+ }
1900
+
1901
+ async function renderSponsorLine(state) {
1902
+ const cachePath = state.cache_path || DEFAULT_CACHE_FILE;
1903
+ return await withCacheLock(cachePath, async () => {
1904
+ const cache = await readJson(cachePath, {});
1905
+ if (cache.activeServe && serveIsExpired(cache.activeServe)) {
1906
+ cache.activeServe = null;
1907
+ }
1908
+
1909
+ await recordImpression(state, cache);
1910
+
1911
+ const shouldFetchNext = !cache.activeServe
1912
+ ? Date.now() - (cache.lastFetchAt || 0) >= FETCH_INTERVAL_MS
1913
+ : cache.activeServe.impressionRecorded &&
1914
+ Date.now() - (cache.lastFetchAt || 0) >= FETCH_INTERVAL_MS;
1915
+ if (shouldFetchNext) {
1916
+ await fetchNextServe(state, cache);
1917
+ }
1918
+
1919
+ await writeJson(cachePath, cache);
1920
+ return cache.activeServe?.line || "";
1921
+ });
1922
+ }
1923
+
1924
+ async function main() {
1925
+ const state = await readJson(STATE_FILE, null);
1926
+ if (!state?.api_key || !state.install_id || !state.base_url) return;
1927
+
1928
+ try {
1929
+ const line = await renderSponsorLine(state);
1930
+ if (line) process.stdout.write(line);
1931
+ } catch {
1932
+ // Prompt hooks must never interrupt the user's shell.
1933
+ }
1934
+ }
1935
+
1936
+ main().catch(() => {});
1937
+ `;
1938
+ }
1939
+ function miMoCodeBashHook() {
1940
+ const D = "$";
1941
+ return `${MIMOCODE_BASHRC_MARKER}
1942
+ __waitspin_statusline() {
1943
+ local line
1944
+ line=$("$HOME/.local/bin/waitspin-mimocode-runtime" 2>/dev/null)
1945
+ if [ -n "$line" ]; then
1946
+ printf "\\x1b[38;5;245m──\\x1b[0m \\x1b[38;5;220m%s\\x1b[0m \\x1b[38;5;245m──\\x1b[0m" "$line"
1947
+ fi
1948
+ }
1949
+
1950
+ __waitspin_prompt_command() {
1951
+ local waitspin_previous_status=$?
1952
+ if [ -n "${D}{__WAITSPIN_PREVIOUS_PROMPT_COMMAND:-}" ]; then
1953
+ eval "$__WAITSPIN_PREVIOUS_PROMPT_COMMAND"
1954
+ fi
1955
+ __waitspin_statusline
1956
+ return ${D}waitspin_previous_status
1957
+ }
1958
+
1959
+ if [ -z "${D}{__WAITSPIN_PROMPT_INSTALLED:-}" ]; then
1960
+ __WAITSPIN_PREVIOUS_PROMPT_COMMAND="${D}{PROMPT_COMMAND:-}"
1961
+ PROMPT_COMMAND="__waitspin_prompt_command"
1962
+ __WAITSPIN_PROMPT_INSTALLED=1
1963
+ fi
1964
+ ${MIMOCODE_BASHRC_END_MARKER}`;
1965
+ }
1966
+ function readJsonObjectFileSafe(filePath) {
1967
+ return readJsonObjectFile(filePath);
1968
+ }
1969
+ async function loadMiMoCodeInstallState() {
1970
+ const parsed = await readJsonObjectFileSafe(miMoCodeStatePath());
1971
+ if (!parsed?.install_id || parsed.target !== MIMOCODE_PUBLISHER_TARGET) {
1972
+ return null;
1973
+ }
1974
+ return parsed;
1975
+ }
1976
+ function redactedMiMoCodeState(state) {
1977
+ if (!state)
1978
+ return null;
1979
+ return {
1980
+ target: state.target,
1981
+ install_id: state.install_id,
1982
+ publisher_id: state.publisher_id,
1983
+ publisher_target: state.publisher_target,
1984
+ registered_at: state.registered_at,
1985
+ base_url: state.base_url,
1986
+ runtime_path: state.runtime_path,
1987
+ cache_path: state.cache_path,
1988
+ state_path: state.state_path,
1989
+ bashrc_path: state.bashrc_path,
1990
+ installed_at: state.installed_at,
1991
+ api_key_present: Boolean(state.api_key),
1992
+ };
1993
+ }
1994
+ async function writeMiMoCodeRuntime(runtimePath) {
1995
+ await mkdir(path.dirname(runtimePath), { recursive: true });
1996
+ await writeFile(runtimePath, miMoCodeStatuslineRuntimeSource(), {
1997
+ encoding: "utf8",
1998
+ mode: 0o755,
1999
+ });
2000
+ await chmod(runtimePath, 0o755);
2001
+ }
2002
+ async function writeTextFileAtomic(filePath, value) {
2003
+ await mkdir(path.dirname(filePath), { recursive: true });
2004
+ const tmpPath = `${filePath}.waitspin-${process.pid}.tmp`;
2005
+ await writeFile(tmpPath, value, { encoding: "utf8" });
2006
+ await rename(tmpPath, filePath);
2007
+ }
2008
+ async function addMiMoCodeBashHook(bashrcPath) {
2009
+ try {
2010
+ const content = await readFile(bashrcPath, "utf8").catch(() => "");
2011
+ if (content.includes(MIMOCODE_BASHRC_MARKER)) {
2012
+ return false;
2013
+ }
2014
+ await writeTextFileAtomic(bashrcPath, `${content}${content.endsWith("\n") || !content ? "" : "\n"}${miMoCodeBashHook()}\n`);
2015
+ return true;
2016
+ }
2017
+ catch {
2018
+ return false;
2019
+ }
2020
+ }
2021
+ async function removeMiMoCodeBashHook(bashrcPath) {
2022
+ try {
2023
+ const content = await readFile(bashrcPath, "utf8");
2024
+ if (!content.includes(MIMOCODE_BASHRC_MARKER)) {
2025
+ return false;
2026
+ }
2027
+ const markerIndex = content.indexOf(MIMOCODE_BASHRC_MARKER);
2028
+ const afterMarker = content.slice(markerIndex);
2029
+ const markerEnd = afterMarker.indexOf(MIMOCODE_BASHRC_END_MARKER);
2030
+ const before = content.slice(0, markerIndex);
2031
+ let after;
2032
+ if (markerEnd >= 0) {
2033
+ after = afterMarker.slice(markerEnd + MIMOCODE_BASHRC_END_MARKER.length);
2034
+ }
2035
+ else {
2036
+ const hookEnd = afterMarker.indexOf("\n}\n");
2037
+ if (hookEnd === -1) {
2038
+ return false;
2039
+ }
2040
+ after = afterMarker.slice(hookEnd + 3);
2041
+ }
2042
+ await writeTextFileAtomic(bashrcPath, before + after);
2043
+ return true;
2044
+ }
2045
+ catch {
2046
+ return false;
2047
+ }
2048
+ }
2049
+ export async function runMiMoCodeInstall(flags) {
2050
+ const dryRun = booleanFlag(flags, "dry-run");
2051
+ const baseUrl = resolveCredentialedBaseUrl(flags);
2052
+ const statePath = miMoCodeStatePath();
2053
+ const runtimePath = miMoCodeRuntimePath();
2054
+ const cachePath = miMoCodeCachePath();
2055
+ const bashrcPath = miMoCodeBashrcPath();
2056
+ const existingState = await loadMiMoCodeInstallState();
2057
+ const installId = existingState?.install_id || generateInstallId();
2058
+ const summary = {
2059
+ ok: true,
2060
+ target: MIMOCODE_PUBLISHER_TARGET,
2061
+ mode: "shell-hook",
2062
+ install_id: installId,
2063
+ publisher_target: MIMOCODE_PUBLISHER_TARGET,
2064
+ state_path: statePath,
2065
+ runtime_path: runtimePath,
2066
+ cache_path: cachePath,
2067
+ bashrc_path: bashrcPath,
2068
+ note: "Installs WaitSpin through a bash hook that polls the API for sponsored messages.",
2069
+ next: "check_status",
2070
+ next_command: "waitspin mimocode status",
2071
+ };
2072
+ if (dryRun) {
2073
+ const hookExists = await readFile(bashrcPath, "utf8")
2074
+ .then((c) => c.includes(MIMOCODE_BASHRC_MARKER))
2075
+ .catch(() => false);
2076
+ printJson({
2077
+ ...summary,
2078
+ dry_run: true,
2079
+ publisher_registered: false,
2080
+ bashrc_hook_exists: hookExists,
2081
+ would_write: [statePath, runtimePath],
2082
+ });
2083
+ return;
2084
+ }
2085
+ const apiKey = requireApiKey(flags);
2086
+ const registration = await registerPublisherInstall({
2087
+ baseUrl,
2088
+ apiKey,
2089
+ installId,
2090
+ target: MIMOCODE_PUBLISHER_TARGET,
2091
+ });
2092
+ const installedAt = new Date().toISOString();
2093
+ const installState = {
2094
+ target: MIMOCODE_PUBLISHER_TARGET,
2095
+ install_id: registration.install_id,
2096
+ publisher_id: registration.publisher_id,
2097
+ publisher_target: registration.target,
2098
+ registered_at: installedAt,
2099
+ base_url: baseUrl,
2100
+ api_key: apiKey,
2101
+ runtime_path: runtimePath,
2102
+ cache_path: cachePath,
2103
+ state_path: statePath,
2104
+ bashrc_path: bashrcPath,
2105
+ installed_at: installedAt,
2106
+ };
2107
+ try {
2108
+ await writeMiMoCodeRuntime(runtimePath);
2109
+ await writeJsonObjectFile(statePath, installState, 0o600);
2110
+ await addMiMoCodeBashHook(bashrcPath);
2111
+ }
2112
+ catch (error) {
2113
+ if (existingState) {
2114
+ await writeJsonObjectFile(statePath, existingState, 0o600).catch(() => { });
2115
+ }
2116
+ else {
2117
+ await Promise.resolve(rm(statePath, { force: true, recursive: true })).catch(() => { });
2118
+ await Promise.resolve(rm(runtimePath, { force: true, recursive: true })).catch(() => { });
2119
+ }
2120
+ throw error;
2121
+ }
2122
+ printJson({
2123
+ ...summary,
2124
+ ...redactedMiMoCodeState(installState),
2125
+ publisher_registered: true,
2126
+ next: "test_runtime",
2127
+ next_command: "waitspin-mimocode-runtime",
2128
+ acceptance_hint: "Run 'source ~/.bashrc' or restart your shell, then keep the sponsored line visible for at least 5 seconds.",
2129
+ });
2130
+ }
2131
+ export async function runMiMoCodeStatus() {
2132
+ const state = await loadMiMoCodeInstallState();
2133
+ const runtimePath = state?.runtime_path ?? miMoCodeRuntimePath();
2134
+ const cachePath = state?.cache_path ?? miMoCodeCachePath();
2135
+ const bashrcPath = state?.bashrc_path ?? miMoCodeBashrcPath();
2136
+ const runtimeInstalled = await pathExists(runtimePath);
2137
+ const hookExists = await readFile(bashrcPath, "utf8")
2138
+ .then((c) => c.includes(MIMOCODE_BASHRC_MARKER))
2139
+ .catch(() => false);
2140
+ const installed = Boolean(state && runtimeInstalled);
2141
+ printJson({
2142
+ ok: true,
2143
+ target: MIMOCODE_PUBLISHER_TARGET,
2144
+ mode: "shell-hook",
2145
+ installed,
2146
+ publisher_registered: Boolean(state?.publisher_id),
2147
+ install_id: state?.install_id ?? null,
2148
+ publisher_id: state?.publisher_id ?? null,
2149
+ publisher_target: state?.publisher_target ?? MIMOCODE_PUBLISHER_TARGET,
2150
+ state_path: miMoCodeStatePath(),
2151
+ runtime_path: runtimePath,
2152
+ cache_path: cachePath,
2153
+ bashrc_path: bashrcPath,
2154
+ runtime_installed: runtimeInstalled,
2155
+ bashrc_hook: hookExists,
2156
+ installed_at: state?.installed_at ?? null,
2157
+ ...(installed
2158
+ ? {
2159
+ next: "test_runtime",
2160
+ next_command: "waitspin-mimocode-runtime",
2161
+ acceptance_hint: "Run 'source ~/.bashrc' or restart your shell, then keep the sponsored line visible for at least 5 seconds.",
2162
+ }
2163
+ : {
2164
+ next: "install_mimocode",
2165
+ next_command: "waitspin mimocode install",
2166
+ }),
2167
+ });
2168
+ }
2169
+ export async function runMiMoCodeUninstall(flags) {
2170
+ const dryRun = booleanFlag(flags, "dry-run");
2171
+ const state = await loadMiMoCodeInstallState();
2172
+ const bashrcPath = state?.bashrc_path ?? miMoCodeBashrcPath();
2173
+ const runtimePath = state?.runtime_path ?? miMoCodeRuntimePath();
2174
+ const cachePath = state?.cache_path ?? miMoCodeCachePath();
2175
+ const declaredRemovePaths = [
2176
+ runtimePath,
2177
+ miMoCodeStatePath(),
2178
+ cachePath,
2179
+ ];
2180
+ if (dryRun) {
2181
+ printJson({
2182
+ ok: true,
2183
+ target: MIMOCODE_PUBLISHER_TARGET,
2184
+ dry_run: true,
2185
+ installed: Boolean(state),
2186
+ would_remove: declaredRemovePaths,
2187
+ bashrc_hook: await readFile(bashrcPath, "utf8")
2188
+ .then((c) => c.includes(MIMOCODE_BASHRC_MARKER))
2189
+ .catch(() => false),
2190
+ });
2191
+ return;
2192
+ }
2193
+ const removePaths = [miMoCodeStatePath()];
2194
+ const skippedUnsafePaths = [];
2195
+ if (state) {
2196
+ try {
2197
+ removePaths.unshift(assertSafeMiMoCodeRuntimePath(state.runtime_path));
2198
+ }
2199
+ catch {
2200
+ skippedUnsafePaths.push(state.runtime_path);
2201
+ }
2202
+ try {
2203
+ removePaths.push(assertSafeMiMoCodeCachePath(cachePath));
2204
+ }
2205
+ catch {
2206
+ skippedUnsafePaths.push(cachePath);
2207
+ }
2208
+ }
2209
+ else {
2210
+ removePaths.push(assertSafeMiMoCodeCachePath(cachePath));
2211
+ }
2212
+ await removeMiMoCodeBashHook(bashrcPath);
2213
+ await Promise.all(removePaths.map((filePath) => rm(filePath, { force: true, recursive: true })));
2214
+ printJson({
2215
+ ok: true,
2216
+ target: MIMOCODE_PUBLISHER_TARGET,
2217
+ uninstalled: true,
2218
+ removed: removePaths,
2219
+ bashrc_hook_removed: true,
2220
+ ...(skippedUnsafePaths.length > 0
2221
+ ? { skipped_unsafe_paths: skippedUnsafePaths }
2222
+ : {}),
2223
+ });
2224
+ }
2225
+ function assertSafeMiMoCodeRuntimePath(filePath) {
2226
+ const expected = path.resolve(miMoCodeRuntimePath());
2227
+ const resolved = path.resolve(filePath);
2228
+ if (resolved !== expected) {
2229
+ throw new Error("Refusing to manage a MiMo Code runtime path that is not the WaitSpin-managed executable.");
2230
+ }
2231
+ return expected;
2232
+ }
2233
+ function assertSafeMiMoCodeCachePath(filePath) {
2234
+ const expected = path.resolve(miMoCodeCachePath());
2235
+ const resolved = path.resolve(filePath);
2236
+ if (resolved !== expected) {
2237
+ throw new Error("Refusing to manage a MiMo Code cache path that is not the WaitSpin-managed cache.");
2238
+ }
2239
+ return expected;
2240
+ }
2241
+ const OPENCODE_TUI_PLUGIN_ENTRY = "./plugins/waitspin-opencode.plugin.tsx";
2242
+ function opencodeInstallDir() {
2243
+ return path.join(os.homedir(), ".waitspin");
2244
+ }
2245
+ function opencodeConfigDir() {
2246
+ return path.join(os.homedir(), ".config", "opencode");
2247
+ }
2248
+ function opencodeStatePath() {
2249
+ return path.join(opencodeInstallDir(), "opencode-install.json");
2250
+ }
2251
+ function opencodeRuntimePath() {
2252
+ return path.join(opencodeInstallDir(), "opencode-statusline.mjs");
2253
+ }
2254
+ function opencodeCachePath() {
2255
+ return path.join(opencodeInstallDir(), "opencode-statusline-cache.json");
2256
+ }
2257
+ function opencodePluginInstallDir() {
2258
+ return path.join(opencodeConfigDir(), "plugins");
2259
+ }
2260
+ function opencodePluginDestPath() {
2261
+ return path.join(opencodePluginInstallDir(), "waitspin-opencode.plugin.tsx");
2262
+ }
2263
+ function opencodeTuiConfigPath() {
2264
+ return path.join(opencodeConfigDir(), "tui.json");
2265
+ }
2266
+ function opencodePluginAssetName() {
2267
+ return "waitspin-opencode.plugin.tsx";
2268
+ }
2269
+ async function resolveOpencodeAssetsDir(flags) {
2270
+ const packageRoot = resolveCliPackageRoot();
2271
+ const candidates = [
2272
+ path.join(packageRoot, "assets", "waitspin-opencode"),
2273
+ ];
2274
+ if (allowDevExtensionAssets(flags)) {
2275
+ candidates.push(path.join(packageRoot, "..", "..", "extensions", "waitspin-opencode"));
2276
+ }
2277
+ for (const candidate of candidates) {
2278
+ const resolved = path.resolve(candidate);
2279
+ try {
2280
+ await access(path.join(resolved, "opencode-statusline.mjs"), fsConstants.F_OK);
2281
+ await access(path.join(resolved, opencodePluginAssetName()), fsConstants.F_OK);
2282
+ return resolved;
2283
+ }
2284
+ catch {
2285
+ continue;
2286
+ }
2287
+ }
2288
+ throw new Error(`WaitSpin OpenCode assets not found. Use a build that ships assets/waitspin-opencode or set ${DEV_EXTENSION_ASSETS_OPT_IN_ENV}=1 / --allow-dev-extension-assets from a trusted checkout.`);
2289
+ }
2290
+ function assertSafeOpencodeManagedPath(filePath) {
2291
+ const expected = path.resolve(opencodeCachePath());
2292
+ const resolved = path.resolve(filePath);
2293
+ if (resolved !== expected) {
2294
+ throw new Error("Refusing to manage an OpenCode WaitSpin cache path that is not the managed cache.");
2295
+ }
2296
+ return expected;
2297
+ }
2298
+ function assertSafeOpencodePluginPath(filePath) {
2299
+ const expected = path.resolve(opencodePluginDestPath());
2300
+ const resolved = path.resolve(filePath);
2301
+ if (resolved !== expected) {
2302
+ throw new Error("Refusing to manage an OpenCode plugin path that is not the WaitSpin-managed plugin.");
2303
+ }
2304
+ return expected;
2305
+ }
2306
+ function assertSafeOpencodeTuiConfigPath(filePath) {
2307
+ const expected = path.resolve(opencodeTuiConfigPath());
2308
+ const resolved = path.resolve(filePath);
2309
+ if (resolved !== expected) {
2310
+ throw new Error("Refusing to manage an OpenCode TUI config path that is not the WaitSpin-managed config.");
2311
+ }
2312
+ return expected;
2313
+ }
2314
+ function assertSafeOpencodeManagedRuntimePath(filePath) {
2315
+ const expected = path.resolve(opencodeRuntimePath());
2316
+ const resolved = path.resolve(filePath);
2317
+ if (resolved !== expected) {
2318
+ throw new Error("Refusing to manage an OpenCode runtime path that is not the WaitSpin-managed runtime.");
2319
+ }
2320
+ return expected;
2321
+ }
2322
+ function opencodeTuiPluginEntrySpec(value) {
2323
+ if (typeof value === "string")
2324
+ return value;
2325
+ if (Array.isArray(value) && typeof value[0] === "string")
2326
+ return value[0];
2327
+ return null;
2328
+ }
2329
+ function isWaitSpinOpencodeTuiPluginEntry(value, tuiConfigPath = opencodeTuiConfigPath()) {
2330
+ const spec = opencodeTuiPluginEntrySpec(value);
2331
+ if (!spec)
2332
+ return false;
2333
+ if (spec === OPENCODE_TUI_PLUGIN_ENTRY)
2334
+ return true;
2335
+ if (path.isAbsolute(spec)) {
2336
+ return path.resolve(spec) === path.resolve(opencodePluginDestPath());
2337
+ }
2338
+ if (spec.startsWith(".")) {
2339
+ return (path.resolve(path.dirname(tuiConfigPath), spec) ===
2340
+ path.resolve(opencodePluginDestPath()));
2341
+ }
2342
+ return false;
2343
+ }
2344
+ function opencodeConfigWithTuiPluginEntry(input) {
2345
+ const plugin = input.config.plugin;
2346
+ if (plugin !== undefined && !Array.isArray(plugin)) {
2347
+ throw new Error("OpenCode tui.json plugin field must be an array before WaitSpin can manage it.");
2348
+ }
2349
+ const entries = Array.isArray(plugin) ? plugin : [];
2350
+ if (entries.some((entry) => isWaitSpinOpencodeTuiPluginEntry(entry, input.tuiConfigPath))) {
2351
+ return input.config;
2352
+ }
2353
+ return {
2354
+ ...input.config,
2355
+ plugin: [...entries, OPENCODE_TUI_PLUGIN_ENTRY],
2356
+ };
2357
+ }
2358
+ async function planOpencodeTuiConfigInstall(tuiConfigPath) {
2359
+ const previousConfig = await readJsonObjectFile(tuiConfigPath);
2360
+ const config = previousConfig ?? {};
2361
+ const nextConfig = opencodeConfigWithTuiPluginEntry({
2362
+ config,
2363
+ tuiConfigPath,
2364
+ });
2365
+ return {
2366
+ configPath: tuiConfigPath,
2367
+ previousConfig,
2368
+ nextConfig,
2369
+ changed: JSON.stringify(config) !== JSON.stringify(nextConfig),
2370
+ };
2371
+ }
2372
+ async function writeOpencodeTuiConfigPlan(plan) {
2373
+ if (!plan.changed)
2374
+ return;
2375
+ await writeJsonObjectFile(plan.configPath, plan.nextConfig);
2376
+ }
2377
+ async function restoreOpencodeTuiConfigPlan(plan) {
2378
+ if (!plan.changed)
2379
+ return;
2380
+ if (plan.previousConfig) {
2381
+ await writeJsonObjectFile(plan.configPath, plan.previousConfig).catch(() => { });
2382
+ return;
2383
+ }
2384
+ await Promise.resolve(rm(plan.configPath, { force: true })).catch(() => { });
2385
+ }
2386
+ async function opencodeTuiPluginConfigured(tuiConfigPath) {
2387
+ const config = (await readJsonObjectFile(tuiConfigPath)) ?? {};
2388
+ const plugin = config.plugin;
2389
+ return (Array.isArray(plugin) &&
2390
+ plugin.some((entry) => isWaitSpinOpencodeTuiPluginEntry(entry, tuiConfigPath)));
2391
+ }
2392
+ async function removeOpencodeTuiPluginEntry(tuiConfigPath) {
2393
+ try {
2394
+ const config = (await readJsonObjectFile(tuiConfigPath)) ?? {};
2395
+ const plugin = config.plugin;
2396
+ if (plugin === undefined) {
2397
+ return { updated: false, configured_before: false, error: null };
2398
+ }
2399
+ if (!Array.isArray(plugin)) {
2400
+ return {
2401
+ updated: false,
2402
+ configured_before: false,
2403
+ error: "invalid_plugin_field",
2404
+ };
2405
+ }
2406
+ const filtered = plugin.filter((entry) => !isWaitSpinOpencodeTuiPluginEntry(entry, tuiConfigPath));
2407
+ if (filtered.length === plugin.length) {
2408
+ return { updated: false, configured_before: false, error: null };
2409
+ }
2410
+ const nextConfig = { ...config };
2411
+ if (filtered.length > 0) {
2412
+ nextConfig.plugin = filtered;
2413
+ }
2414
+ else {
2415
+ delete nextConfig.plugin;
2416
+ }
2417
+ await writeJsonObjectFile(tuiConfigPath, nextConfig);
2418
+ return { updated: true, configured_before: true, error: null };
2419
+ }
2420
+ catch (error) {
2421
+ if (isErrno(error, "ENOENT")) {
2422
+ return { updated: false, configured_before: false, error: null };
2423
+ }
2424
+ return { updated: false, configured_before: false, error: "read_failed" };
2425
+ }
2426
+ }
2427
+ async function loadOpencodeInstallState() {
2428
+ const parsed = await readJsonObjectFile(opencodeStatePath());
2429
+ if (!parsed?.install_id || parsed.target !== OPENCODE_PUBLISHER_TARGET) {
2430
+ return null;
2431
+ }
2432
+ return parsed;
2433
+ }
2434
+ function redactedOpencodeState(state) {
2435
+ if (!state)
2436
+ return null;
2437
+ return {
2438
+ target: state.target,
2439
+ install_id: state.install_id,
2440
+ publisher_id: state.publisher_id,
2441
+ publisher_target: state.publisher_target,
2442
+ registered_at: state.registered_at,
2443
+ base_url: state.base_url,
2444
+ runtime_path: state.runtime_path,
2445
+ cache_path: state.cache_path,
2446
+ plugin_path: state.plugin_path,
2447
+ tui_config_path: state.tui_config_path,
2448
+ tui_plugin_entry: state.tui_plugin_entry,
2449
+ installed_at: state.installed_at,
2450
+ api_key_present: Boolean(state.api_key),
2451
+ };
2452
+ }
2453
+ async function writeOpencodeRuntimeAndPlugin(input) {
2454
+ await mkdir(path.dirname(input.runtimePath), { recursive: true });
2455
+ await mkdir(path.dirname(input.pluginDest), { recursive: true });
2456
+ await cp(path.join(input.sourceDir, "opencode-statusline.mjs"), input.runtimePath, { force: true });
2457
+ const pluginTemplate = await readFile(path.join(input.sourceDir, opencodePluginAssetName()), "utf8");
2458
+ const pluginSource = pluginTemplate
2459
+ .replaceAll('"__WAITSPIN_STATE_PATH__"', JSON.stringify(input.statePath));
2460
+ await writeFile(input.pluginDest, pluginSource, {
2461
+ encoding: "utf8",
2462
+ mode: 0o600,
2463
+ });
2464
+ await chmod(input.runtimePath, 0o755);
2465
+ await chmod(input.pluginDest, 0o600);
2466
+ }
2467
+ export async function runOpencodeInstall(flags) {
2468
+ const dryRun = booleanFlag(flags, "dry-run");
2469
+ const baseUrl = resolveCredentialedBaseUrl(flags);
2470
+ const statePath = opencodeStatePath();
2471
+ const runtimePath = opencodeRuntimePath();
2472
+ const cachePath = opencodeCachePath();
2473
+ const pluginDest = opencodePluginDestPath();
2474
+ const tuiConfigPath = opencodeTuiConfigPath();
2475
+ const existingState = await loadOpencodeInstallState();
2476
+ const installId = existingState?.install_id || generateInstallId();
2477
+ const summary = {
2478
+ ok: true,
2479
+ target: OPENCODE_PUBLISHER_TARGET,
2480
+ mode: "tui-plugin-slot",
2481
+ install_id: installId,
2482
+ publisher_target: OPENCODE_PUBLISHER_TARGET,
2483
+ state_path: statePath,
2484
+ runtime_path: runtimePath,
2485
+ cache_path: cachePath,
2486
+ plugin_path: pluginDest,
2487
+ tui_config_path: tuiConfigPath,
2488
+ tui_plugin_entry: OPENCODE_TUI_PLUGIN_ENTRY,
2489
+ note: "Installs WaitSpin through the OpenCode TUI plugin slot (app_bottom).",
2490
+ next: "check_status",
2491
+ next_command: "waitspin opencode status",
2492
+ };
2493
+ if (dryRun) {
2494
+ printJson({
2495
+ ...summary,
2496
+ dry_run: true,
2497
+ publisher_registered: false,
2498
+ });
2499
+ return;
2500
+ }
2501
+ const assetsDir = await resolveOpencodeAssetsDir(flags);
2502
+ const apiKey = requireApiKey(flags);
2503
+ const tuiConfigPlan = await planOpencodeTuiConfigInstall(tuiConfigPath);
2504
+ const registration = await registerPublisherInstall({
2505
+ baseUrl,
2506
+ apiKey,
2507
+ installId,
2508
+ target: OPENCODE_PUBLISHER_TARGET,
2509
+ });
2510
+ const installedAt = new Date().toISOString();
2511
+ const installState = {
2512
+ target: OPENCODE_PUBLISHER_TARGET,
2513
+ install_id: registration.install_id,
2514
+ publisher_id: registration.publisher_id,
2515
+ publisher_target: registration.target,
2516
+ registered_at: installedAt,
2517
+ base_url: baseUrl,
2518
+ api_key: apiKey,
2519
+ runtime_path: runtimePath,
2520
+ cache_path: cachePath,
2521
+ plugin_path: pluginDest,
2522
+ tui_config_path: tuiConfigPath,
2523
+ tui_plugin_entry: OPENCODE_TUI_PLUGIN_ENTRY,
2524
+ installed_at: installedAt,
2525
+ };
2526
+ try {
2527
+ await writeOpencodeRuntimeAndPlugin({
2528
+ sourceDir: assetsDir,
2529
+ runtimePath,
2530
+ pluginDest,
2531
+ statePath,
2532
+ });
2533
+ await writeOpencodeTuiConfigPlan(tuiConfigPlan);
2534
+ await writeJsonObjectFile(statePath, installState, 0o600);
2535
+ }
2536
+ catch (error) {
2537
+ await restoreOpencodeTuiConfigPlan(tuiConfigPlan);
2538
+ if (existingState) {
2539
+ await writeJsonObjectFile(statePath, existingState, 0o600).catch(() => { });
2540
+ }
2541
+ else {
2542
+ await Promise.resolve(rm(statePath, { force: true, recursive: true })).catch(() => { });
2543
+ await Promise.resolve(rm(runtimePath, { force: true, recursive: true })).catch(() => { });
2544
+ await Promise.resolve(rm(pluginDest, { force: true, recursive: true })).catch(() => { });
2545
+ }
2546
+ throw error;
2547
+ }
2548
+ printJson({
2549
+ ...summary,
2550
+ ...redactedOpencodeState(installState),
2551
+ publisher_registered: true,
2552
+ next: "restart_opencode",
2553
+ next_command: "opencode",
2554
+ acceptance_hint: "Restart OpenCode to load the TUI plugin. The sponsored line appears in the app_bottom slot.",
2555
+ });
2556
+ }
2557
+ export async function runOpencodeStatus() {
2558
+ const state = await loadOpencodeInstallState();
2559
+ const runtimePath = state?.runtime_path ?? opencodeRuntimePath();
2560
+ const cachePath = state?.cache_path ?? opencodeCachePath();
2561
+ const pluginDest = state?.plugin_path ?? opencodePluginDestPath();
2562
+ const tuiConfigPath = state?.tui_config_path ?? opencodeTuiConfigPath();
2563
+ let runtimeInstalled = false;
2564
+ let pluginInstalled = false;
2565
+ let tuiPluginConfigured = false;
2566
+ let tuiConfigError = null;
2567
+ if (state) {
2568
+ runtimeInstalled = await pathExists(assertSafeOpencodeManagedRuntimePath(state.runtime_path));
2569
+ pluginInstalled = await pathExists(assertSafeOpencodePluginPath(state.plugin_path));
2570
+ try {
2571
+ tuiPluginConfigured = await opencodeTuiPluginConfigured(assertSafeOpencodeTuiConfigPath(tuiConfigPath));
2572
+ }
2573
+ catch {
2574
+ tuiConfigError = "invalid_tui_config";
2575
+ }
2576
+ }
2577
+ else {
2578
+ const safeRuntime = assertSafeOpencodeManagedRuntimePath(runtimePath);
2579
+ runtimeInstalled = await pathExists(safeRuntime);
2580
+ const safePlugin = assertSafeOpencodePluginPath(pluginDest);
2581
+ pluginInstalled = await pathExists(safePlugin);
2582
+ try {
2583
+ tuiPluginConfigured = await opencodeTuiPluginConfigured(assertSafeOpencodeTuiConfigPath(tuiConfigPath));
2584
+ }
2585
+ catch {
2586
+ tuiPluginConfigured = false;
2587
+ }
2588
+ }
2589
+ const installed = Boolean(state && runtimeInstalled && pluginInstalled && tuiPluginConfigured);
2590
+ printJson({
2591
+ ok: true,
2592
+ target: OPENCODE_PUBLISHER_TARGET,
2593
+ mode: "tui-plugin-slot",
2594
+ installed,
2595
+ publisher_registered: Boolean(state?.publisher_id),
2596
+ install_id: state?.install_id ?? null,
2597
+ publisher_id: state?.publisher_id ?? null,
2598
+ publisher_target: state?.publisher_target ?? OPENCODE_PUBLISHER_TARGET,
2599
+ state_path: opencodeStatePath(),
2600
+ runtime_path: runtimePath,
2601
+ cache_path: cachePath,
2602
+ plugin_path: pluginDest,
2603
+ tui_config_path: tuiConfigPath,
2604
+ tui_plugin_entry: state?.tui_plugin_entry ?? OPENCODE_TUI_PLUGIN_ENTRY,
2605
+ runtime_installed: runtimeInstalled,
2606
+ plugin_installed: pluginInstalled,
2607
+ tui_plugin_configured: tuiPluginConfigured,
2608
+ tui_config_error: tuiConfigError,
2609
+ installed_at: state?.installed_at ?? null,
2610
+ ...(installed
2611
+ ? {
2612
+ next: "restart_opencode",
2613
+ next_command: "opencode",
2614
+ acceptance_hint: "Restart OpenCode to load the TUI plugin. The sponsored line appears in the app_bottom slot.",
2615
+ }
2616
+ : {
2617
+ next: "install_opencode",
2618
+ next_command: "waitspin opencode install",
2619
+ }),
2620
+ });
2621
+ }
2622
+ export async function runOpencodeUninstall(flags) {
2623
+ const dryRun = booleanFlag(flags, "dry-run");
2624
+ const state = await loadOpencodeInstallState();
2625
+ const tuiConfigPath = state?.tui_config_path ?? opencodeTuiConfigPath();
2626
+ const declaredRemovePaths = state
2627
+ ? [
2628
+ state.runtime_path,
2629
+ state.cache_path,
2630
+ opencodeStatePath(),
2631
+ state.plugin_path,
2632
+ ]
2633
+ : [opencodeStatePath()];
2634
+ if (dryRun) {
2635
+ printJson({
2636
+ ok: true,
2637
+ target: OPENCODE_PUBLISHER_TARGET,
2638
+ dry_run: true,
2639
+ installed: Boolean(state),
2640
+ would_remove: declaredRemovePaths,
2641
+ would_update: [tuiConfigPath],
2642
+ });
2643
+ return;
2644
+ }
2645
+ const removePaths = [];
2646
+ const skippedUnsafePaths = [];
2647
+ let tuiConfigUpdate = null;
2648
+ if (state) {
2649
+ try {
2650
+ removePaths.push(assertSafeOpencodeManagedRuntimePath(state.runtime_path));
2651
+ }
2652
+ catch {
2653
+ skippedUnsafePaths.push(state.runtime_path);
2654
+ }
2655
+ try {
2656
+ removePaths.push(assertSafeOpencodeManagedPath(state.cache_path));
2657
+ }
2658
+ catch {
2659
+ skippedUnsafePaths.push(state.cache_path);
2660
+ }
2661
+ try {
2662
+ removePaths.push(assertSafeOpencodePluginPath(state.plugin_path));
2663
+ }
2664
+ catch {
2665
+ skippedUnsafePaths.push(state.plugin_path);
2666
+ }
2667
+ try {
2668
+ tuiConfigUpdate = await removeOpencodeTuiPluginEntry(assertSafeOpencodeTuiConfigPath(tuiConfigPath));
2669
+ }
2670
+ catch {
2671
+ skippedUnsafePaths.push(tuiConfigPath);
2672
+ }
2673
+ }
2674
+ removePaths.splice(state ? Math.min(removePaths.length, 2) : 0, 0, opencodeStatePath());
2675
+ await Promise.all(removePaths.map((filePath) => rm(filePath, { force: true, recursive: true })));
2676
+ printJson({
2677
+ ok: true,
2678
+ target: OPENCODE_PUBLISHER_TARGET,
2679
+ uninstalled: true,
2680
+ removed: removePaths,
2681
+ tui_config_updated: tuiConfigUpdate?.updated ?? false,
2682
+ tui_plugin_configured_before: tuiConfigUpdate?.configured_before ?? false,
2683
+ tui_config_error: tuiConfigUpdate?.error ?? null,
2684
+ ...(skippedUnsafePaths.length > 0
2685
+ ? { skipped_unsafe_paths: skippedUnsafePaths }
2686
+ : {}),
2687
+ });
2688
+ }
2689
+ function cloneFlags(flags) {
2690
+ return new Map(Array.from(flags.entries()).map(([key, values]) => [key, [...values]]));
2691
+ }
2692
+ function extensionAllFlags(flags) {
2693
+ const next = cloneFlags(flags);
2694
+ next.set("target", ["vscode"]);
2695
+ return next;
2696
+ }
2697
+ function executableFromEnv(envName, defaultBin) {
2698
+ return process.env[envName]?.trim() || defaultBin;
2699
+ }
2700
+ async function executableVersion(envName, defaultBin, label) {
2701
+ const binary = executableFromEnv(envName, defaultBin);
2702
+ try {
2703
+ const result = await execFileText(binary, ["--version"], {
2704
+ encoding: "utf8",
2705
+ timeout: 5_000,
2706
+ });
2707
+ return `${result.stdout || result.stderr || label}`.trim();
2708
+ }
2709
+ catch (error) {
2710
+ throw new Error(`${label} was not detected. Install ${label} or set ${envName} to its executable path.`, { cause: error });
2711
+ }
2712
+ }
2713
+ async function preflightVscode(flags) {
2714
+ return resolveExtensionDir(flags);
2715
+ }
2716
+ async function preflightClaudeCode() {
2717
+ return assertSupportedClaudeCodeVersion();
2718
+ }
2719
+ async function preflightMiMoCode() {
2720
+ return executableVersion(MIMOCODE_BIN_ENV, MIMOCODE_DEFAULT_BIN, "MiMo Code");
2721
+ }
2722
+ async function preflightOpenCode() {
2723
+ return executableVersion(OPENCODE_BIN_ENV, OPENCODE_DEFAULT_BIN, "OpenCode");
2724
+ }
2725
+ function allInstallTargets() {
2726
+ return [
2727
+ {
2728
+ target: "vscode",
2729
+ command: "waitspin extension install --target vscode",
2730
+ statusCommand: "waitspin extension status --target vscode",
2731
+ preflight: preflightVscode,
2732
+ install: (flags) => runExtensionInstall(extensionAllFlags(flags)),
2733
+ status: (flags) => runExtensionStatus(extensionAllFlags(flags)),
2734
+ },
2735
+ {
2736
+ target: CLAUDE_CODE_PUBLISHER_TARGET,
2737
+ command: "waitspin claude-code install --compose-existing",
2738
+ statusCommand: "waitspin claude-code status",
2739
+ preflight: preflightClaudeCode,
2740
+ install: runClaudeCodeInstall,
2741
+ status: () => runClaudeCodeStatus(),
2742
+ },
2743
+ {
2744
+ target: MIMOCODE_PUBLISHER_TARGET,
2745
+ command: "waitspin mimocode install",
2746
+ statusCommand: "waitspin mimocode status",
2747
+ preflight: preflightMiMoCode,
2748
+ install: runMiMoCodeInstall,
2749
+ status: () => runMiMoCodeStatus(),
2750
+ },
2751
+ {
2752
+ target: OPENCODE_PUBLISHER_TARGET,
2753
+ command: "waitspin opencode install",
2754
+ statusCommand: "waitspin opencode status",
2755
+ preflight: preflightOpenCode,
2756
+ install: runOpencodeInstall,
2757
+ status: () => runOpencodeStatus(),
2758
+ },
2759
+ ];
2760
+ }
2761
+ function safeErrorMessage(error) {
2762
+ const message = error instanceof Error ? error.message : String(error);
2763
+ return message
2764
+ .replace(/wts_(?:live|test)__[A-Za-z0-9_-]+/g, "[REDACTED_WAITSPIN_KEY]")
2765
+ .replace(/npm_[A-Za-z0-9_-]+/g, "[REDACTED_NPM_TOKEN]");
2766
+ }
2767
+ function isNotDetectedError(message) {
2768
+ return /not detected|not found|Unable to run Claude Code|Unsupported Claude Code version|ENOENT|spawn .*ENOENT|package not found/i.test(message);
2769
+ }
2770
+ function isConflictError(message) {
2771
+ return /statusLine|status line|conflict|override|already has|blocked/i.test(message);
2772
+ }
2773
+ function objectRecord(value) {
2774
+ return value && typeof value === "object" && !Array.isArray(value)
2775
+ ? value
2776
+ : {};
2777
+ }
2778
+ function dryRunConflictReason(result) {
2779
+ const record = objectRecord(result);
2780
+ if (!record.would_fail)
2781
+ return null;
2782
+ const reason = record.settings_blocked_reason ?? record.human_message;
2783
+ return typeof reason === "string" ? reason : "target reported a conflict";
2784
+ }
2785
+ export async function runInstallAll(flags) {
2786
+ const dryRun = booleanFlag(flags, "dry-run");
2787
+ const installed = [];
2788
+ const wouldInstall = [];
2789
+ const skippedNotDetected = [];
2790
+ const skippedConflict = [];
2791
+ const failedRollback = [];
2792
+ for (const target of allInstallTargets()) {
2793
+ let detail = null;
2794
+ try {
2795
+ detail = await target.preflight(flags);
2796
+ }
2797
+ catch (error) {
2798
+ const reason = safeErrorMessage(error);
2799
+ skippedNotDetected.push({
2800
+ target: target.target,
2801
+ command: target.command,
2802
+ reason,
2803
+ });
2804
+ continue;
2805
+ }
2806
+ try {
2807
+ const result = await capturePrintedJson(() => target.install(flags));
2808
+ const conflictReason = dryRunConflictReason(result);
2809
+ if (conflictReason) {
2810
+ skippedConflict.push({
2811
+ target: target.target,
2812
+ command: target.command,
2813
+ reason: conflictReason,
2814
+ detail,
2815
+ result,
2816
+ });
2817
+ continue;
2818
+ }
2819
+ const summary = {
2820
+ target: target.target,
2821
+ command: target.command,
2822
+ detail,
2823
+ result,
2824
+ };
2825
+ if (dryRun) {
2826
+ wouldInstall.push(summary);
2827
+ }
2828
+ else {
2829
+ installed.push(summary);
2830
+ }
2831
+ }
2832
+ catch (error) {
2833
+ const reason = safeErrorMessage(error);
2834
+ const bucket = isConflictError(reason)
2835
+ ? skippedConflict
2836
+ : isNotDetectedError(reason)
2837
+ ? skippedNotDetected
2838
+ : failedRollback;
2839
+ bucket.push({
2840
+ target: target.target,
2841
+ command: target.command,
2842
+ reason,
2843
+ detail,
2844
+ });
2845
+ }
2846
+ }
2847
+ printJson({
2848
+ ok: failedRollback.length === 0,
2849
+ command: "install --all",
2850
+ dry_run: dryRun,
2851
+ mode: "detected-targets",
2852
+ installed,
2853
+ would_install: wouldInstall,
2854
+ skipped_not_detected: skippedNotDetected,
2855
+ skipped_conflict: skippedConflict,
2856
+ failed_rollback: failedRollback,
2857
+ next: "check_all_status",
2858
+ next_command: "waitspin status --all",
2859
+ human_message: "Install-all is an advanced agent command. Explicit target commands remain the canonical debug path.",
2860
+ });
2861
+ }
2862
+ export async function runStatusAll(flags) {
2863
+ const statuses = [];
2864
+ const installed = [];
2865
+ const failedStatus = [];
2866
+ for (const target of allInstallTargets()) {
2867
+ try {
2868
+ const result = await capturePrintedJson(() => target.status(flags));
2869
+ const summary = {
2870
+ target: target.target,
2871
+ command: target.statusCommand,
2872
+ result,
2873
+ };
2874
+ statuses.push(summary);
2875
+ if (objectRecord(result).installed === true) {
2876
+ installed.push(summary);
2877
+ }
2878
+ }
2879
+ catch (error) {
2880
+ failedStatus.push({
2881
+ target: target.target,
2882
+ command: target.statusCommand,
2883
+ reason: safeErrorMessage(error),
2884
+ });
2885
+ }
2886
+ }
2887
+ printJson({
2888
+ ok: failedStatus.length === 0,
2889
+ command: "status --all",
2890
+ installed,
2891
+ statuses,
2892
+ failed_status: failedStatus,
2893
+ });
2894
+ }
2895
+ export async function main(argv = process.argv.slice(2)) {
2896
+ const { command, flags, positionals } = parseArgs(argv);
2897
+ if (command === "init") {
2898
+ await runInit(flags);
2899
+ return;
2900
+ }
2901
+ if (command === "bid" && positionals[0] === "create") {
2902
+ await runBidCreate(flags);
2903
+ return;
2904
+ }
2905
+ if (command === "bids" && positionals[0] === "list") {
2906
+ await runBidsList(flags);
2907
+ return;
2908
+ }
2909
+ if (command === "bid" && positionals[0] === "checkout") {
2910
+ await runBidCheckout(flags, positionals.slice(1));
2911
+ return;
2912
+ }
2913
+ if (command === "market") {
2914
+ await runMarket(flags);
2915
+ return;
2916
+ }
2917
+ if (command === "wallet" && positionals[0] === "status") {
2918
+ await runWalletStatus(flags);
2919
+ return;
2920
+ }
2921
+ if (command === "wallet" && positionals[0] === "connect") {
2922
+ await runWalletConnect(flags);
2923
+ return;
2924
+ }
2925
+ if (command === "wallet" && positionals[0] === "ledger") {
2926
+ await runWalletLedger(flags);
2927
+ return;
2928
+ }
2929
+ if (command === "wallet" && positionals[0] === "payout") {
2930
+ await runWalletPayout(flags);
2931
+ return;
2932
+ }
2933
+ if (command === "extension" && positionals[0] === "install") {
2934
+ await runExtensionInstall(flags);
2935
+ return;
2936
+ }
2937
+ if (command === "extension" && positionals[0] === "status") {
2938
+ await runExtensionStatus(flags);
2939
+ return;
2940
+ }
2941
+ if (command === "extension" && positionals[0] === "uninstall") {
2942
+ await runExtensionUninstall(flags);
2943
+ return;
2944
+ }
2945
+ if (command === "install" && booleanFlag(flags, "all")) {
2946
+ await runInstallAll(flags);
2947
+ return;
2948
+ }
2949
+ if (command === "status" && booleanFlag(flags, "all")) {
2950
+ await runStatusAll(flags);
2951
+ return;
2952
+ }
2953
+ if (command === "claude-code" && positionals[0] === "install") {
2954
+ await runClaudeCodeInstall(flags);
2955
+ return;
2956
+ }
2957
+ if (command === "claude-code" && positionals[0] === "status") {
2958
+ await runClaudeCodeStatus();
2959
+ return;
2960
+ }
2961
+ if (command === "claude-code" && positionals[0] === "uninstall") {
2962
+ await runClaudeCodeUninstall(flags);
2963
+ return;
2964
+ }
2965
+ if (command === "mimocode" && positionals[0] === "install") {
2966
+ await runMiMoCodeInstall(flags);
2967
+ return;
2968
+ }
2969
+ if (command === "mimocode" && positionals[0] === "status") {
2970
+ await runMiMoCodeStatus();
2971
+ return;
2972
+ }
2973
+ if (command === "mimocode" && positionals[0] === "uninstall") {
2974
+ await runMiMoCodeUninstall(flags);
2975
+ return;
2976
+ }
2977
+ if (command === "opencode" && positionals[0] === "install") {
2978
+ await runOpencodeInstall(flags);
2979
+ return;
2980
+ }
2981
+ if (command === "opencode" && positionals[0] === "status") {
2982
+ await runOpencodeStatus();
2983
+ return;
2984
+ }
2985
+ if (command === "opencode" && positionals[0] === "uninstall") {
2986
+ await runOpencodeUninstall(flags);
663
2987
  return;
664
2988
  }
665
2989
  usage();