@levr-one/cli 0.6.12 → 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.
@@ -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 };