@levr-one/cli 0.11.3 → 0.11.5
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
|
-
/**
|
|
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
|
-
|
|
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
|
|
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
|
-
* `
|
|
853
|
-
*
|
|
854
|
-
*
|
|
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,
|
|
927
|
+
function entryPathFor(harness, scope, projectKey, serverName = DEFAULT_SERVER_NAME) {
|
|
863
928
|
const tail = [serverPropertyFor(harness, scope), serverName];
|
|
864
|
-
const nest = scopeDef(harness, scope)?.
|
|
929
|
+
const nest = scopeDef(harness, scope)?.projectKeyedUnder;
|
|
865
930
|
return nest ? [
|
|
866
931
|
nest,
|
|
867
|
-
|
|
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
|
|
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,
|
|
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,42 @@ 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
|
-
|
|
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,
|
|
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`
|
|
970
|
-
*
|
|
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,
|
|
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,
|
|
1043
|
+
return getAtPath(parseJsonc(text), entryPathFor(harness, scope, projectKey, serverName));
|
|
1044
|
+
}
|
|
1045
|
+
/**
|
|
1046
|
+
* The http(s) URL an entry points at, read in every shape the catalog writes:
|
|
1047
|
+
* the harness's declared HTTP key (`url`, Antigravity's `serverUrl`), the last
|
|
1048
|
+
* argv of an `mcp-remote` spawn, or Zed's nested `command.args`. Anything that
|
|
1049
|
+
* is not an http(s) URL is not one — a script path in `args` must never be
|
|
1050
|
+
* mistaken for the server the entry points at.
|
|
1051
|
+
*/
|
|
1052
|
+
function entryUrl(harness, entry) {
|
|
1053
|
+
if (entry === null || typeof entry !== "object") return void 0;
|
|
1054
|
+
const e = entry;
|
|
1055
|
+
const urlKey = harness.nativeHttpEntry?.urlKey ?? "url";
|
|
1056
|
+
const command = e["command"];
|
|
1057
|
+
const args = Array.isArray(e["args"]) ? e["args"] : command !== null && typeof command === "object" && Array.isArray(command.args) ? command.args : [];
|
|
1058
|
+
return [
|
|
1059
|
+
e[urlKey],
|
|
1060
|
+
e["url"],
|
|
1061
|
+
args[args.length - 1]
|
|
1062
|
+
].find((c) => typeof c === "string" && /^https?:\/\//i.test(c));
|
|
977
1063
|
}
|
|
978
1064
|
/** Read a text file, or `null` if it doesn't exist / can't be read. */
|
|
979
1065
|
function readTextOrNull(path$1) {
|
|
@@ -1418,36 +1504,44 @@ function adapterFor(harness) {
|
|
|
1418
1504
|
//#endregion
|
|
1419
1505
|
//#region ../mcp-harnesses/dist/node/detect.js
|
|
1420
1506
|
/**
|
|
1421
|
-
*
|
|
1507
|
+
* Our server key's entry for this scope, or undefined when it is absent.
|
|
1422
1508
|
*
|
|
1423
1509
|
* Claude Code shares one file (`~/.claude.json`) between its `user` and
|
|
1424
1510
|
* `local` scopes, so the two are told apart by WHERE in the file they sit,
|
|
1425
1511
|
* not by which file is read — hence the entry path rather than a bare
|
|
1426
1512
|
* top-level lookup.
|
|
1427
1513
|
*/
|
|
1428
|
-
function
|
|
1514
|
+
function serverEntry(harness, scope, configPath, projectKey, serverName) {
|
|
1429
1515
|
const text = readTextOrNull(configPath);
|
|
1430
|
-
if (!text) return
|
|
1431
|
-
const read = adapterFor(harness).readAt(text, entryPathFor(harness, scope,
|
|
1432
|
-
return read.kind === "value" && read.value !== null;
|
|
1516
|
+
if (!text) return void 0;
|
|
1517
|
+
const read = adapterFor(harness).readAt(text, entryPathFor(harness, scope, projectKey, serverName));
|
|
1518
|
+
return read.kind === "value" && read.value !== null ? read.value : void 0;
|
|
1433
1519
|
}
|
|
1434
|
-
function detectScope(harness, env, def) {
|
|
1520
|
+
function detectScope(harness, env, def, opts) {
|
|
1435
1521
|
const configPath = resolveConfigPath(harness, env, def.scope);
|
|
1436
1522
|
const inRepo = def.projectPath ? resolveProjectRoot(env).isRepo : true;
|
|
1437
1523
|
const collides = collidingScope(harness, env, def.scope) !== void 0;
|
|
1438
1524
|
const available = Boolean(configPath) && inRepo && !collides;
|
|
1525
|
+
const read = (name) => available && configPath && name ? serverEntry(harness, def.scope, configPath, projectKeyFor(env), name) : void 0;
|
|
1526
|
+
const entry = read(opts.serverName);
|
|
1527
|
+
const currentUrl = entry === void 0 ? void 0 : entryUrl(harness, entry);
|
|
1528
|
+
const beside = read(opts.besideName);
|
|
1529
|
+
const besideUrl = beside === void 0 ? void 0 : entryUrl(harness, beside);
|
|
1439
1530
|
return {
|
|
1440
1531
|
scope: def.scope,
|
|
1441
1532
|
installKind: def.installKind,
|
|
1442
1533
|
configPath: available ? configPath ?? "" : "",
|
|
1443
1534
|
available,
|
|
1444
|
-
alreadyConfigured:
|
|
1535
|
+
alreadyConfigured: entry !== void 0,
|
|
1536
|
+
...currentUrl === void 0 ? {} : { currentUrl },
|
|
1537
|
+
...besideUrl === void 0 ? {} : { besideUrl },
|
|
1538
|
+
...beside === void 0 ? {} : { besideConfigured: true }
|
|
1445
1539
|
};
|
|
1446
1540
|
}
|
|
1447
|
-
function detectOne(harness, env) {
|
|
1448
|
-
const scopes = harness.scopes.map((def) => detectScope(harness, env, def));
|
|
1541
|
+
function detectOne(harness, env, opts) {
|
|
1542
|
+
const scopes = harness.scopes.map((def) => detectScope(harness, env, def, opts));
|
|
1449
1543
|
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));
|
|
1544
|
+
const installed = detectSignalsFor(harness, env.platform).some((s) => signalMatches(s, env, configHome(harness, env))) || scopes.some((s) => s.configPath !== "" && existsSync(s.configPath));
|
|
1451
1545
|
const fallback = scopes.find((s) => s.scope === defaultScope(harness));
|
|
1452
1546
|
return {
|
|
1453
1547
|
id: harness.id,
|
|
@@ -1460,8 +1554,12 @@ function detectOne(harness, env) {
|
|
|
1460
1554
|
};
|
|
1461
1555
|
}
|
|
1462
1556
|
/** Synchronous detection over the whole catalog. Exported for tests. */
|
|
1463
|
-
function detectSync(env = defaultEnv()) {
|
|
1464
|
-
|
|
1557
|
+
function detectSync(env = defaultEnv(), opts = {}) {
|
|
1558
|
+
const resolved = {
|
|
1559
|
+
...opts,
|
|
1560
|
+
serverName: opts.serverName ?? DEFAULT_SERVER_NAME
|
|
1561
|
+
};
|
|
1562
|
+
return HARNESSES.map((h) => detectOne(h, env, resolved));
|
|
1465
1563
|
}
|
|
1466
1564
|
|
|
1467
1565
|
//#endregion
|
|
@@ -1472,6 +1570,21 @@ const COMMAND_TIMEOUT_MS = 3e4;
|
|
|
1472
1570
|
/** Keep a failing client's diagnostics readable in a multi-client report. */
|
|
1473
1571
|
const MAX_STDERR = 2e3;
|
|
1474
1572
|
/**
|
|
1573
|
+
* The variables a client CLI is spawned with: the real environment minus every
|
|
1574
|
+
* catalog `configDirEnv`, then `env.vars` on top (ENG-6696). A config
|
|
1575
|
+
* directory therefore reaches the child only when the caller's env carries it
|
|
1576
|
+
* — the one the probe read — so `claude` never writes a file the installer
|
|
1577
|
+
* did not look at, while PATH and the rest still come through.
|
|
1578
|
+
*/
|
|
1579
|
+
function childVars(env) {
|
|
1580
|
+
const vars = { ...process.env };
|
|
1581
|
+
for (const h of HARNESSES) if (h.configDirEnv) delete vars[h.configDirEnv];
|
|
1582
|
+
return {
|
|
1583
|
+
...vars,
|
|
1584
|
+
...env.vars
|
|
1585
|
+
};
|
|
1586
|
+
}
|
|
1587
|
+
/**
|
|
1475
1588
|
* Windows batch launchers (`.cmd` / `.bat`) cannot be spawned directly.
|
|
1476
1589
|
*
|
|
1477
1590
|
* Since the CVE-2024-27980 fix (Node 18.20.2 / 20.12.2 / 21.7.2),
|
|
@@ -1532,7 +1645,7 @@ function runHarnessCommandSync(argv, opts = {}) {
|
|
|
1532
1645
|
],
|
|
1533
1646
|
timeout: COMMAND_TIMEOUT_MS,
|
|
1534
1647
|
env: {
|
|
1535
|
-
...
|
|
1648
|
+
...childVars(env),
|
|
1536
1649
|
HOME: env.homedir,
|
|
1537
1650
|
USERPROFILE: env.homedir
|
|
1538
1651
|
},
|
|
@@ -1587,7 +1700,7 @@ function currentEntry(harness, env, scope, serverName) {
|
|
|
1587
1700
|
const configPath = resolveConfigPath(harness, env, scope);
|
|
1588
1701
|
if (!configPath) return { present: false };
|
|
1589
1702
|
const adapter = adapterFor(harness);
|
|
1590
|
-
const entry = readServerEntry(harness, scope, configPath, env
|
|
1703
|
+
const entry = readServerEntry(harness, scope, configPath, projectKeyFor(env), (t) => {
|
|
1591
1704
|
const read = adapter.readAt(t, []);
|
|
1592
1705
|
return read.kind === "value" ? read.value : void 0;
|
|
1593
1706
|
}, serverName);
|
|
@@ -1598,25 +1711,6 @@ function currentEntry(harness, env, scope, serverName) {
|
|
|
1598
1711
|
url
|
|
1599
1712
|
};
|
|
1600
1713
|
}
|
|
1601
|
-
/**
|
|
1602
|
-
* The http(s) URL an entry points at, read in every shape the catalog writes:
|
|
1603
|
-
* the harness's declared HTTP key (`url`, Antigravity's `serverUrl`), the last
|
|
1604
|
-
* argv of an `mcp-remote` spawn, or Zed's nested `command.args`. Anything that
|
|
1605
|
-
* is not an http(s) URL is not one — a script path in `args` must never be
|
|
1606
|
-
* mistaken for the server the entry points at.
|
|
1607
|
-
*/
|
|
1608
|
-
function entryUrl(harness, entry) {
|
|
1609
|
-
if (entry === null || typeof entry !== "object") return void 0;
|
|
1610
|
-
const e = entry;
|
|
1611
|
-
const urlKey = harness.nativeHttpEntry?.urlKey ?? "url";
|
|
1612
|
-
const command = e["command"];
|
|
1613
|
-
const args = Array.isArray(e["args"]) ? e["args"] : command !== null && typeof command === "object" && Array.isArray(command.args) ? command.args : [];
|
|
1614
|
-
return [
|
|
1615
|
-
e[urlKey],
|
|
1616
|
-
e["url"],
|
|
1617
|
-
args[args.length - 1]
|
|
1618
|
-
].find((c) => typeof c === "string" && /^https?:\/\//i.test(c));
|
|
1619
|
-
}
|
|
1620
1714
|
/** `claude mcp remove` argv — the inverse of the catalog's add command. */
|
|
1621
1715
|
function removeCommandArgv(scope, serverName) {
|
|
1622
1716
|
return [
|
|
@@ -1729,7 +1823,7 @@ function installHarnessSync(harness, mcpUrl, opts = {}) {
|
|
|
1729
1823
|
};
|
|
1730
1824
|
const { path: path$1 } = target;
|
|
1731
1825
|
const entryValue = buildServerEntry(harness, mcpUrl, scope, serverName)[serverName];
|
|
1732
|
-
const modPath = entryPathFor(harness, scope, env
|
|
1826
|
+
const modPath = entryPathFor(harness, scope, projectKeyFor(env), serverName);
|
|
1733
1827
|
const adapter = adapterFor(harness);
|
|
1734
1828
|
const existing = readTextOrNull(path$1);
|
|
1735
1829
|
const baseText = existing && existing.trim() ? existing : adapter.empty;
|
|
@@ -2008,6 +2102,16 @@ function servedBySessionApi(mcpUrl) {
|
|
|
2008
2102
|
}
|
|
2009
2103
|
/** `…/v1/mcp/w/<url_key>`: the URL already names one workspace (ENG-6300). */
|
|
2010
2104
|
const SCOPED_SUFFIX = /\/w\/[^/]+$/;
|
|
2105
|
+
/** The workspace a scoped MCP URL names (`…/w/<url_key>`), if any. */
|
|
2106
|
+
function workspaceKeyOf(url) {
|
|
2107
|
+
const m = /\/w\/([^/]+)$/.exec(stripSlash(url));
|
|
2108
|
+
if (!m?.[1]) return void 0;
|
|
2109
|
+
try {
|
|
2110
|
+
return decodeURIComponent(m[1]);
|
|
2111
|
+
} catch {
|
|
2112
|
+
return m[1];
|
|
2113
|
+
}
|
|
2114
|
+
}
|
|
2011
2115
|
/** Does this MCP URL already name a workspace? */
|
|
2012
2116
|
function isScopedMcpUrl(url) {
|
|
2013
2117
|
return SCOPED_SUFFIX.test(stripSlash(url));
|
|
@@ -2068,14 +2172,54 @@ function findWorkspaceByKeyOrName(sites, input) {
|
|
|
2068
2172
|
* what someone running an installer once expects: available in every project.
|
|
2069
2173
|
*/
|
|
2070
2174
|
const DEFAULT_SCOPE = "user";
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2175
|
+
function entryState(s, url) {
|
|
2176
|
+
if (s?.alreadyConfigured && s.currentUrl === url) return "same-url";
|
|
2177
|
+
if (s?.besideUrl === url) return "beside";
|
|
2178
|
+
if (!s?.alreadyConfigured) return "absent";
|
|
2179
|
+
return s.currentUrl === void 0 ? "unrecognized" : "other-url";
|
|
2180
|
+
}
|
|
2181
|
+
/**
|
|
2182
|
+
* Allowed client entry names: 1-64 of `A-Za-z0-9_-`, and never starting with
|
|
2183
|
+
* `-`. The name is passed as an argv element to the client's own CLI
|
|
2184
|
+
* (`claude mcp add … <name> <url>`), so a leading `-` would be read there as a
|
|
2185
|
+
* flag — the ENG-4159 argument-injection family.
|
|
2186
|
+
*/
|
|
2187
|
+
const ENTRY_NAME = /^[A-Za-z0-9_-]{1,64}$/;
|
|
2188
|
+
function isValidEntryName(name) {
|
|
2189
|
+
return ENTRY_NAME.test(name) && !name.startsWith("-");
|
|
2190
|
+
}
|
|
2191
|
+
/**
|
|
2192
|
+
* The scope an install would land in: the requested one, or, for a harness
|
|
2193
|
+
* that cannot do it, its own default, which is where the fallback goes.
|
|
2194
|
+
*/
|
|
2195
|
+
function landingScope(d, scope) {
|
|
2196
|
+
const inScope = scope ? d.scopes.find((s) => s.scope === scope) : void 0;
|
|
2197
|
+
if (inScope) return inScope;
|
|
2198
|
+
const harness = getHarness(d.id);
|
|
2199
|
+
return harness ? d.scopes.find((s) => s.scope === defaultScope(harness)) : void 0;
|
|
2200
|
+
}
|
|
2201
|
+
/** Where an entry points, in the user's terms: the workspace, else the URL. */
|
|
2202
|
+
function describeTarget(url) {
|
|
2203
|
+
const key = workspaceKeyOf(url);
|
|
2204
|
+
return key ? `workspace ${key}` : url;
|
|
2205
|
+
}
|
|
2206
|
+
/**
|
|
2207
|
+
* Harness ids to pre-select in interactive mode, and to take under `--yes`:
|
|
2208
|
+
* detected + installable + not already set up for THIS url. Judged in the
|
|
2209
|
+
* scope we are about to install into, not the harness's default one.
|
|
2210
|
+
*
|
|
2211
|
+
* An entry pointing at another URL is selected (ENG-6736): it is not set up
|
|
2212
|
+
* for the workspace asked for, and skipping it would report success while
|
|
2213
|
+
* leaving the client on the old one. Interactively the user is then asked
|
|
2214
|
+
* what to do with it; under `--yes` the installer refuses it (url-mismatch,
|
|
2215
|
+
* exit 1) unless `--replace` or `--name` was given. An entry with no URL we
|
|
2216
|
+
* can read is someone else's, and is never picked on the user's behalf.
|
|
2217
|
+
*/
|
|
2218
|
+
function autoSelectIds(detected, scope, url) {
|
|
2075
2219
|
return detected.filter((d) => {
|
|
2076
2220
|
if (!d.available || !d.installed) return false;
|
|
2077
|
-
const
|
|
2078
|
-
return
|
|
2221
|
+
const state = entryState(landingScope(d, scope), url);
|
|
2222
|
+
return state === "absent" || state === "other-url";
|
|
2079
2223
|
}).map((d) => d.id);
|
|
2080
2224
|
}
|
|
2081
2225
|
/**
|
|
@@ -2087,14 +2231,18 @@ function autoSelectIds(detected, scope) {
|
|
|
2087
2231
|
* NOT configured at `project`, and labelling it "already set up" while
|
|
2088
2232
|
* installing to `project` is simply wrong.
|
|
2089
2233
|
*/
|
|
2090
|
-
function clientChoices(detected, scope) {
|
|
2091
|
-
const preselect = new Set(autoSelectIds(detected, scope));
|
|
2234
|
+
function clientChoices(detected, scope, url, serverName = DEFAULT_SERVER_NAME) {
|
|
2235
|
+
const preselect = new Set(autoSelectIds(detected, scope, url));
|
|
2236
|
+
const beside = besideName(serverName, url);
|
|
2092
2237
|
return detected.filter((d) => d.available).map((d) => {
|
|
2093
2238
|
const inScope = d.scopes.find((s) => s.scope === scope);
|
|
2094
2239
|
const harness = getHarness(d.id);
|
|
2095
2240
|
let hint;
|
|
2096
2241
|
if (!inScope) hint = `no ${scope} scope — will use ${harness ? defaultScope(harness) : DEFAULT_SCOPE}`;
|
|
2097
|
-
else if (inScope
|
|
2242
|
+
else if (entryState(inScope, url) === "same-url") hint = `already set up (${scope})`;
|
|
2243
|
+
else if (entryState(inScope, url) === "beside") hint = `already set up as ${beside ?? "a second entry"} (${scope})`;
|
|
2244
|
+
else if (inScope.currentUrl !== void 0) hint = `points at ${describeTarget(inScope.currentUrl)} (${scope})`;
|
|
2245
|
+
else if (inScope.alreadyConfigured) hint = `its ${serverName} entry holds no Levr URL (${scope})`;
|
|
2098
2246
|
else hint = d.installed ? "detected" : "not detected";
|
|
2099
2247
|
return {
|
|
2100
2248
|
value: d.id,
|
|
@@ -2104,6 +2252,49 @@ function clientChoices(detected, scope) {
|
|
|
2104
2252
|
};
|
|
2105
2253
|
});
|
|
2106
2254
|
}
|
|
2255
|
+
/** Claude Code tool names are `mcp__<entry>__<tool>`; keep that well short. */
|
|
2256
|
+
const BESIDE_NAME_MAX = 30;
|
|
2257
|
+
/**
|
|
2258
|
+
* The name for a second entry beside `serverName`: `<serverName>-<url_key>`,
|
|
2259
|
+
* so it says which workspace it is. Undefined — and "Add beside it" is not
|
|
2260
|
+
* offered — when the target URL names no workspace, or when that name would
|
|
2261
|
+
* be too long or not a valid entry name. It is never shortened: a cut name
|
|
2262
|
+
* could drop the workspace, or give two workspaces the same entry.
|
|
2263
|
+
*/
|
|
2264
|
+
function besideName(serverName, url) {
|
|
2265
|
+
const key = workspaceKeyOf(url);
|
|
2266
|
+
if (!key) return void 0;
|
|
2267
|
+
const name = `${serverName}-${key}`;
|
|
2268
|
+
return name.length <= BESIDE_NAME_MAX && isValidEntryName(name) ? name : void 0;
|
|
2269
|
+
}
|
|
2270
|
+
/**
|
|
2271
|
+
* The selected clients whose entry is not this URL's: each needs a decision —
|
|
2272
|
+
* switch it, add beside it, or leave it — before anything is installed
|
|
2273
|
+
* (ENG-6736). A client already set up beside it is listed too, as `beside`,
|
|
2274
|
+
* so it is installed under that name (and reads as already set up) rather
|
|
2275
|
+
* than refused under the main one. Pure, so it is testable without a TTY.
|
|
2276
|
+
*/
|
|
2277
|
+
function entryConflicts(selection, detected, scope, url, serverName) {
|
|
2278
|
+
const conflicts = [];
|
|
2279
|
+
const beside = besideName(serverName, url);
|
|
2280
|
+
for (const id of selection) {
|
|
2281
|
+
const d = detected.find((x) => x.id === id);
|
|
2282
|
+
if (!d) continue;
|
|
2283
|
+
const landing = landingScope(d, scope);
|
|
2284
|
+
const state = entryState(landing, url);
|
|
2285
|
+
if (state === "absent" || state === "same-url") continue;
|
|
2286
|
+
const offer = state === "beside" || !landing?.besideConfigured;
|
|
2287
|
+
conflicts.push({
|
|
2288
|
+
id,
|
|
2289
|
+
label: d.label,
|
|
2290
|
+
state,
|
|
2291
|
+
...landing?.currentUrl ? { currentUrl: landing.currentUrl } : {},
|
|
2292
|
+
...beside && offer ? { besideName: beside } : {},
|
|
2293
|
+
...beside && !offer ? { besideTaken: beside } : {}
|
|
2294
|
+
});
|
|
2295
|
+
}
|
|
2296
|
+
return conflicts;
|
|
2297
|
+
}
|
|
2107
2298
|
/** Scopes worth offering for a selection: any scope at least one selected
|
|
2108
2299
|
* client can actually use here. Availability already accounts for "are we
|
|
2109
2300
|
* inside a repo", so project scope disappears outside one. */
|
|
@@ -2146,11 +2337,13 @@ function installSelected(ids, plan, install) {
|
|
|
2146
2337
|
const canHonor = supportsScope(harness, plan.scope);
|
|
2147
2338
|
const named = plan.namedIds.has(id);
|
|
2148
2339
|
const effective = canHonor || named ? plan.scope : defaultScope(harness);
|
|
2340
|
+
const override = plan.overrides?.get(id);
|
|
2149
2341
|
outcomes.push({
|
|
2150
2342
|
id,
|
|
2151
2343
|
label: harness.label,
|
|
2152
|
-
result: install(harness, plan.mcpUrl, plan.dryRun, effective),
|
|
2153
|
-
...canHonor ? {} : named ? {} : { fallbackFrom: plan.scope }
|
|
2344
|
+
result: install(harness, plan.mcpUrl, plan.dryRun, effective, override),
|
|
2345
|
+
...canHonor ? {} : named ? {} : { fallbackFrom: plan.scope },
|
|
2346
|
+
...override?.serverName ? { entryName: override.serverName } : {}
|
|
2154
2347
|
});
|
|
2155
2348
|
}
|
|
2156
2349
|
return outcomes;
|
|
@@ -2161,7 +2354,7 @@ function installSelected(ids, plan, install) {
|
|
|
2161
2354
|
* report. No console output — the caller formats it.
|
|
2162
2355
|
*/
|
|
2163
2356
|
function runNonInteractive(options, url, urlSource, deps) {
|
|
2164
|
-
const detected = deps.detect();
|
|
2357
|
+
const detected = deps.detect(url);
|
|
2165
2358
|
const scope = options.scope ?? DEFAULT_SCOPE;
|
|
2166
2359
|
let ids;
|
|
2167
2360
|
let unknown = [];
|
|
@@ -2170,7 +2363,7 @@ function runNonInteractive(options, url, urlSource, deps) {
|
|
|
2170
2363
|
const requested = resolveRequestedIds(options, detected);
|
|
2171
2364
|
ids = requested.ids;
|
|
2172
2365
|
unknown = requested.unknown;
|
|
2173
|
-
} else ids = autoSelectIds(detected, scope);
|
|
2366
|
+
} else ids = autoSelectIds(detected, scope, url);
|
|
2174
2367
|
return {
|
|
2175
2368
|
url,
|
|
2176
2369
|
urlSource,
|
|
@@ -2186,22 +2379,45 @@ function runNonInteractive(options, url, urlSource, deps) {
|
|
|
2186
2379
|
};
|
|
2187
2380
|
}
|
|
2188
2381
|
/** Why an install was refused, in the user's terms rather than the enum's. */
|
|
2189
|
-
function failureText(o, switchCommand) {
|
|
2382
|
+
function failureText(o, switchCommand, entryName) {
|
|
2190
2383
|
const r = o.result;
|
|
2191
2384
|
const harness = getHarness(o.id);
|
|
2192
2385
|
switch (r.reason) {
|
|
2193
2386
|
case "unsupported-scope": return `no ${r.scope} scope` + (harness ? ` (supports: ${supportedScopes(harness).join(", ")})` : "");
|
|
2194
2387
|
case "not-a-repo": return "project scope needs a git repository (run from inside one)";
|
|
2195
2388
|
case "scope-collision": return `${r.scope} scope resolves to the same file as ${r.collidesWith ?? "another"} scope here — refusing rather than overwriting it`;
|
|
2196
|
-
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` :
|
|
2389
|
+
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`);
|
|
2197
2390
|
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>";
|
|
2198
2391
|
case "write-failed": return `its config could not be written` + (r.detail ? ` (${r.detail})` : "") + `; check the file's permissions and re-run`;
|
|
2199
2392
|
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
2393
|
default:
|
|
2201
|
-
if (r.commandError) return `\`${r.command}\` (${r.commandError})${restoreText(r)}
|
|
2394
|
+
if (r.commandError) return `\`${r.command}\` (${r.commandError})${restoreText(r)}` + alreadyExistsHint(o, r.commandError, entryName);
|
|
2202
2395
|
return "no config location on this platform";
|
|
2203
2396
|
}
|
|
2204
2397
|
}
|
|
2398
|
+
/**
|
|
2399
|
+
* The client's CLI refused because the entry already exists, in a config the
|
|
2400
|
+
* installer did not read — otherwise it would have reported already set up,
|
|
2401
|
+
* or a url-mismatch naming --replace. --replace cannot reach an entry it
|
|
2402
|
+
* cannot see, so the way out is another name, or removing that entry with the
|
|
2403
|
+
* client's own CLI (ENG-6696).
|
|
2404
|
+
*/
|
|
2405
|
+
function alreadyExistsHint(o, commandError, entryName) {
|
|
2406
|
+
if (!/already exists/i.test(commandError)) return "";
|
|
2407
|
+
const name = entryName ?? DEFAULT_SERVER_NAME;
|
|
2408
|
+
const scope = /already exists in (user|project|local) config/i.exec(commandError)?.[1]?.toLowerCase() ?? o.result.scope;
|
|
2409
|
+
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`;
|
|
2410
|
+
}
|
|
2411
|
+
/**
|
|
2412
|
+
* Where to remove an entry by hand: the exact command for Claude Code, else
|
|
2413
|
+
* the config file it sits in (ENG-6736).
|
|
2414
|
+
*/
|
|
2415
|
+
function removeHow(o, entryName) {
|
|
2416
|
+
const name = entryName ?? DEFAULT_SERVER_NAME;
|
|
2417
|
+
const r = o.result;
|
|
2418
|
+
if (o.id === "claude-code") return `remove it with \`claude mcp remove --scope ${r.scope} ${name}\``;
|
|
2419
|
+
return r.path ? `remove the ${name} entry from ${r.path}` : `remove the ${name} entry from the client's config`;
|
|
2420
|
+
}
|
|
2205
2421
|
/** What happened to the entry a failed `--replace` removed (ENG-6300 D6). */
|
|
2206
2422
|
function restoreText(r) {
|
|
2207
2423
|
if (r.restored === void 0) return "";
|
|
@@ -2209,13 +2425,16 @@ function restoreText(r) {
|
|
|
2209
2425
|
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
2426
|
}
|
|
2211
2427
|
/** One human-readable status line per outcome. */
|
|
2212
|
-
function outcomeLine(o, dryRun, switchCommand) {
|
|
2428
|
+
function outcomeLine(o, dryRun, switchCommand, entryName) {
|
|
2213
2429
|
const r = o.result;
|
|
2214
|
-
const note = o.fallbackFrom ? ` [${o.fallbackFrom} scope unsupported — used ${r.scope}]` : "";
|
|
2430
|
+
const note = (o.entryName ? ` [as ${o.entryName}]` : "") + (o.fallbackFrom ? ` [${o.fallbackFrom} scope unsupported — used ${r.scope}]` : "");
|
|
2215
2431
|
const where = r.path ? ` → ${r.path}` : "";
|
|
2216
2432
|
const backup = r.backupPath ? dryRun ? ` [original would be backed up to ${r.backupPath}]` : ` [original backed up to ${r.backupPath}]` : "";
|
|
2217
2433
|
const replaced = r.replacedUrl ? dryRun ? ` [would replace ${r.replacedUrl}]` : ` [replaced ${r.replacedUrl}]` : "";
|
|
2218
|
-
if (!r.ok)
|
|
2434
|
+
if (!r.ok) {
|
|
2435
|
+
if (o.entryName) return `${o.label}: failed [as ${o.entryName}] — ${failureText(o, void 0, o.entryName)}`;
|
|
2436
|
+
return `${o.label}: failed — ${failureText(o, switchCommand, entryName)}`;
|
|
2437
|
+
}
|
|
2219
2438
|
if (r.alreadyConfigured) return `${o.label}: already set up (${r.scope})${where}${note}`;
|
|
2220
2439
|
if (r.command) {
|
|
2221
2440
|
if (r.executed) return `${o.label}: installed (${r.scope}) via \`${r.command}\`${replaced}${note}`;
|
|
@@ -2229,8 +2448,13 @@ function outcomeLine(o, dryRun, switchCommand) {
|
|
|
2229
2448
|
function formatReport(report) {
|
|
2230
2449
|
const lines = [];
|
|
2231
2450
|
lines.push(`MCP URL: ${report.url} (${report.urlSource})`);
|
|
2232
|
-
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));
|
|
2451
|
+
if (report.outcomes.length === 0 && (report.left ?? []).length === 0) lines.push("No clients selected.");
|
|
2452
|
+
else for (const o of report.outcomes) lines.push(outcomeLine(o, report.dryRun, report.switchCommand, report.entryName));
|
|
2453
|
+
for (const l of report.left ?? []) {
|
|
2454
|
+
const why = l.currentUrl ? `points at ${describeTarget(l.currentUrl)}` : `its ${report.entryName ?? DEFAULT_SERVER_NAME} entry holds no Levr URL`;
|
|
2455
|
+
const taken = l.besideTaken ? `; ${l.besideTaken} is taken too, so add this one with --name <other>` : "";
|
|
2456
|
+
lines.push(`${l.label}: left as it was (${why}${taken})`);
|
|
2457
|
+
}
|
|
2234
2458
|
if (report.unknownClients.length > 0) lines.push(`Unknown clients (skipped): ${report.unknownClients.join(", ")}`);
|
|
2235
2459
|
if (report.outcomes.some((o) => o.id === "claude-code" && o.result.ok)) lines.push(PLUGIN_TIP);
|
|
2236
2460
|
return lines.join("\n");
|
|
@@ -2240,7 +2464,7 @@ const PLUGIN_TIP = "Tip: the Levr plugin adds /levr:work, /levr:file and /levr:d
|
|
|
2240
2464
|
/** Next-steps blurb after a run. */
|
|
2241
2465
|
function nextStepsText(report) {
|
|
2242
2466
|
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.";
|
|
2467
|
+
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
2468
|
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
2469
|
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
2470
|
return lines.join("\n");
|
|
@@ -2248,25 +2472,18 @@ function nextStepsText(report) {
|
|
|
2248
2472
|
|
|
2249
2473
|
//#endregion
|
|
2250
2474
|
//#region src/commands/mcp/addHandler.ts
|
|
2251
|
-
/**
|
|
2252
|
-
* Allowed client entry names: 1-64 of `A-Za-z0-9_-`, and never starting with
|
|
2253
|
-
* `-`. The name is passed as an argv element to the client's own CLI
|
|
2254
|
-
* (`claude mcp add … <name> <url>`), so a leading `-` would be read there as a
|
|
2255
|
-
* flag — the ENG-4159 argument-injection family.
|
|
2256
|
-
*/
|
|
2257
|
-
const ENTRY_NAME = /^[A-Za-z0-9_-]{1,64}$/;
|
|
2258
|
-
function isValidEntryName(name) {
|
|
2259
|
-
return ENTRY_NAME.test(name) && !name.startsWith("-");
|
|
2260
|
-
}
|
|
2261
2475
|
function depsFor(entry) {
|
|
2262
|
-
const install = (harness, mcpUrl, dryRun, scope) => installHarnessSync(harness, mcpUrl, {
|
|
2476
|
+
const install = (harness, mcpUrl, dryRun, scope, override) => installHarnessSync(harness, mcpUrl, {
|
|
2263
2477
|
dryRun,
|
|
2264
2478
|
scope,
|
|
2265
|
-
serverName: entry.serverName,
|
|
2266
|
-
replaceExisting: entry.replaceExisting
|
|
2479
|
+
serverName: override?.serverName ?? entry.serverName,
|
|
2480
|
+
replaceExisting: override?.replaceExisting ?? entry.replaceExisting
|
|
2267
2481
|
});
|
|
2268
2482
|
return {
|
|
2269
|
-
detect: () => detectSync(
|
|
2483
|
+
detect: (url) => detectSync(void 0, {
|
|
2484
|
+
serverName: entry.serverName,
|
|
2485
|
+
besideName: besideName(entry.serverName, url)
|
|
2486
|
+
}),
|
|
2270
2487
|
install
|
|
2271
2488
|
};
|
|
2272
2489
|
}
|
|
@@ -2380,7 +2597,8 @@ async function mcpAddHandler(flags) {
|
|
|
2380
2597
|
if (options.all || clients.length > 0 || options.yes || !process.stdout.isTTY) {
|
|
2381
2598
|
const report = {
|
|
2382
2599
|
...runNonInteractive(options, url, source, depsFor(entry)),
|
|
2383
|
-
switchCommand: switchCommandFor(flags, urlKey)
|
|
2600
|
+
switchCommand: switchCommandFor(flags, urlKey),
|
|
2601
|
+
entryName: entry.serverName
|
|
2384
2602
|
};
|
|
2385
2603
|
this.process.stdout.write(`${formatReport(report)}\n`);
|
|
2386
2604
|
if (!isScopedMcpUrl(url) && servedBySessionApi(url)) {
|
|
@@ -2452,7 +2670,7 @@ async function interactive(ctx, dryRun, baseUrl, baseSource, flags, entry, resol
|
|
|
2452
2670
|
}
|
|
2453
2671
|
}
|
|
2454
2672
|
p.note(`${url}\n(${urlSource})`, "MCP endpoint");
|
|
2455
|
-
const detected = deps.detect();
|
|
2673
|
+
const detected = deps.detect(url);
|
|
2456
2674
|
const installable = detected.filter((d) => d.available);
|
|
2457
2675
|
if (installable.length === 0) {
|
|
2458
2676
|
p.outro("No supported MCP clients found on this machine.");
|
|
@@ -2477,7 +2695,7 @@ async function interactive(ctx, dryRun, baseUrl, baseSource, flags, entry, resol
|
|
|
2477
2695
|
}
|
|
2478
2696
|
scope = picked;
|
|
2479
2697
|
} else if (!requestedScope && choices.length === 1) scope = choices[0] ?? DEFAULT_SCOPE;
|
|
2480
|
-
const rows = clientChoices(detected, scope);
|
|
2698
|
+
const rows = clientChoices(detected, scope, url, entry.serverName);
|
|
2481
2699
|
const selection = await p.multiselect({
|
|
2482
2700
|
message: `Select clients to set up (${scope} scope)`,
|
|
2483
2701
|
options: rows.map((r) => ({
|
|
@@ -2497,13 +2715,64 @@ async function interactive(ctx, dryRun, baseUrl, baseSource, flags, entry, resol
|
|
|
2497
2715
|
p.outro("Nothing selected — bye.");
|
|
2498
2716
|
return;
|
|
2499
2717
|
}
|
|
2718
|
+
const overrides = /* @__PURE__ */ new Map();
|
|
2719
|
+
const left = [];
|
|
2720
|
+
for (const c of entryConflicts(selection, detected, scope, url, entry.serverName)) {
|
|
2721
|
+
if (c.state === "beside") {
|
|
2722
|
+
if (c.besideName) overrides.set(c.id, {
|
|
2723
|
+
serverName: c.besideName,
|
|
2724
|
+
replaceExisting: false
|
|
2725
|
+
});
|
|
2726
|
+
continue;
|
|
2727
|
+
}
|
|
2728
|
+
if (c.state === "other-url" && entry.replaceExisting) continue;
|
|
2729
|
+
const where = c.currentUrl ? `points at ${describeTarget(c.currentUrl)}` : "holds no Levr URL";
|
|
2730
|
+
const options = [];
|
|
2731
|
+
if (c.state === "other-url") options.push({
|
|
2732
|
+
value: "switch",
|
|
2733
|
+
label: `Switch it to ${describeTarget(url)}`,
|
|
2734
|
+
hint: "replaces the entry"
|
|
2735
|
+
});
|
|
2736
|
+
if (c.besideName) options.push({
|
|
2737
|
+
value: "beside",
|
|
2738
|
+
label: `Add beside it as ${c.besideName}`,
|
|
2739
|
+
hint: "keeps both; each repeats every tool"
|
|
2740
|
+
});
|
|
2741
|
+
options.push({
|
|
2742
|
+
value: "leave",
|
|
2743
|
+
label: "Leave it"
|
|
2744
|
+
});
|
|
2745
|
+
const answer = options.length === 1 ? "leave" : await p.select({
|
|
2746
|
+
message: `${c.label}: its ${entry.serverName} entry ${where}` + (c.besideTaken ? `, and ${c.besideTaken} is taken too` : ""),
|
|
2747
|
+
options
|
|
2748
|
+
});
|
|
2749
|
+
if (p.isCancel(answer)) {
|
|
2750
|
+
p.cancel("Cancelled.");
|
|
2751
|
+
ctx.process.exitCode = 1;
|
|
2752
|
+
return;
|
|
2753
|
+
}
|
|
2754
|
+
if (answer === "switch") overrides.set(c.id, { replaceExisting: true });
|
|
2755
|
+
else if (answer === "beside" && c.besideName) overrides.set(c.id, {
|
|
2756
|
+
serverName: c.besideName,
|
|
2757
|
+
replaceExisting: false
|
|
2758
|
+
});
|
|
2759
|
+
else left.push({
|
|
2760
|
+
id: c.id,
|
|
2761
|
+
label: c.label,
|
|
2762
|
+
...c.currentUrl ? { currentUrl: c.currentUrl } : {},
|
|
2763
|
+
...c.besideTaken ? { besideTaken: c.besideTaken } : {}
|
|
2764
|
+
});
|
|
2765
|
+
}
|
|
2766
|
+
const leftIds = new Set(left.map((l) => l.id));
|
|
2767
|
+
const toInstall = selection.filter((id) => !leftIds.has(id));
|
|
2500
2768
|
const spin = p.spinner();
|
|
2501
2769
|
spin.start(dryRun ? "Previewing changes" : "Installing");
|
|
2502
|
-
const outcomes = installSelected(
|
|
2770
|
+
const outcomes = installSelected(toInstall, {
|
|
2503
2771
|
mcpUrl: url,
|
|
2504
2772
|
dryRun,
|
|
2505
2773
|
scope,
|
|
2506
|
-
namedIds: /* @__PURE__ */ new Set()
|
|
2774
|
+
namedIds: /* @__PURE__ */ new Set(),
|
|
2775
|
+
overrides
|
|
2507
2776
|
}, deps.install);
|
|
2508
2777
|
spin.stop(dryRun ? "Preview ready" : "Done");
|
|
2509
2778
|
const report = {
|
|
@@ -2513,7 +2782,9 @@ async function interactive(ctx, dryRun, baseUrl, baseSource, flags, entry, resol
|
|
|
2513
2782
|
outcomes,
|
|
2514
2783
|
unknownClients: [],
|
|
2515
2784
|
dryRun,
|
|
2516
|
-
switchCommand: switchCommandFor(flags, urlKey)
|
|
2785
|
+
switchCommand: switchCommandFor(flags, urlKey),
|
|
2786
|
+
entryName: entry.serverName,
|
|
2787
|
+
left
|
|
2517
2788
|
};
|
|
2518
2789
|
p.note(formatReport(report), "Results");
|
|
2519
2790
|
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-BL1e6anD.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.5";
|
|
878
880
|
|
|
879
881
|
//#endregion
|
|
880
882
|
//#region src/app.ts
|