@levr-one/cli 0.11.3 → 0.11.4

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.
@@ -143,7 +143,10 @@ const CLAUDE_CODE_COMMAND = [
143
143
  "{name}",
144
144
  "{url}"
145
145
  ];
146
- /** `~/.claude.json` on every platform — read for detection, never written by us. */
146
+ /**
147
+ * `~/.claude.json` on every platform — read for detection, never written by us.
148
+ * `~` is `$CLAUDE_CONFIG_DIR` when that is set (see `configDirEnv`).
149
+ */
147
150
  const CLAUDE_CODE_LOCATIONS = [
148
151
  {
149
152
  platform: "darwin",
@@ -232,6 +235,7 @@ const HARNESSES = [
232
235
  docsUrl: "https://docs.anthropic.com/en/docs/claude-code/mcp",
233
236
  verifiedVersion: UNVERIFIED_LEGACY,
234
237
  verifiedOn: UNVERIFIED_LEGACY,
238
+ configDirEnv: "CLAUDE_CONFIG_DIR",
235
239
  detectSignals: [
236
240
  {
237
241
  platform: "darwin",
@@ -275,7 +279,7 @@ const HARNESSES = [
275
279
  installKind: "cli-command",
276
280
  locations: CLAUDE_CODE_LOCATIONS,
277
281
  command: CLAUDE_CODE_COMMAND,
278
- cwdKeyedUnder: "projects"
282
+ projectKeyedUnder: "projects"
279
283
  }
280
284
  ]
281
285
  },
@@ -775,9 +779,20 @@ function defaultEnv() {
775
779
  platform: process.platform,
776
780
  homedir: homedir(),
777
781
  pathVar: process.env.PATH ?? "",
778
- cwd: process.cwd()
782
+ cwd: process.cwd(),
783
+ vars: process.env
779
784
  };
780
785
  }
786
+ /**
787
+ * The directory `~` means for this harness's config paths and signals: the
788
+ * harness's `configDirEnv` when that variable is set, else the home directory
789
+ * (ENG-6696). A relative value resolves against `env.cwd`, the directory a
790
+ * spawned client CLI runs in, so the probe and the client read one file.
791
+ */
792
+ function configHome(harness, env) {
793
+ const dir = harness.configDirEnv ? env.vars?.[harness.configDirEnv] : void 0;
794
+ return dir ? resolve(env.cwd, dir) : env.homedir;
795
+ }
781
796
  /** Expand a leading `~` / `~/…` to the given home directory. */
782
797
  function expandTilde(p, home) {
783
798
  if (p === "~") return home;
@@ -807,7 +822,7 @@ function resolveConfigPath(harness, env, scope = defaultScope(harness)) {
807
822
  if (def.projectPath) return join(resolveProjectRoot(env).root, ...def.projectPath.split("/"));
808
823
  const loc = locationFor(harness, env.platform, scope);
809
824
  if (!loc) return void 0;
810
- return expandTilde(loc.configPath, env.homedir);
825
+ return expandTilde(loc.configPath, configHome(harness, env));
811
826
  }
812
827
  /**
813
828
  * Nearest ancestor of `cwd` containing `.git`, or `undefined`.
@@ -846,25 +861,75 @@ function resolveProjectRoot(env) {
846
861
  };
847
862
  }
848
863
  /**
864
+ * The key Claude Code files a `local`-scope entry under, in `projects[...]`,
865
+ * for a client run from `env.cwd` (ENG-6696, observed on 2.1.296).
866
+ *
867
+ * It is the PROJECT, not the directory: from `repo/sub` the key is `repo`.
868
+ * A linked git worktree keys to its MAIN checkout (the parent of the common
869
+ * `.git`), a worktree of a bare repository to the bare directory, and a
870
+ * submodule or `--separate-git-dir` checkout to its own work tree. Outside a
871
+ * repository it is the directory itself. Always a real path, with `/`
872
+ * separators on Windows (unverified there: drive-letter case). Claude reads
873
+ * only this key, so an entry under any other key is invisible to it, and a
874
+ * probe that looked elsewhere missed the entry and let `claude mcp add`
875
+ * refuse.
876
+ */
877
+ function projectKeyFor(env) {
878
+ return configKey(projectDir(env), env.platform);
879
+ }
880
+ /** Claude Code writes `projects` keys with `/` on Windows too. */
881
+ function configKey(dir, platform) {
882
+ return platform === "win32" ? dir.replaceAll("\\", "/") : dir;
883
+ }
884
+ function projectDir(env) {
885
+ const cwd = realOrResolved(env.cwd);
886
+ const workTree = findRepoRoot(cwd);
887
+ if (!workTree) return cwd;
888
+ const dotGit = join(workTree, ".git");
889
+ let gitDir = dotGit;
890
+ try {
891
+ if (statSync(dotGit).isFile()) {
892
+ const m = /^gitdir:\s*([^\r\n]+)/.exec(readFileSync(dotGit, "utf8").trim());
893
+ if (!m?.[1]) return workTree;
894
+ gitDir = resolve(workTree, m[1].trim());
895
+ }
896
+ } catch {
897
+ return workTree;
898
+ }
899
+ const commonFile = readTextOrNull(join(gitDir, "commondir"));
900
+ if (commonFile === null) return workTree;
901
+ const common = realOrResolved(resolve(gitDir, commonFile.trim()));
902
+ if (!existsSync(common)) return workTree;
903
+ if (realOrResolved(dirname(gitDir)) !== join(common, "worktrees")) return workTree;
904
+ const back = readTextOrNull(join(gitDir, "gitdir"));
905
+ if (back === null || realOrResolved(dirname(resolve(gitDir, back.trim()))) !== workTree) return workTree;
906
+ if (basename(common) === ".git") return dirname(common);
907
+ return existsSync(join(common, ".git")) ? workTree : common;
908
+ }
909
+ function realOrResolved(p) {
910
+ try {
911
+ return realpathSync(p);
912
+ } catch {
913
+ return resolve(p);
914
+ }
915
+ }
916
+ /**
849
917
  * Where our server entry sits INSIDE a scope's config file.
850
918
  *
851
919
  * Usually `[<serverProperty>, 'levr']` at the top level. A scope declaring
852
- * `cwdKeyedUnder` nests one level deeper, keyed by the launch directory:
853
- * Claude Code's `local` scope lives at
854
- * `projects[<cwd>].mcpServers.levr` in `~/.claude.json`.
920
+ * `projectKeyedUnder` nests one level deeper, under the project key: Claude
921
+ * Code's `local` scope lives at `projects[<key>].mcpServers.levr` in
922
+ * `~/.claude.json`, with `<key>` from {@link projectKeyFor}.
855
923
  *
856
- * `cwdKey` is passed in rather than read off the env because the key is
857
- * specifically the directory the CLIENT was launched in — which is NOT the
858
- * repo root, and callers should have to say which directory they mean.
859
924
  * Shared by detect and install so the two can never disagree about where an
860
925
  * entry lives.
861
926
  */
862
- function entryPathFor(harness, scope, cwdKey, serverName = DEFAULT_SERVER_NAME) {
927
+ function entryPathFor(harness, scope, projectKey, serverName = DEFAULT_SERVER_NAME) {
863
928
  const tail = [serverPropertyFor(harness, scope), serverName];
864
- const nest = scopeDef(harness, scope)?.cwdKeyedUnder;
929
+ const nest = scopeDef(harness, scope)?.projectKeyedUnder;
865
930
  return nest ? [
866
931
  nest,
867
- cwdKey,
932
+ projectKey,
868
933
  ...tail
869
934
  ] : tail;
870
935
  }
@@ -916,13 +981,14 @@ function collidingScope(harness, env, scope) {
916
981
  if (!scopeDef(harness, scope)?.projectPath) return void 0;
917
982
  const target = resolveConfigPath(harness, env, scope);
918
983
  if (!target) return void 0;
919
- const targetEntry = entryPathFor(harness, scope, env.cwd).join("\0");
984
+ const key = projectKeyFor(env);
985
+ const targetEntry = entryPathFor(harness, scope, key).join("\0");
920
986
  for (const other of harness.scopes) {
921
987
  if (other.scope === scope) continue;
922
988
  const otherPath = resolveConfigPath(harness, env, other.scope);
923
989
  if (!otherPath) continue;
924
990
  if (!pathsResolveSame(target, otherPath, env.platform)) continue;
925
- if (entryPathFor(harness, other.scope, env.cwd).join("\0") === targetEntry) return other.scope;
991
+ if (entryPathFor(harness, other.scope, key).join("\0") === targetEntry) return other.scope;
926
992
  }
927
993
  }
928
994
  const WIN_EXTS = [
@@ -958,22 +1024,23 @@ function whichSync(bin, env) {
958
1024
  return whichPathSync(bin, env) !== void 0;
959
1025
  }
960
1026
  /** Does one `installSignals` entry match on this machine? Supports
961
- * `which:<bin>`, `~`-prefixed paths, and absolute (app-bundle) paths. */
962
- function signalMatches(signal, env) {
1027
+ * `which:<bin>`, `~`-prefixed paths, and absolute (app-bundle) paths. `home`
1028
+ * is what `~` means for the harness — see {@link configHome}. */
1029
+ function signalMatches(signal, env, home = env.homedir) {
963
1030
  if (signal.startsWith("which:")) return whichSync(signal.slice(6), env);
964
- return existsSync(signal.startsWith("~") ? expandTilde(signal, env.homedir) : signal);
1031
+ return existsSync(signal.startsWith("~") ? expandTilde(signal, home) : signal);
965
1032
  }
966
1033
  /**
967
1034
  * The value our server key currently holds for this scope, or `undefined`.
968
1035
  *
969
- * Presence alone is not enough for a `cli-command` scope: `claude mcp add` is
970
- * a no-op when the key exists, so telling "already correct" from "present but
971
- * pointing somewhere else" needs the value.
1036
+ * Presence alone is not enough for a `cli-command` scope: `claude mcp add`
1037
+ * refuses when the key exists and never updates its URL, so telling "already
1038
+ * correct" from "present but pointing somewhere else" needs the value.
972
1039
  */
973
- function readServerEntry(harness, scope, configPath, cwdKey, parseJsonc, serverName = DEFAULT_SERVER_NAME) {
1040
+ function readServerEntry(harness, scope, configPath, projectKey, parseJsonc, serverName = DEFAULT_SERVER_NAME) {
974
1041
  const text = readTextOrNull(configPath);
975
1042
  if (!text) return void 0;
976
- return getAtPath(parseJsonc(text), entryPathFor(harness, scope, cwdKey, serverName));
1043
+ return getAtPath(parseJsonc(text), entryPathFor(harness, scope, projectKey, serverName));
977
1044
  }
978
1045
  /** Read a text file, or `null` if it doesn't exist / can't be read. */
979
1046
  function readTextOrNull(path$1) {
@@ -1425,10 +1492,10 @@ function adapterFor(harness) {
1425
1492
  * not by which file is read — hence the entry path rather than a bare
1426
1493
  * top-level lookup.
1427
1494
  */
1428
- function isServerConfigured(harness, scope, configPath, cwdKey) {
1495
+ function isServerConfigured(harness, scope, configPath, projectKey) {
1429
1496
  const text = readTextOrNull(configPath);
1430
1497
  if (!text) return false;
1431
- const read = adapterFor(harness).readAt(text, entryPathFor(harness, scope, cwdKey));
1498
+ const read = adapterFor(harness).readAt(text, entryPathFor(harness, scope, projectKey));
1432
1499
  return read.kind === "value" && read.value !== null;
1433
1500
  }
1434
1501
  function detectScope(harness, env, def) {
@@ -1441,13 +1508,13 @@ function detectScope(harness, env, def) {
1441
1508
  installKind: def.installKind,
1442
1509
  configPath: available ? configPath ?? "" : "",
1443
1510
  available,
1444
- alreadyConfigured: available && configPath ? isServerConfigured(harness, def.scope, configPath, env.cwd) : false
1511
+ alreadyConfigured: available && configPath ? isServerConfigured(harness, def.scope, configPath, projectKeyFor(env)) : false
1445
1512
  };
1446
1513
  }
1447
1514
  function detectOne(harness, env) {
1448
1515
  const scopes = harness.scopes.map((def) => detectScope(harness, env, def));
1449
1516
  const available = scopes.some((s) => s.available);
1450
- const installed = detectSignalsFor(harness, env.platform).some((s) => signalMatches(s, env)) || scopes.some((s) => s.configPath !== "" && existsSync(s.configPath));
1517
+ const installed = detectSignalsFor(harness, env.platform).some((s) => signalMatches(s, env, configHome(harness, env))) || scopes.some((s) => s.configPath !== "" && existsSync(s.configPath));
1451
1518
  const fallback = scopes.find((s) => s.scope === defaultScope(harness));
1452
1519
  return {
1453
1520
  id: harness.id,
@@ -1472,6 +1539,21 @@ const COMMAND_TIMEOUT_MS = 3e4;
1472
1539
  /** Keep a failing client's diagnostics readable in a multi-client report. */
1473
1540
  const MAX_STDERR = 2e3;
1474
1541
  /**
1542
+ * The variables a client CLI is spawned with: the real environment minus every
1543
+ * catalog `configDirEnv`, then `env.vars` on top (ENG-6696). A config
1544
+ * directory therefore reaches the child only when the caller's env carries it
1545
+ * — the one the probe read — so `claude` never writes a file the installer
1546
+ * did not look at, while PATH and the rest still come through.
1547
+ */
1548
+ function childVars(env) {
1549
+ const vars = { ...process.env };
1550
+ for (const h of HARNESSES) if (h.configDirEnv) delete vars[h.configDirEnv];
1551
+ return {
1552
+ ...vars,
1553
+ ...env.vars
1554
+ };
1555
+ }
1556
+ /**
1475
1557
  * Windows batch launchers (`.cmd` / `.bat`) cannot be spawned directly.
1476
1558
  *
1477
1559
  * Since the CVE-2024-27980 fix (Node 18.20.2 / 20.12.2 / 21.7.2),
@@ -1532,7 +1614,7 @@ function runHarnessCommandSync(argv, opts = {}) {
1532
1614
  ],
1533
1615
  timeout: COMMAND_TIMEOUT_MS,
1534
1616
  env: {
1535
- ...process.env,
1617
+ ...childVars(env),
1536
1618
  HOME: env.homedir,
1537
1619
  USERPROFILE: env.homedir
1538
1620
  },
@@ -1587,7 +1669,7 @@ function currentEntry(harness, env, scope, serverName) {
1587
1669
  const configPath = resolveConfigPath(harness, env, scope);
1588
1670
  if (!configPath) return { present: false };
1589
1671
  const adapter = adapterFor(harness);
1590
- const entry = readServerEntry(harness, scope, configPath, env.cwd, (t) => {
1672
+ const entry = readServerEntry(harness, scope, configPath, projectKeyFor(env), (t) => {
1591
1673
  const read = adapter.readAt(t, []);
1592
1674
  return read.kind === "value" ? read.value : void 0;
1593
1675
  }, serverName);
@@ -1729,7 +1811,7 @@ function installHarnessSync(harness, mcpUrl, opts = {}) {
1729
1811
  };
1730
1812
  const { path: path$1 } = target;
1731
1813
  const entryValue = buildServerEntry(harness, mcpUrl, scope, serverName)[serverName];
1732
- const modPath = entryPathFor(harness, scope, env.cwd, serverName);
1814
+ const modPath = entryPathFor(harness, scope, projectKeyFor(env), serverName);
1733
1815
  const adapter = adapterFor(harness);
1734
1816
  const existing = readTextOrNull(path$1);
1735
1817
  const baseText = existing && existing.trim() ? existing : adapter.empty;
@@ -2186,7 +2268,7 @@ function runNonInteractive(options, url, urlSource, deps) {
2186
2268
  };
2187
2269
  }
2188
2270
  /** Why an install was refused, in the user's terms rather than the enum's. */
2189
- function failureText(o, switchCommand) {
2271
+ function failureText(o, switchCommand, entryName) {
2190
2272
  const r = o.result;
2191
2273
  const harness = getHarness(o.id);
2192
2274
  switch (r.reason) {
@@ -2198,10 +2280,23 @@ function failureText(o, switchCommand) {
2198
2280
  case "write-failed": return `its config could not be written` + (r.detail ? ` (${r.detail})` : "") + `; check the file's permissions and re-run`;
2199
2281
  case "unsupported-config-shape": return `its config could not be edited safely` + (r.detail ? ` (${r.detail})` : "") + `; fix the file or add the entry by hand`;
2200
2282
  default:
2201
- if (r.commandError) return `\`${r.command}\` (${r.commandError})${restoreText(r)}`;
2283
+ if (r.commandError) return `\`${r.command}\` (${r.commandError})${restoreText(r)}` + alreadyExistsHint(o, r.commandError, entryName);
2202
2284
  return "no config location on this platform";
2203
2285
  }
2204
2286
  }
2287
+ /**
2288
+ * The client's CLI refused because the entry already exists, in a config the
2289
+ * installer did not read — otherwise it would have reported already set up,
2290
+ * or a url-mismatch naming --replace. --replace cannot reach an entry it
2291
+ * cannot see, so the way out is another name, or removing that entry with the
2292
+ * client's own CLI (ENG-6696).
2293
+ */
2294
+ function alreadyExistsHint(o, commandError, entryName) {
2295
+ if (!/already exists/i.test(commandError)) return "";
2296
+ const name = entryName ?? DEFAULT_SERVER_NAME;
2297
+ const scope = /already exists in (user|project|local) config/i.exec(commandError)?.[1]?.toLowerCase() ?? o.result.scope;
2298
+ return `; to keep it, add this one beside it with --name <other>; to switch it, remove it with ${o.id === "claude-code" ? `\`claude mcp remove --scope ${scope} ${name}\`` : "the client's own CLI"} and re-run`;
2299
+ }
2205
2300
  /** What happened to the entry a failed `--replace` removed (ENG-6300 D6). */
2206
2301
  function restoreText(r) {
2207
2302
  if (r.restored === void 0) return "";
@@ -2209,13 +2304,13 @@ function restoreText(r) {
2209
2304
  return `; the previous entry (${r.replacedUrl}) could NOT be restored` + (r.restoreError ? ` (${r.restoreError})` : "") + " — this client has no Levr entry of that name now";
2210
2305
  }
2211
2306
  /** One human-readable status line per outcome. */
2212
- function outcomeLine(o, dryRun, switchCommand) {
2307
+ function outcomeLine(o, dryRun, switchCommand, entryName) {
2213
2308
  const r = o.result;
2214
2309
  const note = o.fallbackFrom ? ` [${o.fallbackFrom} scope unsupported — used ${r.scope}]` : "";
2215
2310
  const where = r.path ? ` → ${r.path}` : "";
2216
2311
  const backup = r.backupPath ? dryRun ? ` [original would be backed up to ${r.backupPath}]` : ` [original backed up to ${r.backupPath}]` : "";
2217
2312
  const replaced = r.replacedUrl ? dryRun ? ` [would replace ${r.replacedUrl}]` : ` [replaced ${r.replacedUrl}]` : "";
2218
- if (!r.ok) return `${o.label}: failed — ${failureText(o, switchCommand)}`;
2313
+ if (!r.ok) return `${o.label}: failed — ${failureText(o, switchCommand, entryName)}`;
2219
2314
  if (r.alreadyConfigured) return `${o.label}: already set up (${r.scope})${where}${note}`;
2220
2315
  if (r.command) {
2221
2316
  if (r.executed) return `${o.label}: installed (${r.scope}) via \`${r.command}\`${replaced}${note}`;
@@ -2230,7 +2325,7 @@ function formatReport(report) {
2230
2325
  const lines = [];
2231
2326
  lines.push(`MCP URL: ${report.url} (${report.urlSource})`);
2232
2327
  if (report.outcomes.length === 0) lines.push("No clients selected.");
2233
- else for (const o of report.outcomes) lines.push(outcomeLine(o, report.dryRun, report.switchCommand));
2328
+ else for (const o of report.outcomes) lines.push(outcomeLine(o, report.dryRun, report.switchCommand, report.entryName));
2234
2329
  if (report.unknownClients.length > 0) lines.push(`Unknown clients (skipped): ${report.unknownClients.join(", ")}`);
2235
2330
  if (report.outcomes.some((o) => o.id === "claude-code" && o.result.ok)) lines.push(PLUGIN_TIP);
2236
2331
  return lines.join("\n");
@@ -2240,7 +2335,7 @@ const PLUGIN_TIP = "Tip: the Levr plugin adds /levr:work, /levr:file and /levr:d
2240
2335
  /** Next-steps blurb after a run. */
2241
2336
  function nextStepsText(report) {
2242
2337
  if (report.dryRun) return "Dry run — re-run without --dry-run to apply these changes.";
2243
- if (!report.outcomes.some((o) => o.result.wrote || o.result.executed || o.result.command)) return "Nothing to do.";
2338
+ if (!report.outcomes.some((o) => o.result.ok && !o.result.alreadyConfigured && (o.result.wrote || o.result.executed || o.result.command))) return report.outcomes.some((o) => !o.result.ok) ? "Nothing was installed." : "Nothing to do.";
2244
2339
  const lines = ["Next: restart the client(s) above — each will prompt you to authorize", "Levr once in the browser. Then ask it: \"What issues are assigned to me?\""];
2245
2340
  if (report.outcomes.some((o) => o.result.scope === "project" && o.result.wrote)) lines.push("", "Project-scoped config was written into this repository — commit it to", "share the Levr MCP with everyone who checks it out.");
2246
2341
  return lines.join("\n");
@@ -2380,7 +2475,8 @@ async function mcpAddHandler(flags) {
2380
2475
  if (options.all || clients.length > 0 || options.yes || !process.stdout.isTTY) {
2381
2476
  const report = {
2382
2477
  ...runNonInteractive(options, url, source, depsFor(entry)),
2383
- switchCommand: switchCommandFor(flags, urlKey)
2478
+ switchCommand: switchCommandFor(flags, urlKey),
2479
+ entryName: entry.serverName
2384
2480
  };
2385
2481
  this.process.stdout.write(`${formatReport(report)}\n`);
2386
2482
  if (!isScopedMcpUrl(url) && servedBySessionApi(url)) {
@@ -2513,7 +2609,8 @@ async function interactive(ctx, dryRun, baseUrl, baseSource, flags, entry, resol
2513
2609
  outcomes,
2514
2610
  unknownClients: [],
2515
2611
  dryRun,
2516
- switchCommand: switchCommandFor(flags, urlKey)
2612
+ switchCommand: switchCommandFor(flags, urlKey),
2613
+ entryName: entry.serverName
2517
2614
  };
2518
2615
  p.note(formatReport(report), "Results");
2519
2616
  p.outro(nextStepsText(report));
package/dist/cli.js CHANGED
@@ -207,7 +207,7 @@ Examples:
207
207
  aliases: { y: "yes" }
208
208
  },
209
209
  loader: async () => {
210
- const { mcpAddHandler } = await import("./addHandler-Dh4elBBV.js");
210
+ const { mcpAddHandler } = await import("./addHandler-DC1U672-.js");
211
211
  return mcpAddHandler;
212
212
  }
213
213
  });
@@ -874,7 +874,7 @@ Examples:
874
874
 
875
875
  //#endregion
876
876
  //#region package.json
877
- var version = "0.11.3";
877
+ var version = "0.11.4";
878
878
 
879
879
  //#endregion
880
880
  //#region src/app.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@levr-one/cli",
3
- "version": "0.11.3",
3
+ "version": "0.11.4",
4
4
  "description": "The command-line interface for Levr",
5
5
  "type": "module",
6
6
  "license": "MIT",