@mewbleh/purrx 1.0.8

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.
@@ -0,0 +1,223 @@
1
+ import { spawn } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import { configFilePath } from "../config.js";
4
+
5
+ // Minimal MCP (Model Context Protocol) client over stdio using JSON-RPC 2.0
6
+ // with newline-delimited framing. Connects to configured servers, lists their
7
+ // tools, and proxies tools/call requests.
8
+
9
+ const PROTOCOL_VERSION = "2024-11-05";
10
+
11
+ class McpServer {
12
+ constructor(name, config) {
13
+ this.name = name;
14
+ this.config = config;
15
+ this.proc = null;
16
+ this.nextId = 1;
17
+ this.pending = new Map();
18
+ this.buffer = "";
19
+ this.tools = [];
20
+ this.ready = false;
21
+ }
22
+
23
+ async start() {
24
+ const { command, args = [], env = {} } = this.config;
25
+ // On Windows, launchers like `npx` resolve to `.cmd` shims that require a
26
+ // shell. MCP configs come from the user's own trusted config file, so the
27
+ // shell concatenation risk is acceptable here.
28
+ this.proc = spawn(command, args, {
29
+ env: { ...process.env, ...env },
30
+ stdio: ["pipe", "pipe", "pipe"],
31
+ shell: process.platform === "win32",
32
+ });
33
+
34
+ this.proc.stdout.setEncoding("utf8");
35
+ this.proc.stdout.on("data", (chunk) => this._onData(chunk));
36
+ this.proc.stderr.setEncoding("utf8");
37
+ this.proc.stderr.on("data", (data) => {
38
+ // MCP servers commonly log to stderr; ignore unless debugging.
39
+ if (process.env.PURRX_DEBUG) {
40
+ process.stderr.write(`[mcp:${this.name}] ${data}`);
41
+ }
42
+ });
43
+ this.proc.on("error", (err) => {
44
+ for (const { reject } of this.pending.values()) reject(err);
45
+ this.pending.clear();
46
+ });
47
+ this.proc.on("exit", () => {
48
+ this.ready = false;
49
+ });
50
+
51
+ // Handshake.
52
+ await this._request("initialize", {
53
+ protocolVersion: PROTOCOL_VERSION,
54
+ capabilities: { tools: {} },
55
+ clientInfo: { name: "purrx", version: "1.0.0" },
56
+ });
57
+ this._notify("notifications/initialized", {});
58
+ this.ready = true;
59
+
60
+ // Discover tools.
61
+ const result = await this._request("tools/list", {});
62
+ this.tools = result?.tools || [];
63
+ return this.tools;
64
+ }
65
+
66
+ _onData(chunk) {
67
+ this.buffer += chunk;
68
+ let idx;
69
+ while ((idx = this.buffer.indexOf("\n")) !== -1) {
70
+ const line = this.buffer.slice(0, idx).trim();
71
+ this.buffer = this.buffer.slice(idx + 1);
72
+ if (!line) continue;
73
+ let msg;
74
+ try {
75
+ msg = JSON.parse(line);
76
+ } catch {
77
+ continue;
78
+ }
79
+ if (msg.id !== undefined && this.pending.has(msg.id)) {
80
+ const { resolve, reject } = this.pending.get(msg.id);
81
+ this.pending.delete(msg.id);
82
+ if (msg.error) {
83
+ reject(new Error(msg.error.message || "MCP error"));
84
+ } else {
85
+ resolve(msg.result);
86
+ }
87
+ }
88
+ // Notifications from server are ignored.
89
+ }
90
+ }
91
+
92
+ _send(obj) {
93
+ if (!this.proc || !this.proc.stdin.writable) {
94
+ throw new Error(`MCP server ${this.name} is not running`);
95
+ }
96
+ this.proc.stdin.write(JSON.stringify(obj) + "\n");
97
+ }
98
+
99
+ _request(method, params) {
100
+ const id = this.nextId++;
101
+ return new Promise((resolve, reject) => {
102
+ this.pending.set(id, { resolve, reject });
103
+ this._send({ jsonrpc: "2.0", id, method, params });
104
+ // Timeout so a hung server doesn't block forever.
105
+ setTimeout(() => {
106
+ if (this.pending.has(id)) {
107
+ this.pending.delete(id);
108
+ reject(new Error(`MCP ${this.name} ${method} timed out`));
109
+ }
110
+ }, 30_000);
111
+ });
112
+ }
113
+
114
+ _notify(method, params) {
115
+ this._send({ jsonrpc: "2.0", method, params });
116
+ }
117
+
118
+ async callTool(toolName, args) {
119
+ const result = await this._request("tools/call", {
120
+ name: toolName,
121
+ arguments: args || {},
122
+ });
123
+ // MCP returns { content: [{type:'text', text:...}], isError? }
124
+ if (result?.content) {
125
+ const text = result.content
126
+ .map((c) => (c.type === "text" ? c.text : JSON.stringify(c)))
127
+ .join("\n");
128
+ return result.isError ? `Error: ${text}` : text;
129
+ }
130
+ return JSON.stringify(result);
131
+ }
132
+
133
+ stop() {
134
+ try {
135
+ this.proc?.kill();
136
+ } catch {
137
+ // ignore
138
+ }
139
+ }
140
+ }
141
+
142
+ // Reads MCP server config from <home>/config.json:
143
+ // { "mcpServers": { "name": { "command": "...", "args": [...], "env": {} } } }
144
+ export function readMcpConfig() {
145
+ try {
146
+ const raw = fs.readFileSync(configFilePath(), "utf8");
147
+ const cfg = JSON.parse(raw);
148
+ return cfg.mcpServers || {};
149
+ } catch {
150
+ return {};
151
+ }
152
+ }
153
+
154
+ // Manages all configured MCP servers and exposes their tools as purrx tool
155
+ // definitions (namespaced as "mcp__<server>__<tool>").
156
+ export class McpManager {
157
+ constructor() {
158
+ this.servers = new Map();
159
+ this.toolRoutes = new Map(); // namespacedName -> { server, toolName }
160
+ }
161
+
162
+ /**
163
+ * @param {(msg: string) => void} [onLog]
164
+ * @returns {Promise<import("../types.js").ToolDefinition[]>}
165
+ */
166
+ async connectAll(onLog = (/** @type {string} */ _msg) => {}) {
167
+ const config = readMcpConfig();
168
+ const names = Object.keys(config);
169
+ if (!names.length) return [];
170
+
171
+ const defs = [];
172
+ for (const name of names) {
173
+ if (config[name].disabled) continue;
174
+ const server = new McpServer(name, config[name]);
175
+ try {
176
+ const tools = await server.start();
177
+ this.servers.set(name, server);
178
+ for (const t of tools) {
179
+ const nsName = `mcp__${name}__${t.name}`;
180
+ this.toolRoutes.set(nsName, { server, toolName: t.name });
181
+ defs.push({
182
+ type: "function",
183
+ name: nsName,
184
+ description: `[MCP:${name}] ${t.description || t.name}`,
185
+ parameters: t.inputSchema || {
186
+ type: "object",
187
+ properties: {},
188
+ },
189
+ });
190
+ }
191
+ onLog(`connected MCP server "${name}" (${tools.length} tools)`);
192
+ } catch (err) {
193
+ onLog(`failed to connect MCP server "${name}": ${/** @type {Error} */ (err).message}`);
194
+ server.stop();
195
+ }
196
+ }
197
+ return defs;
198
+ }
199
+
200
+ isMcpTool(name) {
201
+ return this.toolRoutes.has(name);
202
+ }
203
+
204
+ async callTool(name, args) {
205
+ const route = this.toolRoutes.get(name);
206
+ if (!route) return `Error: unknown MCP tool "${name}"`;
207
+ try {
208
+ return await route.server.callTool(route.toolName, args);
209
+ } catch (err) {
210
+ return `Error: ${err.message}`;
211
+ }
212
+ }
213
+
214
+ stopAll() {
215
+ for (const s of this.servers.values()) s.stop();
216
+ this.servers.clear();
217
+ this.toolRoutes.clear();
218
+ }
219
+
220
+ serverCount() {
221
+ return this.servers.size;
222
+ }
223
+ }
@@ -0,0 +1,62 @@
1
+ import { toolDefinitions as builtinTools, executeTool, READ_ONLY_TOOLS } from "./builtin.js";
2
+ import { McpManager } from "./mcp.js";
3
+
4
+ // The registry unifies three kinds of tools:
5
+ // 1. purrx built-in function tools (file ops, search, shell, fetch)
6
+ // 2. OpenAI/ChatGPT server-side tools (e.g. web_search) - executed by the API
7
+ // 3. MCP tools from configured servers - proxied locally
8
+ //
9
+ // It produces the combined `tools` array sent to the API and routes
10
+ // tool calls to the right executor.
11
+
12
+ // Server-side tools the model runs on OpenAI's side. We just declare them;
13
+ // the API handles execution and streams results back as output items.
14
+ export const SERVER_SIDE_TOOLS = {
15
+ web_search: { type: "web_search" },
16
+ };
17
+
18
+ export class ToolRegistry {
19
+ constructor() {
20
+ this.mcp = new McpManager();
21
+ this.mcpDefs = [];
22
+ this.enabledServerSide = new Set();
23
+ }
24
+
25
+ // Connect MCP servers (if any configured) and collect their tool defs.
26
+ async init({ onLog = () => {}, webSearch = true } = {}) {
27
+ this.mcpDefs = await this.mcp.connectAll(onLog);
28
+ if (webSearch) this.enabledServerSide.add("web_search");
29
+ }
30
+
31
+ // The full tool list to send to the API.
32
+ definitions() {
33
+ const serverSide = [...this.enabledServerSide]
34
+ .map((k) => SERVER_SIDE_TOOLS[k])
35
+ .filter(Boolean);
36
+ return [...builtinTools, ...this.mcpDefs, ...serverSide];
37
+ }
38
+
39
+ isReadOnly(name) {
40
+ return READ_ONLY_TOOLS.has(name);
41
+ }
42
+
43
+ isMcp(name) {
44
+ return this.mcp.isMcpTool(name);
45
+ }
46
+
47
+ // Execute a locally-handled tool call (built-in or MCP).
48
+ async execute(name, args, cwd) {
49
+ if (this.mcp.isMcpTool(name)) {
50
+ return this.mcp.callTool(name, args);
51
+ }
52
+ return executeTool(name, args, cwd);
53
+ }
54
+
55
+ shutdown() {
56
+ this.mcp.stopAll();
57
+ }
58
+
59
+ mcpServerCount() {
60
+ return this.mcp.serverCount();
61
+ }
62
+ }
package/src/types.js ADDED
@@ -0,0 +1,68 @@
1
+ // Shared JSDoc type definitions for purrx. This file has no runtime exports;
2
+ // it exists so other modules can reference types via `import("./types.js")`.
3
+
4
+ /**
5
+ * How requests authenticate against the backend.
6
+ * @typedef {Object} AuthInfo
7
+ * @property {"apikey"|"chatgpt"} mode
8
+ * @property {string} [apiKey] Present when mode is "apikey".
9
+ * @property {string} [accessToken] Present when mode is "chatgpt".
10
+ * @property {string|null} [accountId] ChatGPT account id, if known.
11
+ */
12
+
13
+ /**
14
+ * Token bundle stored inside auth.json.
15
+ * @typedef {Object} StoredTokens
16
+ * @property {string} id_token
17
+ * @property {string} access_token
18
+ * @property {string} refresh_token
19
+ * @property {string|null} account_id
20
+ */
21
+
22
+ /**
23
+ * The on-disk auth.json shape (Codex-compatible).
24
+ * @typedef {Object} AuthDotJson
25
+ * @property {string|null} [OPENAI_API_KEY]
26
+ * @property {StoredTokens|null} [tokens]
27
+ * @property {string} [last_refresh]
28
+ */
29
+
30
+ /**
31
+ * A single conversation item in the Responses API input format.
32
+ * @typedef {Object} HistoryItem
33
+ * @property {string} type
34
+ * @property {string} [role]
35
+ * @property {Array<Object>} [content]
36
+ * @property {string} [name]
37
+ * @property {string} [arguments]
38
+ * @property {string} [call_id]
39
+ * @property {string} [output]
40
+ */
41
+
42
+ /**
43
+ * A persisted conversation session.
44
+ * @typedef {Object} Session
45
+ * @property {string} id
46
+ * @property {string} created_at
47
+ * @property {string} updated_at
48
+ * @property {string} cwd
49
+ * @property {string|null} model
50
+ * @property {string} [policy]
51
+ * @property {HistoryItem[]} history
52
+ */
53
+
54
+ /**
55
+ * An OpenAI Responses API tool definition (function or server-side tool).
56
+ * @typedef {Object} ToolDefinition
57
+ * @property {string} type
58
+ * @property {string} [name]
59
+ * @property {string} [description]
60
+ * @property {Object} [parameters]
61
+ */
62
+
63
+ /**
64
+ * Approval policy names.
65
+ * @typedef {"suggest"|"auto-edit"|"full-auto"} ApprovalPolicy
66
+ */
67
+
68
+ export {};
@@ -0,0 +1,47 @@
1
+ import { marked } from "marked";
2
+ import { markedTerminal } from "marked-terminal";
3
+ import chalk from "chalk";
4
+
5
+ // Configure marked to render Markdown for the terminal (bold, lists, tables,
6
+ // and syntax-highlighted code blocks via cli-highlight, which marked-terminal
7
+ // uses internally).
8
+ let configured = false;
9
+ function ensureConfigured() {
10
+ if (configured) return;
11
+ marked.use(
12
+ markedTerminal({
13
+ // Tasteful, modern palette. marked-terminal accepts chalk functions.
14
+ code: chalk.cyan,
15
+ blockquote: chalk.gray.italic,
16
+ heading: chalk.bold.white,
17
+ firstHeading: chalk.bold.white,
18
+ strong: chalk.bold,
19
+ em: chalk.italic,
20
+ link: chalk.blue.underline,
21
+ href: chalk.blue.underline,
22
+ listitem: (text) => text,
23
+ reflowText: false,
24
+ width: Math.min(process.stdout.columns || 80, 100),
25
+ })
26
+ );
27
+ configured = true;
28
+ }
29
+
30
+ /**
31
+ * Render a Markdown string to styled terminal text.
32
+ * @param {string} md
33
+ * @returns {string}
34
+ */
35
+ export function renderMarkdown(md) {
36
+ // chalk.level === 0 means colors are disabled (NO_COLOR / non-TTY without
37
+ // FORCE_COLOR). In that case marked-terminal still adds useful structure
38
+ // (indentation, bullets), so we render anyway but it stays plain.
39
+ ensureConfigured();
40
+ try {
41
+ // marked may return a promise if async extensions are used; ours are sync.
42
+ const out = /** @type {string} */ (marked.parse(md));
43
+ return typeof out === "string" ? out.replace(/\n$/, "") : md;
44
+ } catch {
45
+ return md;
46
+ }
47
+ }
@@ -0,0 +1,114 @@
1
+ import chalk from "chalk";
2
+ import boxen from "boxen";
3
+ import ora from "ora";
4
+
5
+ // Terminal styling built on chalk (color), boxen (frames), and ora (spinner).
6
+ // chalk auto-detects color support and respects NO_COLOR, so we don't need a
7
+ // manual on/off switch here.
8
+
9
+ /**
10
+ * Color helpers. Thin wrappers over chalk so the rest of the app has a stable
11
+ * surface and we can restyle in one place.
12
+ */
13
+ export const c = {
14
+ reset: "",
15
+ bold: (s) => chalk.bold(s),
16
+ dim: (s) => chalk.dim(s),
17
+ italic: (s) => chalk.italic(s),
18
+ red: (s) => chalk.red(s),
19
+ green: (s) => chalk.green(s),
20
+ yellow: (s) => chalk.yellow(s),
21
+ blue: (s) => chalk.blue(s),
22
+ magenta: (s) => chalk.magenta(s),
23
+ cyan: (s) => chalk.cyan(s),
24
+ gray: (s) => chalk.gray(s),
25
+ // Brand accent used for the wordmark and prompt.
26
+ accent: (s) => chalk.hex("#a78bfa")(s),
27
+ };
28
+
29
+ export function termWidth() {
30
+ return Math.min(process.stdout.columns || 80, 100);
31
+ }
32
+
33
+ /**
34
+ * Horizontal rule, optionally with a left-aligned label.
35
+ * @param {string} [label]
36
+ * @returns {string}
37
+ */
38
+ export function rule(label = "") {
39
+ const width = termWidth();
40
+ if (!label) return chalk.gray("─".repeat(width));
41
+ // strip ANSI for length math
42
+ const plain = label.replace(/\x1b\[[0-9;]*m/g, "");
43
+ const prefix = "── ";
44
+ const rest = Math.max(0, width - prefix.length - plain.length - 1);
45
+ return chalk.gray(prefix) + label + " " + chalk.gray("─".repeat(rest));
46
+ }
47
+
48
+ /**
49
+ * Render content inside a rounded box.
50
+ * @param {string} content
51
+ * @param {{ title?: string, borderColor?: string, dimBorder?: boolean }} [opts]
52
+ * @returns {string}
53
+ */
54
+ export function box(content, opts = {}) {
55
+ return boxen(content, {
56
+ padding: { top: 0, bottom: 0, left: 1, right: 1 },
57
+ borderStyle: "round",
58
+ borderColor: opts.borderColor || "gray",
59
+ dimBorder: opts.dimBorder ?? false,
60
+ title: opts.title,
61
+ width: termWidth(),
62
+ });
63
+ }
64
+
65
+ // Cat-flavored status phrases shown while the agent works. The UX is feline,
66
+ // the visuals stay clean (no ASCII cat).
67
+ const WORKING_PHRASES = [
68
+ "prowling",
69
+ "sniffing around",
70
+ "pawing through the code",
71
+ "on the hunt",
72
+ "stalking the bug",
73
+ "thinking",
74
+ ];
75
+
76
+ export function randomWorkingPhrase() {
77
+ return WORKING_PHRASES[Math.floor(Math.random() * WORKING_PHRASES.length)];
78
+ }
79
+
80
+ /**
81
+ * Create an ora-backed spinner. Returns a small wrapper with the same surface
82
+ * the rest of the app already uses (start/setText/stop).
83
+ * @param {string} [text]
84
+ */
85
+ export function createSpinner(text = "thinking") {
86
+ const spinner = ora({
87
+ text: chalk.dim(text),
88
+ spinner: "dots",
89
+ color: "magenta",
90
+ // ora handles non-TTY by silently no-op rendering.
91
+ });
92
+ return {
93
+ start() {
94
+ spinner.start();
95
+ },
96
+ setText(t) {
97
+ spinner.text = chalk.dim(t);
98
+ },
99
+ stop() {
100
+ spinner.stop();
101
+ },
102
+ };
103
+ }
104
+
105
+ // Symbols used across the UI. Clean and modern; the cat personality lives in
106
+ // the wording, not in ASCII faces.
107
+ export const sym = {
108
+ ok: chalk.green("✓"),
109
+ fail: chalk.red("✗"),
110
+ bullet: chalk.gray("·"),
111
+ arrow: chalk.hex("#a78bfa")("❯"),
112
+ tool: chalk.blue("⚙"),
113
+ warn: chalk.yellow("!"),
114
+ };