@levr-one/cli 0.11.2 → 0.11.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +69 -32
- package/dist/{addHandler-Dh4elBBV.js → addHandler-DC1U672-.js} +135 -38
- package/dist/cli.js +10 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -23,7 +23,7 @@ replaces the `levr` bin from the deprecated `@levr-one/setup` package.
|
|
|
23
23
|
### Or run it without a global install
|
|
24
24
|
|
|
25
25
|
```bash
|
|
26
|
-
npx @levr-one/cli --help
|
|
26
|
+
npx @levr-one/cli@latest --help
|
|
27
27
|
```
|
|
28
28
|
|
|
29
29
|
`npx` is right for one-shot use (`mcp add`, trying a command). Two things to
|
|
@@ -40,18 +40,22 @@ In scripts and CI, pass `--yes` — **in a terminal the prompt blocks until it i
|
|
|
40
40
|
answered**, so an unattended run with a TTY hangs rather than failing:
|
|
41
41
|
|
|
42
42
|
```bash
|
|
43
|
-
npx --yes @levr-one/cli push ./test-results.xml
|
|
43
|
+
npx --yes @levr-one/cli@latest push ./test-results.xml
|
|
44
44
|
```
|
|
45
45
|
|
|
46
|
-
|
|
46
|
+
Three things to get right:
|
|
47
47
|
|
|
48
|
-
- **
|
|
48
|
+
- **Add `@latest`.** Without a version, npx runs any copy of `@levr-one/cli`
|
|
49
|
+
already installed on the machine, however old, and an old copy does not know
|
|
50
|
+
newer commands and flags (`No flag registered for --workspace`).
|
|
51
|
+
- **Always `npx @levr-one/cli@latest`, never `npx levr`.** npx treats the first
|
|
49
52
|
positional as a _package_ name, so `npx levr` would fetch and run whatever
|
|
50
53
|
package is published under the unscoped name `levr` — which is not ours.
|
|
51
|
-
- **npm's flags go before the package name.** `npx @levr-one/cli --yes` passes
|
|
54
|
+
- **npm's flags go before the package name.** `npx @levr-one/cli@latest --yes` passes
|
|
52
55
|
`--yes` to `levr`, not to npm.
|
|
53
56
|
|
|
54
|
-
For reproducible CI,
|
|
57
|
+
The examples here use `@latest`. For reproducible CI, replace it with a
|
|
58
|
+
version you have validated, such as `@levr-one/cli@0.11.1`.
|
|
55
59
|
|
|
56
60
|
## Quick start
|
|
57
61
|
|
|
@@ -59,7 +63,7 @@ For reproducible CI, pin `@levr-one/cli` to a version you have validated.
|
|
|
59
63
|
the client opens a browser to authorize the first time it connects:
|
|
60
64
|
|
|
61
65
|
```bash
|
|
62
|
-
npx @levr-one/cli mcp add
|
|
66
|
+
npx @levr-one/cli@latest mcp add
|
|
63
67
|
```
|
|
64
68
|
|
|
65
69
|
**Pushing test results?** Authentication is all you need — log in once (or
|
|
@@ -82,40 +86,72 @@ levr mcp add --dry-run # preview the changes without writing
|
|
|
82
86
|
levr mcp add --client cursor,zed --yes # non-interactive selection
|
|
83
87
|
```
|
|
84
88
|
|
|
85
|
-
What it writes is **credential-free** — a `levr` server entry
|
|
86
|
-
|
|
87
|
-
to authorize with Levr on its first connection. After a run, restart
|
|
88
|
-
client(s) and authorize once.
|
|
89
|
+
What it writes is **credential-free** — a `levr` server entry holding only
|
|
90
|
+
the server's address. No token or secret is stored; your client opens the
|
|
91
|
+
browser to authorize with Levr on its first connection. After a run, restart
|
|
92
|
+
the client(s) and authorize once.
|
|
93
|
+
|
|
94
|
+
Clients that speak HTTP MCP themselves (Claude Code, VS Code, Gemini CLI,
|
|
95
|
+
Codex CLI, Grok Build, Antigravity) connect to the URL directly. Claude
|
|
96
|
+
Desktop, Cursor, Windsurf and Zed get an entry that runs
|
|
97
|
+
`npx -y mcp-remote <url>`, which connects on their behalf.
|
|
89
98
|
|
|
90
99
|
**Supported clients:**
|
|
91
100
|
|
|
92
|
-
| Client
|
|
93
|
-
|
|
|
94
|
-
| Claude Desktop
|
|
95
|
-
| Claude Code
|
|
96
|
-
| Cursor
|
|
97
|
-
| Windsurf
|
|
98
|
-
| Zed
|
|
99
|
-
| VS Code
|
|
101
|
+
| Client | How it's configured |
|
|
102
|
+
| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
|
|
103
|
+
| Claude Desktop | config file (`claude_desktop_config.json`) |
|
|
104
|
+
| Claude Code | runs `claude mcp add --transport http --scope user levr <url>` for you (prints it instead when `claude` is not on your `PATH`) |
|
|
105
|
+
| Cursor | config file (`~/.cursor/mcp.json`) |
|
|
106
|
+
| Windsurf | config file (`~/.codeium/windsurf/mcp_config.json`) |
|
|
107
|
+
| Zed | config file (`settings.json`, `context_servers`) |
|
|
108
|
+
| VS Code | config file (`Code/User/mcp.json` in your VS Code settings folder; `.vscode/mcp.json` with `--scope project`) |
|
|
109
|
+
| Gemini CLI | config file (`~/.gemini/settings.json`) |
|
|
110
|
+
| Codex CLI | config file (`~/.codex/config.toml`, TOML) |
|
|
111
|
+
| Grok Build | config file (`~/.grok/config.toml`, TOML) |
|
|
112
|
+
| Antigravity (CLI + IDE) | config file (`~/.gemini/config/mcp_config.json`) |
|
|
100
113
|
|
|
101
114
|
Config edits are **safe and repeatable**: existing MCP servers and comments
|
|
102
|
-
in your config files are preserved
|
|
103
|
-
|
|
115
|
+
in your config files are preserved, and re-running reports "already set up".
|
|
116
|
+
An entry of the same name that points at a different address is left alone
|
|
117
|
+
and reported; pass `--replace` to switch it.
|
|
104
118
|
|
|
105
119
|
**Flags:**
|
|
106
120
|
|
|
107
|
-
| Flag
|
|
108
|
-
|
|
|
109
|
-
| `--client <id,…>`
|
|
110
|
-
| `--all`
|
|
111
|
-
| `--yes`
|
|
112
|
-
| `--dry-run`
|
|
113
|
-
| `--
|
|
121
|
+
| Flag | Alias | Description |
|
|
122
|
+
| -------------------------------- | ----- | ----------------------------------------------------------------------------------- |
|
|
123
|
+
| `--client <id,…>` | | Set up these client ids (comma-separated or repeated) |
|
|
124
|
+
| `--all` | | Set up every detected, installable client |
|
|
125
|
+
| `--yes` | `-y` | Non-interactive; auto-select detected clients |
|
|
126
|
+
| `--dry-run` | | Show the changes without writing |
|
|
127
|
+
| `--scope <user\|project\|local>` | | Where the entry lands: every project (default), this repo for everyone, or just you |
|
|
128
|
+
| `--workspace <url_key\|name>` | | Pin the entry to one workspace (needs `levr auth login`) |
|
|
129
|
+
| `--name <entry>` | | Name of the client entry (default `levr`), to keep a second one beside it |
|
|
130
|
+
| `--replace` | | Switch an existing entry of this name to the new address |
|
|
131
|
+
| `--url <url>` | | MCP server URL (default derived from the API server) |
|
|
114
132
|
|
|
115
133
|
Runs non-interactively whenever `--all`, `--client`, or `--yes` is passed —
|
|
116
134
|
or automatically when not attached to a terminal (CI). Unknown client ids and
|
|
117
135
|
failed writes exit non-zero.
|
|
118
136
|
|
|
137
|
+
### More than one workspace
|
|
138
|
+
|
|
139
|
+
If your account is in more than one workspace, the plain MCP URL does not know
|
|
140
|
+
which one you mean, and its tools reply that no workspace is selected. Connect
|
|
141
|
+
each workspace by its own URL, `https://ai.levr.one/api/v1/mcp/w/<url_key>`:
|
|
142
|
+
|
|
143
|
+
```bash
|
|
144
|
+
levr auth login
|
|
145
|
+
levr mcp add --workspace acme # pin the levr entry to acme
|
|
146
|
+
levr mcp add --workspace beta --name levr-beta # add a second entry beside it
|
|
147
|
+
levr mcp add --workspace beta --replace # or switch the entry to beta
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Each entry lists every Levr tool once more. A URL for a workspace you are not
|
|
151
|
+
in is refused with `403`; connect the plain URL and ask for `list_workspaces`
|
|
152
|
+
to see the URLs you can use. Full guide:
|
|
153
|
+
[levr CLI Overview › More than one workspace](https://doc.levr.one/cli/levr-cli-overview#more-than-one-workspace).
|
|
154
|
+
|
|
119
155
|
## Push test results
|
|
120
156
|
|
|
121
157
|
```bash
|
|
@@ -233,7 +269,7 @@ levr import ./cases.csv --team-id <uuid> --map "State=labels:state"
|
|
|
233
269
|
env:
|
|
234
270
|
LEVR_TOKEN: ${{ secrets.LEVR_TOKEN }}
|
|
235
271
|
# LEVR_TEAM_ID is optional — server resolves from automation source or workspace default
|
|
236
|
-
run: npx --yes @levr-one/cli push ./test-results.xml
|
|
272
|
+
run: npx --yes @levr-one/cli@latest push ./test-results.xml
|
|
237
273
|
```
|
|
238
274
|
|
|
239
275
|
### GitLab CI
|
|
@@ -241,7 +277,7 @@ levr import ./cases.csv --team-id <uuid> --map "State=labels:state"
|
|
|
241
277
|
```yaml
|
|
242
278
|
push-results:
|
|
243
279
|
script:
|
|
244
|
-
- npx --yes @levr-one/cli push ./test-results.xml
|
|
280
|
+
- npx --yes @levr-one/cli@latest push ./test-results.xml
|
|
245
281
|
variables:
|
|
246
282
|
LEVR_TOKEN: $LEVR_TOKEN
|
|
247
283
|
```
|
|
@@ -250,13 +286,14 @@ push-results:
|
|
|
250
286
|
|
|
251
287
|
```groovy
|
|
252
288
|
withEnv(["LEVR_TOKEN=${LEVR_TOKEN}"]) {
|
|
253
|
-
sh 'npx --yes @levr-one/cli push ./test-results.xml'
|
|
289
|
+
sh 'npx --yes @levr-one/cli@latest push ./test-results.xml'
|
|
254
290
|
}
|
|
255
291
|
```
|
|
256
292
|
|
|
257
293
|
## Authentication
|
|
258
294
|
|
|
259
|
-
Needed for `push` and `workspace` commands
|
|
295
|
+
Needed for `push` and `workspace` commands, and for `mcp add --workspace`.
|
|
296
|
+
Three modes:
|
|
260
297
|
|
|
261
298
|
### Interactive (browser) — default
|
|
262
299
|
|
|
@@ -143,7 +143,10 @@ const CLAUDE_CODE_COMMAND = [
|
|
|
143
143
|
"{name}",
|
|
144
144
|
"{url}"
|
|
145
145
|
];
|
|
146
|
-
/**
|
|
146
|
+
/**
|
|
147
|
+
* `~/.claude.json` on every platform — read for detection, never written by us.
|
|
148
|
+
* `~` is `$CLAUDE_CONFIG_DIR` when that is set (see `configDirEnv`).
|
|
149
|
+
*/
|
|
147
150
|
const CLAUDE_CODE_LOCATIONS = [
|
|
148
151
|
{
|
|
149
152
|
platform: "darwin",
|
|
@@ -232,6 +235,7 @@ const HARNESSES = [
|
|
|
232
235
|
docsUrl: "https://docs.anthropic.com/en/docs/claude-code/mcp",
|
|
233
236
|
verifiedVersion: UNVERIFIED_LEGACY,
|
|
234
237
|
verifiedOn: UNVERIFIED_LEGACY,
|
|
238
|
+
configDirEnv: "CLAUDE_CONFIG_DIR",
|
|
235
239
|
detectSignals: [
|
|
236
240
|
{
|
|
237
241
|
platform: "darwin",
|
|
@@ -275,7 +279,7 @@ const HARNESSES = [
|
|
|
275
279
|
installKind: "cli-command",
|
|
276
280
|
locations: CLAUDE_CODE_LOCATIONS,
|
|
277
281
|
command: CLAUDE_CODE_COMMAND,
|
|
278
|
-
|
|
282
|
+
projectKeyedUnder: "projects"
|
|
279
283
|
}
|
|
280
284
|
]
|
|
281
285
|
},
|
|
@@ -775,9 +779,20 @@ function defaultEnv() {
|
|
|
775
779
|
platform: process.platform,
|
|
776
780
|
homedir: homedir(),
|
|
777
781
|
pathVar: process.env.PATH ?? "",
|
|
778
|
-
cwd: process.cwd()
|
|
782
|
+
cwd: process.cwd(),
|
|
783
|
+
vars: process.env
|
|
779
784
|
};
|
|
780
785
|
}
|
|
786
|
+
/**
|
|
787
|
+
* The directory `~` means for this harness's config paths and signals: the
|
|
788
|
+
* harness's `configDirEnv` when that variable is set, else the home directory
|
|
789
|
+
* (ENG-6696). A relative value resolves against `env.cwd`, the directory a
|
|
790
|
+
* spawned client CLI runs in, so the probe and the client read one file.
|
|
791
|
+
*/
|
|
792
|
+
function configHome(harness, env) {
|
|
793
|
+
const dir = harness.configDirEnv ? env.vars?.[harness.configDirEnv] : void 0;
|
|
794
|
+
return dir ? resolve(env.cwd, dir) : env.homedir;
|
|
795
|
+
}
|
|
781
796
|
/** Expand a leading `~` / `~/…` to the given home directory. */
|
|
782
797
|
function expandTilde(p, home) {
|
|
783
798
|
if (p === "~") return home;
|
|
@@ -807,7 +822,7 @@ function resolveConfigPath(harness, env, scope = defaultScope(harness)) {
|
|
|
807
822
|
if (def.projectPath) return join(resolveProjectRoot(env).root, ...def.projectPath.split("/"));
|
|
808
823
|
const loc = locationFor(harness, env.platform, scope);
|
|
809
824
|
if (!loc) return void 0;
|
|
810
|
-
return expandTilde(loc.configPath, env
|
|
825
|
+
return expandTilde(loc.configPath, configHome(harness, env));
|
|
811
826
|
}
|
|
812
827
|
/**
|
|
813
828
|
* Nearest ancestor of `cwd` containing `.git`, or `undefined`.
|
|
@@ -846,25 +861,75 @@ function resolveProjectRoot(env) {
|
|
|
846
861
|
};
|
|
847
862
|
}
|
|
848
863
|
/**
|
|
864
|
+
* The key Claude Code files a `local`-scope entry under, in `projects[...]`,
|
|
865
|
+
* for a client run from `env.cwd` (ENG-6696, observed on 2.1.296).
|
|
866
|
+
*
|
|
867
|
+
* It is the PROJECT, not the directory: from `repo/sub` the key is `repo`.
|
|
868
|
+
* A linked git worktree keys to its MAIN checkout (the parent of the common
|
|
869
|
+
* `.git`), a worktree of a bare repository to the bare directory, and a
|
|
870
|
+
* submodule or `--separate-git-dir` checkout to its own work tree. Outside a
|
|
871
|
+
* repository it is the directory itself. Always a real path, with `/`
|
|
872
|
+
* separators on Windows (unverified there: drive-letter case). Claude reads
|
|
873
|
+
* only this key, so an entry under any other key is invisible to it, and a
|
|
874
|
+
* probe that looked elsewhere missed the entry and let `claude mcp add`
|
|
875
|
+
* refuse.
|
|
876
|
+
*/
|
|
877
|
+
function projectKeyFor(env) {
|
|
878
|
+
return configKey(projectDir(env), env.platform);
|
|
879
|
+
}
|
|
880
|
+
/** Claude Code writes `projects` keys with `/` on Windows too. */
|
|
881
|
+
function configKey(dir, platform) {
|
|
882
|
+
return platform === "win32" ? dir.replaceAll("\\", "/") : dir;
|
|
883
|
+
}
|
|
884
|
+
function projectDir(env) {
|
|
885
|
+
const cwd = realOrResolved(env.cwd);
|
|
886
|
+
const workTree = findRepoRoot(cwd);
|
|
887
|
+
if (!workTree) return cwd;
|
|
888
|
+
const dotGit = join(workTree, ".git");
|
|
889
|
+
let gitDir = dotGit;
|
|
890
|
+
try {
|
|
891
|
+
if (statSync(dotGit).isFile()) {
|
|
892
|
+
const m = /^gitdir:\s*([^\r\n]+)/.exec(readFileSync(dotGit, "utf8").trim());
|
|
893
|
+
if (!m?.[1]) return workTree;
|
|
894
|
+
gitDir = resolve(workTree, m[1].trim());
|
|
895
|
+
}
|
|
896
|
+
} catch {
|
|
897
|
+
return workTree;
|
|
898
|
+
}
|
|
899
|
+
const commonFile = readTextOrNull(join(gitDir, "commondir"));
|
|
900
|
+
if (commonFile === null) return workTree;
|
|
901
|
+
const common = realOrResolved(resolve(gitDir, commonFile.trim()));
|
|
902
|
+
if (!existsSync(common)) return workTree;
|
|
903
|
+
if (realOrResolved(dirname(gitDir)) !== join(common, "worktrees")) return workTree;
|
|
904
|
+
const back = readTextOrNull(join(gitDir, "gitdir"));
|
|
905
|
+
if (back === null || realOrResolved(dirname(resolve(gitDir, back.trim()))) !== workTree) return workTree;
|
|
906
|
+
if (basename(common) === ".git") return dirname(common);
|
|
907
|
+
return existsSync(join(common, ".git")) ? workTree : common;
|
|
908
|
+
}
|
|
909
|
+
function realOrResolved(p) {
|
|
910
|
+
try {
|
|
911
|
+
return realpathSync(p);
|
|
912
|
+
} catch {
|
|
913
|
+
return resolve(p);
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
/**
|
|
849
917
|
* Where our server entry sits INSIDE a scope's config file.
|
|
850
918
|
*
|
|
851
919
|
* Usually `[<serverProperty>, 'levr']` at the top level. A scope declaring
|
|
852
|
-
* `
|
|
853
|
-
*
|
|
854
|
-
*
|
|
920
|
+
* `projectKeyedUnder` nests one level deeper, under the project key: Claude
|
|
921
|
+
* Code's `local` scope lives at `projects[<key>].mcpServers.levr` in
|
|
922
|
+
* `~/.claude.json`, with `<key>` from {@link projectKeyFor}.
|
|
855
923
|
*
|
|
856
|
-
* `cwdKey` is passed in rather than read off the env because the key is
|
|
857
|
-
* specifically the directory the CLIENT was launched in — which is NOT the
|
|
858
|
-
* repo root, and callers should have to say which directory they mean.
|
|
859
924
|
* Shared by detect and install so the two can never disagree about where an
|
|
860
925
|
* entry lives.
|
|
861
926
|
*/
|
|
862
|
-
function entryPathFor(harness, scope,
|
|
927
|
+
function entryPathFor(harness, scope, projectKey, serverName = DEFAULT_SERVER_NAME) {
|
|
863
928
|
const tail = [serverPropertyFor(harness, scope), serverName];
|
|
864
|
-
const nest = scopeDef(harness, scope)?.
|
|
929
|
+
const nest = scopeDef(harness, scope)?.projectKeyedUnder;
|
|
865
930
|
return nest ? [
|
|
866
931
|
nest,
|
|
867
|
-
|
|
932
|
+
projectKey,
|
|
868
933
|
...tail
|
|
869
934
|
] : tail;
|
|
870
935
|
}
|
|
@@ -916,13 +981,14 @@ function collidingScope(harness, env, scope) {
|
|
|
916
981
|
if (!scopeDef(harness, scope)?.projectPath) return void 0;
|
|
917
982
|
const target = resolveConfigPath(harness, env, scope);
|
|
918
983
|
if (!target) return void 0;
|
|
919
|
-
const
|
|
984
|
+
const key = projectKeyFor(env);
|
|
985
|
+
const targetEntry = entryPathFor(harness, scope, key).join("\0");
|
|
920
986
|
for (const other of harness.scopes) {
|
|
921
987
|
if (other.scope === scope) continue;
|
|
922
988
|
const otherPath = resolveConfigPath(harness, env, other.scope);
|
|
923
989
|
if (!otherPath) continue;
|
|
924
990
|
if (!pathsResolveSame(target, otherPath, env.platform)) continue;
|
|
925
|
-
if (entryPathFor(harness, other.scope,
|
|
991
|
+
if (entryPathFor(harness, other.scope, key).join("\0") === targetEntry) return other.scope;
|
|
926
992
|
}
|
|
927
993
|
}
|
|
928
994
|
const WIN_EXTS = [
|
|
@@ -958,22 +1024,23 @@ function whichSync(bin, env) {
|
|
|
958
1024
|
return whichPathSync(bin, env) !== void 0;
|
|
959
1025
|
}
|
|
960
1026
|
/** Does one `installSignals` entry match on this machine? Supports
|
|
961
|
-
* `which:<bin>`, `~`-prefixed paths, and absolute (app-bundle) paths.
|
|
962
|
-
|
|
1027
|
+
* `which:<bin>`, `~`-prefixed paths, and absolute (app-bundle) paths. `home`
|
|
1028
|
+
* is what `~` means for the harness — see {@link configHome}. */
|
|
1029
|
+
function signalMatches(signal, env, home = env.homedir) {
|
|
963
1030
|
if (signal.startsWith("which:")) return whichSync(signal.slice(6), env);
|
|
964
|
-
return existsSync(signal.startsWith("~") ? expandTilde(signal,
|
|
1031
|
+
return existsSync(signal.startsWith("~") ? expandTilde(signal, home) : signal);
|
|
965
1032
|
}
|
|
966
1033
|
/**
|
|
967
1034
|
* The value our server key currently holds for this scope, or `undefined`.
|
|
968
1035
|
*
|
|
969
|
-
* Presence alone is not enough for a `cli-command` scope: `claude mcp add`
|
|
970
|
-
*
|
|
971
|
-
* pointing somewhere else" needs the value.
|
|
1036
|
+
* Presence alone is not enough for a `cli-command` scope: `claude mcp add`
|
|
1037
|
+
* refuses when the key exists and never updates its URL, so telling "already
|
|
1038
|
+
* correct" from "present but pointing somewhere else" needs the value.
|
|
972
1039
|
*/
|
|
973
|
-
function readServerEntry(harness, scope, configPath,
|
|
1040
|
+
function readServerEntry(harness, scope, configPath, projectKey, parseJsonc, serverName = DEFAULT_SERVER_NAME) {
|
|
974
1041
|
const text = readTextOrNull(configPath);
|
|
975
1042
|
if (!text) return void 0;
|
|
976
|
-
return getAtPath(parseJsonc(text), entryPathFor(harness, scope,
|
|
1043
|
+
return getAtPath(parseJsonc(text), entryPathFor(harness, scope, projectKey, serverName));
|
|
977
1044
|
}
|
|
978
1045
|
/** Read a text file, or `null` if it doesn't exist / can't be read. */
|
|
979
1046
|
function readTextOrNull(path$1) {
|
|
@@ -1425,10 +1492,10 @@ function adapterFor(harness) {
|
|
|
1425
1492
|
* not by which file is read — hence the entry path rather than a bare
|
|
1426
1493
|
* top-level lookup.
|
|
1427
1494
|
*/
|
|
1428
|
-
function isServerConfigured(harness, scope, configPath,
|
|
1495
|
+
function isServerConfigured(harness, scope, configPath, projectKey) {
|
|
1429
1496
|
const text = readTextOrNull(configPath);
|
|
1430
1497
|
if (!text) return false;
|
|
1431
|
-
const read = adapterFor(harness).readAt(text, entryPathFor(harness, scope,
|
|
1498
|
+
const read = adapterFor(harness).readAt(text, entryPathFor(harness, scope, projectKey));
|
|
1432
1499
|
return read.kind === "value" && read.value !== null;
|
|
1433
1500
|
}
|
|
1434
1501
|
function detectScope(harness, env, def) {
|
|
@@ -1441,13 +1508,13 @@ function detectScope(harness, env, def) {
|
|
|
1441
1508
|
installKind: def.installKind,
|
|
1442
1509
|
configPath: available ? configPath ?? "" : "",
|
|
1443
1510
|
available,
|
|
1444
|
-
alreadyConfigured: available && configPath ? isServerConfigured(harness, def.scope, configPath, env
|
|
1511
|
+
alreadyConfigured: available && configPath ? isServerConfigured(harness, def.scope, configPath, projectKeyFor(env)) : false
|
|
1445
1512
|
};
|
|
1446
1513
|
}
|
|
1447
1514
|
function detectOne(harness, env) {
|
|
1448
1515
|
const scopes = harness.scopes.map((def) => detectScope(harness, env, def));
|
|
1449
1516
|
const available = scopes.some((s) => s.available);
|
|
1450
|
-
const installed = detectSignalsFor(harness, env.platform).some((s) => signalMatches(s, env)) || scopes.some((s) => s.configPath !== "" && existsSync(s.configPath));
|
|
1517
|
+
const installed = detectSignalsFor(harness, env.platform).some((s) => signalMatches(s, env, configHome(harness, env))) || scopes.some((s) => s.configPath !== "" && existsSync(s.configPath));
|
|
1451
1518
|
const fallback = scopes.find((s) => s.scope === defaultScope(harness));
|
|
1452
1519
|
return {
|
|
1453
1520
|
id: harness.id,
|
|
@@ -1472,6 +1539,21 @@ const COMMAND_TIMEOUT_MS = 3e4;
|
|
|
1472
1539
|
/** Keep a failing client's diagnostics readable in a multi-client report. */
|
|
1473
1540
|
const MAX_STDERR = 2e3;
|
|
1474
1541
|
/**
|
|
1542
|
+
* The variables a client CLI is spawned with: the real environment minus every
|
|
1543
|
+
* catalog `configDirEnv`, then `env.vars` on top (ENG-6696). A config
|
|
1544
|
+
* directory therefore reaches the child only when the caller's env carries it
|
|
1545
|
+
* — the one the probe read — so `claude` never writes a file the installer
|
|
1546
|
+
* did not look at, while PATH and the rest still come through.
|
|
1547
|
+
*/
|
|
1548
|
+
function childVars(env) {
|
|
1549
|
+
const vars = { ...process.env };
|
|
1550
|
+
for (const h of HARNESSES) if (h.configDirEnv) delete vars[h.configDirEnv];
|
|
1551
|
+
return {
|
|
1552
|
+
...vars,
|
|
1553
|
+
...env.vars
|
|
1554
|
+
};
|
|
1555
|
+
}
|
|
1556
|
+
/**
|
|
1475
1557
|
* Windows batch launchers (`.cmd` / `.bat`) cannot be spawned directly.
|
|
1476
1558
|
*
|
|
1477
1559
|
* Since the CVE-2024-27980 fix (Node 18.20.2 / 20.12.2 / 21.7.2),
|
|
@@ -1532,7 +1614,7 @@ function runHarnessCommandSync(argv, opts = {}) {
|
|
|
1532
1614
|
],
|
|
1533
1615
|
timeout: COMMAND_TIMEOUT_MS,
|
|
1534
1616
|
env: {
|
|
1535
|
-
...
|
|
1617
|
+
...childVars(env),
|
|
1536
1618
|
HOME: env.homedir,
|
|
1537
1619
|
USERPROFILE: env.homedir
|
|
1538
1620
|
},
|
|
@@ -1587,7 +1669,7 @@ function currentEntry(harness, env, scope, serverName) {
|
|
|
1587
1669
|
const configPath = resolveConfigPath(harness, env, scope);
|
|
1588
1670
|
if (!configPath) return { present: false };
|
|
1589
1671
|
const adapter = adapterFor(harness);
|
|
1590
|
-
const entry = readServerEntry(harness, scope, configPath, env
|
|
1672
|
+
const entry = readServerEntry(harness, scope, configPath, projectKeyFor(env), (t) => {
|
|
1591
1673
|
const read = adapter.readAt(t, []);
|
|
1592
1674
|
return read.kind === "value" ? read.value : void 0;
|
|
1593
1675
|
}, serverName);
|
|
@@ -1729,7 +1811,7 @@ function installHarnessSync(harness, mcpUrl, opts = {}) {
|
|
|
1729
1811
|
};
|
|
1730
1812
|
const { path: path$1 } = target;
|
|
1731
1813
|
const entryValue = buildServerEntry(harness, mcpUrl, scope, serverName)[serverName];
|
|
1732
|
-
const modPath = entryPathFor(harness, scope, env
|
|
1814
|
+
const modPath = entryPathFor(harness, scope, projectKeyFor(env), serverName);
|
|
1733
1815
|
const adapter = adapterFor(harness);
|
|
1734
1816
|
const existing = readTextOrNull(path$1);
|
|
1735
1817
|
const baseText = existing && existing.trim() ? existing : adapter.empty;
|
|
@@ -2186,7 +2268,7 @@ function runNonInteractive(options, url, urlSource, deps) {
|
|
|
2186
2268
|
};
|
|
2187
2269
|
}
|
|
2188
2270
|
/** Why an install was refused, in the user's terms rather than the enum's. */
|
|
2189
|
-
function failureText(o, switchCommand) {
|
|
2271
|
+
function failureText(o, switchCommand, entryName) {
|
|
2190
2272
|
const r = o.result;
|
|
2191
2273
|
const harness = getHarness(o.id);
|
|
2192
2274
|
switch (r.reason) {
|
|
@@ -2198,10 +2280,23 @@ function failureText(o, switchCommand) {
|
|
|
2198
2280
|
case "write-failed": return `its config could not be written` + (r.detail ? ` (${r.detail})` : "") + `; check the file's permissions and re-run`;
|
|
2199
2281
|
case "unsupported-config-shape": return `its config could not be edited safely` + (r.detail ? ` (${r.detail})` : "") + `; fix the file or add the entry by hand`;
|
|
2200
2282
|
default:
|
|
2201
|
-
if (r.commandError) return `\`${r.command}\` (${r.commandError})${restoreText(r)}
|
|
2283
|
+
if (r.commandError) return `\`${r.command}\` (${r.commandError})${restoreText(r)}` + alreadyExistsHint(o, r.commandError, entryName);
|
|
2202
2284
|
return "no config location on this platform";
|
|
2203
2285
|
}
|
|
2204
2286
|
}
|
|
2287
|
+
/**
|
|
2288
|
+
* The client's CLI refused because the entry already exists, in a config the
|
|
2289
|
+
* installer did not read — otherwise it would have reported already set up,
|
|
2290
|
+
* or a url-mismatch naming --replace. --replace cannot reach an entry it
|
|
2291
|
+
* cannot see, so the way out is another name, or removing that entry with the
|
|
2292
|
+
* client's own CLI (ENG-6696).
|
|
2293
|
+
*/
|
|
2294
|
+
function alreadyExistsHint(o, commandError, entryName) {
|
|
2295
|
+
if (!/already exists/i.test(commandError)) return "";
|
|
2296
|
+
const name = entryName ?? DEFAULT_SERVER_NAME;
|
|
2297
|
+
const scope = /already exists in (user|project|local) config/i.exec(commandError)?.[1]?.toLowerCase() ?? o.result.scope;
|
|
2298
|
+
return `; to keep it, add this one beside it with --name <other>; to switch it, remove it with ${o.id === "claude-code" ? `\`claude mcp remove --scope ${scope} ${name}\`` : "the client's own CLI"} and re-run`;
|
|
2299
|
+
}
|
|
2205
2300
|
/** What happened to the entry a failed `--replace` removed (ENG-6300 D6). */
|
|
2206
2301
|
function restoreText(r) {
|
|
2207
2302
|
if (r.restored === void 0) return "";
|
|
@@ -2209,13 +2304,13 @@ function restoreText(r) {
|
|
|
2209
2304
|
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
2305
|
}
|
|
2211
2306
|
/** One human-readable status line per outcome. */
|
|
2212
|
-
function outcomeLine(o, dryRun, switchCommand) {
|
|
2307
|
+
function outcomeLine(o, dryRun, switchCommand, entryName) {
|
|
2213
2308
|
const r = o.result;
|
|
2214
2309
|
const note = o.fallbackFrom ? ` [${o.fallbackFrom} scope unsupported — used ${r.scope}]` : "";
|
|
2215
2310
|
const where = r.path ? ` → ${r.path}` : "";
|
|
2216
2311
|
const backup = r.backupPath ? dryRun ? ` [original would be backed up to ${r.backupPath}]` : ` [original backed up to ${r.backupPath}]` : "";
|
|
2217
2312
|
const replaced = r.replacedUrl ? dryRun ? ` [would replace ${r.replacedUrl}]` : ` [replaced ${r.replacedUrl}]` : "";
|
|
2218
|
-
if (!r.ok) return `${o.label}: failed — ${failureText(o, switchCommand)}`;
|
|
2313
|
+
if (!r.ok) return `${o.label}: failed — ${failureText(o, switchCommand, entryName)}`;
|
|
2219
2314
|
if (r.alreadyConfigured) return `${o.label}: already set up (${r.scope})${where}${note}`;
|
|
2220
2315
|
if (r.command) {
|
|
2221
2316
|
if (r.executed) return `${o.label}: installed (${r.scope}) via \`${r.command}\`${replaced}${note}`;
|
|
@@ -2230,7 +2325,7 @@ function formatReport(report) {
|
|
|
2230
2325
|
const lines = [];
|
|
2231
2326
|
lines.push(`MCP URL: ${report.url} (${report.urlSource})`);
|
|
2232
2327
|
if (report.outcomes.length === 0) lines.push("No clients selected.");
|
|
2233
|
-
else for (const o of report.outcomes) lines.push(outcomeLine(o, report.dryRun, report.switchCommand));
|
|
2328
|
+
else for (const o of report.outcomes) lines.push(outcomeLine(o, report.dryRun, report.switchCommand, report.entryName));
|
|
2234
2329
|
if (report.unknownClients.length > 0) lines.push(`Unknown clients (skipped): ${report.unknownClients.join(", ")}`);
|
|
2235
2330
|
if (report.outcomes.some((o) => o.id === "claude-code" && o.result.ok)) lines.push(PLUGIN_TIP);
|
|
2236
2331
|
return lines.join("\n");
|
|
@@ -2240,7 +2335,7 @@ const PLUGIN_TIP = "Tip: the Levr plugin adds /levr:work, /levr:file and /levr:d
|
|
|
2240
2335
|
/** Next-steps blurb after a run. */
|
|
2241
2336
|
function nextStepsText(report) {
|
|
2242
2337
|
if (report.dryRun) return "Dry run — re-run without --dry-run to apply these changes.";
|
|
2243
|
-
if (!report.outcomes.some((o) => o.result.wrote || o.result.executed || o.result.command)) return "Nothing to do.";
|
|
2338
|
+
if (!report.outcomes.some((o) => o.result.ok && !o.result.alreadyConfigured && (o.result.wrote || o.result.executed || o.result.command))) return report.outcomes.some((o) => !o.result.ok) ? "Nothing was installed." : "Nothing to do.";
|
|
2244
2339
|
const lines = ["Next: restart the client(s) above — each will prompt you to authorize", "Levr once in the browser. Then ask it: \"What issues are assigned to me?\""];
|
|
2245
2340
|
if (report.outcomes.some((o) => o.result.scope === "project" && o.result.wrote)) lines.push("", "Project-scoped config was written into this repository — commit it to", "share the Levr MCP with everyone who checks it out.");
|
|
2246
2341
|
return lines.join("\n");
|
|
@@ -2380,7 +2475,8 @@ async function mcpAddHandler(flags) {
|
|
|
2380
2475
|
if (options.all || clients.length > 0 || options.yes || !process.stdout.isTTY) {
|
|
2381
2476
|
const report = {
|
|
2382
2477
|
...runNonInteractive(options, url, source, depsFor(entry)),
|
|
2383
|
-
switchCommand: switchCommandFor(flags, urlKey)
|
|
2478
|
+
switchCommand: switchCommandFor(flags, urlKey),
|
|
2479
|
+
entryName: entry.serverName
|
|
2384
2480
|
};
|
|
2385
2481
|
this.process.stdout.write(`${formatReport(report)}\n`);
|
|
2386
2482
|
if (!isScopedMcpUrl(url) && servedBySessionApi(url)) {
|
|
@@ -2513,7 +2609,8 @@ async function interactive(ctx, dryRun, baseUrl, baseSource, flags, entry, resol
|
|
|
2513
2609
|
outcomes,
|
|
2514
2610
|
unknownClients: [],
|
|
2515
2611
|
dryRun,
|
|
2516
|
-
switchCommand: switchCommandFor(flags, urlKey)
|
|
2612
|
+
switchCommand: switchCommandFor(flags, urlKey),
|
|
2613
|
+
entryName: entry.serverName
|
|
2517
2614
|
};
|
|
2518
2615
|
p.note(formatReport(report), "Results");
|
|
2519
2616
|
p.outro(nextStepsText(report));
|
package/dist/cli.js
CHANGED
|
@@ -135,7 +135,7 @@ or when not running in a terminal (CI). Config edits preserve existing
|
|
|
135
135
|
servers and comments, and re-running is a no-op.
|
|
136
136
|
|
|
137
137
|
Examples:
|
|
138
|
-
npx @levr-one/cli mcp add
|
|
138
|
+
npx @levr-one/cli@latest mcp add # detect clients and pick interactively
|
|
139
139
|
levr mcp add --all # set up every detected client
|
|
140
140
|
levr mcp add --client cursor --yes
|
|
141
141
|
levr mcp add --scope project # commit the config to this repo
|
|
@@ -207,7 +207,7 @@ Examples:
|
|
|
207
207
|
aliases: { y: "yes" }
|
|
208
208
|
},
|
|
209
209
|
loader: async () => {
|
|
210
|
-
const { mcpAddHandler } = await import("./addHandler-
|
|
210
|
+
const { mcpAddHandler } = await import("./addHandler-DC1U672-.js");
|
|
211
211
|
return mcpAddHandler;
|
|
212
212
|
}
|
|
213
213
|
});
|
|
@@ -563,12 +563,17 @@ Examples:
|
|
|
563
563
|
//#region src/commands/workspace/select.ts
|
|
564
564
|
const selectCommand = buildCommand({
|
|
565
565
|
docs: {
|
|
566
|
-
brief: "Select
|
|
566
|
+
brief: "Select the CLI workspace (MCP client configs are unchanged)",
|
|
567
567
|
fullDescription: `Select a workspace by ID.
|
|
568
568
|
|
|
569
|
-
The selected workspace is used for all subsequent commands.
|
|
569
|
+
The selected workspace is used for all subsequent levr commands.
|
|
570
570
|
Use 'levr workspace list' to see available workspaces.
|
|
571
571
|
|
|
572
|
+
It does not change MCP client configs: each AI client keeps using the
|
|
573
|
+
workspace its MCP URL names. To point a client at a workspace, run
|
|
574
|
+
'levr mcp add --workspace <url_key>' (add --replace to switch an existing
|
|
575
|
+
entry).
|
|
576
|
+
|
|
572
577
|
Requires JWT authentication (levr auth login).
|
|
573
578
|
|
|
574
579
|
Examples:
|
|
@@ -869,7 +874,7 @@ Examples:
|
|
|
869
874
|
|
|
870
875
|
//#endregion
|
|
871
876
|
//#region package.json
|
|
872
|
-
var version = "0.11.
|
|
877
|
+
var version = "0.11.4";
|
|
873
878
|
|
|
874
879
|
//#endregion
|
|
875
880
|
//#region src/app.ts
|