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/README.md +38 -18
- package/assets/waitspin-vscode/out/extension.js +41 -17
- package/assets/waitspin-vscode/out/extension.js.map +1 -1
- package/assets/waitspin-vscode/package.json +1 -1
- package/dist/cli.js +1096 -43
- package/dist/cli.js.map +1 -1
- package/package.json +3 -2
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
|
|
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
|
|
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 === "
|
|
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(
|
|
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({
|
|
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: "
|
|
323
|
+
next: "enter_email_code",
|
|
193
324
|
delivery: requestResult?.delivery,
|
|
194
325
|
email,
|
|
195
|
-
|
|
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({
|
|
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 =
|
|
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 =
|
|
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
|
|
393
|
+
throw new Error("Usage: waitspin bid checkout CAMPAIGN_ID");
|
|
252
394
|
}
|
|
253
|
-
const baseUrl =
|
|
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
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
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(
|
|
419
|
-
return
|
|
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(
|
|
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 =
|
|
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
|
|
646
|
+
let manifestJson;
|
|
459
647
|
try {
|
|
460
|
-
|
|
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
|
|
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() {
|