@noobdemon/noob-cli 1.0.1

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/src/models.js ADDED
@@ -0,0 +1,58 @@
1
+ // Model catalog supported by the Noob Demon gateway.
2
+ export const MODELS = [
3
+ { id: "gateway-gpt-5", name: "GPT-5", provider: "openai", tier: "flagship" },
4
+ { id: "gateway-gpt-5-1", name: "GPT-5.1", provider: "openai", tier: "flagship" },
5
+ { id: "gateway-gpt-5-3", name: "GPT-5.3", provider: "openai", tier: "flagship" },
6
+ { id: "gateway-gpt-5-4", name: "GPT-5.4", provider: "openai", tier: "flagship" },
7
+ { id: "gateway-gpt-5-5", name: "GPT-5.5", provider: "openai", tier: "flagship" },
8
+ { id: "gateway-gpt-o3", name: "o3", provider: "openai", tier: "reasoning" },
9
+ { id: "gateway-gpt-o3-mini", name: "o3 Mini", provider: "openai", tier: "reasoning" },
10
+ { id: "gateway-gpt-o4-mini", name: "o4-mini", provider: "openai", tier: "reasoning" },
11
+ { id: "gateway-gpt-4o", name: "GPT-4o", provider: "openai", tier: "standard" },
12
+ { id: "gateway-gpt-4-1-mini", name: "GPT-4.1 Mini", provider: "openai", tier: "fast" },
13
+ { id: "gateway-gpt-4-1-nano", name: "GPT-4.1 Nano", provider: "openai", tier: "fast" },
14
+ { id: "gateway-gpt-5-mini", name: "GPT-5 Mini", provider: "openai", tier: "fast" },
15
+ { id: "gateway-gpt-5-nano", name: "GPT-5 Nano", provider: "openai", tier: "fast" },
16
+ { id: "gateway-gpt-5-online", name: "GPT-5 Online", provider: "openai", tier: "standard" },
17
+ { id: "gateway-claude-opus-4-7", name: "Claude Opus 4.7", provider: "anthropic", tier: "flagship" },
18
+ { id: "gateway-claude-opus-4-6", name: "Claude Opus 4.6", provider: "anthropic", tier: "flagship" },
19
+ { id: "gateway-claude-opus-4-5", name: "Claude Opus 4.5", provider: "anthropic", tier: "flagship" },
20
+ { id: "gateway-claude-opus-4-1", name: "Claude Opus 4.1", provider: "anthropic", tier: "standard" },
21
+ { id: "gateway-claude-sonnet-4", name: "Claude Sonnet 4", provider: "anthropic", tier: "standard" },
22
+ { id: "gateway-claude-sonnet-4-6", name: "Claude Sonnet 4.6", provider: "anthropic", tier: "standard" },
23
+ { id: "gateway-google-2.5-pro", name: "Gemini 2.5 Pro", provider: "google", tier: "flagship" },
24
+ { id: "gateway-gemini-3-pro", name: "Gemini 3 Pro", provider: "google", tier: "flagship" },
25
+ { id: "gateway-gemini-3-1-pro", name: "Gemini 3.1 Pro", provider: "google", tier: "flagship" },
26
+ { id: "gateway-gemini-2.5-flash", name: "Gemini 2.5 Flash", provider: "google", tier: "fast" },
27
+ { id: "gateway-deepseek-v4-pro", name: "DeepSeek V4 Pro", provider: "deepseek", tier: "flagship" },
28
+ { id: "gateway-deepseek-v4-flash", name: "DeepSeek V4 Flash", provider: "deepseek", tier: "fast" },
29
+ { id: "gateway-deepseek-r1", name: "DeepSeek R1", provider: "deepseek", tier: "reasoning" },
30
+ { id: "gateway-deepseek-v3", name: "DeepSeek V3", provider: "deepseek", tier: "standard" },
31
+ { id: "gateway-grok-4", name: "Grok 4", provider: "xai", tier: "flagship" },
32
+ { id: "gateway-grok-3", name: "Grok 3", provider: "xai", tier: "standard" },
33
+ { id: "gateway-qwen-3-max", name: "Qwen 3 Max", provider: "alibaba", tier: "standard" },
34
+ { id: "gateway-qwen-qwq-32b", name: "Qwen QwQ 32B", provider: "alibaba", tier: "reasoning" },
35
+ { id: "gateway-deepinfra-kimi-k2", name: "Kimi K2", provider: "moonshot", tier: "standard" },
36
+ { id: "gateway-llama-3-3-70b-versatile", name: "Llama 3.3 70B", provider: "meta", tier: "standard" },
37
+ ];
38
+
39
+ export const PROVIDERS = {
40
+ openai: { name: "OpenAI", color: "#10a37f" },
41
+ anthropic: { name: "Anthropic", color: "#d97706" },
42
+ google: { name: "Google", color: "#3b82f6" },
43
+ deepseek: { name: "DeepSeek", color: "#06b6d4" },
44
+ xai: { name: "xAI", color: "#ef4444" },
45
+ alibaba: { name: "Alibaba", color: "#8b5cf6" },
46
+ moonshot: { name: "Moonshot", color: "#ec4899" },
47
+ meta: { name: "Meta", color: "#6366f1" },
48
+ };
49
+
50
+ export const DEFAULT_MODEL = "gateway-claude-opus-4-7";
51
+
52
+ export function findModel(id) {
53
+ return MODELS.find((m) => m.id === id);
54
+ }
55
+
56
+ export function providerColor(providerKey) {
57
+ return PROVIDERS[providerKey]?.color || "#a78bfa";
58
+ }
package/src/repl.js ADDED
@@ -0,0 +1,420 @@
1
+ import readline from "node:readline";
2
+ import process from "node:process";
3
+ import ora from "ora";
4
+ import chalk from "chalk";
5
+ import { runAgent } from "./agent.js";
6
+ import { stream, usage, ApiError } from "./api.js";
7
+ import { runTool, describe, DESTRUCTIVE } from "./tools.js";
8
+ import { MODELS, PROVIDERS, findModel, providerColor, DEFAULT_MODEL } from "./models.js";
9
+ import { c, banner, modelBadge, renderMarkdown, box } from "./ui.js";
10
+ import { config } from "./config.js";
11
+ import { t } from "./i18n.js";
12
+
13
+ export async function startRepl(opts = {}) {
14
+ const state = {
15
+ model: findModel(opts.model) || findModel(config.model) || findModel(DEFAULT_MODEL),
16
+ mode: "chat", // chat | merge | search
17
+ history: [],
18
+ autoApprove: new Set(),
19
+ yolo: !!opts.yolo,
20
+ };
21
+
22
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
23
+ let closed = false;
24
+ let pending = null;
25
+ let lastPrompt = "";
26
+ let awaitingInput = false;
27
+ rl.on("close", () => {
28
+ closed = true;
29
+ if (pending) {
30
+ const res = pending;
31
+ pending = null;
32
+ res(null);
33
+ }
34
+ });
35
+ const ask = (q) =>
36
+ new Promise((res) => {
37
+ if (closed) return res(null);
38
+ lastPrompt = q;
39
+ awaitingInput = true;
40
+ pending = res;
41
+ rl.question(q, (a) => {
42
+ pending = null;
43
+ awaitingInput = false;
44
+ res(a);
45
+ });
46
+ });
47
+
48
+ // Shift+Tab — quick yolo toggle.
49
+ if (process.stdin.isTTY) {
50
+ readline.emitKeypressEvents(process.stdin);
51
+ process.stdin.on("keypress", (_str, key) => {
52
+ if (!key || key.name !== "tab" || !key.shift) return;
53
+ state.yolo = !state.yolo;
54
+ const msg = state.yolo ? c.err(" " + t.yoloOn) : c.ok(" " + t.yoloOff);
55
+ if (awaitingInput) {
56
+ const buf = rl.line;
57
+ readline.cursorTo(process.stdout, 0);
58
+ readline.clearLine(process.stdout, 0);
59
+ console.log(msg);
60
+ process.stdout.write(lastPrompt + buf);
61
+ } else {
62
+ console.log("\n" + msg);
63
+ }
64
+ });
65
+ }
66
+
67
+ let abort = null; // active turn controller
68
+ let sigintArmed = false;
69
+ process.on("SIGINT", () => {
70
+ if (abort) {
71
+ abort.abort();
72
+ abort = null;
73
+ console.log(c.err("\n ✗ " + t.interrupted));
74
+ return;
75
+ }
76
+ if (sigintArmed) {
77
+ console.log(c.dim("\n " + t.bye));
78
+ process.exit(0);
79
+ }
80
+ sigintArmed = true;
81
+ console.log(c.dim("\n " + t.pressAgainToExit));
82
+ setTimeout(() => (sigintArmed = false), 2000);
83
+ if (awaitingInput) process.stdout.write(lastPrompt + (rl.line || ""));
84
+ });
85
+
86
+ banner();
87
+ printStatus(state);
88
+ if (!config.apiKey) console.log("\n" + c.tool(" " + t.notLoggedIn) + "\n");
89
+ else console.log(c.dim(" " + t.ready + "\n"));
90
+
91
+ if (opts.prompt) {
92
+ console.log(c.user(t.promptYou) + c.dim("› ") + opts.prompt);
93
+ await handle(opts.prompt);
94
+ }
95
+
96
+ // Main loop — runs until /exit, double Ctrl+C, or EOF. Never exits after a task.
97
+ while (!closed) {
98
+ const raw = await ask(c.user("\n" + t.promptYou) + c.dim("› "));
99
+ if (raw == null) break;
100
+ const input = raw.trim();
101
+ if (!input) continue;
102
+ if (input.startsWith("/")) {
103
+ const done = await command(input);
104
+ if (done) break;
105
+ continue;
106
+ }
107
+ await handle(input);
108
+ }
109
+ rl.close();
110
+
111
+ // ── turn handler ─────────────────────────────────────────────────────────
112
+ async function handle(text) {
113
+ if (!config.apiKey) {
114
+ console.log(c.tool(" " + t.notLoggedIn));
115
+ return;
116
+ }
117
+ abort = new AbortController();
118
+ const spinner = ora({ color: "magenta", spinner: "dots" });
119
+ const t0 = Date.now();
120
+ let timer = null;
121
+ const tick = (label) => {
122
+ const elapsed = ((Date.now() - t0) / 1000).toFixed(0);
123
+ spinner.text = c.dim(`${label}… ${elapsed}s`);
124
+ };
125
+
126
+ try {
127
+ if (state.mode !== "chat") {
128
+ spinner.start();
129
+ timer = setInterval(() => tick(state.mode === "search" ? t.searching : t.merging), 200);
130
+ const { text: answer } = await stream({
131
+ mode: state.mode,
132
+ message: text,
133
+ signal: abort.signal,
134
+ onStatus: (s) => (spinner.text = c.dim(" " + s)),
135
+ });
136
+ clearInterval(timer);
137
+ spinner.stop();
138
+ printAnswer(answer, state.mode === "search" ? "Tìm web" : "Merge AI", "#f59e0b");
139
+ return;
140
+ }
141
+
142
+ state.history.push({ role: "user", content: text });
143
+ spinner.start();
144
+ timer = setInterval(() => tick(t.thinking), 200);
145
+
146
+ const answer = await runAgent({
147
+ history: state.history,
148
+ model: state.model.id,
149
+ signal: abort.signal,
150
+ onStatus: () => tick(t.thinking),
151
+ onTool: async (name, input) => {
152
+ clearInterval(timer);
153
+ spinner.stop();
154
+ const res = await execTool(name, input);
155
+ spinner.start();
156
+ timer = setInterval(() => tick(t.thinking), 200);
157
+ return res;
158
+ },
159
+ });
160
+
161
+ clearInterval(timer);
162
+ spinner.stop();
163
+ printAnswer(answer, state.model.name, providerColor(state.model.provider));
164
+ } catch (err) {
165
+ clearInterval(timer);
166
+ spinner.stop();
167
+ if (err.name === "AbortError") return;
168
+ printError(err);
169
+ } finally {
170
+ abort = null;
171
+ }
172
+ }
173
+
174
+ async function execTool(name, input) {
175
+ const desc = describe(name, input);
176
+ const color = name === "run_command" ? "#ef4444" : "#f59e0b";
177
+ console.log("\n" + chalk.hex(color)(" ⚙ " + name) + c.dim(" " + desc));
178
+
179
+ if (name === "write_file" && input.content) preview(input.content, input.path);
180
+ else if (name === "edit_file") preview(`- ${truncate(input.old_string)}\n+ ${truncate(input.new_string)}`, input.path);
181
+
182
+ if (DESTRUCTIVE.has(name) && !state.yolo && !state.autoApprove.has(name)) {
183
+ const a = ((await ask(c.tool(" cho phép? ") + c.dim("[y] có / [n] không / [a] luôn " + name + " › "))) ?? "n")
184
+ .trim()
185
+ .toLowerCase();
186
+ if (a === "a") state.autoApprove.add(name);
187
+ else if (a !== "y" && a !== "") {
188
+ console.log(c.err(" " + t.denied));
189
+ return { allow: false };
190
+ }
191
+ }
192
+
193
+ const sp = ora({ text: c.dim(" " + t.running), color: "yellow" }).start();
194
+ try {
195
+ const result = await runTool(name, input);
196
+ sp.stop();
197
+ console.log(c.ok(" ✓ ") + c.dim(firstLine(result)));
198
+ return { allow: true, result };
199
+ } catch (err) {
200
+ sp.stop();
201
+ console.log(c.err(" ✗ " + err.message));
202
+ return { allow: true, result: "ERROR: " + err.message };
203
+ }
204
+ }
205
+
206
+ // ── slash commands ─────────────────────────────────────────────────────
207
+ async function command(input) {
208
+ const [cmd, ...rest] = input.slice(1).split(/\s+/);
209
+ const arg = rest.join(" ").trim();
210
+ switch (cmd) {
211
+ case "help":
212
+ printHelp();
213
+ break;
214
+ case "model":
215
+ arg ? selectModel(arg) : listModels();
216
+ break;
217
+ case "models":
218
+ listModels();
219
+ break;
220
+ case "merge":
221
+ state.mode = state.mode === "merge" ? "chat" : "merge";
222
+ console.log(c.tool(" " + (state.mode === "merge" ? t.mergeOn : t.mergeOff)));
223
+ break;
224
+ case "search":
225
+ state.mode = state.mode === "search" ? "chat" : "search";
226
+ console.log(c.accent(" " + (state.mode === "search" ? t.searchOn : t.searchOff)));
227
+ break;
228
+ case "chat":
229
+ state.mode = "chat";
230
+ console.log(c.dim(" " + t.backToChat));
231
+ break;
232
+ case "yolo":
233
+ state.yolo = !state.yolo;
234
+ console.log((state.yolo ? c.err : c.ok)(" " + (state.yolo ? t.yoloOn : t.yoloOff)));
235
+ break;
236
+ case "login":
237
+ doLogin(arg);
238
+ break;
239
+ case "logout":
240
+ config.clearKey();
241
+ console.log(c.ok(" " + t.loggedOut));
242
+ break;
243
+ case "usage":
244
+ await showUsage();
245
+ break;
246
+ case "clear":
247
+ case "new":
248
+ state.history = [];
249
+ console.clear();
250
+ banner();
251
+ printStatus(state);
252
+ console.log(c.dim(" " + t.ctxCleared + "\n"));
253
+ break;
254
+ case "cwd":
255
+ console.log(c.dim(" " + process.cwd()));
256
+ break;
257
+ case "status":
258
+ printStatus(state);
259
+ break;
260
+ case "exit":
261
+ case "quit":
262
+ case "q":
263
+ console.log(c.dim(" " + t.bye));
264
+ return true;
265
+ default:
266
+ console.log(c.err(" " + t.unknownCmd(cmd)) + c.dim(" " + t.tryHelp));
267
+ }
268
+ return false;
269
+ }
270
+
271
+ function doLogin(key) {
272
+ if (!key) return console.log(c.err(" " + t.needKeyArg));
273
+ config.setKey(key);
274
+ console.log(c.ok(" ✓ ") + c.dim(t.loginSaved(config.path)));
275
+ showUsage().catch(() => {});
276
+ }
277
+
278
+ async function showUsage() {
279
+ if (!config.apiKey) return console.log(c.tool(" " + t.notLoggedIn));
280
+ const sp = ora({ text: c.dim(" ..."), color: "magenta" }).start();
281
+ try {
282
+ const u = await usage();
283
+ sp.stop();
284
+ if (!u.ok) return printError(new ApiError(t.errInvalidKey, { code: u.error }));
285
+ printUsage(u);
286
+ } catch (err) {
287
+ sp.stop();
288
+ printError(err);
289
+ }
290
+ }
291
+
292
+ function selectModel(q) {
293
+ const s = q.toLowerCase();
294
+ const m =
295
+ MODELS.find((x) => x.id === q) ||
296
+ MODELS.find((x) => x.name.toLowerCase() === s) ||
297
+ MODELS.find((x) => x.name.toLowerCase().includes(s) || x.id.includes(s));
298
+ if (!m) return console.log(c.err(" " + t.noModelMatch(q)) + c.dim(" /models"));
299
+ state.model = m;
300
+ state.mode = "chat";
301
+ config.setModel(m.id);
302
+ console.log(c.ok(" " + t.switchTo + " ") + modelBadge(m));
303
+ if (m.provider === "openai" || m.provider === "google")
304
+ console.log(c.dim(" ") + c.tool(t.providerRefuses(PROVIDERS[m.provider].name)));
305
+ }
306
+
307
+ function printStatus(s) {
308
+ const mode =
309
+ s.mode === "merge" ? c.tool("Merge AI") : s.mode === "search" ? c.accent("Tìm web") : modelBadge(s.model);
310
+ const key = config.apiKey ? c.ok(" 🔑") : c.err(" 🔒");
311
+ console.log(" " + mode + key + (s.yolo ? c.err(" ⚠ yolo") : "") + c.dim(" thư mục: " + shortCwd()));
312
+ }
313
+ }
314
+
315
+ // ── presentation helpers ───────────────────────────────────────────────────
316
+ function printAnswer(text, name, color) {
317
+ if (!text?.trim()) return;
318
+ console.log("\n" + chalk.hex(color).bold(" ● " + name));
319
+ console.log(
320
+ renderMarkdown(text)
321
+ .split("\n")
322
+ .map((l) => " " + l)
323
+ .join("\n") + "\n",
324
+ );
325
+ }
326
+
327
+ function printError(err) {
328
+ const map = {
329
+ missing_key: t.errMissingKey,
330
+ invalid_key: t.errInvalidKey,
331
+ key_dead: t.errKeyDead,
332
+ trial_exhausted: t.errTrialExhausted,
333
+ key_disabled: t.errDisabled,
334
+ rate_limited: t.errRateLimited(err.reset_at ? fmtTime(err.reset_at) : ""),
335
+ };
336
+ const msg = (err instanceof ApiError && map[err.code]) || err.message || t.errConn;
337
+ console.log(c.err(" ✗ " + msg));
338
+ if (err instanceof ApiError && (err.code === "missing_key" || err.code === "invalid_key" || err.status === 401))
339
+ console.log(c.dim(" → noob login <api-key>"));
340
+ }
341
+
342
+ function printUsage(u) {
343
+ const planName = { pro: "Pro", proplus: "Pro+", admin: "Admin", trial: "Trial" }[u.plan] || u.plan;
344
+ const lines = [
345
+ chalk.bold(t.usageTitle),
346
+ ` ${t.plan}: ${chalk.bold(planName)} ${t.status}: ${u.status === "active" ? c.ok(u.status) : c.err(u.status)}`,
347
+ ];
348
+ if (u.plan === "admin") lines.push(` ${t.remaining}: ${c.ok(t.unlimited)}`);
349
+ else if (u.plan === "trial") lines.push(` ${t.remaining}: ${c.accent(t.trialLeft(u.remaining ?? 0))}`);
350
+ else lines.push(` ${t.remaining}: ${c.accent(String(u.remaining))} / ${u.limit} (${t.windowInfo(u.window_count ?? 0, u.limit)})`);
351
+ if (u.reset_at) lines.push(c.dim(` ${t.resetAt}: ${fmtTime(u.reset_at)}`));
352
+ if (u.total_used != null) lines.push(c.dim(` ${t.used} (tổng): ${u.total_used}`));
353
+ console.log(box(lines.join("\n"), t.usageTitle, "#a78bfa"));
354
+ }
355
+
356
+ function printHelp() {
357
+ console.log(
358
+ box(
359
+ [
360
+ chalk.bold(t.helpCommands),
361
+ " " + t.cmdModel,
362
+ " " + t.cmdModels,
363
+ " " + t.cmdMerge,
364
+ " " + t.cmdSearch,
365
+ " " + t.cmdChat,
366
+ " " + t.cmdYolo,
367
+ " " + t.cmdLogin,
368
+ " " + t.cmdLogout,
369
+ " " + t.cmdUsage,
370
+ " " + t.cmdClear,
371
+ " " + t.cmdStatus,
372
+ " " + t.cmdExit,
373
+ "",
374
+ chalk.bold(t.helpTips),
375
+ c.dim(" " + t.tip1),
376
+ c.dim(" " + t.tip2),
377
+ c.dim(" " + t.tip3),
378
+ ].join("\n"),
379
+ t.helpTitle,
380
+ "#a78bfa",
381
+ ),
382
+ );
383
+ }
384
+
385
+ function listModels() {
386
+ const byProv = {};
387
+ for (const m of MODELS) (byProv[m.provider] ||= []).push(m);
388
+ const lines = [];
389
+ for (const [pk, ms] of Object.entries(byProv)) {
390
+ lines.push(chalk.hex(providerColor(pk)).bold(PROVIDERS[pk]?.name || pk));
391
+ lines.push(ms.map((m) => " " + chalk.hex(providerColor(pk))("●") + " " + m.name + c.dim(` (${m.tier})`)).join("\n"));
392
+ }
393
+ console.log("\n" + lines.join("\n") + c.dim("\n\n " + t.modelListHint) + "\n");
394
+ }
395
+
396
+ const shortCwd = () => {
397
+ const p = process.cwd();
398
+ return p.length > 48 ? "…" + p.slice(-47) : p;
399
+ };
400
+ const firstLine = (s) => (s.split("\n")[0] || "").slice(0, 100);
401
+ const truncate = (s = "", n = 120) => (s.length > n ? s.slice(0, n) + "…" : s).replace(/\n/g, "⏎");
402
+ const fmtTime = (iso) => {
403
+ try {
404
+ return new Date(iso).toLocaleString("vi-VN");
405
+ } catch {
406
+ return iso;
407
+ }
408
+ };
409
+
410
+ function preview(content, label) {
411
+ const lines = content.split("\n").slice(0, 12);
412
+ const more = content.split("\n").length - lines.length;
413
+ console.log(
414
+ c.dim(" ┌─ " + (label || "")) +
415
+ "\n" +
416
+ lines.map((l) => c.dim(" │ ") + l.slice(0, 110)).join("\n") +
417
+ (more > 0 ? c.dim(`\n │ … +${more} dòng nữa`) : "") +
418
+ c.dim("\n └─"),
419
+ );
420
+ }
package/src/tools.js ADDED
@@ -0,0 +1,183 @@
1
+ import fs from "node:fs/promises";
2
+ import fssync from "node:fs";
3
+ import path from "node:path";
4
+ import { spawn } from "node:child_process";
5
+
6
+ const MAX_OUT = 30000; // hard cap on any tool result fed back to the model
7
+ const cwd = () => process.cwd();
8
+ const abs = (p) => path.resolve(cwd(), p);
9
+ const rel = (p) => path.relative(cwd(), p) || ".";
10
+
11
+ function clip(s) {
12
+ if (s.length <= MAX_OUT) return s;
13
+ return s.slice(0, MAX_OUT) + `\n… [truncated, ${s.length - MAX_OUT} more chars]`;
14
+ }
15
+
16
+ // Tools that mutate the filesystem or run code require user approval.
17
+ export const DESTRUCTIVE = new Set(["write_file", "edit_file", "run_command"]);
18
+
19
+ export const TOOLS = {
20
+ async read_file({ path: p, offset, limit }) {
21
+ const data = await fs.readFile(abs(p), "utf8");
22
+ let lines = data.split("\n");
23
+ const start = offset ? Math.max(0, offset - 1) : 0;
24
+ if (offset || limit) lines = lines.slice(start, limit ? start + limit : undefined);
25
+ const width = String(start + lines.length).length;
26
+ return clip(
27
+ lines.map((l, idx) => String(start + idx + 1).padStart(width) + " " + l).join("\n"),
28
+ );
29
+ },
30
+
31
+ async write_file({ path: p, content }) {
32
+ await fs.mkdir(path.dirname(abs(p)), { recursive: true });
33
+ await fs.writeFile(abs(p), content ?? "", "utf8");
34
+ const n = (content ?? "").split("\n").length;
35
+ return `Wrote ${n} line(s) to ${rel(abs(p))}`;
36
+ },
37
+
38
+ async edit_file({ path: p, old_string, new_string, replace_all }) {
39
+ const file = abs(p);
40
+ const data = await fs.readFile(file, "utf8");
41
+ if (old_string === new_string) throw new Error("old_string and new_string are identical");
42
+ const count = data.split(old_string).length - 1;
43
+ if (count === 0) throw new Error("old_string not found in file");
44
+ if (count > 1 && !replace_all)
45
+ throw new Error(`old_string is not unique (${count} matches); set replace_all or add context`);
46
+ const next = replace_all ? data.split(old_string).join(new_string) : data.replace(old_string, new_string);
47
+ await fs.writeFile(file, next, "utf8");
48
+ return `Edited ${rel(file)} (${replace_all ? count : 1} replacement(s))`;
49
+ },
50
+
51
+ async list_dir({ path: p = "." }) {
52
+ const dir = abs(p);
53
+ const entries = await fs.readdir(dir, { withFileTypes: true });
54
+ const rows = entries
55
+ .filter((e) => !e.name.startsWith(".git") && e.name !== "node_modules")
56
+ .sort((a, b) => Number(b.isDirectory()) - Number(a.isDirectory()) || a.name.localeCompare(b.name))
57
+ .map((e) => (e.isDirectory() ? e.name + "/" : e.name));
58
+ return clip(`${rel(dir)}/ (${rows.length} entries)\n` + rows.map((r) => " " + r).join("\n"));
59
+ },
60
+
61
+ async glob({ pattern }) {
62
+ const hits = [];
63
+ const rx = globToRegExp(pattern);
64
+ (function walk(dir) {
65
+ let ents;
66
+ try {
67
+ ents = fssync.readdirSync(dir, { withFileTypes: true });
68
+ } catch {
69
+ return;
70
+ }
71
+ for (const e of ents) {
72
+ if (e.name === "node_modules" || e.name.startsWith(".git")) continue;
73
+ const full = path.join(dir, e.name);
74
+ if (e.isDirectory()) walk(full);
75
+ else if (rx.test(rel(full).split(path.sep).join("/"))) hits.push(rel(full));
76
+ if (hits.length > 500) return;
77
+ }
78
+ })(cwd());
79
+ return hits.length ? clip(hits.join("\n")) : "No files matched.";
80
+ },
81
+
82
+ async grep({ pattern, path: p = ".", glob: g }) {
83
+ const rx = new RegExp(pattern, "i");
84
+ const gRx = g ? globToRegExp(g) : null;
85
+ const out = [];
86
+ (function walk(dir) {
87
+ let ents;
88
+ try {
89
+ ents = fssync.readdirSync(dir, { withFileTypes: true });
90
+ } catch {
91
+ return;
92
+ }
93
+ for (const e of ents) {
94
+ if (e.name === "node_modules" || e.name.startsWith(".git")) continue;
95
+ const full = path.join(dir, e.name);
96
+ if (e.isDirectory()) {
97
+ walk(full);
98
+ continue;
99
+ }
100
+ const relp = rel(full).split(path.sep).join("/");
101
+ if (gRx && !gRx.test(relp)) continue;
102
+ let txt;
103
+ try {
104
+ txt = fssync.readFileSync(full, "utf8");
105
+ } catch {
106
+ continue;
107
+ }
108
+ if (txt.includes("\u0000")) continue; // skip binary files
109
+ txt.split("\n").forEach((l, idx) => {
110
+ if (rx.test(l) && out.length < 200) out.push(`${relp}:${idx + 1}: ${l.trim().slice(0, 200)}`);
111
+ });
112
+ }
113
+ })(abs(p));
114
+ return out.length ? clip(out.join("\n")) : "No matches.";
115
+ },
116
+
117
+ run_command({ command, timeout = 60000 }) {
118
+ return new Promise((resolve) => {
119
+ const isWin = process.platform === "win32";
120
+ const shell = isWin ? "powershell.exe" : "/bin/bash";
121
+ const args = isWin ? ["-NoProfile", "-NonInteractive", "-Command", command] : ["-c", command];
122
+ const child = spawn(shell, args, { cwd: cwd() });
123
+ let out = "";
124
+ const killer = setTimeout(() => child.kill(), timeout);
125
+ child.stdout.on("data", (d) => (out += d));
126
+ child.stderr.on("data", (d) => (out += d));
127
+ child.on("error", (e) => {
128
+ clearTimeout(killer);
129
+ resolve(`Failed to start command: ${e.message}`);
130
+ });
131
+ child.on("close", (code) => {
132
+ clearTimeout(killer);
133
+ const tail = `\n[exit code ${code}]`;
134
+ resolve(clip((out.trim() || "(no output)") + tail));
135
+ });
136
+ });
137
+ },
138
+ };
139
+
140
+ function globToRegExp(glob) {
141
+ let rx = "";
142
+ for (let i = 0; i < glob.length; i++) {
143
+ const ch = glob[i];
144
+ if (ch === "*") {
145
+ if (glob[i + 1] === "*") {
146
+ rx += ".*";
147
+ i++;
148
+ if (glob[i + 1] === "/") i++;
149
+ } else rx += "[^/]*";
150
+ } else if (ch === "?") rx += "[^/]";
151
+ else if (".+^${}()|[]\\".includes(ch)) rx += "\\" + ch;
152
+ else rx += ch;
153
+ }
154
+ return new RegExp("^" + rx + "$");
155
+ }
156
+
157
+ // One-line human preview for the permission prompt / activity log.
158
+ export function describe(name, input) {
159
+ switch (name) {
160
+ case "read_file":
161
+ return `read ${input.path}`;
162
+ case "write_file":
163
+ return `write ${input.path} (${(input.content ?? "").split("\n").length} lines)`;
164
+ case "edit_file":
165
+ return `edit ${input.path}`;
166
+ case "list_dir":
167
+ return `ls ${input.path || "."}`;
168
+ case "glob":
169
+ return `glob ${input.pattern}`;
170
+ case "grep":
171
+ return `grep "${input.pattern}"${input.path ? " in " + input.path : ""}`;
172
+ case "run_command":
173
+ return `$ ${input.command}`;
174
+ default:
175
+ return name;
176
+ }
177
+ }
178
+
179
+ export async function runTool(name, input) {
180
+ const fn = TOOLS[name];
181
+ if (!fn) throw new Error(`Unknown tool: ${name}`);
182
+ return await fn(input || {});
183
+ }