@mushi-mushi/cli 0.27.1 → 0.28.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
@@ -1,22 +1,28 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
- FRAMEWORKS,
3
+ CLI_KEY_LABEL,
4
+ CLI_KEY_SCOPES,
5
+ CONFIG_PATH,
6
+ FULL_CLI_KEY_SCOPES,
7
+ MCP_CLIENTS,
8
+ MCP_CLIENT_LABEL,
9
+ SDK_KEY_LABEL,
10
+ SDK_KEY_SCOPES,
4
11
  apiCall,
5
12
  apiKeyHeaders,
6
13
  apiKeyHint,
14
+ buildHostedMcpServerBlock,
7
15
  buildMcpServerBlock,
8
16
  buildMcpServerName,
9
17
  cliSetupDeepLink,
10
18
  consoleUrl,
11
- detectFramework,
12
- detectPackageManager,
19
+ describeUnsafeSdkKey,
13
20
  die,
14
21
  ensureClientId,
15
- envVarsToWrite,
16
22
  fetchIngestSetup,
17
23
  fmtDate,
18
- installCommand,
19
24
  installSignalHandlers,
25
+ isMcpClient,
20
26
  listProfiles,
21
27
  loadConfig,
22
28
  maybeShowTelemetryNotice,
@@ -27,8 +33,8 @@ import {
27
33
  printKeyExportHint,
28
34
  printResult,
29
35
  probeEndpointHealth,
36
+ probeKeyScope,
30
37
  projectIdHint,
31
- readPackageJson,
32
38
  reportsUrl,
33
39
  requireConfig,
34
40
  requireUuid,
@@ -41,16 +47,26 @@ import {
41
47
  sanitizeEndpoint,
42
48
  sanitizeProjectId,
43
49
  saveConfig,
50
+ savedSdkKeyFor,
44
51
  setActiveProfile,
45
52
  setGlobalOutputFormat,
46
53
  trySaveKeyToKeychain,
47
54
  writeMcpServerEntry
48
- } from "./chunk-NNB2NONG.js";
55
+ } from "./chunk-FJETUM25.js";
49
56
  import {
50
57
  MUSHI_CLI_VERSION,
51
58
  MUSHI_MCP_PIN_SPEC
52
- } from "./chunk-DBAEB56N.js";
59
+ } from "./chunk-UVO4A2G6.js";
60
+ import {
61
+ FRAMEWORKS,
62
+ detectFramework,
63
+ detectPackageManager,
64
+ envVarsToWrite,
65
+ installCommand,
66
+ readPackageJson
67
+ } from "./chunk-KOBEOGNF.js";
53
68
  import {
69
+ CLOUD_API_ENDPOINT,
54
70
  TEST_REPORT_FETCH_TIMEOUT_MS,
55
71
  assertEndpoint,
56
72
  normalizeEndpoint,
@@ -315,162 +331,6 @@ async function revokeProjectKey(endpoint, cliToken, projectId, keyPrefix, newKey
315
331
  );
316
332
  }
317
333
 
318
- // src/freshness.ts
319
- var REGISTRY = "https://registry.npmjs.org";
320
- var DEFAULT_TIMEOUT_MS = 2e3;
321
- async function checkFreshness(packageName, currentVersion, opts = {}) {
322
- if (!opts.ignoreOptOut && process.env.MUSHI_NO_UPDATE_CHECK === "1") return null;
323
- const registry = opts.registry ?? REGISTRY;
324
- const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
325
- const controller = new AbortController();
326
- const timer = setTimeout(() => controller.abort(), timeoutMs);
327
- try {
328
- const res = await fetch(
329
- `${registry}/${encodeURIComponent(packageName)}/latest`,
330
- {
331
- signal: controller.signal,
332
- headers: { Accept: "application/json" }
333
- }
334
- );
335
- if (!res.ok) return null;
336
- const body = await res.json();
337
- const latest = typeof body.version === "string" ? body.version : null;
338
- if (!latest) return null;
339
- return {
340
- current: currentVersion,
341
- latest,
342
- isOutdated: isNewerStableVersion(latest, currentVersion)
343
- };
344
- } catch {
345
- return null;
346
- } finally {
347
- clearTimeout(timer);
348
- }
349
- }
350
- function isNewerStableVersion(latest, current) {
351
- const latestCore = stripPreRelease(latest);
352
- if (hasPreReleaseTag(latest)) return false;
353
- const [la, lb, lc] = parse(latestCore);
354
- const [ca, cb, cc] = parse(stripPreRelease(current));
355
- if (la !== ca) return la > ca;
356
- if (lb !== cb) return lb > cb;
357
- return lc > cc;
358
- }
359
- function stripPreRelease(version) {
360
- const idx = version.search(/[-+]/);
361
- return idx === -1 ? version : version.slice(0, idx);
362
- }
363
- function hasPreReleaseTag(version) {
364
- const plus = version.indexOf("+");
365
- const hyphen = version.indexOf("-");
366
- return hyphen !== -1 && (plus === -1 || hyphen < plus);
367
- }
368
- function parse(version) {
369
- const parts = version.split(".").map((part) => Number(part));
370
- return [
371
- Number.isFinite(parts[0]) ? parts[0] : 0,
372
- Number.isFinite(parts[1]) ? parts[1] : 0,
373
- Number.isFinite(parts[2]) ? parts[2] : 0
374
- ];
375
- }
376
-
377
- // src/monorepo.ts
378
- import { existsSync, readFileSync, readdirSync, statSync } from "fs";
379
- import { dirname, join, resolve } from "path";
380
- var WORKSPACE_GLOB_CANDIDATES = ["apps/*", "packages/*", "examples/*"];
381
- var FRAMEWORK_DEPS = {
382
- next: "Next.js",
383
- nuxt: "Nuxt",
384
- "@sveltejs/kit": "SvelteKit",
385
- "@angular/core": "Angular",
386
- expo: "Expo",
387
- "react-native": "React Native",
388
- "@capacitor/core": "Capacitor",
389
- svelte: "Svelte",
390
- vue: "Vue",
391
- react: "React"
392
- };
393
- function detectWorkspaceHint(cwd) {
394
- const root = findWorkspaceRoot(cwd);
395
- if (!root) return null;
396
- const rootPkg = readPackageJsonSafely(join(root, "package.json"));
397
- if (rootPkg && getFrameworkFromPkg(rootPkg)) return null;
398
- const source = existsSync(join(root, "pnpm-workspace.yaml")) ? "pnpm-workspace" : root === cwd ? "package-json" : "parent";
399
- const apps = collectAppsFromGlobs(root);
400
- if (apps.length === 0) return null;
401
- return { root, apps, source };
402
- }
403
- function findWorkspaceRoot(start) {
404
- let dir = resolve(start);
405
- for (let i = 0; i < 8; i++) {
406
- if (isWorkspaceRoot(dir)) return dir;
407
- const parent = dirname(dir);
408
- if (parent === dir) break;
409
- dir = parent;
410
- }
411
- return null;
412
- }
413
- function isWorkspaceRoot(dir) {
414
- if (existsSync(join(dir, "pnpm-workspace.yaml"))) return true;
415
- const pkg = readPackageJsonSafely(join(dir, "package.json"));
416
- if (!pkg) return false;
417
- return Boolean(pkg.workspaces);
418
- }
419
- function collectAppsFromGlobs(root) {
420
- const results = [];
421
- for (const glob of WORKSPACE_GLOB_CANDIDATES) {
422
- const prefix = glob.replace("/*", "");
423
- const parentDir = join(root, prefix);
424
- if (!existsSync(parentDir)) continue;
425
- let entries;
426
- try {
427
- entries = readdirSync(parentDir);
428
- } catch {
429
- continue;
430
- }
431
- for (const entry of entries) {
432
- const pkgPath = join(parentDir, entry, "package.json");
433
- if (!isFileSafe(pkgPath)) continue;
434
- const pkg = readPackageJsonSafely(pkgPath);
435
- if (!pkg) continue;
436
- const framework = getFrameworkFromPkg(pkg);
437
- if (!framework) continue;
438
- results.push({
439
- name: pkg.name ?? `${prefix}/${entry}`,
440
- relativePath: `${prefix}/${entry}`,
441
- framework
442
- });
443
- }
444
- }
445
- return results;
446
- }
447
- function readPackageJsonSafely(path) {
448
- if (!isFileSafe(path)) return null;
449
- try {
450
- return JSON.parse(readFileSync(path, "utf-8"));
451
- } catch {
452
- return null;
453
- }
454
- }
455
- function isFileSafe(path) {
456
- try {
457
- return existsSync(path) && statSync(path).isFile();
458
- } catch {
459
- return false;
460
- }
461
- }
462
- function getFrameworkFromPkg(pkg) {
463
- const deps = {
464
- ...pkg.dependencies ?? {},
465
- ...pkg.devDependencies ?? {},
466
- ...pkg.peerDependencies ?? {}
467
- };
468
- for (const dep of Object.keys(FRAMEWORK_DEPS)) {
469
- if (dep in deps) return FRAMEWORK_DEPS[dep];
470
- }
471
- return void 0;
472
- }
473
-
474
334
  // src/auth-ui.ts
475
335
  var LINE = "\u2500".repeat(58);
