privateer-agent 0.6.7 → 0.6.9

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,754 @@
1
+ /**
2
+ * The Privateer `/connect` panel — add, toggle, and remove MCP connectors without
3
+ * leaving the terminal.
4
+ *
5
+ * WHY THIS EXISTS: pi-mcp-adapter owns `/mcp`, but that command is a *status and
6
+ * connection* surface — its setup flow can only adopt configs from other hosts,
7
+ * scaffold an EMPTY `.mcp.json`, or quick-add RepoPrompt. There is no way to type in
8
+ * a server. So the terminal was the one surface where adding a connector meant
9
+ * hand-editing JSON, while the phone and the desktop both had a real editor.
10
+ *
11
+ * ONE CONFIG, THREE SURFACES: this is deliberately a thin UI over the SAME
12
+ * makeMcpControl() the relay uses (src/remote/mcpControl.ts) — which is why that
13
+ * module was written framework-agnostic. A connector added here lands in
14
+ * `agent/mcp-desktop.json` and is projected into `agent/mcp.json`, so it shows up in
15
+ * the app's MCP screen, survives a toggle from the phone, and is read by the daemon's
16
+ * routine runs. Nothing about this panel is terminal-specific except the pixels.
17
+ *
18
+ * SECRETS: env values are typed into a masked field and written in PLAINTEXT to the
19
+ * config on this machine — correct, and the same thing the desktop does: the adapter
20
+ * has to hand the real token to the server process. The masking is shoulder-surfing
21
+ * and screen-share hygiene, not a storage claim. (Over the relay it's different —
22
+ * there the value is sealed to the terminal's key; see mcpControl's header.)
23
+ */
24
+ import {
25
+ Container,
26
+ fuzzyFilter,
27
+ getKeybindings,
28
+ Input,
29
+ Spacer,
30
+ Text,
31
+ } from "@earendil-works/pi-tui";
32
+ import {
33
+ makeMcpControl,
34
+ type McpDraft,
35
+ type RemoteMcpServer,
36
+ } from "../src/remote/mcpControl.ts";
37
+ import {
38
+ MCP_CATALOG,
39
+ draftFromCatalog,
40
+ promptOrder,
41
+ type CatalogEntry,
42
+ } from "../src/mcp/catalog.ts";
43
+
44
+ // Minimal views of Pi's theme + TUI handle, mirroring privateer-models.ts — enough to
45
+ // color text and request a redraw without coupling this file to Pi internals.
46
+ interface ThemeLike {
47
+ fg(color: string, text: string): string;
48
+ bold(text: string): string;
49
+ }
50
+ interface TuiLike {
51
+ requestRender(): void;
52
+ }
53
+
54
+ // What the panel hands back to the command handler when it closes.
55
+ export interface ConnectResult {
56
+ // True when the config changed, so the handler knows to reload the adapter.
57
+ changed: boolean;
58
+ message?: string;
59
+ }
60
+
61
+ const MAX_VISIBLE = 10;
62
+
63
+ // ---------------------------------------------------------------------------
64
+ // A masked single-line field for credentials.
65
+ //
66
+ // pi-tui's Input has no mask and keeps `value` private, so a subclass can't render
67
+ // dots without breaking the cursor. A token field only needs insert / backspace /
68
+ // clear / paste, so we own those few keys directly and render the dots ourselves.
69
+ // ---------------------------------------------------------------------------
70
+ class SecretField {
71
+ value = "";
72
+
73
+ // Returns true when the keystroke was consumed. Escape sequences (arrows, F-keys)
74
+ // are swallowed rather than inserted — the naive "strip control chars" approach
75
+ // would leave "[A" behind from an up-arrow and silently corrupt the token.
76
+ handleInput(data: string): boolean {
77
+ if (data.startsWith("\x1b")) return true;
78
+ if (data === "\x7f" || data === "\b") {
79
+ this.value = this.value.slice(0, -1);
80
+ return true;
81
+ }
82
+ if (data === "\x15") {
83
+ // ctrl+U — clear the field
84
+ this.value = "";
85
+ return true;
86
+ }
87
+ // Everything printable, including a bracketed-paste chunk arriving at once.
88
+ const printable = data.replace(/[\x00-\x1f\x7f]/g, "");
89
+ if (!printable) return false;
90
+ this.value += printable;
91
+ return true;
92
+ }
93
+
94
+ // Dots, capped so a long PAT can't wrap the panel. Length is intentionally NOT
95
+ // exact past the cap — it isn't information the user needs, and it leaks the
96
+ // token's length to anyone watching.
97
+ display(): string {
98
+ if (!this.value) return "";
99
+ return "•".repeat(Math.min(this.value.length, 32));
100
+ }
101
+ }
102
+
103
+ // ---------------------------------------------------------------------------
104
+ // The wizard's steps. A catalog entry expands into zero or more of these; a custom
105
+ // or edited connector into three. Keeping the form as a flat step list (rather than
106
+ // a focus-managed multi-field form) means one Input on screen at a time, which is
107
+ // both simpler to drive with a keyboard and simpler to reason about.
108
+ // ---------------------------------------------------------------------------
109
+ interface Step {
110
+ key: string;
111
+ prompt: string;
112
+ hint?: string;
113
+ secret?: boolean;
114
+ initial?: string;
115
+ // A step that may be submitted empty. For an edit, an empty secret means "keep
116
+ // the value already on disk" — mcpControl's env merge rule makes that free.
117
+ optional?: boolean;
118
+ }
119
+
120
+ type View = "list" | "catalog" | "form";
121
+
122
+ // A pending save: the steps still to collect plus how to turn the answers into a
123
+ // draft once they're all in.
124
+ interface Pending {
125
+ title: string;
126
+ steps: Step[];
127
+ build(answers: Record<string, string>): McpDraft;
128
+ // Where esc from the FIRST step returns to. Carried explicitly rather than inferred
129
+ // from the title — an edit and a fresh add can look identical on screen.
130
+ origin: View;
131
+ // Shown after a successful save — e.g. the OAuth "authorize on this machine" note.
132
+ note?: string;
133
+ }
134
+
135
+ // Steps for a custom connector, or for editing an existing one. Transport is INFERRED
136
+ // from the answer rather than asked as a separate question: an https:// answer is an
137
+ // http server, anything else is a stdio command line. That matches mcpControl's own
138
+ // inference (`draft.url || prev.url ? "http" : "stdio"`), so there's one rule, not two.
139
+ function customSteps(existing?: RemoteMcpServer): Step[] {
140
+ const target = existing
141
+ ? existing.transport === "http"
142
+ ? existing.url
143
+ : [existing.command, existing.argsPreview].filter(Boolean).join(" ")
144
+ : undefined;
145
+ return [
146
+ {
147
+ key: "name",
148
+ prompt: "Connector name",
149
+ hint: "Lowercase, no spaces — it prefixes every tool, e.g. github__list_issues",
150
+ initial: existing?.name,
151
+ },
152
+ {
153
+ key: "target",
154
+ prompt: "Launch command, or an https:// URL",
155
+ hint: "e.g. npx -y @modelcontextprotocol/server-memory · or https://mcp.example.com/sse",
156
+ initial: target,
157
+ },
158
+ {
159
+ key: "env",
160
+ prompt: "Environment variables (optional)",
161
+ hint: "KEY=value, comma-separated. Leave blank for none.",
162
+ optional: true,
163
+ },
164
+ ];
165
+ }
166
+
167
+ // Parse the "KEY=value, KEY2=value2" answer. Malformed fragments are skipped rather
168
+ // than rejected — a stray comma shouldn't cost the user the whole form.
169
+ function parseEnv(answer: string): Record<string, string> {
170
+ const env: Record<string, string> = {};
171
+ for (const pair of answer.split(",")) {
172
+ const eq = pair.indexOf("=");
173
+ if (eq <= 0) continue;
174
+ const key = pair.slice(0, eq).trim();
175
+ const val = pair.slice(eq + 1).trim();
176
+ if (key) env[key] = val;
177
+ }
178
+ return env;
179
+ }
180
+
181
+ function buildCustomDraft(answers: Record<string, string>): McpDraft {
182
+ const name = (answers.name ?? "").trim();
183
+ const target = (answers.target ?? "").trim();
184
+ const draft: McpDraft = { name };
185
+ if (/^https?:\/\//i.test(target)) {
186
+ draft.transport = "http";
187
+ draft.url = target;
188
+ draft.oauth = true;
189
+ } else {
190
+ draft.transport = "stdio";
191
+ const parts = target.split(/\s+/).filter(Boolean);
192
+ draft.command = parts[0] ?? "";
193
+ draft.args = parts.slice(1);
194
+ }
195
+ const env = parseEnv(answers.env ?? "");
196
+ if (Object.keys(env).length > 0) draft.env = env;
197
+ return draft;
198
+ }
199
+
200
+ // Expand a catalog entry into its wizard. needs:"none"/"oauth" produce zero steps —
201
+ // the panel saves immediately and shows the note.
202
+ function pendingFromCatalog(e: CatalogEntry): Pending {
203
+ const steps: Step[] = [];
204
+ if (e.needs === "token") {
205
+ for (const key of promptOrder(e)) {
206
+ steps.push({
207
+ key: `env:${key}`,
208
+ prompt: `Paste your ${key}`,
209
+ hint: e.credUrl ? `Get one at ${e.credUrl}` : undefined,
210
+ secret: true,
211
+ // Only the primary credential is required; the rest can be filled later.
212
+ optional: key !== e.fill,
213
+ });
214
+ }
215
+ } else if (e.needs === "path" && e.fill) {
216
+ steps.push({
217
+ key: "fill",
218
+ prompt: e.fill.startsWith("postgres") ? "Connection string" : "Folder path",
219
+ hint: `Replaces the placeholder ${e.fill}`,
220
+ initial: e.fill,
221
+ });
222
+ }
223
+ return {
224
+ title: e.label,
225
+ steps,
226
+ origin: "catalog",
227
+ build: (answers) => {
228
+ const env: Record<string, string> = {};
229
+ for (const [k, v] of Object.entries(answers)) {
230
+ if (k.startsWith("env:")) env[k.slice(4)] = v;
231
+ }
232
+ return draftFromCatalog(e, { env, fill: answers.fill });
233
+ },
234
+ note:
235
+ e.needs === "oauth"
236
+ ? `Authorize it in a browser on THIS machine: /mcp-auth ${e.name}`
237
+ : undefined,
238
+ };
239
+ }
240
+
241
+ // One row in the catalog picker. "Custom…" is a real row rather than a separate key
242
+ // so there is exactly one way to start an add.
243
+ interface CatalogRow {
244
+ entry?: CatalogEntry; // undefined → the "Custom…" row
245
+ label: string;
246
+ blurb: string;
247
+ needsLabel: string;
248
+ }
249
+
250
+ const NEEDS_LABEL: Record<string, string> = {
251
+ token: "needs a token",
252
+ path: "needs a path",
253
+ oauth: "browser sign-in",
254
+ none: "no setup",
255
+ };
256
+
257
+ function catalogRows(): CatalogRow[] {
258
+ const rows: CatalogRow[] = MCP_CATALOG.map((e) => ({
259
+ entry: e,
260
+ label: e.label,
261
+ blurb: e.blurb,
262
+ needsLabel: NEEDS_LABEL[e.needs] ?? "",
263
+ }));
264
+ rows.push({
265
+ label: "Custom…",
266
+ blurb: "Any stdio command or http URL.",
267
+ needsLabel: "",
268
+ });
269
+ return rows;
270
+ }
271
+
272
+ // ---------------------------------------------------------------------------
273
+ // The panel.
274
+ // ---------------------------------------------------------------------------
275
+ class ConnectPanel extends Container {
276
+ private tui: TuiLike;
277
+ private theme: ThemeLike;
278
+ private control = makeMcpControl();
279
+ private close: (r: ConnectResult) => void;
280
+
281
+ private view: View = "list";
282
+ private servers: RemoteMcpServer[] = [];
283
+ private changed = false;
284
+ private status = "";
285
+
286
+ // list view
287
+ private index = 0;
288
+ // Name of the connector a first `d` has armed for removal; see removeSelected.
289
+ private armedRemoval?: string;
290
+
291
+ // catalog view
292
+ private rows: CatalogRow[] = [];
293
+ private filtered: CatalogRow[] = [];
294
+ private catalogIndex = 0;
295
+ private search = new Input();
296
+
297
+ // form view
298
+ private pending?: Pending;
299
+ private stepIndex = 0;
300
+ private answers: Record<string, string> = {};
301
+ private field = new Input();
302
+ private secret = new SecretField();
303
+
304
+ private body = new Container();
305
+ private footer = new Text("", 1, 0);
306
+ private _focused = false;
307
+
308
+ constructor(opts: { tui: TuiLike; theme: ThemeLike; close: (r: ConnectResult) => void }) {
309
+ super();
310
+ this.tui = opts.tui;
311
+ this.theme = opts.theme;
312
+ this.close = opts.close;
313
+
314
+ this.addChild(this.body);
315
+ this.addChild(new Spacer(1));
316
+ this.addChild(this.footer);
317
+
318
+ this.search.onSubmit = () => this.chooseCatalogRow();
319
+ this.field.onSubmit = () => this.submitStep();
320
+
321
+ this.reload();
322
+ }
323
+
324
+ get focused(): boolean {
325
+ return this._focused;
326
+ }
327
+ set focused(v: boolean) {
328
+ this._focused = v;
329
+ this.search.focused = v && this.view === "catalog";
330
+ this.field.focused = v && this.view === "form" && !this.currentStep()?.secret;
331
+ }
332
+
333
+ private reload(): void {
334
+ try {
335
+ // Sorted by name, not by however the config file happened to be written. The
336
+ // list is a place you come back to — a row that moves because someone added a
337
+ // connector from the phone is a row you'll toggle or delete by mistake.
338
+ this.servers = this.control.list().sort((a, b) => a.name.localeCompare(b.name));
339
+ } catch (e) {
340
+ this.servers = [];
341
+ this.status = `Couldn't read MCP config: ${(e as Error).message}`;
342
+ }
343
+ if (this.index >= this.servers.length) this.index = Math.max(0, this.servers.length - 1);
344
+ this.refresh();
345
+ }
346
+
347
+ private currentStep(): Step | undefined {
348
+ return this.pending?.steps[this.stepIndex];
349
+ }
350
+
351
+ // -- rendering ------------------------------------------------------------
352
+
353
+ private refresh(): void {
354
+ const t = this.theme;
355
+ this.body.clear();
356
+ if (this.view === "list") this.renderList();
357
+ else if (this.view === "catalog") this.renderCatalog();
358
+ else this.renderForm();
359
+ if (this.status) {
360
+ this.body.addChild(new Spacer(1));
361
+ this.body.addChild(new Text(t.fg("muted", ` ${this.status}`), 1, 0));
362
+ }
363
+ this.tui.requestRender();
364
+ }
365
+
366
+ // The one-line summary of a connector's readiness. This is the whole reason the
367
+ // list exists: "saved" and "usable" are different states, and an http/OAuth server
368
+ // that was never authorized fails only at call time otherwise.
369
+ private statusOf(s: RemoteMcpServer): string {
370
+ const t = this.theme;
371
+ if (s.transport === "http" && s.oauth) {
372
+ return t.fg("warning", `⚠ authorize: /mcp-auth ${s.name}`);
373
+ }
374
+ if (s.envKeys.length > 0) {
375
+ const missing = s.envKeys.filter((k) => !s.secretsSet.includes(k));
376
+ if (missing.length > 0) return t.fg("warning", `⚠ needs ${missing.join(", ")}`);
377
+ return t.fg("success", "⚿ credentials set");
378
+ }
379
+ return t.fg("muted", s.argsPreview ?? s.url ?? "");
380
+ }
381
+
382
+ private renderList(): void {
383
+ const t = this.theme;
384
+ this.body.addChild(new Text(t.fg("accent", t.bold("Connect a service")), 1, 0));
385
+ this.body.addChild(
386
+ new Text(t.fg("muted", "MCP connectors on this machine — shared with the app and the daemon."), 1, 0),
387
+ );
388
+ this.body.addChild(new Spacer(1));
389
+
390
+ if (this.servers.length === 0) {
391
+ this.body.addChild(new Text(t.fg("muted", " No connectors yet."), 1, 0));
392
+ } else {
393
+ const width = Math.max(...this.servers.map((s) => s.name.length));
394
+ this.servers.forEach((s, i) => {
395
+ const sel = i === this.index;
396
+ const prefix = sel ? t.fg("accent", "→ ") : " ";
397
+ const dot = s.enabled ? t.fg("success", "●") : t.fg("muted", "○");
398
+ const name = sel ? t.fg("accent", s.name.padEnd(width)) : t.fg("text", s.name.padEnd(width));
399
+ const transport = t.fg("muted", s.transport.padEnd(5));
400
+ this.body.addChild(new Text(`${prefix}${dot} ${name} ${transport} ${this.statusOf(s)}`, 1, 0));
401
+ });
402
+ }
403
+
404
+ this.body.addChild(new Spacer(1));
405
+ const addSel = this.index === this.servers.length;
406
+ this.body.addChild(
407
+ new Text(
408
+ addSel ? t.fg("accent", "→ + Add a connector…") : t.fg("muted", " + Add a connector…"),
409
+ 1,
410
+ 0,
411
+ ),
412
+ );
413
+ this.footer.setText(
414
+ t.fg("muted", "↑↓ move space enable/disable ⏎ edit a add d remove esc done"),
415
+ );
416
+ }
417
+
418
+ private renderCatalog(): void {
419
+ const t = this.theme;
420
+ this.body.addChild(new Text(t.fg("accent", t.bold("Add a connector")), 1, 0));
421
+ this.body.addChild(new Spacer(1));
422
+ this.body.addChild(this.search);
423
+ this.body.addChild(new Spacer(1));
424
+
425
+ if (this.filtered.length === 0) {
426
+ this.body.addChild(new Text(t.fg("muted", " No matches"), 1, 0));
427
+ } else {
428
+ const width = Math.max(...this.filtered.map((r) => r.label.length));
429
+ const start = Math.max(
430
+ 0,
431
+ Math.min(this.catalogIndex - Math.floor(MAX_VISIBLE / 2), this.filtered.length - MAX_VISIBLE),
432
+ );
433
+ const end = Math.min(start + MAX_VISIBLE, this.filtered.length);
434
+ for (let i = start; i < end; i++) {
435
+ const row = this.filtered[i];
436
+ const sel = i === this.catalogIndex;
437
+ const prefix = sel ? t.fg("accent", "→ ") : " ";
438
+ const label = sel ? t.fg("accent", row.label.padEnd(width)) : t.fg("text", row.label.padEnd(width));
439
+ const needs = row.needsLabel ? t.fg("muted", ` ${row.needsLabel}`) : "";
440
+ this.body.addChild(new Text(`${prefix}${label} ${t.fg("muted", row.blurb)}${needs}`, 1, 0));
441
+ }
442
+ }
443
+ this.footer.setText(t.fg("muted", "↑↓ move ⏎ choose type to search esc back"));
444
+ }
445
+
446
+ private renderForm(): void {
447
+ const t = this.theme;
448
+ const p = this.pending!;
449
+ const step = this.currentStep()!;
450
+ const counter = p.steps.length > 1 ? ` ${this.stepIndex + 1}/${p.steps.length}` : "";
451
+ this.body.addChild(new Text(t.fg("accent", t.bold(p.title)) + t.fg("muted", counter), 1, 0));
452
+ this.body.addChild(new Spacer(1));
453
+ this.body.addChild(new Text(` ${t.fg("text", step.prompt)}`, 1, 0));
454
+ if (step.hint) this.body.addChild(new Text(` ${t.fg("muted", step.hint)}`, 1, 0));
455
+ this.body.addChild(new Spacer(1));
456
+
457
+ if (step.secret) {
458
+ const shown = this.secret.display();
459
+ this.body.addChild(
460
+ new Text(` ${shown || t.fg("muted", "(nothing typed yet)")}`, 1, 0),
461
+ );
462
+ } else {
463
+ this.body.addChild(this.field);
464
+ }
465
+
466
+ this.footer.setText(
467
+ t.fg(
468
+ "muted",
469
+ step.optional
470
+ ? "⏎ next (blank to skip) esc back"
471
+ : "⏎ next esc back",
472
+ ),
473
+ );
474
+ }
475
+
476
+ // -- actions --------------------------------------------------------------
477
+
478
+ private openCatalog(): void {
479
+ this.view = "catalog";
480
+ this.rows = catalogRows();
481
+ // Servers already configured are still listed — re-picking one is a legitimate
482
+ // way to re-enter a rotated token, and save() merges rather than clobbers.
483
+ this.filtered = this.rows;
484
+ this.catalogIndex = 0;
485
+ this.search.setValue("");
486
+ this.search.focused = this._focused;
487
+ this.field.focused = false;
488
+ this.status = "";
489
+ this.refresh();
490
+ }
491
+
492
+ private applySearch(): void {
493
+ const q = this.search.getValue();
494
+ this.filtered = q
495
+ ? fuzzyFilter(this.rows, q, (r: CatalogRow) => `${r.label} ${r.blurb}`)
496
+ : this.rows;
497
+ this.catalogIndex = 0;
498
+ this.refresh();
499
+ }
500
+
501
+ private chooseCatalogRow(): void {
502
+ const row = this.filtered[this.catalogIndex];
503
+ if (!row) return;
504
+ const pending = row.entry
505
+ ? pendingFromCatalog(row.entry)
506
+ : {
507
+ title: "Custom connector",
508
+ steps: customSteps(),
509
+ origin: "catalog" as View,
510
+ build: buildCustomDraft,
511
+ };
512
+ this.startForm(pending);
513
+ }
514
+
515
+ private startForm(pending: Pending): void {
516
+ this.pending = pending;
517
+ this.stepIndex = 0;
518
+ this.answers = {};
519
+ // Zero-step entries (Memory, Playwright, Linear) save on the spot — there is
520
+ // nothing to ask, and making the user press enter through an empty form would
521
+ // be ceremony rather than confirmation.
522
+ if (pending.steps.length === 0) {
523
+ this.save();
524
+ return;
525
+ }
526
+ this.view = "form";
527
+ this.loadStep();
528
+ }
529
+
530
+ private loadStep(): void {
531
+ const step = this.currentStep()!;
532
+ this.secret.value = "";
533
+ this.field.setValue(step.initial ?? "");
534
+ this.field.focused = this._focused && !step.secret;
535
+ this.search.focused = false;
536
+ this.refresh();
537
+ }
538
+
539
+ private submitStep(): void {
540
+ const step = this.currentStep()!;
541
+ const value = step.secret ? this.secret.value : this.field.getValue().trim();
542
+ if (!value && !step.optional) {
543
+ this.status = "That one's required.";
544
+ this.refresh();
545
+ return;
546
+ }
547
+ this.answers[step.key] = value;
548
+ this.status = "";
549
+ if (this.stepIndex < this.pending!.steps.length - 1) {
550
+ this.stepIndex++;
551
+ this.loadStep();
552
+ return;
553
+ }
554
+ this.save();
555
+ }
556
+
557
+ private save(): void {
558
+ const p = this.pending!;
559
+ let res: { ok: boolean; message?: string };
560
+ try {
561
+ res = this.control.save(p.build(this.answers));
562
+ } catch (e) {
563
+ res = { ok: false, message: (e as Error).message };
564
+ }
565
+ if (!res.ok) {
566
+ // Stay in the form so the answers aren't lost to a fixable validation error.
567
+ this.status = res.message ?? "Couldn't save that connector.";
568
+ if (this.view === "form") this.refresh();
569
+ else {
570
+ this.view = "list";
571
+ this.reload();
572
+ }
573
+ return;
574
+ }
575
+ this.changed = true;
576
+ this.status = p.note ? `${res.message} ${p.note}` : (res.message ?? "Saved.");
577
+ this.pending = undefined;
578
+ this.view = "list";
579
+ this.reload();
580
+ }
581
+
582
+ private toggleSelected(): void {
583
+ const s = this.servers[this.index];
584
+ if (!s) return;
585
+ const res = this.control.setEnabled(s.name, !s.enabled);
586
+ if (res.ok) this.changed = true;
587
+ this.status = res.message ?? "";
588
+ this.reload();
589
+ }
590
+
591
+ // Removal is the one irreversible action here — a deleted connector takes its
592
+ // credential with it, and there is no undo. So `d` ARMS and a second `d` confirms,
593
+ // rather than a single keystroke next to the navigation keys wiping a token you'd
594
+ // have to go re-mint. Any other key (including moving the cursor) disarms.
595
+ private removeSelected(): void {
596
+ const s = this.servers[this.index];
597
+ if (!s) return;
598
+ if (this.armedRemoval !== s.name) {
599
+ this.armedRemoval = s.name;
600
+ this.status = `Press d again to remove "${s.name}"${
601
+ s.secretsSet.length > 0 ? " and its stored credentials" : ""
602
+ }.`;
603
+ this.refresh();
604
+ return;
605
+ }
606
+ this.armedRemoval = undefined;
607
+ const res = this.control.remove(s.name);
608
+ if (res.ok) this.changed = true;
609
+ this.status = res.message ?? "";
610
+ this.reload();
611
+ }
612
+
613
+ private editSelected(): void {
614
+ const s = this.servers[this.index];
615
+ if (!s) return;
616
+ this.startForm({
617
+ title: `Edit ${s.name}`,
618
+ steps: customSteps(s),
619
+ origin: "list",
620
+ build: buildCustomDraft,
621
+ });
622
+ }
623
+
624
+ // -- input ----------------------------------------------------------------
625
+
626
+ handleInput(data: string): void {
627
+ if (this.view === "list") return this.handleListInput(data);
628
+ if (this.view === "catalog") return this.handleCatalogInput(data);
629
+ return this.handleFormInput(data);
630
+ }
631
+
632
+ private handleListInput(data: string): void {
633
+ const kb = getKeybindings();
634
+ // The "+ Add a connector…" row sits one past the end of the server list.
635
+ const last = this.servers.length;
636
+ // Anything that isn't a second `d` cancels an armed removal — moving the cursor
637
+ // must never leave a primed delete pointing at a different row.
638
+ if (data !== "d" && data !== "x" && this.armedRemoval) {
639
+ this.armedRemoval = undefined;
640
+ this.status = "";
641
+ }
642
+ if (kb.matches(data, "tui.select.up")) {
643
+ this.index = this.index === 0 ? last : this.index - 1;
644
+ this.refresh();
645
+ } else if (kb.matches(data, "tui.select.down")) {
646
+ this.index = this.index === last ? 0 : this.index + 1;
647
+ this.refresh();
648
+ } else if (kb.matches(data, "tui.select.confirm")) {
649
+ if (this.index === last) this.openCatalog();
650
+ else this.editSelected();
651
+ } else if (kb.matches(data, "tui.select.cancel")) {
652
+ this.close({
653
+ changed: this.changed,
654
+ message: this.changed ? this.status || "MCP connectors updated." : undefined,
655
+ });
656
+ } else if (data === " ") {
657
+ if (this.index !== last) this.toggleSelected();
658
+ } else if (data === "a" || data === "A") {
659
+ this.openCatalog();
660
+ } else if (data === "d" || data === "x") {
661
+ if (this.index !== last) this.removeSelected();
662
+ }
663
+ }
664
+
665
+ private handleCatalogInput(data: string): void {
666
+ const kb = getKeybindings();
667
+ if (kb.matches(data, "tui.select.up")) {
668
+ if (!this.filtered.length) return;
669
+ this.catalogIndex = this.catalogIndex === 0 ? this.filtered.length - 1 : this.catalogIndex - 1;
670
+ this.refresh();
671
+ } else if (kb.matches(data, "tui.select.down")) {
672
+ if (!this.filtered.length) return;
673
+ this.catalogIndex = this.catalogIndex === this.filtered.length - 1 ? 0 : this.catalogIndex + 1;
674
+ this.refresh();
675
+ } else if (kb.matches(data, "tui.select.confirm")) {
676
+ this.chooseCatalogRow();
677
+ } else if (kb.matches(data, "tui.select.cancel")) {
678
+ this.view = "list";
679
+ this.search.focused = false;
680
+ this.status = "";
681
+ this.refresh();
682
+ } else {
683
+ this.search.handleInput(data);
684
+ this.applySearch();
685
+ }
686
+ }
687
+
688
+ private handleFormInput(data: string): void {
689
+ const kb = getKeybindings();
690
+ const step = this.currentStep()!;
691
+ if (kb.matches(data, "tui.select.cancel")) {
692
+ // Back a step, or out of the form entirely from the first one.
693
+ if (this.stepIndex > 0) {
694
+ this.stepIndex--;
695
+ this.loadStep();
696
+ } else {
697
+ this.view = this.pending?.origin ?? "list";
698
+ this.pending = undefined;
699
+ this.status = "";
700
+ this.field.focused = false;
701
+ this.search.focused = this._focused && this.view === "catalog";
702
+ this.refresh();
703
+ }
704
+ return;
705
+ }
706
+ if (kb.matches(data, "tui.select.confirm")) {
707
+ this.submitStep();
708
+ return;
709
+ }
710
+ if (step.secret) {
711
+ this.secret.handleInput(data);
712
+ this.refresh();
713
+ return;
714
+ }
715
+ this.field.handleInput(data);
716
+ this.refresh();
717
+ }
718
+ }
719
+
720
+ // ---------------------------------------------------------------------------
721
+ // Extension entrypoint.
722
+ // ---------------------------------------------------------------------------
723
+ export default function privateerConnect(pi: {
724
+ registerCommand?: (name: string, opts: unknown) => void;
725
+ }): void {
726
+ if (typeof pi.registerCommand !== "function") return;
727
+
728
+ pi.registerCommand("connect", {
729
+ description: "Add, enable, or remove MCP connectors (GitHub, Notion, Linear, …)",
730
+ handler: async (_args: string, ctx: any): Promise<void> => {
731
+ const ui = ctx?.ui;
732
+ if (!ui?.custom) {
733
+ ui?.notify?.("/connect needs an interactive terminal.", "warning");
734
+ return;
735
+ }
736
+
737
+ const result: ConnectResult | undefined = await ui.custom(
738
+ (tui: TuiLike, theme: ThemeLike, _kb: unknown, close: (r?: ConnectResult) => void) =>
739
+ new ConnectPanel({ tui, theme, close: (r) => close(r) }),
740
+ );
741
+
742
+ if (!result?.changed) return;
743
+ // Reload so pi-mcp-adapter re-reads mcp.json in this session — the same thing
744
+ // its own `/mcp setup` does after a write. Without it the new connector only
745
+ // appears on the next launch, which reads as "it didn't work".
746
+ try {
747
+ await ctx.reload?.();
748
+ } catch {
749
+ /* non-fatal — the config is written either way */
750
+ }
751
+ ui.notify?.(`${result.message ?? "MCP connectors updated."} Check it with /mcp.`, "info");
752
+ },
753
+ });
754
+ }