@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.
package/bin/purrx.js ADDED
@@ -0,0 +1,352 @@
1
+ #!/usr/bin/env node
2
+ import readline from "node:readline";
3
+ import { loginWithChatGPT, loginWithApiKey } from "../src/auth/login.js";
4
+ import {
5
+ clearAuth,
6
+ ensureFreshAuth,
7
+ resolveAuthMode,
8
+ readAuth,
9
+ } from "../src/auth/tokens.js";
10
+ import { startTui } from "../src/ui/tui.js";
11
+ import { runTurn } from "../src/core/agent.js";
12
+ import { createApprovalManager } from "../src/core/approval.js";
13
+ import { ToolRegistry } from "../src/tools/registry.js";
14
+ import { authFilePath } from "../src/config.js";
15
+ import { resolveModel, listModels } from "../src/api/models.js";
16
+ import { detectPlatform } from "../src/platform.js";
17
+ import {
18
+ listSessions,
19
+ loadSession,
20
+ latestSession,
21
+ deleteSession,
22
+ createSession,
23
+ saveSession,
24
+ } from "../src/core/session.js";
25
+
26
+ function promptLine(question) {
27
+ return new Promise((resolve) => {
28
+ const rl = readline.createInterface({
29
+ input: process.stdin,
30
+ output: process.stdout,
31
+ });
32
+ rl.question(question, (answer) => {
33
+ rl.close();
34
+ resolve(answer.trim());
35
+ });
36
+ });
37
+ }
38
+
39
+ // Tiny flag parser: returns { _: positional[], flags: {name: value|true} }.
40
+ function parseArgs(argv) {
41
+ const out = { _: [], flags: {} };
42
+ for (let i = 0; i < argv.length; i++) {
43
+ const a = argv[i];
44
+ if (a.startsWith("--")) {
45
+ const name = a.slice(2);
46
+ const next = argv[i + 1];
47
+ if (next && !next.startsWith("--")) {
48
+ out.flags[name] = next;
49
+ i++;
50
+ } else {
51
+ out.flags[name] = true;
52
+ }
53
+ } else {
54
+ out._.push(a);
55
+ }
56
+ }
57
+ return out;
58
+ }
59
+
60
+ async function cmdLogin(args) {
61
+ const { flags } = parseArgs(args);
62
+ if (flags["api-key"]) {
63
+ let key = typeof flags["api-key"] === "string" ? flags["api-key"] : "";
64
+ if (!key) key = await promptLine("Enter your OpenAI API key: ");
65
+ if (!key) {
66
+ console.error("No API key provided.");
67
+ process.exit(1);
68
+ }
69
+ const file = loginWithApiKey(key);
70
+ console.log(`Saved API key to ${file}`);
71
+ return;
72
+ }
73
+
74
+ try {
75
+ const { file, plan } = await loginWithChatGPT();
76
+ console.log(`\n✓ Signed in with ChatGPT (plan: ${plan}).`);
77
+ console.log(`Credentials saved to ${file}`);
78
+ } catch (err) {
79
+ console.error(`\nLogin failed: ${err.message}`);
80
+ process.exit(1);
81
+ }
82
+ }
83
+
84
+ function cmdLogout() {
85
+ const ok = clearAuth();
86
+ console.log(ok ? "Logged out." : "No credentials found.");
87
+ }
88
+
89
+ function cmdStatus() {
90
+ const auth = readAuth();
91
+ console.log(`platform: ${detectPlatform()}`);
92
+ if (!auth) {
93
+ console.log("Not signed in. Run `purrx login`.");
94
+ return;
95
+ }
96
+ const info = resolveAuthMode(auth);
97
+ if (!info) {
98
+ console.log("Credentials present but unusable. Try `purrx login` again.");
99
+ return;
100
+ }
101
+ console.log(
102
+ `signed in: ${info.mode === "chatgpt" ? "ChatGPT account" : "API key"}`
103
+ );
104
+ console.log(`auth file: ${authFilePath()}`);
105
+ }
106
+
107
+ async function ensureAuthOrExit() {
108
+ const auth = await ensureFreshAuth();
109
+ const info = resolveAuthMode(auth);
110
+ if (!info) {
111
+ console.error("You are not signed in. Run `purrx login` first.");
112
+ process.exit(1);
113
+ }
114
+ return info;
115
+ }
116
+
117
+ async function cmdChat(args) {
118
+ const { flags } = parseArgs(args);
119
+ const authInfo = await ensureAuthOrExit();
120
+
121
+ // Resume logic.
122
+ let session = null;
123
+ if (flags.continue) {
124
+ session = latestSession();
125
+ if (session) console.log(`resuming latest session ${session.id}`);
126
+ } else if (flags.resume) {
127
+ session = loadSession(flags.resume);
128
+ if (!session) {
129
+ console.error(`session not found: ${flags.resume}`);
130
+ process.exit(1);
131
+ }
132
+ console.log(`resuming session ${session.id}`);
133
+ }
134
+
135
+ const model = await resolveModel(authInfo, flags.model);
136
+ if (!session) session = createSession({ cwd: process.cwd(), model });
137
+
138
+ await startTui(authInfo, {
139
+ session,
140
+ model,
141
+ policy: flags.approval || session.policy || "suggest",
142
+ });
143
+ }
144
+
145
+ async function cmdExec(args) {
146
+ const { _, flags } = parseArgs(args);
147
+ const message = _.join(" ").trim();
148
+ if (!message) {
149
+ console.error('Usage: purrx exec "your request" [--model M] [--approval P]');
150
+ process.exit(1);
151
+ }
152
+ const authInfo = await ensureAuthOrExit();
153
+ const model = await resolveModel(authInfo, flags.model);
154
+
155
+ // In non-interactive exec, default to full-auto unless told otherwise.
156
+ const policy = flags.approval || "full-auto";
157
+ const approval = createApprovalManager(policy);
158
+
159
+ const registry = new ToolRegistry();
160
+ await registry.init({
161
+ onLog: (msg) => console.error(` · ${msg}`),
162
+ webSearch: flags["no-web-search"] ? false : true,
163
+ });
164
+
165
+ const session = createSession({ cwd: process.cwd(), model });
166
+ try {
167
+ await runTurn({
168
+ authInfo,
169
+ history: session.history,
170
+ userMessage: message,
171
+ cwd: process.cwd(),
172
+ model,
173
+ registry,
174
+ approval,
175
+ onChange: () => saveSession(session),
176
+ });
177
+ } finally {
178
+ registry.shutdown();
179
+ }
180
+ console.log(`\nsession saved: ${session.id}`);
181
+ }
182
+
183
+ async function cmdModels(args) {
184
+ const { flags } = parseArgs(args);
185
+ const authInfo = await ensureAuthOrExit();
186
+ const models = await listModels(authInfo);
187
+ const active = await resolveModel(authInfo, flags.model);
188
+ console.log("available models:");
189
+ for (const m of models) {
190
+ console.log(` ${m}${m === active ? " (default)" : ""}`);
191
+ }
192
+ }
193
+
194
+ function cmdSessions(args) {
195
+ const { _, flags } = parseArgs(args);
196
+ const sub = _[0];
197
+
198
+ if (sub === "rm" || flags.rm) {
199
+ const id = sub === "rm" ? _[1] : flags.rm;
200
+ if (!id) {
201
+ console.error("usage: purrx sessions rm <id>");
202
+ process.exit(1);
203
+ }
204
+ console.log(deleteSession(id) ? `deleted ${id}` : `not found: ${id}`);
205
+ return;
206
+ }
207
+
208
+ const sessions = listSessions();
209
+ if (!sessions.length) {
210
+ console.log("no saved sessions.");
211
+ return;
212
+ }
213
+ console.log("saved sessions (newest first):\n");
214
+ for (const s of sessions) {
215
+ console.log(` ${s.id}`);
216
+ console.log(` updated: ${s.updated_at} turns: ${s.turns}`);
217
+ if (s.preview) console.log(` "${s.preview}"`);
218
+ console.log();
219
+ }
220
+ console.log("resume with: purrx chat --resume <id>");
221
+ }
222
+
223
+ async function cmdMcp(args) {
224
+ const { _ } = parseArgs(args);
225
+ const sub = _[0];
226
+ const { readMcpConfig } = await import("../src/tools/mcp.js");
227
+ const { configFilePath } = await import("../src/config.js");
228
+
229
+ if (sub === "init") {
230
+ const fs = await import("node:fs");
231
+ const path = configFilePath();
232
+ let existing = {};
233
+ try {
234
+ existing = JSON.parse(fs.readFileSync(path, "utf8"));
235
+ } catch {
236
+ // none yet
237
+ }
238
+ if (!existing.mcpServers) existing.mcpServers = {};
239
+ if (Object.keys(existing.mcpServers).length === 0) {
240
+ existing.mcpServers.example = {
241
+ command: "npx",
242
+ args: ["-y", "@modelcontextprotocol/server-filesystem", "."],
243
+ disabled: true,
244
+ };
245
+ }
246
+ fs.mkdirSync(path.replace(/[^\\/]+$/, ""), { recursive: true });
247
+ fs.writeFileSync(path, JSON.stringify(existing, null, 2));
248
+ console.log(`wrote MCP config scaffold to ${path}`);
249
+ console.log("edit it to add servers, then set disabled: false.");
250
+ return;
251
+ }
252
+
253
+ // Default: list configured servers.
254
+ const config = readMcpConfig();
255
+ const names = Object.keys(config);
256
+ if (!names.length) {
257
+ console.log("no MCP servers configured.");
258
+ console.log(`add some in ${configFilePath()} (run: purrx mcp init)`);
259
+ return;
260
+ }
261
+ console.log("configured MCP servers:");
262
+ for (const name of names) {
263
+ const s = config[name];
264
+ const status = s.disabled ? " (disabled)" : "";
265
+ console.log(` ${name}${status}: ${s.command} ${(s.args || []).join(" ")}`);
266
+ }
267
+ }
268
+
269
+ function printHelp() {
270
+ console.log(`purrx lightweight AI coding agent
271
+
272
+ Usage:
273
+ purrx Start interactive chat (default)
274
+ purrx chat [opts] Start interactive chat
275
+ purrx exec "<request>" [opts] Run a single request non-interactively
276
+ purrx models List models your account can use
277
+ purrx mcp List configured MCP servers
278
+ purrx mcp init Write an MCP config scaffold
279
+ purrx sessions List saved sessions
280
+ purrx sessions rm <id> Delete a saved session
281
+ purrx login Sign in with your ChatGPT account (OAuth)
282
+ purrx login --api-key [KEY] Sign in with an OpenAI API key
283
+ purrx logout Remove stored credentials
284
+ purrx status Show platform and sign-in status
285
+ purrx help Show this help
286
+
287
+ Chat options:
288
+ --model <id> Model to use for this session
289
+ --approval <policy> suggest | auto-edit | full-auto (default: suggest)
290
+ --continue Resume the most recent session
291
+ --resume <id> Resume a specific session
292
+
293
+ Exec options:
294
+ --model <id> Model to use
295
+ --approval <policy> default: full-auto for non-interactive use
296
+
297
+ Environment:
298
+ OPENAI_API_KEY Use this API key directly (overrides stored auth)
299
+ PURRX_MODEL Default model (default: gpt-5-codex)
300
+ PURRX_HOME Data dir override (default: OS-appropriate location)
301
+ CODEX_HOME Shared with the official Codex CLI auth.json
302
+ NO_COLOR Disable colored output
303
+
304
+ Supported platforms: Windows, macOS, Linux, Android (Termux)
305
+ `);
306
+ }
307
+
308
+ async function main() {
309
+ const [, , command, ...rest] = process.argv;
310
+
311
+ switch (command) {
312
+ case undefined:
313
+ case "chat":
314
+ await cmdChat(rest);
315
+ break;
316
+ case "exec":
317
+ await cmdExec(rest);
318
+ break;
319
+ case "models":
320
+ await cmdModels(rest);
321
+ break;
322
+ case "mcp":
323
+ await cmdMcp(rest);
324
+ break;
325
+ case "sessions":
326
+ cmdSessions(rest);
327
+ break;
328
+ case "login":
329
+ await cmdLogin(rest);
330
+ break;
331
+ case "logout":
332
+ cmdLogout();
333
+ break;
334
+ case "status":
335
+ cmdStatus();
336
+ break;
337
+ case "help":
338
+ case "--help":
339
+ case "-h":
340
+ printHelp();
341
+ break;
342
+ default:
343
+ console.error(`Unknown command: ${command}\n`);
344
+ printHelp();
345
+ process.exit(1);
346
+ }
347
+ }
348
+
349
+ main().catch((err) => {
350
+ console.error(`Fatal: ${err.message}`);
351
+ process.exit(1);
352
+ });
package/package.json ADDED
@@ -0,0 +1,64 @@
1
+ {
2
+ "name": "@mewbleh/purrx",
3
+ "version": "1.0.8",
4
+ "description": "purrx, a lightweight AI coding agent for your terminal",
5
+ "type": "module",
6
+ "main": "src/index.js",
7
+ "bin": {
8
+ "purrx": "bin/purrx.js"
9
+ },
10
+ "files": [
11
+ "bin/",
12
+ "src/",
13
+ "README.md",
14
+ "LICENSE",
15
+ "AGENTS.md"
16
+ ],
17
+ "scripts": {
18
+ "start": "node bin/purrx.js",
19
+ "check": "node scripts/check.js",
20
+ "typecheck": "tsc --noEmit",
21
+ "test": "node scripts/check.js && tsc --noEmit"
22
+ },
23
+ "keywords": [
24
+ "ai",
25
+ "agent",
26
+ "coding-agent",
27
+ "cli",
28
+ "codex",
29
+ "chatgpt",
30
+ "openai",
31
+ "mcp",
32
+ "llm",
33
+ "tui"
34
+ ],
35
+ "engines": {
36
+ "node": ">=20"
37
+ },
38
+ "repository": {
39
+ "type": "git",
40
+ "url": "https://github.com/mewbleh/purrx.git"
41
+ },
42
+ "homepage": "https://github.com/mewbleh/purrx#readme",
43
+ "bugs": {
44
+ "url": "https://github.com/mewbleh/purrx/issues"
45
+ },
46
+ "author": "mewbleh",
47
+ "license": "Apache-2.0",
48
+ "publishConfig": {
49
+ "access": "public"
50
+ },
51
+ "devDependencies": {
52
+ "@types/node": "^26.0.1",
53
+ "typescript": "^6.0.3"
54
+ },
55
+ "dependencies": {
56
+ "@inquirer/prompts": "^8.5.2",
57
+ "boxen": "^8.0.1",
58
+ "chalk": "^5.6.2",
59
+ "cli-highlight": "^2.1.11",
60
+ "marked": "^15.0.12",
61
+ "marked-terminal": "^7.3.0",
62
+ "ora": "^9.4.1"
63
+ }
64
+ }
@@ -0,0 +1,121 @@
1
+ import { API_BASE, CHATGPT_BASE } from "../config.js";
2
+
3
+ // Builds the request URL and headers for the resolved auth mode.
4
+ /**
5
+ * @param {import("../types.js").AuthInfo} authInfo
6
+ * @returns {{ url: string, headers: Record<string, string> }}
7
+ */
8
+ function buildRequest(authInfo) {
9
+ if (authInfo.mode === "chatgpt") {
10
+ /** @type {Record<string, string>} */
11
+ const headers = {
12
+ "Content-Type": "application/json",
13
+ Authorization: `Bearer ${authInfo.accessToken}`,
14
+ "OpenAI-Beta": "responses=experimental",
15
+ originator: "codex_cli_rs",
16
+ };
17
+ if (authInfo.accountId) {
18
+ headers["chatgpt-account-id"] = authInfo.accountId;
19
+ }
20
+ return { url: `${CHATGPT_BASE}/responses`, headers };
21
+ }
22
+ // API-key mode.
23
+ return {
24
+ url: `${API_BASE}/responses`,
25
+ headers: {
26
+ "Content-Type": "application/json",
27
+ Authorization: `Bearer ${authInfo.apiKey}`,
28
+ },
29
+ };
30
+ }
31
+
32
+ // Streams a Responses API request. Calls handlers as SSE events arrive.
33
+ // onText(deltaString) -> assistant text deltas
34
+ // onEvent(parsedEvent) -> every parsed event (for tool call assembly)
35
+ // Returns the final "response.completed" payload.
36
+ /**
37
+ * @param {Object} req
38
+ * @param {import("../types.js").AuthInfo} req.authInfo
39
+ * @param {string} req.model
40
+ * @param {import("../types.js").HistoryItem[]} req.input
41
+ * @param {import("../types.js").ToolDefinition[]} [req.tools]
42
+ * @param {string} [req.instructions]
43
+ * @param {{ onText?: (delta: string) => void, onEvent?: (event: any) => void }} [handlers]
44
+ * @returns {Promise<any>}
45
+ */
46
+ export async function streamResponse({ authInfo, model, input, tools, instructions }, handlers = {}) {
47
+ const { url, headers } = buildRequest(authInfo);
48
+
49
+ /** @type {Record<string, any>} */
50
+ const payload = {
51
+ model,
52
+ input,
53
+ stream: true,
54
+ store: false,
55
+ };
56
+ if (instructions) payload.instructions = instructions;
57
+ if (tools && tools.length) {
58
+ payload.tools = tools;
59
+ payload.tool_choice = "auto";
60
+ }
61
+
62
+ const resp = await fetch(url, {
63
+ method: "POST",
64
+ headers,
65
+ body: JSON.stringify(payload),
66
+ });
67
+
68
+ if (!resp.ok || !resp.body) {
69
+ const text = await resp.text().catch(() => "");
70
+ throw new Error(`API request failed (${resp.status}): ${text}`);
71
+ }
72
+
73
+ const reader = resp.body.getReader();
74
+ const decoder = new TextDecoder();
75
+ let buffer = "";
76
+ let finalResponse = null;
77
+
78
+ while (true) {
79
+ const { value, done } = await reader.read();
80
+ if (done) break;
81
+ buffer += decoder.decode(value, { stream: true });
82
+
83
+ // SSE frames are separated by a blank line.
84
+ let idx;
85
+ while ((idx = buffer.indexOf("\n\n")) !== -1) {
86
+ const frame = buffer.slice(0, idx);
87
+ buffer = buffer.slice(idx + 2);
88
+
89
+ const dataLines = frame
90
+ .split("\n")
91
+ .filter((l) => l.startsWith("data:"))
92
+ .map((l) => l.slice(5).trim());
93
+ if (!dataLines.length) continue;
94
+
95
+ const data = dataLines.join("\n");
96
+ if (data === "[DONE]") continue;
97
+
98
+ let event;
99
+ try {
100
+ event = JSON.parse(data);
101
+ } catch {
102
+ continue;
103
+ }
104
+
105
+ if (handlers.onEvent) handlers.onEvent(event);
106
+
107
+ if (event.type === "response.output_text.delta" && handlers.onText) {
108
+ handlers.onText(event.delta || "");
109
+ }
110
+ if (event.type === "response.completed") {
111
+ finalResponse = event.response;
112
+ }
113
+ if (event.type === "error" || event.type === "response.failed") {
114
+ const msg = event.error?.message || event.response?.error?.message || "unknown error";
115
+ throw new Error(`API stream error: ${msg}`);
116
+ }
117
+ }
118
+ }
119
+
120
+ return finalResponse;
121
+ }
@@ -0,0 +1,57 @@
1
+ import { API_BASE, CHATGPT_BASE, DEFAULT_MODEL } from "../config.js";
2
+
3
+ // Fetches the list of model ids the current account can actually use.
4
+ // - API-key mode: GET https://api.openai.com/v1/models
5
+ // - ChatGPT mode: the codex backend does not expose a public models list the
6
+ // same way, so we probe a known-good set and fall back to the default.
7
+ export async function listModels(authInfo) {
8
+ if (authInfo.mode === "apikey") {
9
+ return listApiKeyModels(authInfo.apiKey);
10
+ }
11
+ return listChatGptModels();
12
+ }
13
+
14
+ async function listApiKeyModels(apiKey) {
15
+ try {
16
+ const resp = await fetch(`${API_BASE}/models`, {
17
+ headers: { Authorization: `Bearer ${apiKey}` },
18
+ });
19
+ if (!resp.ok) return fallbackModels();
20
+ const json = /** @type {any} */ (await resp.json());
21
+ const ids = (json.data || [])
22
+ .map((/** @type {any} */ m) => m.id)
23
+ .filter(Boolean)
24
+ // Keep models usable for chat/agent work (gpt*, o*, codex).
25
+ .filter((/** @type {string} */ id) => /^(gpt|o\d|codex|chatgpt)/i.test(id))
26
+ .sort();
27
+ return ids.length ? ids : fallbackModels();
28
+ } catch {
29
+ return fallbackModels();
30
+ }
31
+ }
32
+
33
+ // The ChatGPT/Codex backend exposes models tied to the plan. There is no
34
+ // stable public list endpoint, so we report the commonly available Codex
35
+ // models. DEFAULT_MODEL is always first.
36
+ function listChatGptModels() {
37
+ const known = ["gpt-5-codex", "gpt-5", "gpt-5-mini", "o4-mini", "o3"];
38
+ return dedupeWithDefault(known);
39
+ }
40
+
41
+ function fallbackModels() {
42
+ return dedupeWithDefault(["gpt-5-codex", "gpt-5", "gpt-5-mini", "gpt-4.1"]);
43
+ }
44
+
45
+ function dedupeWithDefault(list) {
46
+ const set = new Set([DEFAULT_MODEL, ...list]);
47
+ return [...set];
48
+ }
49
+
50
+ // Resolves the model to use: explicit override > config/session > a model the
51
+ // account actually has > DEFAULT_MODEL.
52
+ export async function resolveModel(authInfo, preferred) {
53
+ if (preferred) return preferred;
54
+ const available = await listModels(authInfo).catch(() => []);
55
+ if (available.includes(DEFAULT_MODEL)) return DEFAULT_MODEL;
56
+ return available[0] || DEFAULT_MODEL;
57
+ }