privateer-agent 0.6.8 → 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.
@@ -146,6 +146,7 @@ else {
146
146
  const MANAGED = [
147
147
  "privateer-brand", "privateer-context", "privateer-gate", "privateer-account",
148
148
  "privateer-models", "privateer-posture", "privateer-tools", "privateer-privacy",
149
+ "privateer-connect",
149
150
  "pi-privacy", "pi-web-access", "rpiv-web-tools", "pi-mcp-adapter", "pi-hypa", "pi-subagents",
150
151
  ];
151
152
  for (const name of MANAGED) fs.rmSync(path.join(EXT_DIR, `${name}.ts`), { force: true });
@@ -171,6 +172,7 @@ else {
171
172
  shim("privateer-posture", ext("privateer-posture.ts"));
172
173
  shim("privateer-tools", ext("privateer-tools.ts"));
173
174
  shim("privateer-privacy", ext("privateer-privacy.ts")); // pi-privacy + account tier resolver
175
+ shim("privateer-connect", ext("privateer-connect.ts")); // /connect — MCP connector manager
174
176
  shim("rpiv-web-tools", dep("@juicesharp/rpiv-web-tools", "index.ts")); // private web tools
175
177
  shim("pi-mcp-adapter", dep("pi-mcp-adapter", "index.ts"));
176
178
  shim("pi-hypa", dep("@hypabolic/pi-hypa", "extensions", "index.ts"));
@@ -615,9 +615,14 @@ export default function privateerBrand(pi: any): void {
615
615
  // ONE vocabulary: log in / log out. Pi's own built-ins are /login and /logout, so
616
616
  // matching them is what makes the pair feel like a single concept instead of two
617
617
  // half-overlapping ones (Pi's /logout used to clear only Pi's authStorage while the
618
- // machine login lived on, which read as "logout doesn't work"). /logout here is the
619
- // canonical command; the Pi built-in is redirected to it by patches/, the same
620
- // mechanism as the /model /models redirect.
618
+ // machine login lived on, which read as "logout doesn't work"). doSignIn/doSignOut
619
+ // are canonical; patches/ redirects Pi's built-ins to `/privateer login|logout`
620
+ // rather than to these registrations, so the redirect can't re-enter the branch it
621
+ // was dispatched from — the same mechanism as the /model → /models redirect.
622
+ //
623
+ // In interactive mode that redirect means the built-in wins and these two never
624
+ // dispatch (patches/ also silences the resulting conflict warning). They stay
625
+ // registered for rpc/print mode, which has no such redirect and reaches them by name.
621
626
  //
622
627
  // signin/signout stay registered as undocumented aliases — they were the shipped
623
628
  // names, they're in muscle memory and in older docs, and an alias costs nothing.
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "privateer-agent",
3
- "version": "0.6.8",
3
+ "version": "0.6.9",
4
4
  "description": "Privateer — a provider-agnostic, safe-by-default terminal coding agent with TEE/Tinfoil attestation, rebuilt on the Pi toolkit. Bring your own model across 20 providers.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -78,10 +78,27 @@ index 197bccc..27f6429 100644
78
78
  +}
79
79
  //# sourceMappingURL=auth-guidance.js.map
80
80
  diff --git a/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/interactive-mode.js b/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/interactive-mode.js
81
- index 5d65200..fef6005 100644
81
+ index 5d65200..0a08971 100644
82
82
  --- a/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/interactive-mode.js
83
83
  +++ b/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/interactive-mode.js
84
- @@ -2042,7 +2042,17 @@ export class InteractiveMode {
84
+ @@ -301,9 +301,15 @@ export class InteractiveMode {
85
+ }
86
+ getBuiltInCommandConflictDiagnostics(extensionRunner) {
87
+ const builtinNames = new Set(BUILTIN_SLASH_COMMANDS.map((command) => command.name));
88
+ + // Privateer redirect: these built-ins are patched above to dispatch into the
89
+ + // Privateer extension, so an extension command of the same name is deliberate,
90
+ + // not a mistake — and it stays live in the modes that have no such redirect
91
+ + // (rpc, print). Warning about it just tells the user their own shipped auth
92
+ + // commands are broken when they aren't.
93
+ + const redirectedBuiltins = new Set(["login", "logout"]);
94
+ return extensionRunner
95
+ .getRegisteredCommands()
96
+ - .filter((command) => builtinNames.has(command.name))
97
+ + .filter((command) => builtinNames.has(command.name) && !redirectedBuiltins.has(command.name))
98
+ .map((command) => ({
99
+ type: "warning",
100
+ message: command.invocationName === command.name
101
+ @@ -2042,7 +2048,17 @@ export class InteractiveMode {
85
102
  if (text === "/model" || text.startsWith("/model ")) {
86
103
  const searchTerm = text.startsWith("/model ") ? text.slice(7).trim() : undefined;
87
104
  this.editor.setText("");
@@ -100,7 +117,7 @@ index 5d65200..fef6005 100644
100
117
  return;
101
118
  }
102
119
  if (text === "/export" || text.startsWith("/export ")) {
103
- @@ -2105,14 +2115,44 @@ export class InteractiveMode {
120
+ @@ -2105,14 +2121,44 @@ export class InteractiveMode {
104
121
  this.editor.setText("");
105
122
  return;
106
123
  }
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Curated MCP connector catalog for the `/connect` TUI picker — the terminal's
3
+ * counterpart to the app's quick-add grid (treeview/client/components/mcpCatalog.ts).
4
+ * Kept in sync with that list by hand: it is small, changes rarely, and duplicating
5
+ * eight entries is cheaper than making the agent depend on the client package.
6
+ *
7
+ * `needs` drives what the wizard asks for after you pick an entry:
8
+ * token — one prompt per env key (masked); `credUrl` is shown as "get one at …"
9
+ * path — one prompt replacing the `fill` placeholder ARG (a folder, a DSN)
10
+ * oauth — nothing to type here; you authorize in a browser on THIS machine
11
+ * none — runs locally with no credentials, save it as-is
12
+ *
13
+ * Keep this list conservative and correct: a broken command in the catalog is worse
14
+ * than an omission — the user has no way to tell "this server is misconfigured" from
15
+ * "MCP is broken". tests/mcpCatalog.test.ts enforces the structural invariants.
16
+ */
17
+ import type { McpDraft, McpTransport } from "../remote/mcpControl.ts";
18
+
19
+ export type CatalogNeeds = "token" | "path" | "oauth" | "none";
20
+
21
+ export interface CatalogEntry {
22
+ // Stable key for the picker; also the default server name written to config.
23
+ id: string;
24
+ name: string;
25
+ label: string; // display name in the picker
26
+ blurb: string; // one line, lowercase-ish, says what it gives the agent
27
+ transport: McpTransport;
28
+ command?: string; // stdio
29
+ args?: string[]; // stdio
30
+ env?: Record<string, string>; // env KEYS the user must fill (values are "")
31
+ url?: string; // http
32
+ oauth?: boolean; // http servers that negotiate OAuth
33
+ needs: CatalogNeeds;
34
+ // needs:"token" → the PRIMARY env key (others are still prompted for).
35
+ // needs:"path" → the placeholder ARG to replace with a real path/DSN.
36
+ fill?: string;
37
+ // Where to get the credential, shown as a hint in the form.
38
+ credUrl?: string;
39
+ }
40
+
41
+ export const MCP_CATALOG: CatalogEntry[] = [
42
+ {
43
+ id: "github",
44
+ name: "github",
45
+ label: "GitHub",
46
+ blurb: "Repos, issues, and pull requests.",
47
+ transport: "stdio",
48
+ command: "npx",
49
+ args: ["-y", "@modelcontextprotocol/server-github"],
50
+ env: { GITHUB_PERSONAL_ACCESS_TOKEN: "" },
51
+ needs: "token",
52
+ fill: "GITHUB_PERSONAL_ACCESS_TOKEN",
53
+ credUrl: "https://github.com/settings/tokens",
54
+ },
55
+ {
56
+ id: "filesystem",
57
+ name: "filesystem",
58
+ label: "Filesystem",
59
+ blurb: "Read and write files in a folder you pick.",
60
+ transport: "stdio",
61
+ command: "npx",
62
+ args: ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/folder"],
63
+ needs: "path",
64
+ fill: "/path/to/folder",
65
+ },
66
+ {
67
+ id: "notion",
68
+ name: "notion",
69
+ label: "Notion",
70
+ blurb: "Pages, databases, and blocks.",
71
+ transport: "stdio",
72
+ command: "npx",
73
+ args: ["-y", "@notionhq/notion-mcp-server"],
74
+ env: { NOTION_TOKEN: "" },
75
+ needs: "token",
76
+ fill: "NOTION_TOKEN",
77
+ credUrl: "https://www.notion.so/my-integrations",
78
+ },
79
+ {
80
+ id: "linear",
81
+ name: "linear",
82
+ label: "Linear",
83
+ blurb: "Issues and projects. Sign in via browser.",
84
+ transport: "http",
85
+ url: "https://mcp.linear.app/sse",
86
+ oauth: true,
87
+ needs: "oauth",
88
+ },
89
+ {
90
+ id: "slack",
91
+ name: "slack",
92
+ label: "Slack",
93
+ blurb: "Read and post to channels.",
94
+ transport: "stdio",
95
+ command: "npx",
96
+ args: ["-y", "@modelcontextprotocol/server-slack"],
97
+ env: { SLACK_BOT_TOKEN: "", SLACK_TEAM_ID: "" },
98
+ needs: "token",
99
+ fill: "SLACK_BOT_TOKEN",
100
+ credUrl: "https://api.slack.com/apps",
101
+ },
102
+ {
103
+ id: "postgres",
104
+ name: "postgres",
105
+ label: "PostgreSQL",
106
+ blurb: "Query a Postgres database (read-only).",
107
+ transport: "stdio",
108
+ command: "npx",
109
+ args: ["-y", "@modelcontextprotocol/server-postgres", "postgresql://localhost/mydb"],
110
+ needs: "path",
111
+ fill: "postgresql://localhost/mydb",
112
+ },
113
+ {
114
+ id: "playwright",
115
+ name: "playwright",
116
+ label: "Browser (Playwright)",
117
+ blurb: "Drive a real browser to fetch and click.",
118
+ transport: "stdio",
119
+ command: "npx",
120
+ args: ["-y", "@playwright/mcp@latest"],
121
+ needs: "none",
122
+ },
123
+ {
124
+ id: "memory",
125
+ name: "memory",
126
+ label: "Memory",
127
+ blurb: "A local knowledge-graph scratchpad.",
128
+ transport: "stdio",
129
+ command: "npx",
130
+ args: ["-y", "@modelcontextprotocol/server-memory"],
131
+ needs: "none",
132
+ },
133
+ ];
134
+
135
+ export function catalogEntry(id: string): CatalogEntry | undefined {
136
+ return MCP_CATALOG.find((e) => e.id === id);
137
+ }
138
+
139
+ // The env keys the wizard prompts for, primary (`fill`) first so the token you
140
+ // actually care about is asked for before the incidentals (Slack's TEAM_ID).
141
+ export function promptOrder(e: CatalogEntry): string[] {
142
+ const keys = Object.keys(e.env ?? {});
143
+ if (!e.fill || !keys.includes(e.fill)) return keys;
144
+ return [e.fill, ...keys.filter((k) => k !== e.fill)];
145
+ }
146
+
147
+ // Build the draft mcpControl.save() persists, from a catalog entry plus whatever the
148
+ // user typed. PURE — the whole reason this lives outside the TUI component.
149
+ //
150
+ // input.env — env VALUES by key. An empty/omitted value is passed through as ""
151
+ // and mcpControl treats that as "clear this key" — so a skipped
152
+ // optional credential is simply absent, never a bogus empty one.
153
+ // input.fill — the real path/DSN replacing the placeholder ARG (needs:"path").
154
+ export function draftFromCatalog(
155
+ e: CatalogEntry,
156
+ input: { env?: Record<string, string>; fill?: string } = {},
157
+ ): McpDraft {
158
+ const draft: McpDraft = { name: e.name, transport: e.transport };
159
+
160
+ if (e.transport === "stdio") {
161
+ draft.command = e.command;
162
+ // Replace the placeholder ARG in place (not by index) so reordering the catalog's
163
+ // args can never silently overwrite the wrong one.
164
+ const filled = input.fill?.trim();
165
+ draft.args = (e.args ?? []).map((a) => (e.fill && a === e.fill && filled ? filled : a));
166
+ } else {
167
+ draft.url = e.url;
168
+ draft.oauth = e.oauth ?? true;
169
+ }
170
+
171
+ const keys = Object.keys(e.env ?? {});
172
+ if (keys.length > 0) {
173
+ const env: Record<string, string> = {};
174
+ for (const k of keys) env[k] = input.env?.[k] ?? "";
175
+ draft.env = env;
176
+ }
177
+ return draft;
178
+ }