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.
- package/SECURITY.md +5 -1
- package/bin/privateer-launch.mjs +33 -23
- package/extensions/privateer-brand.ts +152 -60
- package/extensions/privateer-connect.ts +754 -0
- package/package.json +1 -1
- package/patches/@earendil-works+pi-coding-agent+0.80.3.patch +128 -4
- package/src/auth/accountSessions.ts +4 -1
- package/src/auth/privateer.ts +94 -22
- package/src/cli/chat.ts +2 -2
- package/src/daemon/index.ts +2 -2
- package/src/mcp/catalog.ts +178 -0
- package/src/providers/account.ts +132 -37
- package/src/providers/defaultModel.ts +42 -26
|
@@ -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
|
+
}
|