@levr-one/cli 0.6.11 → 0.7.0
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-7QMKiy7o.js +1380 -0
- package/dist/cli.js +42 -12
- package/dist/{importHandler-C8egfsZc.js → importHandler-D43Q8Jey.js} +2 -2
- package/dist/{listHandler-BtzZD16V.js → listHandler-jEyTQhBk.js} +1 -1
- package/dist/{loginHandler-D56sEnhy.js → loginHandler-BE8MQq83.js} +2 -2
- package/dist/{pushHandler-C0p7-lEN.js → pushHandler-Bf1F1Ltk.js} +2 -2
- package/dist/{resolve-workspace-DzHM22K6.js → resolve-workspace-DfEX0LDh.js} +1 -1
- package/dist/{sdk-client-PqFsqPIp.js → sdk-client-DKifoBwD.js} +29 -0
- package/dist/{selectHandler-C5fOwJXj.js → selectHandler-KNiDUd9H.js} +1 -1
- package/dist/{statusHandler-CRmMO6Xa.js → statusHandler-C2_XOUal.js} +1 -1
- package/package.json +1 -1
- package/dist/addHandler-K2a4rVv8.js +0 -688
|
@@ -0,0 +1,1380 @@
|
|
|
1
|
+
import { getApiUrl } from "./env-CHeKHu5S.js";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync, statSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { delimiter, dirname, join, resolve } from "node:path";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { applyEdits, modify, parse } from "jsonc-parser";
|
|
6
|
+
import { execFileSync } from "node:child_process";
|
|
7
|
+
|
|
8
|
+
//#region ../mcp-harnesses/dist/catalog.js
|
|
9
|
+
/**
|
|
10
|
+
* @levr/mcp-harnesses — isomorphic catalog (browser + Node).
|
|
11
|
+
*
|
|
12
|
+
* Single source of truth for MCP-capable clients ("harnesses"): the catalog,
|
|
13
|
+
* the OAuth-client → catalog matcher, and the pure config builders. This module
|
|
14
|
+
* MUST stay free of Node built-ins (`node:fs`, `node:os`, `node:path`) so the
|
|
15
|
+
* client SPA can import it without pulling `node:fs` into the bundle. Detection
|
|
16
|
+
* and config-write live in the `@levr/mcp-harnesses/node` subpath.
|
|
17
|
+
*
|
|
18
|
+
* Plans: specs/plans/mcp-harness-detect-installer.md (ENG-43, P1) ·
|
|
19
|
+
* specs/plans/mcp-install-scopes-ENG-4151.md (ENG-4152, D1 — scope model)
|
|
20
|
+
*/
|
|
21
|
+
/** Stable server key written into every harness config (used by detect/remove).
|
|
22
|
+
* Renamed from the legacy brand key pre-first-publish (ENG-2515) — this key
|
|
23
|
+
* is a persisted identity in end-users' client config files, so it must not
|
|
24
|
+
* carry the old brand. */
|
|
25
|
+
const SERVER_NAME = "levr";
|
|
26
|
+
/** Every scope in preference order — the vocabulary, not any harness's support. */
|
|
27
|
+
const HARNESS_SCOPES = [
|
|
28
|
+
"user",
|
|
29
|
+
"project",
|
|
30
|
+
"local"
|
|
31
|
+
];
|
|
32
|
+
/** The `claude mcp add` argv shared by Claude Code's `user` and `local` scopes. */
|
|
33
|
+
const CLAUDE_CODE_COMMAND = [
|
|
34
|
+
"claude",
|
|
35
|
+
"mcp",
|
|
36
|
+
"add",
|
|
37
|
+
"--transport",
|
|
38
|
+
"http",
|
|
39
|
+
"--scope",
|
|
40
|
+
"{scope}",
|
|
41
|
+
"{name}",
|
|
42
|
+
"{url}"
|
|
43
|
+
];
|
|
44
|
+
/** `~/.claude.json` on every platform — read for detection, never written by us. */
|
|
45
|
+
const CLAUDE_CODE_LOCATIONS = [
|
|
46
|
+
{
|
|
47
|
+
platform: "darwin",
|
|
48
|
+
configPath: "~/.claude.json"
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
platform: "linux",
|
|
52
|
+
configPath: "~/.claude.json"
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
platform: "win32",
|
|
56
|
+
configPath: "~/.claude.json"
|
|
57
|
+
}
|
|
58
|
+
];
|
|
59
|
+
/**
|
|
60
|
+
* The catalog. Order is presentation order (most common first).
|
|
61
|
+
*
|
|
62
|
+
* `comingSoon` clients (VS Code, Codex) are listed but not installable: their
|
|
63
|
+
* config formats differ enough (VS Code's `servers`/native-http schema, Codex's
|
|
64
|
+
* TOML) that faithful writes are deferred to a dedicated builder branch. Their
|
|
65
|
+
* SCOPES are declared as data regardless, so the support matrix stays complete
|
|
66
|
+
* and turning them on later is a builder change, not a catalog change.
|
|
67
|
+
*/
|
|
68
|
+
const HARNESSES = [
|
|
69
|
+
{
|
|
70
|
+
id: "claude",
|
|
71
|
+
label: "Claude Desktop",
|
|
72
|
+
matchers: [
|
|
73
|
+
"claude desktop",
|
|
74
|
+
"claude-desktop",
|
|
75
|
+
"claude"
|
|
76
|
+
],
|
|
77
|
+
serverPropertyName: "mcpServers",
|
|
78
|
+
transport: "mcp-remote",
|
|
79
|
+
docsUrl: "https://modelcontextprotocol.io/quickstart/user",
|
|
80
|
+
comingSoon: false,
|
|
81
|
+
detectSignals: [
|
|
82
|
+
{
|
|
83
|
+
platform: "darwin",
|
|
84
|
+
signals: ["~/Library/Application Support/Claude", "/Applications/Claude.app"]
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
platform: "win32",
|
|
88
|
+
signals: ["~/AppData/Roaming/Claude"]
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
platform: "linux",
|
|
92
|
+
signals: ["~/.config/Claude"]
|
|
93
|
+
}
|
|
94
|
+
],
|
|
95
|
+
scopes: [{
|
|
96
|
+
scope: "user",
|
|
97
|
+
installKind: "config-file",
|
|
98
|
+
locations: [
|
|
99
|
+
{
|
|
100
|
+
platform: "darwin",
|
|
101
|
+
configPath: "~/Library/Application Support/Claude/claude_desktop_config.json"
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
platform: "win32",
|
|
105
|
+
configPath: "~/AppData/Roaming/Claude/claude_desktop_config.json"
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
platform: "linux",
|
|
109
|
+
configPath: "~/.config/Claude/claude_desktop_config.json"
|
|
110
|
+
}
|
|
111
|
+
]
|
|
112
|
+
}]
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
id: "claude-code",
|
|
116
|
+
label: "Claude Code",
|
|
117
|
+
matchers: [
|
|
118
|
+
"claude code",
|
|
119
|
+
"claude-code",
|
|
120
|
+
"claude_code",
|
|
121
|
+
"claudecode"
|
|
122
|
+
],
|
|
123
|
+
serverPropertyName: "mcpServers",
|
|
124
|
+
transport: "native-http",
|
|
125
|
+
docsUrl: "https://docs.anthropic.com/en/docs/claude-code/mcp",
|
|
126
|
+
comingSoon: false,
|
|
127
|
+
detectSignals: [
|
|
128
|
+
{
|
|
129
|
+
platform: "darwin",
|
|
130
|
+
signals: [
|
|
131
|
+
"which:claude",
|
|
132
|
+
"~/.claude.json",
|
|
133
|
+
"~/.claude"
|
|
134
|
+
]
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
platform: "linux",
|
|
138
|
+
signals: [
|
|
139
|
+
"which:claude",
|
|
140
|
+
"~/.claude.json",
|
|
141
|
+
"~/.claude"
|
|
142
|
+
]
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
platform: "win32",
|
|
146
|
+
signals: [
|
|
147
|
+
"which:claude",
|
|
148
|
+
"~/.claude.json",
|
|
149
|
+
"~/.claude"
|
|
150
|
+
]
|
|
151
|
+
}
|
|
152
|
+
],
|
|
153
|
+
scopes: [
|
|
154
|
+
{
|
|
155
|
+
scope: "user",
|
|
156
|
+
installKind: "cli-command",
|
|
157
|
+
locations: CLAUDE_CODE_LOCATIONS,
|
|
158
|
+
command: CLAUDE_CODE_COMMAND
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
scope: "project",
|
|
162
|
+
installKind: "config-file",
|
|
163
|
+
projectPath: ".mcp.json"
|
|
164
|
+
},
|
|
165
|
+
{
|
|
166
|
+
scope: "local",
|
|
167
|
+
installKind: "cli-command",
|
|
168
|
+
locations: CLAUDE_CODE_LOCATIONS,
|
|
169
|
+
command: CLAUDE_CODE_COMMAND,
|
|
170
|
+
cwdKeyedUnder: "projects"
|
|
171
|
+
}
|
|
172
|
+
]
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
id: "cursor",
|
|
176
|
+
label: "Cursor",
|
|
177
|
+
matchers: ["cursor"],
|
|
178
|
+
serverPropertyName: "mcpServers",
|
|
179
|
+
transport: "mcp-remote",
|
|
180
|
+
docsUrl: "https://cursor.com/docs/mcp",
|
|
181
|
+
comingSoon: false,
|
|
182
|
+
detectSignals: [
|
|
183
|
+
{
|
|
184
|
+
platform: "darwin",
|
|
185
|
+
signals: [
|
|
186
|
+
"~/.cursor",
|
|
187
|
+
"which:cursor",
|
|
188
|
+
"/Applications/Cursor.app"
|
|
189
|
+
]
|
|
190
|
+
},
|
|
191
|
+
{
|
|
192
|
+
platform: "win32",
|
|
193
|
+
signals: ["~/.cursor", "which:cursor"]
|
|
194
|
+
},
|
|
195
|
+
{
|
|
196
|
+
platform: "linux",
|
|
197
|
+
signals: ["~/.cursor", "which:cursor"]
|
|
198
|
+
}
|
|
199
|
+
],
|
|
200
|
+
scopes: [{
|
|
201
|
+
scope: "user",
|
|
202
|
+
installKind: "config-file",
|
|
203
|
+
locations: [
|
|
204
|
+
{
|
|
205
|
+
platform: "darwin",
|
|
206
|
+
configPath: "~/.cursor/mcp.json"
|
|
207
|
+
},
|
|
208
|
+
{
|
|
209
|
+
platform: "win32",
|
|
210
|
+
configPath: "~/.cursor/mcp.json"
|
|
211
|
+
},
|
|
212
|
+
{
|
|
213
|
+
platform: "linux",
|
|
214
|
+
configPath: "~/.cursor/mcp.json"
|
|
215
|
+
}
|
|
216
|
+
]
|
|
217
|
+
}, {
|
|
218
|
+
scope: "project",
|
|
219
|
+
installKind: "config-file",
|
|
220
|
+
projectPath: ".cursor/mcp.json"
|
|
221
|
+
}]
|
|
222
|
+
},
|
|
223
|
+
{
|
|
224
|
+
id: "windsurf",
|
|
225
|
+
label: "Windsurf",
|
|
226
|
+
matchers: ["windsurf", "codeium"],
|
|
227
|
+
serverPropertyName: "mcpServers",
|
|
228
|
+
transport: "mcp-remote",
|
|
229
|
+
docsUrl: "https://docs.windsurf.com/windsurf/mcp",
|
|
230
|
+
comingSoon: false,
|
|
231
|
+
detectSignals: [
|
|
232
|
+
{
|
|
233
|
+
platform: "darwin",
|
|
234
|
+
signals: [
|
|
235
|
+
"~/.codeium/windsurf",
|
|
236
|
+
"/Applications/Windsurf.app",
|
|
237
|
+
"which:windsurf"
|
|
238
|
+
]
|
|
239
|
+
},
|
|
240
|
+
{
|
|
241
|
+
platform: "win32",
|
|
242
|
+
signals: ["~/.codeium/windsurf", "which:windsurf"]
|
|
243
|
+
},
|
|
244
|
+
{
|
|
245
|
+
platform: "linux",
|
|
246
|
+
signals: ["~/.codeium/windsurf", "which:windsurf"]
|
|
247
|
+
}
|
|
248
|
+
],
|
|
249
|
+
scopes: [{
|
|
250
|
+
scope: "user",
|
|
251
|
+
installKind: "config-file",
|
|
252
|
+
locations: [
|
|
253
|
+
{
|
|
254
|
+
platform: "darwin",
|
|
255
|
+
configPath: "~/.codeium/windsurf/mcp_config.json"
|
|
256
|
+
},
|
|
257
|
+
{
|
|
258
|
+
platform: "win32",
|
|
259
|
+
configPath: "~/.codeium/windsurf/mcp_config.json"
|
|
260
|
+
},
|
|
261
|
+
{
|
|
262
|
+
platform: "linux",
|
|
263
|
+
configPath: "~/.codeium/windsurf/mcp_config.json"
|
|
264
|
+
}
|
|
265
|
+
]
|
|
266
|
+
}]
|
|
267
|
+
},
|
|
268
|
+
{
|
|
269
|
+
id: "zed",
|
|
270
|
+
label: "Zed",
|
|
271
|
+
matchers: ["zed"],
|
|
272
|
+
serverPropertyName: "context_servers",
|
|
273
|
+
transport: "mcp-remote",
|
|
274
|
+
docsUrl: "https://zed.dev/docs/ai/mcp",
|
|
275
|
+
comingSoon: false,
|
|
276
|
+
detectSignals: [
|
|
277
|
+
{
|
|
278
|
+
platform: "darwin",
|
|
279
|
+
signals: [
|
|
280
|
+
"~/.config/zed",
|
|
281
|
+
"/Applications/Zed.app",
|
|
282
|
+
"which:zed"
|
|
283
|
+
]
|
|
284
|
+
},
|
|
285
|
+
{
|
|
286
|
+
platform: "linux",
|
|
287
|
+
signals: ["~/.config/zed", "which:zed"]
|
|
288
|
+
},
|
|
289
|
+
{
|
|
290
|
+
platform: "win32",
|
|
291
|
+
signals: ["~/AppData/Roaming/Zed"]
|
|
292
|
+
}
|
|
293
|
+
],
|
|
294
|
+
scopes: [{
|
|
295
|
+
scope: "user",
|
|
296
|
+
installKind: "config-file",
|
|
297
|
+
locations: [
|
|
298
|
+
{
|
|
299
|
+
platform: "darwin",
|
|
300
|
+
configPath: "~/.config/zed/settings.json"
|
|
301
|
+
},
|
|
302
|
+
{
|
|
303
|
+
platform: "linux",
|
|
304
|
+
configPath: "~/.config/zed/settings.json"
|
|
305
|
+
},
|
|
306
|
+
{
|
|
307
|
+
platform: "win32",
|
|
308
|
+
configPath: "~/AppData/Roaming/Zed/settings.json"
|
|
309
|
+
}
|
|
310
|
+
]
|
|
311
|
+
}, {
|
|
312
|
+
scope: "project",
|
|
313
|
+
installKind: "config-file",
|
|
314
|
+
projectPath: ".zed/settings.json"
|
|
315
|
+
}]
|
|
316
|
+
},
|
|
317
|
+
{
|
|
318
|
+
id: "vscode",
|
|
319
|
+
label: "VS Code",
|
|
320
|
+
matchers: [
|
|
321
|
+
"vscode",
|
|
322
|
+
"vs code",
|
|
323
|
+
"visual studio code"
|
|
324
|
+
],
|
|
325
|
+
serverPropertyName: "mcpServers",
|
|
326
|
+
transport: "mcp-remote",
|
|
327
|
+
docsUrl: "https://code.visualstudio.com/docs/copilot/chat/mcp-servers",
|
|
328
|
+
comingSoon: true,
|
|
329
|
+
detectSignals: [
|
|
330
|
+
{
|
|
331
|
+
platform: "darwin",
|
|
332
|
+
signals: ["/Applications/Visual Studio Code.app", "which:code"]
|
|
333
|
+
},
|
|
334
|
+
{
|
|
335
|
+
platform: "win32",
|
|
336
|
+
signals: ["which:code"]
|
|
337
|
+
},
|
|
338
|
+
{
|
|
339
|
+
platform: "linux",
|
|
340
|
+
signals: ["~/.config/Code", "which:code"]
|
|
341
|
+
}
|
|
342
|
+
],
|
|
343
|
+
scopes: [{
|
|
344
|
+
scope: "user",
|
|
345
|
+
installKind: "config-file",
|
|
346
|
+
locations: [
|
|
347
|
+
{
|
|
348
|
+
platform: "darwin",
|
|
349
|
+
configPath: "~/Library/Application Support/Code/User/mcp.json"
|
|
350
|
+
},
|
|
351
|
+
{
|
|
352
|
+
platform: "win32",
|
|
353
|
+
configPath: "~/AppData/Roaming/Code/User/mcp.json"
|
|
354
|
+
},
|
|
355
|
+
{
|
|
356
|
+
platform: "linux",
|
|
357
|
+
configPath: "~/.config/Code/User/mcp.json"
|
|
358
|
+
}
|
|
359
|
+
]
|
|
360
|
+
}, {
|
|
361
|
+
scope: "project",
|
|
362
|
+
installKind: "config-file",
|
|
363
|
+
projectPath: ".vscode/mcp.json"
|
|
364
|
+
}]
|
|
365
|
+
},
|
|
366
|
+
{
|
|
367
|
+
id: "codex",
|
|
368
|
+
label: "Codex CLI",
|
|
369
|
+
matchers: ["codex"],
|
|
370
|
+
serverPropertyName: "mcpServers",
|
|
371
|
+
transport: "mcp-remote",
|
|
372
|
+
docsUrl: "https://github.com/openai/codex",
|
|
373
|
+
comingSoon: true,
|
|
374
|
+
detectSignals: [
|
|
375
|
+
{
|
|
376
|
+
platform: "darwin",
|
|
377
|
+
signals: ["~/.codex", "which:codex"]
|
|
378
|
+
},
|
|
379
|
+
{
|
|
380
|
+
platform: "linux",
|
|
381
|
+
signals: ["~/.codex", "which:codex"]
|
|
382
|
+
},
|
|
383
|
+
{
|
|
384
|
+
platform: "win32",
|
|
385
|
+
signals: ["~/.codex", "which:codex"]
|
|
386
|
+
}
|
|
387
|
+
],
|
|
388
|
+
scopes: [{
|
|
389
|
+
scope: "user",
|
|
390
|
+
installKind: "config-file",
|
|
391
|
+
locations: [
|
|
392
|
+
{
|
|
393
|
+
platform: "darwin",
|
|
394
|
+
configPath: "~/.codex/config.toml"
|
|
395
|
+
},
|
|
396
|
+
{
|
|
397
|
+
platform: "linux",
|
|
398
|
+
configPath: "~/.codex/config.toml"
|
|
399
|
+
},
|
|
400
|
+
{
|
|
401
|
+
platform: "win32",
|
|
402
|
+
configPath: "~/.codex/config.toml"
|
|
403
|
+
}
|
|
404
|
+
]
|
|
405
|
+
}, {
|
|
406
|
+
scope: "project",
|
|
407
|
+
installKind: "config-file",
|
|
408
|
+
projectPath: ".codex/config.toml"
|
|
409
|
+
}]
|
|
410
|
+
}
|
|
411
|
+
];
|
|
412
|
+
/** Look up a harness by id. Returns `undefined` for unknown ids. */
|
|
413
|
+
function getHarness(id) {
|
|
414
|
+
return HARNESSES.find((h) => h.id === id);
|
|
415
|
+
}
|
|
416
|
+
/** This harness's definition for one scope, or `undefined` if unsupported. */
|
|
417
|
+
function scopeDef(harness, scope) {
|
|
418
|
+
return harness.scopes.find((s) => s.scope === scope);
|
|
419
|
+
}
|
|
420
|
+
/** Scopes this harness supports, in preference order. */
|
|
421
|
+
function supportedScopes(harness) {
|
|
422
|
+
return harness.scopes.map((s) => s.scope);
|
|
423
|
+
}
|
|
424
|
+
/** Does this harness accept our server in the given scope? */
|
|
425
|
+
function supportsScope(harness, scope) {
|
|
426
|
+
return scopeDef(harness, scope) !== void 0;
|
|
427
|
+
}
|
|
428
|
+
/**
|
|
429
|
+
* The harness's FALLBACK scope — what an unsupported request resolves to.
|
|
430
|
+
* Throws on an empty `scopes[]`, which is a catalog bug, not a runtime state
|
|
431
|
+
* (`catalog.test.ts` asserts it never happens).
|
|
432
|
+
*/
|
|
433
|
+
function defaultScope(harness) {
|
|
434
|
+
const first = harness.scopes[0];
|
|
435
|
+
if (!first) throw new Error(`harness "${harness.id}" declares no scopes`);
|
|
436
|
+
return first.scope;
|
|
437
|
+
}
|
|
438
|
+
/** The config property this harness uses in this scope. */
|
|
439
|
+
function serverPropertyFor(harness, scope) {
|
|
440
|
+
return scopeDef(harness, scope)?.serverPropertyName ?? harness.serverPropertyName;
|
|
441
|
+
}
|
|
442
|
+
/** Resolve a scope def, or throw with the scopes that WOULD have worked. */
|
|
443
|
+
function requireScope(harness, scope) {
|
|
444
|
+
const def = scopeDef(harness, scope);
|
|
445
|
+
if (!def) throw new Error(`harness "${harness.id}" does not support scope "${scope}" (supported: ${supportedScopes(harness).join(", ")})`);
|
|
446
|
+
return def;
|
|
447
|
+
}
|
|
448
|
+
/**
|
|
449
|
+
* Substitute `{name}` / `{url}` / `{scope}` into a `cli-command` argv. Returns
|
|
450
|
+
* argv rather than a string so the executor can spawn it WITHOUT a shell.
|
|
451
|
+
*/
|
|
452
|
+
function renderHarnessCommand(harness, mcpUrl, scope) {
|
|
453
|
+
const def = requireScope(harness, scope);
|
|
454
|
+
if (!def.command) throw new Error(`harness "${harness.id}" scope "${scope}" declares no command argv`);
|
|
455
|
+
const substitutions = {
|
|
456
|
+
"{name}": SERVER_NAME,
|
|
457
|
+
"{url}": mcpUrl,
|
|
458
|
+
"{scope}": scope
|
|
459
|
+
};
|
|
460
|
+
return def.command.map((arg) => substitutions[arg] ?? arg);
|
|
461
|
+
}
|
|
462
|
+
/** The `npx -y mcp-remote <url>` invocation shared by mcp-remote harnesses. */
|
|
463
|
+
function mcpRemoteInvocation(mcpUrl) {
|
|
464
|
+
return {
|
|
465
|
+
command: "npx",
|
|
466
|
+
args: [
|
|
467
|
+
"-y",
|
|
468
|
+
"mcp-remote",
|
|
469
|
+
mcpUrl
|
|
470
|
+
]
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
/**
|
|
474
|
+
* The structured server entry to merge under the scope's server property,
|
|
475
|
+
* keyed by {@link SERVER_NAME}. Shape is per-transport, then per-property:
|
|
476
|
+
* - `native-http` (Claude Code's `.mcp.json`): `{ type: 'http', url }`.
|
|
477
|
+
* - `mcpServers` (Claude Desktop, Cursor, Windsurf): flat `{ command, args }`.
|
|
478
|
+
* - `context_servers` (Zed): nested `{ source, command: { path, args } }`.
|
|
479
|
+
*
|
|
480
|
+
* Not used for `cli-command` scopes (see {@link buildHarnessConfig}).
|
|
481
|
+
*/
|
|
482
|
+
function buildServerEntry(harness, mcpUrl, scope = defaultScope(harness)) {
|
|
483
|
+
requireScope(harness, scope);
|
|
484
|
+
if (harness.transport === "native-http") return { [SERVER_NAME]: {
|
|
485
|
+
type: "http",
|
|
486
|
+
url: mcpUrl
|
|
487
|
+
} };
|
|
488
|
+
const { command, args } = mcpRemoteInvocation(mcpUrl);
|
|
489
|
+
if (serverPropertyFor(harness, scope) === "context_servers") return { [SERVER_NAME]: {
|
|
490
|
+
source: "custom",
|
|
491
|
+
command: {
|
|
492
|
+
path: command,
|
|
493
|
+
args
|
|
494
|
+
}
|
|
495
|
+
} };
|
|
496
|
+
return { [SERVER_NAME]: {
|
|
497
|
+
command,
|
|
498
|
+
args
|
|
499
|
+
} };
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
//#endregion
|
|
503
|
+
//#region ../mcp-harnesses/dist/node/paths.js
|
|
504
|
+
function defaultEnv() {
|
|
505
|
+
return {
|
|
506
|
+
platform: process.platform,
|
|
507
|
+
homedir: homedir(),
|
|
508
|
+
pathVar: process.env.PATH ?? "",
|
|
509
|
+
cwd: process.cwd()
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
/** Expand a leading `~` / `~/…` to the given home directory. */
|
|
513
|
+
function expandTilde(p, home) {
|
|
514
|
+
if (p === "~") return home;
|
|
515
|
+
if (p.startsWith("~/") || p.startsWith("~\\")) return join(home, p.slice(2));
|
|
516
|
+
return p;
|
|
517
|
+
}
|
|
518
|
+
/**
|
|
519
|
+
* The config location for one scope of a harness on the given platform, if any.
|
|
520
|
+
* Repo-relative (`project`) scopes carry no platform location — they resolve
|
|
521
|
+
* against a project root instead, which is D2's job.
|
|
522
|
+
*/
|
|
523
|
+
function locationFor(harness, platform, scope = defaultScope(harness)) {
|
|
524
|
+
return scopeDef(harness, scope)?.locations?.find((l) => l.platform === platform);
|
|
525
|
+
}
|
|
526
|
+
/**
|
|
527
|
+
* Install signals for this platform. Signals live on the harness, not the
|
|
528
|
+
* scope — "is this client on this machine" has one answer per machine.
|
|
529
|
+
*/
|
|
530
|
+
function detectSignalsFor(harness, platform) {
|
|
531
|
+
return harness.detectSignals.find((d) => d.platform === platform)?.signals ?? [];
|
|
532
|
+
}
|
|
533
|
+
/** Absolute (tilde-expanded) config path for a harness, or undefined if the
|
|
534
|
+
* harness has no location on this platform. */
|
|
535
|
+
function resolveConfigPath(harness, env, scope = defaultScope(harness)) {
|
|
536
|
+
const def = scopeDef(harness, scope);
|
|
537
|
+
if (!def) return void 0;
|
|
538
|
+
if (def.projectPath) return join(resolveProjectRoot(env).root, ...def.projectPath.split("/"));
|
|
539
|
+
const loc = locationFor(harness, env.platform, scope);
|
|
540
|
+
if (!loc) return void 0;
|
|
541
|
+
return expandTilde(loc.configPath, env.homedir);
|
|
542
|
+
}
|
|
543
|
+
/**
|
|
544
|
+
* Nearest ancestor of `cwd` containing `.git`, or `undefined`.
|
|
545
|
+
*
|
|
546
|
+
* Matches `.git` as a FILE as well as a directory: a git worktree checkout has
|
|
547
|
+
* a `.git` *file* pointing at the real gitdir, so a directory-only check would
|
|
548
|
+
* miss every worktree — including the ones this repo's own tooling creates.
|
|
549
|
+
*/
|
|
550
|
+
function findRepoRoot(cwd) {
|
|
551
|
+
let dir = resolve(cwd);
|
|
552
|
+
for (;;) {
|
|
553
|
+
if (existsSync(join(dir, ".git"))) return dir;
|
|
554
|
+
const parent = dirname(dir);
|
|
555
|
+
if (parent === dir) return void 0;
|
|
556
|
+
dir = parent;
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
/**
|
|
560
|
+
* Resolve where `project` scope writes.
|
|
561
|
+
*
|
|
562
|
+
* `isRepo: false` is NOT a soft warning. Outside a repository the walk falls
|
|
563
|
+
* back to the cwd, and if that cwd happens to be `$HOME` then Cursor's
|
|
564
|
+
* repo-relative `.cursor/mcp.json` resolves to `~/.cursor/mcp.json` — the
|
|
565
|
+
* exact path its USER scope owns. Silently writing there would clobber a
|
|
566
|
+
* different scope's config. Callers must refuse project scope when this is
|
|
567
|
+
* false; {@link installHarnessSync} does.
|
|
568
|
+
*/
|
|
569
|
+
function resolveProjectRoot(env) {
|
|
570
|
+
const root = findRepoRoot(env.cwd);
|
|
571
|
+
return root ? {
|
|
572
|
+
root,
|
|
573
|
+
isRepo: true
|
|
574
|
+
} : {
|
|
575
|
+
root: resolve(env.cwd),
|
|
576
|
+
isRepo: false
|
|
577
|
+
};
|
|
578
|
+
}
|
|
579
|
+
/**
|
|
580
|
+
* Where our server entry sits INSIDE a scope's config file.
|
|
581
|
+
*
|
|
582
|
+
* Usually `[<serverProperty>, 'levr']` at the top level. A scope declaring
|
|
583
|
+
* `cwdKeyedUnder` nests one level deeper, keyed by the launch directory:
|
|
584
|
+
* Claude Code's `local` scope lives at
|
|
585
|
+
* `projects[<cwd>].mcpServers.levr` in `~/.claude.json`.
|
|
586
|
+
*
|
|
587
|
+
* `cwdKey` is passed in rather than read off the env because the key is
|
|
588
|
+
* specifically the directory the CLIENT was launched in — which is NOT the
|
|
589
|
+
* repo root, and callers should have to say which directory they mean.
|
|
590
|
+
* Shared by detect and install so the two can never disagree about where an
|
|
591
|
+
* entry lives.
|
|
592
|
+
*/
|
|
593
|
+
function entryPathFor(harness, scope, cwdKey) {
|
|
594
|
+
const tail = [serverPropertyFor(harness, scope), SERVER_NAME];
|
|
595
|
+
const nest = scopeDef(harness, scope)?.cwdKeyedUnder;
|
|
596
|
+
return nest ? [
|
|
597
|
+
nest,
|
|
598
|
+
cwdKey,
|
|
599
|
+
...tail
|
|
600
|
+
] : tail;
|
|
601
|
+
}
|
|
602
|
+
/**
|
|
603
|
+
* Canonical form of a path that may not exist yet.
|
|
604
|
+
*
|
|
605
|
+
* `realpathSync` throws on a missing path, but the files we compare are
|
|
606
|
+
* usually about to be created — so resolve the deepest ancestor that DOES
|
|
607
|
+
* exist and re-join the rest. Without this, a symlinked `$HOME` produces two
|
|
608
|
+
* different strings for one file.
|
|
609
|
+
*/
|
|
610
|
+
function canonicalizePath(p) {
|
|
611
|
+
let head = resolve(p);
|
|
612
|
+
const tail = [];
|
|
613
|
+
for (;;) try {
|
|
614
|
+
return join(realpathSync(head), ...tail.reverse());
|
|
615
|
+
} catch {
|
|
616
|
+
const parent = dirname(head);
|
|
617
|
+
if (parent === head) return resolve(p);
|
|
618
|
+
tail.push(head.slice(parent.length + 1));
|
|
619
|
+
head = parent;
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
/**
|
|
623
|
+
* Do two paths name the same file?
|
|
624
|
+
*
|
|
625
|
+
* Compares canonical forms, and on the case-insensitive platforms
|
|
626
|
+
* (darwin, win32) compares case-insensitively too — `~/.Cursor/mcp.json` and
|
|
627
|
+
* `~/.cursor/mcp.json` are one file there and two on Linux.
|
|
628
|
+
*/
|
|
629
|
+
function pathsResolveSame(a, b, platform) {
|
|
630
|
+
const ca = canonicalizePath(a);
|
|
631
|
+
const cb = canonicalizePath(b);
|
|
632
|
+
if (ca === cb) return true;
|
|
633
|
+
if (platform === "linux") return false;
|
|
634
|
+
return ca.toLowerCase() === cb.toLowerCase();
|
|
635
|
+
}
|
|
636
|
+
/**
|
|
637
|
+
* Would installing this harness at `scope` write over a DIFFERENT scope's
|
|
638
|
+
* config file?
|
|
639
|
+
*
|
|
640
|
+
* The `isRepo` check alone is not this question. `git init` in `$HOME` — an
|
|
641
|
+
* ordinary dotfiles setup — makes Cursor's repo-relative `.cursor/mcp.json`
|
|
642
|
+
* resolve to `~/.cursor/mcp.json`, the file its own USER scope owns: the
|
|
643
|
+
* guard passes and one scope silently overwrites the other. Compare the
|
|
644
|
+
* resolved paths instead of inferring from repo-ness.
|
|
645
|
+
*/
|
|
646
|
+
function collidingScope(harness, env, scope) {
|
|
647
|
+
if (!scopeDef(harness, scope)?.projectPath) return void 0;
|
|
648
|
+
const target = resolveConfigPath(harness, env, scope);
|
|
649
|
+
if (!target) return void 0;
|
|
650
|
+
const targetEntry = entryPathFor(harness, scope, env.cwd).join("\0");
|
|
651
|
+
for (const other of harness.scopes) {
|
|
652
|
+
if (other.scope === scope) continue;
|
|
653
|
+
const otherPath = resolveConfigPath(harness, env, other.scope);
|
|
654
|
+
if (!otherPath) continue;
|
|
655
|
+
if (!pathsResolveSame(target, otherPath, env.platform)) continue;
|
|
656
|
+
if (entryPathFor(harness, other.scope, env.cwd).join("\0") === targetEntry) return other.scope;
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
const WIN_EXTS = [
|
|
660
|
+
"",
|
|
661
|
+
".exe",
|
|
662
|
+
".cmd",
|
|
663
|
+
".bat"
|
|
664
|
+
];
|
|
665
|
+
/**
|
|
666
|
+
* Resolve `bin` on PATH, returning the full path. Pure `process.env.PATH`
|
|
667
|
+
* scan — never spawns a shell (no `which`/`where` subprocess).
|
|
668
|
+
*
|
|
669
|
+
* Returns the PATH, not a boolean, because the caller has to know the
|
|
670
|
+
* extension: a `.cmd`/`.bat` cannot be spawned the same way as a native
|
|
671
|
+
* binary. Requires a regular file — a DIRECTORY named `claude` on PATH would
|
|
672
|
+
* otherwise resolve, then fail at spawn with EISDIR and be reported as a
|
|
673
|
+
* broken install rather than an absent one.
|
|
674
|
+
*/
|
|
675
|
+
function whichPathSync(bin, env) {
|
|
676
|
+
const exts = env.platform === "win32" ? WIN_EXTS : [""];
|
|
677
|
+
for (const dir of env.pathVar.split(delimiter)) {
|
|
678
|
+
if (!dir) continue;
|
|
679
|
+
for (const ext of exts) {
|
|
680
|
+
const candidate = join(dir, bin + ext);
|
|
681
|
+
try {
|
|
682
|
+
if (statSync(candidate).isFile()) return candidate;
|
|
683
|
+
} catch {}
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
/** Is `bin` resolvable on PATH? */
|
|
688
|
+
function whichSync(bin, env) {
|
|
689
|
+
return whichPathSync(bin, env) !== void 0;
|
|
690
|
+
}
|
|
691
|
+
/** Does one `installSignals` entry match on this machine? Supports
|
|
692
|
+
* `which:<bin>`, `~`-prefixed paths, and absolute (app-bundle) paths. */
|
|
693
|
+
function signalMatches(signal, env) {
|
|
694
|
+
if (signal.startsWith("which:")) return whichSync(signal.slice(6), env);
|
|
695
|
+
return existsSync(signal.startsWith("~") ? expandTilde(signal, env.homedir) : signal);
|
|
696
|
+
}
|
|
697
|
+
/**
|
|
698
|
+
* The value our server key currently holds for this scope, or `undefined`.
|
|
699
|
+
*
|
|
700
|
+
* Presence alone is not enough for a `cli-command` scope: `claude mcp add` is
|
|
701
|
+
* a no-op when the key exists, so telling "already correct" from "present but
|
|
702
|
+
* pointing somewhere else" needs the value.
|
|
703
|
+
*/
|
|
704
|
+
function readServerEntry(harness, scope, configPath, cwdKey, parseJsonc) {
|
|
705
|
+
const text = readTextOrNull(configPath);
|
|
706
|
+
if (!text) return void 0;
|
|
707
|
+
return getAtPath(parseJsonc(text), entryPathFor(harness, scope, cwdKey));
|
|
708
|
+
}
|
|
709
|
+
/** Read a text file, or `null` if it doesn't exist / can't be read. */
|
|
710
|
+
function readTextOrNull(path) {
|
|
711
|
+
try {
|
|
712
|
+
return readFileSync(path, "utf8");
|
|
713
|
+
} catch {
|
|
714
|
+
return null;
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
/** Safe nested lookup over an unknown-typed parsed JSON value. */
|
|
718
|
+
function getAtPath(obj, path) {
|
|
719
|
+
let cur = obj;
|
|
720
|
+
for (const key of path) {
|
|
721
|
+
if (cur === null || typeof cur !== "object") return void 0;
|
|
722
|
+
cur = cur[key];
|
|
723
|
+
}
|
|
724
|
+
return cur;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
//#endregion
|
|
728
|
+
//#region ../mcp-harnesses/dist/node/detect.js
|
|
729
|
+
/**
|
|
730
|
+
* Is our server key present for this scope?
|
|
731
|
+
*
|
|
732
|
+
* Claude Code shares one file (`~/.claude.json`) between its `user` and
|
|
733
|
+
* `local` scopes, so the two are told apart by WHERE in the file they sit,
|
|
734
|
+
* not by which file is read — hence the entry path rather than a bare
|
|
735
|
+
* top-level lookup.
|
|
736
|
+
*/
|
|
737
|
+
function isServerConfigured(harness, scope, configPath, cwdKey) {
|
|
738
|
+
const text = readTextOrNull(configPath);
|
|
739
|
+
if (!text) return false;
|
|
740
|
+
const val = getAtPath(parse(text), entryPathFor(harness, scope, cwdKey));
|
|
741
|
+
return val !== void 0 && val !== null;
|
|
742
|
+
}
|
|
743
|
+
function detectScope(harness, env, def) {
|
|
744
|
+
const configPath = resolveConfigPath(harness, env, def.scope);
|
|
745
|
+
const inRepo = def.projectPath ? resolveProjectRoot(env).isRepo : true;
|
|
746
|
+
const collides = collidingScope(harness, env, def.scope) !== void 0;
|
|
747
|
+
const available = Boolean(configPath) && inRepo && !collides;
|
|
748
|
+
return {
|
|
749
|
+
scope: def.scope,
|
|
750
|
+
installKind: def.installKind,
|
|
751
|
+
configPath: available ? configPath ?? "" : "",
|
|
752
|
+
available,
|
|
753
|
+
alreadyConfigured: available && configPath ? isServerConfigured(harness, def.scope, configPath, env.cwd) : false
|
|
754
|
+
};
|
|
755
|
+
}
|
|
756
|
+
function detectOne(harness, env) {
|
|
757
|
+
const scopes = harness.scopes.map((def) => detectScope(harness, env, def));
|
|
758
|
+
const available = scopes.some((s) => s.available);
|
|
759
|
+
const installed = detectSignalsFor(harness, env.platform).some((s) => signalMatches(s, env)) || scopes.some((s) => s.configPath !== "" && existsSync(s.configPath));
|
|
760
|
+
const fallback = scopes.find((s) => s.scope === defaultScope(harness));
|
|
761
|
+
return {
|
|
762
|
+
id: harness.id,
|
|
763
|
+
label: harness.label,
|
|
764
|
+
installed,
|
|
765
|
+
scopes,
|
|
766
|
+
alreadyConfigured: fallback?.alreadyConfigured ?? false,
|
|
767
|
+
configPath: fallback?.configPath ?? "",
|
|
768
|
+
available,
|
|
769
|
+
comingSoon: harness.comingSoon
|
|
770
|
+
};
|
|
771
|
+
}
|
|
772
|
+
/** Synchronous detection over the whole catalog. Exported for tests. */
|
|
773
|
+
function detectSync(env = defaultEnv()) {
|
|
774
|
+
return HARNESSES.map((h) => detectOne(h, env));
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
//#endregion
|
|
778
|
+
//#region ../mcp-harnesses/dist/node/exec.js
|
|
779
|
+
/** Upper bound on a client CLI invocation. `claude mcp add` is a local config
|
|
780
|
+
* edit — anything approaching this is hung, not slow. */
|
|
781
|
+
const COMMAND_TIMEOUT_MS = 3e4;
|
|
782
|
+
/** Keep a failing client's diagnostics readable in a multi-client report. */
|
|
783
|
+
const MAX_STDERR = 2e3;
|
|
784
|
+
/**
|
|
785
|
+
* Windows batch launchers (`.cmd` / `.bat`) cannot be spawned directly.
|
|
786
|
+
*
|
|
787
|
+
* Since the CVE-2024-27980 fix (Node 18.20.2 / 20.12.2 / 21.7.2),
|
|
788
|
+
* `execFileSync` on a `.cmd` without `shell: true` throws EINVAL. This
|
|
789
|
+
* matters because `npm i -g @anthropic-ai/claude-code` installs `claude.cmd`,
|
|
790
|
+
* so the entire npm-installed Windows population takes this path.
|
|
791
|
+
*/
|
|
792
|
+
function needsShell(binPath) {
|
|
793
|
+
return /\.(cmd|bat)$/i.test(binPath);
|
|
794
|
+
}
|
|
795
|
+
/**
|
|
796
|
+
* Characters that change meaning inside a Windows command line. Only consulted
|
|
797
|
+
* on the `shell: true` path, which is the one case where an argument is
|
|
798
|
+
* re-parsed rather than passed through untouched.
|
|
799
|
+
*/
|
|
800
|
+
const SHELL_METACHARACTERS = /[&|<>^"%!`]/;
|
|
801
|
+
/**
|
|
802
|
+
* Run a harness CLI invocation, or explain why it was not run.
|
|
803
|
+
*
|
|
804
|
+
* Resolution uses {@link whichPathSync} — a pure `PATH` scan that spawns
|
|
805
|
+
* nothing, so the "is this installed" check can never itself be the thing
|
|
806
|
+
* that hangs. It returns the resolved path rather than a boolean because the
|
|
807
|
+
* extension decides how the command must be launched.
|
|
808
|
+
*/
|
|
809
|
+
function runHarnessCommandSync(argv, opts = {}) {
|
|
810
|
+
const env = opts.env ?? defaultEnv();
|
|
811
|
+
const dryRun = opts.dryRun ?? false;
|
|
812
|
+
const [bin, ...args] = argv;
|
|
813
|
+
if (!bin) return {
|
|
814
|
+
executed: false,
|
|
815
|
+
ok: false,
|
|
816
|
+
reason: "empty-command"
|
|
817
|
+
};
|
|
818
|
+
if (dryRun) return {
|
|
819
|
+
executed: false,
|
|
820
|
+
ok: true,
|
|
821
|
+
reason: "dry-run"
|
|
822
|
+
};
|
|
823
|
+
const binPath = whichPathSync(bin, env);
|
|
824
|
+
if (!binPath) return {
|
|
825
|
+
executed: false,
|
|
826
|
+
ok: true,
|
|
827
|
+
reason: "binary-not-found"
|
|
828
|
+
};
|
|
829
|
+
const useShell = needsShell(binPath);
|
|
830
|
+
if (useShell && args.some((a) => SHELL_METACHARACTERS.test(a))) return {
|
|
831
|
+
executed: false,
|
|
832
|
+
ok: false,
|
|
833
|
+
reason: "unsafe-argument"
|
|
834
|
+
};
|
|
835
|
+
try {
|
|
836
|
+
execFileSync(binPath, args, {
|
|
837
|
+
encoding: "utf8",
|
|
838
|
+
stdio: [
|
|
839
|
+
"ignore",
|
|
840
|
+
"pipe",
|
|
841
|
+
"pipe"
|
|
842
|
+
],
|
|
843
|
+
timeout: COMMAND_TIMEOUT_MS,
|
|
844
|
+
env: {
|
|
845
|
+
...process.env,
|
|
846
|
+
HOME: env.homedir,
|
|
847
|
+
USERPROFILE: env.homedir
|
|
848
|
+
},
|
|
849
|
+
cwd: env.cwd,
|
|
850
|
+
...useShell ? { shell: true } : {}
|
|
851
|
+
});
|
|
852
|
+
return {
|
|
853
|
+
executed: true,
|
|
854
|
+
ok: true
|
|
855
|
+
};
|
|
856
|
+
} catch (err) {
|
|
857
|
+
const e = err;
|
|
858
|
+
const raw = typeof e.stderr === "string" ? e.stderr : e.stderr?.toString("utf8") ?? "";
|
|
859
|
+
return {
|
|
860
|
+
executed: true,
|
|
861
|
+
ok: false,
|
|
862
|
+
...typeof e.status === "number" ? { exitCode: e.status } : {},
|
|
863
|
+
stderr: (raw.trim() || e.message || "command failed").slice(0, MAX_STDERR)
|
|
864
|
+
};
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
//#endregion
|
|
869
|
+
//#region ../mcp-harnesses/dist/node/install.js
|
|
870
|
+
const FORMAT = {
|
|
871
|
+
insertSpaces: true,
|
|
872
|
+
tabSize: 2,
|
|
873
|
+
eol: "\n"
|
|
874
|
+
};
|
|
875
|
+
/**
|
|
876
|
+
* Resolve the target path for a scope, or the reason it cannot be resolved.
|
|
877
|
+
* Shared by install and remove so the two can never disagree about where a
|
|
878
|
+
* scope lives.
|
|
879
|
+
*/
|
|
880
|
+
function resolveTarget(harness, env, scope) {
|
|
881
|
+
const def = scopeDef(harness, scope);
|
|
882
|
+
if (!def) return { reason: "unsupported-scope" };
|
|
883
|
+
if (def.projectPath && !resolveProjectRoot(env).isRepo) return { reason: "not-a-repo" };
|
|
884
|
+
const collides = collidingScope(harness, env, scope);
|
|
885
|
+
if (collides) return {
|
|
886
|
+
reason: "scope-collision",
|
|
887
|
+
collidesWith: collides
|
|
888
|
+
};
|
|
889
|
+
const path = resolveConfigPath(harness, env, scope);
|
|
890
|
+
if (!path) return { reason: "no-location" };
|
|
891
|
+
return { path };
|
|
892
|
+
}
|
|
893
|
+
/**
|
|
894
|
+
* What the client currently holds for this scope, read from its own config.
|
|
895
|
+
*
|
|
896
|
+
* `url` is present for a `native-http` entry (`{ type: 'http', url }`); for an
|
|
897
|
+
* `mcp-remote` entry the URL is the last argv element. Absent `url` with
|
|
898
|
+
* `present: true` means an entry we cannot interpret — treated as a mismatch
|
|
899
|
+
* rather than assumed correct.
|
|
900
|
+
*/
|
|
901
|
+
function currentEntry(harness, env, scope) {
|
|
902
|
+
const configPath = resolveConfigPath(harness, env, scope);
|
|
903
|
+
if (!configPath) return { present: false };
|
|
904
|
+
const entry = readServerEntry(harness, scope, configPath, env.cwd, (t) => parse(t));
|
|
905
|
+
if (entry === void 0 || entry === null) return { present: false };
|
|
906
|
+
const e = entry;
|
|
907
|
+
if (typeof e.url === "string") return {
|
|
908
|
+
present: true,
|
|
909
|
+
url: e.url
|
|
910
|
+
};
|
|
911
|
+
if (Array.isArray(e.args)) {
|
|
912
|
+
const last = e.args[e.args.length - 1];
|
|
913
|
+
if (typeof last === "string") return {
|
|
914
|
+
present: true,
|
|
915
|
+
url: last
|
|
916
|
+
};
|
|
917
|
+
}
|
|
918
|
+
return { present: true };
|
|
919
|
+
}
|
|
920
|
+
/** JSON-structural equality — sufficient for our small config values. */
|
|
921
|
+
function sameValue(a, b) {
|
|
922
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
923
|
+
}
|
|
924
|
+
/** Merge (or preview) our MCP into a harness config. */
|
|
925
|
+
function installHarnessSync(harness, mcpUrl, opts = {}) {
|
|
926
|
+
const env = opts.env ?? defaultEnv();
|
|
927
|
+
const dryRun = opts.dryRun ?? false;
|
|
928
|
+
const scope = opts.scope ?? defaultScope(harness);
|
|
929
|
+
const def = scopeDef(harness, scope);
|
|
930
|
+
if (!def) return {
|
|
931
|
+
ok: false,
|
|
932
|
+
wrote: false,
|
|
933
|
+
path: "",
|
|
934
|
+
alreadyConfigured: false,
|
|
935
|
+
dryRun,
|
|
936
|
+
scope,
|
|
937
|
+
reason: "unsupported-scope"
|
|
938
|
+
};
|
|
939
|
+
if (def.installKind === "cli-command") {
|
|
940
|
+
const argv = renderHarnessCommand(harness, mcpUrl, scope);
|
|
941
|
+
const base = {
|
|
942
|
+
wrote: false,
|
|
943
|
+
path: "",
|
|
944
|
+
command: argv.join(" "),
|
|
945
|
+
dryRun,
|
|
946
|
+
scope
|
|
947
|
+
};
|
|
948
|
+
const existing$1 = currentEntry(harness, env, scope);
|
|
949
|
+
if (existing$1.present) {
|
|
950
|
+
if (existing$1.url === mcpUrl) return {
|
|
951
|
+
...base,
|
|
952
|
+
ok: true,
|
|
953
|
+
executed: false,
|
|
954
|
+
alreadyConfigured: true
|
|
955
|
+
};
|
|
956
|
+
return {
|
|
957
|
+
...base,
|
|
958
|
+
ok: false,
|
|
959
|
+
executed: false,
|
|
960
|
+
alreadyConfigured: false,
|
|
961
|
+
reason: "url-mismatch",
|
|
962
|
+
...existing$1.url ? { currentUrl: existing$1.url } : {}
|
|
963
|
+
};
|
|
964
|
+
}
|
|
965
|
+
const run = opts.runCommand ?? runHarnessCommandSync;
|
|
966
|
+
const outcome = opts.execute ?? true ? run(argv, {
|
|
967
|
+
env,
|
|
968
|
+
dryRun
|
|
969
|
+
}) : {
|
|
970
|
+
executed: false,
|
|
971
|
+
ok: true,
|
|
972
|
+
reason: "not-requested"
|
|
973
|
+
};
|
|
974
|
+
return {
|
|
975
|
+
...base,
|
|
976
|
+
ok: outcome.ok,
|
|
977
|
+
executed: outcome.executed,
|
|
978
|
+
...outcome.ok ? {} : { commandError: outcome.stderr },
|
|
979
|
+
alreadyConfigured: false
|
|
980
|
+
};
|
|
981
|
+
}
|
|
982
|
+
const target = resolveTarget(harness, env, scope);
|
|
983
|
+
if ("reason" in target) return {
|
|
984
|
+
ok: false,
|
|
985
|
+
wrote: false,
|
|
986
|
+
path: "",
|
|
987
|
+
alreadyConfigured: false,
|
|
988
|
+
dryRun,
|
|
989
|
+
scope,
|
|
990
|
+
reason: target.reason,
|
|
991
|
+
...target.collidesWith ? { collidesWith: target.collidesWith } : {}
|
|
992
|
+
};
|
|
993
|
+
const { path } = target;
|
|
994
|
+
const entryValue = buildServerEntry(harness, mcpUrl, scope)[SERVER_NAME];
|
|
995
|
+
const modPath = entryPathFor(harness, scope, env.cwd);
|
|
996
|
+
const existing = readTextOrNull(path);
|
|
997
|
+
const baseText = existing && existing.trim() ? existing : "{}";
|
|
998
|
+
const current = getAtPath(parse(baseText), modPath);
|
|
999
|
+
const alreadyConfigured = current !== void 0 && sameValue(current, entryValue);
|
|
1000
|
+
const nextText = applyEdits(baseText, modify(baseText, modPath, entryValue, { formattingOptions: FORMAT }));
|
|
1001
|
+
if (alreadyConfigured) return {
|
|
1002
|
+
ok: true,
|
|
1003
|
+
wrote: false,
|
|
1004
|
+
path,
|
|
1005
|
+
alreadyConfigured: true,
|
|
1006
|
+
dryRun,
|
|
1007
|
+
scope,
|
|
1008
|
+
preview: nextText
|
|
1009
|
+
};
|
|
1010
|
+
if (dryRun) return {
|
|
1011
|
+
ok: true,
|
|
1012
|
+
wrote: false,
|
|
1013
|
+
path,
|
|
1014
|
+
alreadyConfigured: false,
|
|
1015
|
+
dryRun: true,
|
|
1016
|
+
scope,
|
|
1017
|
+
preview: nextText
|
|
1018
|
+
};
|
|
1019
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
1020
|
+
const finalText = nextText.endsWith("\n") ? nextText : `${nextText}\n`;
|
|
1021
|
+
writeFileSync(path, finalText, "utf8");
|
|
1022
|
+
return {
|
|
1023
|
+
ok: true,
|
|
1024
|
+
wrote: true,
|
|
1025
|
+
path,
|
|
1026
|
+
alreadyConfigured: false,
|
|
1027
|
+
dryRun: false,
|
|
1028
|
+
scope,
|
|
1029
|
+
preview: finalText
|
|
1030
|
+
};
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
//#endregion
|
|
1034
|
+
//#region src/mcp/url.ts
|
|
1035
|
+
const KNOWN_MCP_URLS = {
|
|
1036
|
+
"api.levr.one": "https://ai.levr.one/api/v1/mcp",
|
|
1037
|
+
"api.levr.now": "https://ai.levr.now/api/v1/mcp"
|
|
1038
|
+
};
|
|
1039
|
+
/**
|
|
1040
|
+
* Resolve the MCP server URL: `--url` flag > `LEVR_MCP_URL` env > derived
|
|
1041
|
+
* from the resolved API URL (which itself honors `LEVR_URL` > the URL stored
|
|
1042
|
+
* at login > production default, ENG-2361). Known Levr hosts map to their
|
|
1043
|
+
* app-host MCP resource; anything else (localhost dev stacks, custom
|
|
1044
|
+
* deployments) derives `<api-url>/v1/mcp`.
|
|
1045
|
+
*/
|
|
1046
|
+
function resolveMcpUrl(flagUrl) {
|
|
1047
|
+
if (flagUrl) return {
|
|
1048
|
+
url: stripSlash(flagUrl),
|
|
1049
|
+
source: "flag"
|
|
1050
|
+
};
|
|
1051
|
+
const envVar = process.env["LEVR_MCP_URL"];
|
|
1052
|
+
if (envVar) return {
|
|
1053
|
+
url: stripSlash(envVar),
|
|
1054
|
+
source: "env:LEVR_MCP_URL"
|
|
1055
|
+
};
|
|
1056
|
+
const apiUrl = getApiUrl();
|
|
1057
|
+
return {
|
|
1058
|
+
url: knownMcpUrl(apiUrl) ?? `${apiUrl}/v1/mcp`,
|
|
1059
|
+
source: `derived:${apiUrl}`
|
|
1060
|
+
};
|
|
1061
|
+
}
|
|
1062
|
+
function knownMcpUrl(apiUrl) {
|
|
1063
|
+
try {
|
|
1064
|
+
return KNOWN_MCP_URLS[new URL(apiUrl).host];
|
|
1065
|
+
} catch {
|
|
1066
|
+
return;
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
function stripSlash(url) {
|
|
1070
|
+
return url.replace(/\/+$/, "");
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
//#endregion
|
|
1074
|
+
//#region src/mcp/run.ts
|
|
1075
|
+
/**
|
|
1076
|
+
* Scope used when the caller passes no `--scope`.
|
|
1077
|
+
*
|
|
1078
|
+
* Uniform `user` for every harness (plan decision C1). A no-op for the
|
|
1079
|
+
* config-file clients — that is already where they write — but a change for
|
|
1080
|
+
* Claude Code, which previously inherited its OWN default of `local`. `user`
|
|
1081
|
+
* is the only default that means the same thing everywhere, and it matches
|
|
1082
|
+
* what someone running an installer once expects: available in every project.
|
|
1083
|
+
*/
|
|
1084
|
+
const DEFAULT_SCOPE = "user";
|
|
1085
|
+
/** Harness ids to pre-select in interactive mode: detected + installable +
|
|
1086
|
+
* not-already-configured. "Already configured" is judged in the scope we are
|
|
1087
|
+
* about to install into, not the harness's default one. */
|
|
1088
|
+
function autoSelectIds(detected, scope) {
|
|
1089
|
+
return detected.filter((d) => {
|
|
1090
|
+
if (!d.available || d.comingSoon || !d.installed) return false;
|
|
1091
|
+
const inScope = scope ? d.scopes.find((s) => s.scope === scope) : void 0;
|
|
1092
|
+
return !(inScope ? inScope.alreadyConfigured : d.alreadyConfigured);
|
|
1093
|
+
}).map((d) => d.id);
|
|
1094
|
+
}
|
|
1095
|
+
/**
|
|
1096
|
+
* Build the client picker for the scope we are actually about to install
|
|
1097
|
+
* into.
|
|
1098
|
+
*
|
|
1099
|
+
* Pure so it can be tested without a TTY — and it needs testing, because the
|
|
1100
|
+
* question it answers is scope-dependent: a client configured at `user` is
|
|
1101
|
+
* NOT configured at `project`, and labelling it "already set up" while
|
|
1102
|
+
* installing to `project` is simply wrong.
|
|
1103
|
+
*/
|
|
1104
|
+
function clientChoices(detected, scope) {
|
|
1105
|
+
const preselect = new Set(autoSelectIds(detected, scope));
|
|
1106
|
+
return detected.filter((d) => d.available && !d.comingSoon).map((d) => {
|
|
1107
|
+
const inScope = d.scopes.find((s) => s.scope === scope);
|
|
1108
|
+
const harness = getHarness(d.id);
|
|
1109
|
+
let hint;
|
|
1110
|
+
if (!inScope) hint = `no ${scope} scope — will use ${harness ? defaultScope(harness) : DEFAULT_SCOPE}`;
|
|
1111
|
+
else if (inScope.alreadyConfigured) hint = `already set up (${scope})`;
|
|
1112
|
+
else hint = d.installed ? "detected" : "not detected";
|
|
1113
|
+
return {
|
|
1114
|
+
value: d.id,
|
|
1115
|
+
label: d.label,
|
|
1116
|
+
hint,
|
|
1117
|
+
selected: preselect.has(d.id)
|
|
1118
|
+
};
|
|
1119
|
+
});
|
|
1120
|
+
}
|
|
1121
|
+
/** Scopes worth offering for a selection: any scope at least one selected
|
|
1122
|
+
* client can actually use here. Availability already accounts for "are we
|
|
1123
|
+
* inside a repo", so project scope disappears outside one. */
|
|
1124
|
+
function offerableScopes(selectedIds, detected) {
|
|
1125
|
+
return HARNESS_SCOPES.filter((scope) => selectedIds.some((id) => detected.find((d) => d.id === id)?.scopes.some((s) => s.scope === scope && s.available)));
|
|
1126
|
+
}
|
|
1127
|
+
/** Resolve `--all` / `--client` into concrete, installable harness ids. */
|
|
1128
|
+
function resolveRequestedIds(options, detected) {
|
|
1129
|
+
if (options.all) return {
|
|
1130
|
+
ids: detected.filter((d) => d.available && !d.comingSoon).map((d) => d.id),
|
|
1131
|
+
unknown: [],
|
|
1132
|
+
comingSoon: []
|
|
1133
|
+
};
|
|
1134
|
+
const ids = [];
|
|
1135
|
+
const unknown = [];
|
|
1136
|
+
const comingSoon = [];
|
|
1137
|
+
for (const c of options.clients ?? []) {
|
|
1138
|
+
const harness = getHarness(c);
|
|
1139
|
+
if (!harness) unknown.push(c);
|
|
1140
|
+
else if (harness.comingSoon) comingSoon.push(c);
|
|
1141
|
+
else ids.push(c);
|
|
1142
|
+
}
|
|
1143
|
+
return {
|
|
1144
|
+
ids,
|
|
1145
|
+
unknown,
|
|
1146
|
+
comingSoon
|
|
1147
|
+
};
|
|
1148
|
+
}
|
|
1149
|
+
/**
|
|
1150
|
+
* Install each selected id, collecting structured outcomes.
|
|
1151
|
+
*
|
|
1152
|
+
* The unsupported-scope policy lives here, and it turns on HOW the client was
|
|
1153
|
+
* selected — that is what says whether the user asserted this pairing:
|
|
1154
|
+
*
|
|
1155
|
+
* - named via `--client` → install at the requested scope and let it fail,
|
|
1156
|
+
* so the report says exactly what was refused and the run exits non-zero;
|
|
1157
|
+
* - swept in by `--all` or a multiselect → fall back to the harness's own
|
|
1158
|
+
* scope and record `fallbackFrom`, so the report states what was used.
|
|
1159
|
+
*
|
|
1160
|
+
* A fallback is never silent.
|
|
1161
|
+
*/
|
|
1162
|
+
function installSelected(ids, plan, install) {
|
|
1163
|
+
const outcomes = [];
|
|
1164
|
+
for (const id of ids) {
|
|
1165
|
+
const harness = getHarness(id);
|
|
1166
|
+
if (!harness) continue;
|
|
1167
|
+
const canHonor = supportsScope(harness, plan.scope);
|
|
1168
|
+
const named = plan.namedIds.has(id);
|
|
1169
|
+
const effective = canHonor || named ? plan.scope : defaultScope(harness);
|
|
1170
|
+
outcomes.push({
|
|
1171
|
+
id,
|
|
1172
|
+
label: harness.label,
|
|
1173
|
+
result: install(harness, plan.mcpUrl, plan.dryRun, effective),
|
|
1174
|
+
...canHonor ? {} : named ? {} : { fallbackFrom: plan.scope }
|
|
1175
|
+
});
|
|
1176
|
+
}
|
|
1177
|
+
return outcomes;
|
|
1178
|
+
}
|
|
1179
|
+
/**
|
|
1180
|
+
* The non-interactive run: detect, pick ids from `--all`/`--client` (or
|
|
1181
|
+
* auto-select when only `--yes` is given), install, and return a structured
|
|
1182
|
+
* report. No console output — the caller formats it.
|
|
1183
|
+
*/
|
|
1184
|
+
function runNonInteractive(options, url, urlSource, deps) {
|
|
1185
|
+
const detected = deps.detect();
|
|
1186
|
+
const scope = options.scope ?? DEFAULT_SCOPE;
|
|
1187
|
+
let ids;
|
|
1188
|
+
let unknown = [];
|
|
1189
|
+
let comingSoon = [];
|
|
1190
|
+
const byName = !options.all && (options.clients?.length ?? 0) > 0;
|
|
1191
|
+
if (options.all || byName) {
|
|
1192
|
+
const requested = resolveRequestedIds(options, detected);
|
|
1193
|
+
ids = requested.ids;
|
|
1194
|
+
unknown = requested.unknown;
|
|
1195
|
+
comingSoon = requested.comingSoon;
|
|
1196
|
+
} else ids = autoSelectIds(detected, scope);
|
|
1197
|
+
return {
|
|
1198
|
+
url,
|
|
1199
|
+
urlSource,
|
|
1200
|
+
scope,
|
|
1201
|
+
outcomes: installSelected(ids, {
|
|
1202
|
+
mcpUrl: url,
|
|
1203
|
+
dryRun: options.dryRun,
|
|
1204
|
+
scope,
|
|
1205
|
+
namedIds: new Set(byName ? ids : [])
|
|
1206
|
+
}, deps.install),
|
|
1207
|
+
unknownClients: unknown,
|
|
1208
|
+
comingSoonClients: comingSoon,
|
|
1209
|
+
dryRun: options.dryRun
|
|
1210
|
+
};
|
|
1211
|
+
}
|
|
1212
|
+
/** Why an install was refused, in the user's terms rather than the enum's. */
|
|
1213
|
+
function failureText(o) {
|
|
1214
|
+
const r = o.result;
|
|
1215
|
+
const harness = getHarness(o.id);
|
|
1216
|
+
switch (r.reason) {
|
|
1217
|
+
case "unsupported-scope": return `no ${r.scope} scope` + (harness ? ` (supports: ${supportedScopes(harness).join(", ")})` : "");
|
|
1218
|
+
case "not-a-repo": return "project scope needs a git repository (run from inside one)";
|
|
1219
|
+
case "scope-collision": return `${r.scope} scope resolves to the same file as ${r.collidesWith ?? "another"} scope here — refusing rather than overwriting it`;
|
|
1220
|
+
case "url-mismatch": return "already configured with a different URL" + (r.currentUrl ? ` (${r.currentUrl})` : "") + "; remove it first, then re-run";
|
|
1221
|
+
default:
|
|
1222
|
+
if (r.commandError) return `\`${r.command}\` (${r.commandError})`;
|
|
1223
|
+
return "no config location on this platform";
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
/** One human-readable status line per outcome. */
|
|
1227
|
+
function outcomeLine(o, dryRun) {
|
|
1228
|
+
const r = o.result;
|
|
1229
|
+
const note = o.fallbackFrom ? ` [${o.fallbackFrom} scope unsupported — used ${r.scope}]` : "";
|
|
1230
|
+
const where = r.path ? ` → ${r.path}` : "";
|
|
1231
|
+
if (!r.ok) return `${o.label}: failed — ${failureText(o)}`;
|
|
1232
|
+
if (r.alreadyConfigured) return `${o.label}: already set up (${r.scope})${where}${note}`;
|
|
1233
|
+
if (r.command) {
|
|
1234
|
+
if (r.executed) return `${o.label}: installed (${r.scope}) via \`${r.command}\`${note}`;
|
|
1235
|
+
return `${o.label} (${r.scope}): run \`${r.command}\`${note}`;
|
|
1236
|
+
}
|
|
1237
|
+
if (dryRun) return `${o.label}: would update (${r.scope})${where} (dry run — no changes)${note}`;
|
|
1238
|
+
if (r.wrote) return `${o.label}: installed (${r.scope})${where}${note}`;
|
|
1239
|
+
return `${o.label}: no change (${r.scope})${where}${note}`;
|
|
1240
|
+
}
|
|
1241
|
+
/** Render a report as a plain multi-line summary (used by the CLI + tests). */
|
|
1242
|
+
function formatReport(report) {
|
|
1243
|
+
const lines = [];
|
|
1244
|
+
lines.push(`MCP URL: ${report.url} (${report.urlSource})`);
|
|
1245
|
+
if (report.outcomes.length === 0) lines.push("No clients selected.");
|
|
1246
|
+
else for (const o of report.outcomes) lines.push(outcomeLine(o, report.dryRun));
|
|
1247
|
+
if (report.unknownClients.length > 0) lines.push(`Unknown clients (skipped): ${report.unknownClients.join(", ")}`);
|
|
1248
|
+
if (report.comingSoonClients.length > 0) lines.push(`Coming soon (skipped): ${report.comingSoonClients.join(", ")}`);
|
|
1249
|
+
return lines.join("\n");
|
|
1250
|
+
}
|
|
1251
|
+
/** Next-steps blurb after a run. */
|
|
1252
|
+
function nextStepsText(report) {
|
|
1253
|
+
if (report.dryRun) return "Dry run — re-run without --dry-run to apply these changes.";
|
|
1254
|
+
if (!report.outcomes.some((o) => o.result.wrote || o.result.executed || o.result.command)) return "Nothing to do.";
|
|
1255
|
+
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?\""];
|
|
1256
|
+
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.");
|
|
1257
|
+
return lines.join("\n");
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
//#endregion
|
|
1261
|
+
//#region src/commands/mcp/addHandler.ts
|
|
1262
|
+
const defaultInstall = (harness, mcpUrl, dryRun, scope) => installHarnessSync(harness, mcpUrl, {
|
|
1263
|
+
dryRun,
|
|
1264
|
+
scope
|
|
1265
|
+
});
|
|
1266
|
+
const defaultDeps = {
|
|
1267
|
+
detect: () => detectSync(),
|
|
1268
|
+
install: defaultInstall
|
|
1269
|
+
};
|
|
1270
|
+
async function mcpAddHandler(flags) {
|
|
1271
|
+
const { url, source } = resolveMcpUrl(flags.url);
|
|
1272
|
+
const clients = (flags.client ?? []).flatMap((c) => c.split(",").map((s) => s.trim()).filter(Boolean));
|
|
1273
|
+
const options = {
|
|
1274
|
+
all: flags.all,
|
|
1275
|
+
clients,
|
|
1276
|
+
yes: flags.yes,
|
|
1277
|
+
dryRun: flags["dry-run"],
|
|
1278
|
+
scope: flags.scope
|
|
1279
|
+
};
|
|
1280
|
+
if (options.all || clients.length > 0 || options.yes || !process.stdout.isTTY) {
|
|
1281
|
+
const report = runNonInteractive(options, url, source, defaultDeps);
|
|
1282
|
+
this.process.stdout.write(`${formatReport(report)}\n`);
|
|
1283
|
+
this.process.stdout.write(`\n${nextStepsText(report)}\n`);
|
|
1284
|
+
if (report.unknownClients.length > 0 || hasFailure(report)) this.process.exitCode = 1;
|
|
1285
|
+
return;
|
|
1286
|
+
}
|
|
1287
|
+
await interactive(this, options.dryRun, url, source, flags.scope);
|
|
1288
|
+
}
|
|
1289
|
+
function hasFailure(report) {
|
|
1290
|
+
return report.outcomes.some((o) => !o.result.ok);
|
|
1291
|
+
}
|
|
1292
|
+
/** Human-readable meaning of each scope, for the interactive picker. */
|
|
1293
|
+
const SCOPE_LABELS = {
|
|
1294
|
+
user: {
|
|
1295
|
+
label: "user",
|
|
1296
|
+
hint: "every project you open"
|
|
1297
|
+
},
|
|
1298
|
+
project: {
|
|
1299
|
+
label: "project",
|
|
1300
|
+
hint: "this repo, shared with your team via git"
|
|
1301
|
+
},
|
|
1302
|
+
local: {
|
|
1303
|
+
label: "local",
|
|
1304
|
+
hint: "this repo, only you"
|
|
1305
|
+
}
|
|
1306
|
+
};
|
|
1307
|
+
async function interactive(ctx, dryRun, url, urlSource, requestedScope) {
|
|
1308
|
+
const p = await import("@clack/prompts");
|
|
1309
|
+
p.intro("Levr MCP setup");
|
|
1310
|
+
p.note(`${url}\n(${urlSource})`, "MCP endpoint");
|
|
1311
|
+
const detected = defaultDeps.detect();
|
|
1312
|
+
const installable = detected.filter((d) => d.available && !d.comingSoon);
|
|
1313
|
+
if (installable.length === 0) {
|
|
1314
|
+
p.outro("No supported MCP clients found on this machine.");
|
|
1315
|
+
return;
|
|
1316
|
+
}
|
|
1317
|
+
const choices = offerableScopes(installable.map((d) => d.id), detected);
|
|
1318
|
+
let scope = requestedScope ?? DEFAULT_SCOPE;
|
|
1319
|
+
if (!requestedScope && choices.length > 1) {
|
|
1320
|
+
const picked = await p.select({
|
|
1321
|
+
message: "Where should Levr be available?",
|
|
1322
|
+
options: choices.map((s) => ({
|
|
1323
|
+
value: s,
|
|
1324
|
+
label: SCOPE_LABELS[s].label,
|
|
1325
|
+
hint: SCOPE_LABELS[s].hint
|
|
1326
|
+
})),
|
|
1327
|
+
initialValue: choices.includes(DEFAULT_SCOPE) ? DEFAULT_SCOPE : choices[0]
|
|
1328
|
+
});
|
|
1329
|
+
if (p.isCancel(picked)) {
|
|
1330
|
+
p.cancel("Cancelled.");
|
|
1331
|
+
ctx.process.exitCode = 1;
|
|
1332
|
+
return;
|
|
1333
|
+
}
|
|
1334
|
+
scope = picked;
|
|
1335
|
+
} else if (!requestedScope && choices.length === 1) scope = choices[0] ?? DEFAULT_SCOPE;
|
|
1336
|
+
const rows = clientChoices(detected, scope);
|
|
1337
|
+
const selection = await p.multiselect({
|
|
1338
|
+
message: `Select clients to set up (${scope} scope)`,
|
|
1339
|
+
options: rows.map((r) => ({
|
|
1340
|
+
value: r.value,
|
|
1341
|
+
label: r.label,
|
|
1342
|
+
hint: r.hint
|
|
1343
|
+
})),
|
|
1344
|
+
initialValues: rows.filter((r) => r.selected).map((r) => r.value),
|
|
1345
|
+
required: false
|
|
1346
|
+
});
|
|
1347
|
+
if (p.isCancel(selection)) {
|
|
1348
|
+
p.cancel("Cancelled.");
|
|
1349
|
+
ctx.process.exitCode = 1;
|
|
1350
|
+
return;
|
|
1351
|
+
}
|
|
1352
|
+
if (selection.length === 0) {
|
|
1353
|
+
p.outro("Nothing selected — bye.");
|
|
1354
|
+
return;
|
|
1355
|
+
}
|
|
1356
|
+
const spin = p.spinner();
|
|
1357
|
+
spin.start(dryRun ? "Previewing changes" : "Installing");
|
|
1358
|
+
const outcomes = installSelected(selection, {
|
|
1359
|
+
mcpUrl: url,
|
|
1360
|
+
dryRun,
|
|
1361
|
+
scope,
|
|
1362
|
+
namedIds: /* @__PURE__ */ new Set()
|
|
1363
|
+
}, defaultDeps.install);
|
|
1364
|
+
spin.stop(dryRun ? "Preview ready" : "Done");
|
|
1365
|
+
const report = {
|
|
1366
|
+
url,
|
|
1367
|
+
urlSource,
|
|
1368
|
+
scope,
|
|
1369
|
+
outcomes,
|
|
1370
|
+
unknownClients: [],
|
|
1371
|
+
comingSoonClients: [],
|
|
1372
|
+
dryRun
|
|
1373
|
+
};
|
|
1374
|
+
p.note(formatReport(report), "Results");
|
|
1375
|
+
p.outro(nextStepsText(report));
|
|
1376
|
+
if (hasFailure(report)) ctx.process.exitCode = 1;
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
//#endregion
|
|
1380
|
+
export { mcpAddHandler };
|