@levr-one/cli 0.11.6 → 0.11.8

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.
@@ -4,12 +4,13 @@ import "./token-refresh-DeusLK8H.js";
4
4
  import { resolveToken } from "./resolve-token-7xb7kG7h.js";
5
5
  import "./workspace-store-CnrxyYRB.js";
6
6
  import { fetchSites } from "./resolve-workspace-CStHisUb.js";
7
+ import { describeWorkspaces, findWorkspaceByKeyOrName, isScopedMcpUrl, resolveMcpUrl, scopedMcpUrl, servedBySessionApi } from "./find-workspace-T4xuXwtS.js";
7
8
  import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
8
9
  import { basename, delimiter, dirname, join, resolve } from "node:path";
9
10
  import { homedir } from "node:os";
10
- import { execFileSync } from "node:child_process";
11
11
  import { applyEdits, modify, parse, printParseErrorCode } from "jsonc-parser";
12
12
  import { TomlError, parse as parse$1 } from "smol-toml";
13
+ import { execFileSync } from "node:child_process";
13
14
 
14
15
  //#region ../mcp-harnesses/dist/format/toml-render.js
15
16
  /**
@@ -773,6 +774,41 @@ function buildServerEntry(harness, mcpUrl, scope = defaultScope(harness), server
773
774
  args
774
775
  } };
775
776
  }
777
+ /** The workspace a scoped MCP URL names (`…/w/<url_key>`), if any. */
778
+ function workspaceKeyOf(url) {
779
+ const m = /\/w\/([^/]+)$/.exec(url.replace(/\/+$/, ""));
780
+ if (!m?.[1]) return void 0;
781
+ try {
782
+ return decodeURIComponent(m[1]);
783
+ } catch {
784
+ return m[1];
785
+ }
786
+ }
787
+ /**
788
+ * Allowed client entry names: 1-64 of `A-Za-z0-9_-`, and never starting with
789
+ * `-`. The name is passed as an argv element to the client's own CLI
790
+ * (`claude mcp add … <name> <url>`), so a leading `-` would be read there as a
791
+ * flag — the ENG-4159 argument-injection family.
792
+ */
793
+ const ENTRY_NAME = /^[A-Za-z0-9_-]{1,64}$/;
794
+ function isValidEntryName(name) {
795
+ return ENTRY_NAME.test(name) && !name.startsWith("-");
796
+ }
797
+ /** Claude Code tool names are `mcp__<entry>__<tool>`; keep that well short. */
798
+ const BESIDE_NAME_MAX = 30;
799
+ /**
800
+ * The name for a second entry beside `serverName`: `<serverName>-<url_key>`,
801
+ * so it says which workspace it is. Undefined — and "Add beside it" is not
802
+ * offered — when the target URL names no workspace, or when that name would
803
+ * be too long or not a valid entry name. It is never shortened: a cut name
804
+ * could drop the workspace, or give two workspaces the same entry.
805
+ */
806
+ function besideName(serverName, url) {
807
+ const key = workspaceKeyOf(url);
808
+ if (!key) return void 0;
809
+ const name = `${serverName}-${key}`;
810
+ return name.length <= BESIDE_NAME_MAX && isValidEntryName(name) ? name : void 0;
811
+ }
776
812
 
777
813
  //#endregion
778
814
  //#region ../mcp-harnesses/dist/node/paths.js
