privateer-agent 0.1.0

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.
Files changed (86) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +474 -0
  3. package/bin/privateer.mjs +11 -0
  4. package/package.json +74 -0
  5. package/src/agents/loader.ts +49 -0
  6. package/src/auth/privateer.ts +393 -0
  7. package/src/commands/custom.ts +75 -0
  8. package/src/commands/registry.ts +499 -0
  9. package/src/components/AgentGroupView.tsx +104 -0
  10. package/src/components/App.tsx +1376 -0
  11. package/src/components/ApprovalPrompt.tsx +38 -0
  12. package/src/components/Banner.tsx +58 -0
  13. package/src/components/Markdown.tsx +183 -0
  14. package/src/components/ModeHint.tsx +40 -0
  15. package/src/components/ModelPicker.tsx +269 -0
  16. package/src/components/Onboarding.tsx +203 -0
  17. package/src/components/PlanConfirm.tsx +37 -0
  18. package/src/components/PrivateerLogin.tsx +109 -0
  19. package/src/components/PromptInput.tsx +602 -0
  20. package/src/components/RewindPicker.tsx +69 -0
  21. package/src/components/Root.tsx +95 -0
  22. package/src/components/SessionPicker.tsx +64 -0
  23. package/src/components/StatusBar.tsx +121 -0
  24. package/src/components/TodoPanel.tsx +36 -0
  25. package/src/components/ToolCallView.tsx +109 -0
  26. package/src/components/Transcript.tsx +203 -0
  27. package/src/components/figures.ts +13 -0
  28. package/src/components/promptModel.ts +73 -0
  29. package/src/components/spinnerVerbs.ts +46 -0
  30. package/src/components/theme.ts +55 -0
  31. package/src/components/types.ts +34 -0
  32. package/src/components/useTeeShield.ts +104 -0
  33. package/src/components/useTerminalWidth.ts +24 -0
  34. package/src/components/useZdrShield.ts +126 -0
  35. package/src/config/load.ts +115 -0
  36. package/src/config/paths.ts +61 -0
  37. package/src/config/schema.ts +94 -0
  38. package/src/context/outputStyles.ts +42 -0
  39. package/src/context/projectInfo.ts +59 -0
  40. package/src/context/systemPrompt.ts +167 -0
  41. package/src/engine/QueryEngine.ts +399 -0
  42. package/src/engine/errors.ts +197 -0
  43. package/src/engine/events.ts +74 -0
  44. package/src/engine/router.ts +165 -0
  45. package/src/hooks/engine.ts +155 -0
  46. package/src/main.tsx +167 -0
  47. package/src/mcp/client.ts +236 -0
  48. package/src/mcp/oauth.ts +245 -0
  49. package/src/memory/auto.ts +146 -0
  50. package/src/memory/checkpoints.ts +227 -0
  51. package/src/memory/store.ts +127 -0
  52. package/src/permissions/danger.ts +56 -0
  53. package/src/permissions/gate.ts +38 -0
  54. package/src/permissions/mode.ts +39 -0
  55. package/src/permissions/protected.ts +29 -0
  56. package/src/permissions/uiGate.ts +73 -0
  57. package/src/providers/attestation.ts +149 -0
  58. package/src/providers/capabilities.ts +104 -0
  59. package/src/providers/catalog.ts +66 -0
  60. package/src/providers/models.ts +183 -0
  61. package/src/providers/registry.ts +71 -0
  62. package/src/providers/resolve.ts +78 -0
  63. package/src/remote/relayClient.ts +283 -0
  64. package/src/session.ts +264 -0
  65. package/src/tools/bash.ts +98 -0
  66. package/src/tools/context.ts +114 -0
  67. package/src/tools/edit.ts +67 -0
  68. package/src/tools/exec.ts +60 -0
  69. package/src/tools/glob.ts +39 -0
  70. package/src/tools/grep.ts +86 -0
  71. package/src/tools/index.ts +69 -0
  72. package/src/tools/memory.ts +53 -0
  73. package/src/tools/processRegistry.ts +77 -0
  74. package/src/tools/read.ts +42 -0
  75. package/src/tools/saveAttachment.ts +53 -0
  76. package/src/tools/task.ts +52 -0
  77. package/src/tools/todo.ts +36 -0
  78. package/src/tools/todoStore.ts +31 -0
  79. package/src/tools/walk.ts +44 -0
  80. package/src/tools/web.ts +145 -0
  81. package/src/tools/write.ts +40 -0
  82. package/src/util/attachmentStore.ts +72 -0
  83. package/src/util/images.ts +343 -0
  84. package/src/util/limit.ts +32 -0
  85. package/src/util/redact.ts +44 -0
  86. package/src/version.ts +13 -0
