waitspin 0.1.1 → 0.1.3

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,20 @@
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, 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;
10
18
  const extensionTargets = {
11
19
  vscode: "vscode",
12
20
  };
@@ -16,7 +24,7 @@ export function usageText() {
16
24
  " waitspin init --email you@example.com [--code CODE] [--key-profile control|publisher-extension] [--base-url URL]",
17
25
  " waitspin bid create --line TEXT --url https://example.com --price-per-block CENTS --blocks N [--base-url URL] [--api-key KEY]",
18
26
  " waitspin bids list [--base-url URL] [--api-key KEY]",
19
- " waitspin bid checkout <campaign-id> [--base-url URL] [--api-key KEY]",
27
+ " waitspin bid checkout CAMPAIGN_ID [--base-url URL] [--api-key KEY]",
20
28
  " waitspin market [--base-url URL]",
21
29
  " waitspin wallet status [--base-url URL] [--api-key KEY]",
22
30
  " waitspin wallet connect [--base-url URL] [--api-key KEY]",
@@ -26,11 +34,23 @@ export function usageText() {
26
34
  " waitspin extension install [--target vscode] [--base-url URL] [--api-key KEY] [--dry-run]",
27
35
  " waitspin extension status [--target vscode]",
28
36
  " waitspin extension uninstall [--target vscode] [--dry-run]",
37
+ " waitspin claude-code install [--base-url URL] [--api-key KEY] [--dry-run] [--compose-existing]",
38
+ " waitspin claude-code status",
39
+ " waitspin claude-code uninstall [--dry-run]",
29
40
  "",
30
41
  "Defaults:",
31
42
  " API base: https://api.waitspin.com",
32
43
  " API key: WAITSPIN_API_KEY env var",
33
- " Public extension target: VS Code status-bar fallback",
44
+ " Public install targets: VS Code status-bar fallback, Claude Code statusline",
45
+ "",
46
+ "Publisher install targets:",
47
+ " VS Code:",
48
+ " waitspin extension install --target vscode",
49
+ " waitspin extension status --target vscode",
50
+ "",
51
+ " Claude Code:",
52
+ " waitspin claude-code install --compose-existing",
53
+ " waitspin claude-code status",
34
54
  ].join("\n") + "\n");
35
55
  }
