@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/AGENTS.md +31 -0
- package/LICENSE +201 -0
- package/README.md +314 -0
- package/bin/purrx.js +352 -0
- package/package.json +64 -0
- package/src/api/client.js +121 -0
- package/src/api/models.js +57 -0
- package/src/auth/login.js +199 -0
- package/src/auth/pkce.js +34 -0
- package/src/auth/tokens.js +186 -0
- package/src/config.js +57 -0
- package/src/core/agent.js +197 -0
- package/src/core/approval.js +101 -0
- package/src/core/compact.js +207 -0
- package/src/core/context.js +245 -0
- package/src/core/session.js +101 -0
- package/src/index.js +24 -0
- package/src/platform.js +94 -0
- package/src/tools/builtin.js +476 -0
- package/src/tools/mcp.js +223 -0
- package/src/tools/registry.js +62 -0
- package/src/types.js +68 -0
- package/src/ui/render.js +47 -0
- package/src/ui/theme.js +114 -0
- package/src/ui/tui.js +317 -0
package/src/platform.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { exec } from "node:child_process";
|
|
5
|
+
|
|
6
|
+
// Detects the platform purrx is running on, with special handling for Termux
|
|
7
|
+
// on Android (which reports as linux but has its own conventions).
|
|
8
|
+
export function detectPlatform() {
|
|
9
|
+
const p = process.platform;
|
|
10
|
+
if (p === "win32") return "windows";
|
|
11
|
+
if (p === "darwin") return "macos";
|
|
12
|
+
if (p === "android") return "android"; // newer Node builds
|
|
13
|
+
if (p === "linux") {
|
|
14
|
+
if (isTermux()) return "android";
|
|
15
|
+
return "linux";
|
|
16
|
+
}
|
|
17
|
+
return p;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function isTermux() {
|
|
21
|
+
if (process.env.TERMUX_VERSION) return true;
|
|
22
|
+
const prefix = process.env.PREFIX || "";
|
|
23
|
+
if (prefix.includes("com.termux")) return true;
|
|
24
|
+
try {
|
|
25
|
+
return fs.existsSync("/data/data/com.termux/files/usr");
|
|
26
|
+
} catch {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Returns the base directory where purrx stores its data (credentials,
|
|
32
|
+
// sessions). Honours overrides and falls back to OS-appropriate locations.
|
|
33
|
+
export function dataHome() {
|
|
34
|
+
if (process.env.PURRX_HOME) return process.env.PURRX_HOME;
|
|
35
|
+
if (process.env.CODEX_HOME) return process.env.CODEX_HOME;
|
|
36
|
+
|
|
37
|
+
const platform = detectPlatform();
|
|
38
|
+
if (platform === "windows") {
|
|
39
|
+
const base =
|
|
40
|
+
process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming");
|
|
41
|
+
return path.join(base, "purrx");
|
|
42
|
+
}
|
|
43
|
+
if (platform === "macos") {
|
|
44
|
+
return path.join(os.homedir(), "Library", "Application Support", "purrx");
|
|
45
|
+
}
|
|
46
|
+
// linux / android (termux): respect XDG, default to ~/.purrx
|
|
47
|
+
const xdg = process.env.XDG_DATA_HOME;
|
|
48
|
+
if (xdg) return path.join(xdg, "purrx");
|
|
49
|
+
return path.join(os.homedir(), ".purrx");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Returns the appropriate default shell command runner for the platform.
|
|
53
|
+
export function shellFor(command) {
|
|
54
|
+
const platform = detectPlatform();
|
|
55
|
+
if (platform === "windows") {
|
|
56
|
+
// Use cmd.exe via the default exec shell.
|
|
57
|
+
return { command, shell: process.env.ComSpec || "cmd.exe" };
|
|
58
|
+
}
|
|
59
|
+
// POSIX-like (linux, macos, termux). Prefer the user's shell, then sh.
|
|
60
|
+
const shell =
|
|
61
|
+
process.env.SHELL ||
|
|
62
|
+
(platform === "android"
|
|
63
|
+
? path.join(process.env.PREFIX || "/data/data/com.termux/files/usr", "bin/bash")
|
|
64
|
+
: "/bin/sh");
|
|
65
|
+
return { command, shell };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Opens a URL in the user's browser across platforms.
|
|
69
|
+
export function openBrowser(url) {
|
|
70
|
+
const platform = detectPlatform();
|
|
71
|
+
let cmd;
|
|
72
|
+
switch (platform) {
|
|
73
|
+
case "windows":
|
|
74
|
+
cmd = `start "" "${url}"`;
|
|
75
|
+
break;
|
|
76
|
+
case "macos":
|
|
77
|
+
cmd = `open "${url}"`;
|
|
78
|
+
break;
|
|
79
|
+
case "android":
|
|
80
|
+
// Termux provides termux-open-url (from termux-api). Fall back to xdg-open.
|
|
81
|
+
cmd = `termux-open-url "${url}" || xdg-open "${url}"`;
|
|
82
|
+
break;
|
|
83
|
+
default:
|
|
84
|
+
cmd = `xdg-open "${url}"`;
|
|
85
|
+
}
|
|
86
|
+
exec(cmd, () => {});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Whether ANSI colors should be used. Disabled when NO_COLOR is set or output
|
|
90
|
+
// is not a TTY.
|
|
91
|
+
export function colorsEnabled() {
|
|
92
|
+
if (process.env.NO_COLOR) return false;
|
|
93
|
+
return Boolean(process.stdout.isTTY);
|
|
94
|
+
}
|
|
@@ -0,0 +1,476 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { exec } from "node:child_process";
|
|
4
|
+
import { shellFor } from "../platform.js";
|
|
5
|
+
import { saveMemory, readSkill, listSkills } from "../core/context.js";
|
|
6
|
+
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
// Built-in tool definitions (OpenAI Responses "function" tool format).
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
|
|
11
|
+
/** @type {import("../types.js").ToolDefinition[]} */
|
|
12
|
+
export const toolDefinitions = [
|
|
13
|
+
{
|
|
14
|
+
type: "function",
|
|
15
|
+
name: "read_file",
|
|
16
|
+
description:
|
|
17
|
+
"Read the contents of a text file. Optionally read a line range.",
|
|
18
|
+
parameters: {
|
|
19
|
+
type: "object",
|
|
20
|
+
properties: {
|
|
21
|
+
path: { type: "string", description: "Path to the file to read." },
|
|
22
|
+
start_line: {
|
|
23
|
+
type: "integer",
|
|
24
|
+
description: "Optional 1-based start line.",
|
|
25
|
+
},
|
|
26
|
+
end_line: { type: "integer", description: "Optional 1-based end line." },
|
|
27
|
+
},
|
|
28
|
+
required: ["path"],
|
|
29
|
+
additionalProperties: false,
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
type: "function",
|
|
34
|
+
name: "write_file",
|
|
35
|
+
description: "Create or overwrite a text file with the provided content.",
|
|
36
|
+
parameters: {
|
|
37
|
+
type: "object",
|
|
38
|
+
properties: {
|
|
39
|
+
path: { type: "string", description: "Path to the file to write." },
|
|
40
|
+
content: { type: "string", description: "Full file content." },
|
|
41
|
+
},
|
|
42
|
+
required: ["path", "content"],
|
|
43
|
+
additionalProperties: false,
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
type: "function",
|
|
48
|
+
name: "edit_file",
|
|
49
|
+
description:
|
|
50
|
+
"Replace an exact substring in a file with new text. The old_text must match exactly once. Use for surgical edits without rewriting the whole file.",
|
|
51
|
+
parameters: {
|
|
52
|
+
type: "object",
|
|
53
|
+
properties: {
|
|
54
|
+
path: { type: "string", description: "Path to the file to edit." },
|
|
55
|
+
old_text: {
|
|
56
|
+
type: "string",
|
|
57
|
+
description: "Exact text to find (must be unique in the file).",
|
|
58
|
+
},
|
|
59
|
+
new_text: { type: "string", description: "Replacement text." },
|
|
60
|
+
},
|
|
61
|
+
required: ["path", "old_text", "new_text"],
|
|
62
|
+
additionalProperties: false,
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
type: "function",
|
|
67
|
+
name: "list_directory",
|
|
68
|
+
description: "List files and folders in a directory.",
|
|
69
|
+
parameters: {
|
|
70
|
+
type: "object",
|
|
71
|
+
properties: {
|
|
72
|
+
path: {
|
|
73
|
+
type: "string",
|
|
74
|
+
description: "Directory path. Defaults to the current directory.",
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
required: [],
|
|
78
|
+
additionalProperties: false,
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
type: "function",
|
|
83
|
+
name: "search_files",
|
|
84
|
+
description:
|
|
85
|
+
"Recursively search file contents for a regular expression. Returns matching lines with file paths and line numbers.",
|
|
86
|
+
parameters: {
|
|
87
|
+
type: "object",
|
|
88
|
+
properties: {
|
|
89
|
+
pattern: { type: "string", description: "Regular expression to search for." },
|
|
90
|
+
path: {
|
|
91
|
+
type: "string",
|
|
92
|
+
description: "Directory to search in. Defaults to current directory.",
|
|
93
|
+
},
|
|
94
|
+
glob: {
|
|
95
|
+
type: "string",
|
|
96
|
+
description:
|
|
97
|
+
"Optional filename glob filter, e.g. '*.js' or '*.{ts,tsx}'.",
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
required: ["pattern"],
|
|
101
|
+
additionalProperties: false,
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
type: "function",
|
|
106
|
+
name: "find_files",
|
|
107
|
+
description:
|
|
108
|
+
"Find files by name using a glob pattern (e.g. '**/*.js'). Returns matching paths.",
|
|
109
|
+
parameters: {
|
|
110
|
+
type: "object",
|
|
111
|
+
properties: {
|
|
112
|
+
glob: { type: "string", description: "Glob pattern to match file paths." },
|
|
113
|
+
path: {
|
|
114
|
+
type: "string",
|
|
115
|
+
description: "Base directory. Defaults to current directory.",
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
required: ["glob"],
|
|
119
|
+
additionalProperties: false,
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
type: "function",
|
|
124
|
+
name: "delete_file",
|
|
125
|
+
description: "Delete a file or empty directory.",
|
|
126
|
+
parameters: {
|
|
127
|
+
type: "object",
|
|
128
|
+
properties: {
|
|
129
|
+
path: { type: "string", description: "Path to delete." },
|
|
130
|
+
},
|
|
131
|
+
required: ["path"],
|
|
132
|
+
additionalProperties: false,
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
type: "function",
|
|
137
|
+
name: "run_command",
|
|
138
|
+
description:
|
|
139
|
+
"Run a shell command in the working directory and return stdout/stderr.",
|
|
140
|
+
parameters: {
|
|
141
|
+
type: "object",
|
|
142
|
+
properties: {
|
|
143
|
+
command: { type: "string", description: "The shell command to run." },
|
|
144
|
+
},
|
|
145
|
+
required: ["command"],
|
|
146
|
+
additionalProperties: false,
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
type: "function",
|
|
151
|
+
name: "fetch_url",
|
|
152
|
+
description:
|
|
153
|
+
"Fetch the contents of an HTTP(S) URL. Returns the response body as text (truncated if large).",
|
|
154
|
+
parameters: {
|
|
155
|
+
type: "object",
|
|
156
|
+
properties: {
|
|
157
|
+
url: { type: "string", description: "The https:// URL to fetch." },
|
|
158
|
+
},
|
|
159
|
+
required: ["url"],
|
|
160
|
+
additionalProperties: false,
|
|
161
|
+
},
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
type: "function",
|
|
165
|
+
name: "remember",
|
|
166
|
+
description:
|
|
167
|
+
"Save a durable note to long-term memory so it persists across sessions. Use for user preferences, project conventions, or facts worth recalling later. Do not store secrets.",
|
|
168
|
+
parameters: {
|
|
169
|
+
type: "object",
|
|
170
|
+
properties: {
|
|
171
|
+
text: { type: "string", description: "The note to remember." },
|
|
172
|
+
topic: {
|
|
173
|
+
type: "string",
|
|
174
|
+
description:
|
|
175
|
+
"Optional topic/file to group the note under (e.g. 'preferences', 'project'). Defaults to 'notes'.",
|
|
176
|
+
},
|
|
177
|
+
},
|
|
178
|
+
required: ["text"],
|
|
179
|
+
additionalProperties: false,
|
|
180
|
+
},
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
type: "function",
|
|
184
|
+
name: "use_skill",
|
|
185
|
+
description:
|
|
186
|
+
"Load the full playbook for a named skill before performing a task. Skills are reusable instructions. Call list_skills first if unsure which exist.",
|
|
187
|
+
parameters: {
|
|
188
|
+
type: "object",
|
|
189
|
+
properties: {
|
|
190
|
+
name: { type: "string", description: "The skill name to load." },
|
|
191
|
+
},
|
|
192
|
+
required: ["name"],
|
|
193
|
+
additionalProperties: false,
|
|
194
|
+
},
|
|
195
|
+
},
|
|
196
|
+
{
|
|
197
|
+
type: "function",
|
|
198
|
+
name: "list_skills",
|
|
199
|
+
description: "List the skills available in this project and globally.",
|
|
200
|
+
parameters: {
|
|
201
|
+
type: "object",
|
|
202
|
+
properties: {},
|
|
203
|
+
required: [],
|
|
204
|
+
additionalProperties: false,
|
|
205
|
+
},
|
|
206
|
+
},
|
|
207
|
+
];
|
|
208
|
+
|
|
209
|
+
// Tools that only read state (used by the approval layer).
|
|
210
|
+
export const READ_ONLY_TOOLS = new Set([
|
|
211
|
+
"read_file",
|
|
212
|
+
"list_directory",
|
|
213
|
+
"search_files",
|
|
214
|
+
"find_files",
|
|
215
|
+
"fetch_url",
|
|
216
|
+
"use_skill",
|
|
217
|
+
"list_skills",
|
|
218
|
+
]);
|
|
219
|
+
|
|
220
|
+
// ---------------------------------------------------------------------------
|
|
221
|
+
// Helpers
|
|
222
|
+
// ---------------------------------------------------------------------------
|
|
223
|
+
|
|
224
|
+
const MAX_OUTPUT = 100_000;
|
|
225
|
+
const IGNORE_DIRS = new Set([
|
|
226
|
+
"node_modules",
|
|
227
|
+
".git",
|
|
228
|
+
"dist",
|
|
229
|
+
"build",
|
|
230
|
+
".next",
|
|
231
|
+
".cache",
|
|
232
|
+
"coverage",
|
|
233
|
+
]);
|
|
234
|
+
|
|
235
|
+
function truncate(text) {
|
|
236
|
+
return text.length > MAX_OUTPUT
|
|
237
|
+
? text.slice(0, MAX_OUTPUT) + "\n...[truncated]"
|
|
238
|
+
: text;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Converts a glob to a RegExp (supports *, **, ?, {a,b}).
|
|
242
|
+
function globToRegExp(glob) {
|
|
243
|
+
let re = "";
|
|
244
|
+
for (let i = 0; i < glob.length; i++) {
|
|
245
|
+
const ch = glob[i];
|
|
246
|
+
if (ch === "*") {
|
|
247
|
+
if (glob[i + 1] === "*") {
|
|
248
|
+
re += ".*";
|
|
249
|
+
i++;
|
|
250
|
+
if (glob[i + 1] === "/") i++;
|
|
251
|
+
} else {
|
|
252
|
+
re += "[^/]*";
|
|
253
|
+
}
|
|
254
|
+
} else if (ch === "?") {
|
|
255
|
+
re += "[^/]";
|
|
256
|
+
} else if (ch === "{") {
|
|
257
|
+
const end = glob.indexOf("}", i);
|
|
258
|
+
const opts = glob.slice(i + 1, end).split(",");
|
|
259
|
+
re += "(" + opts.map(escapeRe).join("|") + ")";
|
|
260
|
+
i = end;
|
|
261
|
+
} else {
|
|
262
|
+
re += escapeRe(ch);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
return new RegExp("^" + re + "$");
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function escapeRe(s) {
|
|
269
|
+
return s.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function* walk(dir, base = dir) {
|
|
273
|
+
let entries;
|
|
274
|
+
try {
|
|
275
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
276
|
+
} catch {
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
for (const e of entries) {
|
|
280
|
+
if (e.name.startsWith(".") && e.name !== ".") {
|
|
281
|
+
if (IGNORE_DIRS.has(e.name)) continue;
|
|
282
|
+
}
|
|
283
|
+
if (IGNORE_DIRS.has(e.name)) continue;
|
|
284
|
+
const full = path.join(dir, e.name);
|
|
285
|
+
if (e.isDirectory()) {
|
|
286
|
+
yield* walk(full, base);
|
|
287
|
+
} else {
|
|
288
|
+
yield full;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function runCommand(command, cwd) {
|
|
294
|
+
return new Promise((resolve) => {
|
|
295
|
+
const { shell } = shellFor(command);
|
|
296
|
+
exec(
|
|
297
|
+
command,
|
|
298
|
+
{ cwd, shell, maxBuffer: 10 * 1024 * 1024, windowsHide: true },
|
|
299
|
+
(error, stdout, stderr) => {
|
|
300
|
+
const out = (stdout || "").trim();
|
|
301
|
+
const err = (stderr || "").trim();
|
|
302
|
+
let result = "";
|
|
303
|
+
if (out) result += out;
|
|
304
|
+
if (err) result += (result ? "\n" : "") + `[stderr]\n${err}`;
|
|
305
|
+
if (error && typeof error.code === "number") {
|
|
306
|
+
result += `\n[exit code: ${error.code}]`;
|
|
307
|
+
}
|
|
308
|
+
resolve(truncate(result || "(no output)"));
|
|
309
|
+
}
|
|
310
|
+
);
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// ---------------------------------------------------------------------------
|
|
315
|
+
// Tool execution
|
|
316
|
+
// ---------------------------------------------------------------------------
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Execute a built-in tool by name.
|
|
320
|
+
* @param {string} name
|
|
321
|
+
* @param {Record<string, any>} args
|
|
322
|
+
* @param {string} [cwd]
|
|
323
|
+
* @returns {Promise<string>}
|
|
324
|
+
*/
|
|
325
|
+
export async function executeTool(name, args, cwd = process.cwd()) {
|
|
326
|
+
try {
|
|
327
|
+
switch (name) {
|
|
328
|
+
case "read_file": {
|
|
329
|
+
const p = path.resolve(cwd, args.path);
|
|
330
|
+
let content = fs.readFileSync(p, "utf8");
|
|
331
|
+
if (args.start_line || args.end_line) {
|
|
332
|
+
const lines = content.split("\n");
|
|
333
|
+
const start = Math.max(1, args.start_line || 1);
|
|
334
|
+
const end = Math.min(lines.length, args.end_line || lines.length);
|
|
335
|
+
content = lines
|
|
336
|
+
.slice(start - 1, end)
|
|
337
|
+
.map((l, idx) => `${start + idx}\t${l}`)
|
|
338
|
+
.join("\n");
|
|
339
|
+
}
|
|
340
|
+
return truncate(content);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
case "write_file": {
|
|
344
|
+
const p = path.resolve(cwd, args.path);
|
|
345
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
346
|
+
fs.writeFileSync(p, args.content, "utf8");
|
|
347
|
+
return `Wrote ${Buffer.byteLength(args.content)} bytes to ${args.path}`;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
case "edit_file": {
|
|
351
|
+
const p = path.resolve(cwd, args.path);
|
|
352
|
+
const content = fs.readFileSync(p, "utf8");
|
|
353
|
+
const count = content.split(args.old_text).length - 1;
|
|
354
|
+
if (count === 0) {
|
|
355
|
+
return `Error: old_text not found in ${args.path}`;
|
|
356
|
+
}
|
|
357
|
+
if (count > 1) {
|
|
358
|
+
return `Error: old_text matches ${count} times in ${args.path}; make it more specific so it is unique.`;
|
|
359
|
+
}
|
|
360
|
+
const updated = content.replace(args.old_text, args.new_text);
|
|
361
|
+
fs.writeFileSync(p, updated, "utf8");
|
|
362
|
+
return `Edited ${args.path} (1 replacement).`;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
case "list_directory": {
|
|
366
|
+
const p = path.resolve(cwd, args.path || ".");
|
|
367
|
+
const entries = fs.readdirSync(p, { withFileTypes: true });
|
|
368
|
+
return (
|
|
369
|
+
entries
|
|
370
|
+
.map((e) => (e.isDirectory() ? `${e.name}/` : e.name))
|
|
371
|
+
.sort()
|
|
372
|
+
.join("\n") || "(empty directory)"
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
case "search_files": {
|
|
377
|
+
const base = path.resolve(cwd, args.path || ".");
|
|
378
|
+
let re;
|
|
379
|
+
try {
|
|
380
|
+
re = new RegExp(args.pattern);
|
|
381
|
+
} catch (err) {
|
|
382
|
+
return `Error: invalid regex: ${err.message}`;
|
|
383
|
+
}
|
|
384
|
+
const globRe = args.glob ? globToRegExp(args.glob) : null;
|
|
385
|
+
const matches = [];
|
|
386
|
+
for (const file of walk(base)) {
|
|
387
|
+
const rel = path.relative(cwd, file).replace(/\\/g, "/");
|
|
388
|
+
if (globRe && !globRe.test(path.basename(file)) && !globRe.test(rel)) {
|
|
389
|
+
continue;
|
|
390
|
+
}
|
|
391
|
+
let text;
|
|
392
|
+
try {
|
|
393
|
+
text = fs.readFileSync(file, "utf8");
|
|
394
|
+
} catch {
|
|
395
|
+
continue;
|
|
396
|
+
}
|
|
397
|
+
const lines = text.split("\n");
|
|
398
|
+
for (let i = 0; i < lines.length; i++) {
|
|
399
|
+
if (re.test(lines[i])) {
|
|
400
|
+
matches.push(`${rel}:${i + 1}: ${lines[i].trim()}`);
|
|
401
|
+
if (matches.length >= 200) break;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
if (matches.length >= 200) break;
|
|
405
|
+
}
|
|
406
|
+
return matches.length
|
|
407
|
+
? truncate(matches.join("\n"))
|
|
408
|
+
: "(no matches)";
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
case "find_files": {
|
|
412
|
+
const base = path.resolve(cwd, args.path || ".");
|
|
413
|
+
const globRe = globToRegExp(args.glob);
|
|
414
|
+
const found = [];
|
|
415
|
+
for (const file of walk(base)) {
|
|
416
|
+
const rel = path.relative(base, file).replace(/\\/g, "/");
|
|
417
|
+
if (globRe.test(rel) || globRe.test(path.basename(file))) {
|
|
418
|
+
found.push(path.relative(cwd, file).replace(/\\/g, "/"));
|
|
419
|
+
if (found.length >= 500) break;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
return found.length ? found.join("\n") : "(no files matched)";
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
case "delete_file": {
|
|
426
|
+
const p = path.resolve(cwd, args.path);
|
|
427
|
+
fs.rmSync(p, { recursive: false });
|
|
428
|
+
return `Deleted ${args.path}`;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
case "run_command": {
|
|
432
|
+
return await runCommand(args.command, cwd);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
case "fetch_url": {
|
|
436
|
+
if (!/^https?:\/\//i.test(args.url)) {
|
|
437
|
+
return "Error: only http(s) URLs are supported.";
|
|
438
|
+
}
|
|
439
|
+
const resp = await fetch(args.url, {
|
|
440
|
+
headers: { "User-Agent": "purrx-agent" },
|
|
441
|
+
});
|
|
442
|
+
const text = await resp.text();
|
|
443
|
+
return truncate(`[status ${resp.status}]\n${text}`);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
case "remember": {
|
|
447
|
+
const file = saveMemory(args.text, args.topic || "notes");
|
|
448
|
+
return `Saved to memory (${path.basename(file)}).`;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
case "use_skill": {
|
|
452
|
+
const body = readSkill(cwd, args.name);
|
|
453
|
+
if (!body) {
|
|
454
|
+
const available = listSkills(cwd)
|
|
455
|
+
.map((s) => s.name)
|
|
456
|
+
.join(", ");
|
|
457
|
+
return `Error: no skill named "${args.name}". Available: ${available || "(none)"}`;
|
|
458
|
+
}
|
|
459
|
+
return body;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
case "list_skills": {
|
|
463
|
+
const skills = listSkills(cwd);
|
|
464
|
+
if (!skills.length) return "(no skills available)";
|
|
465
|
+
return skills
|
|
466
|
+
.map((s) => `${s.name}: ${s.description || "(no description)"}`)
|
|
467
|
+
.join("\n");
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
default:
|
|
471
|
+
return `Error: unknown tool "${name}"`;
|
|
472
|
+
}
|
|
473
|
+
} catch (err) {
|
|
474
|
+
return `Error: ${err.message}`;
|
|
475
|
+
}
|
|
476
|
+
}
|