lensmcp 1.18.0 → 1.18.2

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.
@@ -17435,9 +17435,14 @@ var MAX_ERRORS = 50;
17435
17435
  var STATE_BY_KIND = {
17436
17436
  "service-starting": "starting",
17437
17437
  "service-respawn": "starting",
17438
+ // A lens frontend's vite has been SPAWNED — started, not yet proven to serve.
17439
+ // The gateway's liveness probe emits `service-up` once its port accepts, so
17440
+ // this must claim `starting` and never `up`.
17441
+ "lens-app-up": "starting",
17438
17442
  "service-up": "up",
17439
17443
  "cold-start": "up",
17440
17444
  "service-down": "down",
17445
+ "idle-kill": "idle",
17441
17446
  "service-recycle": "wedged",
17442
17447
  "pod-recycle": "wedged",
17443
17448
  "service-crash-loop": "crash-looping"
@@ -17494,6 +17499,7 @@ var ClusterHealth = class {
17494
17499
  down: byState("down"),
17495
17500
  wedged: byState("wedged"),
17496
17501
  crashLooping: byState("crash-looping"),
17502
+ idle: byState("idle"),
17497
17503
  buildFailed: services.filter((s) => s.buildFailed).map((s) => s.project),
17498
17504
  restarts: services.reduce((n, s) => n + s.restarts, 0),
17499
17505
  errors: this.errors.slice()
package/bundled/main.js CHANGED
@@ -17437,9 +17437,14 @@ var MAX_ERRORS = 50;
17437
17437
  var STATE_BY_KIND = {
17438
17438
  "service-starting": "starting",
17439
17439
  "service-respawn": "starting",
17440
+ // A lens frontend's vite has been SPAWNED — started, not yet proven to serve.
17441
+ // The gateway's liveness probe emits `service-up` once its port accepts, so
17442
+ // this must claim `starting` and never `up`.
17443
+ "lens-app-up": "starting",
17440
17444
  "service-up": "up",
17441
17445
  "cold-start": "up",
17442
17446
  "service-down": "down",
17447
+ "idle-kill": "idle",
17443
17448
  "service-recycle": "wedged",
17444
17449
  "pod-recycle": "wedged",
17445
17450
  "service-crash-loop": "crash-looping"
@@ -17496,6 +17501,7 @@ var ClusterHealth = class {
17496
17501
  down: byState("down"),
17497
17502
  wedged: byState("wedged"),
17498
17503
  crashLooping: byState("crash-looping"),
17504
+ idle: byState("idle"),
17499
17505
  buildFailed: services.filter((s) => s.buildFailed).map((s) => s.project),
17500
17506
  restarts: services.reduce((n, s) => n + s.restarts, 0),
17501
17507
  errors: this.errors.slice()
package/bundled/shim.js CHANGED
@@ -8493,6 +8493,9 @@ async function ensureSharedServer(deps) {
8493
8493
  };
8494
8494
  if (await deps.probe())
8495
8495
  return live("already-running");
8496
+ const taken = await deps.foreignPortOwner?.();
8497
+ if (taken)
8498
+ return { outcome: "unavailable", reason: taken, waitedMs: elapsed() };
8496
8499
  let holdsLock = deps.tryCreateLock(JSON.stringify({ pid: process.pid, at: deps.now() }));
8497
8500
  if (!holdsLock) {
8498
8501
  const existing = parseSpawnLock(deps.readLock());
@@ -8624,6 +8627,9 @@ function startSharedServerWatched(opts) {
8624
8627
  }
8625
8628
  }
8626
8629
 
8630
+ // servers/lensmcp-mcp/dist/shim/probe.js
8631
+ import { connect } from "node:net";
8632
+
8627
8633
  // servers/lensmcp-mcp/dist/shim/workspace.js
8628
8634
  import { existsSync as existsSync2, readFileSync as readFileSync2, realpathSync } from "node:fs";
8629
8635
  import { basename, dirname, join as join2 } from "node:path";
@@ -8779,7 +8785,7 @@ async function probeShared(port, timeoutMs = 2e3) {
8779
8785
  if (res.ok) {
8780
8786
  const identity = parseServerIdentity(await res.json().catch(() => void 0));
8781
8787
  if (identity)
8782
- return { alive: true, identity };
8788
+ return { alive: true, identity, owner: "lensmcp" };
8783
8789
  } else {
8784
8790
  await res.text().catch(() => void 0);
8785
8791
  }
@@ -8788,8 +8794,22 @@ async function probeShared(port, timeoutMs = 2e3) {
8788
8794
  clearTimeout(timer);
8789
8795
  }
8790
8796
  if (!answered)
8791
- return { alive: false };
8792
- return { alive: await probeMcp(sharedMcpUrl(port), timeoutMs) };
8797
+ return { alive: false, owner: "none" };
8798
+ const legacy = await probeMcp(sharedMcpUrl(port), timeoutMs);
8799
+ return { alive: legacy, owner: legacy ? "lensmcp" : "foreign" };
8800
+ }
8801
+ function tcpAccepts(port, timeoutMs = 750) {
8802
+ return new Promise((resolve2) => {
8803
+ const socket = connect({ port, host: "127.0.0.1" });
8804
+ const settle = (accepted) => {
8805
+ socket.destroy();
8806
+ resolve2(accepted);
8807
+ };
8808
+ socket.setTimeout(timeoutMs);
8809
+ socket.once("connect", () => settle(true));
8810
+ socket.once("timeout", () => settle(false));
8811
+ socket.once("error", () => settle(false));
8812
+ });
8793
8813
  }
8794
8814
 
8795
8815
  // servers/lensmcp-mcp/dist/shim/proxy.js
@@ -8976,9 +8996,9 @@ async function main() {
8976
8996
  const scope = resolveWorkspaceScope(cwd);
8977
8997
  const url2 = sharedMcpUrl(scope.mcpHttpPort);
8978
8998
  log(`[lensmcp] shim \u2192 workspace "${scope.key}" (${scope.root}) \u2192 ${url2}`);
8979
- let seen = { alive: false };
8999
+ let seen = { alive: false, owner: "none" };
8980
9000
  let warnedUnknown = false;
8981
- let refused = false;
9001
+ let explained = false;
8982
9002
  const verifyIdentity = () => {
8983
9003
  const mismatch = describeIdentityMismatch(scope, seen.identity);
8984
9004
  if (!mismatch) {
@@ -8989,7 +9009,7 @@ async function main() {
8989
9009
  return void 0;
8990
9010
  }
8991
9011
  const theirs = seen.identity;
8992
- refused = true;
9012
+ explained = true;
8993
9013
  log("");
8994
9014
  log(`[lensmcp] REFUSING the shared MCP server on 127.0.0.1:${scope.mcpHttpPort} \u2014 ${mismatch}.`);
8995
9015
  log(`[lensmcp] this session's workspace : "${scope.key}" ${scope.root}`);
@@ -9002,6 +9022,27 @@ async function main() {
9002
9022
  log("");
9003
9023
  return `the shared MCP port is served by workspace "${theirs?.key}", not "${scope.key}"`;
9004
9024
  };
9025
+ const foreignPortOwner = async () => {
9026
+ const port = scope.mcpHttpPort;
9027
+ const nonHttp = seen.owner === "foreign" ? false : await tcpAccepts(port);
9028
+ if (seen.owner !== "foreign" && !nonHttp)
9029
+ return void 0;
9030
+ explained = true;
9031
+ log("");
9032
+ log(`[lensmcp] the shared MCP port 127.0.0.1:${port} is TAKEN by another process.`);
9033
+ log(`[lensmcp] It ${nonHttp ? "accepts connections but does not speak HTTP" : "answers HTTP but is not a lensmcp MCP server"}, so this session cannot attach to it.`);
9034
+ log("[lensmcp] Not starting a server on it: on macOS a second bind SUCCEEDS on the");
9035
+ log("[lensmcp] wildcard address while the kernel keeps routing 127.0.0.1 to the");
9036
+ log("[lensmcp] process already there \u2014 the new server would be unreachable, and");
9037
+ log("[lensmcp] would keep running after this session ends.");
9038
+ log("[lensmcp] Fix EITHER by freeing the port:");
9039
+ log(`[lensmcp] lsof -nP -iTCP:${port} -sTCP:LISTEN`);
9040
+ log(`[lensmcp] OR by pinning a different one in ${join3(scope.root, ".lensmcp", "config.json")}:`);
9041
+ log(`[lensmcp] { "ports": { "mcpHttp": ${port + 1} } }`);
9042
+ log("[lensmcp] then restart this session. Any free 4500-4699 port works.");
9043
+ log("");
9044
+ return `the shared MCP port ${port} is held by another process that is not a lensmcp server`;
9045
+ };
9005
9046
  const ensure = () => {
9006
9047
  let watch;
9007
9048
  return ensureSharedServer({
@@ -9010,6 +9051,7 @@ async function main() {
9010
9051
  return seen.alive;
9011
9052
  },
9012
9053
  verifyIdentity,
9054
+ foreignPortOwner,
9013
9055
  startServer: () => {
9014
9056
  const started = startSharedServerWatched({
9015
9057
  serverEntry: serverEntry(),
@@ -9032,7 +9074,7 @@ async function main() {
9032
9074
  };
9033
9075
  const ensured = await ensure();
9034
9076
  if (ensured.outcome === "unavailable") {
9035
- runEmbedded(cwd, ensured.reason ?? "unknown", !refused);
9077
+ runEmbedded(cwd, ensured.reason ?? "unknown", !explained);
9036
9078
  return;
9037
9079
  }
9038
9080
  log(`[lensmcp] shim: shared server ${ensured.outcome} (${ensured.waitedMs}ms)`);
package/lib/cli.d.ts.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../../src/lib/cli.ts"],"names":[],"mappings":"AAkBA,MAAM,WAAW,SAAS;IACxB,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,UAAU,UAAU;IAClB,GAAG,EAAE,MAAM,CAAC;IACZ,4EAA4E;IAC5E,IAAI,EAAE,MAAM,EAAE,CAAC;IACf,qDAAqD;IACrD,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC;IACxB,gEAAgE;IAChE,GAAG,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IAC7B,GAAG,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;CAC9B;AA8GD,wBAAsB,MAAM,CAAC,GAAG,EAAE,UAAU,GAAG,OAAO,CAAC,SAAS,CAAC,CA4ChE;AAypBD,MAAM,MAAM,mBAAmB,GAAG,SAAS,GAAG,SAAS,GAAG,QAAQ,CAAC;AAEnE;;;;;;;;iFAQiF;AACjF,wBAAgB,oBAAoB,CAAC,CAAC,EAAE;IACtC,SAAS,EAAE,OAAO,CAAC;IACnB,WAAW,EAAE,OAAO,CAAC;IACrB,YAAY,EAAE,MAAM,GAAG,SAAS,CAAC;IACjC,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB,GAAG,mBAAmB,CAItB"}
1
+ {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../../src/lib/cli.ts"],"names":[],"mappings":"AAmCA,MAAM,WAAW,SAAS;IACxB,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,UAAU,UAAU;IAClB,GAAG,EAAE,MAAM,CAAC;IACZ,4EAA4E;IAC5E,IAAI,EAAE,MAAM,EAAE,CAAC;IACf,qDAAqD;IACrD,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC;IACxB,gEAAgE;IAChE,GAAG,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IAC7B,GAAG,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;CAC9B;AAgID,wBAAsB,MAAM,CAAC,GAAG,EAAE,UAAU,GAAG,OAAO,CAAC,SAAS,CAAC,CAgDhE;AAypBD,MAAM,MAAM,mBAAmB,GAAG,SAAS,GAAG,SAAS,GAAG,QAAQ,CAAC;AAEnE;;;;;;;;iFAQiF;AACjF,wBAAgB,oBAAoB,CAAC,CAAC,EAAE;IACtC,SAAS,EAAE,OAAO,CAAC;IACnB,WAAW,EAAE,OAAO,CAAC;IACrB,YAAY,EAAE,MAAM,GAAG,SAAS,CAAC;IACjC,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB,GAAG,mBAAmB,CAItB"}
package/lib/cli.js CHANGED
@@ -8,6 +8,9 @@ import { ensureLensConfig } from './workspace-scope.js';
8
8
  import { isMcpMode } from './mcp-mode.js';
9
9
  import { effectiveMcpMode, LENS_CONFIG, probeSharedMcp, probeSharedMcpSync, readMcpWiring, wireMcpMode, } from './mcp-wiring.js';
10
10
  import { sweepLensRuntimeFiles } from './sweep.js';
11
+ import { candidateSignals, findingLine, formatContext, hookPayload, isHookEvent, select, } from './status-policy.js';
12
+ import { DEFAULT_BUDGET_MS, readStatus } from './status-client.js';
13
+ import { detectSkew, formatSkewContext, readVersions, spawnRunner, syncPlugin, updateCommands, } from './plugin-version.js';
11
14
  const HELP = `lensmcp — CLI for LensMCP (FrontMCP-based observability for coding agents)
12
15
 
13
16
  Usage:
@@ -83,6 +86,24 @@ Commands:
83
86
  cluster host (+ lensmcp.local), flush DNS. Wraps the
84
87
  auto-discovered \`@lensmcp/cluster:trust\` target.
85
88
  Idempotent; sudo prompts inline — run in a terminal.
89
+ status [--cwd <dir>] [--json] [--quiet] [--hook <event>] [--timeout <ms>]
90
+ Workspace health in one line — the hook-callable read of
91
+ \`agent://current-status\` from this workspace's SHARED MCP
92
+ server (embedded mode has none). Reports ONLY what it can
93
+ vouch for: a signal with no producer is unmonitored, not
94
+ healthy, and a verdict with no evidence of when it last
95
+ ran is unknown, not clean. \`--quiet\` prints NOTHING unless
96
+ something actionable is trustworthy — it is meant to run
97
+ every turn. \`--hook <Stop|SessionStart|SubagentStop>\`
98
+ emits the Claude Code \`additionalContext\` envelope
99
+ (advisory only — it never blocks). FAILS OPEN: no server,
100
+ no setup, a timeout → silent, exit 0, always.
101
+ version-check [--cwd <dir>] [--json] [--quiet] [--hook <event>] [--plugin-root <dir>]
102
+ Compare the installed Claude Code PLUGIN's version against
103
+ the lensmcp this project uses. On a proven mismatch it
104
+ names both versions and the exact update command; when
105
+ they match, or either version cannot be read, it is
106
+ silent. Same fail-open contract as \`status\`.
86
107
  doctor [--cwd <dir>] [--json]
87
108
  Diagnose the install: Node, workspace + plugin,
88
109
  .mcp.json server entry, MCP bundle, per-project
@@ -142,6 +163,10 @@ export async function runCli(ctx) {
142
163
  return runInstall(ctx, rest, out, err);
143
164
  case 'trust':
144
165
  return runTrust(ctx, rest, out, err);
166
+ case 'status':
167
+ return runStatus(ctx, rest, out);
168
+ case 'version-check':
169
+ return runVersionCheck(ctx, rest, out);
145
170
  case 'doctor':
146
171
  return runDoctor(ctx, rest, out);
147
172
  case 'rollout':
@@ -1069,6 +1094,37 @@ async function applyMcpModeStep(cwd, flag, env, out) {
1069
1094
  ? ` \u2713 already up \u2014 ${health.server ?? 'MCP'} (protocol ${health.protocolVersion ?? '?'})`
1070
1095
  : ` \u00b7 not up yet (${health.error ?? 'unknown'}) \u2014 the next session starts it on demand`);
1071
1096
  }
1097
+ /**
1098
+ * Bring the installed Claude Code plugin in step with this lensmcp.
1099
+ *
1100
+ * `setup` already reconciles `.mcp.json`, the nx config, the vite/nest wiring
1101
+ * and the pods; the plugin was the one component it walked past, and it drifted
1102
+ * nine minors on the machine this was written for. It is reconciled LAST and
1103
+ * NON-FATALLY: `setup` is the recovery command, so a marketplace/cache/network
1104
+ * problem must degrade to one warning line rather than abort the steps that
1105
+ * actually fix a broken workspace.
1106
+ *
1107
+ * Nothing is written into `~/.claude/plugins` — see `plugin-version.ts` for why
1108
+ * the sanctioned `claude plugin …` commands are the only path taken.
1109
+ */
1110
+ function applyPluginSyncStep(cwd, env, out) {
1111
+ const facts = readVersions({ cwd, cliVersion: readCliVersion() });
1112
+ const result = syncPlugin({ run: spawnRunner(env), facts });
1113
+ // Quiet when already current — `setup` is run constantly and must not churn.
1114
+ if (result.outcome === 'current' || result.outcome === 'unknown')
1115
+ return;
1116
+ out('');
1117
+ if (result.outcome === 'updated') {
1118
+ out(`[plugin] ${result.from} → ${result.to}`);
1119
+ out(' restart Claude Code for the new plugin to load.');
1120
+ return;
1121
+ }
1122
+ out(`[plugin] the Claude Code plugin is ${result.from}, this lensmcp is ${result.to}.`);
1123
+ out(` could not update it automatically (${result.detail ?? 'unknown reason'}). Run:`);
1124
+ for (const c of result.commands ?? [])
1125
+ out(` ${c}`);
1126
+ out(' …then restart Claude Code.');
1127
+ }
1072
1128
  async function runSetup(ctx, args, out, err) {
1073
1129
  const opts = parseFlags(args, { string: ['--cwd', '--mcp'] });
1074
1130
  const cwd = resolve(stringFlag(opts.flags['--cwd']) ?? ctx.cwd);
@@ -1111,6 +1167,14 @@ async function runSetup(ctx, args, out, err) {
1111
1167
  // shared mode, and re-affirms stdio otherwise. Runs AFTER install so it is the
1112
1168
  // last writer of that entry.
1113
1169
  await applyMcpModeStep(cwd, mcpFlag, ctx.env ?? process.env, out);
1170
+ // The plugin ships inside this npm package but updates on Claude Code's
1171
+ // schedule, not ours — so reconcile it here, last, and never fatally.
1172
+ try {
1173
+ applyPluginSyncStep(cwd, { ...process.env, ...(ctx.env ?? {}) }, out);
1174
+ }
1175
+ catch {
1176
+ /* the plugin step can never be the reason `setup` failed */
1177
+ }
1114
1178
  // All steps succeeded → mark the workspace set up for this version (opens the MCP gate).
1115
1179
  writeSetupState(cwd);
1116
1180
  out('');
@@ -1124,6 +1188,175 @@ async function runSetup(ctx, args, out, err) {
1124
1188
  out(' verify: lensmcp doctor');
1125
1189
  return { exitCode: 0 };
1126
1190
  }
1191
+ /**
1192
+ * `lensmcp status` — the hook-callable health read.
1193
+ *
1194
+ * THE CONTRACT, and the reason this function looks paranoid: it runs at the end
1195
+ * of every turn, so it must never block one, never error, and never hang. Hence
1196
+ * `exitCode: 0` on every path including the catch, a whole-call budget, and
1197
+ * silence as the default output. A non-zero exit or a stray line here would show
1198
+ * up in the transcript as a hook error on turns where nothing is wrong, which is
1199
+ * the exact noise the feature exists to avoid.
1200
+ */
1201
+ async function runStatus(ctx, args, out) {
1202
+ const opts = parseFlags(args, {
1203
+ string: ['--cwd', '--hook', '--timeout'],
1204
+ boolean: ['--json', '--quiet'],
1205
+ });
1206
+ const cwd = resolve(stringFlag(opts.flags['--cwd']) ?? ctx.cwd);
1207
+ const hookRaw = stringFlag(opts.flags['--hook']);
1208
+ const hook = isHookEvent(hookRaw) ? hookRaw : undefined;
1209
+ // A caller that asked for hook output and named an event we do not emit gets
1210
+ // NOTHING, rather than the human text on a channel expecting JSON. Silence is
1211
+ // the honest answer to "I cannot produce what you asked for".
1212
+ if (hookRaw !== undefined && hook === undefined)
1213
+ return { exitCode: 0 };
1214
+ const asJson = opts.flags['--json'] === true;
1215
+ // A hook is a quiet caller by definition: it speaks only when it has news.
1216
+ const quiet = opts.flags['--quiet'] === true || hookRaw !== undefined;
1217
+ const budget = Number(stringFlag(opts.flags['--timeout']) ?? '');
1218
+ const budgetMs = Number.isFinite(budget) && budget > 0 ? budget : DEFAULT_BUDGET_MS;
1219
+ const env = { ...process.env, ...(ctx.env ?? {}) };
1220
+ try {
1221
+ const read = await readStatus({
1222
+ cwd,
1223
+ env,
1224
+ budgetMs,
1225
+ // Only signals we are about to REPORT get a verification read, so the
1226
+ // clean path costs exactly one request.
1227
+ verify: (status) => candidateSignals(status).map((s) => s.resource),
1228
+ });
1229
+ if (!read.available) {
1230
+ if (asJson && !quiet) {
1231
+ out(JSON.stringify({ available: false, workspace: read.source.workspace, mode: read.source.mode, reason: read.reason, elapsedMs: read.elapsedMs }));
1232
+ }
1233
+ else if (!quiet) {
1234
+ out(`lensmcp status — ${read.source.workspace}: unavailable (${read.reason})`);
1235
+ }
1236
+ return { exitCode: 0 };
1237
+ }
1238
+ const selection = select(read.status, read.evidence, Date.now());
1239
+ const context = formatContext(selection, {
1240
+ workspace: read.source.workspace,
1241
+ includeUnmonitored: hook === 'SessionStart' || !quiet,
1242
+ });
1243
+ if (asJson) {
1244
+ if (quiet && context === undefined)
1245
+ return { exitCode: 0 };
1246
+ out(JSON.stringify({
1247
+ available: true,
1248
+ workspace: read.source.workspace,
1249
+ mode: read.source.mode,
1250
+ url: read.source.url,
1251
+ aggregate: read.status.status ?? 'unknown',
1252
+ elapsedMs: read.elapsedMs,
1253
+ findings: selection.findings,
1254
+ suppressed: selection.suppressed,
1255
+ unmonitored: selection.unmonitored,
1256
+ }));
1257
+ return { exitCode: 0 };
1258
+ }
1259
+ if (hook) {
1260
+ if (context !== undefined)
1261
+ out(hookPayload(hook, context));
1262
+ return { exitCode: 0 };
1263
+ }
1264
+ if (quiet) {
1265
+ if (context !== undefined)
1266
+ out(context);
1267
+ return { exitCode: 0 };
1268
+ }
1269
+ // The human view. Unlike the hook it always says something, and it shows the
1270
+ // SUPPRESSED signals too — "why did it stay quiet about the build?" has to be
1271
+ // answerable, or the silence looks like a bug.
1272
+ out(`lensmcp status — ${read.source.workspace} (${read.source.mode}, ${read.source.url})`);
1273
+ out(` aggregate says: ${read.status.status ?? 'unknown'} (read in ${read.elapsedMs}ms)`);
1274
+ if (selection.findings.length === 0) {
1275
+ out(' nothing actionable that this command can vouch for.');
1276
+ }
1277
+ else {
1278
+ for (const f of selection.findings)
1279
+ out(` • ${findingLine(f)}`);
1280
+ }
1281
+ for (const s of selection.suppressed)
1282
+ out(` · not reported — ${s.kind}: ${s.reason}`);
1283
+ if (selection.unmonitored.length > 0) {
1284
+ out(` · unmonitored (unknown, NOT clean): ${selection.unmonitored.join(', ')}`);
1285
+ }
1286
+ return { exitCode: 0 };
1287
+ }
1288
+ catch (e) {
1289
+ // Fail open. A status read is never worth a failed turn.
1290
+ if (!quiet)
1291
+ out(`lensmcp status: unavailable (${e instanceof Error ? e.message : String(e)})`);
1292
+ return { exitCode: 0 };
1293
+ }
1294
+ }
1295
+ /**
1296
+ * `lensmcp version-check` — is the installed plugin the one this lensmcp ships?
1297
+ *
1298
+ * A separate command from `status` because it is a separate question with a
1299
+ * separate cadence: the answer changes only when someone upgrades, so it belongs
1300
+ * at SessionStart, whereas health changes every turn. Same fail-open contract,
1301
+ * and the same advisory-only rule — it names the command, it does not block.
1302
+ */
1303
+ function runVersionCheck(ctx, args, out) {
1304
+ const opts = parseFlags(args, {
1305
+ string: ['--cwd', '--hook', '--plugin-root'],
1306
+ boolean: ['--json', '--quiet'],
1307
+ });
1308
+ const cwd = resolve(stringFlag(opts.flags['--cwd']) ?? ctx.cwd);
1309
+ const hookRaw = stringFlag(opts.flags['--hook']);
1310
+ const hook = isHookEvent(hookRaw) ? hookRaw : undefined;
1311
+ if (hookRaw !== undefined && hook === undefined)
1312
+ return { exitCode: 0 };
1313
+ const asJson = opts.flags['--json'] === true;
1314
+ const quiet = opts.flags['--quiet'] === true || hookRaw !== undefined;
1315
+ const pluginRoot = stringFlag(opts.flags['--plugin-root']);
1316
+ try {
1317
+ const facts = readVersions({
1318
+ cwd,
1319
+ cliVersion: readCliVersion(),
1320
+ ...(pluginRoot ? { pluginRoot } : {}),
1321
+ });
1322
+ const skew = detectSkew(facts);
1323
+ if (asJson) {
1324
+ if (quiet && !skew)
1325
+ return { exitCode: 0 };
1326
+ out(JSON.stringify({ ...facts, skew: skew ?? null }));
1327
+ return { exitCode: 0 };
1328
+ }
1329
+ if (hook) {
1330
+ if (skew)
1331
+ out(hookPayload(hook, formatSkewContext(skew)));
1332
+ return { exitCode: 0 };
1333
+ }
1334
+ if (quiet) {
1335
+ if (skew)
1336
+ out(formatSkewContext(skew));
1337
+ return { exitCode: 0 };
1338
+ }
1339
+ out(`lensmcp version-check — ${facts.pluginId ?? 'lensmcp plugin'}`);
1340
+ out(` plugin ${facts.plugin ?? 'unknown'}${facts.pluginSource ? ` (${facts.pluginSource})` : ''}`);
1341
+ out(` project ${facts.project ?? 'unknown'} (node_modules/lensmcp)`);
1342
+ out(` cli ${facts.cli ?? 'unknown'} (the binary running now)`);
1343
+ if (!skew) {
1344
+ out(facts.plugin && (facts.project ?? facts.cli)
1345
+ ? ' ✓ in step.'
1346
+ : ' ? cannot compare — one of the versions is unreadable (staying silent).');
1347
+ return { exitCode: 0 };
1348
+ }
1349
+ out(` ✗ the plugin is ${skew.direction} — update it, then restart Claude Code:`);
1350
+ for (const c of updateCommands(skew))
1351
+ out(` ${c}`);
1352
+ return { exitCode: 0 };
1353
+ }
1354
+ catch (e) {
1355
+ if (!quiet)
1356
+ out(`lensmcp version-check: unavailable (${e instanceof Error ? e.message : String(e)})`);
1357
+ return { exitCode: 0 };
1358
+ }
1359
+ }
1127
1360
  async function runDoctor(ctx, args, out) {
1128
1361
  const opts = parseFlags(args, { string: ['--cwd'], boolean: ['--json'] });
1129
1362
  const cwd = resolve(stringFlag(opts.flags['--cwd']) ?? ctx.cwd);
@@ -0,0 +1,121 @@
1
+ /** The plugin's id as Claude Code knows it: `<plugin>@<marketplace>`. */
2
+ export declare const PLUGIN_NAME = "lensmcp";
3
+ export interface InstalledPlugin {
4
+ /** `lensmcp@lensmcp-marketplace`. */
5
+ id: string;
6
+ marketplace: string;
7
+ version: string;
8
+ scope?: string;
9
+ projectPath?: string;
10
+ }
11
+ /**
12
+ * What Claude Code records about the installed plugin.
13
+ *
14
+ * Read from `installed_plugins.json` rather than by shelling out to
15
+ * `claude plugin list --json`: it is the same data, costs no subprocess (~0.26s
16
+ * measured), and cannot prompt or hang — all three matter on a hook path. The
17
+ * file is only ever READ.
18
+ */
19
+ export declare function readInstalledPlugin(cwd: string, home?: string): InstalledPlugin | undefined;
20
+ export interface VersionFacts {
21
+ /**
22
+ * The plugin actually loaded, read from `${CLAUDE_PLUGIN_ROOT}` when a hook
23
+ * passed it — the only fully authoritative source, since it is the manifest
24
+ * Claude Code parsed. Falls back to the install index.
25
+ */
26
+ plugin?: string;
27
+ pluginSource?: 'plugin-root' | 'install-index';
28
+ pluginId?: string;
29
+ pluginMarketplace?: string;
30
+ /** The `lensmcp` this project depends on — what the plugin should match. */
31
+ project?: string;
32
+ /** The CLI binary currently executing (may be a global install). */
33
+ cli?: string;
34
+ }
35
+ export interface ReadVersionsOptions {
36
+ cwd: string;
37
+ /** `${CLAUDE_PLUGIN_ROOT}`, when invoked from a plugin hook. */
38
+ pluginRoot?: string;
39
+ /** The running CLI's own version. */
40
+ cliVersion?: string;
41
+ home?: string;
42
+ }
43
+ export declare function readVersions(options: ReadVersionsOptions): VersionFacts;
44
+ /** -1 / 0 / 1, or `undefined` when either side is not a comparable version. */
45
+ export declare function compareVersions(a: string, b: string): number | undefined;
46
+ export interface Skew {
47
+ /** PROVEN: both versions were read and they differ. */
48
+ mismatch: boolean;
49
+ plugin: string;
50
+ expected: string;
51
+ /** Which version the `expected` side came from. */
52
+ expectedSource: 'project' | 'cli';
53
+ direction: 'behind' | 'ahead' | 'differs';
54
+ /** `<plugin>@<marketplace>`, when the install index named one. */
55
+ pluginId?: string;
56
+ marketplace?: string;
57
+ /** A global CLI that disagrees with the project's dependency, if any. */
58
+ cliSkew?: {
59
+ cli: string;
60
+ project: string;
61
+ };
62
+ }
63
+ /**
64
+ * Decide whether there is a skew worth telling anyone about.
65
+ *
66
+ * Returns `undefined` for every uncertain case — no plugin found, no reference
67
+ * version, an unparseable version string. Trust rule: warn only on a PROVEN
68
+ * mismatch, never a suspected one. A hook that guesses is a hook that gets
69
+ * muted.
70
+ */
71
+ export declare function detectSkew(facts: VersionFacts): Skew | undefined;
72
+ /** The exact commands that reconcile the plugin, in order. */
73
+ export declare function updateCommands(skew: Pick<Skew, 'pluginId' | 'marketplace'>): string[];
74
+ /**
75
+ * The SessionStart context for a proven skew.
76
+ *
77
+ * States both versions and names the command, because "something is out of
78
+ * date" without the fix is a nag. Advisory: it asks the agent to update first,
79
+ * it does not prevent anything.
80
+ */
81
+ export declare function formatSkewContext(skew: Skew): string;
82
+ export type SyncOutcome =
83
+ /** Already at the expected version — nothing done, nothing printed. */
84
+ 'current'
85
+ /** Skew found and the update commands succeeded. */
86
+ | 'updated'
87
+ /** Skew found; the update could not be run or failed. Actionable, non-fatal. */
88
+ | 'manual'
89
+ /** Nothing installed, or not enough information to say. Silent. */
90
+ | 'unknown';
91
+ export interface SyncResult {
92
+ outcome: SyncOutcome;
93
+ from?: string;
94
+ to?: string;
95
+ /** What the user should run when we could not. */
96
+ commands?: string[];
97
+ detail?: string;
98
+ }
99
+ export interface SyncDeps {
100
+ /** Run a command; `undefined` means the binary was not found. */
101
+ run: (cmd: string, args: string[]) => {
102
+ code: number;
103
+ stderr?: string;
104
+ } | undefined;
105
+ facts: VersionFacts;
106
+ }
107
+ /**
108
+ * Reconcile the installed plugin with the lensmcp this workspace uses.
109
+ *
110
+ * Quiet when current (the common case — `setup` is run constantly), and NEVER
111
+ * fatal: every failure path returns `manual` with the command to run, so a
112
+ * marketplace, cache or network problem cannot abort the rest of setup.
113
+ */
114
+ export declare function syncPlugin(deps: SyncDeps): SyncResult;
115
+ /** The real runner. Bounded, non-interactive, and never throws. */
116
+ export declare function spawnRunner(env: NodeJS.ProcessEnv, timeoutMs?: number): SyncDeps['run'];
117
+ /** Where a plugin's manifest lives, for callers that already have its root. */
118
+ export declare const pluginManifestPath: (root: string) => string;
119
+ /** True when `root` looks like an unpacked Claude Code plugin. */
120
+ export declare const isPluginRoot: (root: string) => boolean;
121
+ //# sourceMappingURL=plugin-version.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"plugin-version.d.ts","sourceRoot":"","sources":["../../src/lib/plugin-version.ts"],"names":[],"mappings":"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"}