36
56
  function usage(exitCode = 1) {
@@ -67,7 +87,10 @@ function parseArgs(argv) {
67
87
  }
68
88
  if (key === "dry-run" ||
69
89
  key === "allow-debug-auto-verify" ||
70
- key === "confirm-test-transfer") {
90
+ key === "allow-dev-api-base" ||
91
+ key === "allow-dev-extension-assets" ||
92
+ key === "confirm-test-transfer" ||
93
+ key === "compose-existing") {
71
94
  flags.set(key, ["true"]);
72
95
  continue;
73
96
  }
@@ -99,6 +122,51 @@ function booleanFlag(flags, name) {
99
122
  function resolveBaseUrl(flags) {
100
123
  return optionalFlag(flags, "base-url") || DEFAULT_BASE_URL;
101
124
  }
125
+ function allowDevApiBase(flags) {
126
+ return (booleanFlag(flags, "allow-dev-api-base") ||
127
+ process.env[DEV_API_BASE_OPT_IN_ENV] === "1");
128
+ }
129
+ function allowDevExtensionAssets(flags) {
130
+ return (booleanFlag(flags, "allow-dev-extension-assets") ||
131
+ process.env[DEV_EXTENSION_ASSETS_OPT_IN_ENV] === "1");
132
+ }
133
+ function isLoopbackApiHostname(hostname) {
134
+ const normalized = hostname.toLowerCase().replace(/\.$/, "");
135
+ return (normalized === "localhost" ||
136
+ normalized === "::1" ||
137
+ normalized === "[::1]" ||
138
+ /^127(?:\.\d{1,3}){3}$/.test(normalized));
139
+ }
140
+ function normalizeOriginUrl(value) {
141
+ let parsed;
142
+ try {
143
+ parsed = new URL(value);
144
+ }
145
+ catch {
146
+ throw new Error("Invalid WaitSpin API base URL. Use an http(s) origin without credentials, path, query, or fragment.");
147
+ }
148
+ if ((parsed.protocol !== "https:" && parsed.protocol !== "http:") ||
149
+ parsed.username ||
150
+ parsed.password ||
151
+ parsed.search ||
152
+ parsed.hash ||
153
+ (parsed.pathname !== "" && parsed.pathname !== "/")) {
154
+ throw new Error("Invalid WaitSpin API base URL. Use an http(s) origin without credentials, path, query, or fragment.");
155
+ }
156
+ return parsed;
157
+ }
158
+ function resolveCredentialedBaseUrl(flags) {
159
+ const parsed = normalizeOriginUrl(resolveBaseUrl(flags));
160
+ if (parsed.protocol === "https:" && parsed.origin === PRODUCTION_API_ORIGIN) {
161
+ return parsed.origin;
162
+ }
163
+ if (allowDevApiBase(flags) &&
164
+ (parsed.protocol === "http:" || parsed.protocol === "https:") &&
165
+ isLoopbackApiHostname(parsed.hostname)) {
166
+ return parsed.origin;
167
+ }
168
+ 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.`);
169
+ }
102
170
  function resolveApiKey(flags) {
103
171
  return optionalFlag(flags, "api-key") || process.env.WAITSPIN_API_KEY?.trim();
104
172
  }
@@ -118,7 +186,11 @@ function resolveKeyIntendedUse(flags) {
118
186
  function requireApiKey(flags) {
119
187
  const apiKey = resolveApiKey(flags);
120
188
  if (!apiKey) {
121
- throw new Error("Missing API key. Set WAITSPIN_API_KEY or pass --api-key");
189
+ throw new Error([
190
+ "Missing API key. Set WAITSPIN_API_KEY or pass --api-key.",
191
+ "Next command:",
192
+ " export WAITSPIN_API_KEY='PASTE_KEY_HERE'",
193
+ ].join("\n"));
122
194
  }
123
195
  return apiKey;
124
196
  }
@@ -156,6 +228,57 @@ async function requestJson(input, init) {
156
228
  function printJson(value) {
157
229
  process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
158
230
  }
231
+ function keyProfileFromIntendedUse(intendedUse) {
232
+ if (intendedUse === "key_profile:publisher_extension") {
233
+ return "publisher_extension";
234
+ }
235
+ if (intendedUse === "key_profile:control") {
236
+ return "control";
237
+ }
238
+ return null;
239
+ }
240
+ function keyProfileFlagFromIntendedUse(intendedUse) {
241
+ const profile = keyProfileFromIntendedUse(intendedUse);
242
+ if (profile === "publisher_extension") {
243
+ return " --key-profile publisher-extension";
244
+ }
245
+ if (profile === "control") {
246
+ return " --key-profile control";
247
+ }
248
+ return "";
249
+ }
250
+ function waitspinInitVerifyCommand(input) {
251
+ return `waitspin init --email ${input.email} --code CODE_FROM_EMAIL${keyProfileFlagFromIntendedUse(input.intendedUse)}`;
252
+ }
253
+ function nextCommandsForVerifiedKey(input) {
254
+ const scopes = input.scopes ?? [];
255
+ const publisherKey = keyProfileFromIntendedUse(input.intendedUse) === "publisher_extension" ||
256
+ (scopes.includes("publishers:write") &&
257
+ scopes.includes("serve:read") &&
258
+ scopes.includes("events:write") &&
259
+ !scopes.includes("campaigns:write"));
260
+ if (publisherKey) {
261
+ return {
262
+ next: "install_publisher_target",
263
+ next_commands: [
264
+ "export WAITSPIN_API_KEY='PASTE_KEY_HERE'",
265
+ "waitspin claude-code install --compose-existing",
266
+ "waitspin claude-code status",
267
+ "claude",
268
+ ],
269
+ human_message: "Use this publisher-extension key only for publisher install, serve polling, and impressions. Rotate it if it appears in logs.",
270
+ };
271
+ }
272
+ return {
273
+ next: "create_campaign_or_inspect_market",
274
+ next_commands: [
275
+ "export WAITSPIN_API_KEY='PASTE_KEY_HERE'",
276
+ "waitspin market",
277
+ 'waitspin bid create --line "Your ad" --url https://example.com --price-per-block 500 --blocks 1',
278
+ ],
279
+ human_message: "Use this control key for advertiser and wallet commands. Keep it out of shell history and logs.",
280
+ };
281
+ }
159
282
  async function runInit(flags) {
160
283
  const baseUrl = resolveBaseUrl(flags);
161
284
  const email = requireFlag(flags, "email");
@@ -172,7 +295,15 @@ async function runInit(flags) {
172
295
  ...(intendedUse ? { intended_use: intendedUse } : {}),
173
296
  }),
174
297
  });
175
- printJson({ ok: true, base_url: baseUrl, ...body });
298
+ printJson({
299
+ ok: true,
300
+ base_url: baseUrl,
301
+ ...body,
302
+ ...nextCommandsForVerifiedKey({
303
+ intendedUse,
304
+ scopes: body?.scopes,
305
+ }),
306
+ });
176
307
  return;
177
308
  }
178
309
  const { body: requestResult } = await requestJson(`${baseUrl}/v1/keys/request`, {
@@ -189,10 +320,13 @@ async function runInit(flags) {
189
320
  if (!code || !allowDebugAutoVerify) {
190
321
  printJson({
191
322
  ok: true,
192
- next: "check_email_for_code",
323
+ next: "enter_email_code",
193
324
  delivery: requestResult?.delivery,
194
325
  email,
195
- debug_code_available: Boolean(code),
326
+ expires_in_seconds: requestResult?.expires_in_seconds ?? 900,
327
+ next_command: waitspinInitVerifyCommand({ email, intendedUse }),
328
+ human_message: "Check your email for the 6-digit WaitSpin code. Paste it as CODE_FROM_EMAIL.",
329
+ ...(code ? { debug_code_available: true } : {}),
196
330
  });
197
331
  return;
198
332
  }
@@ -205,10 +339,18 @@ async function runInit(flags) {
205
339
  ...(intendedUse ? { intended_use: intendedUse } : {}),
206
340
  }),
207
341
  });
208
- printJson({ ok: true, base_url: baseUrl, ...verifyResult });
342
+ printJson({
343
+ ok: true,
344
+ base_url: baseUrl,
345
+ ...verifyResult,
346
+ ...nextCommandsForVerifiedKey({
347
+ intendedUse,
348
+ scopes: verifyResult?.scopes,
349
+ }),
350
+ });
209
351
  }
210
352
  async function runBidCreate(flags) {
211
- const baseUrl = resolveBaseUrl(flags);
353
+ const baseUrl = resolveCredentialedBaseUrl(flags);
212
354
  const apiKey = requireApiKey(flags);
213
355
  const adLine = requireFlag(flags, "line");
214
356
  const destinationUrl = requireFlag(flags, "url");
@@ -237,7 +379,7 @@ async function runBidCreate(flags) {
237
379
  printJson({ ok: true, ...body });
238
380
  }
239
381
  async function runBidsList(flags) {
240
- const baseUrl = resolveBaseUrl(flags);
382
+ const baseUrl = resolveCredentialedBaseUrl(flags);
241
383
  const apiKey = requireApiKey(flags);
242
384
  const { body } = await requestJson(`${baseUrl}/v1/campaigns`, {
243
385
  method: "GET",
@@ -248,9 +390,9 @@ async function runBidsList(flags) {
248
390
  async function runBidCheckout(flags, positionals) {
249
391
  const campaignId = positionals[0]?.trim();
250
392
  if (!campaignId) {
251
- throw new Error("Usage: waitspin bid checkout <campaign-id>");
393
+ throw new Error("Usage: waitspin bid checkout CAMPAIGN_ID");
252
394
  }
253
- const baseUrl = resolveBaseUrl(flags);
395
+ const baseUrl = resolveCredentialedBaseUrl(flags);
254
396
  const apiKey = requireApiKey(flags);
255
397
  const { body } = await requestJson(`${baseUrl}/v1/blocks/checkout`, {
256
398
  method: "POST",
@@ -266,7 +408,7 @@ async function runBidCheckout(flags, positionals) {
266
408
  checkout_disclosure: {
267
409
  terms_url: "https://waitspin.com/waitspin/terms",
268
410
  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.",
411
+ 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
412
  },
271
413
  });
272
414
  }
@@ -276,7 +418,7 @@ async function runMarket(flags) {
276
418
  printJson({ ok: true, ...body });
277
419
  }
278
420
  async function runWalletStatus(flags) {
279
- const baseUrl = resolveBaseUrl(flags);
421
+ const baseUrl = resolveCredentialedBaseUrl(flags);
280
422
  const apiKey = requireApiKey(flags);
281
423
  const { body } = await requestJson(`${baseUrl}/v1/wallet/status`, {
282
424
  method: "GET",
@@ -285,7 +427,7 @@ async function runWalletStatus(flags) {
285
427
  printJson({ ok: true, ...body });
286
428
  }
287
429
  async function runWalletConnect(flags) {
288
- const baseUrl = resolveBaseUrl(flags);
430
+ const baseUrl = resolveCredentialedBaseUrl(flags);
289
431
  const apiKey = requireApiKey(flags);
290
432
  const { body } = await requestJson(`${baseUrl}/v1/wallet/connect`, {
291
433
  method: "POST",
@@ -294,7 +436,7 @@ async function runWalletConnect(flags) {
294
436
  printJson({ ok: true, ...body });
295
437
  }
296
438
  async function runWalletLedger(flags) {
297
- const baseUrl = resolveBaseUrl(flags);
439
+ const baseUrl = resolveCredentialedBaseUrl(flags);
298
440
  const apiKey = requireApiKey(flags);
299
441
  const limit = optionalFlag(flags, "limit");
300
442
  const url = new URL(`${baseUrl}/v1/wallet/ledger`);
@@ -308,7 +450,7 @@ async function runWalletLedger(flags) {
308
450
  printJson({ ok: true, ...body });
309
451
  }
310
452
  async function runWalletPayout(flags) {
311
- const baseUrl = resolveBaseUrl(flags);
453
+ const baseUrl = resolveCredentialedBaseUrl(flags);
312
454
  const apiKey = requireApiKey(flags);
313
455
  const dryRun = booleanFlag(flags, "dry-run");
314
456
  const confirmTestTransfer = booleanFlag(flags, "confirm-test-transfer");
@@ -342,7 +484,16 @@ function extensionInstallDir(_target) {
342
484
  return path.join(os.homedir(), ".vscode", "extensions");
343
485
  }
344
486
  function vscodeExtensionInstallDir(manifest) {
345
- return path.join(extensionInstallDir("vscode"), `waitspin.${manifest.name}-${manifest.version}`);
487
+ const extensionRoot = path.resolve(extensionInstallDir("vscode"));
488
+ const installName = `${manifest.publisher}.${manifest.name}-${manifest.version}`;
489
+ if (!/^[a-z0-9][a-z0-9.-]*$/.test(installName)) {
490
+ throw new Error("Refusing to install extension with unsafe manifest name.");
491
+ }
492
+ const installPath = path.resolve(extensionRoot, installName);
493
+ if (!installPath.startsWith(`${extensionRoot}${path.sep}`)) {
494
+ throw new Error("Refusing to install extension outside the VS Code root.");
495
+ }
496
+ return installPath;
346
497
  }
347
498
  function installStatePath(target) {
348
499
  return path.join(os.homedir(), ".waitspin", `${target}-install.json`);
@@ -389,40 +540,77 @@ function assertManagedVscodeExtensionPath(input) {
389
540
  return resolved;
390
541
  }
391
542
  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
- });
543
+ let body = null;
544
+ try {
545
+ ({ body } = await requestJson(`${input.baseUrl}/v1/publishers/register`, {
546
+ method: "POST",
547
+ headers: {
548
+ "Content-Type": "application/json",
549
+ Authorization: `Bearer ${input.apiKey}`,
550
+ },
551
+ body: JSON.stringify({
552
+ install_id: input.installId,
553
+ target: input.target,
554
+ }),
555
+ }));
556
+ }
557
+ catch (error) {
558
+ if (input.target === CLAUDE_CODE_PUBLISHER_TARGET &&
559
+ error instanceof WaitSpinCliHttpError &&
560
+ error.status === 400 &&
561
+ /status-bar-fallback|Invalid input|Validation error/.test(error.message)) {
562
+ throw new Error([
563
+ 'WaitSpin API rejected target "claude-code".',
564
+ "Your local CLI supports Claude Code, but the selected API base does not.",
565
+ "",
566
+ "For production:",
567
+ " deploy the backend target allowlist, then rerun:",
568
+ " waitspin claude-code install --compose-existing",
569
+ "",
570
+ "For developer-local acceptance only, use an explicit loopback API with --allow-dev-api-base.",
571
+ ].join("\n"));
572
+ }
573
+ throw error;
574
+ }
403
575
  if (!body) {
404
576
  throw new Error("Publisher registration returned empty body");
405
577
  }
406
578
  return body;
407
579
  }
408
- async function resolveExtensionDir() {
580
+ async function resolveExtensionDir(flags) {
409
581
  const packageRoot = resolveCliPackageRoot();
410
582
  const candidates = [
411
- path.join(process.cwd(), "packages/waitspin/assets/waitspin-vscode"),
412
- path.join(process.cwd(), "extensions/waitspin-vscode"),
413
583
  path.join(packageRoot, "assets", "waitspin-vscode"),
414
- path.join(packageRoot, "../../extensions/waitspin-vscode"),
415
584
  ];
585
+ if (allowDevExtensionAssets(flags)) {
586
+ candidates.push(path.join(packageRoot, "../../extensions/waitspin-vscode"));
587
+ }
416
588
  for (const candidate of candidates) {
589
+ const resolved = path.resolve(candidate);
417
590
  try {
418
- await access(path.join(candidate, "package.json"), fsConstants.F_OK);
419
- return candidate;
591
+ await access(path.join(resolved, "package.json"), fsConstants.F_OK);
592
+ return resolved;
420
593
  }
421
594
  catch {
422
595
  continue;
423
596
  }
424
597
  }
425
- throw new Error("WaitSpin extension package not found. Install from the WaitSpin checkout or use a build that ships assets/waitspin-vscode.");
598
+ 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.`);
599
+ }
600
+ function parseVscodeExtensionManifest(value) {
601
+ if (!value || typeof value !== "object") {
602
+ throw new Error("Extension manifest must be a JSON object.");
603
+ }
604
+ const manifest = value;
605
+ const name = typeof manifest.name === "string" ? manifest.name.trim() : "";
606
+ const publisher = typeof manifest.publisher === "string" ? manifest.publisher.trim() : "";
607
+ const version = typeof manifest.version === "string" ? manifest.version.trim() : "";
608
+ if (name !== "waitspin-vscode" ||
609
+ publisher !== "waitspin" ||
610
+ !/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(version)) {
611
+ throw new Error("Unexpected WaitSpin VS Code extension manifest. Refusing to construct install path.");
612
+ }
613
+ return { name, publisher, version };
426
614
  }
427
615
  function resolveCliPackageRoot() {
428
616
  if (!process.argv[1])
@@ -451,17 +639,18 @@ async function installVscodeExtensionRuntime(input) {
451
639
  }
452
640
  export async function runExtensionInstall(flags) {
453
641
  const target = resolveExtensionTarget(flags);
454
- const baseUrl = resolveBaseUrl(flags);
642
+ const baseUrl = resolveCredentialedBaseUrl(flags);
455
643
  const publisherTarget = publisherTargetForExtension(target);
456
- const extensionDir = await resolveExtensionDir();
644
+ const extensionDir = await resolveExtensionDir(flags);
457
645
  const manifestPath = path.join(extensionDir, "package.json");
458
- let manifest;
646
+ let manifestJson;
459
647
  try {
460
- manifest = JSON.parse(await readFile(manifestPath, "utf8"));
648
+ manifestJson = JSON.parse(await readFile(manifestPath, "utf8"));
461
649
  }
462
650
  catch {
463
651
  throw new Error(`Extension package not found at ${extensionDir}.`);
464
652
  }
653
+ const manifest = parseVscodeExtensionManifest(manifestJson);
465
654
  const installDir = extensionInstallDir(target);
466
655
  const statePath = installStatePath(target);
467
656
  const existingState = await loadInstallState(statePath);
@@ -482,10 +671,9 @@ export async function runExtensionInstall(flags) {
482
671
  set_vscode_settings: {
483
672
  "waitspin.installId": installId,
484
673
  },
485
- credential_storage: "The VS Code extension migrates waitspin.apiKey user settings into SecretStorage; WAITSPIN_API_KEY remains env-only.",
674
+ credential_storage: "The VS Code extension migrates a one-time waitspin.apiKey user setting into SecretStorage; runtime polling reads SecretStorage only.",
486
675
  optional_bootstrap_env: {
487
676
  WAITSPIN_INSTALL_ID: installId,
488
- WAITSPIN_API_KEY: "<temporary publisher-extension key for first activation>",
489
677
  },
490
678
  },
491
679
  };
@@ -612,6 +800,859 @@ export async function runExtensionUninstall(flags) {
612
800
  install_marker_error: installedPathStatus.error,
613
801
  });
614
802
  }
