@mutmutco/cli 2.5.1 → 2.6.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.
Files changed (2) hide show
  1. package/dist/index.cjs +221 -16
  2. package/package.json +1 -1
package/dist/index.cjs CHANGED
@@ -3309,6 +3309,9 @@ function buildHealth(i) {
3309
3309
  if (!i.sagaApiUrl) problems.push("sagaApiUrl not configured in .mmi/config.json");
3310
3310
  if (!i.identity) problems.push("no GitHub identity (gh auth token / GH_TOKEN)");
3311
3311
  if (!i.reachable) problems.push("saga backend unreachable");
3312
+ if (i.reachable && i.livenessStatus === 403 && i.livenessMessage === "Forbidden") {
3313
+ problems.push("saga API route-level 403 from GitHubAuthorizer cache/policy");
3314
+ }
3312
3315
  if (i.reachable && i.authorized === false) problems.push("saga backend rejected authenticated state access");
3313
3316
  if (!i.key.sessionId || i.key.sessionId === "-") problems.push("unsafe session id");
3314
3317
  const safeToWrite = problems.length === 0;
@@ -3317,6 +3320,8 @@ function buildHealth(i) {
3317
3320
  safeToWrite,
3318
3321
  identity: i.identity,
3319
3322
  reachable: i.reachable,
3323
+ livenessStatus: i.livenessStatus,
3324
+ livenessMessage: i.livenessMessage,
3320
3325
  authorized: i.authorized,
3321
3326
  sagaApiUrl: i.sagaApiUrl,
3322
3327
  key: i.key,
@@ -3598,6 +3603,36 @@ query($owner: String!, $number: Int!, $after: String) {
3598
3603
  }
3599
3604
  }
3600
3605
  }`;
3606
+ var ISSUE_PROJECT_ITEM_QUERY = `
3607
+ query($repoOwner: String!, $repoName: String!, $number: Int!) {
3608
+ repository(owner: $repoOwner, name: $repoName) {
3609
+ issue(number: $number) {
3610
+ id
3611
+ number
3612
+ title
3613
+ url
3614
+ state
3615
+ repository { nameWithOwner }
3616
+ labels(first: 10) { nodes { name } }
3617
+ assignees(first: 10) { nodes { login } }
3618
+ projectItems(first: 20) {
3619
+ nodes {
3620
+ id
3621
+ project { id title }
3622
+ fieldValues(first: 8) {
3623
+ nodes {
3624
+ ... on ProjectV2ItemFieldSingleSelectValue {
3625
+ name
3626
+ optionId
3627
+ field { ... on ProjectV2SingleSelectField { name } }
3628
+ }
3629
+ }
3630
+ }
3631
+ }
3632
+ }
3633
+ }
3634
+ }
3635
+ }`;
3601
3636
  function resolveBoardConfig(cfg) {
3602
3637
  const missing = [];
3603
3638
  if (!cfg.projectOwner) missing.push("projectOwner");
@@ -3871,6 +3906,14 @@ async function claimBoardIssue(options, deps = {}) {
3871
3906
  const cfg = resolveBoardConfig(options.config);
3872
3907
  const gh = deps.gh ?? defaultGh;
3873
3908
  const collected = await collectBoardItems(cfg, { repo: options.repo, allowPartial: options.allowPartial }, deps);
3909
+ const selector = parseIssueSelector(options.selector, collected.repo);
3910
+ try {
3911
+ findBoardItem(collected.items, selector);
3912
+ } catch (e) {
3913
+ const fallback = await fetchIssueProjectItem(gh, cfg, selector);
3914
+ if (!fallback) throw e;
3915
+ collected.items.push(fallback);
3916
+ }
3874
3917
  const writable = await resolveWritableReposForClaimables(collected.items, gh, options.allowPartial ?? false);
3875
3918
  collected.warnings.push(...writable.warnings);
3876
3919
  collected.partial = collected.partial || writable.partial;
@@ -3882,7 +3925,6 @@ async function claimBoardIssue(options, deps = {}) {
3882
3925
  warnings: collected.warnings,
3883
3926
  partial: collected.partial
3884
3927
  };
3885
- const selector = parseIssueSelector(options.selector, collected.repo);
3886
3928
  const flatItem = findBoardItem(collected.items, selector);
3887
3929
  if (flatItem.status === "Todo" && flatItem.assignees.length === 0 && !writable.repos.has(flatItem.repository.toLowerCase())) {
3888
3930
  throw new Error(`${flatItem.ref} is not claimable: viewer does not have write access to ${flatItem.repository}`);
@@ -4039,6 +4081,42 @@ async function fetchProjectPage(gh, cfg, after) {
4039
4081
  if (!parsed.data) throw new Error("gh GraphQL response did not include data");
4040
4082
  return parsed.data;
4041
4083
  }
4084
+ async function fetchIssueProjectItem(gh, cfg, selector) {
4085
+ const [repoOwner, repoName] = selector.repo.split("/");
4086
+ if (!repoOwner || !repoName) return void 0;
4087
+ const { stdout } = await gh([
4088
+ "api",
4089
+ "graphql",
4090
+ "-f",
4091
+ `query=${ISSUE_PROJECT_ITEM_QUERY}`,
4092
+ "-f",
4093
+ `repoOwner=${repoOwner}`,
4094
+ "-f",
4095
+ `repoName=${repoName}`,
4096
+ "-F",
4097
+ `number=${selector.number}`
4098
+ ]);
4099
+ const parsed = JSON.parse(stdout);
4100
+ const issue2 = parsed.data?.repository?.issue;
4101
+ if (!issue2) return void 0;
4102
+ const projectItem = (issue2.projectItems?.nodes ?? []).find((item) => item.project?.id === cfg.projectId);
4103
+ if (!projectItem) return void 0;
4104
+ return nodeToItem({
4105
+ id: projectItem.id,
4106
+ fieldValues: projectItem.fieldValues,
4107
+ content: {
4108
+ __typename: "Issue",
4109
+ id: issue2.id,
4110
+ number: issue2.number,
4111
+ title: issue2.title,
4112
+ url: issue2.url,
4113
+ state: issue2.state,
4114
+ repository: issue2.repository,
4115
+ labels: issue2.labels,
4116
+ assignees: issue2.assignees
4117
+ }
4118
+ });
4119
+ }
4042
4120
  function nodesToItems(nodes, warnings) {
4043
4121
  const items = [];
4044
4122
  for (const node of nodes) {
@@ -4505,8 +4583,13 @@ var PLUGIN_DRIFT_LABEL = "plugin config drift (mmi@mmi duplicate rows / stale gi
4505
4583
  function recordFreshness(r) {
4506
4584
  return r.lastUpdated ?? r.installedAt ?? "";
4507
4585
  }
4508
- function freshestRecord(records) {
4509
- return records.reduce((best, r) => recordFreshness(r) > recordFreshness(best) ? r : best);
4586
+ function bestRecord(records) {
4587
+ return records.reduce((best, r) => {
4588
+ const byVersion = compareVersions(r.version ?? "0", best.version ?? "0");
4589
+ if (byVersion > 0) return r;
4590
+ if (byVersion < 0) return best;
4591
+ return recordFreshness(r) > recordFreshness(best) ? r : best;
4592
+ });
4510
4593
  }
4511
4594
  function pluginConfigDriftFix(pluginId) {
4512
4595
  return `\`${pluginId}\` registered as N duplicate project rows / a stale gitCommitSha in ~/.claude/plugins/installed_plugins.json \u2014 run \`mmi-cli doctor\` to collapse them to one \`scope: user\` entry at the highest version (a .bak backup is written first), or in \`/plugin\` uninstall the extra rows and reinstall once at user scope`;
