@levr-one/cli 0.11.2 → 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.
package/README.md CHANGED
@@ -23,7 +23,7 @@ replaces the `levr` bin from the deprecated `@levr-one/setup` package.
23
23
  ### Or run it without a global install
24
24
 
25
25
  ```bash
26
- npx @levr-one/cli --help
26
+ npx @levr-one/cli@latest --help
27
27
  ```
28
28
 
29
29
  `npx` is right for one-shot use (`mcp add`, trying a command). Two things to
@@ -40,18 +40,22 @@ In scripts and CI, pass `--yes` — **in a terminal the prompt blocks until it i
40
40
  answered**, so an unattended run with a TTY hangs rather than failing:
41
41
 
42
42
  ```bash
43
- npx --yes @levr-one/cli push ./test-results.xml
43
+ npx --yes @levr-one/cli@latest push ./test-results.xml
44
44
  ```
45
45
 
46
- Two things to get right:
46
+ Three things to get right:
47
47
 
48
- - **Always `npx @levr-one/cli`, never `npx levr`.** npx treats the first
48
+ - **Add `@latest`.** Without a version, npx runs any copy of `@levr-one/cli`
49
+ already installed on the machine, however old, and an old copy does not know
50
+ newer commands and flags (`No flag registered for --workspace`).
51
+ - **Always `npx @levr-one/cli@latest`, never `npx levr`.** npx treats the first
49
52
  positional as a _package_ name, so `npx levr` would fetch and run whatever
50
53
  package is published under the unscoped name `levr` — which is not ours.
51
- - **npm's flags go before the package name.** `npx @levr-one/cli --yes` passes
54
+ - **npm's flags go before the package name.** `npx @levr-one/cli@latest --yes` passes
52
55
  `--yes` to `levr`, not to npm.
53
56
 
54
- For reproducible CI, pin `@levr-one/cli` to a version you have validated.
57
+ The examples here use `@latest`. For reproducible CI, replace it with a
58
+ version you have validated, such as `@levr-one/cli@0.11.1`.
55
59
 
56
60
  ## Quick start
57
61
 
@@ -59,7 +63,7 @@ For reproducible CI, pin `@levr-one/cli` to a version you have validated.
59
63
  the client opens a browser to authorize the first time it connects:
60
64
 
61
65
  ```bash
62
- npx @levr-one/cli mcp add
66
+ npx @levr-one/cli@latest mcp add
63
67
  ```
64
68
 
