@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
|
-
*
|
|
788
|
-
*
|
|
789
|
-
*
|
|
790
|
-
*
|
|
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
|
|
795
|
+
function expandConfigPath(p, harness, env) {
|
|
793
796
|
const dir = harness.configDirEnv ? env.vars?.[harness.configDirEnv] : void 0;
|
|
794
|
-
|
|
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
|
|
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.
|
|
1028
|
-
*
|
|
1029
|
-
|
|
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("~") ?
|
|
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
|
-
*
|
|
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
|
|
1523
|
+
function serverEntry(harness, scope, configPath, projectKey, serverName) {
|
|
1496
1524
|
const text = readTextOrNull(configPath);
|
|
1497
|
-
if (!text) return
|
|
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:
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
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
|
|
2160
|
-
return
|
|
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
|
|
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` :
|
|
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)
|
|
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(
|
|
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.
|
|
129
|
-
already points somewhere else
|
|
130
|
-
|
|
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-
|
|
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.
|
|
879
|
+
var version = "0.11.6";
|
|
878
880
|
|
|
879
881
|
//#endregion
|
|
880
882
|
//#region src/app.ts
|