@levr-one/cli 0.11.4 → 0.11.6

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.
@@ -642,6 +642,8 @@ const HARNESSES = [
642
642
  docsUrl: "https://github.com/openai/codex",
643
643
  verifiedVersion: "0.154.0",
644
644
  verifiedOn: "2026-09-17",
645
+ configDirEnv: "CODEX_HOME",
646
+ configDirReplaces: "~/.codex",
645
647
  detectSignals: [
646
648
  {
647
649
  platform: "darwin",
@@ -784,14 +786,20 @@ function defaultEnv() {
784
786
  };
785
787
  }
786
788
  /**
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.
789
+ * A config path or signal with `~` expanded. When the harness's `configDirEnv`
790
+ * is set to a non-empty value, the prefix it stands for (`configDirReplaces`,
791
+ * `~` by default) resolves to that directory instead (ENG-6696, ENG-6761). A
792
+ * relative value resolves against `env.cwd`, the directory a spawned client
793
+ * CLI runs in, so the probe and the client read one file.
791
794
  */
792
- function configHome(harness, env) {
795
+ function expandConfigPath(p, harness, env) {
793
796
  const dir = harness.configDirEnv ? env.vars?.[harness.configDirEnv] : void 0;
794
- return dir ? resolve(env.cwd, dir) : env.homedir;
797
+ if (dir) {
798
+ const prefix = harness.configDirReplaces ?? "~";
799
+ if (p === prefix) return resolve(env.cwd, dir);
800
+ if (p.startsWith(`${prefix}/`) || p.startsWith(`${prefix}\\`)) return join(resolve(env.cwd, dir), p.slice(prefix.length + 1));
801
+ }
802
+ return expandTilde(p, env.homedir);
795
803
  }
796
804
  /** Expand a leading `~` / `~/…` to the given home directory. */
797
805
  function expandTilde(p, home) {
@@ -822,7 +830,7 @@ function resolveConfigPath(harness, env, scope = defaultScope(harness)) {
822
830
  if (def.projectPath) return join(resolveProjectRoot(env).root, ...def.projectPath.split("/"));
823
831
  const loc = locationFor(harness, env.platform, scope);
824
832
  if (!loc) return void 0;
825
- return expandTilde(loc.configPath, configHome(harness, env));
833
+ return expandConfigPath(loc.configPath, harness, env);
826
834
  }
827
835
  /**
828
836
  * Nearest ancestor of `cwd` containing `.git`, or `undefined`.
@@ -1024,11 +1032,12 @@ function whichSync(bin, env) {
1024
1032
  return whichPathSync(bin, env) !== void 0;
1025
1033
  }
1026
1034
  /** Does one `installSignals` entry match on this machine? Supports
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) {
1035
+ * `which:<bin>`, `~`-prefixed paths, and absolute (app-bundle) paths.
1036
+ * `expand` turns a `~` path into the one this harness reads — see
1037
+ * {@link expandConfigPath}. */
1038
+ function signalMatches(signal, env, expand = (p) => expandTilde(p, env.homedir)) {
1030
1039
  if (signal.startsWith("which:")) return whichSync(signal.slice(6), env);
1031
- return existsSync(signal.startsWith("~") ? expandTilde(signal, home) : signal);
1040
+ return existsSync(signal.startsWith("~") ? expand(signal) : signal);
1032
1041
  }
1033
1042
  /**
1034
1043
  * The value our server key currently holds for this scope, or `undefined`.
@@ -1042,6 +1051,25 @@ function readServerEntry(harness, scope, configPath, projectKey, parseJsonc, ser
1042
1051
  if (!text) return void 0;
1043
1052
  return getAtPath(parseJsonc(text), entryPathFor(harness, scope, projectKey, serverName));
1044
1053
  }
1054
+ /**
1055
+ * The http(s) URL an entry points at, read in every shape the catalog writes:
1056
+ * the harness's declared HTTP key (`url`, Antigravity's `serverUrl`), the last
1057
+ * argv of an `mcp-remote` spawn, or Zed's nested `command.args`. Anything that
1058
+ * is not an http(s) URL is not one — a script path in `args` must never be
1059
+ * mistaken for the server the entry points at.
1060
+ */
1061
+ function entryUrl(harness, entry) {
1062
+ if (entry === null || typeof entry !== "object") return void 0;
1063
+ const e = entry;
1064
+ const urlKey = harness.nativeHttpEntry?.urlKey ?? "url";
1065
+ const command = e["command"];
1066
+ const args = Array.isArray(e["args"]) ? e["args"] : command !== null && typeof command === "object" && Array.isArray(command.args) ? command.args : [];
1067
+ return [
1068
+ e[urlKey],
1069
+ e["url"],
1070
+ args[args.length - 1]
1071
+ ].find((c) => typeof c === "string" && /^https?:\/\//i.test(c));
1072
+ }
1045
1073
  /** Read a text file, or `null` if it doesn't exist / can't be read. */
1046
1074
  function readTextOrNull(path$1) {
1047
1075
  try {
@@ -1485,36 +1513,44 @@ function adapterFor(harness) {
1485
1513
  //#endregion
1486
1514
  //#region ../mcp-harnesses/dist/node/detect.js
1487
1515
  /**
1488
- * Is our server key present for this scope?
1516
+ * Our server key's entry for this scope, or undefined when it is absent.
1489
1517
  *
1490
1518
  * Claude Code shares one file (`~/.claude.json`) between its `user` and
1491
1519
  * `local` scopes, so the two are told apart by WHERE in the file they sit,
1492
1520
  * not by which file is read — hence the entry path rather than a bare
1493
1521
  * top-level lookup.
1494
1522
  */
1495
- function isServerConfigured(harness, scope, configPath, projectKey) {
1523
+ function serverEntry(harness, scope, configPath, projectKey, serverName) {
1496
1524
  const text = readTextOrNull(configPath);
1497
- if (!text) return false;
1498
- const read = adapterFor(harness).readAt(text, entryPathFor(harness, scope, projectKey));
1499
- return read.kind === "value" && read.value !== null;
1525
+ if (!text) return void 0;
1526
+ const read = adapterFor(harness).readAt(text, entryPathFor(harness, scope, projectKey, serverName));
1527
+ return read.kind === "value" && read.value !== null ? read.value : void 0;
1500
1528
  }
1501
- function detectScope(harness, env, def) {
1529
+ function detectScope(harness, env, def, opts) {
1502
1530
  const configPath = resolveConfigPath(harness, env, def.scope);
1503
1531
  const inRepo = def.projectPath ? resolveProjectRoot(env).isRepo : true;
1504
1532
  const collides = collidingScope(harness, env, def.scope) !== void 0;
1505
1533
  const available = Boolean(configPath) && inRepo && !collides;
1534
+ const read = (name) => available && configPath && name ? serverEntry(harness, def.scope, configPath, projectKeyFor(env), name) : void 0;
1535
+ const entry = read(opts.serverName);
1536
+ const currentUrl = entry === void 0 ? void 0 : entryUrl(harness, entry);
1537
+ const beside = read(opts.besideName);
1538
+ const besideUrl = beside === void 0 ? void 0 : entryUrl(harness, beside);
1506
1539
  return {
1507
1540
  scope: def.scope,
1508
1541
  installKind: def.installKind,
1509
1542
  configPath: available ? configPath ?? "" : "",
1510
1543
  available,
1511
- alreadyConfigured: available && configPath ? isServerConfigured(harness, def.scope, configPath, projectKeyFor(env)) : false
1544
+ alreadyConfigured: entry !== void 0,
1545
+ ...currentUrl === void 0 ? {} : { currentUrl },
1546
+ ...besideUrl === void 0 ? {} : { besideUrl },
1547
+ ...beside === void 0 ? {} : { besideConfigured: true }
1512
1548
  };
1513
1549
  }
1514
- function detectOne(harness, env) {
1515
- const scopes = harness.scopes.map((def) => detectScope(harness, env, def));
1550
+ function detectOne(harness, env, opts) {
1551
+ const scopes = harness.scopes.map((def) => detectScope(harness, env, def, opts));
1516
1552
  const available = scopes.some((s) => s.available);
1517
- const installed = detectSignalsFor(harness, env.platform).some((s) => signalMatches(s, env, configHome(harness, env))) || scopes.some((s) => s.configPath !== "" && existsSync(s.configPath));
1553
+ const installed = detectSignalsFor(harness, env.platform).some((s) => signalMatches(s, env, (p) => expandConfigPath(p, harness, env))) || scopes.some((s) => s.configPath !== "" && existsSync(s.configPath));
1518
1554
  const fallback = scopes.find((s) => s.scope === defaultScope(harness));
1519
1555
  return {
1520
1556
  id: harness.id,
@@ -1527,8 +1563,12 @@ function detectOne(harness, env) {
1527
1563
  };
1528
1564
  }
1529
1565
  /** Synchronous detection over the whole catalog. Exported for tests. */
1530
- function detectSync(env = defaultEnv()) {
1531
- return HARNESSES.map((h) => detectOne(h, env));
1566
+ function detectSync(env = defaultEnv(), opts = {}) {
1567
+ const resolved = {
1568
+ ...opts,
1569
+ serverName: opts.serverName ?? DEFAULT_SERVER_NAME
1570
+ };
1571
+ return HARNESSES.map((h) => detectOne(h, env, resolved));
1532
1572
  }
1533
1573
 
1534
1574
  //#endregion
@@ -1680,25 +1720,6 @@ function currentEntry(harness, env, scope, serverName) {
1680
1720
  url
1681
1721
  };
1682
1722
  }
1683
- /**
1684
- * The http(s) URL an entry points at, read in every shape the catalog writes:
1685
- * the harness's declared HTTP key (`url`, Antigravity's `serverUrl`), the last
1686
- * argv of an `mcp-remote` spawn, or Zed's nested `command.args`. Anything that
1687
- * is not an http(s) URL is not one — a script path in `args` must never be
1688
- * mistaken for the server the entry points at.
1689
- */
1690
- function entryUrl(harness, entry) {
1691
- if (entry === null || typeof entry !== "object") return void 0;
1692
- const e = entry;
1693
- const urlKey = harness.nativeHttpEntry?.urlKey ?? "url";
1694
- const command = e["command"];
1695
- const args = Array.isArray(e["args"]) ? e["args"] : command !== null && typeof command === "object" && Array.isArray(command.args) ? command.args : [];
1696
- return [
1697
- e[urlKey],
1698
- e["url"],
1699
- args[args.length - 1]
1700
- ].find((c) => typeof c === "string" && /^https?:\/\//i.test(c));
1701
- }
1702
1723
  /** `claude mcp remove` argv — the inverse of the catalog's add command. */
1703
1724
  function removeCommandArgv(scope, serverName) {
1704
1725
  return [
@@ -2090,6 +2111,16 @@ function servedBySessionApi(mcpUrl) {
2090
2111
  }
2091
2112
  /** `…/v1/mcp/w/<url_key>`: the URL already names one workspace (ENG-6300). */
2092
2113
  const SCOPED_SUFFIX = /\/w\/[^/]+$/;
2114
+ /** The workspace a scoped MCP URL names (`…/w/<url_key>`), if any. */
2115
+ function workspaceKeyOf(url) {
2116
+ const m = /\/w\/([^/]+)$/.exec(stripSlash(url));
2117
+ if (!m?.[1]) return void 0;
2118
+ try {
2119
+ return decodeURIComponent(m[1]);
2120
+ } catch {
2121
+ return m[1];
2122
+ }
2123
+ }
2093
2124
  /** Does this MCP URL already name a workspace? */
2094
2125
  function isScopedMcpUrl(url) {
2095
2126
  return SCOPED_SUFFIX.test(stripSlash(url));
@@ -2150,14 +2181,54 @@ function findWorkspaceByKeyOrName(sites, input) {
2150
2181
  * what someone running an installer once expects: available in every project.
2151
2182
  */
2152
2183
  const DEFAULT_SCOPE = "user";
2153
- /** Harness ids to pre-select in interactive mode: detected + installable +
2154
- * not-already-configured. "Already configured" is judged in the scope we are
2155
- * about to install into, not the harness's default one. */
2156
- function autoSelectIds(detected, scope) {
2184
+ function entryState(s, url) {
2185
+ if (s?.alreadyConfigured && s.currentUrl === url) return "same-url";
2186
+ if (s?.besideUrl === url) return "beside";
2187
+ if (!s?.alreadyConfigured) return "absent";
2188
+ return s.currentUrl === void 0 ? "unrecognized" : "other-url";
2189
+ }
2190
+ /**
2191
+ * Allowed client entry names: 1-64 of `A-Za-z0-9_-`, and never starting with
2192
+ * `-`. The name is passed as an argv element to the client's own CLI
2193
+ * (`claude mcp add … <name> <url>`), so a leading `-` would be read there as a
2194
+ * flag — the ENG-4159 argument-injection family.
2195
+ */
2196
+ const ENTRY_NAME = /^[A-Za-z0-9_-]{1,64}$/;
2197
+ function isValidEntryName(name) {
2198
+ return ENTRY_NAME.test(name) && !name.startsWith("-");
2199
+ }
2200
+ /**
2201
+ * The scope an install would land in: the requested one, or, for a harness
2202
+ * that cannot do it, its own default, which is where the fallback goes.
2203
+ */
2204
+ function landingScope(d, scope) {
2205
+ const inScope = scope ? d.scopes.find((s) => s.scope === scope) : void 0;
2206
+ if (inScope) return inScope;
2207
+ const harness = getHarness(d.id);
2208
+ return harness ? d.scopes.find((s) => s.scope === defaultScope(harness)) : void 0;
2209
+ }
2210
+ /** Where an entry points, in the user's terms: the workspace, else the URL. */
2211
+ function describeTarget(url) {
2212
+ const key = workspaceKeyOf(url);
2213
+ return key ? `workspace ${key}` : url;
2214
+ }
2215
+ /**
2216
+ * Harness ids to pre-select in interactive mode, and to take under `--yes`:
2217
+ * detected + installable + not already set up for THIS url. Judged in the
2218
+ * scope we are about to install into, not the harness's default one.
2219
+ *
2220
+ * An entry pointing at another URL is selected (ENG-6736): it is not set up
2221
+ * for the workspace asked for, and skipping it would report success while
2222
+ * leaving the client on the old one. Interactively the user is then asked
2223
+ * what to do with it; under `--yes` the installer refuses it (url-mismatch,
2224
+ * exit 1) unless `--replace` or `--name` was given. An entry with no URL we
2225
+ * can read is someone else's, and is never picked on the user's behalf.
2226
+ */
2227
+ function autoSelectIds(detected, scope, url) {
2157
2228
  return detected.filter((d) => {
2158
2229
  if (!d.available || !d.installed) return false;
2159
- const inScope = scope ? d.scopes.find((s) => s.scope === scope) : void 0;
2160
- return !(inScope ? inScope.alreadyConfigured : d.alreadyConfigured);
2230
+ const state = entryState(landingScope(d, scope), url);
2231
+ return state === "absent" || state === "other-url";
2161
2232
  }).map((d) => d.id);
2162
2233
  }
2163
2234
  /**
@@ -2169,14 +2240,18 @@ function autoSelectIds(detected, scope) {
2169
2240
  * NOT configured at `project`, and labelling it "already set up" while
2170
2241
  * installing to `project` is simply wrong.
2171
2242
  */
2172
- function clientChoices(detected, scope) {
2173
- const preselect = new Set(autoSelectIds(detected, scope));
2243
+ function clientChoices(detected, scope, url, serverName = DEFAULT_SERVER_NAME) {
2244
+ const preselect = new Set(autoSelectIds(detected, scope, url));
2245
+ const beside = besideName(serverName, url);
2174
2246
  return detected.filter((d) => d.available).map((d) => {
2175
2247
  const inScope = d.scopes.find((s) => s.scope === scope);
2176
2248
  const harness = getHarness(d.id);
2177
2249
  let hint;
2178
2250
  if (!inScope) hint = `no ${scope} scope — will use ${harness ? defaultScope(harness) : DEFAULT_SCOPE}`;
2179
- else if (inScope.alreadyConfigured) hint = `already set up (${scope})`;
2251
+ else if (entryState(inScope, url) === "same-url") hint = `already set up (${scope})`;
2252
+ else if (entryState(inScope, url) === "beside") hint = `already set up as ${beside ?? "a second entry"} (${scope})`;
2253
+ else if (inScope.currentUrl !== void 0) hint = `points at ${describeTarget(inScope.currentUrl)} (${scope})`;
2254
+ else if (inScope.alreadyConfigured) hint = `its ${serverName} entry holds no Levr URL (${scope})`;
2180
2255
  else hint = d.installed ? "detected" : "not detected";
2181
2256
  return {
2182
2257
  value: d.id,
@@ -2186,6 +2261,49 @@ function clientChoices(detected, scope) {
2186
2261
  };
2187
2262
  });
2188
2263
  }
2264
+ /** Claude Code tool names are `mcp__<entry>__<tool>`; keep that well short. */
2265
+ const BESIDE_NAME_MAX = 30;
2266
+ /**
2267
+ * The name for a second entry beside `serverName`: `<serverName>-<url_key>`,
2268
+ * so it says which workspace it is. Undefined — and "Add beside it" is not
2269
+ * offered — when the target URL names no workspace, or when that name would
2270
+ * be too long or not a valid entry name. It is never shortened: a cut name
2271
+ * could drop the workspace, or give two workspaces the same entry.
2272
+ */
2273
+ function besideName(serverName, url) {
2274
+ const key = workspaceKeyOf(url);
2275
+ if (!key) return void 0;
2276
+ const name = `${serverName}-${key}`;
2277
+ return name.length <= BESIDE_NAME_MAX && isValidEntryName(name) ? name : void 0;
2278
+ }
2279
+ /**
2280
+ * The selected clients whose entry is not this URL's: each needs a decision —
2281
+ * switch it, add beside it, or leave it — before anything is installed
2282
+ * (ENG-6736). A client already set up beside it is listed too, as `beside`,
2283
+ * so it is installed under that name (and reads as already set up) rather
2284
+ * than refused under the main one. Pure, so it is testable without a TTY.
2285
+ */
2286
+ function entryConflicts(selection, detected, scope, url, serverName) {
2287
+ const conflicts = [];
2288
+ const beside = besideName(serverName, url);
2289
+ for (const id of selection) {
2290
+ const d = detected.find((x) => x.id === id);
2291
+ if (!d) continue;
2292
+ const landing = landingScope(d, scope);
2293
+ const state = entryState(landing, url);
2294
+ if (state === "absent" || state === "same-url") continue;
2295
+ const offer = state === "beside" || !landing?.besideConfigured;
2296
+ conflicts.push({
2297
+ id,
2298
+ label: d.label,
2299
+ state,
2300
+ ...landing?.currentUrl ? { currentUrl: landing.currentUrl } : {},
2301
+ ...beside && offer ? { besideName: beside } : {},
2302
+ ...beside && !offer ? { besideTaken: beside } : {}
2303
+ });
2304
+ }
2305
+ return conflicts;
2306
+ }
2189
2307
  /** Scopes worth offering for a selection: any scope at least one selected
2190
2308
  * client can actually use here. Availability already accounts for "are we
2191
2309
  * inside a repo", so project scope disappears outside one. */
@@ -2228,11 +2346,13 @@ function installSelected(ids, plan, install) {
2228
2346
  const canHonor = supportsScope(harness, plan.scope);
2229
2347
  const named = plan.namedIds.has(id);
2230
2348
  const effective = canHonor || named ? plan.scope : defaultScope(harness);
2349
+ const override = plan.overrides?.get(id);
2231
2350
  outcomes.push({
2232
2351
  id,
2233
2352
  label: harness.label,
2234
- result: install(harness, plan.mcpUrl, plan.dryRun, effective),
2235
- ...canHonor ? {} : named ? {} : { fallbackFrom: plan.scope }
2353
+ result: install(harness, plan.mcpUrl, plan.dryRun, effective, override),
2354
+ ...canHonor ? {} : named ? {} : { fallbackFrom: plan.scope },
2355
+ ...override?.serverName ? { entryName: override.serverName } : {}
2236
2356
  });
2237
2357
  }
2238
2358
  return outcomes;
@@ -2243,7 +2363,7 @@ function installSelected(ids, plan, install) {
2243
2363
  * report. No console output — the caller formats it.
2244
2364
  */
2245
2365
  function runNonInteractive(options, url, urlSource, deps) {
2246
- const detected = deps.detect();
2366
+ const detected = deps.detect(url);
2247
2367
  const scope = options.scope ?? DEFAULT_SCOPE;
2248
2368
  let ids;
2249
2369
  let unknown = [];
@@ -2252,7 +2372,7 @@ function runNonInteractive(options, url, urlSource, deps) {
2252
2372
  const requested = resolveRequestedIds(options, detected);
2253
2373
  ids = requested.ids;
2254
2374
  unknown = requested.unknown;
2255
- } else ids = autoSelectIds(detected, scope);
2375
+ } else ids = autoSelectIds(detected, scope, url);
2256
2376
  return {
2257
2377
  url,
2258
2378
  urlSource,
@@ -2275,7 +2395,7 @@ function failureText(o, switchCommand, entryName) {
2275
2395
  case "unsupported-scope": return `no ${r.scope} scope` + (harness ? ` (supports: ${supportedScopes(harness).join(", ")})` : "");
2276
2396
  case "not-a-repo": return "project scope needs a git repository (run from inside one)";
2277
2397
  case "scope-collision": return `${r.scope} scope resolves to the same file as ${r.collidesWith ?? "another"} scope here — refusing rather than overwriting it`;
2278
- case "url-mismatch": return "already configured with a different URL" + (r.currentUrl ? ` (${r.currentUrl})` : "") + (switchCommand ? `; run \`${switchCommand}\` to switch it, or add --name <other> to keep both` : "; remove it first, then re-run");
2398
+ case "url-mismatch": return "already configured with a different URL" + (r.currentUrl ? ` (${r.currentUrl})` : "") + (switchCommand ? `; run \`${switchCommand}\` to switch it, or add --name <other> to keep both` : `; ${removeHow(o, entryName)}, then re-run`);
2279
2399
  case "unrecognized-entry": return "an entry of that name already exists but holds no Levr URL we can read; remove it from the client's config by hand, or add this one with --name <other>";
2280
2400
  case "write-failed": return `its config could not be written` + (r.detail ? ` (${r.detail})` : "") + `; check the file's permissions and re-run`;
2281
2401
  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`;
@@ -2297,6 +2417,16 @@ function alreadyExistsHint(o, commandError, entryName) {
2297
2417
  const scope = /already exists in (user|project|local) config/i.exec(commandError)?.[1]?.toLowerCase() ?? o.result.scope;
2298
2418
  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
2419
  }
2420
+ /**
2421
+ * Where to remove an entry by hand: the exact command for Claude Code, else
2422
+ * the config file it sits in (ENG-6736).
2423
+ */
2424
+ function removeHow(o, entryName) {
2425
+ const name = entryName ?? DEFAULT_SERVER_NAME;
2426
+ const r = o.result;
2427
+ if (o.id === "claude-code") return `remove it with \`claude mcp remove --scope ${r.scope} ${name}\``;
2428
+ return r.path ? `remove the ${name} entry from ${r.path}` : `remove the ${name} entry from the client's config`;
2429
+ }
2300
2430
  /** What happened to the entry a failed `--replace` removed (ENG-6300 D6). */
2301
2431
  function restoreText(r) {
2302
2432
  if (r.restored === void 0) return "";
@@ -2306,11 +2436,14 @@ function restoreText(r) {
2306
2436
  /** One human-readable status line per outcome. */
2307
2437
  function outcomeLine(o, dryRun, switchCommand, entryName) {
2308
2438
  const r = o.result;
2309
- const note = o.fallbackFrom ? ` [${o.fallbackFrom} scope unsupported — used ${r.scope}]` : "";
2439
+ const note = (o.entryName ? ` [as ${o.entryName}]` : "") + (o.fallbackFrom ? ` [${o.fallbackFrom} scope unsupported — used ${r.scope}]` : "");
2310
2440
  const where = r.path ? ` → ${r.path}` : "";
2311
2441
  const backup = r.backupPath ? dryRun ? ` [original would be backed up to ${r.backupPath}]` : ` [original backed up to ${r.backupPath}]` : "";
2312
2442
  const replaced = r.replacedUrl ? dryRun ? ` [would replace ${r.replacedUrl}]` : ` [replaced ${r.replacedUrl}]` : "";
2313
- if (!r.ok) return `${o.label}: failed — ${failureText(o, switchCommand, entryName)}`;
2443
+ if (!r.ok) {
2444
+ if (o.entryName) return `${o.label}: failed [as ${o.entryName}] — ${failureText(o, void 0, o.entryName)}`;
2445
+ return `${o.label}: failed — ${failureText(o, switchCommand, entryName)}`;
2446
+ }
2314
2447
  if (r.alreadyConfigured) return `${o.label}: already set up (${r.scope})${where}${note}`;
2315
2448
  if (r.command) {
2316
2449
  if (r.executed) return `${o.label}: installed (${r.scope}) via \`${r.command}\`${replaced}${note}`;
@@ -2324,8 +2457,13 @@ function outcomeLine(o, dryRun, switchCommand, entryName) {
2324
2457
  function formatReport(report) {
2325
2458
  const lines = [];
2326
2459
  lines.push(`MCP URL: ${report.url} (${report.urlSource})`);
2327
- if (report.outcomes.length === 0) lines.push("No clients selected.");
2460
+ if (report.outcomes.length === 0 && (report.left ?? []).length === 0) lines.push("No clients selected.");
2328
2461
  else for (const o of report.outcomes) lines.push(outcomeLine(o, report.dryRun, report.switchCommand, report.entryName));
2462
+ for (const l of report.left ?? []) {
2463
+ const why = l.currentUrl ? `points at ${describeTarget(l.currentUrl)}` : `its ${report.entryName ?? DEFAULT_SERVER_NAME} entry holds no Levr URL`;
2464
+ const taken = l.besideTaken ? `; ${l.besideTaken} is taken too, so add this one with --name <other>` : "";
2465
+ lines.push(`${l.label}: left as it was (${why}${taken})`);
2466
+ }
2329
2467
  if (report.unknownClients.length > 0) lines.push(`Unknown clients (skipped): ${report.unknownClients.join(", ")}`);
2330
2468
  if (report.outcomes.some((o) => o.id === "claude-code" && o.result.ok)) lines.push(PLUGIN_TIP);
2331
2469
  return lines.join("\n");
@@ -2343,25 +2481,18 @@ function nextStepsText(report) {
2343
2481
 
2344
2482
  //#endregion
2345
2483
  //#region src/commands/mcp/addHandler.ts
2346
- /**
2347
- * Allowed client entry names: 1-64 of `A-Za-z0-9_-`, and never starting with
2348
- * `-`. The name is passed as an argv element to the client's own CLI
2349
- * (`claude mcp add … <name> <url>`), so a leading `-` would be read there as a
2350
- * flag — the ENG-4159 argument-injection family.
2351
- */
2352
- const ENTRY_NAME = /^[A-Za-z0-9_-]{1,64}$/;
2353
- function isValidEntryName(name) {
2354
- return ENTRY_NAME.test(name) && !name.startsWith("-");
2355
- }
2356
2484
  function depsFor(entry) {
2357
- const install = (harness, mcpUrl, dryRun, scope) => installHarnessSync(harness, mcpUrl, {
2485
+ const install = (harness, mcpUrl, dryRun, scope, override) => installHarnessSync(harness, mcpUrl, {
2358
2486
  dryRun,
2359
2487
  scope,
2360
- serverName: entry.serverName,
2361
- replaceExisting: entry.replaceExisting
2488
+ serverName: override?.serverName ?? entry.serverName,
2489
+ replaceExisting: override?.replaceExisting ?? entry.replaceExisting
2362
2490
  });
2363
2491
  return {
2364
- detect: () => detectSync(),
2492
+ detect: (url) => detectSync(void 0, {
2493
+ serverName: entry.serverName,
2494
+ besideName: besideName(entry.serverName, url)
2495
+ }),
2365
2496
  install
2366
2497
  };
2367
2498
  }
@@ -2548,7 +2679,7 @@ async function interactive(ctx, dryRun, baseUrl, baseSource, flags, entry, resol
2548
2679
  }
2549
2680
  }
2550
2681
  p.note(`${url}\n(${urlSource})`, "MCP endpoint");
2551
- const detected = deps.detect();
2682
+ const detected = deps.detect(url);
2552
2683
  const installable = detected.filter((d) => d.available);
2553
2684
  if (installable.length === 0) {
2554
2685
  p.outro("No supported MCP clients found on this machine.");
@@ -2573,7 +2704,7 @@ async function interactive(ctx, dryRun, baseUrl, baseSource, flags, entry, resol
2573
2704
  }
2574
2705
  scope = picked;
2575
2706
  } else if (!requestedScope && choices.length === 1) scope = choices[0] ?? DEFAULT_SCOPE;
2576
- const rows = clientChoices(detected, scope);
2707
+ const rows = clientChoices(detected, scope, url, entry.serverName);
2577
2708
  const selection = await p.multiselect({
2578
2709
  message: `Select clients to set up (${scope} scope)`,
2579
2710
  options: rows.map((r) => ({
@@ -2593,13 +2724,64 @@ async function interactive(ctx, dryRun, baseUrl, baseSource, flags, entry, resol
2593
2724
  p.outro("Nothing selected — bye.");
2594
2725
  return;
2595
2726
  }
2727
+ const overrides = /* @__PURE__ */ new Map();
2728
+ const left = [];
2729
+ for (const c of entryConflicts(selection, detected, scope, url, entry.serverName)) {
2730
+ if (c.state === "beside") {
2731
+ if (c.besideName) overrides.set(c.id, {
2732
+ serverName: c.besideName,
2733
+ replaceExisting: false
2734
+ });
2735
+ continue;
2736
+ }
2737
+ if (c.state === "other-url" && entry.replaceExisting) continue;
2738
+ const where = c.currentUrl ? `points at ${describeTarget(c.currentUrl)}` : "holds no Levr URL";
2739
+ const options = [];
2740
+ if (c.state === "other-url") options.push({
2741
+ value: "switch",
2742
+ label: `Switch it to ${describeTarget(url)}`,
2743
+ hint: "replaces the entry"
2744
+ });
2745
+ if (c.besideName) options.push({
2746
+ value: "beside",
2747
+ label: `Add beside it as ${c.besideName}`,
2748
+ hint: "keeps both; each repeats every tool"
2749
+ });
2750
+ options.push({
2751
+ value: "leave",
2752
+ label: "Leave it"
2753
+ });
2754
+ const answer = options.length === 1 ? "leave" : await p.select({
2755
+ message: `${c.label}: its ${entry.serverName} entry ${where}` + (c.besideTaken ? `, and ${c.besideTaken} is taken too` : ""),
2756
+ options
2757
+ });
2758
+ if (p.isCancel(answer)) {
2759
+ p.cancel("Cancelled.");
2760
+ ctx.process.exitCode = 1;
2761
+ return;
2762
+ }
2763
+ if (answer === "switch") overrides.set(c.id, { replaceExisting: true });
2764
+ else if (answer === "beside" && c.besideName) overrides.set(c.id, {
2765
+ serverName: c.besideName,
2766
+ replaceExisting: false
2767
+ });
2768
+ else left.push({
2769
+ id: c.id,
2770
+ label: c.label,
2771
+ ...c.currentUrl ? { currentUrl: c.currentUrl } : {},
2772
+ ...c.besideTaken ? { besideTaken: c.besideTaken } : {}
2773
+ });
2774
+ }
2775
+ const leftIds = new Set(left.map((l) => l.id));
2776
+ const toInstall = selection.filter((id) => !leftIds.has(id));
2596
2777
  const spin = p.spinner();
2597
2778
  spin.start(dryRun ? "Previewing changes" : "Installing");
2598
- const outcomes = installSelected(selection, {
2779
+ const outcomes = installSelected(toInstall, {
2599
2780
  mcpUrl: url,
2600
2781
  dryRun,
2601
2782
  scope,
2602
- namedIds: /* @__PURE__ */ new Set()
2783
+ namedIds: /* @__PURE__ */ new Set(),
2784
+ overrides
2603
2785
  }, deps.install);
2604
2786
  spin.stop(dryRun ? "Preview ready" : "Done");
2605
2787
  const report = {
@@ -2610,7 +2792,8 @@ async function interactive(ctx, dryRun, baseUrl, baseSource, flags, entry, resol
2610
2792
  unknownClients: [],
2611
2793
  dryRun,
2612
2794
  switchCommand: switchCommandFor(flags, urlKey),
2613
- entryName: entry.serverName
2795
+ entryName: entry.serverName,
2796
+ left
2614
2797
  };
2615
2798
  p.note(formatReport(report), "Results");
2616
2799
  p.outro(nextStepsText(report));
package/dist/cli.js CHANGED
@@ -125,9 +125,11 @@ the scope it does support, and the report says so.
125
125
  interactive run with several workspaces asks which one; a non-interactive run
126
126
  writes the unpinned URL and lists the --workspace choices.
127
127
 
128
- The entry is called "levr" unless you pass --name. An entry of that name that
129
- already points somewhere else is left alone: re-run with --replace to switch it
130
- to the new URL, or pick another --name to keep both side by side. Two entries
128
+ The entry is called "levr" unless you pass --name. When an entry of that name
129
+ already points somewhere else, the interactive picker asks whether to switch
130
+ it, add a second one beside it, or leave it. A non-interactive run leaves it
131
+ alone and exits 1: re-run with --replace to switch it to the new URL, or pick
132
+ another --name to keep both side by side. Two entries
131
133
  means every Levr tool appears twice in that client, once per workspace.
132
134
 
133
135
  Interactive by default; non-interactive when --all/--client/--yes is passed
@@ -207,7 +209,7 @@ Examples:
207
209
  aliases: { y: "yes" }
208
210
  },
209
211
  loader: async () => {
210
- const { mcpAddHandler } = await import("./addHandler-DC1U672-.js");
212
+ const { mcpAddHandler } = await import("./addHandler-DJqJGqz3.js");
211
213
  return mcpAddHandler;
212
214
  }
213
215
  });
@@ -874,7 +876,7 @@ Examples:
874
876
 
875
877
  //#endregion
876
878
  //#region package.json
877
- var version = "0.11.4";
879
+ var version = "0.11.6";
878
880
 
879
881
  //#endregion
880
882
  //#region src/app.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@levr-one/cli",
3
- "version": "0.11.4",
3
+ "version": "0.11.6",
4
4
  "description": "The command-line interface for Levr",
5
5
  "type": "module",
6
6
  "license": "MIT",