@@ -1611,47 +1647,44 @@ function needsShell(binPath) {
1611
1647
  */
1612
1648
  const SHELL_METACHARACTERS = /[&|<>^"%!`]/;
1613
1649
  /**
1614
- * Run a harness CLI invocation, or explain why it was not run.
1650
+ * Everything decided before a command is spawned, shared by both runners: an
1651
+ * outcome when it must not run, else how to run it.
1615
1652
  *
1616
1653
  * Resolution uses {@link whichPathSync} — a pure `PATH` scan that spawns
1617
1654
  * nothing, so the "is this installed" check can never itself be the thing
1618
1655
  * that hangs. It returns the resolved path rather than a boolean because the
1619
1656
  * extension decides how the command must be launched.
1620
1657
  */
1621
- function runHarnessCommandSync(argv, opts = {}) {
1658
+ function prepare(argv, opts) {
1622
1659
  const env = opts.env ?? defaultEnv();
1623
1660
  const dryRun = opts.dryRun ?? false;
1624
1661
  const [bin, ...args] = argv;
1625
- if (!bin) return {
1662
+ if (!bin) return { skip: {
1626
1663
  executed: false,
1627
1664
  ok: false,
1628
1665
  reason: "empty-command"
1629
- };
1630
- if (dryRun) return {
1666
+ } };
1667
+ if (dryRun) return { skip: {
1631
1668
  executed: false,
1632
1669
  ok: true,
1633
1670
  reason: "dry-run"
1634
- };
1671
+ } };
1635
1672
  const binPath = whichPathSync(bin, env);
1636
- if (!binPath) return {
1673
+ if (!binPath) return { skip: {
1637
1674
  executed: false,
1638
1675
  ok: true,
1639
1676
  reason: "binary-not-found"
1640
- };
1677
+ } };
1641
1678
  const useShell = needsShell(binPath);
1642
- if (useShell && args.some((a) => SHELL_METACHARACTERS.test(a))) return {
1679
+ if (useShell && (binPath.includes("\"") || args.some((a) => SHELL_METACHARACTERS.test(a)))) return { skip: {
1643
1680
  executed: false,
1644
1681
  ok: false,
1645
1682
  reason: "unsafe-argument"
1646
- };
1647
- try {
1648
- execFileSync(binPath, args, {
1649
- encoding: "utf8",
1650
- stdio: [
1651
- "ignore",
1652
- "pipe",
1653
- "pipe"
1654
- ],
1683
+ } };
1684
+ return { spawn: {
1685
+ binPath: useShell ? `"${binPath}"` : binPath,
1686
+ args,
1687
+ options: {
1655
1688
  timeout: COMMAND_TIMEOUT_MS,
1656
1689
  env: {
1657
1690
  ...childVars(env),
@@ -1660,20 +1693,42 @@ function runHarnessCommandSync(argv, opts = {}) {
1660
1693
  },
1661
1694
  cwd: env.cwd,
1662
1695
  ...useShell ? { shell: true } : {}
1696
+ }
1697
+ } };
1698
+ }
1699
+ /** A command that ran and failed (or could not start), as an outcome. */
1700
+ function failureOutcome(err) {
1701
+ const e = err;
1702
+ const exitCode = typeof e.status === "number" ? e.status : typeof e.code === "number" ? e.code : void 0;
1703
+ const raw = typeof e.stderr === "string" ? e.stderr : e.stderr?.toString("utf8") ?? "";
1704
+ return {
1705
+ executed: true,
1706
+ ok: false,
1707
+ ...exitCode === void 0 ? {} : { exitCode },
1708
+ stderr: (raw.trim() || e.message || "command failed").slice(0, MAX_STDERR)
1709
+ };
1710
+ }
1711
+ /** Run a harness CLI invocation, or explain why it was not run. */
1712
+ function runHarnessCommandSync(argv, opts = {}) {
1713
+ const prepared = prepare(argv, opts);
1714
+ if ("skip" in prepared) return prepared.skip;
1715
+ const { binPath, args, options } = prepared.spawn;
1716
+ try {
1717
+ execFileSync(binPath, args, {
1718
+ ...options,
1719
+ encoding: "utf8",
1720
+ stdio: [
1721
+ "ignore",
1722
+ "pipe",
1723
+ "pipe"
1724
+ ]
1663
1725
  });
1664
1726
  return {
1665
1727
  executed: true,
1666
1728
  ok: true
1667
1729
  };
1668
1730
  } catch (err) {
1669
- const e = err;
1670
- const raw = typeof e.stderr === "string" ? e.stderr : e.stderr?.toString("utf8") ?? "";
1671
- return {
1672
- executed: true,
1673
- ok: false,
1674
- ...typeof e.status === "number" ? { exitCode: e.status } : {},
1675
- stderr: (raw.trim() || e.message || "command failed").slice(0, MAX_STDERR)
1676
- };
1731
+ return failureOutcome(err);
1677
1732
  }
1678
1733
  }
1679
1734
 
@@ -1745,8 +1800,16 @@ function canonical(value) {
1745
1800
  if (value !== null && typeof value === "object") return `{${Object.entries(value).filter(([, v]) => v !== void 0).sort(([x], [y]) => x < y ? -1 : x > y ? 1 : 0).map(([k, v]) => `${JSON.stringify(k)}:${canonical(v)}`).join(",")}}`;
1746
1801
  return JSON.stringify(value) ?? "undefined";
1747
1802
  }
1803
+ function drive(steps, run) {
1804
+ let step = steps.next();
1805
+ while (!step.done) step = steps.next(run(step.value.argv, step.value.opts));
1806
+ return step.value;
1807
+ }
1748
1808
  /** Merge (or preview) our MCP into a harness config. */
1749
1809
  function installHarnessSync(harness, mcpUrl, opts = {}) {
1810
+ return drive(installSteps(harness, mcpUrl, opts), opts.runCommand ?? runHarnessCommandSync);
1811
+ }
1812
+ function* installSteps(harness, mcpUrl, opts) {
1750
1813
  const env = opts.env ?? defaultEnv();
1751
1814
  const dryRun = opts.dryRun ?? false;
1752
1815
  const scope = opts.scope ?? defaultScope(harness);
@@ -1785,13 +1848,12 @@ function installHarnessSync(harness, mcpUrl, opts = {}) {
1785
1848
  alreadyConfigured: false,
1786
1849
  reason: "unrecognized-entry"
1787
1850
  };
1788
- if (opts.replaceExisting) return replaceViaCommand(harness, argv, existing$1.url, {
1851
+ if (opts.replaceExisting) return yield* replaceSteps(harness, argv, existing$1.url, {
1789
1852
  env,
1790
1853
  dryRun,
1791
1854
  scope,
1792
1855
  serverName,
1793
- execute: opts.execute ?? true,
1794
- run: opts.runCommand ?? runHarnessCommandSync
1856
+ execute: opts.execute ?? true
1795
1857
  });
1796
1858
  return {
1797
1859
  ...base,
@@ -1802,11 +1864,13 @@ function installHarnessSync(harness, mcpUrl, opts = {}) {
1802
1864
  ...existing$1.url ? { currentUrl: existing$1.url } : {}
1803
1865
  };
1804
1866
  }
1805
- const run = opts.runCommand ?? runHarnessCommandSync;
1806
- const outcome = opts.execute ?? true ? run(argv, {
1807
- env,
1808
- dryRun
1809
- }) : {
1867
+ const outcome = opts.execute ?? true ? yield {
1868
+ argv,
1869
+ opts: {
1870
+ env,
1871
+ dryRun
1872
+ }
1873
+ } : {
1810
1874
  executed: false,
1811
1875
  ok: true,
1812
1876
  reason: "not-requested"
@@ -1815,7 +1879,7 @@ function installHarnessSync(harness, mcpUrl, opts = {}) {
1815
1879
  ...base,
1816
1880
  ok: outcome.ok,
1817
1881
  executed: outcome.executed,
1818
- ...outcome.ok ? {} : { commandError: outcome.stderr },
1882
+ ...outcome.ok ? {} : { commandError: notRunError(outcome) },
1819
1883
  alreadyConfigured: false
1820
1884
  };
1821
1885
  }
@@ -1907,7 +1971,14 @@ function installHarnessSync(harness, mcpUrl, opts = {}) {
1907
1971
  * second command must not leave the user with no entry at all without saying
1908
1972
  * so.
1909
1973
  */
1910
- function replaceViaCommand(harness, addArgv, previousUrl, ctx) {
1974
+ /**
1975
+ * What went wrong with a failed command: its stderr, else why it was never
1976
+ * run — so a refusal never reads as "unknown reason" (ENG-6737).
1977
+ */
1978
+ function notRunError(outcome) {
1979
+ return outcome.stderr ?? (outcome.reason ? `not run: ${outcome.reason}` : void 0);
1980
+ }
1981
+ function* replaceSteps(harness, addArgv, previousUrl, ctx) {
1911
1982
  const removeArgv = removeCommandArgv(ctx.scope, ctx.serverName);
1912
1983
  const base = {
1913
1984
  wrote: false,
@@ -1923,11 +1994,15 @@ function replaceViaCommand(harness, addArgv, previousUrl, ctx) {
1923
1994
  ok: true,
1924
1995
  executed: false
1925
1996
  };
1926
- const removed = ctx.run(removeArgv, { env: ctx.env });
1997
+ const removed = yield {
1998
+ argv: removeArgv,
1999
+ opts: { env: ctx.env }
2000
+ };
1927
2001
  if (!removed.executed) return {
1928
2002
  ...base,
1929
2003
  ok: removed.ok,
1930
- executed: false
2004
+ executed: false,
2005
+ ...removed.ok ? {} : { commandError: notRunError(removed) }
1931
2006
  };
1932
2007
  if (!removed.ok) return {
1933
2008
  ...base,
@@ -1935,21 +2010,26 @@ function replaceViaCommand(harness, addArgv, previousUrl, ctx) {
1935
2010
  executed: true,
1936
2011
  commandError: `could not remove the existing entry: ${removed.stderr ?? "unknown error"}`
1937
2012
  };
1938
- const added = ctx.run(addArgv, { env: ctx.env });
2013
+ const added = yield {
2014
+ argv: addArgv,
2015
+ opts: { env: ctx.env }
2016
+ };
1939
2017
  if (added.ok) return {
1940
2018
  ...base,
1941
2019
  ok: true,
1942
2020
  executed: true
1943
2021
  };
1944
- const restoreArgv = renderHarnessCommand(harness, previousUrl, ctx.scope, ctx.serverName);
1945
- const restore = ctx.run(restoreArgv, { env: ctx.env });
2022
+ const restore = yield {
2023
+ argv: renderHarnessCommand(harness, previousUrl, ctx.scope, ctx.serverName),
2024
+ opts: { env: ctx.env }
2025
+ };
1946
2026
  return {
1947
2027
  ...base,
1948
2028
  ok: false,
1949
2029
  executed: true,
1950
- commandError: added.stderr ?? "unknown error",
2030
+ commandError: notRunError(added) ?? "unknown error",
1951
2031
  restored: restore.ok && restore.executed,
1952
- ...restore.ok && restore.executed ? {} : { restoreError: restore.stderr ?? "the restore command did not run" }
2032
+ ...restore.ok && restore.executed ? {} : { restoreError: notRunError(restore) ?? "the restore command did not run" }
1953
2033
  };
1954
2034
  }
1955
2035
  /**
@@ -2025,150 +2105,6 @@ function writeConfigAtomic(path$1, text) {
2025
2105
  }
2026
2106
  }
2027
2107
 
2028
- //#endregion
2029
- //#region src/mcp/url.ts
2030
- const KNOWN_MCP_URLS = {
2031
- "api.levr.one": "https://ai.levr.one/api/v1/mcp",
2032
- "api.levr.now": "https://ai.levr.now/api/v1/mcp"
2033
- };
2034
- /**
2035
- * Resolve the MCP server URL: `--url` flag > `LEVR_MCP_URL` env > derived
2036
- * from the resolved API URL (which itself honors `LEVR_URL` > the URL stored
2037
- * at login > production default, ENG-2361). Known Levr hosts map to their
2038
- * app-host MCP resource; anything else (localhost dev stacks, custom
2039
- * deployments) derives `<api-url>/v1/mcp`.
2040
- */
2041
- function resolveMcpUrl(flagUrl) {
2042
- const resolved = resolveRaw(flagUrl);
2043
- assertUsableMcpUrl(resolved.url, resolved.source);
2044
- return resolved;
2045
- }
2046
- function resolveRaw(flagUrl) {
2047
- if (flagUrl) return {
2048
- url: stripSlash(flagUrl),
2049
- source: "flag"
2050
- };
2051
- const envVar = process.env["LEVR_MCP_URL"];
2052
- if (envVar) return {
2053
- url: stripSlash(envVar),
2054
- source: "env:LEVR_MCP_URL"
2055
- };
2056
- const apiUrl = getApiUrl();
2057
- return {
2058
- url: knownMcpUrl(apiUrl) ?? `${apiUrl}/v1/mcp`,
2059
- source: `derived:${apiUrl}`
2060
- };
2061
- }
2062
- /** Name the input the user has to change, not our internal source tag. */
2063
- function sourceLabel(source) {
2064
- if (source === "flag") return "--url";
2065
- if (source === "env:LEVR_MCP_URL") return "LEVR_MCP_URL";
2066
- return `the API URL (${source.slice(8)})`;
2067
- }
2068
- /**
2069
- * Reject a URL that must not reach a client config or a spawned CLI (ENG-4159).
2070
- *
2071
- * Three things this stops:
2072
- *
2073
- * 1. **No scheme.** `claude mcp add --transport http --scope user levr <value>`
2074
- * is spawned with fixed argv since ENG-4156, so a value like `--foo` is read
2075
- * by that CLI as a FLAG rather than as its URL operand — argument injection.
2076
- * Requiring a parseable absolute URL removes the shape entirely.
2077
- * 2. **A non-http(s) scheme.** `file:` / `javascript:` is never a valid MCP
2078
- * endpoint, but would be written verbatim into every client's config.
2079
- * 3. **Embedded credentials.** `https://user:pass@host` would be written into
2080
- * a config file — and since ENG-4151 a config file can be `--scope project`,
2081
- * which the user is told to COMMIT. That turns a bad URL into a secret in
2082
- * git history. MCP authenticates via OAuth in the browser; credentials in
2083
- * the URL are never needed.
2084
- */
2085
- function assertUsableMcpUrl(url, source) {
2086
- const from = sourceLabel(source);
2087
- let parsed;
2088
- try {
2089
- parsed = new URL(url);
2090
- } catch {
2091
- throw new Error(`Invalid MCP URL from ${from}: ${JSON.stringify(url)} is not a URL. Expected an absolute http(s) URL, e.g. https://ai.levr.one/api/v1/mcp`);
2092
- }
2093
- if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new Error(`Invalid MCP URL from ${from}: ${JSON.stringify(url)} uses "${parsed.protocol}" — only http and https are supported.`);
2094
- if (!parsed.hostname) throw new Error(`Invalid MCP URL from ${from}: ${JSON.stringify(url)} has no host.`);
2095
- 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.`);
2096
- }
2097
- /**
2098
- * Is this MCP URL served by the API the CLI is logged in to? `--workspace`
2099
- * and the workspace picker read that API's workspaces, so pinning a URL on
2100
- * another server to one of them would name the wrong workspace, or none
2101
- * (ENG-6300 D6).
2102
- */
2103
- function servedBySessionApi(mcpUrl) {
2104
- const apiUrl = getApiUrl();
2105
- const derived = knownMcpUrl(apiUrl) ?? `${apiUrl}/v1/mcp`;
2106
- try {
2107
- return new URL(mcpUrl).origin === new URL(derived).origin;
2108
- } catch {
2109
- return false;
2110
- }
2111
- }
2112
- /** `…/v1/mcp/w/<url_key>`: the URL already names one workspace (ENG-6300). */
2113
- const SCOPED_SUFFIX = /\/w\/[^/]+$/;
2114
- /** The workspace a scoped MCP URL names (`…/w/<url_key>`), if any. */
2115
- function workspaceKeyOf(url) {
2116
- const m = /\/w\/([^/]+)$/.exec(stripSlash(url));
2117
- if (!m?.[1]) return void 0;
2118
- try {
2119
- return decodeURIComponent(m[1]);
2120
- } catch {
2121
- return m[1];
2122
- }
2123
- }
2124
- /** Does this MCP URL already name a workspace? */
2125
- function isScopedMcpUrl(url) {
2126
- return SCOPED_SUFFIX.test(stripSlash(url));
2127
- }
2128
- /**
2129
- * The MCP URL for one workspace, `<base>/w/<url_key>` (ENG-6300 D6). A base
2130
- * that already names a workspace is an error rather than a silent re-point:
2131
- * `--url …/w/a --workspace b` asks for two different workspaces at once.
2132
- */
2133
- function scopedMcpUrl(base, urlKey) {
2134
- const root = stripSlash(base);
2135
- 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.`);
2136
- return `${root}/w/${encodeURIComponent(urlKey)}`;
2137
- }
2138
- function knownMcpUrl(apiUrl) {
2139
- try {
2140
- return KNOWN_MCP_URLS[new URL(apiUrl).host];
2141
- } catch {
2142
- return;
2143
- }
2144
- }
2145
- function stripSlash(url) {
2146
- return url.replace(/\/+$/, "");
2147
- }
2148
-
2149
- //#endregion
2150
- //#region src/workspace/find-workspace.ts
2151
- /** ` - Name (url_key)` per workspace, for error messages and hints. */
2152
- function describeWorkspaces(sites) {
2153
- return sites.map((s) => ` - ${s.workspace_name} (${s.workspace_url_key})`).join("\n");
2154
- }
2155
- /**
2156
- * An exact `url_key` wins; otherwise a case-insensitive name that matches
2157
- * exactly one workspace. Anything else throws, listing the choices.
2158
- */
2159
- function findWorkspaceByKeyOrName(sites, input) {
2160
- const wanted = input.trim();
2161
- if (!wanted) throw new Error("--workspace needs a workspace name or url_key.");
2162
- const byKey = sites.find((s) => s.workspace_url_key === wanted);
2163
- if (byKey) return byKey;
2164
- const lower = wanted.toLowerCase();
2165
- const byName = sites.filter((s) => s.workspace_name.toLowerCase() === lower);
2166
- if (byName.length === 1) return byName[0];
2167
- if (byName.length > 1) throw new Error(`"${wanted}" names more than one workspace. Pass its url_key instead:\n` + describeWorkspaces(byName));
2168
- if (sites.length === 0) throw new Error("Your account has no workspaces.");
2169
- throw new Error(`No workspace "${wanted}" in your account. Choose one of:\n` + describeWorkspaces(sites));
2170
- }
2171
-
2172
2108
  //#endregion
2173
2109
  //#region src/mcp/run.ts
2174
2110
  /**
@@ -2188,16 +2124,6 @@ function entryState(s, url) {
2188
2124
  return s.currentUrl === void 0 ? "unrecognized" : "other-url";
2189
2125
  }
2190
2126
  /**
2191
- * Allowed client entry names: 1-64 of `A-Za-z0-9_-`, and never starting with
2192
- * `-`. The name is passed as an argv element to the client's own CLI
2193
- * (`claude mcp add … <name> <url>`), so a leading `-` would be read there as a
2194
- * flag — the ENG-4159 argument-injection family.
2195
- */
2196
- const ENTRY_NAME = /^[A-Za-z0-9_-]{1,64}$/;
2197
- function isValidEntryName(name) {
2198
- return ENTRY_NAME.test(name) && !name.startsWith("-");
2199
- }
2200
- /**
2201
2127
  * The scope an install would land in: the requested one, or, for a harness
2202
2128
  * that cannot do it, its own default, which is where the fallback goes.
2203
2129
  */
@@ -2261,21 +2187,6 @@ function clientChoices(detected, scope, url, serverName = DEFAULT_SERVER_NAME) {
2261
2187
  };
2262
2188
  });
2263
2189
  }
2264
- /** Claude Code tool names are `mcp__<entry>__<tool>`; keep that well short. */
2265
- const BESIDE_NAME_MAX = 30;
2266
- /**
2267
- * The name for a second entry beside `serverName`: `<serverName>-<url_key>`,
2268
- * so it says which workspace it is. Undefined — and "Add beside it" is not
2269
- * offered — when the target URL names no workspace, or when that name would
2270
- * be too long or not a valid entry name. It is never shortened: a cut name
2271
- * could drop the workspace, or give two workspaces the same entry.
2272
- */
2273
- function besideName(serverName, url) {
2274
- const key = workspaceKeyOf(url);
2275
- if (!key) return void 0;
2276
- const name = `${serverName}-${key}`;
2277
- return name.length <= BESIDE_NAME_MAX && isValidEntryName(name) ? name : void 0;
2278
- }
2279
2190
  /**
2280
2191
  * The selected clients whose entry is not this URL's: each needs a decision —
2281
2192
  * switch it, add beside it, or leave it — before anything is installed
@@ -2497,22 +2408,37 @@ function depsFor(entry) {
2497
2408
  };
2498
2409
  }
2499
2410
  /**
2411
+ * The login that makes `--workspace <x>` work. With the hint, the login
2412
+ * skips the workspace picker for that workspace and selects it (ENG-6694).
2413
+ * Double quotes: the one quoting POSIX shells and cmd.exe both take.
2414
+ */
2415
+ function loginCommandFor(workspace) {
2416
+ if (workspace === void 0) return "levr auth login";
2417
+ return `levr auth login --workspace ${/^[A-Za-z0-9_.@:/-]+$/.test(workspace) ? workspace : `"${workspace.replace(/"/g, "\\\"")}"`}`;
2418
+ }
2419
+ /**
2500
2420
  * The workspaces of the logged-in user. Reads the session only; it never
2501
- * reads or changes the workspace the CLI has selected.
2421
+ * reads or changes the workspace the CLI has selected. `workspace` is the
2422
+ * `--workspace` value, for the refusal to name the exact login to run.
2502
2423
  */
2503
- async function loadSites() {
2424
+ async function loadSites(workspace) {
2504
2425
  let auth;
2505
2426
  try {
2506
2427
  auth = await resolveToken();
2507
2428
  } catch (err) {
2429
+ const cause = err instanceof Error ? err.message : "Not authenticated. Run 'levr auth login' first.";
2430
+ if (workspace === void 0) return {
2431
+ ok: false,
2432
+ error: cause
2433
+ };
2508
2434
  return {
2509
2435
  ok: false,
2510
- error: err instanceof Error ? err.message : "Not authenticated. Run 'levr auth login' first."
2436
+ error: `${cause.replace(" or set LEVR_TOKEN environment variable", "")}\nTo pin this entry, log in naming the workspace: ${loginCommandFor(workspace)} (add --device-code on a headless machine), then re-run this command.`
2511
2437
  };
2512
2438
  }
2513
2439
  if (auth.type === "pat") return {
2514
2440
  ok: false,
2515
- error: "--workspace needs a login session, not a personal access token. Run 'levr auth login'."
2441
+ error: `--workspace needs a login session, not a personal access token. Run '${loginCommandFor(workspace)}'.`
2516
2442
  };
2517
2443
  configureClient(auth);
2518
2444
  try {
@@ -2577,7 +2503,7 @@ async function mcpAddHandler(flags) {
2577
2503
  this.process.exitCode = 1;
2578
2504
  return;
2579
2505
  }
2580
- const loaded = await loadSites();
2506
+ const loaded = await loadSites(flags.workspace);
2581
2507
  if (!loaded.ok) {
2582
2508
  this.logger.error(loaded.error);
2583
2509
  this.process.exitCode = 1;
package/dist/cli.js CHANGED
@@ -209,7 +209,7 @@ Examples:
209
209
  aliases: { y: "yes" }
210
210
  },
211
211
  loader: async () => {
212
- const { mcpAddHandler } = await import("./addHandler-DJqJGqz3.js");
212
+ const { mcpAddHandler } = await import("./addHandler-B_3UmMbj.js");
213
213
  return mcpAddHandler;
214
214
  }
215
215
  });
@@ -225,9 +225,13 @@ By default, opens a browser for PKCE-based authentication.
225
225
  For headless environments (SSH, containers), use --device-code
226
226
  to authenticate via a code displayed in the terminal.
227
227
 
228
+ With --workspace, the login selects that workspace for the CLI, and a
229
+ url_key also skips the workspace picker in the browser.
230
+
228
231
  Examples:
229
- levr auth login # Browser-based PKCE login
230
- levr auth login --device-code # Device flow for SSH/headless`
232
+ levr auth login # Browser-based PKCE login
233
+ levr auth login --device-code # Device flow for SSH/headless
234
+ levr auth login --workspace acme # Skip the picker; select acme`
231
235
  },
232
236
  parameters: {
233
237
  flags: {
@@ -242,12 +246,19 @@ Examples:
242
246
  brief: "API base URL (default: https://api.levr.one)",
243
247
  placeholder: "url",
244
248
  optional: true
249
+ },
250
+ workspace: {
251
+ kind: "parsed",
252
+ parse: String,
253
+ brief: "Workspace to select, by url_key or name (a url_key also skips the workspace picker)",
254
+ placeholder: "url_key|name",
255
+ optional: true
245
256
  }
246
257
  },
247
258
  aliases: { d: "device-code" }
248
259
  },
249
260
  loader: async () => {
250
- const { loginHandler } = await import("./loginHandler-DzyfdKc2.js");
261
+ const { loginHandler } = await import("./loginHandler-DE0L1ehF.js");
251
262
  return loginHandler;
252
263
  }
253
264
  });
@@ -266,7 +277,7 @@ Examples:
266
277
  },
267
278
  parameters: {},
268
279
  loader: async () => {
269
- const { logoutHandler } = await import("./logoutHandler-TSQlZGpU.js");
280
+ const { logoutHandler } = await import("./logoutHandler-S4TFYFwz.js");
270
281
  return logoutHandler;
271
282
  }
272
283
  });
@@ -286,7 +297,7 @@ Examples:
286
297
  },
287
298
  parameters: {},
288
299
  loader: async () => {
289
- const { statusHandler } = await import("./statusHandler-B_xmACZl.js");
300
+ const { statusHandler } = await import("./statusHandler-BNiWlC7e.js");
290
301
  return statusHandler;
291
302
  }
292
303
  });