@@ -4523,19 +4606,21 @@ function buildPluginConfigDriftCheck(input) {
4523
4606
  const records = input.installed?.plugins?.[pluginId];
4524
4607
  if (!Array.isArray(records) || records.length === 0) return base;
4525
4608
  const projectRows = records.filter((r) => r.scope === "project");
4526
- const freshest = freshestRecord(records);
4527
- const freshSha = freshest.gitCommitSha;
4528
- const staleShaRows = freshSha ? records.filter((r) => r.gitCommitSha !== void 0 && r.gitCommitSha !== freshSha).length : 0;
4609
+ const best = bestRecord(records);
4610
+ const bestSha = best.gitCommitSha;
4611
+ const staleShaRows = bestSha ? records.filter((r) => r.gitCommitSha !== void 0 && r.gitCommitSha !== bestSha).length : 0;
4612
+ const duplicateRows = records.length > 1 ? records.length : 0;
4529
4613
  const duplicateProjectRows = projectRows.length > 1 ? projectRows.length : 0;
4530
- if (duplicateProjectRows === 0 && staleShaRows === 0) {
4531
- return { ...base, duplicateProjectRows: 0, staleShaRows: 0 };
4614
+ if (duplicateRows === 0 && staleShaRows === 0) {
4615
+ return { ...base, duplicateRows: 0, duplicateProjectRows: 0, staleShaRows: 0 };
4532
4616
  }
4533
- const { projectPath: _drop, ...rest } = freshest;
4617
+ const { projectPath: _drop, ...rest } = best;
4534
4618
  const collapsed = { ...rest, scope: "user" };
4535
4619
  return {
4536
4620
  ...base,
4537
4621
  ok: false,
4538
4622
  recordsToWrite: [collapsed],
4623
+ duplicateRows,
4539
4624
  duplicateProjectRows,
4540
4625
  staleShaRows
4541
4626
  };
@@ -4549,6 +4634,58 @@ function buildGitignoreManagedBlockCheck(input) {
4549
4634
  if (!changed) return base;
4550
4635
  return { ...base, ok: false, contentToWrite: content };
4551
4636
  }
4637
+ function detectSurface(env) {
4638
+ const has = (k) => Boolean(env[k]?.trim());
4639
+ if (env.MMI_AGENT_SURFACE === "codex" || has("CODEX_HOME") || (env.CLAUDE_PLUGIN_ROOT ?? "").includes(".codex")) {
4640
+ return "codex";
4641
+ }
4642
+ const isClaude = has("CLAUDECODE") || has("CLAUDE_CODE_ENTRYPOINT") || has("CLAUDE_PLUGIN_ROOT") || env.MMI_AGENT_SURFACE === "claude";
4643
+ const isVscode = env.TERM_PROGRAM === "vscode" || has("VSCODE_PID") || has("VSCODE_GIT_ASKPASS_NODE");
4644
+ if (isClaude && isVscode) return "claude-vscode";
4645
+ if (isClaude) return "claude-cli";
4646
+ return "shell";
4647
+ }
4648
+ function pluginRecoveryFix(surface) {
4649
+ const claude = "claude plugin marketplace update mmi && claude plugin update mmi@mmi && claude plugin enable mmi@mmi";
4650
+ switch (surface) {
4651
+ case "claude-vscode":
4652
+ return `${claude} # then reopen the VS Code workspace to reload MMI commands`;
4653
+ case "claude-cli":
4654
+ return `${claude} # then restart Claude Code, or run /reload-plugins`;
4655
+ case "codex":
4656
+ return "codex plugin marketplace upgrade mmi && codex plugin add mmi@mmi # then restart Codex";
4657
+ case "shell":
4658
+ default:
4659
+ return "npm install -g @mutmutco/cli@latest";
4660
+ }
4661
+ }
4662
+ var INSTALLED_PLUGIN_VERSION_LABEL = "installed MMI plugin version (vs latest release)";
4663
+ function isSemverVersion(v) {
4664
+ return typeof v === "string" && /^v?\d+\.\d+\.\d+/.test(v.trim());
4665
+ }
4666
+ function buildInstalledPluginVersionCheck(input) {
4667
+ const pluginId = input.pluginId ?? MMI_PLUGIN_ID;
4668
+ const base = {
4669
+ ok: true,
4670
+ label: INSTALLED_PLUGIN_VERSION_LABEL,
4671
+ fix: pluginRecoveryFix(input.surface),
4672
+ pluginId
4673
+ };
4674
+ if (!input.isOrgRepo) return base;
4675
+ const records = input.installed?.plugins?.[pluginId];
4676
+ if (!Array.isArray(records) || records.length === 0) return base;
4677
+ const installedVersion = bestRecord(records).version;
4678
+ if (!isSemverVersion(installedVersion) || !isSemverVersion(input.releasedVersion)) return base;
4679
+ if (compareVersions(installedVersion, input.releasedVersion) >= 0) {
4680
+ return { ...base, installedVersion, releasedVersion: input.releasedVersion };
4681
+ }
4682
+ return {
4683
+ ...base,
4684
+ ok: false,
4685
+ installedVersion,
4686
+ releasedVersion: input.releasedVersion
4687
+ };
4688
+ }
4552
4689
 
4553
4690
  // src/stage-runner.ts
4554
4691
  var import_node_child_process3 = require("node:child_process");
@@ -6413,6 +6550,33 @@ async function applyVersionAutoUpdate(report, log) {
6413
6550
  return report;
6414
6551
  }
6415
6552
  }
6553
+ var CLAUDE_PLUGIN_TIMEOUT_MS = 6e4;
6554
+ async function runClaudePlugin(args) {
6555
+ const candidates = isWin ? ["claude.cmd", "claude.exe", "claude"] : ["claude"];
6556
+ for (const bin of candidates) {
6557
+ try {
6558
+ await execFileP3(bin, args, { timeout: CLAUDE_PLUGIN_TIMEOUT_MS });
6559
+ return true;
6560
+ } catch (err) {
6561
+ if (err.code === "ENOENT") continue;
6562
+ return false;
6563
+ }
6564
+ }
6565
+ return false;
6566
+ }
6567
+ async function applyClaudePluginHeal(surface, log) {
6568
+ if (surface !== "claude-cli" && surface !== "claude-vscode") return false;
6569
+ log(" \u21BB updating the MMI plugin via `claude plugin` (marketplace \u2192 update \u2192 enable)\u2026");
6570
+ const steps = [
6571
+ ["plugin", "marketplace", "update", "mmi"],
6572
+ ["plugin", "update", "mmi@mmi"],
6573
+ ["plugin", "enable", "mmi@mmi"]
6574
+ ];
6575
+ for (const step of steps) {
6576
+ if (!await runClaudePlugin(step)) return false;
6577
+ }
6578
+ return true;
6579
+ }
6416
6580
  var program2 = new Command();
6417
6581
  program2.name("mmi-cli").description("MMI Future CLI \u2014 org rules delivery, saga, KB. The engine the plugin SessionStart hook drives.").version(resolveVersion());
6418
6582
  var rules = program2.command("rules").description("org rules delivery");
@@ -6554,9 +6718,15 @@ saga.command("key").option("--json", "machine-readable output").description("pri
6554
6718
  async function probeBackend(url) {
6555
6719
  try {
6556
6720
  const res = await fetch(`${url}/saga/head`, { headers: await sagaHeaders(), signal: AbortSignal.timeout(8e3) });
6557
- return res.ok;
6721
+ let message = "";
6722
+ try {
6723
+ const body = await res.clone().json();
6724
+ message = typeof body.message === "string" ? body.message : "";
6725
+ } catch {
6726
+ }
6727
+ return { reachable: res.ok || res.status === 403, status: res.status, message };
6558
6728
  } catch {
6559
- return false;
6729
+ return { reachable: false };
6560
6730
  }
6561
6731
  }
6562
6732
  async function probeSagaAccess(url, key) {
@@ -6574,9 +6744,18 @@ saga.command("health").option("--json", "machine-readable output").option("--ban
6574
6744
  const key = await sagaKey(cfg, session);
6575
6745
  const source = session.source;
6576
6746
  const identity = await githubLogin();
6577
- const reachable = cfg.sagaApiUrl ? await probeBackend(cfg.sagaApiUrl) : false;
6578
- const authorized = cfg.sagaApiUrl && reachable ? await probeSagaAccess(cfg.sagaApiUrl, key) : void 0;
6579
- const report = buildHealth({ key, source, identity, reachable, authorized, sagaApiUrl: cfg.sagaApiUrl });
6747
+ const liveness = cfg.sagaApiUrl ? await probeBackend(cfg.sagaApiUrl) : { reachable: false };
6748
+ const authorized = cfg.sagaApiUrl && liveness.reachable ? await probeSagaAccess(cfg.sagaApiUrl, key) : void 0;
6749
+ const report = buildHealth({
6750
+ key,
6751
+ source,
6752
+ identity,
6753
+ reachable: liveness.reachable,
6754
+ livenessStatus: liveness.status,
6755
+ livenessMessage: liveness.message,
6756
+ authorized,
6757
+ sagaApiUrl: cfg.sagaApiUrl
6758
+ });
6580
6759
  if (o.json) return console.log(JSON.stringify(report));
6581
6760
  if (o.banner) {
6582
6761
  const banner = healthBanner(report);
@@ -7489,7 +7668,7 @@ function writeGitignore(content) {
7489
7668
  return false;
7490
7669
  }
7491
7670
  }
7492
- program2.command("doctor").description("check onboarding gates (GitHub auth, mmi-cli on PATH, repo config, plugin git clone, plugin install record, .gitignore managed block, plugin config drift) and print fixes").option("--banner", "one-line resume summary; silent when all gates pass").option("--guide", "print the MMI Agentic Onboarding guide URL").option("--json", "machine-readable output").action(async (opts) => {
7671
+ program2.command("doctor").description("check onboarding gates (GitHub auth, mmi-cli on PATH, repo config, plugin git clone, plugin install record, .gitignore managed block, plugin config drift, installed plugin version) and print fixes").option("--banner", "one-line resume summary; silent when all gates pass").option("--guide", "print the MMI Agentic Onboarding guide URL").option("--json", "machine-readable output").action(async (opts) => {
7493
7672
  if (opts.guide) {
7494
7673
  if (opts.json) console.log(JSON.stringify({ resources: [MMI_AGENTIC_ONBOARDING_GUIDE] }, null, 2));
7495
7674
  else console.log(MMI_AGENTIC_ONBOARDING_GUIDE.url);
@@ -7517,12 +7696,15 @@ program2.command("doctor").description("check onboarding gates (GitHub auth, mmi
7517
7696
  if (root && (0, import_node_fs4.existsSync)(`${root}/bin/mmi-cli${isWin ? ".cmd" : ""}`)) onPath = true;
7518
7697
  }
7519
7698
  checks.push({ ok: onPath, label: "mmi-cli on PATH", fix: "auto-provisioned at session start \u2014 reopen the session, or install the MMI plugin" });
7699
+ const surface = detectSurface(process.env);
7700
+ const releasedVersion = await fetchReleasedVersion();
7520
7701
  let versionReport = buildVersionLagReport({
7521
7702
  currentVersion: resolveVersion(),
7522
7703
  repoVersion: readRepoVersion(),
7523
- releasedVersion: await fetchReleasedVersion()
7704
+ releasedVersion
7524
7705
  });
7525
7706
  if (!opts.json) versionReport = await applyVersionAutoUpdate(versionReport, (m) => console.error(m));
7707
+ if (!versionReport.ok) versionReport = { ...versionReport, fix: pluginRecoveryFix(surface) };
7526
7708
  checks.push(versionReport);
7527
7709
  const cfg = await loadConfig();
7528
7710
  checks.push({ ok: Boolean(cfg.sagaApiUrl), label: "repo config (.mmi/config.json)", fix: "ask a master-admin to run /bootstrap on this repo" });
@@ -7574,7 +7756,30 @@ program2.command("doctor").description("check onboarding gates (GitHub auth, mmi
7574
7756
  if (!opts.banner) console.error(" \u21BB repaired: collapsed mmi@mmi to one user-scope entry (backup at installed_plugins.json.bak) \u2014 run /reload-plugins");
7575
7757
  }
7576
7758
  }
7759
+ if (!driftCheck.ok) driftCheck = { ...driftCheck, fix: pluginRecoveryFix(surface) };
7577
7760
  checks.push(driftCheck);
7761
+ let installedVersionCheck = buildInstalledPluginVersionCheck({
7762
+ isOrgRepo: Boolean(cfg.sagaApiUrl),
7763
+ installed,
7764
+ releasedVersion,
7765
+ surface
7766
+ });
7767
+ if (!installedVersionCheck.ok && !opts.json) {
7768
+ if (await applyClaudePluginHeal(surface, (m) => console.error(m))) {
7769
+ const healed = buildInstalledPluginVersionCheck({
7770
+ isOrgRepo: Boolean(cfg.sagaApiUrl),
7771
+ installed: readInstalledPlugins(),
7772
+ releasedVersion,
7773
+ surface
7774
+ });
7775
+ installedVersionCheck = healed;
7776
+ if (healed.ok && !opts.banner) {
7777
+ const reload = surface === "claude-vscode" ? "reopen the VS Code workspace" : "restart Claude Code (or run /reload-plugins)";
7778
+ console.error(` \u21BB updated MMI plugin \u2192 ${releasedVersion ?? "latest"} via claude plugin \u2014 ${reload} to load the new commands`);
7779
+ }
7780
+ }
7781
+ }
7782
+ checks.push(installedVersionCheck);
7578
7783
  const gaps = checks.filter((c) => !c.ok);
7579
7784
  const resources = doctorResourcesForGaps(gaps);
7580
7785
  if (opts.json) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mutmutco/cli",
3
- "version": "2.5.1",
3
+ "version": "2.6.0",
4
4
  "description": "MMI Future CLI — delivers the org rules (whole-file), plus saga and KB access. The cross-IDE engine the plugin's SessionStart hook drives.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",