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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "privateer-agent",
3
- "version": "0.6.7",
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",
@@ -1,8 +1,23 @@
1
1
  diff --git a/node_modules/@earendil-works/pi-coding-agent/dist/core/agent-session.js b/node_modules/@earendil-works/pi-coding-agent/dist/core/agent-session.js
2
- index e223ce1..2bdab10 100644
2
+ index e223ce1..3615d74 100644
3
3
  --- a/node_modules/@earendil-works/pi-coding-agent/dist/core/agent-session.js
4
4
  +++ b/node_modules/@earendil-works/pi-coding-agent/dist/core/agent-session.js
5
- @@ -711,6 +711,15 @@ export class AgentSession {
5
+ @@ -163,6 +163,14 @@ export class AgentSession {
6
+ }
7
+ const isOAuth = this._modelRegistry.isUsingOAuth(model);
8
+ if (isOAuth) {
9
+ + // Privateer patch: the account channel has no API key that could expire —
10
+ + // the terminal is simply not signed in yet (a fresh install now boots on
11
+ + // `privateer/*`, which is the model it will run once logged in). Stock Pi's
12
+ + // wording describes a state that user was never in, and its "/login
13
+ + // privateer" bypasses the branded sign-in. auth-guidance owns the words.
14
+ + if (model.provider === "privateer") {
15
+ + throw new Error(formatNoApiKeyFoundMessage(model.provider));
16
+ + }
17
+ throw new Error(`Authentication failed for "${model.provider}". ` +
18
+ `Credentials may have expired or network is unavailable. ` +
19
+ `Run '/login ${model.provider}' to re-authenticate.`);
20
+ @@ -711,6 +719,15 @@ export class AgentSession {
6
21
  finalError: msg.errorMessage,
7
22
  });
8
23
  this._retryAttempt = 0;
@@ -18,11 +33,72 @@ index e223ce1..2bdab10 100644
18
33
  }