@@ -415,7 +426,7 @@ levr attach --manifest or later uploads.`
415
426
  }
416
427
  },
417
428
  loader: async () => {
418
- const { pushHandler } = await import("./pushHandler-DRFf9Zeu.js");
429
+ const { pushHandler } = await import("./pushHandler-DdNyVQ5t.js");
419
430
  return pushHandler;
420
431
  }
421
432
  });
@@ -535,7 +546,7 @@ Examples:
535
546
  }
536
547
  },
537
548
  loader: async () => {
538
- const { importHandler } = await import("./importHandler-D2_Ol-Vp.js");
549
+ const { importHandler } = await import("./importHandler-zw5GKb-S.js");
539
550
  return importHandler;
540
551
  }
541
552
  });
@@ -556,7 +567,7 @@ Examples:
556
567
  },
557
568
  parameters: {},
558
569
  loader: async () => {
559
- const { listHandler } = await import("./listHandler-hmLz8zm8.js");
570
+ const { listHandler } = await import("./listHandler-YA-0RkwS.js");
560
571
  return listHandler;
561
572
  }
562
573
  });
@@ -594,7 +605,7 @@ Examples:
594
605
  flags: {}
595
606
  },
596
607
  loader: async () => {
597
- const { selectHandler } = await import("./selectHandler-DNb1KJvM.js");
608
+ const { selectHandler } = await import("./selectHandler-Cejme5nY.js");
598
609
  return selectHandler;
599
610
  }
600
611
  });
@@ -611,7 +622,7 @@ Examples:
611
622
  },
612
623
  parameters: {},
613
624
  loader: async () => {
614
- const { currentHandler } = await import("./currentHandler-B_sWXNeW.js");
625
+ const { currentHandler } = await import("./currentHandler-u89l068v.js");
615
626
  return currentHandler;
616
627
  }
617
628
  });
@@ -680,7 +691,7 @@ Examples:
680
691
  }
681
692
  },
682
693
  loader: async () => {
683
- const { gatesRunHandler } = await import("./runHandler-9tfiD3ZK.js");
694
+ const { gatesRunHandler } = await import("./runHandler-BFDjtxbt.js");
684
695
  return gatesRunHandler;
685
696
  }
686
697
  });
@@ -869,14 +880,14 @@ Examples:
869
880
  }
870
881
  },
871
882
  loader: async () => {
872
- const { resultAppendHandler } = await import("./appendHandler-9WDDNkkA.js");
883
+ const { resultAppendHandler } = await import("./appendHandler-KVsJMxpX.js");
873
884
  return resultAppendHandler;
874
885
  }
875
886
  });
876
887
 
877
888
  //#endregion
878
889
  //#region package.json
879
- var version = "0.11.6";
890
+ var version = "0.11.8";
880
891
 
881
892
  //#endregion
882
893
  //#region src/app.ts
@@ -0,0 +1,147 @@
1
+ import { getApiUrl } from "./env-CdwyPHGV.js";
2
+
3
+ //#region src/mcp/url.ts
4
+ const KNOWN_MCP_URLS = {
5
+ "api.levr.one": "https://ai.levr.one/api/v1/mcp",
6
+ "api.levr.now": "https://ai.levr.now/api/v1/mcp"
7
+ };
8
+ /**
9
+ * Resolve the MCP server URL: `--url` flag > `LEVR_MCP_URL` env > derived
10
+ * from the resolved API URL (which itself honors `LEVR_URL` > the URL stored
11
+ * at login > production default, ENG-2361). Known Levr hosts map to their
12
+ * app-host MCP resource; anything else (localhost dev stacks, custom
13
+ * deployments) derives `<api-url>/v1/mcp`.
14
+ */
15
+ function resolveMcpUrl(flagUrl) {
16
+ const resolved = resolveRaw(flagUrl);
17
+ assertUsableMcpUrl(resolved.url, resolved.source);
18
+ return resolved;
19
+ }
20
+ function resolveRaw(flagUrl) {
21
+ if (flagUrl) return {
22
+ url: stripSlash(flagUrl),
23
+ source: "flag"
24
+ };
25
+ const envVar = process.env["LEVR_MCP_URL"];
26
+ if (envVar) return {
27
+ url: stripSlash(envVar),
28
+ source: "env:LEVR_MCP_URL"
29
+ };
30
+ const apiUrl = getApiUrl();
31
+ return {
32
+ url: knownMcpUrl(apiUrl) ?? `${apiUrl}/v1/mcp`,
33
+ source: `derived:${apiUrl}`
34
+ };
35
+ }
36
+ /** Name the input the user has to change, not our internal source tag. */
37
+ function sourceLabel(source) {
38
+ if (source === "flag") return "--url";
39
+ if (source === "env:LEVR_MCP_URL") return "LEVR_MCP_URL";
40
+ return `the API URL (${source.slice(8)})`;
41
+ }
42
+ /**
43
+ * Reject a URL that must not reach a client config or a spawned CLI (ENG-4159).
44
+ *
45
+ * Three things this stops:
46
+ *
47
+ * 1. **No scheme.** `claude mcp add --transport http --scope user levr <value>`
48
+ * is spawned with fixed argv since ENG-4156, so a value like `--foo` is read
49
+ * by that CLI as a FLAG rather than as its URL operand — argument injection.
50
+ * Requiring a parseable absolute URL removes the shape entirely.
51
+ * 2. **A non-http(s) scheme.** `file:` / `javascript:` is never a valid MCP
52
+ * endpoint, but would be written verbatim into every client's config.
53
+ * 3. **Embedded credentials.** `https://user:pass@host` would be written into
54
+ * a config file — and since ENG-4151 a config file can be `--scope project`,
55
+ * which the user is told to COMMIT. That turns a bad URL into a secret in
56
+ * git history. MCP authenticates via OAuth in the browser; credentials in
57
+ * the URL are never needed.
58
+ */
59
+ function assertUsableMcpUrl(url, source) {
60
+ const from = sourceLabel(source);
61
+ let parsed;
62
+ try {
63
+ parsed = new URL(url);
64
+ } catch {
65
+ throw new Error(`Invalid MCP URL from ${from}: ${JSON.stringify(url)} is not a URL. Expected an absolute http(s) URL, e.g. https://ai.levr.one/api/v1/mcp`);
66
+ }
67
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new Error(`Invalid MCP URL from ${from}: ${JSON.stringify(url)} uses "${parsed.protocol}" — only http and https are supported.`);
68
+ if (!parsed.hostname) throw new Error(`Invalid MCP URL from ${from}: ${JSON.stringify(url)} has no host.`);
69
+ 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.`);
70
+ }
71
+ /**
72
+ * Is this MCP URL served by the API the CLI is logged in to? `--workspace`
73
+ * and the workspace picker read that API's workspaces, so pinning a URL on
74
+ * another server to one of them would name the wrong workspace, or none
75
+ * (ENG-6300 D6).
76
+ */
77
+ function servedBySessionApi(mcpUrl) {
78
+ const apiUrl = getApiUrl();
79
+ const derived = knownMcpUrl(apiUrl) ?? `${apiUrl}/v1/mcp`;
80
+ try {
81
+ return new URL(mcpUrl).origin === new URL(derived).origin;
82
+ } catch {
83
+ return false;
84
+ }
85
+ }
86
+ /** `…/v1/mcp/w/<url_key>`: the URL already names one workspace (ENG-6300). */
87
+ const SCOPED_SUFFIX = /\/w\/[^/]+$/;
88
+ /** Does this MCP URL already name a workspace? */
89
+ function isScopedMcpUrl(url) {
90
+ return SCOPED_SUFFIX.test(stripSlash(url));
91
+ }
92
+ /**
93
+ * The MCP URL for one workspace, `<base>/w/<url_key>` (ENG-6300 D6). A base
94
+ * that already names a workspace is an error rather than a silent re-point:
95
+ * `--url …/w/a --workspace b` asks for two different workspaces at once.
96
+ */
97
+ function scopedMcpUrl(base, urlKey) {
98
+ const root = stripSlash(base);
99
+ 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.`);
100
+ return `${root}/w/${encodeURIComponent(urlKey)}`;
101
+ }
102
+ function knownMcpUrl(apiUrl) {
103
+ try {
104
+ return KNOWN_MCP_URLS[new URL(apiUrl).host];
105
+ } catch {
106
+ return;
107
+ }
108
+ }
109
+ function stripSlash(url) {
110
+ return url.replace(/\/+$/, "");
111
+ }
112
+
113
+ //#endregion
114
+ //#region src/workspace/find-workspace.ts
115
+ /** The url_key syntax the backend and auth-web accept (reserved keys aside). */
116
+ const URL_KEY_SYNTAX = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/;
117
+ /**
118
+ * Could this `--workspace` value be a url_key? Only a url_key can name a
119
+ * workspace before a session exists, so only one can skip the login picker
120
+ * (ENG-6694); a name is resolved once the token has arrived.
121
+ */
122
+ function looksLikeUrlKey(input) {
123
+ return input.length <= 100 && URL_KEY_SYNTAX.test(input);
124
+ }
125
+ /** ` - Name (url_key)` per workspace, for error messages and hints. */
126
+ function describeWorkspaces(sites) {
127
+ return sites.map((s) => ` - ${s.workspace_name} (${s.workspace_url_key})`).join("\n");
128
+ }
129
+ /**
130
+ * An exact `url_key` wins; otherwise a case-insensitive name that matches
131
+ * exactly one workspace. Anything else throws, listing the choices.
132
+ */
133
+ function findWorkspaceByKeyOrName(sites, input) {
134
+ const wanted = input.trim();
135
+ if (!wanted) throw new Error("--workspace needs a workspace name or url_key.");
136
+ const byKey = sites.find((s) => s.workspace_url_key === wanted);
137
+ if (byKey) return byKey;
138
+ const lower = wanted.toLowerCase();
139
+ const byName = sites.filter((s) => s.workspace_name.toLowerCase() === lower);
140
+ if (byName.length === 1) return byName[0];
141
+ if (byName.length > 1) throw new Error(`"${wanted}" names more than one workspace. Pass its url_key instead:\n` + describeWorkspaces(byName));
142
+ if (sites.length === 0) throw new Error("Your account has no workspaces.");
143
+ throw new Error(`No workspace "${wanted}" in your account. Choose one of:\n` + describeWorkspaces(sites));
144
+ }
145
+
146
+ //#endregion
147
+ export { describeWorkspaces, findWorkspaceByKeyOrName, isScopedMcpUrl, looksLikeUrlKey, resolveMcpUrl, scopedMcpUrl, servedBySessionApi };
@@ -1,12 +1,13 @@
1
1
  import { configureClient } from "./sdk-client-DYv-0CfA.js";
