@noobdemon/noob-cli 1.7.7 → 1.7.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/src/tools.js CHANGED
@@ -98,8 +98,15 @@ process.on("SIGTERM", () => {
98
98
  process.exit(143);
99
99
  });
100
100
 
101
+ // Helper: throw nếu signal đã abort. Dùng ở đầu mỗi tool + giữa các vòng walk dài
102
+ // để tool fs (glob/grep) cũng phản ứng với Ctrl+C, không chỉ run_command.
103
+ function checkAbort(signal) {
104
+ if (signal?.aborted) throw new Error("aborted");
105
+ }
106
+
101
107
  export const TOOLS = {
102
- async read_file({ path: p, offset, limit }) {
108
+ async read_file({ path: p, offset, limit }, { signal } = {}) {
109
+ checkAbort(signal);
103
110
  const data = await fs.readFile(abs(p), "utf8");
104
111
  let lines = data.split("\n");
105
112
  const start = offset ? Math.max(0, offset - 1) : 0;
@@ -110,14 +117,16 @@ export const TOOLS = {
110
117
  );
111
118
  },
112
119
 
113
- async write_file({ path: p, content }) {
120
+ async write_file({ path: p, content }, { signal } = {}) {
121
+ checkAbort(signal);
114
122
  await fs.mkdir(path.dirname(abs(p)), { recursive: true });
115
123
  await fs.writeFile(abs(p), content ?? "", "utf8");
116
124
  const n = (content ?? "").split("\n").length;
117
125
  return `Wrote ${n} line(s) to ${rel(abs(p))}`;
118
126
  },
119
127
 
120
- async edit_file({ path: p, old_string, new_string, replace_all }) {
128
+ async edit_file({ path: p, old_string, new_string, replace_all }, { signal } = {}) {
129
+ checkAbort(signal);
121
130
  const file = abs(p);
122
131
  const data = await fs.readFile(file, "utf8");
123
132
  if (old_string === new_string) throw new Error("old_string and new_string are identical");
@@ -163,7 +172,8 @@ export const TOOLS = {
163
172
  );
164
173
  },
165
174
 
166
- async list_dir({ path: p = "." }) {
175
+ async list_dir({ path: p = "." }, { signal } = {}) {
176
+ checkAbort(signal);
167
177
  const dir = abs(p);
168
178
  const entries = await fs.readdir(dir, { withFileTypes: true });
169
179
  const rows = entries
@@ -173,10 +183,13 @@ export const TOOLS = {
173
183
  return clip(`${rel(dir)}/ (${rows.length} entries)\n` + rows.map((r) => " " + r).join("\n"));
174
184
  },
175
185
 
176
- async glob({ pattern }) {
186
+ async glob({ pattern }, { signal } = {}) {
187
+ checkAbort(signal);
177
188
  const hits = [];
178
189
  const rx = globToRegExp(pattern);
179
190
  const roots = listRoots();
191
+ // Check signal mỗi ~256 entries thay vì mỗi entry — đỡ overhead trên repo lớn.
192
+ let tickCounter = 0;
180
193
  for (const root of roots) {
181
194
  (function walk(dir) {
182
195
  let ents;
@@ -186,6 +199,7 @@ export const TOOLS = {
186
199
  return;
187
200
  }
188
201
  for (const e of ents) {
202
+ if ((++tickCounter & 0xff) === 0) checkAbort(signal);
189
203
  if (SKIP_DIRS.has(e.name) || e.name.startsWith(".git")) continue;
190
204
  const full = path.join(dir, e.name);
191
205
  if (e.isDirectory()) walk(full);
@@ -198,11 +212,14 @@ export const TOOLS = {
198
212
  return hits.length ? clip(hits.join("\n")) : "No files matched.";
199
213
  },
200
214
 
201
- async grep({ pattern, path: p, glob: g }) {
215
+ async grep({ pattern, path: p, glob: g }, { signal } = {}) {
216
+ checkAbort(signal);
202
217
  const rx = new RegExp(pattern, "i");
203
218
  const gRx = g ? globToRegExp(g) : null;
204
219
  const out = [];
220
+ let tickCounter = 0;
205
221
  function scanFile(full) {
222
+ if ((++tickCounter & 0xff) === 0) checkAbort(signal);
206
223
  const disp = displayPath(full);
207
224
  const relp = disp.split(path.sep).join("/");
208
225
  if (gRx && !gRx.test(relp)) return;
@@ -250,7 +267,7 @@ export const TOOLS = {
250
267
  return out.length ? clip(out.join("\n")) : "No matches.";
251
268
  },
252
269
 
253
- run_command({ command, timeout = 60000, background = false }) {
270
+ run_command({ command, timeout = 60000, background = false }, { signal } = {}) {
254
271
  const isWin = process.platform === "win32";
255
272
  const shell = isWin ? "powershell.exe" : "/bin/bash";
256
273
  const args = isWin ? ["-NoProfile", "-NonInteractive", "-Command", command] : ["-c", command];
@@ -289,19 +306,34 @@ export const TOOLS = {
289
306
  const child = spawn(shell, args, { cwd: cwd(), stdio: ["ignore", "pipe", "pipe"] });
290
307
  let out = "";
291
308
  let timedOut = false;
309
+ let aborted = false;
292
310
  const killer = setTimeout(() => {
293
311
  timedOut = true;
294
- child.kill();
312
+ killBgTree(child);
295
313
  }, timeout);
314
+ // Ctrl+C trong lúc command đang chạy → kill cây tiến trình con (Windows
315
+ // dùng taskkill /T để diệt cả grand-children, vd npm spawn node).
316
+ const onAbort = () => {
317
+ aborted = true;
318
+ killBgTree(child);
319
+ };
320
+ if (signal) {
321
+ if (signal.aborted) onAbort();
322
+ else signal.addEventListener("abort", onAbort, { once: true });
323
+ }
296
324
  child.stdout.on("data", (d) => (out += d));
297
325
  child.stderr.on("data", (d) => (out += d));
298
326
  child.on("error", (e) => {
299
327
  clearTimeout(killer);
328
+ signal?.removeEventListener?.("abort", onAbort);
300
329
  resolve(`Failed to start command: ${e.message}`);
301
330
  });
302
331
  child.on("close", (code) => {
303
332
  clearTimeout(killer);
304
- const tail = timedOut
333
+ signal?.removeEventListener?.("abort", onAbort);
334
+ const tail = aborted
335
+ ? `\n[aborted by user (Ctrl+C) — killed.]`
336
+ : timedOut
305
337
  ? `\n[timed out after ${Math.round(timeout / 1000)}s — killed. If this is a server or other long-running task, re-run with {"background": true} instead.]`
306
338
  : `\n[exit code ${code}]`;
307
339
  resolve(clip((out.trim() || "(no output)") + tail));
@@ -445,8 +477,11 @@ export function describe(name, input) {
445
477
  }
446
478
  }
447
479
 
448
- export async function runTool(name, input) {
480
+ export async function runTool(name, input, opts = {}) {
449
481
  const fn = TOOLS[name];
450
482
  if (!fn) throw new Error(`Unknown tool: ${name}`);
451
- return await fn(input || {});
483
+ const { signal } = opts;
484
+ // Pre-check: nếu user đã Ctrl+C trước khi tool kịp chạy, bail ngay.
485
+ if (signal?.aborted) throw new Error("aborted");
486
+ return await fn(input || {}, { signal });
452
487
  }
@@ -0,0 +1,142 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import os from "node:os";
4
+
5
+ // CRUD workflow đã lưu. Cảm hứng từ tweet_dump.txt L183–193 ("saving and sharing
6
+ // dynamic workflows"): user nhấn 's' để snapshot prompt template ra file rồi tái
7
+ // dùng. Map sang noob: lưu Markdown ở ~/.noob/workflows/<name>.md, format có
8
+ // front-matter YAML-lite + body là prompt template.
9
+
10
+ const DIR = path.join(os.homedir(), ".noob", "workflows");
11
+
12
+ function ensureDir() {
13
+ try { fs.mkdirSync(DIR, { recursive: true }); } catch {}
14
+ }
15
+
16
+ // Tên file an toàn — chỉ cho phép [a-z0-9-_], chống path traversal.
17
+ function sanitizeName(name) {
18
+ if (!name || typeof name !== "string") return null;
19
+ const trimmed = name.trim().toLowerCase();
20
+ if (!/^[a-z0-9][a-z0-9_-]{0,63}$/.test(trimmed)) return null;
21
+ return trimmed;
22
+ }
23
+
24
+ function filePath(name) {
25
+ const safe = sanitizeName(name);
26
+ if (!safe) return null;
27
+ return path.join(DIR, safe + ".md");
28
+ }
29
+
30
+ // Parse front-matter cực tối giản: --- ... --- ở đầu file, key: value mỗi dòng.
31
+ function parseFile(raw) {
32
+ const meta = {};
33
+ let body = raw;
34
+ const m = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/);
35
+ if (m) {
36
+ for (const line of m[1].split(/\r?\n/)) {
37
+ const kv = line.match(/^([a-zA-Z][a-zA-Z0-9_-]*):\s*(.*)$/);
38
+ if (kv) meta[kv[1]] = kv[2].trim();
39
+ }
40
+ body = m[2];
41
+ }
42
+ return { meta, body };
43
+ }
44
+
45
+ function serialize(meta, body) {
46
+ const lines = ["---"];
47
+ for (const [k, v] of Object.entries(meta)) {
48
+ lines.push(`${k}: ${String(v).replace(/\r?\n/g, " ")}`);
49
+ }
50
+ lines.push("---");
51
+ lines.push("");
52
+ lines.push(body);
53
+ return lines.join("\n");
54
+ }
55
+
56
+ // Lưu workflow. Trả về { ok, path?, error? }.
57
+ export function saveWorkflow(name, prompt, opts = {}) {
58
+ const safe = sanitizeName(name);
59
+ if (!safe) return { ok: false, error: "invalid_name" };
60
+ if (!prompt || typeof prompt !== "string" || !prompt.trim()) {
61
+ return { ok: false, error: "empty_prompt" };
62
+ }
63
+ ensureDir();
64
+ const fp = path.join(DIR, safe + ".md");
65
+ const meta = {
66
+ name: safe,
67
+ created: opts.created || new Date().toISOString(),
68
+ updated: new Date().toISOString(),
69
+ };
70
+ if (opts.description) meta.description = opts.description;
71
+ try {
72
+ fs.writeFileSync(fp, serialize(meta, prompt.trim()), "utf8");
73
+ return { ok: true, path: fp };
74
+ } catch (e) {
75
+ return { ok: false, error: e.message };
76
+ }
77
+ }
78
+
79
+ // Đọc workflow. Trả về { ok, name, prompt, meta, path } hoặc { ok: false }.
80
+ export function loadWorkflow(name) {
81
+ const fp = filePath(name);
82
+ if (!fp) return { ok: false, error: "invalid_name" };
83
+ if (!fs.existsSync(fp)) return { ok: false, error: "not_found" };
84
+ try {
85
+ const raw = fs.readFileSync(fp, "utf8");
86
+ const { meta, body } = parseFile(raw);
87
+ return { ok: true, name: sanitizeName(name), prompt: body.trim(), meta, path: fp };
88
+ } catch (e) {
89
+ return { ok: false, error: e.message };
90
+ }
91
+ }
92
+
93
+ // Liệt kê tất cả workflow đã lưu. Trả về mảng { name, description?, updated? }.
94
+ export function listWorkflows() {
95
+ ensureDir();
96
+ let entries;
97
+ try {
98
+ entries = fs.readdirSync(DIR);
99
+ } catch {
100
+ return [];
101
+ }
102
+ const out = [];
103
+ for (const f of entries) {
104
+ if (!f.endsWith(".md")) continue;
105
+ const name = f.slice(0, -3);
106
+ if (!sanitizeName(name)) continue;
107
+ try {
108
+ const raw = fs.readFileSync(path.join(DIR, f), "utf8");
109
+ const { meta } = parseFile(raw);
110
+ out.push({
111
+ name,
112
+ description: meta.description || "",
113
+ updated: meta.updated || "",
114
+ });
115
+ } catch {
116
+ out.push({ name, description: "", updated: "" });
117
+ }
118
+ }
119
+ // Sort theo updated desc (mới nhất lên đầu), fallback alphabet.
120
+ out.sort((a, b) => {
121
+ if (a.updated && b.updated) return b.updated.localeCompare(a.updated);
122
+ return a.name.localeCompare(b.name);
123
+ });
124
+ return out;
125
+ }
126
+
127
+ // Xoá workflow. Trả về { ok, error? }.
128
+ export function deleteWorkflow(name) {
129
+ const fp = filePath(name);
130
+ if (!fp) return { ok: false, error: "invalid_name" };
131
+ if (!fs.existsSync(fp)) return { ok: false, error: "not_found" };
132
+ try {
133
+ fs.unlinkSync(fp);
134
+ return { ok: true };
135
+ } catch (e) {
136
+ return { ok: false, error: e.message };
137
+ }
138
+ }
139
+
140
+ export function workflowsDir() {
141
+ return DIR;
142
+ }