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.
package/bin/privateer-launch.mjs
CHANGED
|
@@ -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"). /
|
|
619
|
-
// canonical
|
|
620
|
-
//
|
|
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.
|
|
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..
|
|
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
|
-
@@ -
|
|
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 +
|
|
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
|
+
}
|