@pi-archimedes/mcp 2.3.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.
Files changed (52) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +170 -0
  3. package/package.json +39 -0
  4. package/src/auth-flow.test.ts +583 -0
  5. package/src/auth-flow.ts +310 -0
  6. package/src/auth-run.test.ts +309 -0
  7. package/src/auth-run.ts +146 -0
  8. package/src/auth-storage.test.ts +338 -0
  9. package/src/auth-storage.ts +330 -0
  10. package/src/auto-auth.test.ts +231 -0
  11. package/src/auto-auth.ts +135 -0
  12. package/src/callback-server.test.ts +446 -0
  13. package/src/callback-server.ts +538 -0
  14. package/src/commands-auth.test.ts +320 -0
  15. package/src/commands-auth.ts +128 -0
  16. package/src/commands.test.ts +834 -0
  17. package/src/commands.ts +424 -0
  18. package/src/config-write.test.ts +213 -0
  19. package/src/config-write.ts +207 -0
  20. package/src/config.test.ts +468 -0
  21. package/src/config.ts +278 -0
  22. package/src/direct-tools.test.ts +473 -0
  23. package/src/direct-tools.ts +250 -0
  24. package/src/host-configs.test.ts +231 -0
  25. package/src/host-configs.ts +106 -0
  26. package/src/index.test.ts +689 -0
  27. package/src/index.ts +146 -0
  28. package/src/lifecycle.test.ts +274 -0
  29. package/src/lifecycle.ts +77 -0
  30. package/src/metadata-cache.test.ts +383 -0
  31. package/src/metadata-cache.ts +231 -0
  32. package/src/npx-resolver.test.ts +142 -0
  33. package/src/npx-resolver.ts +126 -0
  34. package/src/oauth-provider.test.ts +404 -0
  35. package/src/oauth-provider.ts +197 -0
  36. package/src/oauth-types.ts +54 -0
  37. package/src/panel-rows.ts +210 -0
  38. package/src/panel.test.ts +298 -0
  39. package/src/panel.ts +742 -0
  40. package/src/proxy-tool.ts +524 -0
  41. package/src/renderer.test.ts +326 -0
  42. package/src/renderer.ts +239 -0
  43. package/src/schema-validator.test.ts +56 -0
  44. package/src/schema-validator.ts +42 -0
  45. package/src/server-client.test.ts +1001 -0
  46. package/src/server-client.ts +576 -0
  47. package/src/server-manager.ts +139 -0
  48. package/src/setup-panel.test.ts +162 -0
  49. package/src/setup-panel.ts +715 -0
  50. package/src/tool-naming.test.ts +168 -0
  51. package/src/tool-naming.ts +114 -0
  52. package/src/types.ts +162 -0
