@levr-one/cli 0.10.22 → 0.11.1

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.
@@ -1,4 +1,9 @@
1
+ import { configureClient } from "./sdk-client-DYv-0CfA.js";
1
2
  import { getApiUrl } from "./env-CdwyPHGV.js";
3
+ import "./token-refresh-DeusLK8H.js";
4
+ import { resolveToken } from "./resolve-token-7xb7kG7h.js";
5
+ import "./workspace-store-CnrxyYRB.js";
6
+ import { fetchSites } from "./resolve-workspace-CStHisUb.js";
2
7
  import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
3
8
  import { basename, delimiter, dirname, join, resolve } from "node:path";
4
9
  import { homedir } from "node:os";
@@ -114,11 +119,12 @@ const DEFAULT_NATIVE_HTTP_ENTRY = {
114
119
  * downgraded back. A NEW entry must never be born carrying this value.
115
120
  */
116
121
  const UNVERIFIED_LEGACY = "unverified-legacy";
117
- /** Stable server key written into every harness config (used by detect/remove).
122
+ /** Default server key written into every harness config (used by detect/remove).
118
123
  * Renamed from the legacy brand key pre-first-publish (ENG-2515) — this key
119
124
  * is a persisted identity in end-users' client config files, so it must not
120
- * carry the old brand. */
121
- const SERVER_NAME = "levr";
125
+ * carry the old brand. A caller may write under another name (ENG-6300 D6:
126
+ * `levr mcp add --name`) so a second workspace can sit beside the first. */
127
+ const DEFAULT_SERVER_NAME = "levr";
122
128
  /** Every scope in preference order — the vocabulary, not any harness's support. */
123
129
  const HARNESS_SCOPES = [
124
130
  "user",
@@ -705,9 +711,9 @@ function requireScope(harness, scope) {
705
711
  /**
706
712
  * Substitute `{name}` / `{url}` / `{scope}` into a `cli-command` argv. Returns
707
713
  * argv rather than a string so the executor can spawn it WITHOUT a shell.
708
- * `serverName` defaults to {@link SERVER_NAME}; see {@link buildHarnessConfig}.
714
+ * `serverName` defaults to {@link DEFAULT_SERVER_NAME}; see {@link buildHarnessConfig}.
709
715
  */
710
- function renderHarnessCommand(harness, mcpUrl, scope, serverName = SERVER_NAME) {
716
+ function renderHarnessCommand(harness, mcpUrl, scope, serverName = DEFAULT_SERVER_NAME) {
711
717
  const def = requireScope(harness, scope);
712
718
  if (!def.command) throw new Error(`harness "${harness.id}" scope "${scope}" declares no command argv`);
713
719
  const substitutions = {
@@ -730,7 +736,7 @@ function mcpRemoteInvocation(mcpUrl) {
730
736
  }
731
737
  /**
732
738
  * The structured server entry to merge under the scope's server property,
733
- * keyed by `serverName` (default {@link SERVER_NAME}). Shape is per-transport,
739
+ * keyed by `serverName` (default {@link DEFAULT_SERVER_NAME}). Shape is per-transport,
734
740
  * then per-property:
735
741
  * - `native-http`: the entry's declared {@link NativeHttpEntryShape} —
736
742
  * `{ url, type: 'http' }` by default (Claude Code's `.mcp.json`, Gemini).
@@ -739,7 +745,7 @@ function mcpRemoteInvocation(mcpUrl) {
739
745
  *
740
746
  * Not used for `cli-command` scopes (see {@link buildHarnessConfig}).
741
747
  */
742
- function buildServerEntry(harness, mcpUrl, scope = defaultScope(harness), serverName = SERVER_NAME) {
748
+ function buildServerEntry(harness, mcpUrl, scope = defaultScope(harness), serverName = DEFAULT_SERVER_NAME) {
743
749
  requireScope(harness, scope);
744
750
  if (harness.transport === "native-http") {
745
751
  const shape = harness.nativeHttpEntry ?? DEFAULT_NATIVE_HTTP_ENTRY;
@@ -853,8 +859,8 @@ function resolveProjectRoot(env) {
853
859
  * Shared by detect and install so the two can never disagree about where an
854
860
  * entry lives.
855
861
  */
856
- function entryPathFor(harness, scope, cwdKey) {
857
- const tail = [serverPropertyFor(harness, scope), SERVER_NAME];
862
+ function entryPathFor(harness, scope, cwdKey, serverName = DEFAULT_SERVER_NAME) {
863
+ const tail = [serverPropertyFor(harness, scope), serverName];
858
864
  const nest = scopeDef(harness, scope)?.cwdKeyedUnder;
859
865
  return nest ? [
860
866
  nest,
@@ -964,10 +970,10 @@ function signalMatches(signal, env) {
964
970
  * a no-op when the key exists, so telling "already correct" from "present but
965
971
  * pointing somewhere else" needs the value.
966
972
  */
967
- function readServerEntry(harness, scope, configPath, cwdKey, parseJsonc) {
973
+ function readServerEntry(harness, scope, configPath, cwdKey, parseJsonc, serverName = DEFAULT_SERVER_NAME) {
968
974
  const text = readTextOrNull(configPath);
969
975
  if (!text) return void 0;
970
- return getAtPath(parseJsonc(text), entryPathFor(harness, scope, cwdKey));
976
+ return getAtPath(parseJsonc(text), entryPathFor(harness, scope, cwdKey, serverName));
971
977
  }
972
978
  /** Read a text file, or `null` if it doesn't exist / can't be read. */
973
979
  function readTextOrNull(path$1) {
@@ -1577,28 +1583,50 @@ function resolveTarget(harness, env, scope) {
1577
1583
  * `present: true` means an entry we cannot interpret — treated as a mismatch
1578
1584
  * rather than assumed correct.
1579
1585
  */
1580
- function currentEntry(harness, env, scope) {
1586
+ function currentEntry(harness, env, scope, serverName) {
1581
1587
  const configPath = resolveConfigPath(harness, env, scope);
1582
1588
  if (!configPath) return { present: false };
1583
1589
  const adapter = adapterFor(harness);
1584
1590
  const entry = readServerEntry(harness, scope, configPath, env.cwd, (t) => {
1585
1591
  const read = adapter.readAt(t, []);
1586
1592
  return read.kind === "value" ? read.value : void 0;
1587
- });
1593
+ }, serverName);
1588
1594
  if (entry === void 0 || entry === null) return { present: false };
1589
- const e = entry;
1590
- if (typeof e.url === "string") return {
1595
+ const url = entryUrl(harness, entry);
1596
+ return url === void 0 ? { present: true } : {
1591
1597
  present: true,
1592
- url: e.url
1598
+ url
1593
1599
  };
1594
- if (Array.isArray(e.args)) {
1595
- const last = e.args[e.args.length - 1];
1596
- if (typeof last === "string") return {
1597
- present: true,
1598
- url: last
1599
- };
1600
- }
1601
- return { present: true };
1600
+ }
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
+ /** `claude mcp remove` argv — the inverse of the catalog's add command. */
1621
+ function removeCommandArgv(scope, serverName) {
1622
+ return [
1623
+ "claude",
1624
+ "mcp",
1625
+ "remove",
1626
+ "--scope",
1627
+ scope,
1628
+ serverName
1629
+ ];
1602
1630
  }
1603
1631
  /**
1604
1632
  * Structural equality, key order ignored. A client that writes its keys in a
@@ -1619,6 +1647,7 @@ function installHarnessSync(harness, mcpUrl, opts = {}) {
1619
1647
  const env = opts.env ?? defaultEnv();
1620
1648
  const dryRun = opts.dryRun ?? false;
1621
1649
  const scope = opts.scope ?? defaultScope(harness);
1650
+ const serverName = opts.serverName ?? DEFAULT_SERVER_NAME;
1622
1651
  const def = scopeDef(harness, scope);
1623
1652
  if (!def) return {
1624
1653
  ok: false,
@@ -1630,7 +1659,7 @@ function installHarnessSync(harness, mcpUrl, opts = {}) {
1630
1659
  reason: "unsupported-scope"
1631
1660
  };
1632
1661
  if (def.installKind === "cli-command") {
1633
- const argv = renderHarnessCommand(harness, mcpUrl, scope);
1662
+ const argv = renderHarnessCommand(harness, mcpUrl, scope, serverName);
1634
1663
  const base = {
1635
1664
  wrote: false,
1636
1665
  path: "",
@@ -1638,7 +1667,7 @@ function installHarnessSync(harness, mcpUrl, opts = {}) {
1638
1667
  dryRun,
1639
1668
  scope
1640
1669
  };
1641
- const existing$1 = currentEntry(harness, env, scope);
1670
+ const existing$1 = currentEntry(harness, env, scope, serverName);
1642
1671
  if (existing$1.present) {
1643
1672
  if (existing$1.url === mcpUrl) return {
1644
1673
  ...base,
@@ -1646,6 +1675,21 @@ function installHarnessSync(harness, mcpUrl, opts = {}) {
1646
1675
  executed: false,
1647
1676
  alreadyConfigured: true
1648
1677
  };
1678
+ if (!existing$1.url) return {
1679
+ ...base,
1680
+ ok: false,
1681
+ executed: false,
1682
+ alreadyConfigured: false,
1683
+ reason: "unrecognized-entry"
1684
+ };
1685
+ if (opts.replaceExisting) return replaceViaCommand(harness, argv, existing$1.url, {
1686
+ env,
1687
+ dryRun,
1688
+ scope,
1689
+ serverName,
1690
+ execute: opts.execute ?? true,
1691
+ run: opts.runCommand ?? runHarnessCommandSync
1692
+ });
1649
1693
  return {
1650
1694
  ...base,
1651
1695
  ok: false,
@@ -1684,14 +1728,27 @@ function installHarnessSync(harness, mcpUrl, opts = {}) {
1684
1728
  ...target.collidesWith ? { collidesWith: target.collidesWith } : {}
1685
1729
  };
1686
1730
  const { path: path$1 } = target;
1687
- const entryValue = buildServerEntry(harness, mcpUrl, scope)[SERVER_NAME];
1688
- const modPath = entryPathFor(harness, scope, env.cwd);
1731
+ const entryValue = buildServerEntry(harness, mcpUrl, scope, serverName)[serverName];
1732
+ const modPath = entryPathFor(harness, scope, env.cwd, serverName);
1689
1733
  const adapter = adapterFor(harness);
1690
1734
  const existing = readTextOrNull(path$1);
1691
1735
  const baseText = existing && existing.trim() ? existing : adapter.empty;
1692
1736
  const current = adapter.readAt(baseText, modPath);
1693
1737
  if (current.kind === "unsupported") return unsupportedShape(path$1, dryRun, scope, current.detail);
1694
1738
  const alreadyConfigured = current.kind === "value" && sameValue(current.value, entryValue);
1739
+ const held = current.kind === "value" && !alreadyConfigured;
1740
+ const heldUrl = held ? entryUrl(harness, current.value) : void 0;
1741
+ if (held && heldUrl !== mcpUrl && opts.replaceExisting === false) return {
1742
+ ok: false,
1743
+ wrote: false,
1744
+ path: path$1,
1745
+ alreadyConfigured: false,
1746
+ dryRun,
1747
+ scope,
1748
+ reason: heldUrl === void 0 ? "unrecognized-entry" : "url-mismatch",
1749
+ ...heldUrl === void 0 ? {} : { currentUrl: heldUrl }
1750
+ };
1751
+ const replaced = heldUrl !== void 0 && heldUrl !== mcpUrl ? { replacedUrl: heldUrl } : {};
1695
1752
  let nextText;
1696
1753
  try {
1697
1754
  nextText = adapter.setAt(baseText, modPath, entryValue);
@@ -1717,6 +1774,7 @@ function installHarnessSync(harness, mcpUrl, opts = {}) {
1717
1774
  dryRun: true,
1718
1775
  scope,
1719
1776
  preview: nextText,
1777
+ ...replaced,
1720
1778
  ...backupPath ? { backupPath } : {}
1721
1779
  };
1722
1780
  const finalText = nextText.endsWith("\n") ? nextText : `${nextText}\n`;
@@ -1735,10 +1793,63 @@ function installHarnessSync(harness, mcpUrl, opts = {}) {
1735
1793
  dryRun: false,
1736
1794
  scope,
1737
1795
  preview: finalText,
1796
+ ...replaced,
1738
1797
  ...backupPath ? { backupPath } : {}
1739
1798
  };
1740
1799
  }
1741
1800
  /**
1801
+ * Repoint a `cli-command` client: remove the entry, add the new one, and put
1802
+ * the previous one back if the add fails (ENG-6300 D6, R1 K-002). The client's
1803
+ * CLI will not overwrite an entry, so a replace is two commands — and a failed
1804
+ * second command must not leave the user with no entry at all without saying
1805
+ * so.
1806
+ */
1807
+ function replaceViaCommand(harness, addArgv, previousUrl, ctx) {
1808
+ const removeArgv = removeCommandArgv(ctx.scope, ctx.serverName);
1809
+ const base = {
1810
+ wrote: false,
1811
+ path: "",
1812
+ command: `${removeArgv.join(" ")} && ${addArgv.join(" ")}`,
1813
+ dryRun: ctx.dryRun,
1814
+ scope: ctx.scope,
1815
+ alreadyConfigured: false,
1816
+ replacedUrl: previousUrl
1817
+ };
1818
+ if (ctx.dryRun || !ctx.execute) return {
1819
+ ...base,
1820
+ ok: true,
1821
+ executed: false
1822
+ };
1823
+ const removed = ctx.run(removeArgv, { env: ctx.env });
1824
+ if (!removed.executed) return {
1825
+ ...base,
1826
+ ok: removed.ok,
1827
+ executed: false
1828
+ };
1829
+ if (!removed.ok) return {
1830
+ ...base,
1831
+ ok: false,
1832
+ executed: true,
1833
+ commandError: `could not remove the existing entry: ${removed.stderr ?? "unknown error"}`
1834
+ };
1835
+ const added = ctx.run(addArgv, { env: ctx.env });
1836
+ if (added.ok) return {
1837
+ ...base,
1838
+ ok: true,
1839
+ executed: true
1840
+ };
1841
+ const restoreArgv = renderHarnessCommand(harness, previousUrl, ctx.scope, ctx.serverName);
1842
+ const restore = ctx.run(restoreArgv, { env: ctx.env });
1843
+ return {
1844
+ ...base,
1845
+ ok: false,
1846
+ executed: true,
1847
+ commandError: added.stderr ?? "unknown error",
1848
+ restored: restore.ok && restore.executed,
1849
+ ...restore.ok && restore.executed ? {} : { restoreError: restore.stderr ?? "the restore command did not run" }
1850
+ };
1851
+ }
1852
+ /**
1742
1853
  * Where the one-shot backup of an existing `path` lives, for an adapter that
1743
1854
  * asks for one — `undefined` when the format does not, or when there is no
1744
1855
  * original to protect yet.
@@ -1880,6 +1991,37 @@ function assertUsableMcpUrl(url, source) {
1880
1991
  if (!parsed.hostname) throw new Error(`Invalid MCP URL from ${from}: ${JSON.stringify(url)} has no host.`);
1881
1992
  if (parsed.username || parsed.password) throw new Error(`Invalid MCP URL from ${from}: ${JSON.stringify(url)} embeds credentials. They would be written into client config files — including project-scoped ones you are told to commit. Levr authenticates in the browser; remove the user:password@ prefix.`);
1882
1993
  }
1994
+ /**
1995
+ * Is this MCP URL served by the API the CLI is logged in to? `--workspace`
1996
+ * and the workspace picker read that API's workspaces, so pinning a URL on
1997
+ * another server to one of them would name the wrong workspace, or none
1998
+ * (ENG-6300 D6).
1999
+ */
2000
+ function servedBySessionApi(mcpUrl) {
2001
+ const apiUrl = getApiUrl();
2002
+ const derived = knownMcpUrl(apiUrl) ?? `${apiUrl}/v1/mcp`;
2003
+ try {
2004
+ return new URL(mcpUrl).origin === new URL(derived).origin;
2005
+ } catch {
2006
+ return false;
2007
+ }
2008
+ }
2009
+ /** `…/v1/mcp/w/<url_key>`: the URL already names one workspace (ENG-6300). */
2010
+ const SCOPED_SUFFIX = /\/w\/[^/]+$/;
2011
+ /** Does this MCP URL already name a workspace? */
2012
+ function isScopedMcpUrl(url) {
2013
+ return SCOPED_SUFFIX.test(stripSlash(url));
2014
+ }
2015
+ /**
2016
+ * The MCP URL for one workspace, `<base>/w/<url_key>` (ENG-6300 D6). A base
2017
+ * that already names a workspace is an error rather than a silent re-point:
2018
+ * `--url …/w/a --workspace b` asks for two different workspaces at once.
2019
+ */
2020
+ function scopedMcpUrl(base, urlKey) {
2021
+ const root = stripSlash(base);
2022
+ if (isScopedMcpUrl(root)) throw new Error(`The MCP URL ${root} already names a workspace. Drop --workspace, or pass the URL without its /w/<url_key> suffix.`);
2023
+ return `${root}/w/${encodeURIComponent(urlKey)}`;
2024
+ }
1883
2025
  function knownMcpUrl(apiUrl) {
1884
2026
  try {
1885
2027
  return KNOWN_MCP_URLS[new URL(apiUrl).host];
@@ -1891,6 +2033,29 @@ function stripSlash(url) {
1891
2033
  return url.replace(/\/+$/, "");
1892
2034
  }
1893
2035
 
2036
+ //#endregion
2037
+ //#region src/workspace/find-workspace.ts
2038
+ /** ` - Name (url_key)` per workspace, for error messages and hints. */
2039
+ function describeWorkspaces(sites) {
2040
+ return sites.map((s) => ` - ${s.workspace_name} (${s.workspace_url_key})`).join("\n");
2041
+ }
2042
+ /**
2043
+ * An exact `url_key` wins; otherwise a case-insensitive name that matches
2044
+ * exactly one workspace. Anything else throws, listing the choices.
2045
+ */
2046
+ function findWorkspaceByKeyOrName(sites, input) {
2047
+ const wanted = input.trim();
2048
+ if (!wanted) throw new Error("--workspace needs a workspace name or url_key.");
2049
+ const byKey = sites.find((s) => s.workspace_url_key === wanted);
2050
+ if (byKey) return byKey;
2051
+ const lower = wanted.toLowerCase();
2052
+ const byName = sites.filter((s) => s.workspace_name.toLowerCase() === lower);
2053
+ if (byName.length === 1) return byName[0];
2054
+ if (byName.length > 1) throw new Error(`"${wanted}" names more than one workspace. Pass its url_key instead:\n` + describeWorkspaces(byName));
2055
+ if (sites.length === 0) throw new Error("Your account has no workspaces.");
2056
+ throw new Error(`No workspace "${wanted}" in your account. Choose one of:\n` + describeWorkspaces(sites));
2057
+ }
2058
+
1894
2059
  //#endregion
1895
2060
  //#region src/mcp/run.ts
1896
2061
  /**
@@ -2021,35 +2186,43 @@ function runNonInteractive(options, url, urlSource, deps) {
2021
2186
  };
2022
2187
  }
2023
2188
  /** Why an install was refused, in the user's terms rather than the enum's. */
2024
- function failureText(o) {
2189
+ function failureText(o, switchCommand) {
2025
2190
  const r = o.result;
2026
2191
  const harness = getHarness(o.id);
2027
2192
  switch (r.reason) {
2028
2193
  case "unsupported-scope": return `no ${r.scope} scope` + (harness ? ` (supports: ${supportedScopes(harness).join(", ")})` : "");
2029
2194
  case "not-a-repo": return "project scope needs a git repository (run from inside one)";
2030
2195
  case "scope-collision": return `${r.scope} scope resolves to the same file as ${r.collidesWith ?? "another"} scope here — refusing rather than overwriting it`;
2031
- case "url-mismatch": return "already configured with a different URL" + (r.currentUrl ? ` (${r.currentUrl})` : "") + "; remove it first, then re-run";
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");
2197
+ 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>";
2032
2198
  case "write-failed": return `its config could not be written` + (r.detail ? ` (${r.detail})` : "") + `; check the file's permissions and re-run`;
2033
2199
  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`;
2034
2200
  default:
2035
- if (r.commandError) return `\`${r.command}\` (${r.commandError})`;
2201
+ if (r.commandError) return `\`${r.command}\` (${r.commandError})${restoreText(r)}`;
2036
2202
  return "no config location on this platform";
2037
2203
  }
2038
2204
  }
2205
+ /** What happened to the entry a failed `--replace` removed (ENG-6300 D6). */
2206
+ function restoreText(r) {
2207
+ if (r.restored === void 0) return "";
2208
+ if (r.restored) return `; the previous entry was restored with its URL (${r.replacedUrl}) — any other settings it had were not`;
2209
+ 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
+ }
2039
2211
  /** One human-readable status line per outcome. */
2040
- function outcomeLine(o, dryRun) {
2212
+ function outcomeLine(o, dryRun, switchCommand) {
2041
2213
  const r = o.result;
2042
2214
  const note = o.fallbackFrom ? ` [${o.fallbackFrom} scope unsupported — used ${r.scope}]` : "";
2043
2215
  const where = r.path ? ` → ${r.path}` : "";
2044
2216
  const backup = r.backupPath ? dryRun ? ` [original would be backed up to ${r.backupPath}]` : ` [original backed up to ${r.backupPath}]` : "";
2045
- if (!r.ok) return `${o.label}: failed — ${failureText(o)}`;
2217
+ const replaced = r.replacedUrl ? dryRun ? ` [would replace ${r.replacedUrl}]` : ` [replaced ${r.replacedUrl}]` : "";
2218
+ if (!r.ok) return `${o.label}: failed — ${failureText(o, switchCommand)}`;
2046
2219
  if (r.alreadyConfigured) return `${o.label}: already set up (${r.scope})${where}${note}`;
2047
2220
  if (r.command) {
2048
- if (r.executed) return `${o.label}: installed (${r.scope}) via \`${r.command}\`${note}`;
2049
- return `${o.label} (${r.scope}): run \`${r.command}\`${note}`;
2221
+ if (r.executed) return `${o.label}: installed (${r.scope}) via \`${r.command}\`${replaced}${note}`;
2222
+ return `${o.label} (${r.scope}): run \`${r.command}\`${replaced}${note}`;
2050
2223
  }
2051
- if (dryRun) return `${o.label}: would update (${r.scope})${where} (dry run — no changes)${backup}${note}`;
2052
- if (r.wrote) return `${o.label}: installed (${r.scope})${where}${backup}${note}`;
2224
+ if (dryRun) return `${o.label}: would update (${r.scope})${where} (dry run — no changes)${backup}${replaced}${note}`;
2225
+ if (r.wrote) return `${o.label}: installed (${r.scope})${where}${backup}${replaced}${note}`;
2053
2226
  return `${o.label}: no change (${r.scope})${where}${note}`;
2054
2227
  }
2055
2228
  /** Render a report as a plain multi-line summary (used by the CLI + tests). */
@@ -2057,7 +2230,7 @@ function formatReport(report) {
2057
2230
  const lines = [];
2058
2231
  lines.push(`MCP URL: ${report.url} (${report.urlSource})`);
2059
2232
  if (report.outcomes.length === 0) lines.push("No clients selected.");
2060
- else for (const o of report.outcomes) lines.push(outcomeLine(o, report.dryRun));
2233
+ else for (const o of report.outcomes) lines.push(outcomeLine(o, report.dryRun, report.switchCommand));
2061
2234
  if (report.unknownClients.length > 0) lines.push(`Unknown clients (skipped): ${report.unknownClients.join(", ")}`);
2062
2235
  if (report.outcomes.some((o) => o.id === "claude-code" && o.result.ok)) lines.push(PLUGIN_TIP);
2063
2236
  return lines.join("\n");
@@ -2075,15 +2248,88 @@ function nextStepsText(report) {
2075
2248
 
2076
2249
  //#endregion
2077
2250
  //#region src/commands/mcp/addHandler.ts
2078
- const defaultInstall = (harness, mcpUrl, dryRun, scope) => installHarnessSync(harness, mcpUrl, {
2079
- dryRun,
2080
- scope
2081
- });
2082
- const defaultDeps = {
2083
- detect: () => detectSync(),
2084
- install: defaultInstall
2085
- };
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
+ function depsFor(entry) {
2262
+ const install = (harness, mcpUrl, dryRun, scope) => installHarnessSync(harness, mcpUrl, {
2263
+ dryRun,
2264
+ scope,
2265
+ serverName: entry.serverName,
2266
+ replaceExisting: entry.replaceExisting
2267
+ });
2268
+ return {
2269
+ detect: () => detectSync(),
2270
+ install
2271
+ };
2272
+ }
2273
+ /**
2274
+ * The workspaces of the logged-in user. Reads the session only; it never
2275
+ * reads or changes the workspace the CLI has selected.
2276
+ */
2277
+ async function loadSites() {
2278
+ let auth;
2279
+ try {
2280
+ auth = await resolveToken();
2281
+ } catch (err) {
2282
+ return {
2283
+ ok: false,
2284
+ error: err instanceof Error ? err.message : "Not authenticated. Run 'levr auth login' first."
2285
+ };
2286
+ }
2287
+ if (auth.type === "pat") return {
2288
+ ok: false,
2289
+ error: "--workspace needs a login session, not a personal access token. Run 'levr auth login'."
2290
+ };
2291
+ configureClient(auth);
2292
+ try {
2293
+ return {
2294
+ ok: true,
2295
+ sites: await fetchSites()
2296
+ };
2297
+ } catch (err) {
2298
+ return {
2299
+ ok: false,
2300
+ error: err instanceof Error ? err.message : "Could not list your workspaces."
2301
+ };
2302
+ }
2303
+ }
2304
+ /**
2305
+ * The same `levr mcp add` run with `--replace`, for refusals: every flag the
2306
+ * user passed is kept, so the suggestion targets the same server, scope and
2307
+ * clients, and the workspace that was actually chosen.
2308
+ */
2309
+ function switchCommandFor(flags, urlKey) {
2310
+ const parts = ["levr mcp add"];
2311
+ if (flags.all) parts.push("--all");
2312
+ for (const client of flags.client ?? []) parts.push(`--client ${client}`);
2313
+ if (flags.yes) parts.push("--yes");
2314
+ if (flags.scope) parts.push(`--scope ${flags.scope}`);
2315
+ if (flags.url) parts.push(`--url ${flags.url}`);
2316
+ const workspace = urlKey ?? flags.workspace;
2317
+ if (workspace) parts.push(`--workspace ${workspace}`);
2318
+ if (flags.name) parts.push(`--name ${flags.name}`);
2319
+ parts.push("--replace");
2320
+ return parts.join(" ");
2321
+ }
2086
2322
  async function mcpAddHandler(flags) {
2323
+ const serverName = flags.name ?? DEFAULT_SERVER_NAME;
2324
+ if (!isValidEntryName(serverName)) {
2325
+ this.logger.error(`Invalid --name ${JSON.stringify(serverName)}: use 1-64 letters, digits, "-" or "_", not starting with "-".`);
2326
+ this.process.exitCode = 1;
2327
+ return;
2328
+ }
2329
+ const entry = {
2330
+ serverName,
2331
+ replaceExisting: flags.replace ?? false
2332
+ };
2087
2333
  let url;
2088
2334
  let source;
2089
2335
  try {
@@ -2093,6 +2339,36 @@ async function mcpAddHandler(flags) {
2093
2339
  this.process.exitCode = 1;
2094
2340
  return;
2095
2341
  }
2342
+ let urlKey;
2343
+ if (flags.workspace !== void 0) {
2344
+ if (isScopedMcpUrl(url)) {
2345
+ this.logger.error(`The MCP URL ${url} already names a workspace. Drop --workspace, or pass the URL without its /w/<url_key> suffix.`);
2346
+ this.process.exitCode = 1;
2347
+ return;
2348
+ }
2349
+ if (!servedBySessionApi(url)) {
2350
+ this.logger.error(`--workspace looks workspaces up on ${getApiUrl()}, where you are logged in, but the MCP URL ${url} is on another server. Log in to that server (LEVR_URL=<its API> levr auth login), or drop --workspace.`);
2351
+ this.process.exitCode = 1;
2352
+ return;
2353
+ }
2354
+ const loaded = await loadSites();
2355
+ if (!loaded.ok) {
2356
+ this.logger.error(loaded.error);
2357
+ this.process.exitCode = 1;
2358
+ return;
2359
+ }
2360
+ let site;
2361
+ try {
2362
+ site = findWorkspaceByKeyOrName(loaded.sites, flags.workspace);
2363
+ } catch (err) {
2364
+ this.logger.error(err instanceof Error ? err.message : "Unknown workspace.");
2365
+ this.process.exitCode = 1;
2366
+ return;
2367
+ }
2368
+ urlKey = site.workspace_url_key;
2369
+ url = scopedMcpUrl(url, urlKey);
2370
+ source = `${source}, workspace ${site.workspace_name}`;
2371
+ }
2096
2372
  const clients = (flags.client ?? []).flatMap((c) => c.split(",").map((s) => s.trim()).filter(Boolean));
2097
2373
  const options = {
2098
2374
  all: flags.all,
@@ -2102,13 +2378,30 @@ async function mcpAddHandler(flags) {
2102
2378
  scope: flags.scope
2103
2379
  };
2104
2380
  if (options.all || clients.length > 0 || options.yes || !process.stdout.isTTY) {
2105
- const report = runNonInteractive(options, url, source, defaultDeps);
2381
+ const report = {
2382
+ ...runNonInteractive(options, url, source, depsFor(entry)),
2383
+ switchCommand: switchCommandFor(flags, urlKey)
2384
+ };
2106
2385
  this.process.stdout.write(`${formatReport(report)}\n`);
2386
+ if (!isScopedMcpUrl(url) && servedBySessionApi(url)) {
2387
+ const hint = await workspaceHint();
2388
+ if (hint) this.process.stdout.write(`\n${hint}\n`);
2389
+ }
2107
2390
  this.process.stdout.write(`\n${nextStepsText(report)}\n`);
2108
2391
  if (report.unknownClients.length > 0 || hasFailure(report)) this.process.exitCode = 1;
2109
2392
  return;
2110
2393
  }
2111
- await interactive(this, options.dryRun, url, source, flags.scope);
2394
+ await interactive(this, options.dryRun, url, source, flags, entry, urlKey);
2395
+ }
2396
+ /**
2397
+ * For an unpinned entry: the `--workspace` choices. Without a session there is
2398
+ * no list to show, so it says how to get one. Never fails the run.
2399
+ */
2400
+ async function workspaceHint() {
2401
+ const loaded = await loadSites();
2402
+ if (!loaded.ok) return "This entry is not pinned to a workspace. To pin one, run 'levr auth login', then re-run with --workspace <url_key>.";
2403
+ if (loaded.sites.length === 0) return null;
2404
+ return "This entry is not pinned to a workspace. To pin it, re-run with --workspace <url_key>:\n" + describeWorkspaces(loaded.sites);
2112
2405
  }
2113
2406
  function hasFailure(report) {
2114
2407
  return report.outcomes.some((o) => !o.result.ok);
@@ -2128,11 +2421,38 @@ const SCOPE_LABELS = {
2128
2421
  hint: "this repo, only you"
2129
2422
  }
2130
2423
  };
2131
- async function interactive(ctx, dryRun, url, urlSource, requestedScope) {
2424
+ async function interactive(ctx, dryRun, baseUrl, baseSource, flags, entry, resolvedUrlKey) {
2132
2425
  const p = await import("@clack/prompts");
2426
+ const requestedScope = flags.scope;
2427
+ const deps = depsFor(entry);
2428
+ let url = baseUrl;
2429
+ let urlSource = baseSource;
2430
+ let urlKey = resolvedUrlKey;
2133
2431
  p.intro("Levr MCP setup");
2432
+ if (!isScopedMcpUrl(url) && servedBySessionApi(url)) {
2433
+ const loaded = await loadSites();
2434
+ if (loaded.ok && loaded.sites.length > 1) {
2435
+ const picked = await p.select({
2436
+ message: "Which workspace should this entry use?",
2437
+ options: loaded.sites.map((s) => ({
2438
+ value: s.workspace_url_key,
2439
+ label: s.workspace_name,
2440
+ hint: s.workspace_url_key
2441
+ }))
2442
+ });
2443
+ if (p.isCancel(picked)) {
2444
+ p.cancel("Cancelled.");
2445
+ ctx.process.exitCode = 1;
2446
+ return;
2447
+ }
2448
+ const site = loaded.sites.find((s) => s.workspace_url_key === picked);
2449
+ urlKey = picked;
2450
+ url = scopedMcpUrl(url, picked);
2451
+ urlSource = `${urlSource}, workspace ${site?.workspace_name ?? picked}`;
2452
+ }
2453
+ }
2134
2454
  p.note(`${url}\n(${urlSource})`, "MCP endpoint");
2135
- const detected = defaultDeps.detect();
2455
+ const detected = deps.detect();
2136
2456
  const installable = detected.filter((d) => d.available);
2137
2457
  if (installable.length === 0) {
2138
2458
  p.outro("No supported MCP clients found on this machine.");
@@ -2184,7 +2504,7 @@ async function interactive(ctx, dryRun, url, urlSource, requestedScope) {
2184
2504
  dryRun,
2185
2505
  scope,
2186
2506
  namedIds: /* @__PURE__ */ new Set()
2187
- }, defaultDeps.install);
2507
+ }, deps.install);
2188
2508
  spin.stop(dryRun ? "Preview ready" : "Done");
2189
2509
  const report = {
2190
2510
  url,
@@ -2192,7 +2512,8 @@ async function interactive(ctx, dryRun, url, urlSource, requestedScope) {
2192
2512
  scope,
2193
2513
  outcomes,
2194
2514
  unknownClients: [],
2195
- dryRun
2515
+ dryRun,
2516
+ switchCommand: switchCommandFor(flags, urlKey)
2196
2517
  };
2197
2518
  p.note(formatReport(report), "Results");
2198
2519
  p.outro(nextStepsText(report));
@@ -1,12 +1,12 @@
1
- import { appendActualResult } from "./sdk-client-CTvYVr7D.js";
1
+ import { appendActualResult } from "./sdk-client-DYv-0CfA.js";
2
2
  import "./env-CdwyPHGV.js";
3
3
  import "./token-refresh-DeusLK8H.js";
4
4
  import "./resolve-token-7xb7kG7h.js";
5
5
  import "./workspace-store-CnrxyYRB.js";
6
- import "./resolve-workspace-B3Gz3-Vh.js";
7
- import { connect } from "./connect-DX41Im8c.js";
6
+ import "./resolve-workspace-CStHisUb.js";
7
+ import { connect } from "./connect-BgQmrL_0.js";
8
8
  import "./sleep-BpJ39Ypm.js";
9
- import { embedMarkdown, parseTarget, uploadArtifactFile } from "./artifact-upload-CF2iXUiR.js";
9
+ import { embedMarkdown, parseTarget, uploadArtifactFile } from "./artifact-upload-BzLxue32.js";
10
10
  import { readFileSync } from "node:fs";
11
11
  import { basename } from "node:path";
12
12
 
@@ -1,4 +1,4 @@
1
- import { uploadAttachment } from "./sdk-client-CTvYVr7D.js";
1
+ import { uploadAttachment } from "./sdk-client-DYv-0CfA.js";
2
2
  import { sleep } from "./sleep-BpJ39Ypm.js";
3
3
  import { constants, promises, readlinkSync, realpathSync, statSync } from "node:fs";
4
4
  import path from "node:path";