476
336
  function printAuthBanner(userCode, verificationUri) {
@@ -518,54 +378,449 @@ function printAuthFailed(reason, detail) {
518
378
  console.log("");
519
379
  }
520
380
 
521
- // src/doctor.ts
522
- function checkCliConfig(config) {
523
- return [
524
- {
525
- name: "CLI config file",
526
- ok: Boolean(config.endpoint),
527
- detail: config.endpoint ? `endpoint=${config.endpoint}` : "No endpoint \u2014 set MUSHI_API_ENDPOINT, run `mushi connect`, or `mushi config endpoint <url>`"
528
- },
529
- {
530
- name: "API key configured",
531
- ok: Boolean(config.apiKey),
532
- detail: config.apiKey ? `apiKey=${config.apiKey.slice(0, 8)}\u2026${config.apiKey.slice(-4)}` : "No API key set \u2014 run `mushi login --api-key <key>`"
533
- },
534
- {
535
- name: "Project ID configured",
536
- ok: Boolean(config.projectId),
537
- detail: config.projectId ? `projectId=${config.projectId}` : "No default project \u2014 set via `mushi config projectId <uuid>`"
381
+ // src/login.ts
382
+ function getPostLoginBannerMessage(opts) {
383
+ if (opts.suppressPostLoginBanner) return null;
384
+ if (opts.upgradeScope) {
385
+ return " Key upgraded! Re-run 'mushi setup' to update your IDE's MCP config, then restart the IDE.";
386
+ }
387
+ return " Run 'mushi init' to set up the SDK in this project.";
388
+ }
389
+ function printPostLoginBanner(opts) {
390
+ const message = getPostLoginBannerMessage(opts);
391
+ if (message) console.log(message);
392
+ }
393
+ var MAX_CONSECUTIVE_ERRORS = 5;
394
+ async function pollUntilApproved(endpoint, deviceData, deps) {
395
+ const sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
396
+ const now = deps.now ?? Date.now;
397
+ let pollIntervalMs = (deviceData.interval ?? 5) * 1e3;
398
+ const deadline = now() + (deviceData.expires_in ?? 600) * 1e3;
399
+ let consecutiveErrors = 0;
400
+ let firstPoll = true;
401
+ while (now() < deadline) {
402
+ if (!firstPoll) {
403
+ await sleep(pollIntervalMs);
538
404
  }
539
- ];
405
+ firstPoll = false;
406
+ const outcome = await deps.poll(endpoint, deviceData.device_code);
407
+ switch (outcome.status) {
408
+ case "approved":
409
+ return { status: "approved", cliToken: outcome.cliToken };
410
+ case "pending":
411
+ consecutiveErrors = 0;
412
+ continue;
413
+ case "slow_down":
414
+ consecutiveErrors = 0;
415
+ pollIntervalMs = Math.max(pollIntervalMs, outcome.retryAfterMs);
416
+ continue;
417
+ case "error":
418
+ if (outcome.retryable) {
419
+ consecutiveErrors += 1;
420
+ if (consecutiveErrors < MAX_CONSECUTIVE_ERRORS) continue;
421
+ }
422
+ return { status: "error", message: outcome.message };
423
+ case "denied":
424
+ return { status: "denied" };
425
+ case "expired":
426
+ return { status: "timeout" };
427
+ }
428
+ }
429
+ return { status: "timeout" };
540
430
  }
541
- async function checkEndpointReachability(endpoint, doFetch = globalThis.fetch) {
542
- try {
543
- const safeEndpoint = sanitizeEndpoint(endpoint);
544
- const res = await doFetch(`${safeEndpoint}/health`, {
545
- signal: AbortSignal.timeout(5e3)
431
+ async function resolveProjectChoice(projectsList, ask) {
432
+ if (projectsList.length > 0) {
433
+ console.log("");
434
+ console.log(" Your projects:");
435
+ projectsList.forEach((p3, i) => {
436
+ console.log(` ${i + 1}. ${p3.name} (${p3.id})`);
546
437
  });
547
- return {
548
- name: "Endpoint reachable",
549
- ok: res.status === 200,
550
- detail: `GET ${safeEndpoint}/health \u2192 ${res.status}`
551
- };
552
- } catch (err) {
553
- const msg = err instanceof Error ? err.message : String(err);
554
- return { name: "Endpoint reachable", ok: false, detail: `Fetch failed: ${msg}` };
438
+ console.log(` ${projectsList.length + 1}. Create a new project`);
439
+ console.log("");
440
+ const choice = await ask(` Pick a project [1-${projectsList.length + 1}]: `);
441
+ const num = parseInt(choice, 10);
442
+ if (num >= 1 && num <= projectsList.length) {
443
+ const picked = projectsList[num - 1];
444
+ return { kind: "picked", id: picked.id, name: picked.name };
445
+ }
555
446
  }
556
- }
557
- async function checkCliAuthPath(config, doFetch = globalThis.fetch) {
558
- const checks = [];
559
- if (!config.endpoint) {
560
- return [
447
+ console.log("");
448
+ const newName = (await ask(" Project name: ")).trim();
449
+ if (!newName) return { kind: "empty_name" };
450
+ return { kind: "create", name: newName };
451
+ }
452
+ function slugify(value) {
453
+ return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
454
+ }
455
+ function defaultProjectName(packageName, cwd) {
456
+ const fromPkg = packageName?.replace(/^@[^/]+\//, "").trim();
457
+ if (fromPkg) return fromPkg;
458
+ const parts = cwd.split(/[\\/]/).filter(Boolean);
459
+ return parts[parts.length - 1] || "my-app";
460
+ }
461
+ function chooseProjectNonInteractive(projects, preferredName) {
462
+ const wanted = slugify(preferredName);
463
+ const match = projects.find((p3) => slugify(p3.name) === wanted || p3.slug !== void 0 && slugify(p3.slug) === wanted);
464
+ return match ? { kind: "existing", id: match.id, name: match.name } : { kind: "new", name: preferredName };
465
+ }
466
+ async function runLogin(opts = {}) {
467
+ const { CLOUD_API_ENDPOINT: CLOUD_API_ENDPOINT2, resolveCloudEndpoint: resolveCloudEndpoint2 } = await import("./endpoint-TEBF4IH6.js");
468
+ const endpoint = opts.endpoint ? resolveCloudEndpoint2(opts.endpoint) : CLOUD_API_ENDPOINT2;
469
+ const consoleBase = await resolveConsoleUrl();
470
+ let apiKey = opts.apiKey;
471
+ let projectId = opts.projectId;
472
+ if (apiKey) {
473
+ const config2 = loadConfig();
474
+ config2.apiKey = apiKey;
475
+ config2.endpoint = endpoint;
476
+ if (projectId) config2.projectId = projectId;
477
+ config2.consoleUrl = consoleBase;
478
+ saveConfig(config2);
479
+ console.log("");
480
+ console.log(" \u2713 Credentials saved.");
481
+ const verifyResult = await apiCall("/v1/sync/whoami", { apiKey, endpoint, projectId });
482
+ if (!verifyResult.ok) {
483
+ console.warn(` \u26A0 Key saved, but verification failed: ${verifyResult.error?.message ?? "unknown error"}`);
484
+ console.warn(` Check your key at: ${cliSetupDeepLink(consoleBase)}`);
485
+ return;
486
+ }
487
+ const d = verifyResult.data;
488
+ if (d.project_name) {
489
+ if (!projectId && d.project_id) {
490
+ config2.projectId = d.project_id;
491
+ saveConfig(config2);
492
+ }
493
+ console.log(` \u2713 Project: ${d.project_name} (${d.project_id})`);
494
+ }
495
+ console.log(` \u2713 Endpoint: ${endpoint}`);
496
+ console.log("");
497
+ if (!opts.suppressPostLoginBanner) {
498
+ console.log(" Run 'mushi whoami' to verify \xB7 'mushi init' to set up the SDK");
499
+ }
500
+ return;
501
+ }
502
+ if (!opts.suppressPostLoginBanner) {
503
+ console.log("");
504
+ }
505
+ if (opts.upgradeScope) {
506
+ console.log(" Mushi login \u2014 scope upgrade");
507
+ 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");
508
+ console.log(" Re-authenticating to mint a new key with report:write + mcp:read + mcp:write scopes.");
509
+ console.log(" This lets the CLI admin commands (including writes) and MCP server both work with a single key.");
510
+ console.log("");
511
+ } else if (!opts.suppressPostLoginBanner) {
512
+ console.log(" Mushi login");
513
+ console.log(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
514
+ }
515
+ let deviceData;
516
+ try {
517
+ deviceData = await startDeviceAuth(endpoint, ensureClientId());
518
+ } catch (err) {
519
+ process.stderr.write(`
520
+ error: Could not start login session: ${err instanceof Error ? err.message : String(err)}
521
+ `);
522
+ process.stderr.write(` Fallback: mushi login --api-key <key> --project-id <uuid>
523
+ `);
524
+ process.exit(1);
525
+ }
526
+ const verifyUrl = deviceData.verification_uri;
527
+ if (opts.browser !== false) {
528
+ try {
529
+ await openInBrowser(verifyUrl);
530
+ } catch {
531
+ }
532
+ }
533
+ printAuthBanner(deviceData.user_code, verifyUrl);
534
+ const pollResult = await pollUntilApproved(endpoint, deviceData, {
535
+ poll: pollDeviceToken
536
+ });
537
+ if (pollResult.status !== "approved") {
538
+ if (pollResult.status === "denied") printAuthFailed("denied");
539
+ else if (pollResult.status === "error") printAuthFailed("error", pollResult.message);
540
+ else printAuthFailed("timeout");
541
+ process.exit(1);
542
+ }
543
+ const cliToken = pollResult.cliToken;
544
+ printAuthApproved();
545
+ const projectsList = await listProjects(endpoint, cliToken);
546
+ let chosenProjectId = projectId;
547
+ let chosenProjectName;
548
+ const mintScopes = opts.upgradeScope ? FULL_CLI_KEY_SCOPES : CLI_KEY_SCOPES;
549
+ if (!chosenProjectId) {
550
+ let choice;
551
+ if (process.stdin.isTTY) {
552
+ const { createInterface } = await import("readline");
553
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
554
+ const ask = (q) => new Promise((resolve3) => rl.question(q, (a) => resolve3(a.trim())));
555
+ choice = await resolveProjectChoice(projectsList, ask);
556
+ rl.close();
557
+ } else {
558
+ const { readPackageJson: readPackageJson2 } = await import("./detect-4PYRZXVK.js");
559
+ const cwd = process.cwd();
560
+ const nonInteractive = chooseProjectNonInteractive(
561
+ projectsList,
562
+ defaultProjectName(readPackageJson2(cwd)?.name, cwd)
563
+ );
564
+ choice = nonInteractive.kind === "existing" ? { kind: "picked", id: nonInteractive.id, name: nonInteractive.name } : { kind: "create", name: nonInteractive.name };
565
+ console.log(
566
+ choice.kind === "picked" ? ` Using project "${choice.name}" (matches this folder \u2014 pass --project-id to pick another).` : ` Creating project "${choice.name}" (no project matches this folder \u2014 pass --project-id to use an existing one).`
567
+ );
568
+ }
569
+ if (choice.kind === "empty_name") {
570
+ process.stderr.write("\nerror: Project name is required.\n");
571
+ process.exit(2);
572
+ }
573
+ if (choice.kind === "picked") {
574
+ chosenProjectId = choice.id;
575
+ chosenProjectName = choice.name;
576
+ } else {
577
+ try {
578
+ const created = await createProject(endpoint, cliToken, choice.name, { scopes: mintScopes });
579
+ chosenProjectId = created.id;
580
+ chosenProjectName = created.name;
581
+ apiKey = created.apiKey ?? void 0;
582
+ console.log(` \u2713 Created project "${chosenProjectName}"`);
583
+ } catch (err) {
584
+ process.stderr.write(`
585
+ error: Could not create project: ${err instanceof Error ? err.message : String(err)}
586
+ `);
587
+ process.exit(1);
588
+ }
589
+ }
590
+ }
591
+ if (!apiKey && chosenProjectId) {
592
+ try {
593
+ apiKey = await mintProjectKey(endpoint, cliToken, chosenProjectId, { scopes: mintScopes }) ?? void 0;
594
+ } catch {
595
+ }
596
+ }
597
+ const config = loadConfig();
598
+ config.endpoint = endpoint;
599
+ config.consoleUrl = consoleBase;
600
+ if (chosenProjectId) config.projectId = chosenProjectId;
601
+ if (apiKey) config.apiKey = apiKey;
602
+ saveConfig(config);
603
+ if (apiKey) {
604
+ const profile = resolveProfileName(void 0, config.activeProfile);
605
+ trySaveKeyToKeychain(apiKey, profile);
606
+ }
607
+ console.log("");
608
+ if (chosenProjectName) console.log(` \u2713 Project: ${chosenProjectName}`);
609
+ if (apiKey) {
610
+ if (opts.upgradeScope) {
611
+ console.log(` \u2713 Full-scope CLI key saved (${apiKey.slice(0, 12)}\u2026)`);
612
+ console.log(` Scopes: report:write \xB7 mcp:read \xB7 mcp:write \u2014 SDK + MCP + admin CLI (incl. writes) all work with this key`);
613
+ } else {
614
+ console.log(` \u2713 CLI key saved (${apiKey.slice(0, 12)}\u2026)`);
615
+ console.log(` Scopes: report:write \xB7 mcp:read \u2014 SDK ingest + MCP read tools.`);
616
+ console.log(` Admin writes (billing cap, fix merge, pipeline start) need: mushi login --upgrade-scope`);
617
+ }
618
+ } else {
619
+ console.log(` \u2139 Get an SDK key at: ${apiKeyHint(consoleBase)}`);
620
+ }
621
+ console.log("");
622
+ printPostLoginBanner(opts);
623
+ }
624
+
625
+ // src/freshness.ts
626
+ var REGISTRY = "https://registry.npmjs.org";
627
+ var DEFAULT_TIMEOUT_MS = 2e3;
628
+ async function checkFreshness(packageName, currentVersion, opts = {}) {
629
+ if (!opts.ignoreOptOut && process.env.MUSHI_NO_UPDATE_CHECK === "1") return null;
630
+ const registry = opts.registry ?? REGISTRY;
631
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
632
+ const controller = new AbortController();
633
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
634
+ try {
635
+ const res = await fetch(
636
+ `${registry}/${encodeURIComponent(packageName)}/latest`,
561
637
  {
562
- name: "Sign-in route reachable",
563
- ok: false,
564
- detail: "No endpoint configured \u2014 run `npx mushi-mushi` or pass MUSHI_API_ENDPOINT."
638
+ signal: controller.signal,
639
+ headers: { Accept: "application/json" }
565
640
  }
566
- ];
641
+ );
642
+ if (!res.ok) return null;
643
+ const body = await res.json();
644
+ const latest = typeof body.version === "string" ? body.version : null;
645
+ if (!latest) return null;
646
+ return {
647
+ current: currentVersion,
648
+ latest,
649
+ isOutdated: isNewerStableVersion(latest, currentVersion)
650
+ };
651
+ } catch {
652
+ return null;
653
+ } finally {
654
+ clearTimeout(timer);
655
+ }
656
+ }
657
+ function isNewerStableVersion(latest, current) {
658
+ const latestCore = stripPreRelease(latest);
659
+ if (hasPreReleaseTag(latest)) return false;
660
+ const [la, lb, lc] = parse(latestCore);
661
+ const [ca, cb, cc] = parse(stripPreRelease(current));
662
+ if (la !== ca) return la > ca;
663
+ if (lb !== cb) return lb > cb;
664
+ return lc > cc;
665
+ }
666
+ function stripPreRelease(version) {
667
+ const idx = version.search(/[-+]/);
668
+ return idx === -1 ? version : version.slice(0, idx);
669
+ }
670
+ function hasPreReleaseTag(version) {
671
+ const plus = version.indexOf("+");
672
+ const hyphen = version.indexOf("-");
673
+ return hyphen !== -1 && (plus === -1 || hyphen < plus);
674
+ }
675
+ function parse(version) {
676
+ const parts = version.split(".").map((part) => Number(part));
677
+ return [
678
+ Number.isFinite(parts[0]) ? parts[0] : 0,
679
+ Number.isFinite(parts[1]) ? parts[1] : 0,
680
+ Number.isFinite(parts[2]) ? parts[2] : 0
681
+ ];
682
+ }
683
+
684
+ // src/monorepo.ts
685
+ import { existsSync, readFileSync, readdirSync, statSync } from "fs";
686
+ import { dirname, join, resolve } from "path";
687
+ var WORKSPACE_GLOB_CANDIDATES = ["apps/*", "packages/*", "examples/*"];
688
+ var FRAMEWORK_DEPS = {
689
+ next: "Next.js",
690
+ nuxt: "Nuxt",
691
+ "@sveltejs/kit": "SvelteKit",
692
+ "@angular/core": "Angular",
693
+ expo: "Expo",
694
+ "react-native": "React Native",
695
+ "@capacitor/core": "Capacitor",
696
+ svelte: "Svelte",
697
+ vue: "Vue",
698
+ react: "React"
699
+ };
700
+ function detectWorkspaceHint(cwd) {
701
+ const root = findWorkspaceRoot(cwd);
702
+ if (!root) return null;
703
+ const rootPkg = readPackageJsonSafely(join(root, "package.json"));
704
+ if (rootPkg && getFrameworkFromPkg(rootPkg)) return null;
705
+ const source = existsSync(join(root, "pnpm-workspace.yaml")) ? "pnpm-workspace" : root === cwd ? "package-json" : "parent";
706
+ const apps = collectAppsFromGlobs(root);
707
+ if (apps.length === 0) return null;
708
+ return { root, apps, source };
709
+ }
710
+ function findWorkspaceRoot(start) {
711
+ let dir = resolve(start);
712
+ for (let i = 0; i < 8; i++) {
713
+ if (isWorkspaceRoot(dir)) return dir;
714
+ const parent = dirname(dir);
715
+ if (parent === dir) break;
716
+ dir = parent;
567
717
  }
568
- const base = sanitizeEndpoint(config.endpoint);
718
+ return null;
719
+ }
720
+ function isWorkspaceRoot(dir) {
721
+ if (existsSync(join(dir, "pnpm-workspace.yaml"))) return true;
722
+ const pkg = readPackageJsonSafely(join(dir, "package.json"));
723
+ if (!pkg) return false;
724
+ return Boolean(pkg.workspaces);
725
+ }
726
+ function collectAppsFromGlobs(root) {
727
+ const results = [];
728
+ for (const glob of WORKSPACE_GLOB_CANDIDATES) {
729
+ const prefix = glob.replace("/*", "");
730
+ const parentDir = join(root, prefix);
731
+ if (!existsSync(parentDir)) continue;
732
+ let entries;
733
+ try {
734
+ entries = readdirSync(parentDir);
735
+ } catch {
736
+ continue;
737
+ }
738
+ for (const entry of entries) {
739
+ const pkgPath = join(parentDir, entry, "package.json");
740
+ if (!isFileSafe(pkgPath)) continue;
741
+ const pkg = readPackageJsonSafely(pkgPath);
742
+ if (!pkg) continue;
743
+ const framework = getFrameworkFromPkg(pkg);
744
+ if (!framework) continue;
745
+ results.push({
746
+ name: pkg.name ?? `${prefix}/${entry}`,
747
+ relativePath: `${prefix}/${entry}`,
748
+ framework
749
+ });
750
+ }
751
+ }
752
+ return results;
753
+ }
754
+ function readPackageJsonSafely(path) {
755
+ if (!isFileSafe(path)) return null;
756
+ try {
757
+ return JSON.parse(readFileSync(path, "utf-8"));
758
+ } catch {
759
+ return null;
760
+ }
761
+ }
762
+ function isFileSafe(path) {
763
+ try {
764
+ return existsSync(path) && statSync(path).isFile();
765
+ } catch {
766
+ return false;
767
+ }
768
+ }
769
+ function getFrameworkFromPkg(pkg) {
770
+ const deps = {
771
+ ...pkg.dependencies ?? {},
772
+ ...pkg.devDependencies ?? {},
773
+ ...pkg.peerDependencies ?? {}
774
+ };
775
+ for (const dep of Object.keys(FRAMEWORK_DEPS)) {
776
+ if (dep in deps) return FRAMEWORK_DEPS[dep];
777
+ }
778
+ return void 0;
779
+ }
780
+
781
+ // src/doctor.ts
782
+ var SIGN_IN_HINT = "Run `npx mushi-mushi` in your app folder \u2014 browser sign-in saves the API key, project and endpoint.";
783
+ function checkCliConfig(config) {
784
+ return [
785
+ {
786
+ // No saved endpoint is not a failure: every command falls back to Mushi
787
+ // Cloud (endpoint.ts), so reporting "No endpoint" first sent people
788
+ // hunting for a URL they never needed.
789
+ name: "CLI config file",
790
+ ok: true,
791
+ detail: config.endpoint ? `endpoint=${config.endpoint}` : `endpoint=${CLOUD_API_ENDPOINT} (Mushi Cloud default \u2014 set MUSHI_API_ENDPOINT for a self-hosted backend)`
792
+ },
793
+ {
794
+ name: "API key configured",
795
+ ok: Boolean(config.apiKey),
796
+ detail: config.apiKey ? `apiKey=${config.apiKey.slice(0, 8)}\u2026${config.apiKey.slice(-4)}` : "No API key saved."
797
+ },
798
+ {
799
+ name: "Project ID configured",
800
+ ok: Boolean(config.projectId),
801
+ detail: config.projectId ? `projectId=${config.projectId}` : "No default project saved."
802
+ }
803
+ ];
804
+ }
805
+ async function checkEndpointReachability(endpoint, doFetch = globalThis.fetch) {
806
+ try {
807
+ const safeEndpoint = sanitizeEndpoint(endpoint);
808
+ const res = await doFetch(`${safeEndpoint}/health`, {
809
+ signal: AbortSignal.timeout(5e3)
810
+ });
811
+ return {
812
+ name: "Endpoint reachable",
813
+ ok: res.status === 200,
814
+ detail: `GET ${safeEndpoint}/health \u2192 ${res.status}`
815
+ };
816
+ } catch (err) {
817
+ const msg = err instanceof Error ? err.message : String(err);
818
+ return { name: "Endpoint reachable", ok: false, detail: `Fetch failed: ${msg}` };
819
+ }
820
+ }
821
+ async function checkCliAuthPath(config, doFetch = globalThis.fetch) {
822
+ const checks = [];
823
+ const base = sanitizeEndpoint(config.endpoint ?? CLOUD_API_ENDPOINT);
569
824
  let dateHeader = null;
570
825
  try {
571
826
  const res = await doFetch(`${base}/v1/cli/auth/device/token`, {
@@ -639,22 +894,27 @@ async function checkCliAuthPath(config, doFetch = globalThis.fetch) {
639
894
  async function checkSdkInstall(cwd) {
640
895
  try {
641
896
  const { readFile: readFile3 } = await import("fs/promises");
642
- const { join: join4, resolve: resolve3 } = await import("path");
897
+ const { join: join5, resolve: resolve3 } = await import("path");
643
898
  const root = resolve3(cwd);
644
- const pkgPath = join4(root, "package.json");
899
+ const pkgPath = join5(root, "package.json");
645
900
  const pkg = JSON.parse(await readFile3(pkgPath, "utf8"));
646
901
  const deps = { ...pkg.dependencies ?? {}, ...pkg.devDependencies ?? {} };
647
902
  const sdks = [
648
903
  "@mushi-mushi/react",
649
904
  "@mushi-mushi/web",
650
905
  "@mushi-mushi/core",
651
- "@mushi-mushi/react-native"
906
+ "@mushi-mushi/react-native",
907
+ "@mushi-mushi/vue",
908
+ "@mushi-mushi/svelte",
909
+ "@mushi-mushi/angular",
910
+ "@mushi-mushi/capacitor",
911
+ "@mushi-mushi/node"
652
912
  ];
653
913
  const installed = sdks.filter((s) => deps[s]);
654
914
  return {
655
915
  name: "SDK installed in this repo",
656
916
  ok: installed.length > 0,
657
- detail: installed.length > 0 ? installed.map((s) => `${s}@${deps[s]}`).join(", ") : "No @mushi-mushi/* package in package.json \u2014 run `mushi init` to install"
917
+ detail: installed.length > 0 ? installed.map((s) => `${s}@${deps[s]}`).join(", ") : "No @mushi-mushi/* package in package.json \u2014 run `npx mushi-mushi` to install"
658
918
  };
659
919
  } catch {
660
920
  return null;
@@ -846,9 +1106,9 @@ async function checkHostAppWiring(cwd) {
846
1106
  const checks = [];
847
1107
  try {
848
1108
  const { readFile: readFile3, access } = await import("fs/promises");
849
- const { join: join4, resolve: resolve3 } = await import("path");
1109
+ const { join: join5, resolve: resolve3 } = await import("path");
850
1110
  const root = resolve3(cwd);
851
- const pkgPath = join4(root, "package.json");
1111
+ const pkgPath = join5(root, "package.json");
852
1112
  const pkg = JSON.parse(await readFile3(pkgPath, "utf8"));
853
1113
  const deps = { ...pkg.dependencies ?? {}, ...pkg.devDependencies ?? {} };
854
1114
  const isCapHybrid = Boolean(deps["@capacitor/core"] && deps["react"]);
@@ -856,7 +1116,7 @@ async function checkHostAppWiring(cwd) {
856
1116
  let envContent = "";
857
1117
  for (const f of envCandidates) {
858
1118
  try {
859
- envContent = await readFile3(join4(root, f), "utf8");
1119
+ envContent = await readFile3(join5(root, f), "utf8");
860
1120
  break;
861
1121
  } catch {
862
1122
  }
@@ -870,16 +1130,19 @@ async function checkHostAppWiring(cwd) {
870
1130
  ok: hasProjectId && hasApiKey,
871
1131
  detail: hasProjectId && hasApiKey ? "VITE_/MUSHI_ project id + API key found" : "Run `mushi connect --write-env` or add VITE_MUSHI_PROJECT_ID + VITE_MUSHI_API_KEY"
872
1132
  });
873
- let mcpPresent = false;
874
- try {
875
- await access(join4(root, ".cursor", "mcp.json"));
876
- mcpPresent = true;
877
- } catch {
1133
+ let mcpFound = null;
1134
+ for (const rel of [".mcp.json", join5(".cursor", "mcp.json")]) {
1135
+ try {
1136
+ await access(join5(root, rel));
1137
+ mcpFound = rel;
1138
+ break;
1139
+ } catch {
1140
+ }
878
1141
  }
879
1142
  checks.push({
880
- name: "[host] Cursor MCP config",
881
- ok: mcpPresent,
882
- detail: mcpPresent ? ".cursor/mcp.json present" : "Run `mushi connect` to wire MCP for two-way reporter replies"
1143
+ name: "[host] MCP config",
1144
+ ok: mcpFound !== null,
1145
+ detail: mcpFound ? `${mcpFound} present` : "No .mcp.json (Claude Code) or .cursor/mcp.json (Cursor) \u2014 run `npx mushi-mushi setup --ide claude` or `--ide cursor` for two-way reporter replies"
883
1146
  });
884
1147
  if (isCapHybrid) {
885
1148
  const hasWebSdk = Boolean(deps["@mushi-mushi/web"] || deps["@mushi-mushi/react"]);
@@ -912,7 +1175,7 @@ var HASH_ROUTER_PATTERNS = [
912
1175
  var HASH_TEMPLATE_PATTERN = /['"`]#\//;
913
1176
  async function scanDirForPattern(dir, patterns, maxDepth = 6, maxFiles = 2e3) {
914
1177
  const { readdir: readdir2, readFile: readFile3 } = await import("fs/promises");
915
- const { join: join4 } = await import("path");
1178
+ const { join: join5 } = await import("path");
916
1179
  let count = 0;
917
1180
  async function walk(d, depth) {
918
1181
  if (depth > maxDepth || count > maxFiles) return null;
@@ -924,7 +1187,7 @@ async function scanDirForPattern(dir, patterns, maxDepth = 6, maxFiles = 2e3) {
924
1187
  }
925
1188
  for (const e of entries) {
926
1189
  if (e.name.startsWith(".") || e.name === "node_modules" || e.name === "dist") continue;
927
- const full = join4(d, e.name);
1190
+ const full = join5(d, e.name);
928
1191
  if (e.isDirectory()) {
929
1192
  const found = await walk(full, depth + 1);
930
1193
  if (found) return found;
@@ -941,7 +1204,7 @@ async function scanDirForPattern(dir, patterns, maxDepth = 6, maxFiles = 2e3) {
941
1204
  }
942
1205
  const { resolve: resolve3 } = await import("path");
943
1206
  for (const sub of ["src", "app", "pages", "lib", "components"]) {
944
- const found = await walk(join4(resolve3(dir), sub), 0);
1207
+ const found = await walk(join5(resolve3(dir), sub), 0);
945
1208
  if (found) return found;
946
1209
  }
947
1210
  return walk(resolve3(dir), 0);
@@ -968,95 +1231,140 @@ async function checkHashRouterCapture(cwd) {
968
1231
  }
969
1232
  ];
970
1233
  }
971
- async function checkMcpConfig(config, cwd, doFetch = globalThis.fetch) {
972
- const checks = [];
1234
+ function isJsonObject(value) {
1235
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1236
+ }
1237
+ function normalizeProjectPath(path) {
1238
+ const slashed = path.replace(/\\/g, "/").replace(/\/+$/, "");
1239
+ return process.platform === "win32" ? slashed.toLowerCase() : slashed;
1240
+ }
1241
+ async function collectMcpSources(root, home) {
973
1242
  const { readFile: readFile3 } = await import("fs/promises");
974
- const { join: join4, resolve: resolve3 } = await import("path");
975
- const { homedir } = await import("os");
976
- const root = resolve3(cwd);
977
- const candidates = [join4(root, ".cursor", "mcp.json"), join4(homedir(), ".cursor", "mcp.json")];
978
- let mcpPath = null;
979
- let mcpRaw = null;
980
- for (const candidate of candidates) {
1243
+ const { join: join5 } = await import("path");
1244
+ const sources = [];
1245
+ const invalid = [];
1246
+ const plainFiles = [
1247
+ { path: join5(root, ".mcp.json"), claudeCode: true },
1248
+ { path: join5(root, ".cursor", "mcp.json"), claudeCode: false },
1249
+ { path: join5(home, ".cursor", "mcp.json"), claudeCode: false }
1250
+ ];
1251
+ for (const file of plainFiles) {
1252
+ let raw;
981
1253
  try {
982
- const raw = await readFile3(candidate, "utf8");
983
- let parsed = {};
984
- try {
985
- parsed = JSON.parse(raw);
986
- } catch {
987
- }
988
- const hasEntries = Object.keys(parsed.mcpServers ?? {}).length > 0;
989
- if (!hasEntries && candidates.indexOf(candidate) < candidates.length - 1) {
990
- continue;
991
- }
992
- mcpRaw = raw;
993
- mcpPath = candidate;
994
- break;
1254
+ raw = await readFile3(file.path, "utf8");
995
1255
  } catch {
1256
+ continue;
1257
+ }
1258
+ try {
1259
+ const parsed = JSON.parse(raw);
1260
+ const servers = isJsonObject(parsed) && isJsonObject(parsed.mcpServers) ? parsed.mcpServers : {};
1261
+ sources.push({ path: file.path, servers, claudeCode: file.claudeCode });
1262
+ } catch {
1263
+ invalid.push(file.path);
996
1264
  }
997
1265
  }
998
- if (!mcpPath || !mcpRaw) {
1266
+ const claudeJsonPath = join5(home, ".claude.json");
1267
+ try {
1268
+ const parsed = JSON.parse(await readFile3(claudeJsonPath, "utf8"));
1269
+ if (isJsonObject(parsed)) {
1270
+ const servers = isJsonObject(parsed.mcpServers) ? { ...parsed.mcpServers } : {};
1271
+ const projects = isJsonObject(parsed.projects) ? parsed.projects : {};
1272
+ const wanted = normalizeProjectPath(root);
1273
+ for (const [projectPath, entry] of Object.entries(projects)) {
1274
+ if (normalizeProjectPath(projectPath) === wanted && isJsonObject(entry) && isJsonObject(entry.mcpServers)) {
1275
+ Object.assign(servers, entry.mcpServers);
1276
+ }
1277
+ }
1278
+ sources.push({ path: claudeJsonPath, servers, claudeCode: true });
1279
+ }
1280
+ } catch {
1281
+ }
1282
+ return { sources, invalid };
1283
+ }
1284
+ function assessMushiEntry(source, name, entry, cliKeySaved) {
1285
+ const label = `${name} (${source.path})`;
1286
+ if (!isJsonObject(entry)) return { usable: false, stdio: false, detail: `${label}: not an object` };
1287
+ if (typeof entry.url === "string") {
1288
+ if (source.claudeCode && entry.type !== "http" && entry.type !== "sse") {
1289
+ return {
1290
+ usable: false,
1291
+ stdio: false,
1292
+ detail: `${label}: has a url but no "type": "http" \u2014 Claude Code skips it`
1293
+ };
1294
+ }
1295
+ return { usable: true, stdio: false, detail: `${label}: hosted OAuth \u2014 sign in from the IDE's MCP panel` };
1296
+ }
1297
+ const env = isJsonObject(entry.env) ? entry.env : {};
1298
+ const key = env.MUSHI_API_KEY;
1299
+ const endpoint = env.MUSHI_API_ENDPOINT;
1300
+ if (typeof endpoint === "string" && !/^https?:\/\//.test(endpoint) && !/^\$\{.+\}$/.test(endpoint)) {
1301
+ return { usable: false, stdio: true, detail: `${label}: MUSHI_API_ENDPOINT is not a URL` };
1302
+ }
1303
+ if (typeof key === "string" && key.startsWith("mushi_")) {
1304
+ return { usable: true, stdio: true, detail: `${label}: key written inline` };
1305
+ }
1306
+ if (key === void 0 || typeof key === "string" && /^\$\{.*\}$/.test(key)) {
1307
+ return cliKeySaved ? { usable: true, stdio: true, detail: `${label}: key from MUSHI_API_KEY or the saved CLI config` } : {
1308
+ usable: false,
1309
+ stdio: true,
1310
+ detail: `${label}: reads the key from MUSHI_API_KEY or the CLI config, and neither is set`
1311
+ };
1312
+ }
1313
+ return { usable: false, stdio: true, detail: `${label}: MUSHI_API_KEY is neither a mushi_ key nor a placeholder` };
1314
+ }
1315
+ async function checkMcpConfig(config, cwd, doFetch = globalThis.fetch, home) {
1316
+ const checks = [];
1317
+ const { resolve: resolve3 } = await import("path");
1318
+ const { homedir } = await import("os");
1319
+ const root = resolve3(cwd);
1320
+ const { sources, invalid } = await collectMcpSources(root, home ?? homedir());
1321
+ if (invalid.length > 0) {
999
1322
  checks.push({
1000
- name: "[mcp] mcp.json present",
1323
+ name: "[mcp] MCP config valid JSON",
1001
1324
  ok: false,
1002
- detail: "No .cursor/mcp.json found in cwd or ~/.cursor/. Run `mushi setup` to create it."
1325
+ detail: `Not valid JSON: ${invalid.join(", ")}`
1003
1326
  });
1327
+ }
1328
+ if (sources.length === 0) {
1329
+ if (invalid.length === 0) {
1330
+ checks.push({
1331
+ name: "[mcp] MCP config present",
1332
+ ok: false,
1333
+ detail: "No .mcp.json, .cursor/mcp.json, ~/.cursor/mcp.json or ~/.claude.json found."
1334
+ });
1335
+ }
1004
1336
  return checks;
1005
1337
  }
1006
1338
  checks.push({
1007
- name: "[mcp] mcp.json present",
1339
+ name: "[mcp] MCP config present",
1008
1340
  ok: true,
1009
- detail: `Found at ${mcpPath}`
1341
+ detail: `Found: ${sources.map((s) => s.path).join(", ")}`
1010
1342
  });
1011
- let mcpConfig = {};
1012
- try {
1013
- mcpConfig = JSON.parse(mcpRaw);
1014
- } catch {
1015
- checks.push({
1016
- name: "[mcp] mcp.json valid JSON",
1017
- ok: false,
1018
- detail: "mcp.json is not valid JSON \u2014 regenerate with `mushi setup`."
1019
- });
1020
- return checks;
1021
- }
1022
- const servers = mcpConfig.mcpServers ?? {};
1023
- const mushiEntries = Object.entries(servers).filter(
1024
- ([k]) => k === "mushi" || k.startsWith("mushi-")
1343
+ const mushiEntries = sources.flatMap(
1344
+ (source) => Object.entries(source.servers).filter(([name]) => name === "mushi" || name.startsWith("mushi-")).map(([name, entry]) => ({ source, name, entry }))
1025
1345
  );
1026
1346
  if (mushiEntries.length === 0) {
1027
1347
  checks.push({
1028
1348
  name: "[mcp] mushi server entry",
1029
1349
  ok: false,
1030
- detail: "No mushi or mushi-* server found in mcpServers. Run `mushi setup` to add one."
1350
+ detail: "No mushi or mushi-* server in any MCP config."
1031
1351
  });
1032
1352
  return checks;
1033
1353
  }
1034
1354
  checks.push({
1035
1355
  name: "[mcp] mushi server entry",
1036
1356
  ok: true,
1037
- detail: `Found: ${mushiEntries.map(([k]) => k).join(", ")}`
1038
- });
1039
- let anyKeyValid = false;
1040
- let anyEndpointSet = false;
1041
- for (const [, srv] of mushiEntries) {
1042
- const s = srv;
1043
- const env = s.env ?? {};
1044
- const key = env["MUSHI_API_KEY"] ?? "";
1045
- const endpoint = env["MUSHI_API_ENDPOINT"] ?? "";
1046
- if (key.startsWith("mushi_")) anyKeyValid = true;
1047
- if (endpoint.includes("supabase.co") || endpoint.includes("localhost")) anyEndpointSet = true;
1048
- }
1049
- checks.push({
1050
- name: "[mcp] MUSHI_API_KEY set",
1051
- ok: anyKeyValid,
1052
- detail: anyKeyValid ? "At least one mushi server has a valid mushi_* API key" : "No mushi_* API key found in any mushi server env. Re-run `mushi setup` to regenerate."
1357
+ detail: `Found: ${mushiEntries.map(({ name }) => name).join(", ")}`
1053
1358
  });
1359
+ const assessed = mushiEntries.map(
1360
+ ({ source, name, entry }) => assessMushiEntry(source, name, entry, Boolean(config.apiKey))
1361
+ );
1054
1362
  checks.push({
1055
- name: "[mcp] MUSHI_API_ENDPOINT set",
1056
- ok: anyEndpointSet,
1057
- detail: anyEndpointSet ? "MUSHI_API_ENDPOINT is present and looks valid" : "MUSHI_API_ENDPOINT missing or not a Supabase URL. Re-run `mushi setup`."
1363
+ name: "[mcp] mushi entries usable",
1364
+ ok: assessed.every((a) => a.usable),
1365
+ detail: assessed.map((a) => a.detail).join("; ")
1058
1366
  });
1059
- if (anyKeyValid && anyEndpointSet && config.apiKey && config.endpoint) {
1367
+ if (assessed.some((a) => a.stdio && a.usable) && config.apiKey && config.endpoint) {
1060
1368
  try {
1061
1369
  const { endpoint, apiKey } = sanitizeCliCredentials(config);
1062
1370
  const res = await doFetch(`${endpoint}/v1/admin/mcp/account-overview`, {
@@ -1100,13 +1408,13 @@ async function checkMcpConfig(config, cwd, doFetch = globalThis.fetch) {
1100
1408
  return checks;
1101
1409
  }
1102
1410
  var FIX_HINTS = {
1103
- "CLI config file": "Run `mushi connect --endpoint <url> --project-id <uuid> --api-key mushi_xxx` or `mushi config endpoint <url>`.",
1104
- "API key configured": "Mint a key in the console (Projects \u2192 API Keys) then `mushi login --api-key mushi_xxx`.",
1105
- "Project ID configured": "Copy the project UUID from the console Projects page \u2192 `mushi config projectId <uuid>`.",
1411
+ "Mushi set up here": "Run `npx mushi-mushi` in your app folder \u2014 it signs you in, installs the SDK and writes the env vars.",
1412
+ "API key configured": SIGN_IN_HINT,
1413
+ "Project ID configured": SIGN_IN_HINT,
1106
1414
  "Endpoint reachable": "Check your network and that MUSHI_API_ENDPOINT points at `\u2026/functions/v1/api`.",
1107
1415
  "[ingest]": "Open the console Onboarding wizard \u2192 Install SDK \u2192 submit a test report, or run `mushi connect --wait`.",
1108
1416
  "[server]": "Open Settings \u2192 Integrations: connect GitHub, index codebase, add Anthropic BYOK key, enable autofix.",
1109
- "[mcp]": "Run `mushi setup` to regenerate .cursor/mcp.json with a fresh API key and endpoint."
1417
+ "[mcp]": "Run `npx mushi-mushi setup --ide cursor` (or `--ide claude`) to rewrite the Mushi MCP entry."
1110
1418
  };
1111
1419
  function fixHintForCheck(name) {
1112
1420
  if (FIX_HINTS[name]) return FIX_HINTS[name];
@@ -1132,12 +1440,12 @@ async function checkOnboardingStatus(config, _consoleBase, cwd, doFetch = global
1132
1440
  }
1133
1441
  try {
1134
1442
  const { readFile: readFile3 } = await import("fs/promises");
1135
- const { join: join4, resolve: resolve3 } = await import("path");
1443
+ const { join: join5, resolve: resolve3 } = await import("path");
1136
1444
  const root = resolve3(cwd);
1137
1445
  let envContent = "";
1138
1446
  for (const f of [".env.local", ".env"]) {
1139
1447
  try {
1140
- envContent = await readFile3(join4(root, f), "utf8");
1448
+ envContent = await readFile3(join5(root, f), "utf8");
1141
1449
  break;
1142
1450
  } catch {
1143
1451
  }
@@ -1203,7 +1511,7 @@ async function checkOnboardingStatus(config, _consoleBase, cwd, doFetch = global
1203
1511
  }
1204
1512
  async function checkVersionDrift(cwd, doFetch) {
1205
1513
  const checks = [];
1206
- const { MUSHI_CLI_VERSION: MUSHI_CLI_VERSION2 } = await import("./version-AYMUW3UQ.js");
1514
+ const { MUSHI_CLI_VERSION: MUSHI_CLI_VERSION2 } = await import("./version-VR37VEE4.js");
1207
1515
  if (MUSHI_CLI_VERSION2 === "0.0.0-dev") return checks;
1208
1516
  const parse2 = (v) => {
1209
1517
  const m = v.replace(/^[~^]/, "").match(/^(\d+)\.(\d+)\.(\d+)/);
@@ -1233,8 +1541,8 @@ async function checkVersionDrift(cwd, doFetch) {
1233
1541
  }
1234
1542
  try {
1235
1543
  const { readFile: readFile3 } = await import("fs/promises");
1236
- const { join: join4 } = await import("path");
1237
- const pkg = JSON.parse(await readFile3(join4(cwd, "package.json"), "utf8"));
1544
+ const { join: join5 } = await import("path");
1545
+ const pkg = JSON.parse(await readFile3(join5(cwd, "package.json"), "utf8"));
1238
1546
  const deps = { ...pkg.dependencies, ...pkg.devDependencies };
1239
1547
  const candidates = Object.entries(deps).filter(([name]) => name.startsWith("@mushi-mushi/")).filter(([, v]) => /^[~^]?\d/.test(v));
1240
1548
  const drifted = (await Promise.all(
@@ -1301,11 +1609,23 @@ async function runDoctor(config, options = {}) {
1301
1609
  const isFull = options.full === true;
1302
1610
  const runServer = isFull || options.server !== false;
1303
1611
  const runIngest = isFull || options.ingest !== false;
1612
+ const sdkCheck = await checkSdkInstall(options.cwd ?? process.cwd());
1613
+ if (!config.apiKey && !config.projectId && !sdkCheck?.ok && !options.auth && !isFull) {
1614
+ return {
1615
+ ready: false,
1616
+ checks: [
1617
+ {
1618
+ name: "Mushi set up here",
1619
+ ok: false,
1620
+ detail: "Not set up yet \u2014 no saved Mushi credentials and no Mushi SDK in this folder."
1621
+ }
1622
+ ]
1623
+ };
1624
+ }
1304
1625
  checks.push(...checkCliConfig(config));
1305
1626
  if (config.endpoint) {
1306
1627
  checks.push(await checkEndpointReachability(config.endpoint, doFetch));
1307
1628
  }
1308
- const sdkCheck = await checkSdkInstall(options.cwd ?? process.cwd());
1309
1629
  if (sdkCheck) checks.push(sdkCheck);
1310
1630
  if (sdkCheck?.ok && !sdkCheck.warn) {
1311
1631
  const { findSdkImport } = await import("./snippet-inject-CCXILWYZ.js");
@@ -1365,13 +1685,17 @@ function formatDoctorResult(result) {
1365
1685
  const WARN = "WARN";
1366
1686
  const FAIL = "FAIL";
1367
1687
  const lines = [];
1688
+ const printedHints = /* @__PURE__ */ new Set();
1368
1689
  for (const c of result.checks) {
1369
1690
  const icon = !c.ok ? FAIL : c.warn ? WARN : PASS;
1370
1691
  lines.push(`${icon} ${c.name}`);
1371
1692
  if (c.detail) lines.push(` ${c.detail}`);
1372
1693
  if (!c.ok) {
1373
1694
  const hint = fixHintForCheck(c.name);
1374
- if (hint) lines.push(` \u2192 Fix: ${hint}`);
1695
+ if (hint && !printedHints.has(hint)) {
1696
+ printedHints.add(hint);
1697
+ lines.push(` \u2192 Fix: ${hint}`);
1698
+ }
1375
1699
  }
1376
1700
  }
1377
1701
  const failed = result.checks.filter((c) => !c.ok);
@@ -1395,6 +1719,9 @@ ${failed.length} check${failed.length === 1 ? "" : "s"} failed.`);
1395
1719
 
1396
1720
  // src/init.ts
1397
1721
  var ENV_FILES = [".env.local", ".env"];
1722
+ function isInteractive() {
1723
+ return Boolean(process.stdin.isTTY && process.stdout.isTTY);
1724
+ }
1398
1725
  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;
1399
1726
  var API_KEY_PATTERN = /^(mushi_|mush_pk_)[A-Za-z0-9_-]{10,}$/;
1400
1727
  async function runInit(options = {}) {
@@ -1404,6 +1731,7 @@ async function runInit(options = {}) {
1404
1731
  return;
1405
1732
  }
1406
1733
  ensureInteractiveOrBailOut(options);
1734
+ if (!isInteractive()) options = { ...options, yes: true };
1407
1735
  p.intro("Mushi setup");
1408
1736
  await printFreshnessHint();
1409
1737
  warnIfWorkspaceRoot(cwd);
@@ -1411,6 +1739,10 @@ async function runInit(options = {}) {
1411
1739
  maybeSuggestAudit(pkg);
1412
1740
  if (!pkg) {
1413
1741
  p.log.warn("No package.json found in this directory.");
1742
+ if (!isInteractive()) {
1743
+ p.cancel("Aborted. Run from your project root, or pass --cwd <path>.");
1744
+ process.exit(1);
1745
+ }
1414
1746
  const cont = await p.confirm({
1415
1747
  message: "Continue anyway? (Mushi will install into the current folder)",
1416
1748
  initialValue: false
@@ -1438,13 +1770,13 @@ async function runInit(options = {}) {
1438
1770
  }
1439
1771
  await writeEnvFile(
1440
1772
  cwd,
1441
- credentials.apiKey,
1773
+ credentials.sdkKey,
1442
1774
  credentials.projectId,
1443
1775
  framework,
1444
1776
  endpoint,
1445
1777
  Boolean(options.yes)
1446
1778
  );
1447
- persistCliConfig(credentials.apiKey, credentials.projectId, endpoint);
1779
+ persistCliConfig(credentials, endpoint);
1448
1780
  emitWizardFunnelEvent(credentials, endpoint, "wizard_env_written", { framework: framework.id });
1449
1781
  const enableRewards = await maybeEnableRewards(options);
1450
1782
  await maybeInjectSnippet(cwd, framework, options);
@@ -1477,14 +1809,11 @@ function maybeSuggestAudit(pkg) {
1477
1809
  }
1478
1810
  }
1479
1811
  function ensureInteractiveOrBailOut(options) {
1480
- const isTTY = Boolean(process.stdin.isTTY && process.stdout.isTTY);
1481
- if (isTTY) return;
1482
- const hasAllFlags = Boolean(
1483
- (options.framework || options.yes) && options.projectId && options.apiKey
1484
- );
1485
- if (hasAllFlags) return;
1812
+ if (isInteractive()) return;
1813
+ if (options.yes) return;
1814
+ if (options.framework && options.projectId && options.apiKey) return;
1486
1815
  process.stderr.write(
1487
- "mushi-mushi: non-interactive terminal detected.\nPass all of --yes (or --framework), --project-id, and --api-key to run unattended.\nExample: npx mushi-mushi --yes --project-id <uuid-from-console> --api-key mushi_xxx\nYour project ID is the UUID shown in the Projects page of the Mushi admin console.\n"
1816
+ "mushi-mushi: non-interactive terminal detected.\nRe-run with --yes to set up without prompts: it reuses the credentials saved by\n`npx mushi-mushi login`, or prints a sign-in URL and waits while you approve it in a browser.\nExample: npx mushi-mushi --yes\nCI with no browser: npx mushi-mushi --yes --project-id <uuid> --api-key <ingest-only key>\n"
1488
1817
  );
1489
1818
  process.exit(1);
1490
1819
  }
@@ -1515,14 +1844,16 @@ async function chooseFramework(detected, options) {
1515
1844
  return FRAMEWORKS[confirmed];
1516
1845
  }
1517
1846
  async function acquireCredentials(options, consoleBase, endpoint) {
1847
+ const probe = (e, k, pid) => probeKeyScope(e, k, pid);
1518
1848
  if (options.projectId && options.apiKey) {
1519
- return {
1520
- projectId: sanitizeSecret(options.projectId),
1521
- apiKey: sanitizeSecret(options.apiKey)
1522
- };
1849
+ const projectId = sanitizeSecret(options.projectId);
1850
+ const sdkKey = sanitizeSecret(options.apiKey);
1851
+ await assertUserKeyIsIngestOnly(sdkKey, projectId, endpoint, probe);
1852
+ return { projectId, sdkKey };
1523
1853
  }
1524
1854
  const existing = loadConfig();
1525
1855
  maybeShowTelemetryNotice(existing);
1856
+ let browserTried = false;
1526
1857
  if (!options.projectId && !options.apiKey && existing.projectId && existing.apiKey) {
1527
1858
  const reuse = options.yes ? true : await p.confirm({
1528
1859
  message: "Use the Mushi credentials saved from your last sign-in?",
@@ -1533,13 +1864,32 @@ async function acquireCredentials(options, consoleBase, endpoint) {
1533
1864
  process.exit(0);
1534
1865
  }
1535
1866
  if (reuse) {
1536
- return {
1537
- projectId: sanitizeSecret(existing.projectId),
1538
- apiKey: sanitizeSecret(existing.apiKey)
1539
- };
1867
+ const saved = await resolveSavedCredentials(
1868
+ { projectId: existing.projectId, apiKey: existing.apiKey, sdkKey: existing.sdkKey },
1869
+ endpoint,
1870
+ probe
1871
+ );
1872
+ if (saved.kind === "ready") return saved.credentials;
1873
+ if (saved.kind === "needs-sdk-key") {
1874
+ p.log.info(
1875
+ "Your saved sign-in key can read bug reports, so it stays in the CLI config. Approve one browser sign-in to mint a separate ingest-only key for the app's env vars."
1876
+ );
1877
+ browserTried = true;
1878
+ const creds = await runBrowserSignIn(options, endpoint, consoleBase, {
1879
+ projectId: saved.projectId,
1880
+ cliKey: saved.cliKey
1881
+ });
1882
+ if (creds) return creds;
1883
+ } else {
1884
+ p.log.warn(`Saved credentials can't be reused (${saved.reason}) \u2014 signing in again.`);
1885
+ }
1540
1886
  }
1541
1887
  }
1542
- if (options.yes) {
1888
+ if (browserTried) {
1889
+ p.log.warn(
1890
+ "Browser sign-in didn't complete \u2014 switching to manual entry. Run `npx mushi-mushi doctor --auth` to diagnose the sign-in path."
1891
+ );
1892
+ } else if (options.yes) {
1543
1893
  const creds = await runBrowserSignIn(options, endpoint, consoleBase);
1544
1894
  if (creds) return creds;
1545
1895
  p.log.warn(
@@ -1574,9 +1924,70 @@ async function acquireCredentials(options, consoleBase, endpoint) {
1574
1924
  );
1575
1925
  }
1576
1926
  }
1577
- return collectCredentialsManually(options, consoleBase, endpoint);
1927
+ return collectCredentialsManually(options, consoleBase, endpoint, probe);
1928
+ }
1929
+ async function assertUserKeyIsIngestOnly(key, projectId, endpoint, probe) {
1930
+ const result = await probe(endpoint, key, projectId);
1931
+ if (result.result === "mcp" || result.result === "unknown") {
1932
+ throw new Error(
1933
+ `Not writing this API key into your app's env vars: ${describeUnsafeSdkKey(result, endpoint)}. Use an ingest-only (report:write) key from the console, or leave out --api-key to sign in with the browser, which mints one.`
1934
+ );
1935
+ }
1936
+ }
1937
+ async function resolveSavedCredentials(saved, endpoint, probe) {
1938
+ const projectId = sanitizeSecret(saved.projectId);
1939
+ const apiKey = sanitizeSecret(saved.apiKey);
1940
+ const savedSdkKey = savedSdkKeyFor(saved, projectId);
1941
+ if (savedSdkKey) {
1942
+ const sdkProbe = await probe(endpoint, savedSdkKey, projectId);
1943
+ if (sdkProbe.result === "ingest-only") {
1944
+ return { kind: "ready", credentials: { projectId, sdkKey: savedSdkKey, cliKey: apiKey } };
1945
+ }
1946
+ }
1947
+ const cliProbe = await probe(endpoint, apiKey, projectId);
1948
+ if (cliProbe.result === "ingest-only") {
1949
+ return { kind: "ready", credentials: { projectId, sdkKey: apiKey } };
1950
+ }
1951
+ if (cliProbe.result === "mcp") {
1952
+ return { kind: "needs-sdk-key", projectId, cliKey: apiKey };
1953
+ }
1954
+ return { kind: "unusable", reason: describeUnsafeSdkKey(cliProbe, endpoint) };
1578
1955
  }
1579
- async function runBrowserSignIn(options, endpoint, consoleBase) {
1956
+ async function mintWizardKeys(deps, args) {
1957
+ let projectId;
1958
+ let createdName;
1959
+ let sdkKey = null;
1960
+ if (args.target.kind === "new") {
1961
+ const created = await deps.createProject(args.endpoint, args.cliToken, args.target.name, {
1962
+ scopes: SDK_KEY_SCOPES
1963
+ });
1964
+ projectId = created.id;
1965
+ createdName = created.name;
1966
+ sdkKey = created.apiKey;
1967
+ } else {
1968
+ projectId = args.target.projectId;
1969
+ }
1970
+ if (!sdkKey) {
1971
+ sdkKey = await deps.mintProjectKey(args.endpoint, args.cliToken, projectId, {
1972
+ scopes: SDK_KEY_SCOPES,
1973
+ label: SDK_KEY_LABEL
1974
+ });
1975
+ }
1976
+ let cliKey;
1977
+ let cliKeyError;
1978
+ if (args.withCliKey) {
1979
+ try {
1980
+ cliKey = await deps.mintProjectKey(args.endpoint, args.cliToken, projectId, {
1981
+ scopes: CLI_KEY_SCOPES,
1982
+ label: CLI_KEY_LABEL
1983
+ });
1984
+ } catch (err) {
1985
+ cliKeyError = err instanceof Error ? err.message : String(err);
1986
+ }
1987
+ }
1988
+ return { projectId, createdName, sdkKey, cliKey, cliKeyError };
1989
+ }
1990
+ async function runBrowserSignIn(options, endpoint, consoleBase, preset) {
1580
1991
  const startSpin = p.spinner();
1581
1992
  startSpin.start("Starting secure browser sign-in\u2026");
1582
1993
  let session;
@@ -1604,9 +2015,11 @@ async function runBrowserSignIn(options, endpoint, consoleBase) {
1604
2015
  return null;
1605
2016
  }
1606
2017
  waitSpin.stop("Approved.");
1607
- let projectId = options.projectId ? sanitizeSecret(options.projectId) : void 0;
1608
- let apiKey;
1609
- if (!projectId) {
2018
+ const presetProjectId = preset?.projectId ?? (options.projectId ? sanitizeSecret(options.projectId) : void 0);
2019
+ let target;
2020
+ if (presetProjectId) {
2021
+ target = { kind: "existing", projectId: presetProjectId };
2022
+ } else {
1610
2023
  let projects = [];
1611
2024
  const fetchSpin = p.spinner();
1612
2025
  fetchSpin.start("Loading your projects\u2026");
@@ -1616,91 +2029,117 @@ async function runBrowserSignIn(options, endpoint, consoleBase) {
1616
2029
  projects.length > 0 ? `Found ${projects.length} project(s).` : "No projects yet."
1617
2030
  );
1618
2031
  } catch (err) {
2032
+ if (!isInteractive()) {
2033
+ fetchSpin.stop("Could not load projects.");
2034
+ p.log.warn(err instanceof Error ? err.message : String(err));
2035
+ return null;
2036
+ }
1619
2037
  fetchSpin.stop("Could not load projects \u2014 you can still create a new one.");
1620
2038
  p.log.warn(err instanceof Error ? err.message : String(err));
1621
2039
  }
1622
- const NEW = "__new__";
1623
- const choice = await p.select({
1624
- message: "Choose a project",
1625
- initialValue: projects[0]?.id ?? NEW,
1626
- options: [
1627
- ...projects.map((pr) => ({ value: pr.id, label: pr.name, hint: pr.id.slice(0, 8) })),
1628
- { value: NEW, label: "Create a new project", hint: "mints an SDK key automatically" }
1629
- ]
1630
- });
1631
- if (p.isCancel(choice)) {
1632
- p.cancel("Aborted.");
1633
- process.exit(0);
1634
- }
1635
- if (choice === NEW) {
1636
- const name = await p.text({
1637
- message: "Project name",
1638
- placeholder: "My app",
1639
- validate: (v) => v && v.trim().length > 0 ? void 0 : "Required"
2040
+ if (!isInteractive()) {
2041
+ const cwd = options.cwd ?? process.cwd();
2042
+ const choice = chooseProjectNonInteractive(
2043
+ projects,
2044
+ defaultProjectName(readPackageJson(cwd)?.name, cwd)
2045
+ );
2046
+ if (choice.kind === "existing") {
2047
+ p.log.info(`Using project "${choice.name}" (matches this app \u2014 pass --project-id to pick another).`);
2048
+ target = { kind: "existing", projectId: choice.id };
2049
+ } else {
2050
+ p.log.info(`Creating project "${choice.name}" (no project matches this app \u2014 pass --project-id to use an existing one).`);
2051
+ target = { kind: "new", name: choice.name };
2052
+ }
2053
+ } else {
2054
+ const NEW = "__new__";
2055
+ const choice = await p.select({
2056
+ message: "Choose a project",
2057
+ initialValue: projects[0]?.id ?? NEW,
2058
+ options: [
2059
+ ...projects.map((pr) => ({ value: pr.id, label: pr.name, hint: pr.id.slice(0, 8) })),
2060
+ { value: NEW, label: "Create a new project", hint: "mints an SDK key automatically" }
2061
+ ]
1640
2062
  });
1641
- if (p.isCancel(name)) {
2063
+ if (p.isCancel(choice)) {
1642
2064
  p.cancel("Aborted.");
1643
2065
  process.exit(0);
1644
2066
  }
1645
- const createSpin = p.spinner();
1646
- createSpin.start(`Creating "${name.trim()}"\u2026`);
1647
- try {
1648
- const created = await createProject(endpoint, cliToken, name.trim(), {
1649
- scopes: ["report:write", "mcp:read"]
2067
+ if (choice === NEW) {
2068
+ const name = await p.text({
2069
+ message: "Project name",
2070
+ placeholder: "My app",
2071
+ validate: (v) => v && v.trim().length > 0 ? void 0 : "Required"
1650
2072
  });
1651
- projectId = created.id;
1652
- apiKey = created.apiKey ?? void 0;
1653
- createSpin.stop(`Created project "${created.name}".`);
1654
- } catch (err) {
1655
- createSpin.stop("Could not create the project.");
1656
- p.log.warn(err instanceof Error ? err.message : String(err));
1657
- return null;
2073
+ if (p.isCancel(name)) {
2074
+ p.cancel("Aborted.");
2075
+ process.exit(0);
2076
+ }
2077
+ target = { kind: "new", name: name.trim() };
2078
+ } else {
2079
+ target = { kind: "existing", projectId: choice };
1658
2080
  }
1659
- } else {
1660
- projectId = choice;
1661
2081
  }
1662
2082
  }
1663
- if (projectId && !apiKey) {
1664
- const keySpin = p.spinner();
1665
- keySpin.start("Minting SDK key\u2026");
1666
- try {
1667
- apiKey = await mintProjectKey(endpoint, cliToken, projectId, {
1668
- scopes: ["report:write", "mcp:read"]
1669
- });
1670
- keySpin.stop("SDK key ready.");
1671
- } catch (err) {
1672
- keySpin.stop("Could not mint an API key.");
1673
- p.log.warn(err instanceof Error ? err.message : String(err));
2083
+ const keySpin = p.spinner();
2084
+ keySpin.start(target.kind === "new" ? `Creating "${target.name}"\u2026` : "Minting keys\u2026");
2085
+ let minted;
2086
+ try {
2087
+ minted = await mintWizardKeys(
2088
+ { createProject, mintProjectKey },
2089
+ { endpoint, cliToken, target, withCliKey: !preset }
2090
+ );
2091
+ } catch (err) {
2092
+ keySpin.stop(target.kind === "new" ? "Could not create the project." : "Could not mint an API key.");
2093
+ p.log.warn(err instanceof Error ? err.message : String(err));
2094
+ if (target.kind === "existing") {
1674
2095
  p.log.warn(
1675
2096
  `You're signed in, but key minting failed. Generate one manually in the console Verify tab: ${consoleUrl(consoleBase, "/onboarding?tab=verify")}`
1676
2097
  );
1677
- return null;
1678
2098
  }
2099
+ return null;
1679
2100
  }
1680
- if (!projectId || !apiKey) return null;
1681
- return { apiKey, projectId };
2101
+ keySpin.stop(
2102
+ minted.createdName ? `Created project "${minted.createdName}" with an ingest-only SDK key.` : "SDK key ready (ingest-only)."
2103
+ );
2104
+ if (minted.cliKeyError) {
2105
+ p.log.warn(
2106
+ `The CLI/MCP key could not be minted (${minted.cliKeyError}). The SDK install continues; run \`npx mushi-mushi login\` later for MCP and admin commands.`
2107
+ );
2108
+ }
2109
+ return {
2110
+ projectId: minted.projectId,
2111
+ sdkKey: minted.sdkKey,
2112
+ cliKey: preset?.cliKey ?? minted.cliKey
2113
+ };
1682
2114
  }
1683
- async function collectCredentialsManually(options, consoleBase, endpoint) {
2115
+ async function collectCredentialsManually(options, consoleBase, endpoint, probe) {
1684
2116
  const existing = loadConfig();
1685
2117
  let savedProjectId = existing.projectId;
1686
- let savedApiKey = existing.apiKey;
1687
- if (!options.projectId && !options.apiKey && savedProjectId && savedApiKey) {
2118
+ let savedApiKey;
2119
+ if (!isInteractive() && !(options.projectId && options.apiKey)) {
2120
+ throw new Error(
2121
+ "Browser sign-in didn't complete and this terminal cannot prompt for a key. Re-run `npx mushi-mushi --yes` and approve the sign-in in the browser, or pass --project-id and an ingest-only --api-key."
2122
+ );
2123
+ }
2124
+ if (!options.projectId && !options.apiKey && savedProjectId && existing.apiKey) {
2125
+ const projectIdForCheck = sanitizeSecret(savedProjectId);
2126
+ const candidate = savedSdkKeyFor(existing, projectIdForCheck) ?? sanitizeSecret(existing.apiKey);
1688
2127
  p.log.info(
1689
- `Found saved credentials from a previous sign-in (project ${sanitizeSecret(savedProjectId).slice(0, 8)}\u2026).`
2128
+ `Found saved credentials from a previous sign-in (project ${projectIdForCheck.slice(0, 8)}\u2026).`
1690
2129
  );
1691
2130
  const checkSpin = p.spinner();
1692
2131
  checkSpin.start("Checking saved credentials\u2026");
1693
- const check = await apiCall("/v1/sync/whoami", {
1694
- apiKey: sanitizeSecret(savedApiKey),
1695
- projectId: sanitizeSecret(savedProjectId),
1696
- endpoint
1697
- });
1698
- if (check.ok) {
1699
- checkSpin.stop(`Saved credentials still work (${check.data.project_name}).`);
1700
- } else {
2132
+ const check = await probe(endpoint, candidate, projectIdForCheck);
2133
+ if (check.result === "ingest-only") {
2134
+ checkSpin.stop("Saved ingest key still works.");
2135
+ savedApiKey = candidate;
2136
+ } else if (check.result === "mcp") {
2137
+ checkSpin.stop("Saved key can read reports, so it can't go into your app's env \u2014 paste an ingest-only key below.");
2138
+ } else if (check.result === "invalid") {
1701
2139
  checkSpin.stop("Saved credentials no longer authenticate \u2014 enter fresh ones below.");
1702
2140
  savedProjectId = void 0;
1703
- savedApiKey = void 0;
2141
+ } else {
2142
+ checkSpin.stop(`Could not check the saved key (${describeUnsafeSdkKey(check, endpoint)}) \u2014 enter one below.`);
1704
2143
  }
1705
2144
  }
1706
2145
  const rawProjectId = options.projectId ?? savedProjectId ?? await promptText({
@@ -1727,17 +2166,29 @@ async function collectCredentialsManually(options, consoleBase, endpoint) {
1727
2166
  `Invalid API key. Expected format: mushi_[A-Za-z0-9_-]{10,}. Got: ${redact(apiKey)}`
1728
2167
  );
1729
2168
  }
1730
- return { projectId, apiKey };
2169
+ if (apiKey !== savedApiKey) {
2170
+ await assertUserKeyIsIngestOnly(apiKey, projectId, endpoint, probe);
2171
+ }
2172
+ return { projectId, sdkKey: apiKey };
1731
2173
  }
1732
2174
  async function verifyCredentials(credentials, options, consoleBase) {
1733
2175
  const endpoint = resolveCloudEndpoint(options.endpoint);
1734
2176
  const spinner3 = p.spinner();
1735
2177
  spinner3.start("Verifying credentials\u2026");
1736
2178
  const result = await apiCall("/v1/sync/whoami", {
1737
- apiKey: credentials.apiKey,
2179
+ apiKey: credentials.sdkKey,
1738
2180
  projectId: credentials.projectId,
1739
2181
  endpoint
1740
2182
  });
2183
+ if (!result.ok && (result.error.code === "NETWORK_ERROR" || result.error.code === "TIMEOUT")) {
2184
+ spinner3.stop(`Could not reach ${endpoint}.`);
2185
+ p.log.error(result.error.message);
2186
+ p.log.warn("Setup did NOT complete: nothing was installed and no env vars were written.");
2187
+ p.log.info(
2188
+ "Check your network, proxy or VPN, or pass --endpoint <url> if you run a self-hosted backend, then re-run `npx mushi-mushi`."
2189
+ );
2190
+ throw new Error(`Could not reach the Mushi API at ${endpoint} \u2014 nothing was changed.`);
2191
+ }
1741
2192
  if (!result.ok) {
1742
2193
  spinner3.stop("Credentials could not be verified.");
1743
2194
  p.log.error(result.error?.message ?? "Authentication failed.");
@@ -1893,9 +2344,24 @@ function warnIfMissingFromGitignore(cwd, envFile) {
1893
2344
  p.log.warn(`${envFile} is not in .gitignore \u2014 add it before committing.`);
1894
2345
  }
1895
2346
  }
1896
- function persistCliConfig(apiKey, projectId, endpoint) {
2347
+ function planCliConfigUpdate(existing, credentials, endpoint) {
2348
+ const keepSavedKey = existing.projectId === credentials.projectId && Boolean(existing.apiKey);
2349
+ const apiKey = credentials.cliKey ?? (keepSavedKey ? existing.apiKey : credentials.sdkKey);
2350
+ return {
2351
+ ...existing,
2352
+ apiKey,
2353
+ projectId: credentials.projectId,
2354
+ endpoint,
2355
+ sdkKey: { projectId: credentials.projectId, key: credentials.sdkKey }
2356
+ };
2357
+ }
2358
+ function persistCliConfig(credentials, endpoint) {
1897
2359
  const existing = loadConfig();
1898
- saveConfig({ ...existing, apiKey, projectId, endpoint });
2360
+ const next = planCliConfigUpdate(existing, credentials, endpoint);
2361
+ saveConfig(next);
2362
+ if (next.apiKey && next.apiKey !== existing.apiKey) {
2363
+ trySaveKeyToKeychain(next.apiKey, resolveProfileName());
2364
+ }
1899
2365
  }
1900
2366
  function emitWizardFunnelEvent(credentials, endpoint, event, metadata = {}) {
1901
2367
  if (process.env.MUSHI_NO_TELEMETRY) return;
@@ -1903,7 +2369,7 @@ function emitWizardFunnelEvent(credentials, endpoint, event, metadata = {}) {
1903
2369
  method: "POST",
1904
2370
  headers: {
1905
2371
  "Content-Type": "application/json",
1906
- "X-Mushi-Api-Key": credentials.apiKey,
2372
+ "X-Mushi-Api-Key": credentials.sdkKey,
1907
2373
  "X-Mushi-Project": credentials.projectId
1908
2374
  },
1909
2375
  body: JSON.stringify({ event, source: "cli", metadata }),
@@ -1954,7 +2420,7 @@ import { MushiRewardsBadge } from '@mushi-mushi/react';
1954
2420
  [
1955
2421
  " [ ] 1. Paste the init snippet above into your app entry file",
1956
2422
  " [ ] 2. Start your dev server",
1957
- " [ ] 3. Run: mushi connect --write-env --wire-ide --wait",
2423
+ " [ ] 3. Wire your editor: npx mushi-mushi setup --ide cursor (or --ide claude)",
1958
2424
  ` [ ] 4. Open the Verify tab to send a test report: ${consoleUrl(consoleBase, "/onboarding?tab=verify")}`
1959
2425
  ].join("\n"),
1960
2426
  "Next steps:"
@@ -1963,353 +2429,143 @@ import { MushiRewardsBadge } from '@mushi-mushi/react';
1963
2429
  async function maybeOfferConnect(credentials, options, _consoleBase) {
1964
2430
  if (options.yes) return;
1965
2431
  const answer = await p.confirm({
1966
- message: "Run `mushi connect --write-env --wire-ide --wait` now? (SDK env + Cursor MCP + heartbeat check)",
1967
- initialValue: false
1968
- });
1969
- if (p.isCancel(answer) || !answer) return;
1970
- const endpoint = resolveCloudEndpoint(options.endpoint);
1971
- try {
1972
- const { runConnect: runConnect2 } = await import("./connect-TWO2ZNFB.js");
1973
- await runConnect2({
1974
- apiKey: credentials.apiKey,
1975
- projectId: credentials.projectId,
1976
- endpoint,
1977
- cwd: options.cwd,
1978
- writeEnv: true,
1979
- wireIde: true,
1980
- wait: true
1981
- });
1982
- } catch (err) {
1983
- p.log.warn(err instanceof Error ? err.message : String(err));
1984
- p.log.info("You can run manually: mushi connect --write-env --wire-ide --wait");
1985
- }
1986
- }
1987
- async function maybeEnableRewards(options) {
1988
- if (options.yes) return false;
1989
- const answer = await p.confirm({
1990
- message: "Enable Mushi Rewards? (users earn points for bug reports + app activity)",
1991
- initialValue: false
1992
- });
1993
- if (p.isCancel(answer)) return false;
1994
- return Boolean(answer);
1995
- }
1996
- async function maybeSendTestReport(credentials, options) {
1997
- if (options.sendTestReport === false) return;
1998
- let shouldSend;
1999
- if (options.sendTestReport === true || options.yes) {
2000
- shouldSend = true;
2001
- } else {
2002
- const answer = await p.confirm({
2003
- message: "Send a test report now to verify the pipeline?",
2004
- initialValue: true
2005
- });
2006
- if (p.isCancel(answer)) return;
2007
- shouldSend = answer;
2008
- }
2009
- if (!shouldSend) return;
2010
- const endpoint = normalizeEndpoint(resolveCloudEndpoint(options.endpoint));
2011
- const spinner3 = p.spinner();
2012
- spinner3.start("Sending test report\u2026");
2013
- const controller = new AbortController();
2014
- const timer = setTimeout(() => controller.abort(), TEST_REPORT_FETCH_TIMEOUT_MS);
2015
- try {
2016
- const res = await fetch(`${endpoint}/v1/reports`, {
2017
- method: "POST",
2018
- signal: controller.signal,
2019
- headers: {
2020
- "Content-Type": "application/json",
2021
- "X-Mushi-Api-Key": credentials.apiKey,
2022
- "X-Mushi-Project": credentials.projectId
2023
- },
2024
- body: JSON.stringify({
2025
- projectId: credentials.projectId,
2026
- description: "Test report from the mushi-mushi setup wizard",
2027
- category: "other",
2028
- reporterToken: `wizard-${randomUUID()}`,
2029
- createdAt: (/* @__PURE__ */ new Date()).toISOString(),
2030
- environment: {
2031
- url: "cli://wizard",
2032
- userAgent: `mushi-wizard/${process.platform}-${process.arch}`,
2033
- platform: process.platform,
2034
- language: "en",
2035
- viewport: { width: 0, height: 0 },
2036
- referrer: "",
2037
- timestamp: (/* @__PURE__ */ new Date()).toISOString(),
2038
- timezone: Intl.DateTimeFormat().resolvedOptions().timeZone
2039
- }
2040
- })
2041
- });
2042
- if (!res.ok) {
2043
- spinner3.stop(`Test report rejected (HTTP ${res.status}).`);
2044
- p.log.warn(
2045
- res.status === 401 || res.status === 403 ? "Credentials did not authenticate \u2014 double-check the project ID and API key." : "Skipping test report. You can retry with `mushi test`."
2046
- );
2047
- return;
2048
- }
2049
- spinner3.stop("Test report sent.");
2050
- let reportId;
2051
- try {
2052
- const body = await res.json();
2053
- reportId = body.data?.reportId;
2054
- } catch {
2055
- }
2056
- const consoleBase = options.consoleBase ?? resolveConsoleUrlSync(options.cwd);
2057
- p.log.success(`View it at ${reportsUrl(consoleBase, reportId)}`);
2058
- } catch (err) {
2059
- const aborted = err instanceof Error && err.name === "AbortError";
2060
- spinner3.stop(aborted ? "Timed out reaching the Mushi API." : "Could not reach the Mushi API.");
2061
- p.log.warn(err instanceof Error ? err.message : String(err));
2062
- } finally {
2063
- clearTimeout(timer);
2064
- }
2065
- }
2066
- async function printFreshnessHint() {
2067
- const result = await checkFreshness("mushi-mushi", MUSHI_CLI_VERSION);
2068
- if (!result || !result.isOutdated) return;
2069
- p.log.info(
2070
- `A newer version of mushi-mushi is available: ${result.current} \u2192 ${result.latest}. Run \`npx mushi-mushi@latest\` to get the freshest wizard.`
2071
- );
2072
- }
2073
- function warnIfWorkspaceRoot(cwd) {
2074
- let hint;
2075
- try {
2076
- hint = detectWorkspaceHint(cwd);
2077
- } catch {
2078
- return;
2079
- }
2080
- if (!hint || hint.apps.length === 0) return;
2081
- const hasFrameworkAtCwd = hint.apps.some(
2082
- (app) => isSameDirectory(cwd, resolveWorkspaceAppPath(hint.root, app.relativePath))
2083
- );
2084
- if (hasFrameworkAtCwd) return;
2085
- const apps = hint.apps.slice(0, 5).map((app) => ` \u2022 ${app.relativePath} (${app.framework})`).join("\n");
2086
- p.log.warn(
2087
- `You appear to be at a workspace root (source: ${hint.source}). Mushi will install into the current directory, which has no framework dep. You probably meant one of these sub-packages:
2088
- ${apps}
2089
- Run \`mushi init --cwd <path>\` \u2014 or re-run the wizard from inside that package.`
2090
- );
2091
- }
2092
- function resolveWorkspaceAppPath(root, relativePath) {
2093
- return `${root}/${relativePath}`.replace(/\\/g, "/");
2094
- }
2095
- function isSameDirectory(a, b) {
2096
- return a.replace(/\\/g, "/").replace(/\/+$/, "") === b.replace(/\\/g, "/").replace(/\/+$/, "");
2097
- }
2098
-
2099
- // src/login.ts
2100
- function getPostLoginBannerMessage(opts) {
2101
- if (opts.suppressPostLoginBanner) return null;
2102
- if (opts.upgradeScope) {
2103
- return " Key upgraded! Re-run 'mushi setup' to update your .cursor/mcp.json, then restart Cursor.";
2104
- }
2105
- return " Run 'mushi init' to set up the SDK in this project.";
2106
- }
2107
- function printPostLoginBanner(opts) {
2108
- const message = getPostLoginBannerMessage(opts);
2109
- if (message) console.log(message);
2110
- }
2111
- var MAX_CONSECUTIVE_ERRORS = 5;
2112
- async function pollUntilApproved(endpoint, deviceData, deps) {
2113
- const sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
2114
- const now = deps.now ?? Date.now;
2115
- let pollIntervalMs = (deviceData.interval ?? 5) * 1e3;
2116
- const deadline = now() + (deviceData.expires_in ?? 600) * 1e3;
2117
- let consecutiveErrors = 0;
2118
- let firstPoll = true;
2119
- while (now() < deadline) {
2120
- if (!firstPoll) {
2121
- await sleep(pollIntervalMs);
2122
- }
2123
- firstPoll = false;
2124
- const outcome = await deps.poll(endpoint, deviceData.device_code);
2125
- switch (outcome.status) {
2126
- case "approved":
2127
- return { status: "approved", cliToken: outcome.cliToken };
2128
- case "pending":
2129
- consecutiveErrors = 0;
2130
- continue;
2131
- case "slow_down":
2132
- consecutiveErrors = 0;
2133
- pollIntervalMs = Math.max(pollIntervalMs, outcome.retryAfterMs);
2134
- continue;
2135
- case "error":
2136
- if (outcome.retryable) {
2137
- consecutiveErrors += 1;
2138
- if (consecutiveErrors < MAX_CONSECUTIVE_ERRORS) continue;
2139
- }
2140
- return { status: "error", message: outcome.message };
2141
- case "denied":
2142
- return { status: "denied" };
2143
- case "expired":
2144
- return { status: "timeout" };
2145
- }
2146
- }
2147
- return { status: "timeout" };
2148
- }
2149
- async function resolveProjectChoice(projectsList, ask) {
2150
- if (projectsList.length > 0) {
2151
- console.log("");
2152
- console.log(" Your projects:");
2153
- projectsList.forEach((p3, i) => {
2154
- console.log(` ${i + 1}. ${p3.name} (${p3.id})`);
2155
- });
2156
- console.log(` ${projectsList.length + 1}. Create a new project`);
2157
- console.log("");
2158
- const choice = await ask(` Pick a project [1-${projectsList.length + 1}]: `);
2159
- const num = parseInt(choice, 10);
2160
- if (num >= 1 && num <= projectsList.length) {
2161
- const picked = projectsList[num - 1];
2162
- return { kind: "picked", id: picked.id, name: picked.name };
2163
- }
2164
- }
2165
- console.log("");
2166
- const newName = (await ask(" Project name: ")).trim();
2167
- if (!newName) return { kind: "empty_name" };
2168
- return { kind: "create", name: newName };
2169
- }
2170
- async function runLogin(opts = {}) {
2171
- const { CLOUD_API_ENDPOINT, resolveCloudEndpoint: resolveCloudEndpoint2 } = await import("./endpoint-TEBF4IH6.js");
2172
- const endpoint = opts.endpoint ? resolveCloudEndpoint2(opts.endpoint) : CLOUD_API_ENDPOINT;
2173
- const consoleBase = await resolveConsoleUrl();
2174
- let apiKey = opts.apiKey;
2175
- let projectId = opts.projectId;
2176
- if (apiKey) {
2177
- const config2 = loadConfig();
2178
- config2.apiKey = apiKey;
2179
- config2.endpoint = endpoint;
2180
- if (projectId) config2.projectId = projectId;
2181
- config2.consoleUrl = consoleBase;
2182
- saveConfig(config2);
2183
- console.log("");
2184
- console.log(" \u2713 Credentials saved.");
2185
- const verifyResult = await apiCall("/v1/sync/whoami", { apiKey, endpoint, projectId });
2186
- if (!verifyResult.ok) {
2187
- console.warn(` \u26A0 Key saved, but verification failed: ${verifyResult.error?.message ?? "unknown error"}`);
2188
- console.warn(` Check your key at: ${cliSetupDeepLink(consoleBase)}`);
2189
- return;
2190
- }
2191
- const d = verifyResult.data;
2192
- if (d.project_name) {
2193
- if (!projectId && d.project_id) {
2194
- config2.projectId = d.project_id;
2195
- saveConfig(config2);
2196
- }
2197
- console.log(` \u2713 Project: ${d.project_name} (${d.project_id})`);
2198
- }
2199
- console.log(` \u2713 Endpoint: ${endpoint}`);
2200
- console.log("");
2201
- if (!opts.suppressPostLoginBanner) {
2202
- console.log(" Run 'mushi whoami' to verify \xB7 'mushi init' to set up the SDK");
2203
- }
2204
- return;
2205
- }
2206
- if (!opts.suppressPostLoginBanner) {
2207
- console.log("");
2208
- }
2209
- if (opts.upgradeScope) {
2210
- console.log(" Mushi login \u2014 scope upgrade");
2211
- 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");
2212
- console.log(" Re-authenticating to mint a new key with report:write + mcp:read + mcp:write scopes.");
2213
- console.log(" This lets the CLI admin commands (including writes) and MCP server both work with a single key.");
2214
- console.log("");
2215
- } else if (!opts.suppressPostLoginBanner) {
2216
- console.log(" Mushi login");
2217
- console.log(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
2218
- }
2219
- let deviceData;
2432
+ message: "Run `mushi connect --write-env --wire-ide --wait` now? (SDK env + Cursor MCP + heartbeat check)",
2433
+ initialValue: false
2434
+ });
2435
+ if (p.isCancel(answer) || !answer) return;
2436
+ const endpoint = resolveCloudEndpoint(options.endpoint);
2220
2437
  try {
2221
- deviceData = await startDeviceAuth(endpoint, ensureClientId());
2438
+ const { runConnect: runConnect2 } = await import("./connect-63GYZLFM.js");
2439
+ const saved = loadConfig();
2440
+ const result = await runConnect2(
2441
+ {
2442
+ apiKey: saved.apiKey ?? credentials.sdkKey,
2443
+ sdkKey: credentials.sdkKey,
2444
+ projectId: credentials.projectId,
2445
+ endpoint,
2446
+ cwd: options.cwd,
2447
+ writeEnv: true,
2448
+ wireIde: true,
2449
+ wait: true
2450
+ },
2451
+ saved
2452
+ );
2453
+ for (const line of result.messages) p.log.message(line);
2222
2454
  } catch (err) {
2223
- process.stderr.write(`
2224
- error: Could not start login session: ${err instanceof Error ? err.message : String(err)}
2225
- `);
2226
- process.stderr.write(` Fallback: mushi login --api-key <key> --project-id <uuid>
2227
- `);
2228
- process.exit(1);
2229
- }
2230
- const verifyUrl = deviceData.verification_uri;
2231
- if (opts.browser !== false) {
2232
- try {
2233
- await openInBrowser(verifyUrl);
2234
- } catch {
2235
- }
2455
+ p.log.warn(err instanceof Error ? err.message : String(err));
2456
+ p.log.info("You can run manually: mushi connect --write-env --wire-ide --wait");
2236
2457
  }
2237
- printAuthBanner(deviceData.user_code, verifyUrl);
2238
- const pollResult = await pollUntilApproved(endpoint, deviceData, {
2239
- poll: pollDeviceToken
2458
+ }
2459
+ async function maybeEnableRewards(options) {
2460
+ if (options.yes) return false;
2461
+ const answer = await p.confirm({
2462
+ message: "Enable Mushi Rewards? (users earn points for bug reports + app activity)",
2463
+ initialValue: false
2240
2464
  });
2241
- if (pollResult.status !== "approved") {
2242
- if (pollResult.status === "denied") printAuthFailed("denied");
2243
- else if (pollResult.status === "error") printAuthFailed("error", pollResult.message);
2244
- else printAuthFailed("timeout");
2245
- process.exit(1);
2465
+ if (p.isCancel(answer)) return false;
2466
+ return Boolean(answer);
2467
+ }
2468
+ async function maybeSendTestReport(credentials, options) {
2469
+ if (options.sendTestReport === false) return;
2470
+ let shouldSend;
2471
+ if (options.sendTestReport === true || options.yes) {
2472
+ shouldSend = true;
2473
+ } else {
2474
+ const answer = await p.confirm({
2475
+ message: "Send a test report now to verify the pipeline?",
2476
+ initialValue: true
2477
+ });
2478
+ if (p.isCancel(answer)) return;
2479
+ shouldSend = answer;
2246
2480
  }
2247
- const cliToken = pollResult.cliToken;
2248
- printAuthApproved();
2249
- const projectsList = await listProjects(endpoint, cliToken);
2250
- let chosenProjectId = projectId;
2251
- let chosenProjectName;
2252
- const mintScopes = opts.upgradeScope ? ["report:write", "mcp:read", "mcp:write"] : ["report:write", "mcp:read"];
2253
- if (!chosenProjectId) {
2254
- const { createInterface } = await import("readline");
2255
- const rl = createInterface({ input: process.stdin, output: process.stdout });
2256
- const ask = (q) => new Promise((resolve3) => rl.question(q, (a) => resolve3(a.trim())));
2257
- const choice = await resolveProjectChoice(projectsList, ask);
2258
- rl.close();
2259
- if (choice.kind === "empty_name") {
2260
- process.stderr.write("\nerror: Project name is required.\n");
2261
- process.exit(2);
2262
- }
2263
- if (choice.kind === "picked") {
2264
- chosenProjectId = choice.id;
2265
- chosenProjectName = choice.name;
2266
- } else {
2267
- try {
2268
- const created = await createProject(endpoint, cliToken, choice.name, { scopes: mintScopes });
2269
- chosenProjectId = created.id;
2270
- chosenProjectName = created.name;
2271
- apiKey = created.apiKey ?? void 0;
2272
- console.log(` \u2713 Created project "${chosenProjectName}"`);
2273
- } catch (err) {
2274
- process.stderr.write(`
2275
- error: Could not create project: ${err instanceof Error ? err.message : String(err)}
2276
- `);
2277
- process.exit(1);
2278
- }
2481
+ if (!shouldSend) return;
2482
+ const endpoint = normalizeEndpoint(resolveCloudEndpoint(options.endpoint));
2483
+ const spinner3 = p.spinner();
2484
+ spinner3.start("Sending test report\u2026");
2485
+ const controller = new AbortController();
2486
+ const timer = setTimeout(() => controller.abort(), TEST_REPORT_FETCH_TIMEOUT_MS);
2487
+ try {
2488
+ const res = await fetch(`${endpoint}/v1/reports`, {
2489
+ method: "POST",
2490
+ signal: controller.signal,
2491
+ headers: {
2492
+ "Content-Type": "application/json",
2493
+ "X-Mushi-Api-Key": credentials.sdkKey,
2494
+ "X-Mushi-Project": credentials.projectId
2495
+ },
2496
+ body: JSON.stringify({
2497
+ projectId: credentials.projectId,
2498
+ description: "Test report from the mushi-mushi setup wizard",
2499
+ category: "other",
2500
+ reporterToken: `wizard-${randomUUID()}`,
2501
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
2502
+ environment: {
2503
+ url: "cli://wizard",
2504
+ userAgent: `mushi-wizard/${process.platform}-${process.arch}`,
2505
+ platform: process.platform,
2506
+ language: "en",
2507
+ viewport: { width: 0, height: 0 },
2508
+ referrer: "",
2509
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
2510
+ timezone: Intl.DateTimeFormat().resolvedOptions().timeZone
2511
+ }
2512
+ })
2513
+ });
2514
+ if (!res.ok) {
2515
+ spinner3.stop(`Test report rejected (HTTP ${res.status}).`);
2516
+ p.log.warn(
2517
+ res.status === 401 || res.status === 403 ? "Credentials did not authenticate \u2014 double-check the project ID and API key." : "Skipping test report. You can retry with `mushi test`."
2518
+ );
2519
+ return;
2279
2520
  }
2280
- }
2281
- if (!apiKey && chosenProjectId) {
2521
+ spinner3.stop("Test report sent.");
2522
+ let reportId;
2282
2523
  try {
2283
- apiKey = await mintProjectKey(endpoint, cliToken, chosenProjectId, { scopes: mintScopes }) ?? void 0;
2524
+ const body = await res.json();
2525
+ reportId = body.data?.reportId;
2284
2526
  } catch {
2285
2527
  }
2528
+ const consoleBase = options.consoleBase ?? resolveConsoleUrlSync(options.cwd);
2529
+ p.log.success(`View it at ${reportsUrl(consoleBase, reportId)}`);
2530
+ } catch (err) {
2531
+ const aborted = err instanceof Error && err.name === "AbortError";
2532
+ spinner3.stop(aborted ? "Timed out reaching the Mushi API." : "Could not reach the Mushi API.");
2533
+ p.log.warn(err instanceof Error ? err.message : String(err));
2534
+ } finally {
2535
+ clearTimeout(timer);
2286
2536
  }
2287
- const config = loadConfig();
2288
- config.endpoint = endpoint;
2289
- config.consoleUrl = consoleBase;
2290
- if (chosenProjectId) config.projectId = chosenProjectId;
2291
- if (apiKey) config.apiKey = apiKey;
2292
- saveConfig(config);
2293
- if (apiKey) {
2294
- const profile = resolveProfileName(void 0, config.activeProfile);
2295
- trySaveKeyToKeychain(apiKey, profile);
2296
- }
2297
- console.log("");
2298
- if (chosenProjectName) console.log(` \u2713 Project: ${chosenProjectName}`);
2299
- if (apiKey) {
2300
- if (opts.upgradeScope) {
2301
- console.log(` \u2713 Full-scope CLI key saved (${apiKey.slice(0, 12)}\u2026)`);
2302
- console.log(` Scopes: report:write \xB7 mcp:read \xB7 mcp:write \u2014 SDK + MCP + admin CLI (incl. writes) all work with this key`);
2303
- } else {
2304
- console.log(` \u2713 CLI key saved (${apiKey.slice(0, 12)}\u2026)`);
2305
- console.log(` Scopes: report:write \xB7 mcp:read \u2014 SDK ingest + MCP read tools.`);
2306
- console.log(` Admin writes (billing cap, fix merge, pipeline start) need: mushi login --upgrade-scope`);
2307
- }
2308
- } else {
2309
- console.log(` \u2139 Get an SDK key at: ${apiKeyHint(consoleBase)}`);
2537
+ }
2538
+ async function printFreshnessHint() {
2539
+ const result = await checkFreshness("mushi-mushi", MUSHI_CLI_VERSION);
2540
+ if (!result || !result.isOutdated) return;
2541
+ p.log.info(
2542
+ `A newer version of mushi-mushi is available: ${result.current} \u2192 ${result.latest}. Run \`npx mushi-mushi@latest\` to get the freshest wizard.`
2543
+ );
2544
+ }
2545
+ function warnIfWorkspaceRoot(cwd) {
2546
+ let hint;
2547
+ try {
2548
+ hint = detectWorkspaceHint(cwd);
2549
+ } catch {
2550
+ return;
2310
2551
  }
2311
- console.log("");
2312
- printPostLoginBanner(opts);
2552
+ if (!hint || hint.apps.length === 0) return;
2553
+ const hasFrameworkAtCwd = hint.apps.some(
2554
+ (app) => isSameDirectory(cwd, resolveWorkspaceAppPath(hint.root, app.relativePath))
2555
+ );
2556
+ if (hasFrameworkAtCwd) return;
2557
+ const apps = hint.apps.slice(0, 5).map((app) => ` \u2022 ${app.relativePath} (${app.framework})`).join("\n");
2558
+ p.log.warn(
2559
+ `You appear to be at a workspace root (source: ${hint.source}). Mushi will install into the current directory, which has no framework dep. You probably meant one of these sub-packages:
2560
+ ${apps}
2561
+ Run \`mushi init --cwd <path>\` \u2014 or re-run the wizard from inside that package.`
2562
+ );
2563
+ }
2564
+ function resolveWorkspaceAppPath(root, relativePath) {
2565
+ return `${root}/${relativePath}`.replace(/\\/g, "/");
2566
+ }
2567
+ function isSameDirectory(a, b) {
2568
+ return a.replace(/\\/g, "/").replace(/\/+$/, "") === b.replace(/\\/g, "/").replace(/\/+$/, "");
2313
2569
  }
2314
2570
 
2315
2571
  // src/migrate.ts
@@ -2562,8 +2818,9 @@ Useful after 'mushi login' to confirm credentials are correct.`).action(async (o
2562
2818
  console.log("");
2563
2819
  console.log(`Fixes: ${d.fixes_count} total \xB7 ${d.fixes_merged} merged`);
2564
2820
  console.log(`Lessons: ${d.lessons_count} active rules`);
2821
+ console.log(await formatActivationLine(config));
2565
2822
  });
2566
- program2.command("config").description("View or update CLI config (stored in ~/.config/mushi/config.json)").argument("[key]", "Config key to set: apiKey | endpoint | projectId").argument("[value]", "New value").addHelpText("after", `
2823
+ program2.command("config").description(`View or update CLI config (stored in ${CONFIG_PATH})`).argument("[key]", "Config key to set: apiKey | endpoint | projectId").argument("[value]", "New value").addHelpText("after", `
2567
2824
  Keys:
2568
2825
  apiKey \u2014 Mushi API key (mushi_...)
2569
2826
  endpoint \u2014 Supabase edge function URL
@@ -2587,11 +2844,33 @@ Examples:
2587
2844
  saveConfig(config);
2588
2845
  console.log(`\u2713 Set ${key}`);
2589
2846
  } else {
2590
- const safe = { ...config, apiKey: config.apiKey ? `${config.apiKey.slice(0, 10)}\u2026` : void 0 };
2847
+ const safe = {
2848
+ ...config,
2849
+ apiKey: config.apiKey ? `${config.apiKey.slice(0, 10)}\u2026` : void 0,
2850
+ sdkKey: typeof config.sdkKey?.key === "string" ? { ...config.sdkKey, key: `${config.sdkKey.key.slice(0, 10)}\u2026` } : void 0
2851
+ };
2591
2852
  console.log(JSON.stringify(safe, null, 2));
2592
2853
  }
2593
2854
  });
2594
2855
  }
2856
+ async function formatActivationLine(config) {
2857
+ const qs = config.projectId ? `?project_id=${encodeURIComponent(config.projectId)}` : "";
2858
+ const result = await apiCall(`/v1/admin/activation${qs}`, config);
2859
+ if (!result.ok) {
2860
+ const code = result.error.code.toUpperCase();
2861
+ const status = result.httpStatus ?? 0;
2862
+ if (status === 401 || status === 403 || /SCOPE|UNAUTHORIZED|FORBIDDEN|TOKEN/.test(code)) {
2863
+ return "Activation: (needs mcp:read \u2014 run `mushi login --upgrade-scope`)";
2864
+ }
2865
+ return `Activation: unavailable (${result.error.code})`;
2866
+ }
2867
+ const d = result.data;
2868
+ const stats = d.stats ?? {};
2869
+ const firstAt = stats.first_report_at ?? stats.firstReportAt ?? null;
2870
+ const reportCount = typeof stats.reportCount === "number" ? stats.reportCount : 0;
2871
+ const firstReport = firstAt ? String(firstAt).slice(0, 10) : reportCount > 0 ? "received" : "none yet";
2872
+ return `Activation: ${d.phase ?? "ingest"} \xB7 first report ${firstReport}`;
2873
+ }
2595
2874
 
2596
2875
  // src/commands/deploy.ts
2597
2876
  function registerDeployCommands(program2) {
@@ -3389,7 +3668,8 @@ async function writeProjectBootstrapFiles(opts) {
3389
3668
  const serverBlock = buildMcpServerBlock({
3390
3669
  endpoint: opts.endpoint,
3391
3670
  projectId: opts.projectId,
3392
- apiKey: opts.apiKey
3671
+ apiKey: opts.apiKey,
3672
+ client: "cursor"
3393
3673
  });
3394
3674
  const { created: mcpCreated } = await writeMcpServerEntry({
3395
3675
  configPath: mcpPath,
@@ -3525,38 +3805,121 @@ error: Could not create project: ${err instanceof Error ? err.message : String(e
3525
3805
  }
3526
3806
 
3527
3807
  // src/commands/setup.ts
3808
+ import { join as join4 } from "path";
3528
3809
  function resolveLoginEndpoint(optsEndpoint, existingConfigEndpoint, envEndpoint) {
3529
3810
  return optsEndpoint ?? existingConfigEndpoint ?? envEndpoint?.trim();
3530
3811
  }
3812
+ function resolveMcpConfigTarget(ide, cwd, home) {
3813
+ switch (ide) {
3814
+ case "cursor":
3815
+ return { dir: join4(cwd, ".cursor"), path: join4(cwd, ".cursor", "mcp.json"), format: "mcp-json", repoLocal: true };
3816
+ case "claude":
3817
+ return { dir: cwd, path: join4(cwd, ".mcp.json"), format: "mcp-json", repoLocal: true };
3818
+ case "continue":
3819
+ return { dir: join4(cwd, ".continue"), path: join4(cwd, ".continue", "mcp.json"), format: "mcp-json", repoLocal: true };
3820
+ case "zed":
3821
+ return {
3822
+ dir: join4(home, ".config", "zed"),
3823
+ path: join4(home, ".config", "zed", "settings.json"),
3824
+ format: "zed",
3825
+ repoLocal: false
3826
+ };
3827
+ }
3828
+ }
3829
+ function deriveHostedMcpUrl(endpoint) {
3830
+ return /\/api\/?$/.test(endpoint) ? endpoint.replace(/\/api\/?$/, "/mcp") : null;
3831
+ }
3832
+ function buildSetupServerBlock(opts) {
3833
+ const stdioBlock = buildMcpServerBlock({
3834
+ endpoint: opts.endpoint,
3835
+ projectId: opts.projectId,
3836
+ apiKey: opts.apiKey,
3837
+ client: opts.ide,
3838
+ inlineKey: opts.inlineKey
3839
+ });
3840
+ const wantsHosted = (opts.ide === "cursor" || opts.ide === "claude") && !opts.stdio && !opts.allProjects;
3841
+ if (!wantsHosted) return { block: stdioBlock, hosted: false };
3842
+ const hostedUrl = deriveHostedMcpUrl(opts.endpoint);
3843
+ if (!hostedUrl) {
3844
+ return {
3845
+ block: stdioBlock,
3846
+ hosted: false,
3847
+ note: "Note: endpoint does not end in /api \u2014 cannot derive the hosted MCP URL; writing a local stdio entry instead."
3848
+ };
3849
+ }
3850
+ return { block: buildHostedMcpServerBlock(hostedUrl), hosted: true };
3851
+ }
3852
+ function buildZedServerBlock(opts) {
3853
+ const { env } = buildMcpServerBlock({ ...opts, client: "zed" });
3854
+ return {
3855
+ command: { path: "npx", args: ["-y", MUSHI_MCP_PIN_SPEC], env },
3856
+ settings: {}
3857
+ };
3858
+ }
3859
+ function slugifyProjectName(name) {
3860
+ return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 24);
3861
+ }
3531
3862
  function registerSetupCommands(program2) {
3532
- 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").option("--endpoint <url>", "Override the Mushi API endpoint (self-hosted) \u2014 used for first-run login if not yet configured").option("--stdio", "Write a local stdio entry with an API key instead of the hosted OAuth URL (cursor/claude default is hosted OAuth \u2014 no key on disk)").option("--ci", "Alias for --stdio: headless environments cannot drive the browser OAuth flow").option("--inline-key", "Write the literal API key into the mcp.json env block (default: write ${MUSHI_API_KEY} placeholder to prevent accidental git leaks)").addHelpText("after", `
3863
+ program2.command("setup").description(`Wire the Mushi MCP server into Cursor, Claude Code, Continue or Zed (reads ${CONFIG_PATH} \u2014 signs you in first if needed)`).option("--ide <ide>", `Target IDE: ${MCP_CLIENTS.join(" | ")}`, "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 (never signs in or saves anything)").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").option("--endpoint <url>", "Override the Mushi API endpoint (self-hosted) \u2014 used for first-run login if not yet configured").option("--stdio", "Write a local stdio entry instead of the hosted OAuth URL (cursor/claude default is hosted OAuth \u2014 no key on disk)").option("--ci", "Alias for --stdio: headless environments cannot drive the browser OAuth flow").option("--inline-key", "Write the literal API key into the stdio env block (default: no key in the file \u2014 the server reads your saved CLI key)").addHelpText("after", `
3533
3864
  Examples:
3534
3865
  mushi setup # wire Cursor (default: hosted OAuth \u2014 sign in from the IDE, no key on disk)
3535
- mushi setup --stdio # local stdio entry with an API key (previous behavior)
3866
+ mushi setup --ide claude # wire Claude Code (.mcp.json at the repo root)
3867
+ mushi setup --stdio # local stdio entry that reads your saved CLI key
3536
3868
  mushi setup --ci # same as --stdio, for headless/CI environments
3537
3869
  mushi setup --all-projects # one server entry per accessible project (stdio)
3538
- mushi setup --ide claude # wire Claude Code
3539
3870
  mushi setup --ide cursor --with-rules # also write .cursorrules
3871
+ mushi setup --ide claude --dry-run # preview the entry; signs nothing in, writes nothing
3540
3872
 
3541
3873
  Supported IDEs:
3542
- cursor \u2014 writes .cursor/mcp.json
3543
- claude \u2014 writes .claude/mcp.json (Claude Code / Claude Desktop)
3544
- continue \u2014 writes .continue/mcp.json (stdio only)
3545
- zed \u2014 writes ~/.config/zed/settings.json mcpServers block (stdio only)
3874
+ cursor \u2014 merges into .cursor/mcp.json
3875
+ claude \u2014 merges into .mcp.json at the repo root (Claude Code project scope)
3876
+ continue \u2014 merges into .continue/mcp.json (stdio only)
3877
+ zed \u2014 merges into ~/.config/zed/settings.json context_servers (stdio only)
3546
3878
 
3547
3879
  For cursor and claude the default entry is the hosted MCP URL: your IDE opens
3548
3880
  the browser consent page on first use (OAuth + PKCE) and stores a revocable
3549
3881
  key for you \u2014 nothing sensitive is written to the repo. Pass --stdio/--ci to
3550
- get the local subprocess entry with an API key instead.
3882
+ get the local subprocess entry instead; it reads the key from the CLI config
3883
+ unless you pass --inline-key.
3551
3884
 
3552
- The command reads credentials from ~/.config/mushi/config.json. If you are not logged in yet, it runs browser sign-in first, then writes MCP config.`).action(async (opts) => {
3885
+ Credentials are read from the CLI config at:
3886
+ ${CONFIG_PATH}
3887
+ If you are not signed in yet, setup runs browser sign-in first, then writes the MCP config.`).action(async (opts) => {
3553
3888
  const { writeFile: writeFile3, mkdir, readFile: readFile3 } = await import("fs/promises");
3554
- const { existsSync: existsSync3 } = await import("fs");
3555
3889
  const nodePath2 = await import("path");
3556
3890
  const os = await import("os");
3891
+ const ide = opts.ide;
3892
+ if (!isMcpClient(ide)) {
3893
+ process.stderr.write(`error: unsupported IDE "${opts.ide}". Supported: ${MCP_CLIENTS.join(", ")}
3894
+ `);
3895
+ process.exit(2);
3896
+ }
3897
+ const ideLabel = MCP_CLIENT_LABEL[ide];
3898
+ const cwd = process.cwd();
3899
+ const target = resolveMcpConfigTarget(ide, cwd, os.homedir());
3900
+ const configPath = target.path;
3901
+ const inlineKey = Boolean(opts.inlineKey);
3902
+ const useStdio = Boolean(opts.stdio || opts.ci);
3557
3903
  const existingConfig = loadConfig();
3558
3904
  if (!existingConfig.apiKey) {
3559
3905
  const endpoint = resolveLoginEndpoint(opts.endpoint, existingConfig.endpoint, process.env.MUSHI_API_ENDPOINT);
3906
+ if (opts.dryRun) {
3907
+ const { block } = buildSetupServerBlock({
3908
+ ide,
3909
+ endpoint: resolveCloudEndpoint(endpoint),
3910
+ projectId: "<project-id>",
3911
+ apiKey: "mushi_\u2026",
3912
+ stdio: useStdio,
3913
+ allProjects: Boolean(opts.allProjects),
3914
+ inlineKey
3915
+ });
3916
+ const name = `mushi-${opts.projectSlug ?? "<project>"}`;
3917
+ const preview = target.format === "zed" ? { context_servers: { [name]: buildZedServerBlock({ endpoint: resolveCloudEndpoint(endpoint), projectId: "<project-id>", apiKey: "mushi_\u2026", inlineKey }) } } : { mcpServers: { [name]: block } };
3918
+ console.log("[dry-run] Not signed in \u2014 a real run starts browser sign-in first. Nothing was sent or saved.");
3919
+ console.log(`[dry-run] Would merge into ${configPath}:`);
3920
+ console.log(JSON.stringify(preview, null, 2));
3921
+ return;
3922
+ }
3560
3923
  await runLogin({ endpoint, suppressPostLoginBanner: true });
3561
3924
  console.log(" Continuing MCP setup\u2026");
3562
3925
  console.log("");
@@ -3586,11 +3949,7 @@ The command reads credentials from ~/.config/mushi/config.json. If you are not l
3586
3949
  if (res.ok) {
3587
3950
  const body = await res.json();
3588
3951
  const project = body?.data?.projects?.find((p3) => p3.id === config.projectId);
3589
- if (project?.name) {
3590
- slug = project.name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 24);
3591
- } else {
3592
- slug = config.projectId?.slice(0, 8) ?? "mushi";
3593
- }
3952
+ slug = project?.name ? slugifyProjectName(project.name) : config.projectId?.slice(0, 8) ?? "mushi";
3594
3953
  } else {
3595
3954
  slug = config.projectId?.slice(0, 8) ?? "mushi";
3596
3955
  }
@@ -3598,19 +3957,6 @@ The command reads credentials from ~/.config/mushi/config.json. If you are not l
3598
3957
  slug = config.projectId?.slice(0, 8) ?? "mushi";
3599
3958
  }
3600
3959
  }
3601
- const IDE_CONFIG = {
3602
- cursor: { dir: ".cursor", file: "mcp.json", format: "mcp-json" },
3603
- claude: { dir: ".claude", file: "mcp.json", format: "mcp-json" },
3604
- continue: { dir: ".continue", file: "mcp.json", format: "mcp-json" },
3605
- zed: { dir: nodePath2.join(os.homedir(), ".config", "zed"), file: "settings.json", format: "zed" }
3606
- };
3607
- const ideEntry = IDE_CONFIG[opts.ide];
3608
- if (!ideEntry) {
3609
- process.stderr.write(`error: unsupported IDE "${opts.ide}". Supported: ${Object.keys(IDE_CONFIG).join(", ")}
3610
- `);
3611
- process.exit(2);
3612
- }
3613
- const cwd = process.cwd();
3614
3960
  let allProjectsList = null;
3615
3961
  if (opts.allProjects && config.endpoint && config.apiKey) {
3616
3962
  try {
@@ -3629,23 +3975,26 @@ The command reads credentials from ~/.config/mushi/config.json. If you are not l
3629
3975
  }
3630
3976
  }
3631
3977
  const serverName = `mushi-${slug}`;
3632
- const canDeriveHostedUrl = /\/api\/?$/.test(config.endpoint ?? "");
3633
- const useHostedOauth = (opts.ide === "cursor" || opts.ide === "claude") && !opts.stdio && !opts.ci && !opts.allProjects && canDeriveHostedUrl;
3634
- if ((opts.ide === "cursor" || opts.ide === "claude") && !opts.stdio && !opts.ci && !opts.allProjects && !canDeriveHostedUrl) {
3635
- console.log("Note: endpoint does not end in /api \u2014 cannot derive the hosted MCP URL; writing a local stdio entry instead.");
3636
- }
3637
- const hostedMcpUrl = (config.endpoint ?? "").replace(/\/api\/?$/, "/mcp");
3638
- const hostedServerBlock = { url: hostedMcpUrl };
3639
- const inlineKey = !!opts.inlineKey;
3640
- const mcpServerBlock = buildMcpServerBlock({
3978
+ const writeEntry = async (name, serverBlock) => {
3979
+ try {
3980
+ await writeMcpServerEntry({ configPath, serverName: name, serverBlock });
3981
+ } catch (err) {
3982
+ process.stderr.write(`error: ${err instanceof Error ? err.message : String(err)}
3983
+ `);
3984
+ process.exit(1);
3985
+ }
3986
+ };
3987
+ const { block: chosenBlock, hosted: useHostedOauth, note: note2 } = buildSetupServerBlock({
3988
+ ide,
3641
3989
  endpoint: config.endpoint,
3642
3990
  projectId: config.projectId ?? "",
3643
3991
  apiKey: config.apiKey,
3992
+ stdio: useStdio,
3993
+ allProjects: Boolean(opts.allProjects),
3644
3994
  inlineKey
3645
3995
  });
3646
- const configDir = nodePath2.isAbsolute(ideEntry.dir) ? ideEntry.dir : nodePath2.join(cwd, ideEntry.dir);
3647
- const configPath = nodePath2.join(configDir, ideEntry.file);
3648
- if (ideEntry.format === "mcp-json") {
3996
+ if (note2) console.log(note2);
3997
+ if (target.format === "mcp-json") {
3649
3998
  if (allProjectsList && allProjectsList.length > 0) {
3650
3999
  for (const p3 of allProjectsList) {
3651
4000
  const pServerName = buildMcpServerName({ projectId: p3.id, projectName: p3.name ?? void 0 });
@@ -3653,66 +4002,72 @@ The command reads credentials from ~/.config/mushi/config.json. If you are not l
3653
4002
  endpoint: config.endpoint,
3654
4003
  projectId: p3.id,
3655
4004
  apiKey: config.apiKey,
4005
+ client: ide,
3656
4006
  inlineKey
3657
4007
  });
3658
4008
  if (!opts.dryRun) {
3659
- await writeMcpServerEntry({ configPath, serverName: pServerName, serverBlock: pBlock });
4009
+ await writeEntry(pServerName, pBlock);
3660
4010
  }
3661
4011
  }
3662
4012
  if (opts.dryRun) {
3663
4013
  console.log(`[dry-run] Would add ${allProjectsList.length} mushi-* entries to ${configPath}`);
3664
4014
  } else {
3665
4015
  console.log(`\u2713 Added ${allProjectsList.length} mushi-* server entries (${allProjectsList.map((p3) => p3.name ?? p3.id.slice(0, 8)).join(", ")})`);
4016
+ if (!inlineKey) printKeyExportHint(ide);
3666
4017
  }
3667
4018
  } else {
3668
- const chosenBlock = useHostedOauth ? hostedServerBlock : mcpServerBlock;
3669
4019
  if (opts.dryRun) {
3670
4020
  const preview = JSON.stringify({ mcpServers: { [serverName]: chosenBlock } }, null, 2) + "\n";
3671
- console.log(`[dry-run] Would write ${configPath}:`);
4021
+ console.log(`[dry-run] Would merge into ${configPath}:`);
3672
4022
  console.log(redactKeyForDisplay(preview));
3673
4023
  } else {
3674
- await writeMcpServerEntry({ configPath, serverName, serverBlock: chosenBlock });
4024
+ await writeEntry(serverName, chosenBlock);
3675
4025
  console.log(`\u2713 Written ${configPath}`);
3676
4026
  if (useHostedOauth) {
3677
4027
  console.log(" Hosted MCP with OAuth login \u2014 no API key was written to this file.");
3678
- console.log(` Restart ${opts.ide === "cursor" ? "Cursor" : "Claude Code"}, open the MCP panel (/mcp in Claude Code), pick "${serverName}" and sign in via the browser.`);
4028
+ console.log(` Restart ${ideLabel}, open the MCP panel (/mcp in Claude Code), pick "${serverName}" and sign in via the browser.`);
4029
+ if (ide === "claude") {
4030
+ console.log(" Claude Code asks you to approve servers from .mcp.json the first time \u2014 approve this one.");
4031
+ }
3679
4032
  console.log(" Prefer a local key-based entry (headless/CI)? Re-run with --stdio.");
3680
- } else if (!inlineKey && config.apiKey) {
3681
- printKeyExportHint(config.apiKey);
4033
+ } else if (!inlineKey) {
4034
+ printKeyExportHint(ide);
3682
4035
  }
3683
4036
  }
3684
4037
  }
3685
- } else if (ideEntry.format === "zed") {
4038
+ } else {
3686
4039
  let settings = {};
3687
- if (existsSync3(configPath)) {
4040
+ let raw = null;
4041
+ try {
4042
+ raw = await readFile3(configPath, "utf8");
4043
+ } catch {
4044
+ }
4045
+ if (raw !== null && raw.trim() !== "") {
3688
4046
  try {
3689
- const raw = await readFile3(configPath, "utf8");
3690
4047
  settings = JSON.parse(raw);
3691
4048
  } catch {
4049
+ process.stderr.write(`error: ${configPath} is not plain JSON \u2014 add the "${serverName}" entry by hand (run with --dry-run to print it).
4050
+ `);
4051
+ process.exit(1);
3692
4052
  }
3693
4053
  }
3694
4054
  const servers = settings.context_servers ?? {};
3695
- servers[serverName] = {
3696
- command: {
3697
- path: "npx",
3698
- args: ["-y", MUSHI_MCP_PIN_SPEC],
3699
- env: {
3700
- MUSHI_API_ENDPOINT: config.endpoint,
3701
- MUSHI_PROJECT_ID: config.projectId ?? "",
3702
- MUSHI_API_KEY: inlineKey ? config.apiKey : "${MUSHI_API_KEY}"
3703
- }
3704
- },
3705
- settings: {}
3706
- };
4055
+ servers[serverName] = buildZedServerBlock({
4056
+ endpoint: config.endpoint,
4057
+ projectId: config.projectId ?? "",
4058
+ apiKey: config.apiKey,
4059
+ inlineKey
4060
+ });
3707
4061
  settings.context_servers = servers;
3708
4062
  const output = JSON.stringify(settings, null, 2) + "\n";
3709
4063
  if (opts.dryRun) {
3710
4064
  console.log(`[dry-run] Would write ${configPath}:`);
3711
4065
  console.log(redactKeyForDisplay(output));
3712
4066
  } else {
3713
- await mkdir(configDir, { recursive: true });
4067
+ await mkdir(target.dir, { recursive: true });
3714
4068
  await writeFile3(configPath, output, "utf8");
3715
4069
  console.log(`\u2713 Written ${configPath}`);
4070
+ if (!inlineKey) printKeyExportHint(ide);
3716
4071
  }
3717
4072
  }
3718
4073
  if (opts.withRules) {
@@ -3740,7 +4095,7 @@ The command reads credentials from ~/.config/mushi/config.json. If you are not l
3740
4095
  "<!-- run `mushi sync-lessons` to refresh -->",
3741
4096
  ""
3742
4097
  ].join("\n");
3743
- if (opts.ide === "cursor") {
4098
+ if (ide === "cursor") {
3744
4099
  const rulesPath = nodePath2.join(cwd, ".cursorrules");
3745
4100
  if (opts.dryRun) {
3746
4101
  console.log(`[dry-run] Would write ${rulesPath}`);
@@ -3748,7 +4103,7 @@ The command reads credentials from ~/.config/mushi/config.json. If you are not l
3748
4103
  await writeFile3(rulesPath, rulesContent, "utf8");
3749
4104
  console.log(`\u2713 Written .cursorrules`);
3750
4105
  }
3751
- } else if (opts.ide === "claude") {
4106
+ } else if (ide === "claude") {
3752
4107
  const rulesDir = nodePath2.join(cwd, ".claude", "rules");
3753
4108
  const rulesPath = nodePath2.join(rulesDir, "mushi.md");
3754
4109
  if (opts.dryRun) {
@@ -3775,7 +4130,7 @@ The command reads credentials from ~/.config/mushi/config.json. If you are not l
3775
4130
  }
3776
4131
  );
3777
4132
  if (probeRes.ok) {
3778
- console.log("\u2713 MCP key valid \u2014 restart Cursor (or your IDE) to activate");
4133
+ console.log(`\u2713 MCP key valid \u2014 restart ${ideLabel} to activate`);
3779
4134
  if (!process.env.MUSHI_NO_TELEMETRY) void fetch(
3780
4135
  `${config.endpoint.replace(/\/$/, "")}/v1/cli/funnel`,
3781
4136
  {
@@ -3811,17 +4166,17 @@ The command reads credentials from ~/.config/mushi/config.json. If you are not l
3811
4166
  if (answer === "" || answer === "y" || answer === "yes") {
3812
4167
  await runLogin({ endpoint: opts.endpoint, upgradeScope: true, suppressPostLoginBanner: true });
3813
4168
  const fresh = loadConfig();
3814
- if (fresh.apiKey && ideEntry.format === "mcp-json" && !allProjectsList) {
3815
- await writeMcpServerEntry({
3816
- configPath,
4169
+ if (fresh.apiKey && target.format === "mcp-json" && !allProjectsList) {
4170
+ await writeEntry(
3817
4171
  serverName,
3818
- serverBlock: buildMcpServerBlock({
4172
+ buildMcpServerBlock({
3819
4173
  endpoint: fresh.endpoint ?? config.endpoint,
3820
4174
  projectId: fresh.projectId ?? config.projectId ?? "",
3821
4175
  apiKey: fresh.apiKey,
4176
+ client: ide,
3822
4177
  inlineKey
3823
4178
  })
3824
- });
4179
+ );
3825
4180
  console.log(`\u2713 Rewrote ${configPath} with the upgraded key`);
3826
4181
  const reprobe = await fetch(
3827
4182
  `${(fresh.endpoint ?? config.endpoint)?.replace(/\/$/, "")}/v1/admin/mcp/account-overview`,
@@ -3835,7 +4190,7 @@ The command reads credentials from ~/.config/mushi/config.json. If you are not l
3835
4190
  ).catch(() => null);
3836
4191
  if (reprobe?.ok) {
3837
4192
  upgraded = true;
3838
- console.log("\u2713 MCP key valid \u2014 restart Cursor (or your IDE) to activate");
4193
+ console.log(`\u2713 MCP key valid \u2014 restart ${ideLabel} to activate`);
3839
4194
  } else {
3840
4195
  console.log(`\u26A0 Re-probe after upgrade returned HTTP ${reprobe?.status ?? "error"} \u2014 run \`mushi doctor --mcp\`.`);
3841
4196
  }
@@ -3860,24 +4215,23 @@ The command reads credentials from ~/.config/mushi/config.json. If you are not l
3860
4215
  }
3861
4216
  }
3862
4217
  console.log("");
3863
- console.log(`Done! Restart ${opts.ide === "cursor" ? "Cursor" : opts.ide === "claude" ? "Claude Code" : opts.ide} and ask: "list mushi tools"`);
4218
+ console.log(`Done! Restart ${ideLabel} and ask: "list mushi tools"`);
3864
4219
  if (!opts.withRules) {
3865
4220
  console.log(`Tip: run with --with-rules to also write the lesson-library coding hook.`);
3866
4221
  }
3867
- const configRelPath = nodePath2.isAbsolute(ideEntry.dir) ? configPath : nodePath2.relative(cwd, configPath);
3868
- if (useHostedOauth) {
4222
+ const configRelPath = target.repoLocal ? nodePath2.relative(cwd, configPath) : configPath;
4223
+ if (!inlineKey) {
3869
4224
  console.log(`
3870
- Note: ${configRelPath} holds no secrets (OAuth login) \u2014 safe to commit and share with your team.`);
3871
- } else if (!nodePath2.isAbsolute(ideEntry.dir)) {
4225
+ Note: ${configRelPath} holds no secrets \u2014 safe to commit and share with your team.`);
4226
+ } else if (target.repoLocal) {
3872
4227
  const gitignorePath = nodePath2.join(cwd, ".gitignore");
3873
4228
  const ignoreLine = configRelPath.replaceAll("\\", "/");
3874
4229
  try {
3875
- const { readFile: readFile4, writeFile: writeFile4 } = await import("fs/promises");
3876
- const existing = await readFile4(gitignorePath, "utf8").catch(() => "");
4230
+ const existing = await readFile3(gitignorePath, "utf8").catch(() => "");
3877
4231
  const lines = existing.split(/\r?\n/);
3878
4232
  if (!lines.some((l) => l.trim() === ignoreLine)) {
3879
4233
  const sep = existing.endsWith("\n") || existing === "" ? "" : "\n";
3880
- await writeFile4(
4234
+ await writeFile3(
3881
4235
  gitignorePath,
3882
4236
  `${existing}${sep}# Mushi MCP config holds a plaintext API key
3883
4237
  ${ignoreLine}
@@ -4333,8 +4687,21 @@ async function planUpgrade(cwd) {
4333
4687
  installCmd: toBump.length > 0 ? installCommand(pm, toBump) : null
4334
4688
  };
4335
4689
  }
4690
+ function evaluateUpgradeCheckExit(plan) {
4691
+ if (plan.entries.length === 0) return 1;
4692
+ const semverEntries = plan.entries.filter((e) => /^\d/.test(e.current.replace(/^[\^~>=<]*/, "")));
4693
+ if (semverEntries.length === 0) return 0;
4694
+ if (semverEntries.every((e) => e.latest === null)) return 2;
4695
+ if (plan.entries.some((e) => e.willUpgrade)) return 1;
4696
+ return 0;
4697
+ }
4336
4698
  async function runUpgrade(opts = {}) {
4337
4699
  const plan = await planUpgrade(opts.cwd ?? process.cwd());
4700
+ if (opts.check) {
4701
+ const exit = evaluateUpgradeCheckExit(plan);
4702
+ const message = exit === 0 ? "All installed Mushi packages are already at the latest stable version." : exit === 2 ? "Could not reach the npm registry to check for updates \u2014 try again in a moment." : plan.entries.length === 0 ? "No @mushi-mushi/* packages in package.json \u2014 run `mushi init` first." : "One or more Mushi packages are behind the latest stable npm release.";
4703
+ return { plan, upgraded: false, message };
4704
+ }
4338
4705
  if (plan.entries.length === 0) {
4339
4706
  return {
4340
4707
  plan,
@@ -4483,28 +4850,37 @@ ${via}` };
4483
4850
 
4484
4851
  // src/commands/upgrade-cli.ts
4485
4852
  function registerUpgradeCommand(program2) {
4486
- program2.command("upgrade").description("Bump installed @mushi-mushi/* packages to the latest stable npm release").option("--cwd <path>", "Target repo (default: cwd)").option("--self", "Upgrade the globally-installed `mushi` CLI itself (not the project SDK deps)").option("--dry-run", "Print the install command without running it").option("--json", "Machine-readable plan + result").addHelpText("after", `
4853
+ program2.command("upgrade").description("Bump installed @mushi-mushi/* packages to the latest stable npm release").option("--cwd <path>", "Target repo (default: cwd)").option("--self", "Upgrade the globally-installed `mushi` CLI itself (not the project SDK deps)").option("--dry-run", "Print the install command without running it").option("--check", "Plan only: exit 0 current, 1 outdated, 2 registry unreachable").option("--json", "Machine-readable plan + result").addHelpText("after", `
4487
4854
  Examples:
4488
4855
  mushi upgrade
4489
4856
  mushi upgrade --dry-run
4857
+ mushi upgrade --check # CI freshness (no install)
4490
4858
  mushi upgrade --self # upgrade the CLI binary itself
4491
4859
  mushi upgrade --self --dry-run
4492
4860
  mushi upgrade --cwd ../glot.it`).action(async (opts) => {
4493
4861
  if (opts.self) {
4494
- const selfResult = await runSelfUpgrade({ dryRun: opts.dryRun });
4862
+ const selfResult = await runSelfUpgrade({ dryRun: opts.dryRun || opts.check });
4495
4863
  if (opts.json) {
4496
4864
  console.log(JSON.stringify(selfResult, null, 2));
4497
4865
  } else {
4498
4866
  console.log(selfResult.message);
4499
4867
  }
4500
- if (!selfResult.upgraded && selfResult.command && !opts.dryRun) {
4868
+ if (!selfResult.upgraded && selfResult.command && !opts.dryRun && !opts.check) {
4501
4869
  process.exit(1);
4502
4870
  }
4503
4871
  return;
4504
4872
  }
4505
- const result = await runUpgrade({ cwd: opts.cwd, dryRun: opts.dryRun, json: opts.json });
4873
+ const result = await runUpgrade({
4874
+ cwd: opts.cwd,
4875
+ dryRun: opts.dryRun,
4876
+ json: opts.json,
4877
+ check: opts.check
4878
+ });
4506
4879
  if (opts.json) {
4507
- console.log(JSON.stringify(result, null, 2));
4880
+ console.log(JSON.stringify({
4881
+ ...result,
4882
+ ...opts.check ? { exit: evaluateUpgradeCheckExit(result.plan) } : {}
4883
+ }, null, 2));
4508
4884
  } else {
4509
4885
  console.log(result.message);
4510
4886
  for (const e of result.plan.entries) {
@@ -4512,6 +4888,9 @@ Examples:
4512
4888
  console.log(` ${e.name}@${e.current} ${tag}`);
4513
4889
  }
4514
4890
  }
4891
+ if (opts.check) {
4892
+ process.exit(evaluateUpgradeCheckExit(result.plan));
4893
+ }
4515
4894
  if (!result.upgraded && result.plan.entries.some((e) => e.willUpgrade) && !opts.dryRun) {
4516
4895
  process.exit(1);
4517
4896
  }
@@ -4521,10 +4900,18 @@ Examples:
4521
4900
 
4522
4901
  // src/commands/connect-cli.ts
4523
4902
  function registerConnectCommand(program2) {
4524
- 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", `
4903
+ 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) saved to the CLI config \u2014 or set MUSHI_API_KEY to keep it out of shell history").option(
4904
+ "--sdk-key <key>",
4905
+ "Ingest-only (report:write) key for the SDK env vars \u2014 or set MUSHI_SDK_KEY. Default: the key saved for this project, else --api-key if it is ingest-only"
4906
+ ).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", `
4525
4907
  Examples:
4526
4908
  MUSHI_API_KEY=mushi_xxx mushi connect --project-id <uuid> --endpoint https://<ref>.supabase.co/functions/v1/api --wait
4527
- mushi connect --api-key mushi_xxx --project-id <uuid> --endpoint <url> --no-ide`).action(async (opts) => {
4909
+ mushi connect --api-key mushi_xxx --project-id <uuid> --endpoint <url> --no-ide
4910
+ MUSHI_API_KEY=mushi_cli MUSHI_SDK_KEY=mushi_ingest mushi connect --project-id <uuid> --endpoint <url>
4911
+
4912
+ SDK env vars (VITE_ / NEXT_PUBLIC_ / EXPO_PUBLIC_ \u2026) ship inside your app bundle,
4913
+ so connect only writes a key the backend confirms is ingest-only (report:write).
4914
+ A key that can read reports is refused for .env.local; it still works for the CLI and MCP.`).action(async (opts) => {
4528
4915
  const apiKey = process.env.MUSHI_API_KEY ?? opts.apiKey;
4529
4916
  if (!apiKey) {
4530
4917
  console.error("Provide the API key via the MUSHI_API_KEY env var (recommended) or --api-key <key>.");
@@ -4532,6 +4919,7 @@ Examples:
4532
4919
  }
4533
4920
  const result = await runConnect({
4534
4921
  apiKey,
4922
+ sdkKey: process.env.MUSHI_SDK_KEY?.trim() || opts.sdkKey,
4535
4923
  projectId: opts.projectId,
4536
4924
  endpoint: opts.endpoint,
4537
4925
  cwd: opts.cwd,
@@ -4540,7 +4928,7 @@ Examples:
4540
4928
  wait: opts.wait,
4541
4929
  waitTimeoutSec: parseInt(opts.waitTimeout, 10) || 120,
4542
4930
  json: opts.json
4543
- });
4931
+ }, loadConfig());
4544
4932
  if (opts.json) {
4545
4933
  console.log(JSON.stringify(result, null, 2));
4546
4934
  } else {
@@ -6212,7 +6600,7 @@ function registerSelfhostCommands(program2) {
6212
6600
  "Base URL of your deployed admin console (e.g. https://your-domain.example.com/admin)"
6213
6601
  ).option(
6214
6602
  "--endpoint <url>",
6215
- "Mushi API endpoint to use for the proof step (default: from ~/.config/mushi/config.json)"
6603
+ `Mushi API endpoint to use for the proof step (default: from ${CONFIG_PATH})`
6216
6604
  ).option(
6217
6605
  "--skip-deploy",
6218
6606
  "Skip function deploys (run db push + secrets only)"
@@ -6241,7 +6629,7 @@ After "up" completes:
6241
6629
  "Check the health of a self-hosted deployment (calls GET /v1/admin/doctor)"
6242
6630
  ).option(
6243
6631
  "--endpoint <url>",
6244
- "Mushi API endpoint (default: from ~/.config/mushi/config.json)"
6632
+ `Mushi API endpoint (default: from ${CONFIG_PATH})`
6245
6633
  ).option("--json", "Machine-readable JSON output").addHelpText(
6246
6634
  "after",
6247
6635
  `