@@ -0,0 +1,715 @@
1
+ /**
2
+ * The `/mcp setup` onboarding panel (plan-027, Task 4).
3
+ *
4
+ * Mode-based `string[]` overlay built on the shared overlay chrome
5
+ * (`@pi-archimedes/core/overlay`) — structurally identical to the `/mcp panel`
6
+ * management panel (`panel.ts`) and the `/agents` manager: `renderHeader`
7
+ * at top, cursor-highlighted flat lists, bracket-hinted `renderFooter`
8
+ * lines at the bottom, everything wrapped by `wrapWithBorder` (ADR 0003).
9
+ * No pi-tui `SelectList`/`DynamicBorder`/`Input`/`Focusable`.
10
+ *
11
+ * Modes and keys:
12
+ * - `menu` (entry): "Scaffold minimal .mcp.json" / "Add a known server" /
13
+ * "Import from another tool" / "Cancel"; `enter` runs the selected action,
14
+ * `esc` closes
15
+ * - `import`: checklist of `discoverHostConfigs(ctx.cwd)` results
16
+ * (`[x] cursor ~/.cursor/mcp.json`); `space` toggles the check;
17
+ * `enter` on a CHECKED row previews (then confirms) just that host;
18
+ * `w` previews (then confirms) everything checked; `esc` → menu
19
+ * - `preview`: plain list of the server names to be added — the
20
+ * add-if-absent diff, so servers already in `.mcp.json` are NOT listed;
21
+ * `enter`/`y` writes the selection to `<cwd>/.mcp.json` via
22
+ * `mergeServerDefinitions` and returns to `import` with a success line;
23
+ * `n`/`esc` cancels
24
+ * - `known`: small curated `{ name, def }` list; `enter` adds that entry
25
+ * (add-if-absent — an existing entry is never overwritten); `esc` → menu
26
+ * - `scaffold`: shows the minimal `.mcp.json` content; `enter` writes it
27
+ * ONLY when `<cwd>/.mcp.json` is absent (never clobbers — when present,
28
+ * the panel says so); `esc` → menu
29
+ *
30
+ * All writes target `<cwd>/.mcp.json` — the project-shared DEFINITIONS file
31
+ * from the normal config cascade, NOT the Pi override under
32
+ * `<cwd>/<CONFIG_DIR_NAME>/`. Every successful write notifies the user to
33
+ * run `/reload`.
34
+ */
35
+ import { existsSync } from "node:fs";
36
+ import { homedir } from "node:os";
37
+ import { join, sep } from "node:path";
38
+ import { Key, matchesKey } from "@earendil-works/pi-tui";
39
+ import type { TUI } from "@earendil-works/pi-tui";
40
+ import type {
41
+ ExtensionAPI,
42
+ ExtensionCommandContext,
43
+ Theme,
44
+ } from "@earendil-works/pi-coding-agent";
45
+ import {
46
+ OVERLAY_CHROME,
47
+ borderContentWidth,
48
+ hardTruncate,
49
+ padEnd,
50
+ renderFooter,
51
+ renderHeader,
52
+ visibleWidth,
53
+ wrapWithBorder,
54
+ } from "@pi-archimedes/core/overlay";
55
+ import { existingProjectServerNames, mergeServerDefinitions, writeJsonFileAtomic } from "./config-write.js";
56
+ import { discoverHostConfigs, type HostConfig } from "./host-configs.js";
57
+ import type { ServerDef } from "./types.js";
58
+
59
+ // ── Known servers (curated, deliberately small) ─────────────────────────────
60
+
61
+ export interface KnownServer {
62
+ name: string;
63
+ summary: string;
64
+ def: ServerDef;
65
+ }
66
+
67
+ /**
68
+ * The "Add a known server" presets. Keep this list small — it is surfaced
69
+ * verbatim in the TUI and every entry becomes a bare entry in `.mcp.json`.
70
+ */
71
+ export const KNOWN_SERVERS: readonly KnownServer[] = [
72
+ {
73
+ name: "context7",
74
+ summary: "Upstash Context7 — up-to-date library docs",
75
+ def: { command: "npx", args: ["-y", "@upstash/context7-mcp"] },
76
+ },
77
+ {
78
+ name: "chrome-devtools",
79
+ summary: "Chrome DevTools MCP (official, Chrome team)",
80
+ def: { command: "npx", args: ["-y", "chrome-devtools-mcp@latest"] },
81
+ },
82
+ {
83
+ name: "deepwiki",
84
+ summary: "DeepWiki — ask questions about any GitHub repo",
85
+ def: { command: "npx", args: ["-y", "mcp-deepwiki@latest"] },
86
+ },
87
+ {
88
+ name: "fetch",
89
+ summary: "Fetch web pages as markdown (reference server)",
90
+ def: { command: "uvx", args: ["mcp-server-fetch"] },
91
+ },
92
+ ];
93
+
94
+ /** The minimal `.mcp.json` the scaffold mode writes (absent files only). */
95
+ export const SCAFFOLD_MCP_JSON = '{\n "mcpServers": {}\n}\n';
96
+
97
+ // ── Import preview (pure, exported for future unit tests) ───────────────────
98
+
99
+ /** The import preview: which names the add-if-absent write will actually add. */
100
+ export interface ImportPreview {
101
+ /** Which source(s) drive this preview (agent labels, `+`-joined). */
102
+ sourceLabel: string;
103
+ /** The server names that will be written (new ones only, first-seen order). */
104
+ adding: string[];
105
+ /** Selected servers already present in `.mcp.json` (kept untouched). */
106
+ alreadyPresent: number;
107
+ /** All selected defs — `mergeServerDefinitions` still applies add-if-absent. */
108
+ defs: Record<string, ServerDef>;
109
+ }
110
+
111
+ /**
112
+ * Derive the preview for a set of checked host configs: union their servers
113
+ * in discovery order (first seen wins a name clash), then diff against the
114
+ * names already in `<cwd>/.mcp.json`. Returns null when nothing is selected.
115
+ */
116
+ export function computeImportPreview(
117
+ hostConfigs: readonly HostConfig[],
118
+ checked: ReadonlySet<number>,
119
+ existingNames: readonly string[],
120
+ ): ImportPreview | null {
121
+ const existing = new Set(existingNames);
122
+ const defs: Record<string, ServerDef> = {};
123
+ const order: string[] = [];
124
+ const labels: string[] = [];
125
+ for (let i = 0; i < hostConfigs.length; i++) {
126
+ if (!checked.has(i)) continue;
127
+ const host = hostConfigs[i];
128
+ if (host === undefined) continue;
129
+ labels.push(host.agent);
130
+ for (const [name, def] of Object.entries(host.servers)) {
131
+ if (!(name in defs)) {
132
+ defs[name] = def;
133
+ order.push(name);
134
+ }
135
+ }
136
+ }
137
+ if (order.length === 0) return null;
138
+ const adding = order.filter((n) => !existing.has(n));
139
+ return {
140
+ sourceLabel: labels.join(" + "),
141
+ adding,
142
+ alreadyPresent: order.length - adding.length,
143
+ defs,
144
+ };
145
+ }
146
+
147
+ // ── Component ────────────────────────────────────────────────────────────────
148
+
149
+ type SetupMode = "menu" | "import" | "preview" | "known" | "scaffold";
150
+
151
+ interface SetupPanelState {
152
+ mode: SetupMode;
153
+ menuCursor: number;
154
+ importCursor: number;
155
+ knownCursor: number;
156
+ hostConfigs: HostConfig[];
157
+ /** True after the first import-mode discovery (re-discovered each entry). */
158
+ discovered: boolean;
159
+ /** Indices into hostConfigs the user checked ([space]). */
160
+ checked: Set<number>;
161
+ preview: ImportPreview | null;
162
+ /** Transient result line rendered above the footer. */
163
+ notice: { text: string; tone: "info" | "success" | "error" } | null;
164
+ }
165
+
166
+ interface McpSetupPanelComponent {
167
+ render(width: number): string[];
168
+ handleInput(data: string): void;
169
+ invalidate(): void;
170
+ dispose(): void;
171
+ }
172
+
173
+ const MENU_ACTIONS = [
174
+ "Scaffold minimal .mcp.json",
175
+ "Add a known server",
176
+ "Import from another tool",
177
+ "Cancel",
178
+ ] as const;
179
+
180
+ /**
181
+ * Footer hint parts per mode (bracket style). The parts are joined and
182
+ * rendered as ONE footer line when they fit `borderContentWidth`; otherwise
183
+ * they are pre-split one per line (renderFooter does not wrap).
184
+ */
185
+ const FOOTERS: Record<SetupMode, readonly string[]> = {
186
+ menu: [" [↑/↓] move [enter] select [esc] close "],
187
+ import: [
188
+ " [↑/↓] move [space] check [enter] preview checked ",
189
+ "[w] write all checked [esc] back ",
190
+ ],
191
+ preview: [" [enter] write [n / esc] cancel "],
192
+ known: [" [↑/↓] move [enter] add [esc] back "],
193
+ scaffold: [" [enter] write (if absent) [esc] back "],
194
+ };
195
+
196
+ /** Path with `~` substituted for the home prefix (display only). */
197
+ function displayPath(p: string): string {
198
+ const home = homedir();
199
+ if (p.startsWith(home + sep)) return `~${p.slice(home.length)}`;
200
+ return p;
201
+ }
202
+
203
+ /**
204
+ * Build the overlay component. `ctx` is captured for `ctx.cwd` (the
205
+ * `<cwd>/.mcp.json` write target) and `ctx.ui.notify` (toast parity with the
206
+ * command path); everything else is a direct import.
207
+ */
208
+ function makeSetupPanel(
209
+ tui: TUI,
210
+ theme: Theme,
211
+ done: () => void,
212
+ ctx: ExtensionCommandContext,
213
+ ): McpSetupPanelComponent {
214
+ const state: SetupPanelState = {
215
+ mode: "menu",
216
+ menuCursor: 0,
217
+ importCursor: 0,
218
+ knownCursor: 0,
219
+ hostConfigs: [],
220
+ discovered: false,
221
+ checked: new Set<number>(),
222
+ preview: null,
223
+ notice: null,
224
+ };
225
+
226
+ let disposed = false;
227
+ let cachedWidth: number | undefined;
228
+ let cachedLines: string[] | undefined;
229
+
230
+ function requestRender(): void {
231
+ cachedWidth = undefined;
232
+ cachedLines = undefined;
233
+ if (!disposed) tui.requestRender();
234
+ }
235
+
236
+ function setNotice(text: string, tone: "info" | "success" | "error"): void {
237
+ state.notice = { text, tone };
238
+ }
239
+
240
+ function closePanel(): void {
241
+ if (disposed) return;
242
+ disposed = true;
243
+ done();
244
+ }
245
+
246
+ function projectTarget(): string {
247
+ return join(ctx.cwd, ".mcp.json");
248
+ }
249
+
250
+ // ── Mode transitions ─────────────────────────────────────────────────────
251
+
252
+ function enterMenu(): void {
253
+ state.mode = "menu";
254
+ state.preview = null;
255
+ }
256
+
257
+ function enterImportMode(): void {
258
+ // Re-discover so the checklist matches current reality; checks that
259
+ // reference paths still present are preserved across menu round-trips.
260
+ const previousChecked = new Map<string, boolean>();
261
+ for (let i = 0; i < state.hostConfigs.length; i++) {
262
+ const host = state.hostConfigs[i];
263
+ if (host !== undefined) previousChecked.set(host.path, state.checked.has(i));
264
+ }
265
+ let fresh: HostConfig[];
266
+ try {
267
+ fresh = discoverHostConfigs(ctx.cwd);
268
+ } catch {
269
+ fresh = state.hostConfigs; // defensive: never throw into the input path
270
+ }
271
+ state.hostConfigs = fresh;
272
+ state.discovered = true;
273
+ state.checked = new Set(
274
+ fresh
275
+ .map((host, i) => (previousChecked.get(host.path) === true ? i : -1))
276
+ .filter((i) => i >= 0),
277
+ );
278
+ state.importCursor = Math.min(state.importCursor, Math.max(0, fresh.length - 1));
279
+ state.mode = "import";
280
+ state.preview = null;
281
+ }
282
+
283
+ // ── Actions ──────────────────────────────────────────────────────────────
284
+
285
+ function runMenuAction(): void {
286
+ const action = MENU_ACTIONS[state.menuCursor];
287
+ switch (action) {
288
+ case "Scaffold minimal .mcp.json":
289
+ state.mode = "scaffold";
290
+ requestRender();
291
+ return;
292
+ case "Add a known server":
293
+ state.mode = "known";
294
+ requestRender();
295
+ return;
296
+ case "Import from another tool":
297
+ enterImportMode();
298
+ requestRender();
299
+ return;
300
+ case "Cancel":
301
+ default:
302
+ closePanel();
303
+ return;
304
+ }
305
+ }
306
+
307
+ /**
308
+ * Build and show the preview for the given host indices (checked set for
309
+ * `w`, a single index for `enter` on a checked row). Refuses to open when
310
+ * nothing NEW would be added, and on an unreadable `.mcp.json` it surfaces
311
+ * the reader's error instead of guessing the diff.
312
+ */
313
+ function openPreviewFor(indices: number[]): void {
314
+ let existingNames: string[];
315
+ try {
316
+ existingNames = existingProjectServerNames(ctx.cwd);
317
+ } catch (e) {
318
+ const message = e instanceof Error ? e.message : String(e);
319
+ setNotice(message, "error");
320
+ ctx.ui.notify(`/mcp setup: ${message}`, "error");
321
+ requestRender();
322
+ return;
323
+ }
324
+ const preview = computeImportPreview(state.hostConfigs, new Set(indices), existingNames);
325
+ if (preview === null || preview.adding.length === 0) {
326
+ setNotice("Nothing to add — the selected hosts contribute no new servers", "info");
327
+ requestRender();
328
+ return;
329
+ }
330
+ state.preview = preview;
331
+ state.notice = null;
332
+ state.mode = "preview";
333
+ requestRender();
334
+ }
335
+
336
+ function cancelPreview(): void {
337
+ state.preview = null;
338
+ state.mode = "import";
339
+ requestRender();
340
+ }
341
+
342
+ function confirmPreview(): void {
343
+ const preview = state.preview;
344
+ if (preview === null) {
345
+ cancelPreview();
346
+ return;
347
+ }
348
+ try {
349
+ // The writer is add-if-absent — existing entries are untouched.
350
+ mergeServerDefinitions(ctx.cwd, preview.defs);
351
+ } catch (e) {
352
+ // Failed write → stay in preview so the user can back out and retry.
353
+ const message = e instanceof Error ? e.message : String(e);
354
+ setNotice(message, "error");
355
+ ctx.ui.notify(`/mcp setup: ${message}`, "error");
356
+ requestRender();
357
+ return;
358
+ }
359
+ state.checked.clear();
360
+ const n = preview.adding.length;
361
+ const summary = `✓ Added ${n} server${n === 1 ? "" : "s"} to .mcp.json — run /reload`;
362
+ state.preview = null;
363
+ setNotice(summary, "success");
364
+ ctx.ui.notify(`${summary} to apply`, "info");
365
+ state.mode = "import";
366
+ requestRender();
367
+ }
368
+
369
+ function addKnown(): void {
370
+ const known = KNOWN_SERVERS[state.knownCursor];
371
+ if (known === undefined) return;
372
+ try {
373
+ if (existingProjectServerNames(ctx.cwd).includes(known.name)) {
374
+ setNotice(`${known.name} is already in .mcp.json — kept as-is`, "info");
375
+ ctx.ui.notify(`/mcp setup: ${known.name} already present in .mcp.json — kept as-is`, "info");
376
+ requestRender();
377
+ return;
378
+ }
379
+ mergeServerDefinitions(ctx.cwd, { [known.name]: known.def });
380
+ } catch (e) {
381
+ const message = e instanceof Error ? e.message : String(e);
382
+ setNotice(message, "error");
383
+ ctx.ui.notify(`/mcp setup: ${message}`, "error");
384
+ requestRender();
385
+ return;
386
+ }
387
+ setNotice(`✓ Added ${known.name} to .mcp.json — run /reload`, "success");
388
+ ctx.ui.notify(`✓ Added ${known.name} to .mcp.json — run /reload to apply`, "info");
389
+ requestRender();
390
+ }
391
+
392
+ function runScaffold(): void {
393
+ const target = projectTarget();
394
+ if (existsSync(target)) {
395
+ // Never clobber an existing project file — say so instead.
396
+ setNotice(".mcp.json already exists — not modified", "info");
397
+ ctx.ui.notify("/mcp setup: .mcp.json already exists — not modified", "info");
398
+ requestRender();
399
+ return;
400
+ }
401
+ try {
402
+ // Atomic tmp+rename in the same dir via the shared writer — a
403
+ // 2-space stringification of the parsed scaffold is byte-identical
404
+ // to SCAFFOLD_MCP_JSON itself.
405
+ writeJsonFileAtomic(target, JSON.parse(SCAFFOLD_MCP_JSON));
406
+ } catch (e) {
407
+ const message = e instanceof Error ? e.message : String(e);
408
+ setNotice(message, "error");
409
+ ctx.ui.notify(`/mcp setup: ${message}`, "error");
410
+ requestRender();
411
+ return;
412
+ }
413
+ setNotice("✓ .mcp.json created — run /reload", "success");
414
+ ctx.ui.notify("✓ Scaffolded .mcp.json — run /reload to apply", "info");
415
+ requestRender();
416
+ }
417
+
418
+ // ── Input (per-mode handlers) ────────────────────────────────────────────
419
+
420
+ function handleMenuInput(data: string): void {
421
+ if (matchesKey(data, Key.up)) {
422
+ if (state.menuCursor > 0) {
423
+ state.menuCursor--;
424
+ requestRender();
425
+ }
426
+ return;
427
+ }
428
+ if (matchesKey(data, Key.down)) {
429
+ if (state.menuCursor < MENU_ACTIONS.length - 1) {
430
+ state.menuCursor++;
431
+ requestRender();
432
+ }
433
+ return;
434
+ }
435
+ if (matchesKey(data, Key.enter)) runMenuAction();
436
+ }
437
+
438
+ function handleImportInput(data: string): void {
439
+ const rows = state.hostConfigs.length;
440
+ if (matchesKey(data, Key.up)) {
441
+ if (state.importCursor > 0) {
442
+ state.importCursor--;
443
+ requestRender();
444
+ }
445
+ return;
446
+ }
447
+ if (matchesKey(data, Key.down)) {
448
+ if (rows > 0 && state.importCursor < rows - 1) {
449
+ state.importCursor++;
450
+ requestRender();
451
+ }
452
+ return;
453
+ }
454
+ if (rows === 0) return;
455
+ if (matchesKey(data, Key.space)) {
456
+ const i = state.importCursor;
457
+ if (state.hostConfigs[i] === undefined) return;
458
+ if (state.checked.has(i)) state.checked.delete(i);
459
+ else state.checked.add(i);
460
+ requestRender();
461
+ return;
462
+ }
463
+ if (matchesKey(data, Key.enter)) {
464
+ // Preview (then confirm) just the checked row under the cursor.
465
+ if (state.checked.has(state.importCursor)) openPreviewFor([state.importCursor]);
466
+ return;
467
+ }
468
+ if (matchesKey(data, "w")) {
469
+ const checked = [...state.checked].sort((a, b) => a - b);
470
+ if (checked.length === 0) {
471
+ setNotice("Check at least one source first ([space])", "info");
472
+ requestRender();
473
+ } else {
474
+ openPreviewFor(checked);
475
+ }
476
+ }
477
+ }
478
+
479
+ function handlePreviewInput(data: string): void {
480
+ if (matchesKey(data, Key.enter) || matchesKey(data, "y")) {
481
+ confirmPreview();
482
+ return;
483
+ }
484
+ if (matchesKey(data, "n")) cancelPreview();
485
+ }
486
+
487
+ function handleKnownInput(data: string): void {
488
+ if (matchesKey(data, Key.up)) {
489
+ if (state.knownCursor > 0) {
490
+ state.knownCursor--;
491
+ requestRender();
492
+ }
493
+ return;
494
+ }
495
+ if (matchesKey(data, Key.down)) {
496
+ if (state.knownCursor < KNOWN_SERVERS.length - 1) {
497
+ state.knownCursor++;
498
+ requestRender();
499
+ }
500
+ return;
501
+ }
502
+ if (matchesKey(data, Key.enter)) addKnown();
503
+ }
504
+
505
+ function handleScaffoldInput(data: string): void {
506
+ if (matchesKey(data, Key.enter)) runScaffold();
507
+ }
508
+
509
+ function handleInput(data: string): void {
510
+ // ctrl+c closes from ANY mode (no confirm — nothing is written until a
511
+ // confirm key is pressed, so there is nothing to lose).
512
+ if (matchesKey(data, Key.ctrl("c"))) {
513
+ closePanel();
514
+ return;
515
+ }
516
+ // esc backs out a sub-mode; in the menu it closes the panel.
517
+ if (matchesKey(data, Key.escape)) {
518
+ if (state.mode === "menu") closePanel();
519
+ else enterMenu();
520
+ requestRender();
521
+ return;
522
+ }
523
+ switch (state.mode) {
524
+ case "menu":
525
+ handleMenuInput(data);
526
+ break;
527
+ case "import":
528
+ handleImportInput(data);
529
+ break;
530
+ case "preview":
531
+ handlePreviewInput(data);
532
+ break;
533
+ case "known":
534
+ handleKnownInput(data);
535
+ break;
536
+ case "scaffold":
537
+ handleScaffoldInput(data);
538
+ break;
539
+ }
540
+ }
541
+
542
+ // ── Render ───────────────────────────────────────────────────────────────
543
+
544
+ function headerText(): string {
545
+ switch (state.mode) {
546
+ case "menu":
547
+ return " MCP Setup ";
548
+ case "import":
549
+ return " MCP Setup — Import from another tool ";
550
+ case "preview":
551
+ return " MCP Setup — Confirm import ";
552
+ case "known":
553
+ return " MCP Setup — Known servers ";
554
+ case "scaffold":
555
+ return " MCP Setup — Scaffold .mcp.json ";
556
+ }
557
+ }
558
+
559
+ function renderBody(contentWidth: number): string[] {
560
+ switch (state.mode) {
561
+ case "menu": {
562
+ const lines: string[] = [];
563
+ lines.push(theme.fg("dim", "Get started with MCP servers in this project:"));
564
+ for (let i = 0; i < MENU_ACTIONS.length; i++) {
565
+ const label = MENU_ACTIONS[i];
566
+ if (label === undefined) continue;
567
+ const prefix = i === state.menuCursor ? theme.fg("accent", "> ") : " ";
568
+ lines.push(hardTruncate(prefix + label, contentWidth));
569
+ }
570
+ return lines;
571
+ }
572
+ case "import": {
573
+ const lines: string[] = [];
574
+ if (state.hostConfigs.length === 0) {
575
+ lines.push(theme.fg("dim", "No other-tool MCP configs found."));
576
+ lines.push(theme.fg("dim", "(looked for cursor, claude-code, claude-desktop, vscode)"));
577
+ return lines;
578
+ }
579
+ for (let i = 0; i < state.hostConfigs.length; i++) {
580
+ const host = state.hostConfigs[i];
581
+ if (host === undefined) continue;
582
+ const count = Object.keys(host.servers).length;
583
+ let line = `${state.checked.has(i) ? "[x]" : "[ ]"} ${padEnd(host.agent, 14)} ` +
584
+ `${displayPath(host.path)} ${theme.fg("dim", `(${count})`)}`;
585
+ if (i === state.importCursor) line = theme.fg("accent", line);
586
+ lines.push(hardTruncate(line, contentWidth));
587
+ }
588
+ return lines;
589
+ }
590
+ case "preview": {
591
+ const lines: string[] = [];
592
+ const preview = state.preview;
593
+ if (preview === null) {
594
+ lines.push(theme.fg("dim", "Nothing to preview."));
595
+ return lines;
596
+ }
597
+ lines.push(`Source : ${preview.sourceLabel}`);
598
+ lines.push(`Target : ${displayPath(projectTarget())}`);
599
+ const n = preview.adding.length;
600
+ lines.push(`Will add ${n} server${n === 1 ? "" : "s"}:`);
601
+ for (const name of preview.adding) lines.push(` ${name}`);
602
+ if (preview.alreadyPresent > 0) {
603
+ lines.push(theme.fg("dim", `(${preview.alreadyPresent} already present — kept as-is)`));
604
+ }
605
+ return lines;
606
+ }
607
+ case "known": {
608
+ const lines: string[] = [];
609
+ for (let i = 0; i < KNOWN_SERVERS.length; i++) {
610
+ const known = KNOWN_SERVERS[i];
611
+ if (known === undefined) continue;
612
+ let line = `${padEnd(known.name, 16)} ${known.summary}`;
613
+ if (i === state.knownCursor) line = theme.fg("accent", line);
614
+ lines.push(hardTruncate(line, contentWidth));
615
+ }
616
+ lines.push(theme.fg("dim", "add-if-absent: an existing entry is never overwritten"));
617
+ return lines;
618
+ }
619
+ case "scaffold": {
620
+ const lines: string[] = [];
621
+ const exists = existsSync(projectTarget());
622
+ lines.push(
623
+ theme.fg(
624
+ "dim",
625
+ exists
626
+ ? "Target file already exists — it will NOT be modified."
627
+ : "Target file is absent — [enter] creates it.",
628
+ ),
629
+ );
630
+ for (const jsonLine of SCAFFOLD_MCP_JSON.trimEnd().split("\n")) {
631
+ lines.push(theme.fg("dim", jsonLine));
632
+ }
633
+ return lines;
634
+ }
635
+ }
636
+ }
637
+
638
+ function renderFooters(contentWidth: number): string[] {
639
+ const parts = FOOTERS[state.mode];
640
+ const single = parts.join("");
641
+ // At the standard 84-char overlay width the import footer overflows the
642
+ // 80-char content width and renderFooter does NOT wrap — so it is
643
+ // pre-split into lines only when it overflows.
644
+ if (visibleWidth(single) <= contentWidth) {
645
+ return [renderFooter(single, contentWidth, theme)];
646
+ }
647
+ return parts.map((part) => renderFooter(part, contentWidth, theme));
648
+ }
649
+
650
+ function renderLines(contentWidth: number): string[] {
651
+ const lines: string[] = [];
652
+
653
+ lines.push(renderHeader(headerText(), contentWidth, theme));
654
+
655
+ for (const line of renderBody(contentWidth)) {
656
+ lines.push(hardTruncate(line, contentWidth));
657
+ }
658
+
659
+ // Transient result line (latest action's outcome).
660
+ if (state.notice !== null) {
661
+ const token =
662
+ state.notice.tone === "error" ? "error" : state.notice.tone === "success" ? "success" : "dim";
663
+ lines.push(hardTruncate(theme.fg(token, state.notice.text), contentWidth));
664
+ }
665
+
666
+ for (const footer of renderFooters(contentWidth)) lines.push(footer);
667
+
668
+ return lines;
669
+ }
670
+
671
+ return {
672
+ render(width: number): string[] {
673
+ if (cachedLines !== undefined && cachedWidth === width) return cachedLines;
674
+ const bordered = wrapWithBorder(renderLines(borderContentWidth(width)), width, theme);
675
+ cachedWidth = width;
676
+ cachedLines = bordered;
677
+ return bordered;
678
+ },
679
+
680
+ handleInput,
681
+
682
+ invalidate(): void {
683
+ cachedWidth = undefined;
684
+ cachedLines = undefined;
685
+ },
686
+
687
+ dispose(): void {
688
+ disposed = true;
689
+ },
690
+ };
691
+ }
692
+
693
+ // ── Entry point ──────────────────────────────────────────────────────────────
694
+
695
+ /**
696
+ * Open the setup panel as a centered overlay (shared chrome, ADR 0003).
697
+ * `pi` participates in the stable panel API (the management panel takes it
698
+ * too) but is unused here — the setup flow only writes `<cwd>/.mcp.json`.
699
+ */
700
+ export async function openMcpSetupPanel(
701
+ pi: ExtensionAPI,
702
+ ctx: ExtensionCommandContext,
703
+ ): Promise<void> {
704
+ void pi;
705
+ if (!ctx.hasUI) {
706
+ ctx.ui.notify("/mcp setup requires an interactive TUI", "error");
707
+ return;
708
+ }
709
+
710
+ await ctx.ui.custom<void>(
711
+ (tui: TUI, theme: Theme, _keybindings, done: () => void) =>
712
+ makeSetupPanel(tui, theme, done, ctx),
713
+ { overlay: true, overlayOptions: OVERLAY_CHROME },
714
+ );
715
+ }