@mushi-mushi/cli 0.12.0 → 0.14.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
@@ -35,10 +35,11 @@ function loadConfig(path = CONFIG_PATH) {
35
35
  } else if (path === CONFIG_PATH && existsSync(LEGACY_CONFIG_PATH)) {
36
36
  file = migrateLegacyConfig() ?? {};
37
37
  }
38
+ const endpointFromEnv = process.env["MUSHI_API_ENDPOINT"] ?? process.env["MUSHI_ENDPOINT"] ?? void 0;
38
39
  const fromEnv = {
39
40
  ...process.env["MUSHI_API_KEY"] ? { apiKey: process.env["MUSHI_API_KEY"] } : {},
40
41
  ...process.env["MUSHI_PROJECT_ID"] ? { projectId: process.env["MUSHI_PROJECT_ID"] } : {},
41
- ...process.env["MUSHI_API_ENDPOINT"] ? { endpoint: process.env["MUSHI_API_ENDPOINT"] } : {}
42
+ ...endpointFromEnv ? { endpoint: endpointFromEnv } : {}
42
43
  };
43
44
  return { ...file, ...fromEnv };
44
45
  }
@@ -444,7 +445,7 @@ function normalizeEndpoint(url) {
444
445
  var REGISTRY = "https://registry.npmjs.org";
445
446
  var DEFAULT_TIMEOUT_MS = 2e3;
446
447
  async function checkFreshness(packageName, currentVersion, opts = {}) {
447
- if (process.env.MUSHI_NO_UPDATE_CHECK === "1") return null;
448
+ if (!opts.ignoreOptOut && process.env.MUSHI_NO_UPDATE_CHECK === "1") return null;
448
449
  const registry = opts.registry ?? REGISTRY;
449
450
  const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
450
451
  const controller = new AbortController();
@@ -482,11 +483,13 @@ function isNewerStableVersion(latest, current) {
482
483
  return lc > cc;
483
484
  }
484
485
  function stripPreRelease(version) {
485
- const idx = version.indexOf("-");
486
+ const idx = version.search(/[-+]/);
486
487
  return idx === -1 ? version : version.slice(0, idx);
487
488
  }
488
489
  function hasPreReleaseTag(version) {
489
- return version.includes("-");
490
+ const plus = version.indexOf("+");
491
+ const hyphen = version.indexOf("-");
492
+ return hyphen !== -1 && (plus === -1 || hyphen < plus);
490
493
  }
491
494
  function parse(version) {
492
495
  const parts = version.split(".").map((part) => Number(part));
@@ -595,7 +598,7 @@ function getFrameworkFromPkg(pkg) {
595
598
  }
596
599
 
597
600
  // src/version.ts
598
- var MUSHI_CLI_VERSION = true ? "0.12.0" : "0.0.0-dev";
601
+ var MUSHI_CLI_VERSION = true ? "0.14.0" : "0.0.0-dev";
599
602
 
600
603
  // src/init.ts
601
604
  var ENV_FILES = [".env.local", ".env"];
@@ -744,7 +747,7 @@ async function installPackages(pm, packages, cwd) {
744
747
  function runCommand(pm, packages, cwd) {
745
748
  const verb = pm === "npm" ? "install" : "add";
746
749
  const command = process.platform === "win32" ? `${pm}.cmd` : pm;
747
- return new Promise((resolve2, reject) => {
750
+ return new Promise((resolve4, reject) => {
748
751
  const child = spawn(command, [verb, ...packages], {
749
752
  stdio: "inherit",
750
753
  shell: false,
@@ -753,7 +756,7 @@ function runCommand(pm, packages, cwd) {
753
756
  });
754
757
  child.on("error", reject);
755
758
  child.on("exit", (code) => {
756
- if (code === 0) resolve2();
759
+ if (code === 0) resolve4();
757
760
  else reject(new Error(`${pm} exited with code ${code ?? "null"}`));
758
761
  });
759
762
  });
@@ -1108,11 +1111,11 @@ async function findMapFiles(dir) {
1108
1111
  return results;
1109
1112
  }
1110
1113
  function fileHash(path) {
1111
- return new Promise((resolve2, reject) => {
1114
+ return new Promise((resolve4, reject) => {
1112
1115
  const hash = createHash("sha256");
1113
1116
  const stream = createReadStream(path);
1114
1117
  stream.on("data", (chunk) => hash.update(chunk));
1115
- stream.on("end", () => resolve2(hash.digest("hex")));
1118
+ stream.on("end", () => resolve4(hash.digest("hex")));
1116
1119
  stream.on("error", reject);
1117
1120
  });
1118
1121
  }
@@ -1416,13 +1419,83 @@ function renderNudgeExplainer(phase) {
1416
1419
  return lines.join("\n");
1417
1420
  }
1418
1421
 
1422
+ // src/heartbeat-wait.ts
1423
+ var IngestSetupHttpError = class extends Error {
1424
+ status;
1425
+ constructor(status) {
1426
+ super(`ingest-setup HTTP ${status}`);
1427
+ this.name = "IngestSetupHttpError";
1428
+ this.status = status;
1429
+ }
1430
+ };
1431
+ var NON_RETRYABLE_STATUSES = /* @__PURE__ */ new Set([401, 403, 404]);
1432
+ async function fetchIngestSetup(config, doFetch = globalThis.fetch) {
1433
+ const safeKey = config.apiKey.replace(/[\r\n]/g, "");
1434
+ const res = await doFetch(`${config.endpoint}/v1/sync/ingest-setup`, {
1435
+ headers: {
1436
+ Authorization: `Bearer ${safeKey}`,
1437
+ "X-Mushi-Api-Key": safeKey,
1438
+ ...config.projectId ? { "X-Mushi-Project": config.projectId } : {}
1439
+ },
1440
+ signal: AbortSignal.timeout(8e3)
1441
+ });
1442
+ if (!res.ok) {
1443
+ if (NON_RETRYABLE_STATUSES.has(res.status)) throw new IngestSetupHttpError(res.status);
1444
+ return null;
1445
+ }
1446
+ const body = await res.json();
1447
+ return body.ok && body.data ? body.data : null;
1448
+ }
1449
+ async function waitForIngestReady(options) {
1450
+ const doFetch = options.fetch ?? globalThis.fetch;
1451
+ const maxAttempts = options.maxAttempts ?? 40;
1452
+ const intervalMs = options.intervalMs ?? 3e3;
1453
+ let lastPayload = null;
1454
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
1455
+ if (options.signal?.aborted) {
1456
+ return { ok: false, payload: lastPayload, attempts: attempt - 1, reason: "aborted" };
1457
+ }
1458
+ try {
1459
+ lastPayload = await fetchIngestSetup(
1460
+ { endpoint: options.endpoint, apiKey: options.apiKey, projectId: options.projectId },
1461
+ doFetch
1462
+ );
1463
+ if (lastPayload) {
1464
+ options.onPoll?.(lastPayload, attempt);
1465
+ if (lastPayload.ready) {
1466
+ return { ok: true, payload: lastPayload, attempts: attempt, reason: "ready" };
1467
+ }
1468
+ const sdkStep = lastPayload.steps.find((s) => s.id === "sdk_installed");
1469
+ if (sdkStep?.complete) {
1470
+ return { ok: true, payload: lastPayload, attempts: attempt, reason: "heartbeat" };
1471
+ }
1472
+ }
1473
+ } catch (err) {
1474
+ const msg = err instanceof Error ? err.message : String(err);
1475
+ if (err instanceof IngestSetupHttpError) {
1476
+ return { ok: false, payload: lastPayload, attempts: attempt, reason: "unauthorized", error: msg };
1477
+ }
1478
+ if (options.signal?.aborted) {
1479
+ return { ok: false, payload: lastPayload, attempts: attempt, reason: "aborted", error: msg };
1480
+ }
1481
+ if (attempt === maxAttempts) {
1482
+ return { ok: false, payload: lastPayload, attempts: attempt, reason: "fetch-error", error: msg };
1483
+ }
1484
+ }
1485
+ if (attempt < maxAttempts) {
1486
+ await new Promise((r) => setTimeout(r, intervalMs));
1487
+ }
1488
+ }
1489
+ return { ok: false, payload: lastPayload, attempts: maxAttempts, reason: "timeout" };
1490
+ }
1491
+
1419
1492
  // src/doctor.ts
1420
1493
  function checkCliConfig(config) {
1421
1494
  return [
1422
1495
  {
1423
1496
  name: "CLI config file",
1424
1497
  ok: Boolean(config.endpoint),
1425
- detail: config.endpoint ? `endpoint=${config.endpoint}` : "No endpoint in ~/.mushirc \u2014 run `mushi init` or `mushi config endpoint <url>`"
1498
+ detail: config.endpoint ? `endpoint=${config.endpoint}` : "No endpoint \u2014 set MUSHI_API_ENDPOINT, run `mushi connect`, or `mushi config endpoint <url>`"
1426
1499
  },
1427
1500
  {
1428
1501
  name: "API key configured",
@@ -1453,11 +1526,11 @@ async function checkEndpointReachability(endpoint, doFetch = globalThis.fetch) {
1453
1526
  }
1454
1527
  async function checkSdkInstall(cwd) {
1455
1528
  try {
1456
- const { readFile: readFile2 } = await import("fs/promises");
1457
- const { join: join6, resolve: resolve2 } = await import("path");
1458
- const root = resolve2(cwd);
1459
- const pkgPath = join6(root, "package.json");
1460
- const pkg = JSON.parse(await readFile2(pkgPath, "utf8"));
1529
+ const { readFile: readFile3 } = await import("fs/promises");
1530
+ const { join: join7, resolve: resolve4 } = await import("path");
1531
+ const root = resolve4(cwd);
1532
+ const pkgPath = join7(root, "package.json");
1533
+ const pkg = JSON.parse(await readFile3(pkgPath, "utf8"));
1461
1534
  const deps = { ...pkg.dependencies ?? {}, ...pkg.devDependencies ?? {} };
1462
1535
  const sdks = [
1463
1536
  "@mushi-mushi/react",
@@ -1503,7 +1576,7 @@ async function checkServerPreflight(config, doFetch = globalThis.fetch) {
1503
1576
  return serverChecks.map((sc) => ({
1504
1577
  name: `[server] ${sc.label}`,
1505
1578
  ok: sc.ready,
1506
- detail: sc.hint
1579
+ detail: sc.ready ? "" : sc.hint
1507
1580
  }));
1508
1581
  }
1509
1582
  const text2 = await res.text().catch(() => "");
@@ -1519,6 +1592,132 @@ async function checkServerPreflight(config, doFetch = globalThis.fetch) {
1519
1592
  return [{ name: "Server preflight", ok: false, detail: `Fetch failed: ${msg}` }];
1520
1593
  }
1521
1594
  }
1595
+ async function checkIngestSetup(config, doFetch = globalThis.fetch) {
1596
+ if (!config.apiKey || !config.endpoint) {
1597
+ return [
1598
+ {
1599
+ name: "Ingest setup",
1600
+ ok: false,
1601
+ detail: "Need apiKey and endpoint. Run `mushi connect`."
1602
+ }
1603
+ ];
1604
+ }
1605
+ try {
1606
+ const data = await fetchIngestSetup(
1607
+ { endpoint: config.endpoint, apiKey: config.apiKey, projectId: config.projectId },
1608
+ doFetch
1609
+ );
1610
+ if (!data) {
1611
+ return [{ name: "Ingest setup", ok: false, detail: "Request to /v1/sync/ingest-setup failed or returned invalid payload" }];
1612
+ }
1613
+ const steps = data.steps ?? [];
1614
+ const checks = steps.filter((s) => s.required).map((s) => ({
1615
+ name: `[ingest] ${s.label}`,
1616
+ ok: s.complete,
1617
+ detail: s.complete ? "" : s.hint ?? ""
1618
+ }));
1619
+ const diag = data.diagnostic;
1620
+ if (diag?.last_sdk_seen_at) {
1621
+ checks.push({
1622
+ name: "[ingest] Last SDK heartbeat",
1623
+ ok: true,
1624
+ detail: `${diag.last_sdk_seen_at}${diag.last_sdk_endpoint_host ? ` @ ${diag.last_sdk_endpoint_host}` : ""}`
1625
+ });
1626
+ }
1627
+ return checks.length > 0 ? checks : [{ name: "Ingest setup", ok: false, detail: "Empty response from /v1/sync/ingest-setup" }];
1628
+ } catch (err) {
1629
+ const msg = err instanceof Error ? err.message : String(err);
1630
+ return [{ name: "Ingest setup", ok: false, detail: `Fetch failed: ${msg}` }];
1631
+ }
1632
+ }
1633
+ async function checkQaStoriesHealth(config, doFetch = globalThis.fetch) {
1634
+ if (!config.projectId || !config.apiKey || !config.endpoint) {
1635
+ return [
1636
+ {
1637
+ name: "QA stories health",
1638
+ ok: false,
1639
+ detail: "Need projectId, apiKey, and endpoint for QA story checks."
1640
+ }
1641
+ ];
1642
+ }
1643
+ const checks = [];
1644
+ try {
1645
+ const storiesRes = await doFetch(
1646
+ `${config.endpoint}/v1/admin/projects/${config.projectId}/qa-coverage`,
1647
+ {
1648
+ headers: {
1649
+ Authorization: `Bearer ${config.apiKey}`,
1650
+ "X-Mushi-Api-Key": config.apiKey,
1651
+ "X-Mushi-Project": config.projectId
1652
+ },
1653
+ signal: AbortSignal.timeout(8e3)
1654
+ }
1655
+ );
1656
+ if (!storiesRes.ok) {
1657
+ checks.push({ name: "[qa] Fetch QA stories", ok: false, detail: `HTTP ${storiesRes.status}` });
1658
+ return checks;
1659
+ }
1660
+ const storiesBody = await storiesRes.json();
1661
+ const stories2 = storiesBody.data?.coverage ?? [];
1662
+ const enabled = stories2.filter((s) => s.enabled);
1663
+ if (enabled.length === 0) {
1664
+ checks.push({ name: "[qa] Enabled QA stories", ok: true, detail: "No enabled stories \u2014 create one at /qa-coverage" });
1665
+ return checks;
1666
+ }
1667
+ checks.push({
1668
+ name: "[qa] Enabled QA stories",
1669
+ ok: true,
1670
+ detail: `${enabled.length} enabled story/stories configured`
1671
+ });
1672
+ const slackRes = await doFetch(
1673
+ `${config.endpoint}/v1/admin/projects/${config.projectId}/integrations/probe/slack`,
1674
+ {
1675
+ method: "POST",
1676
+ headers: {
1677
+ Authorization: `Bearer ${config.apiKey}`,
1678
+ "X-Mushi-Api-Key": config.apiKey,
1679
+ "X-Mushi-Project": config.projectId
1680
+ },
1681
+ signal: AbortSignal.timeout(6e3)
1682
+ }
1683
+ );
1684
+ const slackBody = slackRes.ok ? await slackRes.json() : null;
1685
+ const slackOk = slackBody?.status === "ok";
1686
+ checks.push({
1687
+ name: "[qa] Slack notifications configured",
1688
+ ok: slackOk,
1689
+ detail: slackOk ? "Slack connected \u2014 failures will notify your channel" : "Slack not connected \u2014 you won't be notified when stories fail. Visit /integrations \u2192 Add to Slack."
1690
+ });
1691
+ const fcRes = await doFetch(
1692
+ `${config.endpoint}/v1/admin/projects/${config.projectId}/integrations/probe/firecrawl`,
1693
+ {
1694
+ method: "POST",
1695
+ headers: {
1696
+ Authorization: `Bearer ${config.apiKey}`,
1697
+ "X-Mushi-Api-Key": config.apiKey,
1698
+ "X-Mushi-Project": config.projectId
1699
+ },
1700
+ signal: AbortSignal.timeout(6e3)
1701
+ }
1702
+ );
1703
+ const fcBody = fcRes.ok ? await fcRes.json() : null;
1704
+ const hasFirecrawlStories = enabled.some(
1705
+ (s) => !s.browser_provider || s.browser_provider === "firecrawl_actions"
1706
+ );
1707
+ if (hasFirecrawlStories) {
1708
+ const fcOk = fcBody?.status === "ok";
1709
+ checks.push({
1710
+ name: "[qa] Firecrawl API key configured",
1711
+ ok: fcOk,
1712
+ detail: fcOk ? "Firecrawl key is resolvable \u2014 stories will run without Unauthorized errors" : "No Firecrawl key found \u2014 enabled stories using firecrawl_actions will 401. Add a key at /integrations \u2192 BYOK keys."
1713
+ });
1714
+ }
1715
+ } catch (err) {
1716
+ const msg = err instanceof Error ? err.message : String(err);
1717
+ checks.push({ name: "[qa] QA stories health", ok: false, detail: `Fetch failed: ${msg}` });
1718
+ }
1719
+ return checks;
1720
+ }
1522
1721
  async function runDoctor(config, options = {}) {
1523
1722
  const doFetch = options.fetch ?? globalThis.fetch;
1524
1723
  const checks = [];
@@ -1532,6 +1731,14 @@ async function runDoctor(config, options = {}) {
1532
1731
  const serverChecks = await checkServerPreflight(config, doFetch);
1533
1732
  checks.push(...serverChecks);
1534
1733
  }
1734
+ if (options.ingest) {
1735
+ const ingestChecks = await checkIngestSetup(config, doFetch);
1736
+ checks.push(...ingestChecks);
1737
+ }
1738
+ if (options.qaStories) {
1739
+ const qaChecks = await checkQaStoriesHealth(config, doFetch);
1740
+ checks.push(...qaChecks);
1741
+ }
1535
1742
  return { checks, ready: checks.every((c) => c.ok) };
1536
1743
  }
1537
1744
  function formatDoctorResult(result) {
@@ -1540,7 +1747,7 @@ function formatDoctorResult(result) {
1540
1747
  const lines = [];
1541
1748
  for (const c of result.checks) {
1542
1749
  lines.push(`${c.ok ? PASS : FAIL} ${c.name}`);
1543
- lines.push(` ${c.detail}`);
1750
+ if (c.detail) lines.push(` ${c.detail}`);
1544
1751
  }
1545
1752
  const failed = result.checks.filter((c) => !c.ok);
1546
1753
  if (failed.length === 0) {
@@ -1553,6 +1760,237 @@ ${failed.length} check${failed.length === 1 ? "" : "s"} failed.`);
1553
1760
  return lines.join("\n");
1554
1761
  }
1555
1762
 
1763
+ // src/upgrade.ts
1764
+ import { resolve as resolve2 } from "path";
1765
+ import { execSync } from "child_process";
1766
+ var SAFE_NPM_VERSION = /^\d+\.\d+\.\d+(-[\w.]+)?$/;
1767
+ var MUSHI_PACKAGES = [
1768
+ "@mushi-mushi/web",
1769
+ "@mushi-mushi/core",
1770
+ "@mushi-mushi/react",
1771
+ "@mushi-mushi/react-native",
1772
+ "@mushi-mushi/capacitor",
1773
+ "@mushi-mushi/node",
1774
+ "@mushi-mushi/cli"
1775
+ ];
1776
+ async function planUpgrade(cwd) {
1777
+ const root = resolve2(cwd);
1778
+ const pkg = readPackageJson(root);
1779
+ const pm = detectPackageManager(root);
1780
+ const deps = {
1781
+ ...pkg?.dependencies ?? {},
1782
+ ...pkg?.devDependencies ?? {}
1783
+ };
1784
+ const installed2 = MUSHI_PACKAGES.filter((name) => deps[name]);
1785
+ const entries = await Promise.all(
1786
+ installed2.map(async (name) => {
1787
+ const current = deps[name] ?? "";
1788
+ const currentCore = current.replace(/^[\^~>=<]*/, "");
1789
+ if (!/^\d/.test(currentCore)) {
1790
+ return { name, current, latest: null, willUpgrade: false };
1791
+ }
1792
+ const freshness = await checkFreshness(name, currentCore, {
1793
+ timeoutMs: 4e3,
1794
+ ignoreOptOut: true
1795
+ });
1796
+ const latest = freshness?.latest ?? null;
1797
+ const safeLatest = latest && SAFE_NPM_VERSION.test(latest) ? latest : null;
1798
+ const willUpgrade = Boolean(freshness?.isOutdated && safeLatest);
1799
+ return {
1800
+ name,
1801
+ current,
1802
+ latest: safeLatest,
1803
+ willUpgrade,
1804
+ migrateToWeb: name === "@mushi-mushi/react" && willUpgrade
1805
+ };
1806
+ })
1807
+ );
1808
+ const toBump = entries.filter((e) => e.willUpgrade && e.latest).map((e) => `${e.name}@${e.latest}`);
1809
+ return {
1810
+ cwd: root,
1811
+ packageManager: pm,
1812
+ entries,
1813
+ installCmd: toBump.length > 0 ? installCommand(pm, toBump) : null
1814
+ };
1815
+ }
1816
+ async function runUpgrade(opts = {}) {
1817
+ const plan = await planUpgrade(opts.cwd ?? process.cwd());
1818
+ if (plan.entries.length === 0) {
1819
+ return {
1820
+ plan,
1821
+ upgraded: false,
1822
+ message: "No @mushi-mushi/* packages in package.json \u2014 run `mushi init` first."
1823
+ };
1824
+ }
1825
+ if (!plan.installCmd) {
1826
+ const semverEntries = plan.entries.filter((e) => /\d/.test(e.current));
1827
+ const allChecksFailed = semverEntries.length > 0 && semverEntries.every((e) => e.latest === null);
1828
+ return {
1829
+ plan,
1830
+ upgraded: false,
1831
+ message: allChecksFailed ? "Could not reach the npm registry to check for updates \u2014 try again in a moment." : "All installed Mushi packages are already at the latest stable version."
1832
+ };
1833
+ }
1834
+ if (opts.dryRun) {
1835
+ return { plan, upgraded: false, message: `[dry-run] Would run: ${plan.installCmd}` };
1836
+ }
1837
+ try {
1838
+ execSync(plan.installCmd, { cwd: plan.cwd, stdio: "inherit", env: process.env });
1839
+ } catch {
1840
+ return {
1841
+ plan,
1842
+ upgraded: false,
1843
+ message: `Install command failed \u2014 fix the error above or run it manually:
1844
+ ${plan.installCmd}`
1845
+ };
1846
+ }
1847
+ const reactEntry = plan.entries.find((e) => e.migrateToWeb);
1848
+ const migrateNote = reactEntry ? "\nNote: @mushi-mushi/react is legacy \u2014 prefer @mushi-mushi/web for Vite/Capacitor/SPA apps." : "";
1849
+ return {
1850
+ plan,
1851
+ upgraded: true,
1852
+ message: `Upgraded Mushi SDK packages.
1853
+ ${plan.installCmd}${migrateNote}`
1854
+ };
1855
+ }
1856
+
1857
+ // src/connect.ts
1858
+ import { appendFile, mkdir, readFile as readFile2, writeFile } from "fs/promises";
1859
+ import { join as join6, resolve as resolve3 } from "path";
1860
+ function envKeyPresent(content, key) {
1861
+ const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1862
+ return new RegExp(`^${escaped}=`, "m").test(content);
1863
+ }
1864
+ async function mergeEnvFile(path, lines) {
1865
+ const block = `
1866
+ # Mushi \u2014 added by mushi connect
1867
+ ${lines.join("\n")}
1868
+ `;
1869
+ let existing = null;
1870
+ try {
1871
+ existing = await readFile2(path, "utf8");
1872
+ } catch {
1873
+ }
1874
+ if (existing !== null) {
1875
+ const needs = lines.filter((line) => {
1876
+ const key = line.split("=")[0];
1877
+ return !envKeyPresent(existing, key);
1878
+ });
1879
+ if (needs.length === 0) return false;
1880
+ await appendFile(path, `
1881
+ # Mushi \u2014 added by mushi connect
1882
+ ${needs.join("\n")}
1883
+ `, "utf8");
1884
+ return true;
1885
+ }
1886
+ await writeFile(path, block, "utf8");
1887
+ return true;
1888
+ }
1889
+ async function ensureMcpJsonGitignored(cwd, messages) {
1890
+ const gitignorePath = join6(cwd, ".gitignore");
1891
+ const patterns = [".cursor/mcp.json", ".cursor/"];
1892
+ let content = null;
1893
+ try {
1894
+ content = await readFile2(gitignorePath, "utf8");
1895
+ } catch {
1896
+ }
1897
+ if (content === null) {
1898
+ messages.push(
1899
+ "\u26A0 No .gitignore found \u2014 .cursor/mcp.json contains your API key. Add `.cursor/mcp.json` before committing."
1900
+ );
1901
+ return;
1902
+ }
1903
+ const covered = patterns.some((p3) => content.split("\n").some((line) => line.trim() === p3 || line.trim() === `${p3}/`));
1904
+ if (covered) return;
1905
+ await appendFile(gitignorePath, "\n# Mushi \u2014 keep MCP credentials out of git\n.cursor/mcp.json\n", "utf8");
1906
+ messages.push("\u2713 Added .cursor/mcp.json to .gitignore (contains API key)");
1907
+ }
1908
+ async function runConnect(opts, baseConfig = {}) {
1909
+ const cwd = resolve3(opts.cwd ?? process.cwd());
1910
+ const endpoint = assertEndpoint(opts.endpoint);
1911
+ const messages = [];
1912
+ const config = {
1913
+ ...baseConfig,
1914
+ apiKey: opts.apiKey,
1915
+ projectId: opts.projectId,
1916
+ endpoint
1917
+ };
1918
+ saveConfig(config);
1919
+ messages.push(`\u2713 Credentials saved to ${CONFIG_PATH}`);
1920
+ let envPath = null;
1921
+ if (opts.writeEnv !== false) {
1922
+ const pkg = readPackageJson(cwd);
1923
+ const framework = detectFramework(cwd, pkg);
1924
+ const lines = envVarsToWrite(opts.apiKey, opts.projectId, framework).split("\n");
1925
+ envPath = join6(cwd, ".env.local");
1926
+ const wrote = await mergeEnvFile(envPath, lines);
1927
+ messages.push(
1928
+ wrote ? `\u2713 Env vars merged into ${envPath}` : `\u2713 Env vars already present in ${envPath} (existing values left untouched)`
1929
+ );
1930
+ }
1931
+ let mcpPath = null;
1932
+ if (opts.wireIde !== false) {
1933
+ const mcpDir = join6(cwd, ".cursor");
1934
+ mcpPath = join6(mcpDir, "mcp.json");
1935
+ const serverName = `mushi-${opts.projectId.slice(0, 8)}`;
1936
+ const mcpServerBlock = {
1937
+ command: "npx",
1938
+ args: ["-y", "@mushi-mushi/mcp@latest"],
1939
+ env: {
1940
+ MUSHI_API_ENDPOINT: endpoint,
1941
+ MUSHI_PROJECT_ID: opts.projectId,
1942
+ MUSHI_API_KEY: opts.apiKey
1943
+ }
1944
+ };
1945
+ let merged = { mcpServers: {} };
1946
+ try {
1947
+ merged = JSON.parse(await readFile2(mcpPath, "utf8"));
1948
+ } catch {
1949
+ }
1950
+ const servers = merged.mcpServers ?? {};
1951
+ servers[serverName] = mcpServerBlock;
1952
+ merged.mcpServers = servers;
1953
+ await mkdir(mcpDir, { recursive: true });
1954
+ await writeFile(mcpPath, JSON.stringify(merged, null, 2) + "\n", "utf8");
1955
+ await ensureMcpJsonGitignored(cwd, messages);
1956
+ messages.push(`\u2713 Wired ${mcpPath} \u2014 restart Cursor and run "list mushi tools"`);
1957
+ }
1958
+ let heartbeat = null;
1959
+ if (opts.wait) {
1960
+ const timeoutSec = opts.waitTimeoutSec ?? 120;
1961
+ const maxAttempts = Math.max(1, Math.ceil(timeoutSec * 1e3 / 3e3));
1962
+ messages.push(`\u2026 Waiting for SDK heartbeat (up to ${timeoutSec}s) \u2014 start your dev server with the snippet installed`);
1963
+ heartbeat = await waitForIngestReady({
1964
+ endpoint,
1965
+ apiKey: opts.apiKey,
1966
+ projectId: opts.projectId,
1967
+ maxAttempts,
1968
+ onPoll: (payload, attempt) => {
1969
+ if (!opts.json && attempt % 3 === 0) {
1970
+ const sdk = payload.steps.find((s) => s.id === "sdk_installed");
1971
+ const seen = payload.diagnostic?.last_sdk_seen_at ?? "never";
1972
+ process.stdout.write(` poll ${attempt}: sdk_installed=${sdk?.complete ? "yes" : "no"} last_seen=${seen}
1973
+ `);
1974
+ }
1975
+ }
1976
+ });
1977
+ if (heartbeat.ok) {
1978
+ const label = heartbeat.reason === "heartbeat" ? "SDK heartbeat detected" : "Ingest setup complete";
1979
+ messages.push(`\u2713 ${label} \u2014 ingest pipeline is live`);
1980
+ } else if (heartbeat.reason === "unauthorized") {
1981
+ messages.push(
1982
+ `\u2717 The backend rejected these credentials (${heartbeat.error ?? "auth error"}). Double-check --api-key, --project-id, and --endpoint, then re-run \`mushi connect --wait\`.`
1983
+ );
1984
+ } else {
1985
+ messages.push(
1986
+ `\u2717 No heartbeat before timeout (${heartbeat.reason}). Confirm env vars are in your build, restart the dev server, then re-run \`mushi connect --wait\`.`
1987
+ );
1988
+ }
1989
+ }
1990
+ const ok = !opts.wait || Boolean(heartbeat?.ok);
1991
+ return { ok, envPath, mcpPath, heartbeat, messages };
1992
+ }
1993
+
1556
1994
  // src/index.ts
1557
1995
  installSignalHandlers();
1558
1996
  var API_TIMEOUT_MS = 15e3;
@@ -1799,7 +2237,7 @@ Examples:
1799
2237
  mushi config endpoint https://... # set endpoint
1800
2238
  mushi config projectId <uuid> # set project`).action((key, value) => {
1801
2239
  const config = loadConfig();
1802
- const ALLOWED_KEYS = /* @__PURE__ */ new Set(["apiKey", "endpoint", "projectId"]);
2240
+ const ALLOWED_KEYS = /* @__PURE__ */ new Set(["apiKey", "endpoint", "projectId", "consoleUrl"]);
1803
2241
  if (key && value) {
1804
2242
  if (!ALLOWED_KEYS.has(key)) {
1805
2243
  process.stderr.write(`error: unknown config key "${key}". Allowed: ${[...ALLOWED_KEYS].join(", ")}
@@ -2016,6 +2454,31 @@ reports.command("dismiss <id>").description("Dismiss a report (not a real bug /
2016
2454
  console.log(`\u2713 Dismissed report ${id}`);
2017
2455
  }
2018
2456
  });
2457
+ reports.command("reply <id> <message>").description("Send a visible reply to the reporter widget for a report").option("--author <name>", 'Display name for the sender (default: "Mushi Admin")').option("--json", "Machine-readable JSON output").addHelpText("after", `
2458
+ Examples:
2459
+ mushi reports reply abc123 "Thanks for reporting \u2014 fixing this in the next release."
2460
+ mushi reports reply abc123 "Can you share a screenshot?" --author "Alice"`).action(async (id, message, opts) => {
2461
+ const config = requireConfig();
2462
+ const body = { message };
2463
+ if (opts.author) body["author_name"] = opts.author;
2464
+ const result = await apiCall(`/v1/sync/reports/${id}/reply`, config, {
2465
+ method: "POST",
2466
+ body: JSON.stringify(body)
2467
+ });
2468
+ if (!result.ok) {
2469
+ if (result.httpStatus === 404 || result.error.code === "NOT_FOUND") {
2470
+ process.stderr.write(`error: report "${id}" not found
2471
+ `);
2472
+ process.exit(3);
2473
+ }
2474
+ die(result);
2475
+ }
2476
+ if (opts.json) {
2477
+ console.log(JSON.stringify(result.data, null, 2));
2478
+ } else {
2479
+ console.log(`\u2713 Reply sent to reporter for report ${id}`);
2480
+ }
2481
+ });
2019
2482
  reports.command("search <query>").description("Search reports by keyword in summary and description").option("--limit <n>", "Max results (1\u201350)", "10").option("--status <status>", "Filter by status").option("--json", "Machine-readable JSON output").addHelpText("after", `
2020
2483
  Examples:
2021
2484
  mushi reports search "login button"
@@ -2117,7 +2580,7 @@ AI code review context.
2117
2580
  Typical CI usage:
2118
2581
  MUSHI_API_KEY=$KEY MUSHI_PROJECT_ID=$PID MUSHI_API_ENDPOINT=$URL \\
2119
2582
  npx @mushi-mushi/cli sync-lessons --cwd .`).action(async (opts) => {
2120
- const { writeFile, mkdir } = await import("fs/promises");
2583
+ const { writeFile: writeFile2, mkdir: mkdir2 } = await import("fs/promises");
2121
2584
  const nodePath = await import("path");
2122
2585
  const config = requireConfig();
2123
2586
  const cwd = opts.cwd ?? process.cwd();
@@ -2144,8 +2607,8 @@ Typical CI usage:
2144
2607
  console.log(JSON.stringify(output, null, 2));
2145
2608
  return;
2146
2609
  }
2147
- await mkdir(nodePath.dirname(target), { recursive: true });
2148
- await writeFile(target, JSON.stringify(output, null, 2) + "\n", "utf8");
2610
+ await mkdir2(nodePath.dirname(target), { recursive: true });
2611
+ await writeFile2(target, JSON.stringify(output, null, 2) + "\n", "utf8");
2149
2612
  if (opts.json) {
2150
2613
  console.log(JSON.stringify({ ok: true, path: target, count: lessons2.length }));
2151
2614
  } else {
@@ -2194,7 +2657,7 @@ Examples:
2194
2657
  mushi index ./src
2195
2658
  mushi index ./src --language ts --dry-run`).action(async (path, opts) => {
2196
2659
  const config = requireConfig({ needsProject: true });
2197
- const { readdir: readdir2, readFile: readFile2, stat } = await import("fs/promises");
2660
+ const { readdir: readdir2, readFile: readFile3, stat } = await import("fs/promises");
2198
2661
  const nodePath = await import("path");
2199
2662
  const SKIP = /node_modules|\.git|dist|build|\.next|\.turbo|coverage/;
2200
2663
  const EXTS = /* @__PURE__ */ new Set([".ts", ".tsx", ".js", ".jsx", ".py", ".go", ".rs"]);
@@ -2221,7 +2684,7 @@ Examples:
2221
2684
  `);
2222
2685
  continue;
2223
2686
  }
2224
- const source = await readFile2(file, "utf8");
2687
+ const source = await readFile3(file, "utf8");
2225
2688
  const relative2 = nodePath.relative(root, file).replaceAll("\\", "/");
2226
2689
  count++;
2227
2690
  bytes += source.length;
@@ -2278,7 +2741,7 @@ Typical first-time flow:
2278
2741
  # Browser opens \u2192 sign up / magic-link \u2192 come back to terminal
2279
2742
  # CLI writes .env.local and .cursor/mcp.json
2280
2743
  # mushi whoami to confirm`).action(async (opts) => {
2281
- const { writeFile, mkdir } = await import("fs/promises");
2744
+ const { writeFile: writeFile2, mkdir: mkdir2 } = await import("fs/promises");
2282
2745
  const { existsSync: existsSync5 } = await import("fs");
2283
2746
  const nodePath = await import("path");
2284
2747
  const endpoint = opts.endpoint ?? loadConfig().endpoint ?? "https://api.mushimushi.dev";
@@ -2304,7 +2767,7 @@ Typical first-time flow:
2304
2767
  console.log("");
2305
2768
  const { createInterface } = await import("readline");
2306
2769
  const rl = createInterface({ input: process.stdin, output: process.stdout });
2307
- const ask = (q) => new Promise((resolve2) => rl.question(q, (a) => resolve2(a.trim())));
2770
+ const ask = (q) => new Promise((resolve4) => rl.question(q, (a) => resolve4(a.trim())));
2308
2771
  const projectId = await ask(" Project ID (uuid): ");
2309
2772
  const apiKey = await ask(" API key (mushi_...): ");
2310
2773
  rl.close();
@@ -2327,17 +2790,17 @@ Typical first-time flow:
2327
2790
  ""
2328
2791
  ];
2329
2792
  const envExisting = existsSync5(envPath);
2330
- await writeFile(envPath, envLines.join("\n"), "utf8");
2793
+ await writeFile2(envPath, envLines.join("\n"), "utf8");
2331
2794
  console.log(`
2332
2795
  \u2713 ${envExisting ? "Updated" : "Created"} .env.local`);
2333
2796
  const mcpDir = nodePath.join(cwd, ".cursor");
2334
- await mkdir(mcpDir, { recursive: true });
2797
+ await mkdir2(mcpDir, { recursive: true });
2335
2798
  const mcpPath = nodePath.join(mcpDir, "mcp.json");
2336
2799
  const mcpJson = {
2337
2800
  mcpServers: {
2338
2801
  mushi: {
2339
2802
  command: "npx",
2340
- args: ["-y", "mushi-mcp@latest"],
2803
+ args: ["-y", "@mushi-mushi/mcp@latest"],
2341
2804
  env: {
2342
2805
  MUSHI_API_ENDPOINT: endpoint,
2343
2806
  MUSHI_PROJECT_ID: projectId,
@@ -2349,16 +2812,16 @@ Typical first-time flow:
2349
2812
  const mcpExisting = existsSync5(mcpPath);
2350
2813
  if (mcpExisting) {
2351
2814
  try {
2352
- const { readFile: readFile2 } = await import("fs/promises");
2353
- const raw = JSON.parse(await readFile2(mcpPath, "utf8"));
2815
+ const { readFile: readFile3 } = await import("fs/promises");
2816
+ const raw = JSON.parse(await readFile3(mcpPath, "utf8"));
2354
2817
  const existing = raw;
2355
2818
  existing.mcpServers = { ...existing.mcpServers ?? {}, mushi: mcpJson.mcpServers.mushi };
2356
- await writeFile(mcpPath, JSON.stringify(existing, null, 2) + "\n", "utf8");
2819
+ await writeFile2(mcpPath, JSON.stringify(existing, null, 2) + "\n", "utf8");
2357
2820
  } catch {
2358
- await writeFile(mcpPath, JSON.stringify(mcpJson, null, 2) + "\n", "utf8");
2821
+ await writeFile2(mcpPath, JSON.stringify(mcpJson, null, 2) + "\n", "utf8");
2359
2822
  }
2360
2823
  } else {
2361
- await writeFile(mcpPath, JSON.stringify(mcpJson, null, 2) + "\n", "utf8");
2824
+ await writeFile2(mcpPath, JSON.stringify(mcpJson, null, 2) + "\n", "utf8");
2362
2825
  }
2363
2826
  console.log(` \u2713 ${mcpExisting ? "Updated" : "Created"} .cursor/mcp.json`);
2364
2827
  console.log("");
@@ -2379,7 +2842,7 @@ Supported IDEs:
2379
2842
  zed \u2014 writes ~/.config/zed/settings.json mcpServers block
2380
2843
 
2381
2844
  The command reads credentials from ~/.mushirc (run \`mushi login\` first).`).action(async (opts) => {
2382
- const { writeFile, mkdir, readFile: readFile2 } = await import("fs/promises");
2845
+ const { writeFile: writeFile2, mkdir: mkdir2, readFile: readFile3 } = await import("fs/promises");
2383
2846
  const { existsSync: existsSync5 } = await import("fs");
2384
2847
  const nodePath = await import("path");
2385
2848
  const os = await import("os");
@@ -2401,7 +2864,7 @@ The command reads credentials from ~/.mushirc (run \`mushi login\` first).`).act
2401
2864
  const serverName = `mushi-${slug}`;
2402
2865
  const mcpServerBlock = {
2403
2866
  command: "npx",
2404
- args: ["-y", "mushi-mcp@latest"],
2867
+ args: ["-y", "@mushi-mushi/mcp@latest"],
2405
2868
  env: {
2406
2869
  MUSHI_API_ENDPOINT: config.endpoint,
2407
2870
  MUSHI_PROJECT_ID: config.projectId ?? "",
@@ -2414,7 +2877,7 @@ The command reads credentials from ~/.mushirc (run \`mushi login\` first).`).act
2414
2877
  let merged = { mcpServers: {} };
2415
2878
  if (existsSync5(configPath)) {
2416
2879
  try {
2417
- const raw = await readFile2(configPath, "utf8");
2880
+ const raw = await readFile3(configPath, "utf8");
2418
2881
  merged = JSON.parse(raw);
2419
2882
  } catch {
2420
2883
  }
@@ -2427,15 +2890,15 @@ The command reads credentials from ~/.mushirc (run \`mushi login\` first).`).act
2427
2890
  console.log(`[dry-run] Would write ${configPath}:`);
2428
2891
  console.log(output);
2429
2892
  } else {
2430
- await mkdir(configDir, { recursive: true });
2431
- await writeFile(configPath, output, "utf8");
2893
+ await mkdir2(configDir, { recursive: true });
2894
+ await writeFile2(configPath, output, "utf8");
2432
2895
  console.log(`\u2713 Written ${configPath}`);
2433
2896
  }
2434
2897
  } else if (ideEntry.format === "zed") {
2435
2898
  let settings = {};
2436
2899
  if (existsSync5(configPath)) {
2437
2900
  try {
2438
- const raw = await readFile2(configPath, "utf8");
2901
+ const raw = await readFile3(configPath, "utf8");
2439
2902
  settings = JSON.parse(raw);
2440
2903
  } catch {
2441
2904
  }
@@ -2444,7 +2907,7 @@ The command reads credentials from ~/.mushirc (run \`mushi login\` first).`).act
2444
2907
  servers[serverName] = {
2445
2908
  command: {
2446
2909
  path: "npx",
2447
- args: ["-y", "mushi-mcp@latest"],
2910
+ args: ["-y", "@mushi-mushi/mcp@latest"],
2448
2911
  env: {
2449
2912
  MUSHI_API_ENDPOINT: config.endpoint,
2450
2913
  MUSHI_PROJECT_ID: config.projectId ?? "",
@@ -2459,8 +2922,8 @@ The command reads credentials from ~/.mushirc (run \`mushi login\` first).`).act
2459
2922
  console.log(`[dry-run] Would write ${configPath}:`);
2460
2923
  console.log(output);
2461
2924
  } else {
2462
- await mkdir(configDir, { recursive: true });
2463
- await writeFile(configPath, output, "utf8");
2925
+ await mkdir2(configDir, { recursive: true });
2926
+ await writeFile2(configPath, output, "utf8");
2464
2927
  console.log(`\u2713 Written ${configPath}`);
2465
2928
  }
2466
2929
  }
@@ -2494,7 +2957,7 @@ The command reads credentials from ~/.mushirc (run \`mushi login\` first).`).act
2494
2957
  if (opts.dryRun) {
2495
2958
  console.log(`[dry-run] Would write ${rulesPath}`);
2496
2959
  } else {
2497
- await writeFile(rulesPath, rulesContent, "utf8");
2960
+ await writeFile2(rulesPath, rulesContent, "utf8");
2498
2961
  console.log(`\u2713 Written .cursorrules`);
2499
2962
  }
2500
2963
  } else if (opts.ide === "claude") {
@@ -2503,8 +2966,8 @@ The command reads credentials from ~/.mushirc (run \`mushi login\` first).`).act
2503
2966
  if (opts.dryRun) {
2504
2967
  console.log(`[dry-run] Would write ${rulesPath}`);
2505
2968
  } else {
2506
- await mkdir(rulesDir, { recursive: true });
2507
- await writeFile(rulesPath, rulesContent, "utf8");
2969
+ await mkdir2(rulesDir, { recursive: true });
2970
+ await writeFile2(rulesPath, rulesContent, "utf8");
2508
2971
  console.log(`\u2713 Written .claude/rules/mushi.md`);
2509
2972
  }
2510
2973
  }
@@ -2558,6 +3021,7 @@ Examples:
2558
3021
  emitEvent("dispatch.start", { reportId, agent: opts.agent, model: opts.model ?? null });
2559
3022
  const body = {
2560
3023
  reportId,
3024
+ projectId: cfg.projectId,
2561
3025
  agent: opts.agent
2562
3026
  };
2563
3027
  if (opts.agent === "cursor_cloud") {
@@ -2638,14 +3102,67 @@ program.command("nudge").description(
2638
3102
  }
2639
3103
  console.log(renderNudgeSnippet({ phase, overrides }));
2640
3104
  });
3105
+ program.command("upgrade").description("Bump installed @mushi-mushi/* packages to the latest stable npm release").option("--cwd <path>", "Target repo (default: cwd)").option("--dry-run", "Print the install command without running it").option("--json", "Machine-readable plan + result").addHelpText("after", `
3106
+ Examples:
3107
+ mushi upgrade
3108
+ mushi upgrade --dry-run
3109
+ mushi upgrade --cwd ../glot.it`).action(async (opts) => {
3110
+ const result = await runUpgrade({ cwd: opts.cwd, dryRun: opts.dryRun, json: opts.json });
3111
+ if (opts.json) {
3112
+ console.log(JSON.stringify(result, null, 2));
3113
+ } else {
3114
+ console.log(result.message);
3115
+ for (const e of result.plan.entries) {
3116
+ const tag = e.willUpgrade && e.latest ? `\u2192 v${e.latest}` : "(current)";
3117
+ console.log(` ${e.name}@${e.current} ${tag}`);
3118
+ }
3119
+ }
3120
+ if (!result.upgraded && result.plan.entries.some((e) => e.willUpgrade) && !opts.dryRun) {
3121
+ process.exit(1);
3122
+ }
3123
+ if (result.plan.entries.length === 0) process.exit(1);
3124
+ });
3125
+ program.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("--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", `
3126
+ Examples:
3127
+ MUSHI_API_KEY=mushi_xxx mushi connect --project-id <uuid> --endpoint https://<ref>.supabase.co/functions/v1/api --wait
3128
+ mushi connect --api-key mushi_xxx --project-id <uuid> --endpoint <url> --no-ide`).action(async (opts) => {
3129
+ const apiKey = process.env.MUSHI_API_KEY ?? opts.apiKey;
3130
+ if (!apiKey) {
3131
+ console.error("Provide the API key via the MUSHI_API_KEY env var (recommended) or --api-key <key>.");
3132
+ process.exit(1);
3133
+ }
3134
+ const result = await runConnect({
3135
+ apiKey,
3136
+ projectId: opts.projectId,
3137
+ endpoint: opts.endpoint,
3138
+ cwd: opts.cwd,
3139
+ writeEnv: opts.env !== false,
3140
+ wireIde: opts.ide !== false,
3141
+ wait: opts.wait,
3142
+ waitTimeoutSec: parseInt(opts.waitTimeout, 10) || 120,
3143
+ json: opts.json
3144
+ });
3145
+ if (opts.json) {
3146
+ console.log(JSON.stringify(result, null, 2));
3147
+ } else {
3148
+ for (const line of result.messages) console.log(line);
3149
+ }
3150
+ if (!result.ok) process.exit(1);
3151
+ });
2641
3152
  program.command("doctor").description(
2642
3153
  "Run pre-flight checks: CLI config, endpoint reachability, API key shape, SDK install status, and (with --server) the same 4 dispatch-readiness checks shown in the Mushi console. Mirrors the in-console dispatch preflight so you can spot setup gaps before opening the admin UI."
2643
3154
  ).option("--cwd <path>", "Run package detection from a different directory").option("--json", "Machine-readable output").option(
2644
3155
  "--server",
2645
3156
  "Also call GET /preflight on the backend and include the 4 dispatch checks (GitHub repo, codebase indexed, Anthropic key, autofix enabled). Requires a configured projectId and API key."
3157
+ ).option(
3158
+ "--ingest",
3159
+ "Also call GET /v1/sync/ingest-setup for the 4 required ingest steps (API key, SDK heartbeat, first report). Composable with --server."
3160
+ ).option(
3161
+ "--qa-stories",
3162
+ "Check enabled QA stories for common setup issues: missing Firecrawl key, missing target URL, Slack not connected. Requires --server credentials."
2646
3163
  ).action(async (opts) => {
2647
3164
  const config = loadConfig();
2648
- const result = await runDoctor(config, { cwd: opts.cwd, server: opts.server });
3165
+ const result = await runDoctor(config, { cwd: opts.cwd, server: opts.server, ingest: opts.ingest, qaStories: opts.qaStories });
2649
3166
  const { checks } = result;
2650
3167
  if (opts.json) {
2651
3168
  console.log(JSON.stringify({ checks, ready: result.ready }, null, 2));
@@ -2957,4 +3474,374 @@ keys.command("add").description("Add a new API key to the pool").requiredOption(
2957
3474
  }
2958
3475
  console.log(`\u2713 Key added \u2014 id: ${res.data.id}`);
2959
3476
  });
2960
- program.parse();
3477
+ var integrations = program.command("integrations").description("Manage service integrations");
3478
+ integrations.command("list").description("List all configured integrations and their current health status").option("--json", "Machine-readable output").action(async (opts) => {
3479
+ const config = loadConfig();
3480
+ if (!config.apiKey) {
3481
+ console.error("Run `mushi login` first");
3482
+ process.exit(2);
3483
+ }
3484
+ if (!config.projectId) {
3485
+ console.error("No projectId. Run `mushi config projectId <uuid>`");
3486
+ process.exit(2);
3487
+ }
3488
+ const result = await apiCall(
3489
+ `/v1/admin/projects/${config.projectId}/integrations`,
3490
+ config
3491
+ );
3492
+ const rawResult = result;
3493
+ if (!rawResult.integrations && !result.ok) {
3494
+ console.error("Failed:", result.error);
3495
+ process.exit(1);
3496
+ }
3497
+ if (opts.json) {
3498
+ console.log(JSON.stringify(rawResult, null, 2));
3499
+ return;
3500
+ }
3501
+ const rows = rawResult.integrations ?? [];
3502
+ if (rows.length === 0) {
3503
+ console.log("No integrations configured. Visit the Integrations page to connect services.");
3504
+ return;
3505
+ }
3506
+ const icons = {
3507
+ slack: "\u{1F514}",
3508
+ github: "\u{1F419}",
3509
+ sentry: "\u{1FAB2}",
3510
+ langfuse: "\u{1F52D}",
3511
+ discord: "\u{1F4AC}",
3512
+ linear: "\u{1F4D0}",
3513
+ jira: "\u{1F5C2}\uFE0F",
3514
+ cursor_cloud: "\u{1F5B1}\uFE0F",
3515
+ claude_code_agent: "\u{1F916}"
3516
+ };
3517
+ console.log("\nIntegrations:\n");
3518
+ for (const row of rows) {
3519
+ const icon = icons[row.kind] ?? "\u{1F50C}";
3520
+ const statusIcon = row.status === "ok" ? "\u2705" : row.status === "error" ? "\u274C" : "\u26AA";
3521
+ console.log(` ${icon} ${row.kind.padEnd(20)} ${statusIcon} ${row.detail ?? ""}`);
3522
+ }
3523
+ console.log();
3524
+ });
3525
+ integrations.command("test <kind>").description(
3526
+ "Run a health probe for a specific integration (e.g. slack, sentry, github, langfuse, discord, cursor_cloud, claude_code_agent)"
3527
+ ).option("--json", "Machine-readable output").action(async (kind, opts) => {
3528
+ const config = loadConfig();
3529
+ if (!config.apiKey) {
3530
+ console.error("Run `mushi login` first");
3531
+ process.exit(2);
3532
+ }
3533
+ if (!config.projectId) {
3534
+ console.error("No projectId. Run `mushi config projectId <uuid>`");
3535
+ process.exit(2);
3536
+ }
3537
+ const result = await apiCall(
3538
+ `/v1/admin/projects/${config.projectId}/integrations/probe/${kind}`,
3539
+ config,
3540
+ { method: "POST" }
3541
+ );
3542
+ if (!result.ok) {
3543
+ console.error("Request failed:", result.error);
3544
+ process.exit(1);
3545
+ }
3546
+ if (opts.json) {
3547
+ console.log(JSON.stringify(result.data, null, 2));
3548
+ return;
3549
+ }
3550
+ const probeOk = result.data?.status === "ok";
3551
+ console.log(
3552
+ probeOk ? `\u2705 ${kind} integration is healthy${result.data.detail ? ": " + result.data.detail : ""}` : `\u274C ${kind} integration check failed${result.data.detail ? ": " + result.data.detail : ""}`
3553
+ );
3554
+ if (!probeOk) process.exit(1);
3555
+ });
3556
+ var slack = program.command("slack").description("Slack integration commands");
3557
+ slack.command("status").description("Show whether Slack is connected and which channel receives notifications").option("--json", "Machine-readable output").action(async (opts) => {
3558
+ const config = loadConfig();
3559
+ if (!config.apiKey) {
3560
+ console.error("Run `mushi login` first");
3561
+ process.exit(2);
3562
+ }
3563
+ if (!config.projectId) {
3564
+ console.error("No projectId. Run `mushi config projectId <uuid>`");
3565
+ process.exit(2);
3566
+ }
3567
+ const result = await apiCall(
3568
+ `/v1/admin/projects/${config.projectId}/integrations/probe/slack`,
3569
+ config,
3570
+ { method: "POST" }
3571
+ );
3572
+ if (!result.ok) {
3573
+ console.error("Request failed:", result.error);
3574
+ process.exit(1);
3575
+ }
3576
+ if (opts.json) {
3577
+ console.log(JSON.stringify(result.data, null, 2));
3578
+ return;
3579
+ }
3580
+ if (result.data?.status === "ok") {
3581
+ console.log("\u2705 Slack connected");
3582
+ if (result.data.detail) console.log(` ${result.data.detail}`);
3583
+ console.log("\n To change the channel or notification prefs, visit /integrations in the Mushi console.");
3584
+ } else {
3585
+ console.log("\u26AA Slack not connected");
3586
+ console.log(' Visit /integrations in the Mushi console and click "Add to Slack".');
3587
+ }
3588
+ });
3589
+ slack.command("test").description("Send a test Slack notification to confirm the current channel is working").option("--json", "Machine-readable output").action(async (opts) => {
3590
+ const config = loadConfig();
3591
+ if (!config.apiKey) {
3592
+ console.error("Run `mushi login` first");
3593
+ process.exit(2);
3594
+ }
3595
+ if (!config.projectId) {
3596
+ console.error("No projectId. Run `mushi config projectId <uuid>`");
3597
+ process.exit(2);
3598
+ }
3599
+ const result = await apiCall(
3600
+ `/v1/admin/projects/${config.projectId}/integrations/slack/test`,
3601
+ config,
3602
+ { method: "POST" }
3603
+ );
3604
+ if (!result.ok) {
3605
+ console.error("Request failed:", result.error);
3606
+ process.exit(1);
3607
+ }
3608
+ if (opts.json) {
3609
+ console.log(JSON.stringify(result.data, null, 2));
3610
+ return;
3611
+ }
3612
+ if (result.data?.ok) {
3613
+ console.log("\u2705 Test message sent! Check your Slack channel.");
3614
+ } else {
3615
+ console.error("\u274C Test failed:", result.data?.error ?? "unknown error");
3616
+ process.exit(1);
3617
+ }
3618
+ });
3619
+ var qa = program.command("qa").description("QA story management");
3620
+ qa.command("stories").description("List QA stories for the current project").option("--json", "Machine-readable output").option("-n, --limit <n>", "Max stories to return (not applied server-side; all stories returned)", "20").action(async (opts) => {
3621
+ const config = loadConfig();
3622
+ if (!config.apiKey) {
3623
+ console.error("Run `mushi login` first");
3624
+ process.exit(2);
3625
+ }
3626
+ if (!config.projectId) {
3627
+ console.error("No projectId. Run `mushi config projectId <uuid>`");
3628
+ process.exit(2);
3629
+ }
3630
+ const result = await apiCall(
3631
+ `/v1/admin/projects/${config.projectId}/qa-coverage`,
3632
+ config
3633
+ );
3634
+ if (!result.ok) {
3635
+ console.error("Failed:", result.error);
3636
+ process.exit(1);
3637
+ }
3638
+ if (opts.json) {
3639
+ console.log(JSON.stringify(result.data, null, 2));
3640
+ return;
3641
+ }
3642
+ const stories2 = result.data?.coverage ?? [];
3643
+ if (stories2.length === 0) {
3644
+ console.log("No QA stories yet. Create one at /qa-coverage in the Mushi console.");
3645
+ return;
3646
+ }
3647
+ console.log(`
3648
+ QA Stories (${stories2.length}):
3649
+ `);
3650
+ for (const s of stories2) {
3651
+ const statusIcon = s.last_run_status === "passed" ? "\u2705" : s.last_run_status === "failed" ? "\u274C" : s.last_run_status === "error" ? "\u{1F6A8}" : "\u26AA";
3652
+ const enabled = s.enabled ? "" : " [disabled]";
3653
+ const sid = s.story_id ?? s.id ?? "\u2014";
3654
+ console.log(` ${statusIcon} ${s.name.slice(0, 50).padEnd(52)} ${sid}${enabled}`);
3655
+ }
3656
+ console.log(`
3657
+ Use 'mushi qa runs <storyId>' to see recent runs for a story.`);
3658
+ console.log();
3659
+ });
3660
+ qa.command("runs <storyId>").description("Show recent runs for a QA story, including error heads").option("--json", "Machine-readable output").option("-n, --limit <n>", "Max runs to return", "10").action(async (storyId, opts) => {
3661
+ const config = loadConfig();
3662
+ if (!config.apiKey) {
3663
+ console.error("Run `mushi login` first");
3664
+ process.exit(2);
3665
+ }
3666
+ if (!config.projectId) {
3667
+ console.error("No projectId. Run `mushi config projectId <uuid>`");
3668
+ process.exit(2);
3669
+ }
3670
+ const limit = parseInt(opts.limit ?? "10", 10);
3671
+ const result = await apiCall(
3672
+ `/v1/admin/projects/${config.projectId}/qa-stories/${storyId}/runs?limit=${limit}`,
3673
+ config
3674
+ );
3675
+ if (!result.ok) {
3676
+ console.error("Failed:", result.error);
3677
+ process.exit(1);
3678
+ }
3679
+ if (opts.json) {
3680
+ console.log(JSON.stringify(result.data, null, 2));
3681
+ return;
3682
+ }
3683
+ const runs = result.data?.runs ?? [];
3684
+ if (runs.length === 0) {
3685
+ console.log("No runs yet for this story. Trigger one with `mushi qa run <storyId>`.");
3686
+ return;
3687
+ }
3688
+ console.log(`
3689
+ Recent runs for story ${storyId.slice(0, 8)}\u2026:
3690
+ `);
3691
+ for (const r of runs) {
3692
+ const statusIcon = r.status === "passed" ? "\u2705" : r.status === "failed" ? "\u274C" : r.status === "error" ? "\u{1F6A8}" : "\u23F3";
3693
+ const ts = r.created_at ? new Date(r.created_at).toISOString().slice(0, 16).replace("T", " ") : "\u2014";
3694
+ const latency = r.latency_ms ? ` (${(r.latency_ms / 1e3).toFixed(1)}s)` : "";
3695
+ console.log(` ${statusIcon} ${ts}${latency} ${r.id.slice(0, 8)}`);
3696
+ if (r.error_message) {
3697
+ console.log(` Error: ${r.error_message.slice(0, 120)}`);
3698
+ }
3699
+ if (r.assertion_failures?.length) {
3700
+ for (const af of r.assertion_failures.slice(0, 3)) {
3701
+ console.log(` \xB7 ${String(af).slice(0, 100)}`);
3702
+ }
3703
+ }
3704
+ }
3705
+ const consoleUrl = config.consoleUrl ?? "https://app.mushi.ai";
3706
+ console.log(`
3707
+ Open in console: ${consoleUrl}/qa-coverage?story=${storyId}`);
3708
+ console.log(` Tip: run 'mushi config consoleUrl http://localhost:6464' to set your local console URL`);
3709
+ console.log();
3710
+ });
3711
+ qa.command("run <storyId>").description("Manually trigger a QA story run (fire-and-forget; check results with `mushi qa runs <id>`)").option("--json", "Machine-readable output").action(async (storyId, opts) => {
3712
+ const config = loadConfig();
3713
+ if (!config.apiKey) {
3714
+ console.error("Run `mushi login` first");
3715
+ process.exit(2);
3716
+ }
3717
+ if (!config.projectId) {
3718
+ console.error("No projectId. Run `mushi config projectId <uuid>`");
3719
+ process.exit(2);
3720
+ }
3721
+ const result = await apiCall(
3722
+ `/v1/admin/projects/${config.projectId}/qa-stories/${storyId}/run`,
3723
+ config,
3724
+ { method: "POST" }
3725
+ );
3726
+ if (!result.ok) {
3727
+ console.error("Failed:", result.error);
3728
+ process.exit(1);
3729
+ }
3730
+ if (opts.json) {
3731
+ console.log(JSON.stringify(result.data, null, 2));
3732
+ return;
3733
+ }
3734
+ const runId = result.data?.run_id;
3735
+ if (runId) {
3736
+ console.log(`\u25B6 Run triggered: ${runId.slice(0, 8)}\u2026`);
3737
+ console.log(` Check results: mushi qa runs ${storyId}`);
3738
+ } else {
3739
+ console.error("\u274C Trigger failed: no run_id in response", JSON.stringify(result.data));
3740
+ process.exit(1);
3741
+ }
3742
+ });
3743
+ program.command("audit").description("Run a full-stack health audit for the current project").option("--json", "Machine-readable JSON output").option("--project-id <id>", "Project ID to audit (defaults to MUSHI_PROJECT_ID from config)").addHelpText("after", `
3744
+ Description:
3745
+ Fans out to the Mushi backend to run a full-stack health audit:
3746
+ \u2022 DB schema + Supabase advisors (requires Supabase PAT in API Keys)
3747
+ \u2022 Recent backend error logs
3748
+ \u2022 Tables without RLS enabled
3749
+ \u2022 Gate results: API contract (G3), spec drift (G6), orphan endpoints (G7),
3750
+ unknown frontend calls (G8), schema drift, status claim (G5)
3751
+
3752
+ Returns a PM-readable scorecard with severity-ranked findings.
3753
+
3754
+ Prerequisites:
3755
+ 1. Configure your Supabase PAT: mushi settings set supabase-pat <token>
3756
+ 2. Set supabase_project_ref in Admin \u2192 Settings \u2192 Project.
3757
+
3758
+ Examples:
3759
+ mushi audit
3760
+ mushi audit --json
3761
+ mushi audit --project-id abc123`).action(async (opts) => {
3762
+ const config = requireConfig();
3763
+ const projectId = opts.projectId ?? config.projectId;
3764
+ if (!projectId) {
3765
+ process.stderr.write("error: project ID required. Run `mushi login` or pass --project-id\n");
3766
+ process.exit(1);
3767
+ }
3768
+ const headers = {
3769
+ "Content-Type": "application/json",
3770
+ "X-Mushi-Project-Id": projectId
3771
+ };
3772
+ const jwt = config.jwt ?? null;
3773
+ const apiKey = config.apiKey ?? null;
3774
+ if (jwt) {
3775
+ headers["Authorization"] = `Bearer ${jwt}`;
3776
+ } else if (apiKey) {
3777
+ headers["X-Mushi-Api-Key"] = apiKey;
3778
+ } else {
3779
+ process.stderr.write("error: no credentials found. Run `mushi login` first.\n");
3780
+ process.exit(1);
3781
+ }
3782
+ if (!opts.json) process.stdout.write("Running full-stack audit\u2026 ");
3783
+ try {
3784
+ const controller = new AbortController();
3785
+ const timer = setTimeout(() => controller.abort(), 3e4);
3786
+ const res = await fetch(
3787
+ `${config.endpoint}/v1/admin/projects/${projectId}/audit`,
3788
+ { method: "POST", headers, body: "{}", signal: controller.signal }
3789
+ );
3790
+ clearTimeout(timer);
3791
+ const body = await res.json();
3792
+ if (!res.ok || !body.ok) {
3793
+ if (opts.json) {
3794
+ console.log(JSON.stringify(body));
3795
+ process.exit(1);
3796
+ }
3797
+ process.stdout.write("FAIL\n");
3798
+ process.stderr.write(`error: ${body.error?.message ?? `HTTP ${res.status}`}
3799
+ `);
3800
+ process.exit(1);
3801
+ }
3802
+ if (opts.json) {
3803
+ console.log(JSON.stringify(body.data, null, 2));
3804
+ return;
3805
+ }
3806
+ const data = body.data;
3807
+ const overallGlyph = data.summary.overall === "fail" ? "\u274C" : data.summary.overall === "warn" ? "\u26A0\uFE0F " : "\u2705";
3808
+ process.stdout.write(`${overallGlyph}
3809
+
3810
+ `);
3811
+ console.log(`Full-Stack Audit \u2014 ${new Date(data.audit_at).toLocaleString()}`);
3812
+ console.log(`Backend linked: ${data.backend_linked ? "yes" : "no (configure Supabase PAT + project ref)"}`);
3813
+ console.log(`Summary: ${data.summary.error_count} error(s) \xB7 ${data.summary.warn_count} warning(s)
3814
+ `);
3815
+ if (data.findings.length === 0) {
3816
+ console.log(" \u2713 No findings. Your project looks healthy.");
3817
+ } else {
3818
+ for (const f of data.findings) {
3819
+ const icon = f.severity === "error" ? "\u{1F534}" : f.severity === "warn" ? "\u{1F7E1}" : "\u2139\uFE0F ";
3820
+ console.log(` ${icon} ${f.title}`);
3821
+ console.log(` ${f.detail.slice(0, 120)}${f.detail.length > 120 ? "\u2026" : ""}`);
3822
+ }
3823
+ }
3824
+ if (data.gate_runs.length > 0) {
3825
+ console.log("\nGate Results:");
3826
+ for (const run of data.gate_runs) {
3827
+ const g = run.status === "pass" ? "\u2713" : run.status === "fail" ? "\u2717" : "~";
3828
+ console.log(` ${g} ${run.gate.padEnd(22)} ${run.status} (${run.findings_count} finding${run.findings_count !== 1 ? "s" : ""})`);
3829
+ }
3830
+ }
3831
+ if (data.summary.overall === "fail") process.exit(1);
3832
+ } catch (err) {
3833
+ const msg = err instanceof Error ? err.message : String(err);
3834
+ if (opts.json) {
3835
+ console.log(JSON.stringify({ ok: false, error: msg }));
3836
+ } else {
3837
+ process.stdout.write("ERROR\n");
3838
+ process.stderr.write(`error: ${msg}
3839
+ `);
3840
+ }
3841
+ process.exit(1);
3842
+ }
3843
+ });
3844
+ program.parseAsync().catch((err) => {
3845
+ console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
3846
+ process.exit(1);
3847
+ });