2
2
  import { CLI_CLIENT_ID, getApiUrl, getAuthUrl, setSessionApiUrl, writeCredentials } from "./env-CdwyPHGV.js";
3
- import "./workspace-store-CnrxyYRB.js";
4
- import { autoSelectWorkspace } from "./resolve-workspace-CStHisUb.js";
3
+ import { saveWorkspace } from "./workspace-store-CnrxyYRB.js";
4
+ import { autoSelectWorkspace, fetchSites } from "./resolve-workspace-CStHisUb.js";
5
5
  import { sleep } from "./sleep-BpJ39Ypm.js";
6
+ import { findWorkspaceByKeyOrName, looksLikeUrlKey, resolveMcpUrl, scopedMcpUrl } from "./find-workspace-T4xuXwtS.js";
6
7
  import chalk from "chalk";
7
8
  import { createHash, randomBytes } from "node:crypto";
8
9
  import { createServer } from "node:http";
9
- import { URL } from "node:url";
10
+ import { URL as URL$1 } from "node:url";
10
11
 
11
12
  //#region src/auth/pkce.ts
12
13
  /**
@@ -103,7 +104,7 @@ function startCallbackServer(options = {}) {
103
104
  rejectCode = reject;
104
105
  });
105
106
  const server = createServer((req, res) => {
106
- const url = new URL(req.url ?? "/", `http://127.0.0.1`);
107
+ const url = new URL$1(req.url ?? "/", `http://127.0.0.1`);
107
108
  if (url.pathname === "/callback") {
108
109
  const code = url.searchParams.get("code");
109
110
  const error = url.searchParams.get("error");
@@ -225,20 +226,21 @@ function formatCountdown(ms) {
225
226
  async function loginHandler(flags) {
226
227
  await performLogin(this, {
227
228
  deviceCode: flags["device-code"],
228
- url: flags.url
229
+ url: flags.url,
230
+ workspace: flags.workspace
229
231
  });
230
232
  }
231
233
  /**
232
234
  * Run the login flow (PKCE or device code). Reusable outside the Stricli
233
- * handler (levr init composes it). Returns true on success; on failure the
234
- * error has been printed and `exitCode` set.
235
+ * handler. Returns true on success; on failure the error has been printed
236
+ * and `exitCode` set.
235
237
  */