65
69
  **Pushing test results?** Authentication is all you need — log in once (or
@@ -82,40 +86,72 @@ levr mcp add --dry-run # preview the changes without writing
82
86
  levr mcp add --client cursor,zed --yes # non-interactive selection
83
87
  ```
84
88
 
85
- What it writes is **credential-free** — a `levr` server entry that connects
86
- via `mcp-remote`. No token or secret is stored; your client opens the browser
87
- to authorize with Levr on its first connection. After a run, restart the
88
- client(s) and authorize once.
89
+ What it writes is **credential-free** — a `levr` server entry holding only
90
+ the server's address. No token or secret is stored; your client opens the
91
+ browser to authorize with Levr on its first connection. After a run, restart
92
+ the client(s) and authorize once.
93
+
94
+ Clients that speak HTTP MCP themselves (Claude Code, VS Code, Gemini CLI,
95
+ Codex CLI, Grok Build, Antigravity) connect to the URL directly. Claude
96
+ Desktop, Cursor, Windsurf and Zed get an entry that runs
97
+ `npx -y mcp-remote <url>`, which connects on their behalf.
89
98
 
90
99
  **Supported clients:**
91
100
 
92
- | Client | How it's configured |
93
- | -------------- | ------------------------------------------------------------------------------ |
94
- | Claude Desktop | config file (`claude_desktop_config.json`) |
95
- | Claude Code | prints the `claude mcp add --transport http levr <url>` command for you to run |
96
- | Cursor | config file (`~/.cursor/mcp.json`) |
97
- | Windsurf | config file (`~/.codeium/windsurf/mcp_config.json`) |
98
- | Zed | config file (`settings.json`, `context_servers`) |
99
- | VS Code, Codex | listed but not yet installable (coming soon) |
101
+ | Client | How it's configured |
102
+ | ----------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
103
+ | Claude Desktop | config file (`claude_desktop_config.json`) |
104
+ | Claude Code | runs `claude mcp add --transport http --scope user levr <url>` for you (prints it instead when `claude` is not on your `PATH`) |
105
+ | Cursor | config file (`~/.cursor/mcp.json`) |
106
+ | Windsurf | config file (`~/.codeium/windsurf/mcp_config.json`) |
107
+ | Zed | config file (`settings.json`, `context_servers`) |
108
+ | VS Code | config file (`Code/User/mcp.json` in your VS Code settings folder; `.vscode/mcp.json` with `--scope project`) |
109
+ | Gemini CLI | config file (`~/.gemini/settings.json`) |
110
+ | Codex CLI | config file (`~/.codex/config.toml`, TOML) |
111
+ | Grok Build | config file (`~/.grok/config.toml`, TOML) |
112
+ | Antigravity (CLI + IDE) | config file (`~/.gemini/config/mcp_config.json`) |
100
113
 
101
114
  Config edits are **safe and repeatable**: existing MCP servers and comments
102
- in your config files are preserved (jsonc-aware merge), and re-running is a
103
- no-op that reports "already set up".
115
+ in your config files are preserved, and re-running reports "already set up".
116
+ An entry of the same name that points at a different address is left alone
117
+ and reported; pass `--replace` to switch it.
104
118
 
105
119
  **Flags:**
106
120
 
107
- | Flag | Alias | Description |
108
- | ----------------- | ----- | ----------------------------------------------------- |
109
- | `--client <id,…>` | | Set up these client ids (comma-separated or repeated) |
110
- | `--all` | | Set up every detected, installable client |
111
- | `--yes` | `-y` | Non-interactive; auto-select detected clients |
112
- | `--dry-run` | | Show the changes without writing |
113
- | `--url <url>` | | MCP server URL (default derived from the API server) |
121
+ | Flag | Alias | Description |
122
+ | -------------------------------- | ----- | ----------------------------------------------------------------------------------- |
123
+ | `--client <id,…>` | | Set up these client ids (comma-separated or repeated) |
124
+ | `--all` | | Set up every detected, installable client |
125
+ | `--yes` | `-y` | Non-interactive; auto-select detected clients |
126
+ | `--dry-run` | | Show the changes without writing |
127
+ | `--scope <user\|project\|local>` | | Where the entry lands: every project (default), this repo for everyone, or just you |
128
+ | `--workspace <url_key\|name>` | | Pin the entry to one workspace (needs `levr auth login`) |
129
+ | `--name <entry>` | | Name of the client entry (default `levr`), to keep a second one beside it |
130
+ | `--replace` | | Switch an existing entry of this name to the new address |
131
+ | `--url <url>` | | MCP server URL (default derived from the API server) |
114
132
 
115
133
  Runs non-interactively whenever `--all`, `--client`, or `--yes` is passed —
116
134
  or automatically when not attached to a terminal (CI). Unknown client ids and
117
135
  failed writes exit non-zero.
118
136
 
137
+ ### More than one workspace
138
+
139
+ If your account is in more than one workspace, the plain MCP URL does not know
140
+ which one you mean, and its tools reply that no workspace is selected. Connect
141
+ each workspace by its own URL, `https://ai.levr.one/api/v1/mcp/w/<url_key>`:
142
+
143
+ ```bash
144
+ levr auth login
145
+ levr mcp add --workspace acme # pin the levr entry to acme
146
+ levr mcp add --workspace beta --name levr-beta # add a second entry beside it
147
+ levr mcp add --workspace beta --replace # or switch the entry to beta
148
+ ```
149
+
150
+ Each entry lists every Levr tool once more. A URL for a workspace you are not
151
+ in is refused with `403`; connect the plain URL and ask for `list_workspaces`
152
+ to see the URLs you can use. Full guide:
153
+ [levr CLI Overview › More than one workspace](https://doc.levr.one/cli/levr-cli-overview#more-than-one-workspace).
154
+
119
155
  ## Push test results
120
156
 
121
157
  ```bash
@@ -233,7 +269,7 @@ levr import ./cases.csv --team-id <uuid> --map "State=labels:state"
233
269
  env:
234
270
  LEVR_TOKEN: ${{ secrets.LEVR_TOKEN }}
235
271
  # LEVR_TEAM_ID is optional — server resolves from automation source or workspace default
236
- run: npx --yes @levr-one/cli push ./test-results.xml
272
+ run: npx --yes @levr-one/cli@latest push ./test-results.xml
237
273
  ```
238
274
 
239
275
  ### GitLab CI
@@ -241,7 +277,7 @@ levr import ./cases.csv --team-id <uuid> --map "State=labels:state"
241
277
  ```yaml
242
278
  push-results:
243
279
  script:
244
- - npx --yes @levr-one/cli push ./test-results.xml
280
+ - npx --yes @levr-one/cli@latest push ./test-results.xml
245
281
  variables:
246
282
  LEVR_TOKEN: $LEVR_TOKEN
247
283
  ```
@@ -250,13 +286,14 @@ push-results:
250
286
 
251
287
  ```groovy
252
288
  withEnv(["LEVR_TOKEN=${LEVR_TOKEN}"]) {
253
- sh 'npx --yes @levr-one/cli push ./test-results.xml'
289
+ sh 'npx --yes @levr-one/cli@latest push ./test-results.xml'
254
290
  }
255
291
  ```
256
292
 
257
293
  ## Authentication
258
294
 
259
- Needed for `push` and `workspace` commands (`mcp add` needs none). Three modes:
295
+ Needed for `push` and `workspace` commands, and for `mcp add --workspace`.
296
+ Three modes:
260
297
 
261
298
  ### Interactive (browser) — default
262
299
 
@@ -143,7 +143,10 @@ const CLAUDE_CODE_COMMAND = [
143
143
  "{name}",
144
144
  "{url}"
145
145
  ];
146
- /** `~/.claude.json` on every platform — read for detection, never written by us. */
146
+ /**
147
+ * `~/.claude.json` on every platform — read for detection, never written by us.
148
+ * `~` is `$CLAUDE_CONFIG_DIR` when that is set (see `configDirEnv`).
149
+ */
147
150
  const CLAUDE_CODE_LOCATIONS = [
148
151
  {
149
152
  platform: "darwin",
@@ -232,6 +235,7 @@ const HARNESSES = [
232
235
  docsUrl: "https://docs.anthropic.com/en/docs/claude-code/mcp",
233
236
  verifiedVersion: UNVERIFIED_LEGACY,
234
237
  verifiedOn: UNVERIFIED_LEGACY,
238
+ configDirEnv: "CLAUDE_CONFIG_DIR",
235
239
  detectSignals: [
236
240
  {
237
241
  platform: "darwin",
@@ -275,7 +279,7 @@ const HARNESSES = [
275
279
  installKind: "cli-command",
276
280
  locations: CLAUDE_CODE_LOCATIONS,
277
281
  command: CLAUDE_CODE_COMMAND,
278
- cwdKeyedUnder: "projects"
282
+ projectKeyedUnder: "projects"
279
283
  }
280
284
  ]
281
285
  },
@@ -775,9 +779,20 @@ function defaultEnv() {
775
779
  platform: process.platform,
776
780
  homedir: homedir(),
777
781
  pathVar: process.env.PATH ?? "",
778
- cwd: process.cwd()
782
+ cwd: process.cwd(),
783
+ vars: process.env
779
784
  };
780
785
  }
786
+ /**
787
+ * The directory `~` means for this harness's config paths and signals: the
788
+ * harness's `configDirEnv` when that variable is set, else the home directory
789
+ * (ENG-6696). A relative value resolves against `env.cwd`, the directory a
790
+ * spawned client CLI runs in, so the probe and the client read one file.
791
+ */
792
+ function configHome(harness, env) {
793
+ const dir = harness.configDirEnv ? env.vars?.[harness.configDirEnv] : void 0;
794
+ return dir ? resolve(env.cwd, dir) : env.homedir;
795
+ }
781
796
  /** Expand a leading `~` / `~/…` to the given home directory. */
782
797
  function expandTilde(p, home) {
783
798
  if (p === "~") return home;
@@ -807,7 +822,7 @@ function resolveConfigPath(harness, env, scope = defaultScope(harness)) {
807
822
  if (def.projectPath) return join(resolveProjectRoot(env).root, ...def.projectPath.split("/"));
808
823
  const loc = locationFor(harness, env.platform, scope);
809
824
  if (!loc) return void 0;
810
- return expandTilde(loc.configPath, env.homedir);
825
+ return expandTilde(loc.configPath, configHome(harness, env));
811
826
  }
812
827
  /**
813
828
  * Nearest ancestor of `cwd` containing `.git`, or `undefined`.
@@ -846,25 +861,75 @@ function resolveProjectRoot(env) {
846
861
  };
847
862
  }
848
863
  /**
864
+ * The key Claude Code files a `local`-scope entry under, in `projects[...]`,
865
+ * for a client run from `env.cwd` (ENG-6696, observed on 2.1.296).
866
+ *
867
+ * It is the PROJECT, not the directory: from `repo/sub` the key is `repo`.
868
+ * A linked git worktree keys to its MAIN checkout (the parent of the common
869
+ * `.git`), a worktree of a bare repository to the bare directory, and a
870
+ * submodule or `--separate-git-dir` checkout to its own work tree. Outside a
871
+ * repository it is the directory itself. Always a real path, with `/`
872
+ * separators on Windows (unverified there: drive-letter case). Claude reads
873
+ * only this key, so an entry under any other key is invisible to it, and a
874
+ * probe that looked elsewhere missed the entry and let `claude mcp add`
875
+ * refuse.
876
+ */
877
+ function projectKeyFor(env) {
878
+ return configKey(projectDir(env), env.platform);
879
+ }
880
+ /** Claude Code writes `projects` keys with `/` on Windows too. */
881
+ function configKey(dir, platform) {
882
+ return platform === "win32" ? dir.replaceAll("\\", "/") : dir;
883
+ }
884
+ function projectDir(env) {
885
+ const cwd = realOrResolved(env.cwd);
886
+ const workTree = findRepoRoot(cwd);
887
+ if (!workTree) return cwd;
888
+ const dotGit = join(workTree, ".git");
889
+ let gitDir = dotGit;
890
+ try {
891
+ if (statSync(dotGit).isFile()) {
892
+ const m = /^gitdir:\s*([^\r\n]+)/.exec(readFileSync(dotGit, "utf8").trim());
893
+ if (!m?.[1]) return workTree;
894
+ gitDir = resolve(workTree, m[1].trim());
895
+ }
896
+ } catch {
897
+ return workTree;
898
+ }
899
+ const commonFile = readTextOrNull(join(gitDir, "commondir"));
900
+ if (commonFile === null) return workTree;
901
+ const common = realOrResolved(resolve(gitDir, commonFile.trim()));
902
+ if (!existsSync(common)) return workTree;
903
+ if (realOrResolved(dirname(gitDir)) !== join(common, "worktrees")) return workTree;
904
+ const back = readTextOrNull(join(gitDir, "gitdir"));
905
+ if (back === null || realOrResolved(dirname(resolve(gitDir, back.trim()))) !== workTree) return workTree;
906
+ if (basename(common) === ".git") return dirname(common);
907
+ return existsSync(join(common, ".git")) ? workTree : common;
908
+ }
909
+ function realOrResolved(p) {
910
+ try {
911
+ return realpathSync(p);
912
+ } catch {
913
+ return resolve(p);
914
+ }
915
+ }
916
+ /**
849
917
  * Where our server entry sits INSIDE a scope's config file.
850
918
  *
851
919
  * Usually `[<serverProperty>, 'levr']` at the top level. A scope declaring
852
- * `cwdKeyedUnder` nests one level deeper, keyed by the launch directory:
853
- * Claude Code's `local` scope lives at
854
- * `projects[<cwd>].mcpServers.levr` in `~/.claude.json`.
920
+ * `projectKeyedUnder` nests one level deeper, under the project key: Claude
921
+ * Code's `local` scope lives at `projects[<key>].mcpServers.levr` in
922
+ * `~/.claude.json`, with `<key>` from {@link projectKeyFor}.
855
923
  *
856
- * `cwdKey` is passed in rather than read off the env because the key is
857
- * specifically the directory the CLIENT was launched in — which is NOT the
858
- * repo root, and callers should have to say which directory they mean.
859
924
  * Shared by detect and install so the two can never disagree about where an
860
925
  * entry lives.
861
926
  */
862
- function entryPathFor(harness, scope, cwdKey, serverName = DEFAULT_SERVER_NAME) {
927
+ function entryPathFor(harness, scope, projectKey, serverName = DEFAULT_SERVER_NAME) {
863
928
  const tail = [serverPropertyFor(harness, scope), serverName];
864
- const nest = scopeDef(harness, scope)?.cwdKeyedUnder;
929
+ const nest = scopeDef(harness, scope)?.projectKeyedUnder;
865
930
  return nest ? [
866
931
  nest,
867
- cwdKey,
932
+ projectKey,
868
933
  ...tail
869
934
  ] : tail;
870
935
  }
@@ -916,13 +981,14 @@ function collidingScope(harness, env, scope) {
916
981
  if (!scopeDef(harness, scope)?.projectPath) return void 0;
917
982
  const target = resolveConfigPath(harness, env, scope);
918
983
  if (!target) return void 0;
919
- const targetEntry = entryPathFor(harness, scope, env.cwd).join("\0");
984
+ const key = projectKeyFor(env);
985
+ const targetEntry = entryPathFor(harness, scope, key).join("\0");
920
986
  for (const other of harness.scopes) {
921
987
  if (other.scope === scope) continue;
922
988
  const otherPath = resolveConfigPath(harness, env, other.scope);
923
989
  if (!otherPath) continue;
924
990
  if (!pathsResolveSame(target, otherPath, env.platform)) continue;
925
- if (entryPathFor(harness, other.scope, env.cwd).join("\0") === targetEntry) return other.scope;
991
+ if (entryPathFor(harness, other.scope, key).join("\0") === targetEntry) return other.scope;
926
992
  }
927
993
  }
928
994
  const WIN_EXTS = [
@@ -958,22 +1024,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
- function signalMatches(signal, env) {
1027
+ * `which:<bin>`, `~`-prefixed paths, and absolute (app-bundle) paths. `home`
1028
+ * is what `~` means for the harness — see {@link configHome}. */
1029
+ function signalMatches(signal, env, home = env.homedir) {
963
1030
  if (signal.startsWith("which:")) return whichSync(signal.slice(6), env);
964
- return existsSync(signal.startsWith("~") ? expandTilde(signal, env.homedir) : signal);
1031
+ return existsSync(signal.startsWith("~") ? expandTilde(signal, home) : signal);
965
1032
  }
966
1033
  /**
967
1034
  * The value our server key currently holds for this scope, or `undefined`.
968
1035
  *
969
- * Presence alone is not enough for a `cli-command` scope: `claude mcp add` is
970
- * a no-op when the key exists, so telling "already correct" from "present but
971
- * pointing somewhere else" needs the value.
1036
+ * Presence alone is not enough for a `cli-command` scope: `claude mcp add`
1037
+ * refuses when the key exists and never updates its URL, so telling "already
1038
+ * correct" from "present but pointing somewhere else" needs the value.
972
1039
  */
973
- function readServerEntry(harness, scope, configPath, cwdKey, parseJsonc, serverName = DEFAULT_SERVER_NAME) {
1040
+ function readServerEntry(harness, scope, configPath, projectKey, parseJsonc, serverName = DEFAULT_SERVER_NAME) {
974
1041
  const text = readTextOrNull(configPath);
975
1042
  if (!text) return void 0;
976
- return getAtPath(parseJsonc(text), entryPathFor(harness, scope, cwdKey, serverName));
1043
+ return getAtPath(parseJsonc(text), entryPathFor(harness, scope, projectKey, serverName));
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
- * Is our server key present for this scope?
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 isServerConfigured(harness, scope, configPath, cwdKey) {
1514
+ function serverEntry(harness, scope, configPath, projectKey, serverName) {
1429
1515
  const text = readTextOrNull(configPath);
1430
- if (!text) return false;
1431
- const read = adapterFor(harness).readAt(text, entryPathFor(harness, scope, cwdKey));
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: available && configPath ? isServerConfigured(harness, def.scope, configPath, env.cwd) : false
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
- return HARNESSES.map((h) => detectOne(h, env));
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
- ...process.env,
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.cwd, (t) => {
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.cwd, serverName);
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
- /** Harness ids to pre-select in interactive mode: detected + installable +
2072
- * not-already-configured. "Already configured" is judged in the scope we are
2073
- * about to install into, not the harness's default one. */
2074
- function autoSelectIds(detected, scope) {
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 inScope = scope ? d.scopes.find((s) => s.scope === scope) : void 0;
2078
- return !(inScope ? inScope.alreadyConfigured : d.alreadyConfigured);
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.alreadyConfigured) hint = `already set up (${scope})`;
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` : "; remove it first, then re-run");
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) return `${o.label}: failed — ${failureText(o, switchCommand)}`;
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(selection, {
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. 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
@@ -135,7 +137,7 @@ or when not running in a terminal (CI). Config edits preserve existing
135
137
  servers and comments, and re-running is a no-op.
136
138
 
137
139
  Examples:
138
- npx @levr-one/cli mcp add # detect clients and pick interactively
140
+ npx @levr-one/cli@latest mcp add # detect clients and pick interactively
139
141
  levr mcp add --all # set up every detected client
140
142
  levr mcp add --client cursor --yes
141
143
  levr mcp add --scope project # commit the config to this repo
@@ -207,7 +209,7 @@ Examples:
207
209
  aliases: { y: "yes" }
208
210
  },
209
211
  loader: async () => {
210
- const { mcpAddHandler } = await import("./addHandler-Dh4elBBV.js");
212
+ const { mcpAddHandler } = await import("./addHandler-BL1e6anD.js");
211
213
  return mcpAddHandler;
212
214
  }
213
215
  });
@@ -563,12 +565,17 @@ Examples:
563
565
  //#region src/commands/workspace/select.ts
564
566
  const selectCommand = buildCommand({
565
567
  docs: {
566
- brief: "Select a workspace",
568
+ brief: "Select the CLI workspace (MCP client configs are unchanged)",
567
569
  fullDescription: `Select a workspace by ID.
568
570
 
569
- The selected workspace is used for all subsequent commands.
571
+ The selected workspace is used for all subsequent levr commands.
570
572
  Use 'levr workspace list' to see available workspaces.
571
573
 
574
+ It does not change MCP client configs: each AI client keeps using the
575
+ workspace its MCP URL names. To point a client at a workspace, run
576
+ 'levr mcp add --workspace <url_key>' (add --replace to switch an existing
577
+ entry).
578
+
572
579
  Requires JWT authentication (levr auth login).
573
580
 
574
581
  Examples:
@@ -869,7 +876,7 @@ Examples:
869
876
 
870
877
  //#endregion
871
878
  //#region package.json
872
- var version = "0.11.2";
879
+ var version = "0.11.5";
873
880
 
874
881
  //#endregion
875
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.2",
3
+ "version": "0.11.5",
4
4
  "description": "The command-line interface for Levr",
5
5
  "type": "module",
6
6
  "license": "MIT",