@mushi-mushi/cli 0.19.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -18,7 +18,7 @@ import {
18
18
  sanitizeProjectId,
19
19
  saveConfig,
20
20
  writeMcpServerEntry
21
- } from "./chunk-D5IDNBJO.js";
21
+ } from "./chunk-R2HPR2FV.js";
22
22
  import {
23
23
  CLOUD_API_ENDPOINT,
24
24
  TEST_REPORT_FETCH_TIMEOUT_MS,
@@ -110,13 +110,13 @@ function getAbortSignal(external) {
110
110
  }
111
111
 
112
112
  // src/version.ts
113
- var MUSHI_CLI_VERSION = true ? "0.19.0" : "0.0.0-dev";
113
+ var MUSHI_CLI_VERSION = true ? "0.21.0" : "0.0.0-dev";
114
114
 
115
115
  // src/init.ts
116
116
  import * as p from "@clack/prompts";
117
117
  import { spawn } from "child_process";
118
118
  import { randomUUID } from "crypto";
119
- import { appendFileSync, existsSync as existsSync3, readFileSync as readFileSync3 } from "fs";
119
+ import { appendFileSync, existsSync as existsSync3, readFileSync as readFileSync3, writeFileSync } from "fs";
120
120
  import { join as join3 } from "path";
121
121
 
122
122
  // src/console-url.ts
@@ -126,7 +126,10 @@ var HOSTED_CONSOLE_BASE = "https://kensaur.us/mushi-mushi/admin";
126
126
  var LOCAL_ADMIN_URL = "http://localhost:6464";
127
127
  var LOCAL_PROBE_MS = 800;
128
128
  function normalizeConsoleBase(url) {
129
- return url.trim().replace(/\/+$/, "");
129
+ const trimmed = url.trim();
130
+ let end = trimmed.length;
131
+ while (end > 0 && trimmed.charCodeAt(end - 1) === 47) end--;
132
+ return trimmed.slice(0, end);
130
133
  }
131
134
  function consoleUrl(base, route) {
132
135
  const normalizedRoute = route.startsWith("/") ? route : `/${route}`;
@@ -201,10 +204,34 @@ function reportsUrl(base, reportId) {
201
204
  return reportId ? consoleUrl(base, `/reports/${reportId}`) : consoleUrl(base, "/reports");
202
205
  }
203
206
  async function openInBrowser(url) {
204
- const { exec } = await import("child_process");
205
- const openCmd = process.platform === "win32" ? `start "" "${url}"` : process.platform === "darwin" ? `open "${url}"` : `xdg-open "${url}"`;
207
+ let parsed;
208
+ try {
209
+ parsed = new URL(url);
210
+ } catch {
211
+ return;
212
+ }
213
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
214
+ return;
215
+ }
216
+ const safeUrl = parsed.toString();
217
+ const { spawn: spawn2 } = await import("child_process");
218
+ const [command, args] = process.platform === "win32" ? (
219
+ // Open via rundll32's FileProtocolHandler rather than `cmd /c start`.
220
+ // `start` is a cmd builtin, so the URL would be re-parsed by cmd.exe and
221
+ // characters like `&` / `|` / `^` could still break out — exactly the
222
+ // injection surface CodeQL flags. rundll32 never invokes a shell: the URL
223
+ // is handed to the default browser as one opaque argument.
224
+ ["rundll32", ["url.dll,FileProtocolHandler", safeUrl]]
225
+ ) : process.platform === "darwin" ? ["open", [safeUrl]] : ["xdg-open", [safeUrl]];
206
226
  await new Promise((resolve3) => {
207
- exec(openCmd, () => resolve3());
227
+ try {
228
+ const child = spawn2(command, [...args], { stdio: "ignore", shell: false });
229
+ child.on("error", () => resolve3());
230
+ child.on("exit", () => resolve3());
231
+ child.unref();
232
+ } catch {
233
+ resolve3();
234
+ }
208
235
  });
209
236
  }
210
237
 
@@ -265,13 +292,13 @@ async function apiCall(path, config, options = {}) {
265
292
  }
266
293
  if (!res.ok && typeof body === "object" && body !== null && !("ok" in body)) {
267
294
  const b = body;
295
+ const nested = b["error"];
296
+ const errCode = nested?.["code"] ?? b["code"] ?? `HTTP_${res.status}`;
297
+ const errMsg = nested?.["message"] ?? b["message"] ?? `Request failed (${res.status})`;
268
298
  return {
269
299
  ok: false,
270
300
  httpStatus: res.status,
271
- error: {
272
- code: b["code"] ?? `HTTP_${res.status}`,
273
- message: b["message"] ?? `Request failed (${res.status})`
274
- }
301
+ error: { code: errCode, message: errMsg }
275
302
  };
276
303
  }
277
304
  return body;
@@ -300,6 +327,11 @@ function die(result, exitCode = 1) {
300
327
  const status = result.httpStatus ? ` [${result.httpStatus}]` : "";
301
328
  process.stderr.write(`error${status}: ${code} \u2014 ${message}
302
329
  `);
330
+ if (code === "INSUFFICIENT_SCOPE") {
331
+ process.stderr.write(
332
+ "\n Your current API key does not have the required scope for this command.\n New keys minted by the wizard already include both scopes. Existing keys\n can be upgraded with:\n\n mushi login --upgrade-scope\n\n Or visit your console \u2192 Projects \u2192 API Keys to mint a new key.\n"
333
+ );
334
+ }
303
335
  process.exit(exitCode);
304
336
  }
305
337
  function requireConfig(opts = {}) {
@@ -329,6 +361,151 @@ function pad(s, width) {
329
361
  return s.length >= width ? s : s + " ".repeat(width - s.length);
330
362
  }
331
363
 
364
+ // src/device-auth.ts
365
+ var DEVICE_FETCH_TIMEOUT_MS = 15e3;
366
+ function trimTrailingSlash(endpoint) {
367
+ let end = endpoint.length;
368
+ while (end > 0 && endpoint.charCodeAt(end - 1) === 47) end--;
369
+ return endpoint.slice(0, end);
370
+ }
371
+ async function deviceFetch(url, init = {}) {
372
+ const controller = new AbortController();
373
+ const timer = setTimeout(() => controller.abort(), DEVICE_FETCH_TIMEOUT_MS);
374
+ try {
375
+ return await fetch(url, { ...init, signal: controller.signal });
376
+ } finally {
377
+ clearTimeout(timer);
378
+ }
379
+ }
380
+ async function startDeviceAuth(endpoint) {
381
+ const base = trimTrailingSlash(endpoint);
382
+ const res = await deviceFetch(`${base}/v1/cli/auth/device/start`, {
383
+ method: "POST",
384
+ headers: { "Content-Type": "application/json" }
385
+ });
386
+ const json = await res.json().catch(() => null);
387
+ if (!res.ok || !json?.ok || !json.data) {
388
+ throw new Error(json?.error?.message ?? `Could not start browser sign-in (HTTP ${res.status}).`);
389
+ }
390
+ return json.data;
391
+ }
392
+ async function pollDeviceToken(endpoint, deviceCode) {
393
+ const base = trimTrailingSlash(endpoint);
394
+ try {
395
+ const res = await deviceFetch(`${base}/v1/cli/auth/device/token`, {
396
+ method: "POST",
397
+ headers: { "Content-Type": "application/json" },
398
+ body: JSON.stringify({ device_code: deviceCode })
399
+ });
400
+ const json = await res.json().catch(() => ({}));
401
+ if (res.ok && json.ok && json.data?.cli_token) {
402
+ return { status: "approved", cliToken: json.data.cli_token, userId: json.data.user_id };
403
+ }
404
+ switch (json.error) {
405
+ case "authorization_pending":
406
+ return { status: "pending" };
407
+ case "access_denied":
408
+ return { status: "denied" };
409
+ case "expired_token":
410
+ return { status: "expired" };
411
+ default:
412
+ return {
413
+ status: "error",
414
+ message: json.error_description ?? json.error ?? `HTTP ${res.status}`,
415
+ // 5xx is a server hiccup the next poll may clear; a 4xx is a definitive
416
+ // rejection (already claimed / invalid_grant) that won't fix itself.
417
+ retryable: res.status >= 500
418
+ };
419
+ }
420
+ } catch (err) {
421
+ return { status: "error", message: err instanceof Error ? err.message : String(err), retryable: true };
422
+ }
423
+ }
424
+ var defaultSleep = (ms) => new Promise((r) => setTimeout(r, ms));
425
+ async function waitForCliToken(endpoint, session, opts = {}) {
426
+ const sleep = opts.sleep ?? defaultSleep;
427
+ const now = opts.now ?? Date.now;
428
+ const intervalMs = (session.interval || 5) * 1e3;
429
+ const deadline = now() + (session.expires_in || 600) * 1e3;
430
+ const maxConsecutiveErrors = opts.maxConsecutiveErrors ?? 5;
431
+ let consecutiveErrors = 0;
432
+ let firstPoll = true;
433
+ while (now() < deadline) {
434
+ if (!firstPoll) {
435
+ await sleep(intervalMs);
436
+ }
437
+ firstPoll = false;
438
+ const outcome = await pollDeviceToken(endpoint, session.device_code);
439
+ switch (outcome.status) {
440
+ case "approved":
441
+ return outcome.cliToken;
442
+ case "pending":
443
+ consecutiveErrors = 0;
444
+ opts.onPending?.();
445
+ continue;
446
+ case "denied":
447
+ throw new Error("Login was denied in the browser.");
448
+ case "expired":
449
+ throw new Error("The login code expired. Run sign-in again.");
450
+ case "error":
451
+ if (!outcome.retryable) {
452
+ throw new Error(outcome.message);
453
+ }
454
+ consecutiveErrors += 1;
455
+ if (consecutiveErrors >= maxConsecutiveErrors) {
456
+ throw new Error(outcome.message);
457
+ }
458
+ opts.onTransientError?.(outcome.message, consecutiveErrors);
459
+ continue;
460
+ }
461
+ }
462
+ throw new Error("Login timed out before approval. Run sign-in again.");
463
+ }
464
+ function cliAuthHeaders(cliToken) {
465
+ return {
466
+ "Content-Type": "application/json",
467
+ Authorization: `Bearer ${cliToken}`
468
+ };
469
+ }
470
+ async function listProjects(endpoint, cliToken) {
471
+ const base = trimTrailingSlash(endpoint);
472
+ try {
473
+ const res = await deviceFetch(`${base}/v1/cli/projects`, { headers: cliAuthHeaders(cliToken) });
474
+ const json = await res.json().catch(() => null);
475
+ if (res.ok && json?.ok) return json.data?.projects ?? [];
476
+ } catch {
477
+ }
478
+ return [];
479
+ }
480
+ async function createProject(endpoint, cliToken, name) {
481
+ const base = trimTrailingSlash(endpoint);
482
+ const res = await deviceFetch(`${base}/v1/cli/projects`, {
483
+ method: "POST",
484
+ headers: cliAuthHeaders(cliToken),
485
+ body: JSON.stringify({ name })
486
+ });
487
+ const json = await res.json().catch(() => null);
488
+ if (!res.ok || !json?.ok || !json.data) {
489
+ throw new Error(json?.error?.message ?? `Could not create project (HTTP ${res.status}).`);
490
+ }
491
+ const { id, name: created, slug, apiKey } = json.data;
492
+ return { id, name: created, slug, apiKey: apiKey ?? null };
493
+ }
494
+ async function mintProjectKey(endpoint, cliToken, projectId) {
495
+ const base = trimTrailingSlash(endpoint);
496
+ try {
497
+ const res = await deviceFetch(`${base}/v1/cli/projects/${projectId}/keys`, {
498
+ method: "POST",
499
+ headers: cliAuthHeaders(cliToken),
500
+ body: JSON.stringify({})
501
+ });
502
+ const json = await res.json().catch(() => null);
503
+ if (res.ok && json?.ok) return json.data?.key ?? null;
504
+ } catch {
505
+ }
506
+ return null;
507
+ }
508
+
332
509
  // src/freshness.ts
333
510
  var REGISTRY = "https://registry.npmjs.org";
334
511
  var DEFAULT_TIMEOUT_MS = 2e3;
@@ -485,6 +662,53 @@ function getFrameworkFromPkg(pkg) {
485
662
  return void 0;
486
663
  }
487
664
 
665
+ // src/auth-ui.ts
666
+ var LINE = "\u2500".repeat(58);
667
+ function printAuthBanner(userCode, verificationUri) {
668
+ console.log("");
669
+ console.log(` \u250C${LINE}\u2510`);
670
+ console.log(` \u2502 Browser sign-in \u2014 follow these 3 steps \u2502`);
671
+ console.log(` \u251C${LINE}\u2524`);
672
+ console.log(` \u2502 \u2502`);
673
+ console.log(` \u2502 1. A browser tab will open (or visit the URL below) \u2502`);
674
+ console.log(` \u2502 2. Sign in if prompted, then click "Approve" \u2502`);
675
+ console.log(` \u2502 3. Come back here \u2014 setup continues automatically \u2502`);
676
+ console.log(` \u2502 \u2502`);
677
+ console.log(` \u251C${LINE}\u2524`);
678
+ console.log(` \u2502 Verification code (show in browser \u2014 NOT terminal): \u2502`);
679
+ console.log(` \u2502 \u2502`);
680
+ console.log(` \u2502 ${userCode.padEnd(52)}\u2502`);
681
+ console.log(` \u2502 \u2502`);
682
+ console.log(` \u2502 \u26A0 Do NOT paste or type this code in your terminal \u2502`);
683
+ console.log(` \u2514${LINE}\u2518`);
684
+ console.log("");
685
+ console.log(` If the browser didn't open, visit this URL and approve:`);
686
+ console.log(` ${verificationUri}`);
687
+ console.log("");
688
+ console.log(" \u23F3 Waiting for browser approval\u2026 (Ctrl+C to cancel)");
689
+ console.log("");
690
+ }
691
+ function printAuthApproved() {
692
+ console.log("");
693
+ console.log(" \u2713 Approved! Continuing setup in this terminal\u2026");
694
+ console.log("");
695
+ }
696
+ function printAuthFailed(reason, detail) {
697
+ console.log("");
698
+ if (reason === "denied") {
699
+ console.error(" \u2717 Browser sign-in was denied.");
700
+ console.error(" If that was a mistake, run mushi login again.");
701
+ } else if (reason === "timeout") {
702
+ console.error(" \u2717 Browser sign-in timed out (10 minutes elapsed).");
703
+ console.error(" Run mushi login again \u2014 a new code will be generated.");
704
+ } else {
705
+ console.error(` \u2717 Browser sign-in failed: ${detail ?? "unknown error"}`);
706
+ console.error(" Run mushi login again, or use:");
707
+ console.error(" mushi login --api-key <key> --project-id <uuid>");
708
+ }
709
+ console.log("");
710
+ }
711
+
488
712
  // src/init.ts
489
713
  var ENV_FILES = [".env.local", ".env"];
490
714
  var PROJECT_ID_PATTERN = /^(?:proj_[A-Za-z0-9_-]{10,}|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i;
@@ -510,8 +734,8 @@ async function runInit(options = {}) {
510
734
  const detected = detectFramework(cwd, pkg);
511
735
  const framework = await chooseFramework(detected, options);
512
736
  const consoleBase = await resolveConsoleUrl({ cwd });
513
- await maybeGuideConsoleSetup(options, consoleBase);
514
- const credentials = await collectCredentials(options, consoleBase);
737
+ const endpoint = resolveCloudEndpoint(options.endpoint);
738
+ const credentials = await acquireCredentials(options, consoleBase, endpoint);
515
739
  await verifyCredentials(credentials, options, consoleBase);
516
740
  const pm = detectPackageManager(cwd);
517
741
  const packagesToInstall = framework.needsWebPackage ? [framework.packageName, "@mushi-mushi/web"] : [framework.packageName];
@@ -520,8 +744,7 @@ async function runInit(options = {}) {
520
744
  } else {
521
745
  p.log.info(`Skipped install. Run \`${installCommand(pm, packagesToInstall)}\` yourself.`);
522
746
  }
523
- const endpoint = resolveCloudEndpoint(options.endpoint);
524
- writeEnvFile(cwd, credentials.apiKey, credentials.projectId, framework);
747
+ await writeEnvFile(cwd, credentials.apiKey, credentials.projectId, framework, endpoint, Boolean(options.yes));
525
748
  persistCliConfig(credentials.apiKey, credentials.projectId, endpoint);
526
749
  const enableRewards = await maybeEnableRewards(options);
527
750
  printNextSteps(framework, consoleBase, enableRewards);
@@ -567,63 +790,17 @@ async function chooseFramework(detected, options) {
567
790
  }
568
791
  return FRAMEWORKS[confirmed];
569
792
  }
570
- async function maybeGuideConsoleSetup(options, consoleBase) {
571
- if (options.yes) return;
572
- if (options.projectId && options.apiKey) return;
573
- const existing = loadConfig();
574
- if (existing.projectId && existing.apiKey) return;
575
- const choice = await p.select({
576
- message: "Do you already have a Mushi project?",
577
- options: [
578
- {
579
- value: "create",
580
- label: "No \u2014 open the console to create one",
581
- hint: "Recommended for first-time setup"
582
- },
583
- {
584
- value: "have",
585
- label: "Yes \u2014 I have Project ID + API key",
586
- hint: "Paste values from the admin console"
587
- },
588
- {
589
- value: "login",
590
- label: "Use mushi login first",
591
- hint: "Run `mushi login` in another terminal, then re-run this wizard"
592
- }
593
- ]
594
- });
595
- if (p.isCancel(choice)) {
596
- p.cancel("Aborted.");
597
- process.exit(0);
598
- }
599
- if (choice === "login") {
600
- p.log.info("Run `mushi login` to save credentials, then re-run `mushi init`.");
601
- p.cancel("Aborted.");
602
- process.exit(0);
603
- }
604
- if (choice !== "create") return;
605
- const setupUrl = cliSetupDeepLink(consoleBase);
606
- p.log.info(`Opening the Mushi console:
607
- ${setupUrl}`);
608
- await openInBrowser(setupUrl);
609
- p.log.message("In the console:");
610
- p.log.message(" 1. Enter a project name and click Create");
611
- p.log.message(" 2. Copy the Project ID from the success panel");
612
- p.log.message(" 3. Generate an API key (Verify tab \u2014 report:write scope)");
613
- p.log.message(" 4. Return here and paste both values");
614
- const ready = await p.confirm({
615
- message: "Created your project and ready to paste credentials?",
616
- initialValue: false
617
- });
618
- if (p.isCancel(ready) || !ready) {
619
- p.log.warn(`Finish in the console first: ${setupUrl}`);
793
+ async function acquireCredentials(options, consoleBase, endpoint) {
794
+ if (options.projectId && options.apiKey) {
795
+ return {
796
+ projectId: sanitizeSecret(options.projectId),
797
+ apiKey: sanitizeSecret(options.apiKey)
798
+ };
620
799
  }
621
- }
622
- async function collectCredentials(options, consoleBase) {
623
800
  const existing = loadConfig();
624
801
  if (!options.projectId && !options.apiKey && existing.projectId && existing.apiKey) {
625
802
  const reuse = options.yes ? true : await p.confirm({
626
- message: "Use Project ID + API key from ~/.config/mushi/config.json?",
803
+ message: "Use the Mushi credentials saved from your last sign-in?",
627
804
  initialValue: true
628
805
  });
629
806
  if (p.isCancel(reuse)) {
@@ -637,6 +814,144 @@ async function collectCredentials(options, consoleBase) {
637
814
  };
638
815
  }
639
816
  }
817
+ if (options.yes) {
818
+ const creds = await runBrowserSignIn(options, endpoint, consoleBase);
819
+ if (creds) return creds;
820
+ p.log.warn("Browser sign-in didn't complete \u2014 switching to manual entry.");
821
+ } else {
822
+ const method = await p.select({
823
+ message: "Connect this app to Mushi",
824
+ initialValue: "browser",
825
+ options: [
826
+ {
827
+ value: "browser",
828
+ label: "Sign in with your browser",
829
+ hint: "Recommended \u2014 no copy-paste, creates the project + key for you"
830
+ },
831
+ {
832
+ value: "manual",
833
+ label: "Paste a Project ID + API key",
834
+ hint: "Self-hosted or expert setup \u2014 run npx mushi-mushi (not mushi setup)"
835
+ }
836
+ ]
837
+ });
838
+ if (p.isCancel(method)) {
839
+ p.cancel("Aborted.");
840
+ process.exit(0);
841
+ }
842
+ if (method === "browser") {
843
+ const creds = await runBrowserSignIn(options, endpoint, consoleBase);
844
+ if (creds) return creds;
845
+ p.log.warn("Browser sign-in didn't complete \u2014 switching to manual entry.");
846
+ }
847
+ }
848
+ return collectCredentialsManually(options, consoleBase);
849
+ }
850
+ async function runBrowserSignIn(options, endpoint, consoleBase) {
851
+ const startSpin = p.spinner();
852
+ startSpin.start("Starting secure browser sign-in\u2026");
853
+ let session;
854
+ try {
855
+ session = await startDeviceAuth(endpoint);
856
+ } catch (err) {
857
+ startSpin.stop("Could not start browser sign-in.");
858
+ p.log.warn(err instanceof Error ? err.message : String(err));
859
+ return null;
860
+ }
861
+ startSpin.stop("Browser sign-in ready.");
862
+ try {
863
+ await openInBrowser(session.verification_uri);
864
+ } catch {
865
+ }
866
+ printAuthBanner(session.user_code, session.verification_uri);
867
+ const waitSpin = p.spinner();
868
+ waitSpin.start("Waiting for you to approve in the browser\u2026");
869
+ let cliToken;
870
+ try {
871
+ cliToken = await waitForCliToken(endpoint, session);
872
+ } catch (err) {
873
+ waitSpin.stop("Browser sign-in didn't complete.");
874
+ p.log.warn(err instanceof Error ? err.message : String(err));
875
+ return null;
876
+ }
877
+ waitSpin.stop("Approved.");
878
+ let projectId = options.projectId ? sanitizeSecret(options.projectId) : void 0;
879
+ let apiKey;
880
+ if (!projectId) {
881
+ let projects = [];
882
+ const fetchSpin = p.spinner();
883
+ fetchSpin.start("Loading your projects\u2026");
884
+ try {
885
+ projects = await listProjects(endpoint, cliToken);
886
+ fetchSpin.stop(projects.length > 0 ? `Found ${projects.length} project(s).` : "No projects yet.");
887
+ } catch (err) {
888
+ fetchSpin.stop("Could not load projects \u2014 you can still create a new one.");
889
+ p.log.warn(err instanceof Error ? err.message : String(err));
890
+ }
891
+ const NEW = "__new__";
892
+ const choice = await p.select({
893
+ message: "Choose a project",
894
+ initialValue: projects[0]?.id ?? NEW,
895
+ options: [
896
+ ...projects.map((pr) => ({ value: pr.id, label: pr.name, hint: pr.id.slice(0, 8) })),
897
+ { value: NEW, label: "Create a new project", hint: "mints an SDK key automatically" }
898
+ ]
899
+ });
900
+ if (p.isCancel(choice)) {
901
+ p.cancel("Aborted.");
902
+ process.exit(0);
903
+ }
904
+ if (choice === NEW) {
905
+ const name = await p.text({
906
+ message: "Project name",
907
+ placeholder: "My app",
908
+ validate: (v) => v && v.trim().length > 0 ? void 0 : "Required"
909
+ });
910
+ if (p.isCancel(name)) {
911
+ p.cancel("Aborted.");
912
+ process.exit(0);
913
+ }
914
+ const createSpin = p.spinner();
915
+ createSpin.start(`Creating "${name.trim()}"\u2026`);
916
+ try {
917
+ const created = await createProject(endpoint, cliToken, name.trim());
918
+ projectId = created.id;
919
+ apiKey = created.apiKey ?? void 0;
920
+ createSpin.stop(`Created project "${created.name}".`);
921
+ } catch (err) {
922
+ createSpin.stop("Could not create the project.");
923
+ p.log.warn(err instanceof Error ? err.message : String(err));
924
+ return null;
925
+ }
926
+ } else {
927
+ projectId = choice;
928
+ }
929
+ }
930
+ if (projectId && !apiKey) {
931
+ const keySpin = p.spinner();
932
+ keySpin.start("Minting SDK key\u2026");
933
+ try {
934
+ const minted = await mintProjectKey(endpoint, cliToken, projectId);
935
+ apiKey = minted ?? void 0;
936
+ if (!apiKey) {
937
+ keySpin.stop("Could not mint an API key.");
938
+ p.log.warn(
939
+ `Open the console Verify tab to generate a key manually: ${consoleUrl(consoleBase, "/onboarding?tab=verify")}`
940
+ );
941
+ return null;
942
+ }
943
+ keySpin.stop("SDK key ready.");
944
+ } catch (err) {
945
+ keySpin.stop("Could not mint an API key.");
946
+ p.log.warn(err instanceof Error ? err.message : String(err));
947
+ return null;
948
+ }
949
+ }
950
+ if (!projectId || !apiKey) return null;
951
+ return { apiKey, projectId };
952
+ }
953
+ async function collectCredentialsManually(options, consoleBase) {
954
+ const existing = loadConfig();
640
955
  const rawProjectId = options.projectId ?? existing.projectId ?? await promptText({
641
956
  message: "Project ID",
642
957
  placeholder: "e.g. bdafa28d-b153-482f-bd4f-42981f3fd3a4",
@@ -736,13 +1051,37 @@ function runCommand(pm, packages, cwd) {
736
1051
  });
737
1052
  });
738
1053
  }
739
- function writeEnvFile(cwd, apiKey, projectId, framework) {
1054
+ async function writeEnvFile(cwd, apiKey, projectId, framework, endpoint, overwrite) {
740
1055
  const target = ENV_FILES.find((f) => existsSync3(join3(cwd, f))) ?? ENV_FILES[0];
741
1056
  const targetPath = join3(cwd, target);
742
- const newVars = envVarsToWrite(apiKey, projectId, framework);
1057
+ const newVars = envVarsToWrite(apiKey, projectId, framework, endpoint);
743
1058
  const existing = existsSync3(targetPath) ? readFileSync3(targetPath, "utf-8") : "";
744
1059
  if (existing.includes("MUSHI_PROJECT_ID")) {
745
- p.log.warn(`Existing MUSHI_* vars found in ${target} \u2014 leaving them untouched.`);
1060
+ let shouldOverwrite = overwrite;
1061
+ if (!shouldOverwrite) {
1062
+ const answer = await p.confirm({
1063
+ message: `Existing MUSHI_* vars found in ${target}. Update with new credentials?`,
1064
+ initialValue: true
1065
+ });
1066
+ if (p.isCancel(answer)) {
1067
+ p.log.info(`Kept existing env vars in ${target}.`);
1068
+ return;
1069
+ }
1070
+ shouldOverwrite = Boolean(answer);
1071
+ }
1072
+ if (!shouldOverwrite) {
1073
+ p.log.info(`Kept existing env vars in ${target}. Re-run and confirm to overwrite.`);
1074
+ return;
1075
+ }
1076
+ const MUSHI_LINE_RE = /^(NEXT_PUBLIC_|NUXT_PUBLIC_|VITE_|EXPO_PUBLIC_)?MUSHI_[A-Z_]+=.*/gm;
1077
+ const stripped = existing.replace(MUSHI_LINE_RE, "").replace(/\n{3,}/g, "\n\n").trimEnd();
1078
+ const prefix2 = stripped.length > 0 ? "\n" : "";
1079
+ writeFileSync(targetPath, `${stripped}${prefix2}
1080
+ # Mushi Mushi
1081
+ ${newVars}
1082
+ `);
1083
+ p.log.success(`Updated MUSHI_* env vars in ${target}`);
1084
+ warnIfMissingFromGitignore(cwd, target);
746
1085
  return;
747
1086
  }
748
1087
  const prefix = existing.length > 0 && !existing.endsWith("\n") ? "\n" : "";
@@ -803,12 +1142,14 @@ import { MushiRewardsBadge } from '@mushi-mushi/react';
803
1142
  p.log.info(`Enable rewards in your project settings at ${consoleUrl(consoleBase, "/rewards")}`);
804
1143
  p.log.info("Users will earn points for bug reports, screen navigation, and app activity.");
805
1144
  }
806
- p.log.message("Verify the install:");
807
- p.log.message(" \u2022 Start your dev server");
808
- p.log.message(" \u2022 Look for the \u{1F41B} button in the bottom-right corner (or shake on mobile)");
809
- p.log.message(` \u2022 Submit a test report \u2014 it should appear at ${reportsUrl(consoleBase)}`);
810
- p.log.info(
811
- "Tip: `mushi connect --write-env --wire-ide --wait` also wires Cursor MCP and waits for SDK heartbeat."
1145
+ p.note(
1146
+ [
1147
+ " [ ] 1. Paste the init snippet above into your app entry file",
1148
+ " [ ] 2. Start your dev server",
1149
+ " [ ] 3. Run: mushi connect --write-env --wire-ide --wait",
1150
+ ` [ ] 4. Open the Verify tab to send a test report: ${consoleUrl(consoleBase, "/onboarding?tab=verify")}`
1151
+ ].join("\n"),
1152
+ "Next steps:"
812
1153
  );
813
1154
  }
814
1155
  async function maybeOfferConnect(credentials, options, _consoleBase) {
@@ -820,7 +1161,7 @@ async function maybeOfferConnect(credentials, options, _consoleBase) {
820
1161
  if (p.isCancel(answer) || !answer) return;
821
1162
  const endpoint = resolveCloudEndpoint(options.endpoint);
822
1163
  try {
823
- const { runConnect: runConnect2 } = await import("./connect-W2Q6N7K3.js");
1164
+ const { runConnect: runConnect2 } = await import("./connect-JIEIWTF3.js");
824
1165
  await runConnect2({
825
1166
  apiKey: credentials.apiKey,
826
1167
  projectId: credentials.projectId,
@@ -1105,11 +1446,15 @@ function registerAccountCommands(program2) {
1105
1446
  const { matches } = runMigrate({ cwd: opts.cwd, json: opts.json });
1106
1447
  if (matches.length === 0) process.exit(1);
1107
1448
  });
1108
- program2.command("login").description("Authenticate the CLI via browser (zero copy-paste), or pass --api-key to skip").option("--api-key <key>", "API key (mushi_...) \u2014 non-interactive / CI path").option("--endpoint <url>", "Override the Mushi API endpoint (self-hosted)").option("--project-id <id>", "Project UUID \u2014 skip the project picker").option("--no-browser", "Print the verification URL instead of opening the browser").addHelpText("after", `
1449
+ program2.command("login").description("Authenticate the CLI via browser (zero copy-paste), or pass --api-key to skip").option("--api-key <key>", "API key (mushi_...) \u2014 non-interactive / CI path").option("--endpoint <url>", "Override the Mushi API endpoint (self-hosted)").option("--project-id <id>", "Project UUID \u2014 skip the project picker").option("--no-browser", "Print the verification URL instead of opening the browser").option(
1450
+ "--upgrade-scope",
1451
+ "Re-authenticate and mint a new key with report:write + mcp:read + mcp:write scopes. Use this to upgrade an existing ingest-only key so MCP and admin commands (including writes like `mushi billing cap`) work."
1452
+ ).addHelpText("after", `
1109
1453
  Examples:
1110
1454
  mushi login # browser-guided (recommended)
1111
1455
  mushi login --api-key mushi_xxx # non-interactive / CI
1112
- mushi login --api-key mushi_xxx --project-id <uuid>`).action(async (opts) => {
1456
+ mushi login --api-key mushi_xxx --project-id <uuid>
1457
+ mushi login --upgrade-scope # re-auth to add mcp:read + mcp:write to your key`).action(async (opts) => {
1113
1458
  const { CLOUD_API_ENDPOINT: CLOUD_API_ENDPOINT2, resolveCloudEndpoint: resolveCloudEndpoint2 } = await import("./endpoint-DGS73AVM.js");
1114
1459
  const endpoint = opts.endpoint ? resolveCloudEndpoint2(opts.endpoint) : CLOUD_API_ENDPOINT2;
1115
1460
  const consoleBase = await resolveConsoleUrl();
@@ -1147,19 +1492,19 @@ Examples:
1147
1492
  return;
1148
1493
  }
1149
1494
  console.log("");
1150
- console.log(" Mushi login");
1151
- console.log(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
1495
+ if (opts.upgradeScope) {
1496
+ console.log(" Mushi login \u2014 scope upgrade");
1497
+ console.log(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
1498
+ console.log(" Re-authenticating to mint a new key with report:write + mcp:read + mcp:write scopes.");
1499
+ console.log(" This lets the CLI admin commands (including writes) and MCP server both work with a single key.");
1500
+ console.log("");
1501
+ } else {
1502
+ console.log(" Mushi login");
1503
+ console.log(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
1504
+ }
1152
1505
  let deviceData;
1153
1506
  try {
1154
- const res = await fetch(`${endpoint}/v1/cli/auth/device/start`, {
1155
- method: "POST",
1156
- headers: { "Content-Type": "application/json" }
1157
- });
1158
- const json = await res.json();
1159
- if (!res.ok || !json.ok || !json.data) {
1160
- throw new Error(json.error?.message ?? `HTTP ${res.status}`);
1161
- }
1162
- deviceData = json.data;
1507
+ deviceData = await startDeviceAuth(endpoint);
1163
1508
  } catch (err) {
1164
1509
  process.stderr.write(`
1165
1510
  error: Could not start login session: ${err instanceof Error ? err.message : String(err)}
@@ -1169,77 +1514,56 @@ error: Could not start login session: ${err instanceof Error ? err.message : Str
1169
1514
  process.exit(1);
1170
1515
  }
1171
1516
  const verifyUrl = deviceData.verification_uri;
1172
- console.log("");
1173
- console.log(` Your confirmation code: ${deviceData.user_code}`);
1174
- console.log("");
1175
1517
  if (opts.browser !== false) {
1176
- console.log(" Opening the Mushi console in your browser\u2026");
1177
1518
  try {
1178
1519
  await openInBrowser(verifyUrl);
1179
1520
  } catch {
1180
1521
  }
1181
1522
  }
1182
- console.log(` If the browser didn't open: ${verifyUrl}`);
1183
- console.log("");
1184
- console.log(" Waiting for you to approve in the browser\u2026 (Ctrl+C to cancel)");
1523
+ printAuthBanner(deviceData.user_code, verifyUrl);
1185
1524
  let cliToken = null;
1186
1525
  const pollIntervalMs = (deviceData.interval ?? 5) * 1e3;
1187
1526
  const deadline = Date.now() + (deviceData.expires_in ?? 600) * 1e3;
1527
+ const MAX_CONSECUTIVE_ERRORS = 5;
1528
+ let consecutiveErrors = 0;
1529
+ let firstPoll = true;
1188
1530
  while (Date.now() < deadline) {
1189
- await new Promise((r) => setTimeout(r, pollIntervalMs));
1190
- try {
1191
- const pollRes = await fetch(`${endpoint}/v1/cli/auth/device/token`, {
1192
- method: "POST",
1193
- headers: { "Content-Type": "application/json" },
1194
- body: JSON.stringify({ device_code: deviceData.device_code })
1195
- });
1196
- const pollJson = await pollRes.json();
1197
- if (pollRes.ok && pollJson.ok && pollJson.data?.cli_token) {
1198
- cliToken = pollJson.data.cli_token;
1199
- break;
1200
- }
1201
- const errCode = pollJson.error ?? "";
1202
- if (errCode === "authorization_pending") {
1203
- process.stdout.write(".");
1204
- continue;
1205
- }
1206
- if (errCode === "access_denied") {
1207
- console.log("");
1208
- process.stderr.write("\nerror: Login denied in the browser.\n");
1209
- process.exit(1);
1210
- }
1211
- if (errCode === "expired_token") {
1212
- console.log("");
1213
- process.stderr.write("\nerror: Login code expired. Run mushi login again.\n");
1214
- process.exit(1);
1531
+ if (!firstPoll) {
1532
+ await new Promise((r) => setTimeout(r, pollIntervalMs));
1533
+ }
1534
+ firstPoll = false;
1535
+ const outcome = await pollDeviceToken(endpoint, deviceData.device_code);
1536
+ if (outcome.status === "approved") {
1537
+ cliToken = outcome.cliToken;
1538
+ break;
1539
+ }
1540
+ if (outcome.status === "pending") {
1541
+ consecutiveErrors = 0;
1542
+ continue;
1543
+ }
1544
+ if (outcome.status === "error") {
1545
+ if (outcome.retryable) {
1546
+ consecutiveErrors += 1;
1547
+ if (consecutiveErrors < MAX_CONSECUTIVE_ERRORS) continue;
1215
1548
  }
1216
- throw new Error(pollJson.error_description ?? errCode ?? `HTTP ${pollRes.status}`);
1217
- } catch (err) {
1218
- console.log("");
1219
- process.stderr.write(`
1220
- error: Poll failed: ${err instanceof Error ? err.message : String(err)}
1221
- `);
1549
+ printAuthFailed("error", outcome.message);
1550
+ process.exit(1);
1551
+ }
1552
+ if (outcome.status === "denied") {
1553
+ printAuthFailed("denied");
1554
+ process.exit(1);
1555
+ }
1556
+ if (outcome.status === "expired") {
1557
+ printAuthFailed("timeout");
1222
1558
  process.exit(1);
1223
1559
  }
1224
1560
  }
1225
1561
  if (!cliToken) {
1226
- console.log("");
1227
- process.stderr.write("\nerror: Login timed out. Run mushi login again.\n");
1562
+ printAuthFailed("timeout");
1228
1563
  process.exit(1);
1229
1564
  }
1230
- console.log("");
1231
- console.log(" \u2713 Approved!");
1232
- const cliHeaders = {
1233
- "Content-Type": "application/json",
1234
- "Authorization": `Bearer ${cliToken}`
1235
- };
1236
- let projectsList = [];
1237
- try {
1238
- const listRes = await fetch(`${endpoint}/v1/cli/projects`, { headers: cliHeaders });
1239
- const listJson = await listRes.json();
1240
- if (listRes.ok && listJson.ok) projectsList = listJson.data?.projects ?? [];
1241
- } catch {
1242
- }
1565
+ printAuthApproved();
1566
+ const projectsList = await listProjects(endpoint, cliToken);
1243
1567
  let chosenProjectId = projectId;
1244
1568
  let chosenProjectName;
1245
1569
  if (!chosenProjectId) {
@@ -1271,18 +1595,10 @@ error: Poll failed: ${err instanceof Error ? err.message : String(err)}
1271
1595
  process.exit(2);
1272
1596
  }
1273
1597
  try {
1274
- const createRes = await fetch(`${endpoint}/v1/cli/projects`, {
1275
- method: "POST",
1276
- headers: cliHeaders,
1277
- body: JSON.stringify({ name: newName.trim() })
1278
- });
1279
- const createJson = await createRes.json();
1280
- if (!createRes.ok || !createJson.ok || !createJson.data) {
1281
- throw new Error(createJson.error?.message ?? `HTTP ${createRes.status}`);
1282
- }
1283
- chosenProjectId = createJson.data.id;
1284
- chosenProjectName = createJson.data.name;
1285
- apiKey = createJson.data.apiKey ?? void 0;
1598
+ const created = await createProject(endpoint, cliToken, newName.trim());
1599
+ chosenProjectId = created.id;
1600
+ chosenProjectName = created.name;
1601
+ apiKey = created.apiKey ?? void 0;
1286
1602
  console.log(` \u2713 Created project "${chosenProjectName}"`);
1287
1603
  } catch (err) {
1288
1604
  process.stderr.write(`
@@ -1296,15 +1612,7 @@ error: Could not create project: ${err instanceof Error ? err.message : String(e
1296
1612
  }
1297
1613
  if (!apiKey && chosenProjectId) {
1298
1614
  try {
1299
- const keyRes = await fetch(`${endpoint}/v1/cli/projects/${chosenProjectId}/keys`, {
1300
- method: "POST",
1301
- headers: cliHeaders,
1302
- body: JSON.stringify({})
1303
- });
1304
- const keyJson = await keyRes.json();
1305
- if (keyRes.ok && keyJson.ok) {
1306
- apiKey = keyJson.data?.key ?? void 0;
1307
- }
1615
+ apiKey = await mintProjectKey(endpoint, cliToken, chosenProjectId) ?? void 0;
1308
1616
  } catch {
1309
1617
  }
1310
1618
  }
@@ -1317,12 +1625,17 @@ error: Could not create project: ${err instanceof Error ? err.message : String(e
1317
1625
  console.log("");
1318
1626
  if (chosenProjectName) console.log(` \u2713 Project: ${chosenProjectName}`);
1319
1627
  if (apiKey) {
1320
- console.log(` \u2713 SDK key saved (${apiKey.slice(0, 12)}\u2026)`);
1628
+ console.log(` \u2713 Full-scope CLI key saved (${apiKey.slice(0, 12)}\u2026)`);
1629
+ console.log(` Scopes: report:write \xB7 mcp:read \xB7 mcp:write \u2014 SDK + MCP + admin CLI (incl. writes) all work with this key`);
1321
1630
  } else {
1322
1631
  console.log(` \u2139 Get an SDK key at: ${apiKeyHint(consoleBase)}`);
1323
1632
  }
1324
1633
  console.log("");
1325
- console.log(" Run 'mushi init' to set up the SDK in this project.");
1634
+ if (opts.upgradeScope) {
1635
+ console.log(" Key upgraded! Re-run 'mushi setup' to update your .cursor/mcp.json, then restart Cursor.");
1636
+ } else {
1637
+ console.log(" Run 'mushi init' to set up the SDK in this project.");
1638
+ }
1326
1639
  });
1327
1640
  program2.command("whoami").description("Verify API key and display project info").option("--json", "Machine-readable JSON output").addHelpText("after", `
1328
1641
  Verifies that MUSHI_API_KEY is valid and shows which project it belongs to.
@@ -2145,56 +2458,115 @@ Examples:
2145
2458
  // src/commands/project.ts
2146
2459
  function registerProjectCommands(program2) {
2147
2460
  const project = program2.command("project").description("Project management");
2148
- project.command("create").description("Create a new Mushi project, mint an API key, and write config files").option("--name <name>", "Project name (skip the prompt)").option("--no-browser", "Skip opening the browser for the sign-up / magic-link step").option("--endpoint <url>", "Override API endpoint (self-hosted)").addHelpText("after", `
2149
- Creates a project on the Mushi console, mints an API key with mcp:read+write scope,
2150
- and writes the following to the current directory:
2461
+ project.command("create").description("Create or select a Mushi project via browser sign-in, then write config files").option("--name <name>", "Project name for a new project (skips the prompt)").option("--no-browser", "Print the verification URL instead of opening the browser").option("--endpoint <url>", "Override API endpoint (self-hosted)").addHelpText("after", `
2462
+ Signs you in through the browser (no copy-paste), creates or selects a project,
2463
+ mints a report:write SDK key, and writes the following to the current directory:
2151
2464
  .env.local \u2014 MUSHI_API_KEY, MUSHI_PROJECT_ID, MUSHI_API_ENDPOINT
2152
2465
  .cursor/mcp.json \u2014 pre-filled mcpServers.mushi block for Cursor
2153
2466
 
2154
2467
  Typical first-time flow:
2155
2468
  npx mushi-mushi project create
2156
- # Browser opens \u2192 sign up / magic-link \u2192 come back to terminal
2469
+ # Browser opens \u2192 click Approve \u2192 pick or create a project
2157
2470
  # CLI writes .env.local and .cursor/mcp.json
2158
2471
  # mushi whoami to confirm`).action(async (opts) => {
2159
2472
  const { writeFile } = await import("fs/promises");
2160
2473
  const { existsSync: existsSync4 } = await import("fs");
2161
2474
  const nodePath = await import("path");
2162
- const endpoint = opts.endpoint ?? loadConfig().endpoint ?? "https://api.mushimushi.dev";
2163
- const consoleBase = resolveConsoleUrlSync();
2164
- const signUpUrl = consoleUrl(consoleBase, "/sign-up");
2475
+ const savedConfig = loadConfig();
2476
+ const endpoint = resolveCloudEndpoint(
2477
+ opts.endpoint ?? process.env.MUSHI_API_ENDPOINT?.trim() ?? savedConfig.endpoint
2478
+ );
2479
+ const consoleBase = await resolveConsoleUrl();
2165
2480
  console.log("");
2166
2481
  console.log(" Mushi project create");
2167
2482
  console.log(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
2168
2483
  console.log("");
2484
+ let session;
2485
+ try {
2486
+ session = await startDeviceAuth(endpoint);
2487
+ } catch (err) {
2488
+ process.stderr.write(`
2489
+ error: Could not start browser sign-in: ${err instanceof Error ? err.message : String(err)}
2490
+ `);
2491
+ process.stderr.write(" Fallback: mushi login --api-key <key> --project-id <uuid>\n");
2492
+ process.exit(1);
2493
+ }
2169
2494
  if (opts.browser !== false) {
2170
- console.log(" 1. Opening the Mushi sign-up page in your browser...");
2171
2495
  try {
2172
- const { exec } = await import("child_process");
2173
- const openCmd = process.platform === "win32" ? `start "" "${signUpUrl}"` : process.platform === "darwin" ? `open "${signUpUrl}"` : `xdg-open "${signUpUrl}"`;
2174
- exec(openCmd);
2496
+ await openInBrowser(session.verification_uri);
2175
2497
  } catch {
2176
2498
  }
2177
- } else {
2178
- console.log(` 1. Sign up or log in at: ${signUpUrl}`);
2179
2499
  }
2180
- console.log("");
2181
- console.log(" 2. Create a project in the console, then paste your credentials below.");
2182
- console.log(" (Settings \u2192 API Keys \u2192 New key \u2192 Copy as .env.local)");
2183
- console.log("");
2500
+ printAuthBanner(session.user_code, session.verification_uri);
2501
+ let cliToken;
2502
+ try {
2503
+ cliToken = await waitForCliToken(endpoint, session);
2504
+ } catch (err) {
2505
+ const msg = err instanceof Error ? err.message : String(err);
2506
+ if (msg.includes("denied")) printAuthFailed("denied");
2507
+ else if (msg.includes("timed out") || msg.includes("expired")) printAuthFailed("timeout");
2508
+ else printAuthFailed("error", msg);
2509
+ process.exit(1);
2510
+ }
2511
+ printAuthApproved();
2512
+ const projectsList = await listProjects(endpoint, cliToken);
2184
2513
  const { createInterface } = await import("readline");
2185
2514
  const rl = createInterface({ input: process.stdin, output: process.stdout });
2186
2515
  const ask = (q) => new Promise((resolve3) => rl.question(q, (a) => resolve3(a.trim())));
2187
- const projectId = await ask(" Project ID (uuid): ");
2188
- const apiKey = await ask(" API key (mushi_...): ");
2516
+ let chosen;
2517
+ let projectId;
2518
+ let projectName;
2519
+ let apiKey;
2520
+ if (!opts.name && projectsList.length > 0) {
2521
+ console.log("");
2522
+ console.log(" Your projects:");
2523
+ projectsList.forEach((pr, i) => {
2524
+ console.log(` ${i + 1}. ${pr.name} (${pr.id})`);
2525
+ });
2526
+ console.log(` ${projectsList.length + 1}. Create a new project`);
2527
+ console.log("");
2528
+ const choice = await ask(` Pick a project [1-${projectsList.length + 1}]: `);
2529
+ const num = parseInt(choice, 10);
2530
+ if (num >= 1 && num <= projectsList.length) {
2531
+ chosen = projectsList[num - 1];
2532
+ projectId = chosen.id;
2533
+ projectName = chosen.name;
2534
+ }
2535
+ }
2536
+ if (!projectId) {
2537
+ const newName = opts.name?.trim() || await ask(" Project name: ");
2538
+ if (!newName) {
2539
+ rl.close();
2540
+ process.stderr.write("\nerror: Project name is required.\n");
2541
+ process.exit(2);
2542
+ }
2543
+ try {
2544
+ const created = await createProject(endpoint, cliToken, newName);
2545
+ projectId = created.id;
2546
+ projectName = created.name;
2547
+ apiKey = created.apiKey ?? void 0;
2548
+ console.log(` \u2713 Created project "${projectName}"`);
2549
+ } catch (err) {
2550
+ rl.close();
2551
+ process.stderr.write(`
2552
+ error: Could not create project: ${err instanceof Error ? err.message : String(err)}
2553
+ `);
2554
+ process.exit(1);
2555
+ }
2556
+ }
2189
2557
  rl.close();
2558
+ if (projectId && !apiKey) {
2559
+ apiKey = await mintProjectKey(endpoint, cliToken, projectId) ?? void 0;
2560
+ }
2190
2561
  if (!projectId || !apiKey) {
2191
- process.stderr.write("\nerror: Project ID and API key are required.\n");
2192
- process.exit(2);
2562
+ process.stderr.write("\nerror: Could not obtain an SDK key for the project. Run `mushi login` and try again.\n");
2563
+ process.exit(1);
2193
2564
  }
2194
2565
  const config = loadConfig();
2195
2566
  config.apiKey = apiKey;
2196
2567
  config.endpoint = endpoint;
2197
2568
  config.projectId = projectId;
2569
+ config.consoleUrl = consoleBase;
2198
2570
  saveConfig(config);
2199
2571
  const cwd = process.cwd();
2200
2572
  const envPath = nodePath.join(cwd, ".env.local");
@@ -2223,7 +2595,7 @@ Typical first-time flow:
2223
2595
 
2224
2596
  // src/commands/setup.ts
2225
2597
  function registerSetupCommands(program2) {
2226
- program2.command("setup").description("Wire Cursor MCP into your IDE (reads ~/.config/mushi/config.json \u2014 run `mushi login` first)").option("--ide <ide>", "Target IDE: cursor | claude | continue | zed", "cursor").option("--project-slug <slug>", "Override the project slug in the server name (default: fetched from API or ID prefix)").option("--all-projects", "Write a separate mushi-<name> server entry for every accessible project").option("--with-rules", "Also write the .cursorrules / .claude/rules/mushi.md lesson-library hook").option("--dry-run", "Print what would be written without making changes").addHelpText("after", `
2598
+ program2.command("setup").description("Wire Cursor MCP into your IDE (reads ~/.config/mushi/config.json \u2014 run `mushi login` first)").option("--ide <ide>", "Target IDE: cursor | claude | continue | zed", "cursor").option("--project-slug <slug>", "Override the project slug in the server name (default: fetched from API or ID prefix)").option("--all-projects", "Write a separate mushi-<name> server entry for every accessible project").option("--with-rules", "Also write the .cursorrules / .claude/rules/mushi.md lesson-library hook").option("--dry-run", "Print what would be written without making changes").option("--verify", "Probe the MCP key after writing to confirm it has mcp:read scope (default: on)").option("--no-verify", "Skip the post-write key probe").addHelpText("after", `
2227
2599
  Examples:
2228
2600
  mushi setup # wire Cursor (default)
2229
2601
  mushi setup --all-projects # one server entry per accessible project
@@ -2427,6 +2799,54 @@ The command reads credentials from ~/.config/mushi/config.json (run \`mushi logi
2427
2799
  }
2428
2800
  }
2429
2801
  if (!opts.dryRun) {
2802
+ const shouldVerify = opts.verify !== false;
2803
+ if (shouldVerify && config.endpoint && config.apiKey && config.projectId) {
2804
+ try {
2805
+ const probeRes = await fetch(
2806
+ `${config.endpoint.replace(/\/$/, "")}/v1/admin/mcp/account-overview`,
2807
+ {
2808
+ headers: {
2809
+ "X-Mushi-Api-Key": config.apiKey,
2810
+ "X-Mushi-Project": config.projectId
2811
+ },
2812
+ signal: AbortSignal.timeout(6e3)
2813
+ }
2814
+ );
2815
+ if (probeRes.ok) {
2816
+ console.log("\u2713 MCP key valid \u2014 restart Cursor (or your IDE) to activate");
2817
+ void fetch(
2818
+ `${config.endpoint.replace(/\/$/, "")}/v1/cli/funnel`,
2819
+ {
2820
+ method: "POST",
2821
+ headers: {
2822
+ "Content-Type": "application/json",
2823
+ "X-Mushi-Api-Key": config.apiKey,
2824
+ "X-Mushi-Project": config.projectId
2825
+ },
2826
+ body: JSON.stringify({ event: "mcp_setup_done", source: "cli" }),
2827
+ signal: AbortSignal.timeout(4e3)
2828
+ }
2829
+ ).catch(() => {
2830
+ });
2831
+ } else if (probeRes.status === 403) {
2832
+ let errCode;
2833
+ try {
2834
+ const b = await probeRes.json();
2835
+ errCode = b?.error?.code;
2836
+ } catch {
2837
+ }
2838
+ if (errCode === "INSUFFICIENT_SCOPE") {
2839
+ console.log("\n\u26A0 Your key has report:write scope only \u2014 MCP admin tools will not work.");
2840
+ console.log(" New keys minted by the wizard include both scopes automatically.");
2841
+ console.log(" To upgrade an existing key, run:\n");
2842
+ console.log(" mushi login --upgrade-scope\n");
2843
+ } else {
2844
+ console.log(`\u26A0 Key probe returned HTTP ${probeRes.status} \u2014 check your credentials.`);
2845
+ }
2846
+ }
2847
+ } catch {
2848
+ }
2849
+ }
2430
2850
  console.log("");
2431
2851
  console.log(`Done! Restart ${opts.ide === "cursor" ? "Cursor" : opts.ide === "claude" ? "Claude Code" : opts.ide} and ask: "list mushi tools"`);
2432
2852
  if (!opts.withRules) {
@@ -2637,6 +3057,23 @@ async function checkServerPreflight(config, doFetch = globalThis.fetch) {
2637
3057
  detail: sc.ready ? "" : sc.hint
2638
3058
  }));
2639
3059
  }
3060
+ if (res.status === 403) {
3061
+ let errCode;
3062
+ try {
3063
+ const body = await res.json();
3064
+ errCode = body?.error?.code;
3065
+ } catch {
3066
+ }
3067
+ if (errCode === "INSUFFICIENT_SCOPE") {
3068
+ return [
3069
+ {
3070
+ name: "Server preflight",
3071
+ ok: true,
3072
+ detail: "Skipped \u2014 your key has report:write scope only (pre-Jun 2026 key). Run `mushi login --upgrade-scope` to get mcp:read and unlock admin checks."
3073
+ }
3074
+ ];
3075
+ }
3076
+ }
2640
3077
  const text2 = await res.text().catch(() => "");
2641
3078
  return [
2642
3079
  {
@@ -2839,7 +3276,17 @@ async function checkMcpConfig(config, cwd, doFetch = globalThis.fetch) {
2839
3276
  let mcpRaw = null;
2840
3277
  for (const candidate of candidates) {
2841
3278
  try {
2842
- mcpRaw = await readFile2(candidate, "utf8");
3279
+ const raw = await readFile2(candidate, "utf8");
3280
+ let parsed = {};
3281
+ try {
3282
+ parsed = JSON.parse(raw);
3283
+ } catch {
3284
+ }
3285
+ const hasEntries = Object.keys(parsed.mcpServers ?? {}).length > 0;
3286
+ if (!hasEntries && candidates.indexOf(candidate) < candidates.length - 1) {
3287
+ continue;
3288
+ }
3289
+ mcpRaw = raw;
2843
3290
  mcpPath = candidate;
2844
3291
  break;
2845
3292
  } catch {
@@ -2916,10 +3363,20 @@ async function checkMcpConfig(config, cwd, doFetch = globalThis.fetch) {
2916
3363
  detail: `Key is valid; ${projectCount} accessible project${projectCount === 1 ? "" : "s"}`
2917
3364
  });
2918
3365
  } else {
3366
+ let detail = `GET /v1/admin/mcp/account-overview \u2192 HTTP ${res.status}. Verify the API key is active.`;
3367
+ try {
3368
+ const errBody = await res.json();
3369
+ const nested = errBody["error"];
3370
+ const errCode = nested?.["code"] ?? errBody["code"] ?? "";
3371
+ if (errCode === "INSUFFICIENT_SCOPE") {
3372
+ detail = `Key has report:write scope only \u2014 run \`mushi login --upgrade-scope\` then \`mushi setup\` to get mcp:read.`;
3373
+ }
3374
+ } catch {
3375
+ }
2919
3376
  checks.push({
2920
3377
  name: "[mcp] account-overview reachable",
2921
3378
  ok: false,
2922
- detail: `GET /v1/admin/mcp/account-overview \u2192 HTTP ${res.status}. Verify the API key is active.`
3379
+ detail
2923
3380
  });
2924
3381
  }
2925
3382
  } catch (err) {
@@ -2949,11 +3406,96 @@ function fixHintForCheck(name) {
2949
3406
  if (name.startsWith("[mcp]")) return FIX_HINTS["[mcp]"];
2950
3407
  return void 0;
2951
3408
  }
3409
+ async function checkOnboardingStatus(config, _consoleBase, cwd, doFetch = globalThis.fetch) {
3410
+ if (!config.apiKey || !config.projectId || !config.endpoint) {
3411
+ if (config.apiKey && config.endpoint) {
3412
+ return {
3413
+ nextAction: "Select or create a project: run `mushi project create` or pick one with `mushi project list`",
3414
+ ctaPath: "/onboarding?tab=steps&setup=cli",
3415
+ done: false
3416
+ };
3417
+ }
3418
+ return {
3419
+ nextAction: "Sign in to Mushi: run `npx mushi-mushi` (browser opens automatically \u2014 do NOT type the code in the terminal)",
3420
+ ctaPath: "/onboarding?tab=steps&setup=cli",
3421
+ done: false
3422
+ };
3423
+ }
3424
+ try {
3425
+ const { readFile: readFile2 } = await import("fs/promises");
3426
+ const { join: join5, resolve: resolve3 } = await import("path");
3427
+ const root = resolve3(cwd);
3428
+ let envContent = "";
3429
+ for (const f of [".env.local", ".env"]) {
3430
+ try {
3431
+ envContent = await readFile2(join5(root, f), "utf8");
3432
+ break;
3433
+ } catch {
3434
+ }
3435
+ }
3436
+ const hasEnvVars = /(?:VITE_|NEXT_PUBLIC_|EXPO_PUBLIC_|NUXT_PUBLIC_)?MUSHI_PROJECT_ID=/.test(envContent);
3437
+ if (!hasEnvVars) {
3438
+ return {
3439
+ nextAction: "Write env vars to your app: run `npx mushi-mushi` or `mushi connect --write-env`",
3440
+ ctaPath: "/onboarding?tab=sdk",
3441
+ done: false
3442
+ };
3443
+ }
3444
+ } catch {
3445
+ }
3446
+ try {
3447
+ const sdkCheck = await checkSdkInstall(cwd);
3448
+ if (sdkCheck && !sdkCheck.ok) {
3449
+ return {
3450
+ nextAction: `Install the Mushi SDK: ${sdkCheck.detail}`,
3451
+ ctaPath: "/onboarding?tab=sdk",
3452
+ done: false
3453
+ };
3454
+ }
3455
+ } catch {
3456
+ }
3457
+ try {
3458
+ const data = await fetchIngestSetup(
3459
+ { endpoint: config.endpoint, apiKey: config.apiKey, projectId: config.projectId },
3460
+ doFetch
3461
+ );
3462
+ if (data) {
3463
+ const steps = data.steps ?? [];
3464
+ const incomplete = steps.find((s) => s.required && !s.complete);
3465
+ if (incomplete) {
3466
+ const tabMap = {
3467
+ project_created: "/onboarding?tab=steps&setup=cli",
3468
+ api_key_generated: "/onboarding?tab=verify",
3469
+ sdk_installed: "/onboarding?tab=sdk",
3470
+ first_report_received: "/onboarding?tab=verify"
3471
+ };
3472
+ const tab = tabMap[incomplete.id] ?? "/onboarding?tab=verify";
3473
+ return {
3474
+ nextAction: incomplete.hint ?? `Complete setup step: ${incomplete.label}`,
3475
+ ctaPath: tab,
3476
+ done: false
3477
+ };
3478
+ }
3479
+ return {
3480
+ nextAction: "All 4 required setup steps are complete! Open the console to explore.",
3481
+ ctaPath: "/reports",
3482
+ done: true
3483
+ };
3484
+ }
3485
+ } catch {
3486
+ }
3487
+ return {
3488
+ nextAction: "Open the onboarding wizard in the console to check your setup status.",
3489
+ ctaPath: "/onboarding",
3490
+ done: false
3491
+ };
3492
+ }
2952
3493
  async function runDoctor(config, options = {}) {
2953
3494
  const doFetch = options.fetch ?? globalThis.fetch;
2954
3495
  const checks = [];
2955
- const runServer = options.server !== false;
2956
- const runIngest = options.ingest !== false;
3496
+ const isFull = options.full === true;
3497
+ const runServer = isFull || options.server !== false;
3498
+ const runIngest = isFull || options.ingest !== false;
2957
3499
  checks.push(...checkCliConfig(config));
2958
3500
  if (config.endpoint) {
2959
3501
  checks.push(await checkEndpointReachability(config.endpoint, doFetch));
@@ -2968,26 +3510,40 @@ async function runDoctor(config, options = {}) {
2968
3510
  const ingestChecks = await checkIngestSetup(config, doFetch);
2969
3511
  checks.push(...ingestChecks);
2970
3512
  }
2971
- if (options.qaStories) {
3513
+ if (isFull || options.qaStories) {
2972
3514
  const qaChecks = await checkQaStoriesHealth(config, doFetch);
2973
3515
  checks.push(...qaChecks);
2974
3516
  }
2975
- if (options.hostApp) {
3517
+ if (isFull || options.hostApp) {
2976
3518
  const hostChecks = await checkHostAppWiring(options.cwd ?? process.cwd());
2977
3519
  checks.push(...hostChecks);
2978
3520
  }
2979
- if (options.mcp) {
3521
+ if (isFull || options.mcp) {
2980
3522
  const mcpChecks = await checkMcpConfig(config, options.cwd ?? process.cwd(), doFetch);
2981
3523
  checks.push(...mcpChecks);
2982
3524
  }
3525
+ const heartbeatPassed = checks.some((c) => c.name === "[ingest] Last SDK heartbeat" && c.ok);
3526
+ if (heartbeatPassed) {
3527
+ const idx = checks.findIndex((c) => c.name === "SDK installed in this repo" && !c.ok);
3528
+ if (idx >= 0) {
3529
+ checks[idx] = {
3530
+ ...checks[idx],
3531
+ ok: true,
3532
+ warn: true,
3533
+ detail: "No @mushi-mushi/* found in cwd package.json \u2014 but live heartbeats confirm the SDK is active. Run from your app repo or install with `mushi init`."
3534
+ };
3535
+ }
3536
+ }
2983
3537
  return { checks, ready: checks.every((c) => c.ok) };
2984
3538
  }
2985
3539
  function formatDoctorResult(result) {
2986
3540
  const PASS = "\u2713";
3541
+ const WARN = "\u26A0";
2987
3542
  const FAIL = "\u2717";
2988
3543
  const lines = [];
2989
3544
  for (const c of result.checks) {
2990
- lines.push(`${c.ok ? PASS : FAIL} ${c.name}`);
3545
+ const icon = !c.ok ? FAIL : c.warn ? WARN : PASS;
3546
+ lines.push(`${icon} ${c.name}`);
2991
3547
  if (c.detail) lines.push(` ${c.detail}`);
2992
3548
  if (!c.ok) {
2993
3549
  const hint = fixHintForCheck(c.name);
@@ -2995,8 +3551,14 @@ function formatDoctorResult(result) {
2995
3551
  }
2996
3552
  }
2997
3553
  const failed = result.checks.filter((c) => !c.ok);
3554
+ const warned = result.checks.filter((c) => c.ok && c.warn);
2998
3555
  if (failed.length === 0) {
2999
- lines.push("\nAll checks passed. The CLI is ready.");
3556
+ if (warned.length > 0) {
3557
+ lines.push(`
3558
+ All checks passed with ${warned.length} advisory warning${warned.length === 1 ? "" : "s"}. The CLI is ready.`);
3559
+ } else {
3560
+ lines.push("\nAll checks passed. The CLI is ready.");
3561
+ }
3000
3562
  } else {
3001
3563
  lines.push(`
3002
3564
  ${failed.length} check${failed.length === 1 ? "" : "s"} failed.`);
@@ -3240,7 +3802,7 @@ Examples:
3240
3802
  }
3241
3803
  if (result.plan.entries.length === 0) process.exit(1);
3242
3804
  });
3243
- program2.command("connect").description("Save credentials, merge env vars, wire Cursor MCP, optionally wait for SDK heartbeat").option("--api-key <key>", "Mushi API key (mushi_\u2026) \u2014 or set MUSHI_API_KEY to keep it out of shell history").requiredOption("--project-id <id>", "Project UUID").requiredOption("--endpoint <url>", "Supabase edge function URL").option("--cwd <path>", "Target repo").option("--write-env", "Write SDK env vars to .env.local (default: enabled)").option("--wire-ide", "Wire Cursor MCP into .cursor/mcp.json (default: enabled)").option("--no-env", "Skip writing .env.local").option("--no-ide", "Skip writing .cursor/mcp.json").option("--wait", "Poll ingest-setup until SDK heartbeat lands").option("--wait-timeout <sec>", "Max seconds for --wait", "120").option("--json", "Machine-readable output").addHelpText("after", `
3805
+ program2.command("connect").description("Save credentials, merge env vars, wire Cursor MCP, optionally wait for SDK heartbeat").option("--api-key <key>", "Mushi API key (mushi_\u2026) \u2014 or set MUSHI_API_KEY to keep it out of shell history").requiredOption("--project-id <id>", "Project UUID").requiredOption("--endpoint <url>", "Supabase edge function URL").option("--cwd <path>", "Target repo").option("--write-env", "Force-write SDK env vars to .env.local (default; overrides --no-env)").option("--wire-ide", "Force-wire Cursor MCP into .cursor/mcp.json (default; overrides --no-ide)").option("--no-env", "Skip writing .env.local").option("--no-ide", "Skip writing .cursor/mcp.json").option("--wait", "Poll ingest-setup until SDK heartbeat lands").option("--wait-timeout <sec>", "Max seconds for --wait", "120").option("--json", "Machine-readable output").addHelpText("after", `
3244
3806
  Examples:
3245
3807
  MUSHI_API_KEY=mushi_xxx mushi connect --project-id <uuid> --endpoint https://<ref>.supabase.co/functions/v1/api --wait
3246
3808
  mushi connect --api-key mushi_xxx --project-id <uuid> --endpoint <url> --no-ide`).action(async (opts) => {
@@ -3254,8 +3816,10 @@ Examples:
3254
3816
  projectId: opts.projectId,
3255
3817
  endpoint: opts.endpoint,
3256
3818
  cwd: opts.cwd,
3257
- writeEnv: opts.env !== false,
3258
- wireIde: opts.ide !== false,
3819
+ // Explicit --write-env / --wire-ide force the action on even if --no-env /
3820
+ // --no-ide was also passed; otherwise writing is the default.
3821
+ writeEnv: opts.writeEnv === true ? true : opts.env !== false,
3822
+ wireIde: opts.wireIde === true ? true : opts.ide !== false,
3259
3823
  wait: opts.wait,
3260
3824
  waitTimeoutSec: parseInt(opts.waitTimeout, 10) || 120,
3261
3825
  json: opts.json
@@ -3281,9 +3845,38 @@ Examples:
3281
3845
  ).option(
3282
3846
  "--fix",
3283
3847
  "Apply safe local fixes when checks fail: write missing .env.local lines and wire Cursor MCP config."
3848
+ ).option(
3849
+ "--onboarding",
3850
+ "Focused onboarding check: prints the single next action you need to take to finish SDK setup, with a console deep link."
3851
+ ).option(
3852
+ "--full",
3853
+ "Run ALL check categories (server, ingest, host-app, mcp, qa-stories) in one shot. Good for first-run diagnostics. Exit 0 only when all checks pass."
3284
3854
  ).action(async (opts) => {
3285
3855
  const config = loadConfig();
3286
- const doctorOpts = { cwd: opts.cwd, server: opts.server, ingest: opts.ingest, qaStories: opts.qaStories, hostApp: opts.hostApp, mcp: opts.mcp };
3856
+ if (opts.onboarding) {
3857
+ const cwd = opts.cwd ?? process.cwd();
3858
+ const consoleBase = await resolveConsoleUrl({ cwd });
3859
+ const status = await checkOnboardingStatus(config, consoleBase, cwd);
3860
+ const base = consoleBase.replace(/\/$/, "");
3861
+ if (status.done) {
3862
+ console.log(`\u2713 Setup complete. ${status.nextAction}`);
3863
+ console.log(` ${base}${status.ctaPath}`);
3864
+ } else {
3865
+ console.log(`\u2192 Next: ${status.nextAction}`);
3866
+ console.log(` Open: ${base}${status.ctaPath}`);
3867
+ }
3868
+ if (!status.done) process.exit(1);
3869
+ return;
3870
+ }
3871
+ const doctorOpts = {
3872
+ cwd: opts.cwd,
3873
+ server: opts.server,
3874
+ ingest: opts.ingest,
3875
+ qaStories: opts.qaStories,
3876
+ hostApp: opts.hostApp,
3877
+ mcp: opts.mcp,
3878
+ full: opts.full
3879
+ };
3287
3880
  let result = await runDoctor(config, doctorOpts);
3288
3881
  if (!result.ready && opts.fix && config.apiKey && config.projectId && config.endpoint) {
3289
3882
  const connectResult = await runConnect({
@@ -4367,6 +4960,97 @@ ${"\u2500".repeat(60)}`);
4367
4960
  });
4368
4961
  }
4369
4962
 
4963
+ // src/commands/billing.ts
4964
+ function registerBillingCommands(program2) {
4965
+ program2.command("usage").description("Show diagnoses used / limit / cap for the current billing period").option("--json", "Machine-readable JSON output").addHelpText("after", `
4966
+ Examples:
4967
+ mushi usage
4968
+ mushi usage --json`).action(async (opts) => {
4969
+ const config = requireConfig();
4970
+ const result = await apiCall("/v1/admin/billing/stats", config);
4971
+ if (!result.ok) die(result);
4972
+ const s = result.data;
4973
+ if (opts.json) {
4974
+ console.log(JSON.stringify(s, null, 2));
4975
+ return;
4976
+ }
4977
+ const limitStr = s.diagnosesLimit != null ? String(s.diagnosesLimit) : "unlimited";
4978
+ const pctStr = s.diagnosesUsagePct != null ? `${Math.round(s.diagnosesUsagePct)}%` : "\u2014";
4979
+ const capStr = s.monthlySpendCapUsd != null ? `$${s.monthlySpendCapUsd}/mo` : "none";
4980
+ const badge = s.overDiagnosisQuota ? " \u26A0 OVER LIMIT" : s.approachingDiagnosisQuota ? " \u26A0 approaching limit" : "";
4981
+ console.log(`
4982
+ Diagnoses this period: ${s.diagnosesUsed} / ${limitStr} (${pctStr})${badge}`);
4983
+ console.log(`Plan: ${s.planId ?? "\u2014"}`);
4984
+ console.log(`Spend cap: ${capStr}`);
4985
+ console.log(`Period ends: ${s.periodEnd ? s.periodEnd.slice(0, 10) : "?"}`);
4986
+ if (s.overDiagnosisQuota) {
4987
+ console.log("\n Diagnoses paused \u2014 upgrade at https://kensaur.us/mushi-mushi/admin/billing");
4988
+ } else if (s.approachingDiagnosisQuota) {
4989
+ console.log("\n You're at 80%+ of your included diagnoses. Consider upgrading.");
4990
+ }
4991
+ });
4992
+ const billing = program2.command("billing").description("Show billing summary and manage spend cap");
4993
+ billing.command("status").description("Show full billing summary (plan, usage, cap)").option("--json", "Machine-readable JSON output").addHelpText("after", `
4994
+ Examples:
4995
+ mushi billing status
4996
+ mushi billing status --json`).action(async (opts) => {
4997
+ const config = requireConfig();
4998
+ const result = await apiCall("/v1/admin/billing", config);
4999
+ if (!result.ok) die(result);
5000
+ const s = result.data;
5001
+ if (opts.json) {
5002
+ console.log(JSON.stringify(s, null, 2));
5003
+ return;
5004
+ }
5005
+ const limitStr = s.diagnosesLimit != null ? String(s.diagnosesLimit) : "unlimited";
5006
+ const pctStr = s.diagnosesUsagePct != null ? `${Math.round(s.diagnosesUsagePct)}%` : "\u2014";
5007
+ const capStr = s.monthlySpendCapUsd != null ? `$${s.monthlySpendCapUsd}/mo` : "none";
5008
+ const overageStr = s.overageRateDiagnoses != null ? `$${s.overageRateDiagnoses}/diagnosis` : "N/A";
5009
+ console.log(`
5010
+ Plan: ${s.planId ?? "\u2014"}`);
5011
+ console.log(`Status: ${s.subscriptionStatus ?? "free"}${s.cancelAtPeriodEnd ? " (cancels at period end)" : ""}`);
5012
+ console.log(`Diagnoses: ${s.diagnosesUsed} / ${limitStr} (${pctStr})`);
5013
+ console.log(`Overage: ${overageStr}`);
5014
+ console.log(`Spend cap: ${capStr}`);
5015
+ console.log(`Renews: ${s.periodEnd ? s.periodEnd.slice(0, 10) : "?"}`);
5016
+ });
5017
+ billing.command("cap [usd]").description("Set or clear the monthly spend cap (pass 0 to clear, omit to show current cap)").addHelpText("after", `
5018
+ Examples:
5019
+ mushi billing cap # show current cap
5020
+ mushi billing cap 100 # set $100/mo hard cap
5021
+ mushi billing cap 0 # clear cap`).action(async (usdArg) => {
5022
+ const config = requireConfig();
5023
+ if (usdArg === void 0) {
5024
+ const result2 = await apiCall("/v1/admin/billing/stats", config);
5025
+ if (!result2.ok) die(result2);
5026
+ const cap2 = result2.data.monthlySpendCapUsd;
5027
+ if (cap2 == null) {
5028
+ console.log("No spend cap set. Pass a dollar amount to set one, e.g.: mushi billing cap 100");
5029
+ } else {
5030
+ console.log(`Current spend cap: $${cap2}/mo`);
5031
+ }
5032
+ return;
5033
+ }
5034
+ const capUsd = parseFloat(usdArg);
5035
+ if (isNaN(capUsd) || capUsd < 0) {
5036
+ console.error("Error: spend cap must be a non-negative number (or 0 to clear).");
5037
+ process.exit(2);
5038
+ }
5039
+ const result = await apiCall("/v1/admin/billing/spend-cap", config, {
5040
+ method: "PUT",
5041
+ headers: { "Content-Type": "application/json" },
5042
+ body: JSON.stringify({ project_id: config.projectId, spend_cap_usd: capUsd === 0 ? null : capUsd })
5043
+ });
5044
+ if (!result.ok) die(result);
5045
+ const cap = result.data.spend_cap_usd;
5046
+ if (cap == null) {
5047
+ console.log("\u2713 Spend cap cleared \u2014 no hard monthly limit.");
5048
+ } else {
5049
+ console.log(`\u2713 Spend cap set to $${cap}/mo. Diagnoses will pause gracefully once reached.`);
5050
+ }
5051
+ });
5052
+ }
5053
+
4370
5054
  // src/index.ts
4371
5055
  installSignalHandlers();
4372
5056
  var program = new Command().name("mushi").description("Mushi Mushi CLI \u2014 set up the SDK, manage bug reports, monitor pipeline").version(MUSHI_CLI_VERSION).addHelpText("after", `
@@ -4399,6 +5083,7 @@ registerIntegrationsCommands(program);
4399
5083
  registerQaCommands(program);
4400
5084
  registerAuditCommands(program);
4401
5085
  registerSkillsCommands(program);
5086
+ registerBillingCommands(program);
4402
5087
  program.parseAsync().catch((err) => {
4403
5088
  console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
4404
5089
  process.exit(1);