236
238
  async function performLogin(ctx, options) {
237
239
  if (options.url) setSessionApiUrl(options.url);
238
- if (options.deviceCode) return deviceCodeLogin(ctx);
239
- return pkceLogin(ctx);
240
+ if (options.deviceCode) return deviceCodeLogin(ctx, options.workspace);
241
+ return pkceLogin(ctx, options.workspace);
240
242
  }
241
- async function pkceLogin(ctx) {
243
+ async function pkceLogin(ctx, workspace) {
242
244
  const apiUrl = getApiUrl();
243
245
  let authUrl;
244
246
  try {
@@ -248,6 +250,12 @@ async function pkceLogin(ctx) {
248
250
  ctx.process.exitCode = 1;
249
251
  return false;
250
252
  }
253
+ let resource;
254
+ if (workspace !== void 0 && looksLikeUrlKey(workspace)) try {
255
+ resource = scopedMcpUrl(resolveMcpUrl().url, workspace);
256
+ } catch (err) {
257
+ ctx.logger.debug(`Workspace picker not skipped: ${err instanceof Error ? err.message : String(err)}`);
258
+ }
251
259
  const codeVerifier = generateCodeVerifier();
252
260
  const codeChallenge = generateCodeChallenge(codeVerifier);
253
261
  const state = randomBytes(16).toString("hex");
@@ -265,6 +273,7 @@ async function pkceLogin(ctx) {
265
273
  scope: "read:own write:own",
266
274
  state
267
275
  });
276
+ if (resource) params.append("resource", resource);
268
277
  const authorizeUrl = `${authUrl}/auth/oauth/authorize?${params.toString()}`;
269
278
  ctx.process.stdout.write("Opening browser to authenticate...\n\n");
270
279
  ctx.process.stdout.write(` If the browser doesn't open, visit:\n ${chalk.cyan(authorizeUrl)}\n\n`);
@@ -306,7 +315,7 @@ async function pkceLogin(ctx) {
306
315
  const errBody = await tokenRes.text();
307
316
  throw new Error(`Token exchange failed (${tokenRes.status}): ${errBody}`);
308
317
  }
309
- return await saveTokensAndFinish(ctx, await tokenRes.json());
318
+ return await saveTokensAndFinish(ctx, await tokenRes.json(), workspace);
310
319
  } catch (err) {
311
320
  ctx.process.stdout.write("\n");
312
321
  ctx.logger.error(err instanceof Error ? err.message : "Token exchange failed.");
@@ -314,10 +323,16 @@ async function pkceLogin(ctx) {
314
323
  return false;
315
324
  }
316
325
  }
317
- async function deviceCodeLogin(ctx) {
326
+ async function deviceCodeLogin(ctx, workspace) {
318
327
  try {
319
328
  const device = await requestDeviceCode();
320
- ctx.process.stdout.write(`\nVisit: ${chalk.cyan(device.verification_uri)}\n`);
329
+ let visit = device.verification_uri;
330
+ if (workspace !== void 0 && looksLikeUrlKey(workspace)) try {
331
+ const url = new URL(device.verification_uri);
332
+ url.searchParams.set("workspace", workspace);
333
+ visit = url.toString();
334
+ } catch {}
335
+ ctx.process.stdout.write(`\nVisit: ${chalk.cyan(visit)}\n`);
321
336
  ctx.process.stdout.write(`Enter code: ${chalk.bold.yellow(device.user_code)}\n\n`);
322
337
  const deviceDeadline = Date.now() + device.expires_in * 1e3;
323
338
  const deviceTimer = setInterval(() => {
@@ -337,7 +352,7 @@ async function deviceCodeLogin(ctx) {
337
352
  throw err;
338
353
  }
339
354
  clearInterval(deviceTimer);
340
- return await saveTokensAndFinish(ctx, tokenData);
355
+ return await saveTokensAndFinish(ctx, tokenData, workspace);
341
356
  } catch (err) {
342
357
  ctx.process.stdout.write("\n");
343
358
  ctx.logger.error(err instanceof Error ? err.message : "Device flow failed.");
@@ -345,7 +360,7 @@ async function deviceCodeLogin(ctx) {
345
360
  return false;
346
361
  }
347
362
  }
348
- async function saveTokensAndFinish(ctx, tokenData) {
363
+ async function saveTokensAndFinish(ctx, tokenData, workspace) {
349
364
  const apiUrl = getApiUrl();
350
365
  configureClient({
351
366
  token: tokenData.access_token,
@@ -358,6 +373,26 @@ async function saveTokensAndFinish(ctx, tokenData) {
358
373
  ctx.process.exitCode = 1;
359
374
  return false;
360
375
  }
376
+ let selected;
377
+ if (workspace !== void 0) {
378
+ let sites;
379
+ try {
380
+ sites = await fetchSites();
381
+ } catch (err) {
382
+ ctx.process.stdout.write("\n");
383
+ ctx.logger.error(`${err instanceof Error ? err.message : "Could not list your workspaces."}\nNothing was saved. Try again.`);
384
+ ctx.process.exitCode = 1;
385
+ return false;
386
+ }
387
+ try {
388
+ selected = findWorkspaceByKeyOrName(sites, workspace);
389
+ } catch (err) {
390
+ ctx.process.stdout.write("\n");
391
+ ctx.logger.error(`${err instanceof Error ? err.message : "Unknown workspace."}\nNothing was saved. Run 'levr auth login --workspace <url_key>' with one of them, or 'levr auth login' to choose later.`);
392
+ ctx.process.exitCode = 1;
393
+ return false;
394
+ }
395
+ }
361
396
  writeCredentials({
362
397
  version: 1,
363
398
  api_url: apiUrl,
@@ -373,6 +408,11 @@ async function saveTokensAndFinish(ctx, tokenData) {
373
408
  ctx.process.stdout.write(` ${chalk.green("done")}\n\n`);
374
409
  ctx.logger.success(`Logged in as ${chalk.bold(user.email)}`);
375
410
  ctx.process.stdout.write("Credentials saved.\n");
411
+ if (selected) {
412
+ saveWorkspace(selected.workspace_id);
413
+ ctx.process.stdout.write(`Workspace: ${chalk.bold(selected.workspace_name)} (${selected.workspace_url_key})\n`);
414
+ return true;
415
+ }
376
416
  const wsResult = await autoSelectWorkspace();
377
417
  if (wsResult.kind === "single") ctx.process.stdout.write(`Workspace: ${chalk.bold(wsResult.workspaceName)}\n`);
378
418
  else if (wsResult.kind === "multiple") ctx.process.stdout.write(`\n${wsResult.count} workspaces available. Run ${chalk.cyan("'levr workspace list'")} to see them.\n`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@levr-one/cli",
3
- "version": "0.11.6",
3
+ "version": "0.11.8",
4
4
  "description": "The command-line interface for Levr",
5
5
  "type": "module",
6
6
  "license": "MIT",