@@ -0,0 +1,236 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { tool, jsonSchema, type ToolSet } from "ai";
3
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
4
+ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
5
+ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
6
+ import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
7
+ import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js";
8
+ import { globalPaths, projectPaths } from "../config/paths.ts";
9
+ import { type PermissionGate, PermissionDeniedError } from "../permissions/gate.ts";
10
+ import { FileOAuthProvider, type AuthorizePrompt } from "./oauth.ts";
11
+
12
+ // A local stdio server (launched as a child process) or a remote HTTP server.
13
+ export type StdioServerConfig = { command: string; args?: string[]; env?: Record<string, string> };
14
+ export type HttpServerConfig = { url: string; headers?: Record<string, string>; transport?: "http" | "sse" };
15
+ export type McpServerConfig = StdioServerConfig | HttpServerConfig;
16
+ export type McpServers = Record<string, McpServerConfig>;
17
+
18
+ function isStdio(cfg: McpServerConfig): cfg is StdioServerConfig {
19
+ return typeof (cfg as StdioServerConfig).command === "string";
20
+ }
21
+
22
+ export interface McpToolDef {
23
+ name: string;
24
+ description?: string;
25
+ inputSchema?: Record<string, unknown>;
26
+ }
27
+
28
+ // Read mcp.json from project then user scope (project overrides). Accepts either a
29
+ // top-level map or a { "mcpServers": {...} } wrapper. An entry is kept if it names a
30
+ // stdio `command` or a remote `url`; anything else is dropped.
31
+ export function loadMcpServers(cwd: string = process.cwd()): McpServers {
32
+ const merge = (path: string, into: McpServers) => {
33
+ if (!existsSync(path)) return;
34
+ try {
35
+ const raw = JSON.parse(readFileSync(path, "utf8"));
36
+ const map = (raw && typeof raw === "object" && raw.mcpServers) || raw;
37
+ if (map && typeof map === "object") {
38
+ for (const [name, cfg] of Object.entries(map as Record<string, unknown>)) {
39
+ if (cfg && typeof cfg === "object") {
40
+ const c = cfg as Partial<StdioServerConfig & HttpServerConfig>;
41
+ if (typeof c.command === "string" || typeof c.url === "string") {
42
+ into[name] = cfg as McpServerConfig;
43
+ }
44
+ }
45
+ }
46
+ }
47
+ } catch {
48
+ /* malformed mcp.json → skip */
49
+ }
50
+ };
51
+ const servers: McpServers = {};
52
+ merge(globalPaths().mcp, servers);
53
+ merge(projectPaths(cwd).mcp, servers);
54
+ return servers;
55
+ }
56
+
57
+ // process.env as a clean string map, so stdio servers inherit our environment
58
+ // (StdioClientTransport otherwise launches with a minimal default env).
59
+ function inheritedEnv(extra?: Record<string, string>): Record<string, string> {
60
+ const base: Record<string, string> = {};
61
+ for (const [k, v] of Object.entries(process.env)) if (v !== undefined) base[k] = v;
62
+ return { ...base, ...extra };
63
+ }
64
+
65
+ function withTimeout<T>(p: Promise<T>, ms: number, label: string): Promise<T> {
66
+ return new Promise<T>((resolve, reject) => {
67
+ const timer = setTimeout(() => reject(new Error(`${label} timed out`)), ms);
68
+ p.then((v) => (clearTimeout(timer), resolve(v)), (e) => (clearTimeout(timer), reject(e)));
69
+ });
70
+ }
71
+
72
+ // An MCP client over the official SDK, transport chosen from config: a local child
73
+ // process (stdio) or a remote server (Streamable HTTP, falling back to legacy SSE).
74
+ // Exposes only what the agent needs: connect, tools/list, tools/call, close.
75
+ export class McpClient {
76
+ private client?: Client;
77
+
78
+ constructor(
79
+ private readonly name: string,
80
+ private readonly cfg: McpServerConfig,
81
+ private readonly cwd: string,
82
+ private readonly onAuthorize?: AuthorizePrompt,
83
+ ) {}
84
+
85
+ async connect(timeoutMs = 10_000): Promise<void> {
86
+ const client = new Client({ name: "privateer", version: "0.1.0" }, { capabilities: {} });
87
+ if (isStdio(this.cfg)) {
88
+ const transport = new StdioClientTransport({
89
+ command: this.cfg.command,
90
+ args: this.cfg.args ?? [],
91
+ env: inheritedEnv(this.cfg.env),
92
+ cwd: this.cwd,
93
+ stderr: "ignore", // keep server logs out of the TUI
94
+ });
95
+ await withTimeout(client.connect(transport), timeoutMs, `MCP server "${this.name}" connect`);
96
+ this.client = client;
97
+ return;
98
+ }
99
+
100
+ const url = new URL(this.cfg.url);
101
+ const requestInit = this.cfg.headers ? { headers: this.cfg.headers } : undefined;
102
+ // Static header auth wins. Otherwise attach an interactive OAuth provider — it
103
+ // stays dormant unless the server actually answers 401.
104
+ const authProvider = this.cfg.headers
105
+ ? undefined
106
+ : new FileOAuthProvider(this.name, this.cfg.url, this.onAuthorize);
107
+ const sse = () => new SSEClientTransport(url, { requestInit, authProvider });
108
+
109
+ if (this.cfg.transport === "sse") {
110
+ await this.tryConnect(client, sse, authProvider, timeoutMs);
111
+ } else {
112
+ const http = () => new StreamableHTTPClientTransport(url, { requestInit, authProvider });
113
+ try {
114
+ await this.tryConnect(client, http, authProvider, timeoutMs);
115
+ } catch (err) {
116
+ // A server that only speaks the legacy HTTP+SSE transport rejects the
117
+ // Streamable-HTTP handshake; retry once over SSE before giving up.
118
+ if (this.cfg.transport !== "http") await this.tryConnect(client, sse, authProvider, timeoutMs);
119
+ else throw err;
120
+ }
121
+ }
122
+ this.client = client;
123
+ }
124
+
125
+ // Connect with one transport kind. On a 401 with an OAuth provider configured,
126
+ // run the interactive consent dance (browser → loopback redirect → code →
127
+ // token exchange) and reconnect with a fresh transport that picks up the tokens.
128
+ private async tryConnect(
129
+ client: Client,
130
+ make: () => StreamableHTTPClientTransport | SSEClientTransport,
131
+ provider: FileOAuthProvider | undefined,
132
+ timeoutMs: number,
133
+ ): Promise<void> {
134
+ const transport = make();
135
+ try {
136
+ await withTimeout(client.connect(transport), timeoutMs, `MCP server "${this.name}" connect`);
137
+ } catch (err) {
138
+ if (err instanceof UnauthorizedError && provider) {
139
+ const code = await provider.waitForCode();
140
+ await transport.finishAuth(code);
141
+ await withTimeout(client.connect(make()), timeoutMs, `MCP server "${this.name}" reconnect`);
142
+ } else {
143
+ throw err;
144
+ }
145
+ }
146
+ }
147
+
148
+ async listTools(): Promise<McpToolDef[]> {
149
+ const res = await this.client!.listTools();
150
+ return Array.isArray(res?.tools)
151
+ ? res.tools.map((t) => ({ name: t.name, description: t.description, inputSchema: t.inputSchema as Record<string, unknown> }))
152
+ : [];
153
+ }
154
+
155
+ async callTool(name: string, args: unknown): Promise<string> {
156
+ const res = await this.client!.callTool({ name, arguments: (args ?? {}) as Record<string, unknown> });
157
+ return formatContent(res);
158
+ }
159
+
160
+ close(): void {
161
+ void this.client?.close();
162
+ }
163
+ }
164
+
165
+ function formatContent(result: any): string {
166
+ const content = result?.content;
167
+ if (Array.isArray(content)) {
168
+ const text = content
169
+ .map((c: any) => (c?.type === "text" ? c.text : `[${c?.type ?? "content"}]`))
170
+ .join("\n");
171
+ return result?.isError ? `Error: ${text}` : text || "(no output)";
172
+ }
173
+ return JSON.stringify(result ?? {});
174
+ }
175
+
176
+ // Adapt one server's MCP tools into AI-SDK tools, namespaced as "<server>__<tool>" and
177
+ // routed through the permission gate (MCP calls are external, so they prompt by default).
178
+ export function adaptMcpTools(
179
+ server: string,
180
+ client: McpClient,
181
+ defs: McpToolDef[],
182
+ gate: PermissionGate,
183
+ ): ToolSet {
184
+ const set: ToolSet = {};
185
+ for (const d of defs) {
186
+ const name = `${server}__${d.name}`;
187
+ set[name] = tool({
188
+ description: d.description ?? `${d.name} (MCP server: ${server})`,
189
+ inputSchema: jsonSchema((d.inputSchema as any) ?? { type: "object", properties: {} }),
190
+ execute: async (args: unknown) => {
191
+ const decision = await gate.request({
192
+ tool: name,
193
+ kind: "fetch",
194
+ title: `MCP ${server}: ${d.name}`,
195
+ detail: JSON.stringify(args ?? {}).slice(0, 120),
196
+ });
197
+ if (decision === "deny") throw new PermissionDeniedError(name);
198
+ return client.callTool(d.name, args);
199
+ },
200
+ });
201
+ }
202
+ return set;
203
+ }
204
+
205
+ export interface McpConnection {
206
+ tools: ToolSet;
207
+ clients: McpClient[];
208
+ status: { server: string; tools: number; error?: string }[];
209
+ }
210
+
211
+ // Connect every configured server, returning the merged toolset, the live clients (to
212
+ // close on teardown), and a per-server status. Failures are isolated per server.
213
+ export async function connectMcpServers(
214
+ servers: McpServers,
215
+ cwd: string,
216
+ gate: PermissionGate,
217
+ onAuthorize?: AuthorizePrompt,
218
+ ): Promise<McpConnection> {
219
+ const tools: ToolSet = {};
220
+ const clients: McpClient[] = [];
221
+ const status: McpConnection["status"] = [];
222
+ for (const [name, cfg] of Object.entries(servers)) {
223
+ const client = new McpClient(name, cfg, cwd, onAuthorize);
224
+ try {
225
+ await client.connect();
226
+ const defs = await client.listTools();
227
+ Object.assign(tools, adaptMcpTools(name, client, defs, gate));
228
+ clients.push(client);
229
+ status.push({ server: name, tools: defs.length });
230
+ } catch (err) {
231
+ client.close();
232
+ status.push({ server: name, tools: 0, error: err instanceof Error ? err.message : String(err) });
233
+ }
234
+ }
235
+ return { tools, clients, status };
236
+ }
@@ -0,0 +1,245 @@
1
+ import { createServer, type Server as HttpServer } from "node:http";
2
+ import { spawn } from "node:child_process";
3
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync, rmSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { createHash, randomBytes } from "node:crypto";
6
+ import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js";
7
+ import type {
8
+ OAuthClientInformation,
9
+ OAuthClientInformationFull,
10
+ OAuthClientMetadata,
11
+ OAuthTokens,
12
+ } from "@modelcontextprotocol/sdk/shared/auth.js";
13
+ import { globalPaths } from "../config/paths.ts";
14
+
15
+ // Called when interactive consent is needed, so the host (e.g. the TUI) can show
16
+ // the URL in case the browser doesn't open on its own.
17
+ export type AuthorizePrompt = (info: { server: string; url: string }) => void;
18
+
19
+ // Everything we persist between runs for one remote server. Written owner-only
20
+ // (0600) since it holds OAuth tokens. `port` is pinned so the loopback redirect
21
+ // URI stays stable across runs (and thus matches the registered client).
22
+ interface AuthStore {
23
+ port?: number;
24
+ state?: string;
25
+ codeVerifier?: string;
26
+ clientInformation?: OAuthClientInformationFull;
27
+ tokens?: OAuthTokens;
28
+ }
29
+
30
+ const DEFAULT_PORT = 7777;
31
+ const CALLBACK_PATH = "/oauth/callback";
32
+
33
+ function authDir(): string {
34
+ return join(globalPaths().dir, "mcp-auth");
35
+ }
36
+
37
+ // One file per server, keyed by a short hash of its URL.
38
+ function storePath(serverUrl: string): string {
39
+ const hash = createHash("sha256").update(serverUrl).digest("hex").slice(0, 16);
40
+ return join(authDir(), `${hash}.json`);
41
+ }
42
+
43
+ // An interactive OAuth 2.1 (PKCE + dynamic client registration) provider that
44
+ // persists credentials to disk and catches the redirect on a loopback server.
45
+ // One instance per remote server connection.
46
+ export class FileOAuthProvider implements OAuthClientProvider {
47
+ private store: AuthStore;
48
+ private readonly path: string;
49
+ private port: number;
50
+ private server?: HttpServer;
51
+ private redirect?: { resolve: (code: string) => void; reject: (e: Error) => void };
52
+
53
+ constructor(
54
+ private readonly serverName: string,
55
+ private readonly serverUrl: string,
56
+ private readonly onAuthorize?: AuthorizePrompt,
57
+ ) {
58
+ this.path = storePath(serverUrl);
59
+ this.store = readStore(this.path);
60
+ const envPort = Number(process.env.PRIVATEER_OAUTH_PORT);
61
+ this.port = this.store.port ?? (Number.isInteger(envPort) ? envPort : DEFAULT_PORT);
62
+ }
63
+
64
+ get redirectUrl(): string {
65
+ return `http://127.0.0.1:${this.port}${CALLBACK_PATH}`;
66
+ }
67
+
68
+ get clientMetadata(): OAuthClientMetadata {
69
+ return {
70
+ client_name: "Privateer",
71
+ redirect_uris: [this.redirectUrl],
72
+ grant_types: ["authorization_code", "refresh_token"],
73
+ response_types: ["code"],
74
+ token_endpoint_auth_method: "none", // public client (PKCE), no secret
75
+ };
76
+ }
77
+
78
+ clientInformation(): OAuthClientInformation | undefined {
79
+ return this.store.clientInformation;
80
+ }
81
+
82
+ saveClientInformation(info: OAuthClientInformationFull): void {
83
+ this.store.clientInformation = info;
84
+ this.persist();
85
+ }
86
+
87
+ tokens(): OAuthTokens | undefined {
88
+ return this.store.tokens;
89
+ }
90
+
91
+ saveTokens(tokens: OAuthTokens): void {
92
+ this.store.tokens = tokens;
93
+ this.persist();
94
+ }
95
+
96
+ saveCodeVerifier(verifier: string): void {
97
+ this.store.codeVerifier = verifier;
98
+ this.persist();
99
+ }
100
+
101
+ codeVerifier(): string {
102
+ if (!this.store.codeVerifier) throw new Error("no PKCE code verifier saved");
103
+ return this.store.codeVerifier;
104
+ }
105
+
106
+ state(): string {
107
+ this.store.state = randomBytes(16).toString("hex");
108
+ this.persist();
109
+ return this.store.state;
110
+ }
111
+
112
+ // Drop persisted credentials when the server says they're stale, so the next
113
+ // connect re-runs discovery / re-authorizes instead of looping on 401s.
114
+ invalidateCredentials(scope: "all" | "client" | "tokens" | "verifier" | "discovery"): void {
115
+ if (scope === "all") this.store = { port: this.store.port };
116
+ else if (scope === "client") delete this.store.clientInformation;
117
+ else if (scope === "tokens") delete this.store.tokens;
118
+ else if (scope === "verifier") delete this.store.codeVerifier;
119
+ this.persist();
120
+ }
121
+
122
+ // Bind the loopback listener, open the browser, and arm the wait. The SDK calls
123
+ // this during auth() right before it throws UnauthorizedError.
124
+ async redirectToAuthorization(authorizationUrl: URL): Promise<void> {
125
+ await this.listen();
126
+ this.onAuthorize?.({ server: this.serverName, url: authorizationUrl.toString() });
127
+ openBrowser(authorizationUrl.toString());
128
+ }
129
+
130
+ // Resolves with the authorization code once the user is redirected back. Caller
131
+ // passes it to transport.finishAuth(). Always tears the listener down after.
132
+ waitForCode(timeoutMs = 300_000): Promise<string> {
133
+ return new Promise<string>((resolve, reject) => {
134
+ const done = (fn: () => void) => {
135
+ clearTimeout(timer);
136
+ this.closeServer();
137
+ fn();
138
+ };
139
+ const timer = setTimeout(
140
+ () => done(() => reject(new Error(`OAuth for "${this.serverName}" timed out`))),
141
+ timeoutMs,
142
+ );
143
+ this.redirect = {
144
+ resolve: (code) => done(() => resolve(code)),
145
+ reject: (e) => done(() => reject(e)),
146
+ };
147
+ });
148
+ }
149
+
150
+ private listen(): Promise<void> {
151
+ if (this.server) return Promise.resolve();
152
+ return new Promise<void>((resolve, reject) => {
153
+ const server = createServer((req, res) => this.handleCallback(req.url ?? "", res));
154
+ server.once("error", (err) => reject(new Error(`OAuth loopback on port ${this.port}: ${err.message}`)));
155
+ server.listen(this.port, "127.0.0.1", () => {
156
+ this.server = server;
157
+ // Capture the actually-bound port (matters when port 0 = ephemeral) and pin
158
+ // it, so future runs reuse the same redirect URI the client registered with.
159
+ const addr = server.address();
160
+ if (addr && typeof addr === "object") this.port = addr.port;
161
+ this.store.port = this.port;
162
+ this.persist();
163
+ resolve();
164
+ });
165
+ });
166
+ }
167
+
168
+ private handleCallback(rawUrl: string, res: import("node:http").ServerResponse): void {
169
+ const url = new URL(rawUrl, this.redirectUrl);
170
+ if (url.pathname !== CALLBACK_PATH) {
171
+ res.writeHead(404).end();
172
+ return;
173
+ }
174
+ const code = url.searchParams.get("code");
175
+ const err = url.searchParams.get("error");
176
+ const state = url.searchParams.get("state");
177
+ res.writeHead(200, { "content-type": "text/html" });
178
+ res.end(
179
+ `<!doctype html><body style="font-family:system-ui;padding:2rem">` +
180
+ `<h2>${code ? "Authorized — you can close this tab." : "Authorization failed."}</h2>` +
181
+ `</body>`,
182
+ );
183
+ if (err) return this.redirect?.reject(new Error(`authorization error: ${err}`));
184
+ if (state && this.store.state && state !== this.store.state) {
185
+ return this.redirect?.reject(new Error("OAuth state mismatch (possible CSRF)"));
186
+ }
187
+ if (code) this.redirect?.resolve(code);
188
+ else this.redirect?.reject(new Error("authorization callback missing code"));
189
+ }
190
+
191
+ private closeServer(): void {
192
+ this.server?.close();
193
+ this.server = undefined;
194
+ }
195
+
196
+ private persist(): void {
197
+ mkdirSync(authDir(), { recursive: true });
198
+ tryChmod(authDir(), 0o700);
199
+ writeFileSync(this.path, JSON.stringify(this.store, null, 2) + "\n", { encoding: "utf8", mode: 0o600 });
200
+ tryChmod(this.path, 0o600);
201
+ }
202
+ }
203
+
204
+ function readStore(path: string): AuthStore {
205
+ if (!existsSync(path)) return {};
206
+ try {
207
+ return JSON.parse(readFileSync(path, "utf8")) as AuthStore;
208
+ } catch {
209
+ return {};
210
+ }
211
+ }
212
+
213
+ function tryChmod(path: string, mode: number): void {
214
+ try {
215
+ chmodSync(path, mode);
216
+ } catch {
217
+ /* non-POSIX filesystem — best effort */
218
+ }
219
+ }
220
+
221
+ // Open a URL in the user's default browser. Best-effort and non-blocking; if it
222
+ // fails the URL was already surfaced via the AuthorizePrompt callback.
223
+ function openBrowser(url: string): void {
224
+ // Headless / CI / tests: skip the launch; the URL is surfaced via AuthorizePrompt.
225
+ if (process.env.PRIVATEER_NO_BROWSER) return;
226
+ const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
227
+ try {
228
+ const child = spawn(cmd, [url], { stdio: "ignore", detached: true, shell: process.platform === "win32" });
229
+ child.on("error", () => {});
230
+ child.unref();
231
+ } catch {
232
+ /* headless / no browser — user uses the printed URL */
233
+ }
234
+ }
235
+
236
+ // Wipe stored OAuth state for one server (used by `/mcp logout`).
237
+ export function clearStoredAuth(serverUrl: string): void {
238
+ const path = storePath(serverUrl);
239
+ if (existsSync(path)) rmSync(path, { force: true });
240
+ }
241
+
242
+ // Whether we hold an OAuth access token for this server (for `/mcp` status).
243
+ export function hasStoredAuth(serverUrl: string): boolean {
244
+ return Boolean(readStore(storePath(serverUrl)).tokens?.access_token);
245
+ }
@@ -0,0 +1,146 @@
1
+ import { mkdirSync, writeFileSync, readFileSync, existsSync, readdirSync, rmSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { globalDir } from "../config/load.ts";
4
+ import { parseFrontmatter } from "../commands/custom.ts";
5
+ import { projectKey } from "./store.ts";
6
+
7
+ // Agent-authored "auto-memory": durable facts the agent records across runs, modeled on
8
+ // Claude Code's memory files. Each memory is one markdown file with flat frontmatter
9
+ // (so it round-trips through parseFrontmatter, which only reads `key: value` lines) plus
10
+ // a body. A per-scope MEMORY.md index lists them and is recalled into the system prompt.
11
+ //
12
+ // Two scopes: "project" memories live under the per-project dir (keyed by cwd) and only
13
+ // recall in that project; "global" memories live under the global dir and recall
14
+ // everywhere. Project memories win on a name clash.
15
+
16
+ export type MemoryType = "user" | "feedback" | "project" | "reference";
17
+ export type MemoryScope = "project" | "global";
18
+
19
+ export interface MemoryRecord {
20
+ name: string;
21
+ description: string;
22
+ type: MemoryType;
23
+ scope: MemoryScope;
24
+ body: string;
25
+ path: string;
26
+ }
27
+
28
+ const INDEX_FILE = "MEMORY.md";
29
+ const VALID_TYPES: MemoryType[] = ["user", "feedback", "project", "reference"];
30
+
31
+ function memoryDir(scope: MemoryScope, cwd: string): string {
32
+ return scope === "global"
33
+ ? join(globalDir(), "memory")
34
+ : join(globalDir(), "projects", projectKey(cwd), "memory");
35
+ }
36
+
37
+ // Constrain a proposed name to a safe, kebab-ish file stem (no path traversal, no spaces).
38
+ export function sanitizeName(name: string): string {
39
+ return name
40
+ .toLowerCase()
41
+ .replace(/[^a-z0-9._-]+/g, "-")
42
+ .replace(/^[-.]+|[-.]+$/g, "")
43
+ .slice(0, 80);
44
+ }
45
+
46
+ function coerceType(raw: string | undefined): MemoryType {
47
+ return VALID_TYPES.includes(raw as MemoryType) ? (raw as MemoryType) : "project";
48
+ }
49
+
50
+ function readDir(scope: MemoryScope, cwd: string): MemoryRecord[] {
51
+ const dir = memoryDir(scope, cwd);
52
+ if (!existsSync(dir)) return [];
53
+ const out: MemoryRecord[] = [];
54
+ for (const file of readdirSync(dir)) {
55
+ if (!file.endsWith(".md") || file === INDEX_FILE) continue;
56
+ const path = join(dir, file);
57
+ const { meta, body } = parseFrontmatter(readFileSync(path, "utf8"));
58
+ const name = meta.name || file.replace(/\.md$/, "");
59
+ out.push({
60
+ name,
61
+ description: meta.description ?? "",
62
+ type: coerceType(meta.type),
63
+ scope,
64
+ body: body.trim(),
65
+ path,
66
+ });
67
+ }
68
+ return out;
69
+ }
70
+
71
+ // Regenerate a scope's MEMORY.md from the memory files it contains. Regenerating (vs.
72
+ // editing in place) keeps the index free of stale or duplicate lines.
73
+ function rebuildIndex(scope: MemoryScope, cwd: string): void {
74
+ const dir = memoryDir(scope, cwd);
75
+ const records = readDir(scope, cwd).sort((a, b) => a.name.localeCompare(b.name));
76
+ const indexPath = join(dir, INDEX_FILE);
77
+ if (records.length === 0) {
78
+ if (existsSync(indexPath)) rmSync(indexPath, { force: true });
79
+ return;
80
+ }
81
+ const lines = [
82
+ "# Memory Index",
83
+ "",
84
+ ...records.map((r) => `- [${r.name}](${r.name}.md) — ${r.description}`),
85
+ "",
86
+ ];
87
+ mkdirSync(dir, { recursive: true });
88
+ writeFileSync(indexPath, lines.join("\n"), "utf8");
89
+ }
90
+
91
+ // All memories visible from this cwd: project entries override global on a name clash.
92
+ export function listMemories(cwd: string): MemoryRecord[] {
93
+ const byName = new Map<string, MemoryRecord>();
94
+ for (const r of readDir("global", cwd)) byName.set(r.name, r);
95
+ for (const r of readDir("project", cwd)) byName.set(r.name, r);
96
+ return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
97
+ }
98
+
99
+ export function readMemory(cwd: string, name: string): MemoryRecord | null {
100
+ const key = sanitizeName(name);
101
+ return listMemories(cwd).find((r) => r.name === key) ?? null;
102
+ }
103
+
104
+ export function saveMemory(
105
+ cwd: string,
106
+ input: { name: string; description: string; type?: MemoryType; scope?: MemoryScope; body: string },
107
+ ): MemoryRecord {
108
+ const scope: MemoryScope = input.scope === "global" ? "global" : "project";
109
+ const name = sanitizeName(input.name);
110
+ if (!name) throw new Error("memory name is empty after sanitizing");
111
+ const type = coerceType(input.type);
112
+ const dir = memoryDir(scope, cwd);
113
+ mkdirSync(dir, { recursive: true });
114
+ const path = join(dir, `${name}.md`);
115
+ const frontmatter = [
116
+ "---",
117
+ `name: ${name}`,
118
+ `description: ${input.description.replace(/\n/g, " ").trim()}`,
119
+ `type: ${type}`,
120
+ `scope: ${scope}`,
121
+ "---",
122
+ ].join("\n");
123
+ writeFileSync(path, `${frontmatter}\n${input.body.trim()}\n`, "utf8");
124
+ rebuildIndex(scope, cwd);
125
+ return { name, description: input.description, type, scope, body: input.body.trim(), path };
126
+ }
127
+
128
+ export function deleteMemory(cwd: string, name: string): MemoryRecord | null {
129
+ const existing = readMemory(cwd, name);
130
+ if (!existing) return null;
131
+ rmSync(existing.path, { force: true });
132
+ rebuildIndex(existing.scope, cwd);
133
+ return existing;
134
+ }
135
+
136
+ // The memory index(es) to recall into the system prompt, or null when there are none.
137
+ export function loadMemoryContext(cwd: string): string | null {
138
+ const sections: string[] = [];
139
+ for (const scope of ["project", "global"] as const) {
140
+ const indexPath = join(memoryDir(scope, cwd), INDEX_FILE);
141
+ if (!existsSync(indexPath)) continue;
142
+ const body = readFileSync(indexPath, "utf8").trim();
143
+ if (body) sections.push(`(${scope})\n${body}`);
144
+ }
145
+ return sections.length ? sections.join("\n\n") : null;
146
+ }