19
34
  if (await this._checkCompaction(msg)) {
20
35
  return true;
36
+ diff --git a/node_modules/@earendil-works/pi-coding-agent/dist/core/auth-guidance.js b/node_modules/@earendil-works/pi-coding-agent/dist/core/auth-guidance.js
37
+ index 197bccc..27f6429 100644
38
+ --- a/node_modules/@earendil-works/pi-coding-agent/dist/core/auth-guidance.js
39
+ +++ b/node_modules/@earendil-works/pi-coding-agent/dist/core/auth-guidance.js
40
+ @@ -1,21 +1,33 @@
41
+ import { join } from "node:path";
42
+ import { getDocsPath } from "../config.js";
43
+ const UNKNOWN_PROVIDER = "unknown";
44
+ +// Privateer patch: speak Privateer, and stop printing absolute node_modules doc paths.
45
+ +//
46
+ +// Stock Pi answered every auth failure with four lines, two of them full paths into
47
+ +// node_modules/@earendil-works/pi-coding-agent/docs/. On a terminal that isn't signed
48
+ +// in, that wall repeats on every prompt and buries the one sentence that matters. It
49
+ +// also told a Privateer user to go find a provider API key when their subscription
50
+ +// already covers the model. Same information, one actionable line.
51
+ export function getProviderLoginHelp() {
52
+ - return [
53
+ - "Use /login to log into a provider via OAuth or API key. See:",
54
+ - ` ${join(getDocsPath(), "providers.md")}`,
55
+ - ` ${join(getDocsPath(), "models.md")}`,
56
+ - ].join("\n");
57
+ + return "Run /login to connect your Privateer account, or /login keys to use your own provider API key.";
58
+ }
59
+ export function formatNoModelsAvailableMessage() {
60
+ return `No models available. ${getProviderLoginHelp()}`;
61
+ }
62
+ export function formatNoModelSelectedMessage() {
63
+ - return `No model selected.\n\n${getProviderLoginHelp()}\n\nThen use /model to select a model.`;
64
+ + return `No model selected. ${getProviderLoginHelp()}\n\nThen use /models to select a model.`;
65
+ }
66
+ export function formatNoApiKeyFoundMessage(provider) {
67
+ + // The account channel: there is no API key to find — you're just not signed in (or
68
+ + // the session didn't arm). Naming the real problem is the whole fix here.
69
+ + if (provider === "privateer") {
70
+ + return "This terminal isn't signed in to Privateer, so it can't run your subscription models.\n\nRun /login — it takes one approval in the Privateer app, and no API key.";
71
+ + }
72
+ const providerDisplay = provider === UNKNOWN_PROVIDER ? "the selected model" : provider;
73
+ return `No API key found for ${providerDisplay}.\n\n${getProviderLoginHelp()}`;
74
+ }
75
+ +// Kept so the module's imports stay meaningful for anything that still wants the docs.
76
+ +export function getProviderDocsPaths() {
77
+ + return [join(getDocsPath(), "providers.md"), join(getDocsPath(), "models.md")];
78
+ +}
79
+ //# sourceMappingURL=auth-guidance.js.map
21
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
22
- index 5d65200..a997ad7 100644
81
+ index 5d65200..0a08971 100644
23
82
  --- a/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/interactive-mode.js
24
83
  +++ b/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/interactive-mode.js
25
- @@ -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 {
26
102
  if (text === "/model" || text.startsWith("/model ")) {
27
103
  const searchTerm = text.startsWith("/model ") ? text.slice(7).trim() : undefined;
28
104
  this.editor.setText("");
@@ -41,3 +117,51 @@ index 5d65200..a997ad7 100644
41
117
  return;
42
118
  }
43
119
  if (text === "/export" || text.startsWith("/export ")) {
120
+ @@ -2105,14 +2121,44 @@ export class InteractiveMode {
121
+ this.editor.setText("");
122
+ return;
123
+ }
124
+ - if (text === "/login") {
125
+ - this.showOAuthSelector("login");
126
+ + if (text === "/login" || text.startsWith("/login ")) {
127
+ this.editor.setText("");
128
+ + // Privateer redirect: a bare /login IS the account sign-in. Pi's built-in
129
+ + // opens a two-step menu ("Use a subscription" → a list of 20+ providers)
130
+ + // that buries the one option a Privateer user wants, and — because it
131
+ + // only auto-selects a model when the current one is UNKNOWN — a
132
+ + // successful login through it left the terminal on its launch model and
133
+ + // the next prompt died on "No API key found". The extension's own flow
134
+ + // signs in, arms the account channel, and selects the model.
135
+ + // `/login <anything>` (documented as `/login keys`) still opens Pi's own
136
+ + // selector, so BYO provider keys stay reachable; and if the extension
137
+ + // isn't loaded we fall back to Pi's selector for both.
138
+ + if (text === "/login" && this.isExtensionCommand("/privateer")) {
139
+ + await this.session.prompt("/privateer login");
140
+ + }
141
+ + else {
142
+ + this.showOAuthSelector("login");
143
+ + }
144
+ return;
145
+ }
146
+ if (text === "/logout") {
147
+ - this.showOAuthSelector("logout");
148
+ this.editor.setText("");
149
+ + // Privateer redirect: /logout must actually log you out. Pi's built-in
150
+ + // only clears Pi's authStorage, which leaves the Privateer machine
151
+ + // login in ~/.privateer/credentials.json untouched — so on a signed-in
152
+ + // machine it reported "No stored credentials to remove" and changed
153
+ + // nothing. Route to the account logout instead, which revokes this
154
+ + // machine's whole token family (login + every terminal spawned from it)
155
+ + // and wipes local state. Target is `/privateer logout` rather than the
156
+ + // extension's own `/logout` so this can never re-enter the branch it
157
+ + // was dispatched from. Falls back to Pi's selector when the extension
158
+ + // isn't loaded.
159
+ + if (this.isExtensionCommand("/privateer")) {
160
+ + await this.session.prompt("/privateer logout");
161
+ + }
162
+ + else {
163
+ + this.showOAuthSelector("logout");
164
+ + }
165
+ return;
166
+ }
167
+ if (text === "/new") {
@@ -144,7 +144,10 @@ export function dropOwnedSession(pid: number): void {
144
144
  writeRegistry(reg);
145
145
  }
146
146
 
147
- // Test seam: wipe the registry file.
147
+ // Wipe the registry. Used by logout(), where the server has just revoked this
148
+ // machine's whole token family: every entry now names a dead session, and leaving
149
+ // them behind would offer the next login a list of orphans to "reclaim" that can
150
+ // only fail. Also a test seam.
148
151
  export function clearOwnedSessions(): void {
149
152
  try {
150
153
  rmSync(accountSessionsPath(), { force: true });
@@ -21,6 +21,7 @@ import {
21
21
  forgetOwnedSession,
22
22
  orphanedSessions,
23
23
  dropOwnedSession,
24
+ clearOwnedSessions,
24
25
  } from "./accountSessions.ts";
25
26
  import { isAccountCapCode } from "../engine/errors.ts";
26
27
  import { terminalPublicKeyBase64 } from "../crypto/terminalKey.ts";
@@ -94,9 +95,37 @@ export function defaultDeviceLabel(): string {
94
95
  }
95
96
  }
96
97
 
97
- // ── Credential storage (0600, like saveGlobalConfig) ─────────────────────────
98
+ // ── Cross-instance state ─────────────────────────────────────────────────────
99
+ //
100
+ // Pi loads every extension with a FRESH jiti instance (`moduleCache: false`, see
101
+ // core/extensions/loader.js), so privateer-brand and privateer-account each get their
102
+ // OWN copy of this module — separate credential cache, separate listener sets.
103
+ //
104
+ // That silently broke /login. The account provider's OAuth login() ran inside the
105
+ // privateer-account copy and called notifySignedIn() there, while the UI's listener
106
+ // (the one that switches the live session onto a model the account can actually
107
+ // serve) was registered on the privateer-brand copy. The signal never crossed, so a
108
+ // successful sign-in left the terminal pinned to its keyless launch model and the
109
+ // very next prompt died with "No API key found for openrouter" — exactly the state
110
+ // the login was supposed to fix. The same split let a /logout in one copy leave a
111
+ // stale `user` memoized in another.
112
+ //
113
+ // So anything that must be observed ACROSS extensions lives on globalThis, keyed by
114
+ // a registered Symbol — one bus, however many module instances jiti creates.
115
+ const SHARED = Symbol.for("privateer.auth.shared");
116
+
117
+ interface SharedAuthState {
118
+ cache: Credentials | null;
119
+ signedIn: Set<SignedInListener>;
120
+ expired: Set<SessionExpiredListener>;
121
+ }
98
122
 
99
- let _cache: Credentials | null | undefined;
123
+ function shared(): SharedAuthState {
124
+ const g = globalThis as { [SHARED]?: SharedAuthState };
125
+ return (g[SHARED] ??= { cache: null, signedIn: new Set(), expired: new Set() });
126
+ }
127
+
128
+ // ── Credential storage (0600, like saveGlobalConfig) ─────────────────────────
100
129
 
101
130
  // Per-terminal child session (see spawnChildSession). Held in memory ONLY — it
102
131
  // is never written to the shared credentials file, so each running terminal
@@ -144,15 +173,16 @@ export function loadCredentials(): Credentials | null {
144
173
  // memoized the pre-login "absent" as null, that instance would report "not signed
145
174
  // in" forever (e.g. /remote-access refusing after a successful sign-in). Re-reading
146
175
  // disk on each miss lets a later call see what a sign-in just wrote.
147
- if (_cache) return _cache;
176
+ const state = shared();
177
+ if (state.cache) return state.cache;
148
178
  const path = credentialsPath();
149
179
  if (!existsSync(path)) return null;
150
180
  try {
151
- _cache = JSON.parse(readFileSync(path, "utf8")) as Credentials;
181
+ state.cache = JSON.parse(readFileSync(path, "utf8")) as Credentials;
152
182
  } catch {
153
183
  return null;
154
184
  }
155
- return _cache;
185
+ return state.cache;
156
186
  }
157
187
 
158
188
  export function saveCredentials(creds: Credentials): void {
@@ -162,7 +192,7 @@ export function saveCredentials(creds: Credentials): void {
162
192
  const path = credentialsPath();
163
193
  writeFileSync(path, JSON.stringify(creds, null, 2) + "\n", { encoding: "utf8", mode: 0o600 });
164
194
  tryChmod(path, 0o600);
165
- _cache = creds;
195
+ shared().cache = creds;
166
196
  }
167
197
 
168
198
  export function clearCredentials(): void {
@@ -174,7 +204,7 @@ export function clearCredentials(): void {
174
204
  // Drop the pinned account signing key too — it belongs to the account that just
175
205
  // signed out; a different account must re-pin its own at link.
176
206
  clearAccountSignKey();
177
- _cache = null;
207
+ shared().cache = null;
178
208
  _child = null;
179
209
  _account = null;
180
210
  }
@@ -204,15 +234,15 @@ function tryChmod(path: string, mode: number): void {
204
234
  // stops working; the UI subscribes to announce the sign-out prominently.
205
235
 
206
236
  type SessionExpiredListener = () => void;
207
- const _expiredListeners = new Set<SessionExpiredListener>();
208
237
 
209
238
  export function onSessionExpired(listener: SessionExpiredListener): () => void {
210
- _expiredListeners.add(listener);
211
- return () => _expiredListeners.delete(listener);
239
+ const listeners = shared().expired;
240
+ listeners.add(listener);
241
+ return () => listeners.delete(listener);
212
242
  }
213
243
 
214
244
  function notifySessionExpired(): void {
215
- for (const listener of _expiredListeners) {
245
+ for (const listener of shared().expired) {
216
246
  try {
217
247
  listener();
218
248
  } catch {
@@ -248,17 +278,19 @@ export function handleServerRevoke(): void {
248
278
  // A listener here refreshes the UI regardless of which path the user took.
249
279
 
250
280
  type SignedInListener = () => void;
251
- const _signedInListeners = new Set<SignedInListener>();
252
281
 
253
282
  export function onSignedIn(listener: SignedInListener): () => void {
254
- _signedInListeners.add(listener);
255
- return () => _signedInListeners.delete(listener);
283
+ const listeners = shared().signedIn;
284
+ listeners.add(listener);
285
+ return () => listeners.delete(listener);
256
286
  }
257
287
 
258
288
  // Emit the sign-in signal. Exported so the account OAuth provider can announce a
259
289
  // completed subscription login on the already-linked path (see the note above).
290
+ // Listeners MUST be idempotent: a device-code login fires this once the credentials
291
+ // land and again once the account channel is armed (see privateerOAuthProvider.login).
260
292
  export function notifySignedIn(): void {
261
- for (const listener of _signedInListeners) {
293
+ for (const listener of shared().signedIn) {
262
294
  try {
263
295
  listener();
264
296
  } catch {
@@ -602,15 +634,55 @@ export async function revokeLocalSessions(timeoutMs = 1500): Promise<void> {
602
634
  // ── Logout ───────────────────────────────────────────────────────────────────
603
635
 
604
636
  /**
605
- * Log out this terminal: revoke its session server-side (best effort) and wipe
606
- * local credentials. Other devices/sessions are untouched.
637
+ * Log out this MACHINE: revoke the machine login and every terminal session
638
+ * spawned from it, then wipe all local auth state. Other devices (the phone app,
639
+ * another laptop) keep their own logins — each has its own token family.
640
+ *
641
+ * Two things this deliberately does NOT do, both of which it used to:
642
+ *
643
+ * 1. It does not POST /auth/logout. That endpoint calls revokeAllUserSessions —
644
+ * the entire ACCOUNT, every device including the app — while this function's
645
+ * contract (and its old doc comment) promised the opposite. Signing out of one
646
+ * terminal must not sign you out of your phone.
647
+ *
648
+ * 2. It does not go through apiRequest/authedFetch. Those authenticate with a CHILD
649
+ * session and spawn one if absent — so at the per-machine child cap the spawn
650
+ * throws 429 and the logout never reaches the server AT ALL. That was a deadlock:
651
+ * the cap blocked the one call that clears the cap. We authenticate with the
652
+ * PARENT instead, which is never subject to the cap.
653
+ *
654
+ * The parent's stored access token is usually expired (it is minted once at /login
655
+ * and never rotated — the refresh token is the liveness proof), so we rotate for a
656
+ * fresh one first. Rotation is free here precisely because we are destroying the
657
+ * credential either way: nothing downstream needs the token we burn. rotateSession
658
+ * is the no-ownership-side-effects variant, so this cannot clobber the registry
659
+ * entry of a session we are about to revoke wholesale anyway.
660
+ *
661
+ * DELETE /auth/session/current then revokes the parent's family, which the server
662
+ * cascades to every row with `parentFamilyId === familyId` — i.e. all this machine's
663
+ * terminals, including the orphans left by terminals that died without their
664
+ * shutdown hook. That cascade is what makes an accumulated cap self-clearing:
665
+ * logout, log back in, and the machine starts from zero live children.
666
+ *
667
+ * Local state is wiped unconditionally at the end, whatever the network did. A
668
+ * logout that can't reach the server must still leave you logged out locally —
669
+ * the rows it failed to revoke age out on their TTL.
607
670
  */
608
671
  export async function logout(): Promise<void> {
609
- try {
610
- await apiRequest("/auth/logout", { method: "POST" });
611
- } catch {
612
- /* best effort — clear locally regardless */
672
+ const parent = loadCredentials();
673
+ if (parent) {
674
+ try {
675
+ const fresh = await rotateSession(parent.refreshToken);
676
+ await deleteSession(fresh.access, 5000);
677
+ } catch {
678
+ /* offline, or the login was already dead server-side — wipe locally anyway */
679
+ }
613
680
  }
681
+ // In-memory sessions are gone with the family above; drop the handles so nothing
682
+ // tries to revoke them individually on the way out.
683
+ _child = null;
684
+ _account = null;
685
+ clearOwnedSessions(); // every entry named a session the cascade just killed
614
686
  clearCredentials();
615
687
  }
616
688
 
@@ -622,7 +694,7 @@ export async function logout(): Promise<void> {
622
694
  // = the JWT's exp (so Pi refreshes just before the server would reject it). These are
623
695
  // independent of authedFetch's in-memory _child (Pi owns this credential's lifecycle).
624
696
 
625
- interface AccountCredential {
697
+ export interface AccountCredential {
626
698
  access: string;
627
699
  refresh: string;
628
700
  expires: number; // ms epoch
package/src/cli/chat.ts CHANGED
@@ -478,8 +478,8 @@ async function main() {
478
478
  });
479
479
  console.log(`${GREEN}Signed in as ${user.email ?? user.id}.${RESET}`);
480
480
  // Move the live session onto a confidential model right away, so the next prompt
481
- // doesn't dead-end on the keyless launch model ("No API key found for openrouter").
482
- // resolveSignedInModel prefers Tinfoil GLM 5.2, else the account's NEAR channel;
481
+ // doesn't dead-end on the launch model's missing key. resolveSignedInModel picks
482
+ // Tinfoil GLM 5.2 — direct with a Tinfoil key, over the subscription otherwise;
483
483
  // PRIVATEER_MODEL (a deliberate override) is respected and left alone.
484
484
  if (!process.env.PRIVATEER_MODEL?.trim()) {
485
485
  const target = resolveSignedInModel();
@@ -326,7 +326,7 @@ export class Daemon {
326
326
  // The account signed this daemon out server-side (revoked from the app's Linked
327
327
  // Devices). Beyond ending remote access (onTerminate), this wipes the machine
328
328
  // login: drop the relay and clear credentials, so routines/tasks stop cleanly
329
- // instead of dead-ending on a 401 each run. Stays idle until you /signin on this
329
+ // instead of dead-ending on a 401 each run. Stays idle until you /login on this
330
330
  // machine and restart the daemon (the relayTerminated guard, as with onTerminate).
331
331
  onRevoked: () => {
332
332
  this.relayTerminated = true;
@@ -334,7 +334,7 @@ export class Daemon {
334
334
  this.relay?.stop();
335
335
  this.relay = undefined;
336
336
  handleServerRevoke();
337
- log("account signed out from the app (session revoked) — cleared credentials; idle until you run /signin on this machine and restart the daemon");
337
+ log("account signed out from the app (session revoked) — cleared credentials; idle until you run /login on this machine and restart the daemon");
338
338
  },
339
339
  onStatus: (text) => log(`relay: ${text}`),
340
340
  onDisconnected: () => {
@@ -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
+ }