803
+ function claudeCodeInstallDir() {
804
+ return path.join(os.homedir(), ".waitspin");
805
+ }
806
+ function claudeCodeStatePath() {
807
+ return path.join(claudeCodeInstallDir(), "claude-code-install.json");
808
+ }
809
+ function claudeCodeRuntimePath() {
810
+ return path.join(claudeCodeInstallDir(), "claude-code-statusline.mjs");
811
+ }
812
+ function claudeCodeCachePath() {
813
+ return path.join(claudeCodeInstallDir(), "claude-code-statusline-cache.json");
814
+ }
815
+ function claudeCodeSettingsPath() {
816
+ return path.join(os.homedir(), ".claude", "settings.json");
817
+ }
818
+ function claudeCodeProjectSettingsCandidates(startDir = process.cwd()) {
819
+ const candidates = [];
820
+ const homeDir = path.resolve(os.homedir());
821
+ let currentDir = path.resolve(startDir);
822
+ while (currentDir !== homeDir) {
823
+ candidates.push({
824
+ scope: "local",
825
+ path: path.join(currentDir, ".claude", "settings.local.json"),
826
+ });
827
+ candidates.push({
828
+ scope: "project",
829
+ path: path.join(currentDir, ".claude", "settings.json"),
830
+ });
831
+ const parentDir = path.dirname(currentDir);
832
+ if (parentDir === currentDir)
833
+ break;
834
+ currentDir = parentDir;
835
+ }
836
+ return candidates;
837
+ }
838
+ function claudeCodeBinary() {
839
+ return process.env[CLAUDE_CODE_BIN_ENV]?.trim() || "claude";
840
+ }
841
+ function execFileText(file, args, options) {
842
+ return new Promise((resolve, reject) => {
843
+ execFile(file, args, options, (error, stdout, stderr) => {
844
+ if (error) {
845
+ reject(error);
846
+ return;
847
+ }
848
+ resolve({
849
+ stdout: String(stdout || ""),
850
+ stderr: String(stderr || ""),
851
+ });
852
+ });
853
+ });
854
+ }
855
+ function shellQuote(value) {
856
+ return `'${value.replace(/'/g, "'\\''")}'`;
857
+ }
858
+ function windowsPath(value) {
859
+ return value.replace(/\\/g, "/");
860
+ }
861
+ function powerShellQuote(value) {
862
+ return `'${windowsPath(value).replace(/'/g, "''")}'`;
863
+ }
864
+ function powerShellCommandArgument(value) {
865
+ return `"${value.replace(/[`"$]/g, "`$&")}"`;
866
+ }
867
+ function claudeCodeStatusLineCommand(input) {
868
+ if (process.platform === "win32") {
869
+ const command = [
870
+ "&",
871
+ powerShellQuote(process.execPath),
872
+ powerShellQuote(input.runtimePath),
873
+ "--state",
874
+ powerShellQuote(input.statePath),
875
+ ].join(" ");
876
+ return [
877
+ "powershell",
878
+ "-NoProfile",
879
+ "-NonInteractive",
880
+ "-ExecutionPolicy",
881
+ "Bypass",
882
+ "-Command",
883
+ powerShellCommandArgument(command),
884
+ ].join(" ");
885
+ }
886
+ return `${shellQuote(process.execPath)} ${shellQuote(input.runtimePath)} --state ${shellQuote(input.statePath)}`;
887
+ }
888
+ function managedClaudeCodeStatusLine(input) {
889
+ return {
890
+ type: "command",
891
+ command: claudeCodeStatusLineCommand(input),
892
+ padding: 2,
893
+ refreshInterval: CLAUDE_CODE_REFRESH_INTERVAL_SECONDS,
894
+ };
895
+ }
896
+ function parseVersionParts(value) {
897
+ const match = value.match(/(\d+)\.(\d+)\.(\d+)/);
898
+ if (!match)
899
+ return null;
900
+ return [Number(match[1]), Number(match[2]), Number(match[3])];
901
+ }
902
+ function isVersionAtLeast(value, minimum) {
903
+ const current = parseVersionParts(value);
904
+ const target = parseVersionParts(minimum);
905
+ if (!current || !target)
906
+ return false;
907
+ for (let index = 0; index < target.length; index += 1) {
908
+ if (current[index] > target[index])
909
+ return true;
910
+ if (current[index] < target[index])
911
+ return false;
912
+ }
913
+ return true;
914
+ }
915
+ async function readClaudeCodeVersion() {
916
+ try {
917
+ const result = await execFileText(claudeCodeBinary(), ["--version"], {
918
+ encoding: "utf8",
919
+ timeout: 5_000,
920
+ });
921
+ return `${result.stdout || result.stderr || ""}`.trim();
922
+ }
923
+ catch (error) {
924
+ 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 });
925
+ }
926
+ }
927
+ async function assertSupportedClaudeCodeVersion() {
928
+ const version = await readClaudeCodeVersion();
929
+ if (!isVersionAtLeast(version, CLAUDE_CODE_MIN_VERSION)) {
930
+ throw new Error(`Unsupported Claude Code version: ${version || "unknown"}. WaitSpin Claude Code statusline support requires Claude Code ${CLAUDE_CODE_MIN_VERSION} or newer.`);
931
+ }
932
+ return version;
933
+ }
934
+ function isErrno(error, code) {
935
+ return (typeof error === "object" &&
936
+ error !== null &&
937
+ "code" in error &&
938
+ error.code === code);
939
+ }
940
+ async function readJsonObjectFile(filePath) {
941
+ try {
942
+ const raw = await readFile(filePath, "utf8");
943
+ if (!raw.trim())
944
+ return {};
945
+ const parsed = JSON.parse(raw);
946
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
947
+ throw new Error(`${filePath} must contain a JSON object.`);
948
+ }
949
+ return parsed;
950
+ }
951
+ catch (error) {
952
+ if (isErrno(error, "ENOENT")) {
953
+ return null;
954
+ }
955
+ throw error;
956
+ }
957
+ }
958
+ async function writeJsonObjectFile(filePath, value, mode) {
959
+ await mkdir(path.dirname(filePath), { recursive: true });
960
+ await writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, {
961
+ encoding: "utf8",
962
+ mode,
963
+ });
964
+ if (mode) {
965
+ await chmod(filePath, mode);
966
+ }
967
+ }
968
+ async function loadClaudeCodeSettings() {
969
+ const parsed = await readJsonObjectFile(claudeCodeSettingsPath());
970
+ return (parsed ?? {});
971
+ }
972
+ async function findClaudeCodeScopedStatusLine() {
973
+ for (const candidate of claudeCodeProjectSettingsCandidates()) {
974
+ const parsed = await readJsonObjectFile(candidate.path);
975
+ if (parsed &&
976
+ Object.prototype.hasOwnProperty.call(parsed, "statusLine")) {
977
+ return {
978
+ scope: candidate.scope,
979
+ path: candidate.path,
980
+ statusLine: parsed.statusLine,
981
+ };
982
+ }
983
+ }
984
+ return null;
985
+ }
986
+ async function loadClaudeCodeInstallState() {
987
+ const parsed = await readJsonObjectFile(claudeCodeStatePath());
988
+ if (!parsed?.install_id || parsed.target !== CLAUDE_CODE_PUBLISHER_TARGET) {
989
+ return null;
990
+ }
991
+ return parsed;
992
+ }
993
+ function isCommandStatusLine(value) {
994
+ return (Boolean(value) &&
995
+ typeof value === "object" &&
996
+ !Array.isArray(value) &&
997
+ value.type === "command" &&
998
+ typeof value.command === "string" &&
999
+ value.command.trim().length > 0);
1000
+ }
1001
+ function statusLineEquals(left, right) {
1002
+ if (isCommandStatusLine(left) && isCommandStatusLine(right)) {
1003
+ return (left.type === right.type &&
1004
+ left.command === right.command &&
1005
+ left.padding === right.padding &&
1006
+ left.refreshInterval === right.refreshInterval);
1007
+ }
1008
+ return JSON.stringify(left ?? null) === JSON.stringify(right ?? null);
1009
+ }
1010
+ function claudeCodeScopedStatusLineBlocker(scopedStatusLine, managedStatusLine) {
1011
+ if (!scopedStatusLine)
1012
+ return null;
1013
+ if (statusLineEquals(scopedStatusLine.statusLine, managedStatusLine)) {
1014
+ return null;
1015
+ }
1016
+ 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.`;
1017
+ }
1018
+ function resolveClaudeCodeSettingsUpdate(input) {
1019
+ const current = input.settings.statusLine;
1020
+ const isAlreadyManaged = statusLineEquals(current, input.managedStatusLine) ||
1021
+ (input.existingState?.managed_status_line &&
1022
+ statusLineEquals(current, input.existingState.managed_status_line));
1023
+ if (!current || isAlreadyManaged) {
1024
+ return {
1025
+ nextSettings: { ...input.settings, statusLine: input.managedStatusLine },
1026
+ previousStatusLine: input.existingState?.previous_status_line,
1027
+ composedExistingStatusLine: Boolean(input.existingState?.composed_existing_status_line),
1028
+ action: isAlreadyManaged ? "refresh-managed" : "install",
1029
+ };
1030
+ }
1031
+ if (!input.composeExisting) {
1032
+ 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.");
1033
+ }
1034
+ if (!isCommandStatusLine(current)) {
1035
+ throw new Error("Claude Code statusLine exists but is not a command status line; refusing to compose because restore would be ambiguous.");
1036
+ }
1037
+ if (current.command === input.managedStatusLine.command) {
1038
+ return {
1039
+ nextSettings: { ...input.settings, statusLine: input.managedStatusLine },
1040
+ composedExistingStatusLine: false,
1041
+ action: "refresh-managed",
1042
+ };
1043
+ }
1044
+ return {
1045
+ nextSettings: { ...input.settings, statusLine: input.managedStatusLine },
1046
+ previousStatusLine: current,
1047
+ composedExistingStatusLine: true,
1048
+ action: "compose-existing",
1049
+ };
1050
+ }
1051
+ function redactedClaudeCodeState(state) {
1052
+ if (!state)
1053
+ return null;
1054
+ return {
1055
+ target: state.target,
1056
+ install_id: state.install_id,
1057
+ publisher_id: state.publisher_id,
1058
+ publisher_target: state.publisher_target,
1059
+ registered_at: state.registered_at,
1060
+ base_url: state.base_url,
1061
+ runtime_path: state.runtime_path,
1062
+ cache_path: state.cache_path,
1063
+ settings_path: state.settings_path,
1064
+ composed_existing_status_line: Boolean(state.composed_existing_status_line),
1065
+ has_previous_status_line: state.previous_status_line !== undefined,
1066
+ claude_version: state.claude_version,
1067
+ installed_at: state.installed_at,
1068
+ api_key_present: Boolean(state.api_key),
1069
+ };
1070
+ }
1071
+ function claudeCodeStatuslineRuntimeSource() {
1072
+ return String.raw `#!/usr/bin/env node
1073
+ import { spawn } from "node:child_process";
1074
+ import {
1075
+ mkdir,
1076
+ readFile,
1077
+ rename,
1078
+ rm,
1079
+ stat,
1080
+ writeFile,
1081
+ } from "node:fs/promises";
1082
+
1083
+ const FETCH_INTERVAL_MS = 15_000;
1084
+ const FETCH_TIMEOUT_MS = 2_500;
1085
+ const PREVIOUS_TIMEOUT_MS = 1_000;
1086
+ const MAX_ACTIVE_AGE_MS = 60_000;
1087
+ const LOCK_RETRY_MS = 40;
1088
+ const LOCK_TIMEOUT_MS = 2_000;
1089
+ const LOCK_STALE_MS = 10_000;
1090
+
1091
+ function argValue(name) {
1092
+ const index = process.argv.indexOf(name);
1093
+ return index >= 0 ? process.argv[index + 1] : undefined;
1094
+ }
1095
+
1096
+ async function readStdin() {
1097
+ const chunks = [];
1098
+ for await (const chunk of process.stdin) chunks.push(Buffer.from(chunk));
1099
+ return Buffer.concat(chunks).toString("utf8");
1100
+ }
1101
+
1102
+ async function readJson(filePath, fallback) {
1103
+ try {
1104
+ return JSON.parse(await readFile(filePath, "utf8"));
1105
+ } catch {
1106
+ return fallback;
1107
+ }
1108
+ }
1109
+
1110
+ async function writeJson(filePath, value) {
1111
+ const tmp = filePath + "." + process.pid + ".tmp";
1112
+ await writeFile(tmp, JSON.stringify(value, null, 2) + "\n", {
1113
+ encoding: "utf8",
1114
+ mode: 0o600,
1115
+ });
1116
+ await rename(tmp, filePath);
1117
+ }
1118
+
1119
+ function sleep(ms) {
1120
+ return new Promise((resolve) => setTimeout(resolve, ms));
1121
+ }
1122
+
1123
+ async function acquireCacheLock(cachePath) {
1124
+ const lockPath = cachePath + ".lock";
1125
+ const startedAt = Date.now();
1126
+
1127
+ while (Date.now() - startedAt < LOCK_TIMEOUT_MS) {
1128
+ try {
1129
+ await mkdir(lockPath);
1130
+ return async () => {
1131
+ await rm(lockPath, { recursive: true, force: true });
1132
+ };
1133
+ } catch {
1134
+ try {
1135
+ const lockStat = await stat(lockPath);
1136
+ if (Date.now() - lockStat.mtimeMs > LOCK_STALE_MS) {
1137
+ await rm(lockPath, { recursive: true, force: true });
1138
+ continue;
1139
+ }
1140
+ } catch {
1141
+ // Another process may have released the lock between mkdir/stat.
1142
+ }
1143
+ await sleep(LOCK_RETRY_MS);
1144
+ }
1145
+ }
1146
+
1147
+ throw new Error("Timed out waiting for WaitSpin statusline cache lock.");
1148
+ }
1149
+
1150
+ async function withCacheLock(cachePath, callback) {
1151
+ const release = await acquireCacheLock(cachePath);
1152
+ try {
1153
+ return await callback();
1154
+ } finally {
1155
+ await release();
1156
+ }
1157
+ }
1158
+
1159
+ function cleanLine(value) {
1160
+ return String(value || "")
1161
+ .replace(/[\r\n\u0000-\u001F\u007F]/g, " ")
1162
+ .replace(/\s+/g, " ")
1163
+ .trim()
1164
+ .slice(0, 120);
1165
+ }
1166
+
1167
+ async function runPreviousStatusLine(command, input) {
1168
+ if (!command) return "";
1169
+ return await new Promise((resolve) => {
1170
+ const child = spawn(command, {
1171
+ shell: true,
1172
+ stdio: ["pipe", "pipe", "ignore"],
1173
+ env: process.env,
1174
+ });
1175
+ let stdout = "";
1176
+ let settled = false;
1177
+ let killTimer = null;
1178
+ function finish(value) {
1179
+ if (settled) return;
1180
+ settled = true;
1181
+ clearTimeout(timer);
1182
+ if (killTimer) clearTimeout(killTimer);
1183
+ resolve(value);
1184
+ }
1185
+ const timer = setTimeout(() => {
1186
+ child.stdout.destroy();
1187
+ child.stdin.destroy();
1188
+ child.kill("SIGTERM");
1189
+ killTimer = setTimeout(() => {
1190
+ child.kill("SIGKILL");
1191
+ }, 100);
1192
+ killTimer.unref?.();
1193
+ child.unref?.();
1194
+ finish("");
1195
+ }, PREVIOUS_TIMEOUT_MS);
1196
+ timer.unref?.();
1197
+ child.stdout.on("data", (chunk) => {
1198
+ stdout += chunk.toString("utf8");
1199
+ if (stdout.length > 4000) stdout = stdout.slice(0, 4000);
1200
+ });
1201
+ child.on("error", () => {
1202
+ finish("");
1203
+ });
1204
+ child.on("close", () => {
1205
+ finish(stdout.trimEnd());
1206
+ });
1207
+ child.stdin.end(input);
1208
+ });
1209
+ }
1210
+
1211
+ async function waitspinFetch(url, init) {
1212
+ const controller = new AbortController();
1213
+ const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
1214
+ try {
1215
+ return await fetch(url, { ...init, signal: controller.signal });
1216
+ } finally {
1217
+ clearTimeout(timeout);
1218
+ }
1219
+ }
1220
+
1221
+ function parseServe(payload) {
1222
+ if (!payload || typeof payload !== "object") return null;
1223
+ const creative = payload.creative;
1224
+ if (!creative || typeof creative !== "object") return null;
1225
+ const line = cleanLine(creative.line);
1226
+ if (!line) return null;
1227
+ if (
1228
+ typeof payload.serve_id !== "string" ||
1229
+ typeof payload.serve_receipt !== "string"
1230
+ ) {
1231
+ return null;
1232
+ }
1233
+ const parsedExpiresAt = Date.parse(payload.expires_at || "");
1234
+ return {
1235
+ serveId: payload.serve_id,
1236
+ serveReceipt: payload.serve_receipt,
1237
+ line,
1238
+ shownAt: Date.now(),
1239
+ expiresAtMs: Number.isFinite(parsedExpiresAt)
1240
+ ? parsedExpiresAt
1241
+ : Date.now() + MAX_ACTIVE_AGE_MS,
1242
+ minVisibleMs:
1243
+ typeof payload.min_visible_ms === "number" && payload.min_visible_ms >= 5000
1244
+ ? payload.min_visible_ms
1245
+ : 5000,
1246
+ impressionRecorded: false,
1247
+ };
1248
+ }
1249
+
1250
+ function serveIsExpired(serve) {
1251
+ return (
1252
+ Date.now() >= (serve.expiresAtMs || 0) ||
1253
+ Date.now() - serve.shownAt > MAX_ACTIVE_AGE_MS
1254
+ );
1255
+ }
1256
+
1257
+ async function fetchNextServe(state, session) {
1258
+ const response = await waitspinFetch(state.base_url + "/v1/serve/next", {
1259
+ method: "POST",
1260
+ headers: {
1261
+ Authorization: "Bearer " + state.api_key,
1262
+ "Content-Type": "application/json",
1263
+ },
1264
+ body: JSON.stringify({ install_id: state.install_id }),
1265
+ });
1266
+ session.lastFetchAt = Date.now();
1267
+ if (response.status === 204) {
1268
+ session.activeServe = null;
1269
+ return;
1270
+ }
1271
+ if (!response.ok) return;
1272
+ const parsed = parseServe(await response.json());
1273
+ if (parsed) session.activeServe = parsed;
1274
+ }
1275
+
1276
+ async function recordImpression(state, session) {
1277
+ const serve = session.activeServe;
1278
+ if (!serve || serve.impressionRecorded) return;
1279
+ const visibleMs = Date.now() - serve.shownAt;
1280
+ if (visibleMs < serve.minVisibleMs) return;
1281
+ const response = await waitspinFetch(state.base_url + "/v1/events/impression", {
1282
+ method: "POST",
1283
+ headers: {
1284
+ Authorization: "Bearer " + state.api_key,
1285
+ "Content-Type": "application/json",
1286
+ },
1287
+ body: JSON.stringify({
1288
+ serve_id: serve.serveId,
1289
+ serve_receipt: serve.serveReceipt,
1290
+ install_id: state.install_id,
1291
+ visible_ms: Math.max(visibleMs, serve.minVisibleMs),
1292
+ }),
1293
+ });
1294
+ if (response.ok) serve.impressionRecorded = true;
1295
+ }
1296
+
1297
+ function sessionKey(inputJson) {
1298
+ return (
1299
+ inputJson.session_id ||
1300
+ inputJson.transcript_path ||
1301
+ inputJson.cwd ||
1302
+ "claude-code"
1303
+ );
1304
+ }
1305
+
1306
+ function pruneSessions(cache) {
1307
+ const cutoff = Date.now() - 24 * 60 * 60 * 1000;
1308
+ for (const [key, value] of Object.entries(cache.sessions || {})) {
1309
+ if ((value.lastSeenAt || 0) < cutoff) delete cache.sessions[key];
1310
+ }
1311
+ }
1312
+
1313
+ async function main() {
1314
+ const statePath = argValue("--state");
1315
+ if (!statePath) return;
1316
+ const stdin = await readStdin();
1317
+ let inputJson = {};
1318
+ try {
1319
+ inputJson = stdin.trim() ? JSON.parse(stdin) : {};
1320
+ } catch {
1321
+ inputJson = {};
1322
+ }
1323
+ const state = await readJson(statePath, null);
1324
+ if (!state?.api_key || !state.install_id || !state.base_url || !state.cache_path) {
1325
+ return;
1326
+ }
1327
+
1328
+ const previous = await runPreviousStatusLine(
1329
+ state.previous_status_line?.type === "command"
1330
+ ? state.previous_status_line.command
1331
+ : "",
1332
+ stdin,
1333
+ );
1334
+ let renderedSession = null;
1335
+
1336
+ try {
1337
+ renderedSession = await withCacheLock(state.cache_path, async () => {
1338
+ const cache = await readJson(state.cache_path, { sessions: {} });
1339
+ if (!cache.sessions || typeof cache.sessions !== "object") cache.sessions = {};
1340
+ const key = sessionKey(inputJson);
1341
+ const session = cache.sessions[key] || {};
1342
+ session.lastSeenAt = Date.now();
1343
+ cache.sessions[key] = session;
1344
+
1345
+ if (session.activeServe && serveIsExpired(session.activeServe)) {
1346
+ session.activeServe = null;
1347
+ }
1348
+ await recordImpression(state, session);
1349
+ const shouldFetchNext = !session.activeServe
1350
+ ? Date.now() - (session.lastFetchAt || 0) >= FETCH_INTERVAL_MS
1351
+ : session.activeServe.impressionRecorded &&
1352
+ Date.now() - (session.lastFetchAt || 0) >= FETCH_INTERVAL_MS;
1353
+ if (shouldFetchNext) {
1354
+ await fetchNextServe(state, session);
1355
+ }
1356
+ pruneSessions(cache);
1357
+ await writeJson(state.cache_path, cache);
1358
+ return session;
1359
+ });
1360
+ } catch {
1361
+ // Statusline rendering must never interrupt Claude Code.
1362
+ }
1363
+
1364
+ const sponsor = renderedSession?.activeServe?.line
1365
+ ? "Sponsored: " + renderedSession.activeServe.line
1366
+ : "";
1367
+ const lines = [previous, sponsor].filter(Boolean);
1368
+ if (lines.length > 0) process.stdout.write(lines.join("\n"));
1369
+ }
1370
+
1371
+ main().catch(() => {});
1372
+ `;
1373
+ }
1374
+ async function writeClaudeCodeRuntime(runtimePath) {
1375
+ await mkdir(path.dirname(runtimePath), { recursive: true });
1376
+ await writeFile(runtimePath, claudeCodeStatuslineRuntimeSource(), {
1377
+ encoding: "utf8",
1378
+ mode: 0o755,
1379
+ });
1380
+ await chmod(runtimePath, 0o755);
1381
+ }
1382
+ function assertSafeClaudeCodeManagedPath(filePath) {
1383
+ const installRoot = path.resolve(claudeCodeInstallDir());
1384
+ const resolved = path.resolve(filePath);
1385
+ if (!resolved.startsWith(`${installRoot}${path.sep}`) ||
1386
+ !path.basename(resolved).startsWith("claude-code-")) {
1387
+ throw new Error("Refusing to manage a Claude Code WaitSpin file outside ~/.waitspin.");
1388
+ }
1389
+ return resolved;
1390
+ }
1391
+ export async function runClaudeCodeInstall(flags) {
1392
+ const dryRun = booleanFlag(flags, "dry-run");
1393
+ const baseUrl = resolveCredentialedBaseUrl(flags);
1394
+ const statePath = claudeCodeStatePath();
1395
+ const runtimePath = claudeCodeRuntimePath();
1396
+ const cachePath = claudeCodeCachePath();
1397
+ const settingsPath = claudeCodeSettingsPath();
1398
+ const existingState = await loadClaudeCodeInstallState();
1399
+ const installId = existingState?.install_id || generateInstallId();
1400
+ const managedStatusLine = managedClaudeCodeStatusLine({
1401
+ runtimePath,
1402
+ statePath,
1403
+ });
1404
+ const settings = await loadClaudeCodeSettings();
1405
+ const scopedStatusLine = await findClaudeCodeScopedStatusLine();
1406
+ const scopedStatusLineBlockedReason = claudeCodeScopedStatusLineBlocker(scopedStatusLine, managedStatusLine);
1407
+ let settingsUpdate = null;
1408
+ let settingsBlockedReason = null;
1409
+ try {
1410
+ settingsUpdate = resolveClaudeCodeSettingsUpdate({
1411
+ settings,
1412
+ managedStatusLine,
1413
+ existingState,
1414
+ composeExisting: booleanFlag(flags, "compose-existing"),
1415
+ });
1416
+ }
1417
+ catch (error) {
1418
+ if (!dryRun)
1419
+ throw error;
1420
+ settingsBlockedReason =
1421
+ error instanceof Error ? error.message : String(error);
1422
+ }
1423
+ if (scopedStatusLineBlockedReason && !dryRun) {
1424
+ throw new Error(scopedStatusLineBlockedReason);
1425
+ }
1426
+ const summary = {
1427
+ ok: true,
1428
+ target: CLAUDE_CODE_PUBLISHER_TARGET,
1429
+ mode: "statusline-command",
1430
+ install_id: installId,
1431
+ publisher_target: CLAUDE_CODE_PUBLISHER_TARGET,
1432
+ state_path: statePath,
1433
+ runtime_path: runtimePath,
1434
+ cache_path: cachePath,
1435
+ settings_path: settingsPath,
1436
+ settings_action: settingsUpdate?.action ?? "blocked",
1437
+ composed_existing_status_line: settingsUpdate?.composedExistingStatusLine ?? false,
1438
+ note: "Installs WaitSpin through Claude Code's official statusLine.command surface.",
1439
+ next: "check_status",
1440
+ next_command: "waitspin claude-code status",
1441
+ };
1442
+ if (dryRun) {
1443
+ const blockedReason = settingsBlockedReason ?? scopedStatusLineBlockedReason;
1444
+ printJson({
1445
+ ...summary,
1446
+ dry_run: true,
1447
+ publisher_registered: false,
1448
+ has_existing_status_line: Boolean(settings.statusLine),
1449
+ ...(scopedStatusLine
1450
+ ? {
1451
+ scoped_status_line: {
1452
+ scope: scopedStatusLine.scope,
1453
+ path: scopedStatusLine.path,
1454
+ overrides_user_settings: Boolean(scopedStatusLineBlockedReason),
1455
+ },
1456
+ }
1457
+ : {}),
1458
+ ...(blockedReason
1459
+ ? {
1460
+ would_fail: true,
1461
+ settings_blocked_reason: blockedReason,
1462
+ next: settingsBlockedReason
1463
+ ? "resolve_status_line_conflict"
1464
+ : "resolve_project_status_line_override",
1465
+ ...(settingsBlockedReason
1466
+ ? {
1467
+ next_command: "waitspin claude-code install --compose-existing",
1468
+ }
1469
+ : {
1470
+ human_message: "Current project/local Claude Code settings override user-level statusLine. Remove or compose that statusLine before installing WaitSpin for this project.",
1471
+ }),
1472
+ }
1473
+ : {}),
1474
+ });
1475
+ return;
1476
+ }
1477
+ if (!settingsUpdate) {
1478
+ throw new Error("Unable to resolve Claude Code settings update.");
1479
+ }
1480
+ const claudeVersion = await assertSupportedClaudeCodeVersion();
1481
+ const apiKey = requireApiKey(flags);
1482
+ const registration = await registerPublisherInstall({
1483
+ baseUrl,
1484
+ apiKey,
1485
+ installId,
1486
+ target: CLAUDE_CODE_PUBLISHER_TARGET,
1487
+ });
1488
+ const installedAt = new Date().toISOString();
1489
+ const installState = {
1490
+ target: CLAUDE_CODE_PUBLISHER_TARGET,
1491
+ install_id: registration.install_id,
1492
+ publisher_id: registration.publisher_id,
1493
+ publisher_target: registration.target,
1494
+ registered_at: installedAt,
1495
+ base_url: baseUrl,
1496
+ api_key: apiKey,
1497
+ runtime_path: runtimePath,
1498
+ cache_path: cachePath,
1499
+ settings_path: settingsPath,
1500
+ managed_status_line: managedStatusLine,
1501
+ previous_status_line: settingsUpdate.previousStatusLine,
1502
+ composed_existing_status_line: settingsUpdate.composedExistingStatusLine,
1503
+ claude_version: claudeVersion,
1504
+ installed_at: installedAt,
1505
+ };
1506
+ try {
1507
+ await writeClaudeCodeRuntime(runtimePath);
1508
+ await writeJsonObjectFile(statePath, installState, 0o600);
1509
+ await writeJsonObjectFile(settingsPath, settingsUpdate.nextSettings);
1510
+ }
1511
+ catch (error) {
1512
+ if (existingState) {
1513
+ await writeJsonObjectFile(statePath, existingState, 0o600).catch(() => { });
1514
+ }
1515
+ else {
1516
+ await Promise.resolve(rm(statePath, { force: true, recursive: true })).catch(() => { });
1517
+ await Promise.resolve(rm(runtimePath, { force: true, recursive: true })).catch(() => { });
1518
+ }
1519
+ throw error;
1520
+ }
1521
+ printJson({
1522
+ ...summary,
1523
+ ...redactedClaudeCodeState(installState),
1524
+ publisher_registered: true,
1525
+ claude_version: claudeVersion,
1526
+ next: "launch_claude_code",
1527
+ next_command: "claude",
1528
+ acceptance_hint: "Keep the sponsored line visible for at least 5 seconds, then verify an impression.",
1529
+ });
1530
+ }
1531
+ export async function runClaudeCodeStatus() {
1532
+ const state = await loadClaudeCodeInstallState();
1533
+ const settings = await loadClaudeCodeSettings();
1534
+ const managedStatusLine = state?.managed_status_line ?? null;
1535
+ const currentStatusLine = settings.statusLine;
1536
+ const statusLineConfigured = Boolean(managedStatusLine && statusLineEquals(currentStatusLine, managedStatusLine));
1537
+ const scopedStatusLine = await findClaudeCodeScopedStatusLine();
1538
+ const scopedStatusLineOverridden = Boolean(managedStatusLine &&
1539
+ scopedStatusLine &&
1540
+ !statusLineEquals(scopedStatusLine.statusLine, managedStatusLine));
1541
+ const effectiveStatusLineConfigured = statusLineConfigured && !scopedStatusLineOverridden;
1542
+ const runtimeInstalled = state
1543
+ ? await pathExists(assertSafeClaudeCodeManagedPath(state.runtime_path))
1544
+ : false;
1545
+ const installed = Boolean(state && runtimeInstalled && effectiveStatusLineConfigured);
1546
+ printJson({
1547
+ ok: true,
1548
+ target: CLAUDE_CODE_PUBLISHER_TARGET,
1549
+ mode: "statusline-command",
1550
+ installed,
1551
+ publisher_registered: Boolean(state?.publisher_id),
1552
+ install_id: state?.install_id ?? null,
1553
+ publisher_id: state?.publisher_id ?? null,
1554
+ publisher_target: state?.publisher_target ?? CLAUDE_CODE_PUBLISHER_TARGET,
1555
+ state_path: claudeCodeStatePath(),
1556
+ runtime_path: state?.runtime_path ?? claudeCodeRuntimePath(),
1557
+ cache_path: state?.cache_path ?? claudeCodeCachePath(),
1558
+ settings_path: claudeCodeSettingsPath(),
1559
+ runtime_installed: runtimeInstalled,
1560
+ status_line_configured: statusLineConfigured,
1561
+ effective_status_line_configured: effectiveStatusLineConfigured,
1562
+ status_line_overridden: scopedStatusLineOverridden,
1563
+ ...(scopedStatusLineOverridden && scopedStatusLine
1564
+ ? {
1565
+ status_line_override_scope: scopedStatusLine.scope,
1566
+ status_line_override_path: scopedStatusLine.path,
1567
+ }
1568
+ : {}),
1569
+ composed_existing_status_line: Boolean(state?.composed_existing_status_line),
1570
+ has_previous_status_line: Boolean(state?.previous_status_line),
1571
+ claude_version: state?.claude_version ?? null,
1572
+ ...(installed
1573
+ ? {
1574
+ next: "launch_claude_code",
1575
+ next_command: "claude",
1576
+ acceptance_hint: "Keep the sponsored line visible for at least 5 seconds, then verify an impression.",
1577
+ }
1578
+ : {
1579
+ next: "install_claude_code",
1580
+ next_command: "waitspin claude-code install --compose-existing",
1581
+ human_message: scopedStatusLineOverridden
1582
+ ? "Claude Code WaitSpin support is installed in user settings, but a higher-priority project/local statusLine overrides it in this directory."
1583
+ : "Claude Code WaitSpin statusline support is not installed for this user.",
1584
+ }),
1585
+ });
1586
+ }
1587
+ export async function runClaudeCodeUninstall(flags) {
1588
+ const dryRun = booleanFlag(flags, "dry-run");
1589
+ const state = await loadClaudeCodeInstallState();
1590
+ const settings = await loadClaudeCodeSettings();
1591
+ const declaredRemovePaths = state
1592
+ ? [
1593
+ state.runtime_path,
1594
+ state.cache_path,
1595
+ claudeCodeStatePath(),
1596
+ ]
1597
+ : [claudeCodeStatePath()];
1598
+ let nextSettings = null;
1599
+ let settingsAction = "not-managed";
1600
+ let settingsWarning = null;
1601
+ if (state?.managed_status_line) {
1602
+ if (!statusLineEquals(settings.statusLine, state.managed_status_line)) {
1603
+ settingsAction = "skip-user-settings";
1604
+ settingsWarning =
1605
+ "Claude Code statusLine is no longer the WaitSpin managed command; leaving user settings unchanged while removing WaitSpin-managed files.";
1606
+ }
1607
+ else {
1608
+ nextSettings = { ...settings };
1609
+ if (state.previous_status_line !== undefined) {
1610
+ nextSettings.statusLine = state.previous_status_line;
1611
+ settingsAction = "restore-previous";
1612
+ }
1613
+ else {
1614
+ delete nextSettings.statusLine;
1615
+ settingsAction = "remove-managed";
1616
+ }
1617
+ }
1618
+ }
1619
+ if (dryRun) {
1620
+ printJson({
1621
+ ok: true,
1622
+ target: CLAUDE_CODE_PUBLISHER_TARGET,
1623
+ dry_run: true,
1624
+ installed: Boolean(state),
1625
+ settings_action: settingsAction,
1626
+ would_remove: declaredRemovePaths,
1627
+ path_validation: state ? "deferred_until_apply" : "not_needed",
1628
+ ...(settingsWarning
1629
+ ? {
1630
+ settings_warning: settingsWarning,
1631
+ }
1632
+ : {}),
1633
+ });
1634
+ return;
1635
+ }
1636
+ const removePaths = state
1637
+ ? [
1638
+ assertSafeClaudeCodeManagedPath(state.runtime_path),
1639
+ assertSafeClaudeCodeManagedPath(state.cache_path),
1640
+ claudeCodeStatePath(),
1641
+ ]
1642
+ : [claudeCodeStatePath()];
1643
+ if (nextSettings) {
1644
+ await writeJsonObjectFile(claudeCodeSettingsPath(), nextSettings);
1645
+ }
1646
+ await Promise.all(removePaths.map((filePath) => rm(filePath, { force: true, recursive: true })));
1647
+ printJson({
1648
+ ok: true,
1649
+ target: CLAUDE_CODE_PUBLISHER_TARGET,
1650
+ uninstalled: true,
1651
+ settings_action: settingsAction,
1652
+ removed: removePaths,
1653
+ ...(settingsWarning ? { settings_warning: settingsWarning } : {}),
1654
+ });
1655
+ }
615
1656
  export async function main(argv = process.argv.slice(2)) {
616
1657
  const { command, flags, positionals } = parseArgs(argv);
617
1658
  if (command === "init") {
@@ -662,6 +1703,18 @@ export async function main(argv = process.argv.slice(2)) {
662
1703
  await runExtensionUninstall(flags);
663
1704
  return;
664
1705
  }
1706
+ if (command === "claude-code" && positionals[0] === "install") {
1707
+ await runClaudeCodeInstall(flags);
1708
+ return;
1709
+ }
1710
+ if (command === "claude-code" && positionals[0] === "status") {
1711
+ await runClaudeCodeStatus();
1712
+ return;
1713
+ }
1714
+ if (command === "claude-code" && positionals[0] === "uninstall") {
1715
+ await runClaudeCodeUninstall(flags);
1716
+ return;
1717
+ }
665
1718
  usage();
666
1719
  }
667
1720
  function isDirectEntrypoint() {