@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.
- package/dist/{addHandler-DUulKXOX.js → addHandler-Dh4elBBV.js} +373 -52
- package/dist/{appendHandler-u-NvHPDW.js → appendHandler-9WDDNkkA.js} +4 -4
- package/dist/{artifact-upload-CF2iXUiR.js → artifact-upload-BzLxue32.js} +1 -1
- package/dist/{attachHandler-DgVCvhz7.js → attachHandler-DQpK_pVa.js} +4 -4
- package/dist/cli.js +43 -11
- package/dist/{connect-DX41Im8c.js → connect-BgQmrL_0.js} +2 -2
- package/dist/{importHandler-CUUroX_n.js → importHandler-D2_Ol-Vp.js} +49 -2
- package/dist/{listHandler-CB_9cISi.js → listHandler-hmLz8zm8.js} +1 -1
- package/dist/{loginHandler-DSznhtmk.js → loginHandler-DzyfdKc2.js} +2 -2
- package/dist/{pushHandler-Q9Cbo2HC.js → pushHandler-DRFf9Zeu.js} +3 -3
- package/dist/{resolve-workspace-B3Gz3-Vh.js → resolve-workspace-CStHisUb.js} +2 -2
- package/dist/{runHandler-DmRAoyJc.js → runHandler-CCxnGJjS.js} +41 -2
- package/dist/sdk-client-DYv-0CfA.js +405 -0
- package/dist/{selectHandler-CdOVuuTa.js → selectHandler-DNb1KJvM.js} +1 -1
- package/dist/{statusHandler-p6CmwgjS.js → statusHandler-B_xmACZl.js} +1 -1
- package/package.json +1 -1
- package/dist/sdk-client-CTvYVr7D.js +0 -32201
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
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
|
|
714
|
+
* `serverName` defaults to {@link DEFAULT_SERVER_NAME}; see {@link buildHarnessConfig}.
|
|
709
715
|
*/
|
|
710
|
-
function renderHarnessCommand(harness, mcpUrl, scope, serverName =
|
|
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
|
|
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 =
|
|
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),
|
|
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
|
|
1590
|
-
|
|
1595
|
+
const url = entryUrl(harness, entry);
|
|
1596
|
+
return url === void 0 ? { present: true } : {
|
|
1591
1597
|
present: true,
|
|
1592
|
-
url
|
|
1598
|
+
url
|
|
1593
1599
|
};
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
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)[
|
|
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
|
-
|
|
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
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
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 =
|
|
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
|
|
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,
|
|
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 =
|
|
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
|
-
},
|
|
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-
|
|
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-
|
|
7
|
-
import { connect } from "./connect-
|
|
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-
|
|
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-
|
|
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";
|