@noobdemon/noob-cli 1.7.0 → 1.7.2

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/noob.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { startRepl } from "../src/repl.js";
3
3
  import { config } from "../src/config.js";
4
- import { usage, ApiError } from "../src/api.js";
4
+ import { usage, ApiError, applyInsecureTLS } from "../src/api.js";
5
5
  import { c } from "../src/ui.js";
6
6
  import { t } from "../src/i18n.js";
7
7
  import { checkLatest, runUpdate, CURRENT } from "../src/update.js";
@@ -27,6 +27,10 @@ for (let i = 0; i < argv.length; i++) {
27
27
 
28
28
  const sub = positional[0];
29
29
 
30
+ // Áp dụng --insecure-tls SAU khi parse argv (ESM import hoist khiến top-level
31
+ // check trong api.js chạy trước khi flag được set).
32
+ applyInsecureTLS();
33
+
30
34
  // ── subcommands ──────────────────────────────────────────────────────────
31
35
  if (sub === "login") {
32
36
  const key = positional[1];
@@ -38,12 +42,20 @@ if (sub === "login") {
38
42
  console.log(c.ok("✓ ") + t.loginSaved(config.path));
39
43
  try {
40
44
  const u = await usage();
41
- if (u.ok) console.log(c.ok(t.loginOk({ pro: "Pro", proplus: "Pro+", admin: "Admin", trial: "Trial" }[u.plan] || u.plan)));
42
- else console.log(c.err(" " + t.errInvalidKey));
43
- } catch {
44
- /* network — key still saved */
45
+ if (u.ok) {
46
+ console.log(c.ok(t.loginOk({ pro: "Pro", proplus: "Pro+", admin: "Admin", trial: "Trial" }[u.plan] || u.plan)));
47
+ process.exit(0);
48
+ } else {
49
+ // Key đã lưu xuống đĩa nhưng gateway từ chối — exit != 0 để user biết, script CI cũng bắt được.
50
+ console.log(c.err("✗ " + t.errInvalidKey));
51
+ process.exit(2);
52
+ }
53
+ } catch (err) {
54
+ // Mạng/gateway down: giữ key (user có thể đang offline), nhưng KHÔNG im lặng.
55
+ console.log(c.err("⚠ không verify được key (mạng/gateway): " + (err?.message || err)));
56
+ console.log(c.dim(" key vẫn được lưu — chạy `noob usage` khi có mạng để xác nhận."));
57
+ process.exit(3);
45
58
  }
46
- process.exit(0);
47
59
  } else if (sub === "logout") {
48
60
  config.clearKey();
49
61
  console.log(c.ok(t.loggedOut));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noobdemon/noob-cli",
3
- "version": "1.7.0",
3
+ "version": "1.7.2",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
package/src/agent.js CHANGED
@@ -17,21 +17,22 @@ To call a tool, emit EXACTLY ONE fenced code block tagged \`tool\` containing a
17
17
 
18
18
  Then STOP and wait — the runtime executes the tool and replies with a TOOL RESULT. Use one tool per step. When the task is complete (or you are only answering a question), reply normally in Markdown with NO tool block.
19
19
 
20
- Available tools:
21
- - read_file {"path": str, "offset"?: int, "limit"?: int} — read a file. The "N " line-number prefix in the output is DISPLAY ONLY — it is NOT part of the file; never copy it into edit_file.
22
- - write_file {"path": str, "content": str} — create/overwrite a file
20
+ Available tools (each is self-contained; pick the SMALLEST tool that answers the question):
21
+ - read_file {"path": str, "offset"?: int, "limit"?: int} — read a file. Default reads whole file. For files you suspect are LARGE (>500 lines), first check size via list_dir/glob, then read with offset+limit (e.g. 200 lines at a time) instead of slurping. The "N " line-number prefix in output is DISPLAY ONLY — never copy it into edit_file.
22
+ - write_file {"path": str, "content": str} — create/overwrite a file. Use ONLY for new files or full rewrites; otherwise prefer edit_file.
23
23
  - edit_file {"path": str, "old_string": str, "new_string": str, "replace_all"?: bool} — exact string replace. old_string must match the file's RAW text byte-for-byte (indentation/whitespace included, NO line-number prefix) and be unique unless replace_all. If a replace fails, re-read the file and copy the exact text.
24
- - list_dir {"path"?: str} — list a directory
25
- - glob {"pattern": str} — find files by glob (supports ** and *)
26
- - grep {"pattern": str, "path"?: str, "glob"?: str} — regex search file contents
27
- - run_command {"command": str, "timeout"?: int, "background"?: bool} — run a shell command in the cwd. A foreground command is killed after ~60s (override with "timeout" ms). For anything long-running or that never exits on its own — dev servers, watchers, \`python -m http.server\`, \`npm run dev\`, \`flask run\` — set "background": true: it starts the process, returns immediately, and keeps running WITHOUT blocking the next steps. Never start a server in the foreground (it will hang then be killed).
28
- - bg_output {"id"?: int} — no id: list background processes + status; with id: show that process's captured output so far (poll this after starting a server to confirm it came up)
29
- - kill_bg {"id": int} — stop a background process started with run_command background:true
24
+ - list_dir {"path"?: str} — list a directory. Use to map an unfamiliar project before reading anything.
25
+ - glob {"pattern": str} — find files by glob (supports ** and *). Use when you know the filename pattern but not its location.
26
+ - grep {"pattern": str, "path"?: str, "glob"?: str} — regex search file contents. Use to LOCATE code before reading; cheaper than reading whole files when hunting a symbol/string.
27
+ - run_command {"command": str, "timeout"?: int, "background"?: bool} — run a shell command in the cwd. Foreground commands are killed after ~60s (override with "timeout" ms). For long-running processes — dev servers, watchers, \`python -m http.server\`, \`npm run dev\`, \`flask run\` — set "background": true: starts the process, returns immediately, keeps running WITHOUT blocking next steps. Never start a server in the foreground (it will hang then be killed).
28
+ - bg_output {"id"?: int} — no id: list background processes + status; with id: show that process's captured output so far (poll after starting a server to confirm it came up).
29
+ - kill_bg {"id": int} — stop a background process started with run_command background:true.
30
+
31
+ # Retrieval strategy (just-in-time, not bulk)
32
+ Context is finite. Don't slurp the whole repo up front. Discover information progressively: list_dir/glob to map → grep to locate → read_file (with offset+limit for big files) to inspect only what matters. Each tool result spends your attention budget — make every call earn it. When a tool returns a huge blob, extract the few facts you need, then move on; don't re-read it later (the result stays in history).
30
33
 
31
34
  # Rules
32
- - GROUND TRUTH = the filesystem, NOT your memory of this chat. A file was created/changed ONLY if a write_file/edit_file TOOL RESULT confirms it (see the FILES CHANGED list). Saying "I created/updated X" in prose does NOT change any file you must emit the tool call. If the user says a file is missing or asks its state, read_file/list_dir to check reality first; never claim a file "was reverted" or "should be there" from memory.
33
- - NO FABRICATED VERIFICATION. Never claim a result you did not actually get from a real TOOL RESULT in THIS conversation. Do NOT say "tests pass" / "300/300" / "verified" / "done" / "100%" / "it works" unless a run_command TOOL RESULT above truly shows it. Do NOT reference or quote output that does not appear above — if you have not run the check, say so and RUN it; never narrate a result you only imagine.
34
- - BEFORE any "finished/summary" message, RE-CHECK reality against the FILES CHANGED list: every file you are about to say you created/edited MUST appear there. If it is not there, you did NOT write it — emit the write_file/edit_file (or list_dir/read_file to confirm) NOW instead of claiming completion. When in doubt, list_dir/read_file the workspace and verify before asserting — do not assert from memory.
35
+ - GROUND TRUTH = real TOOL RESULTs in this conversation, not your memory or what you intended to do. A file changed only if a write_file/edit_file result confirms it (see the FILES CHANGED list). A test passed / build succeeded / command worked only if a run_command result above shows it. Never narrate outcomes you didn't observe; if you haven't checked, say so and check now (read_file / list_dir / run the command). Before any "done/summary" reply, reconcile every file and result you're about to claim against the actual tool results above — if it isn't there, you didn't do it yet.
35
36
  - Investigate before editing: read the relevant files first; never invent file contents.
36
37
  - Make the smallest change that fully solves the task. Match the surrounding code style.
37
38
  - Prefer edit_file over write_file for existing files.
package/src/api.js CHANGED
@@ -5,9 +5,19 @@ import { config } from "./config.js";
5
5
 
6
6
  // Opt-in TLS escape hatch for machines behind a TLS-intercepting / broken-
7
7
  // revocation proxy. Off by default. Prefer fixing the trust store.
8
- if (process.env.NOOB_INSECURE_TLS === "1") {
8
+ // LƯU Ý: gọi từ bin/noob.js SAU khi parse argv — vì ESM import được hoist nên
9
+ // nếu chỉ check ở top-level, flag --insecure-tls set sau import sẽ không kịp.
10
+ let _tlsWarned = false;
11
+ export function applyInsecureTLS() {
12
+ if (process.env.NOOB_INSECURE_TLS !== "1") return;
9
13
  process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";
14
+ if (_tlsWarned) return;
15
+ _tlsWarned = true;
16
+ // Cảnh báo rõ ràng: flag này tắt verify TLS TOÀN PROCESS. MITM-able.
17
+ console.warn("\x1b[33m⚠ NOOB_INSECURE_TLS=1: TLS verification DISABLED for this process. MITM-vulnerable. Unset when done.\x1b[0m");
10
18
  }
19
+ // Vẫn áp dụng ngay nếu env có sẵn từ shell (export NOOB_INSECURE_TLS=1).
20
+ applyInsecureTLS();
11
21
 
12
22
  function authHeaders() {
13
23
  const h = { "Content-Type": "application/json" };
package/src/repl.js CHANGED
@@ -383,24 +383,39 @@ Tự đánh giá: còn THIẾU gì để đạt mục tiêu? Chọn 1 nhiệm v
383
383
  state.mode = "chat"; // tự hành chỉ chạy ở chế độ agent
384
384
  state.ultra = true;
385
385
  console.log(c.accent(" 🚀 " + t.ultraOn));
386
- let answer = await handle(ultraStart(goal));
387
- persist();
388
- let i = 0;
389
- // Lượt đầu = lập kế hoạch → KHÔNG xét hoàn thành. Mỗi vòng sau là một lượt
390
- // "tiếp tục" có cổng kiểm chứng; chỉ dừng khi token nằm ở CUỐI câu trả lời.
391
- while (state.ultra && i < MAX_QUESTS) {
392
- if (!answer) break; // lượt bị ngắt/ lỗi → dừng tự hành, đừng quay vô ích
393
- i++;
394
- console.log(c.accent(" ↻ " + t.ultraQuest(i)));
395
- answer = await handle(ultraContinue(goal));
386
+ // Mốc history TRƯỚC khi ULTRA bơm prompt — kết thúc thì cắt về để các lượt sau không bị "dính" mục tiêu cũ.
387
+ const baseLen = state.history.length;
388
+ try {
389
+ let answer = await handle(ultraStart(goal));
396
390
  persist();
397
- if (answer && ultraIsDone(answer)) {
398
- console.log(c.ok(" ✓ " + t.ultraDone));
399
- break;
391
+ let i = 0;
392
+ // Lượt đầu = lập kế hoạch → KHÔNG xét hoàn thành. Mỗi vòng sau là một lượt
393
+ // "tiếp tục" có cổng kiểm chứng; chỉ dừng khi token nằm ở CUỐI câu trả lời.
394
+ while (state.ultra && i < MAX_QUESTS) {
395
+ if (!answer) break; // lượt bị ngắt/ lỗi → dừng tự hành, đừng quay vô ích
396
+ i++;
397
+ console.log(c.accent(" ↻ " + t.ultraQuest(i)));
398
+ answer = await handle(ultraContinue(goal));
399
+ persist();
400
+ if (answer && ultraIsDone(answer)) {
401
+ console.log(c.ok(" ✓ " + t.ultraDone));
402
+ break;
403
+ }
400
404
  }
405
+ if (state.ultra && i >= MAX_QUESTS) console.log(c.tool(" " + t.ultraMax));
406
+ } finally {
407
+ // Dọn dấu vết ULTRA khỏi history (prompt khởi động, các lượt "tiếp tục",
408
+ // token <<ULTRA_DONE>>…) để các yêu cầu SAU đó không bị model coi như vẫn
409
+ // đang tự hành / vẫn theo đuổi mục tiêu cũ.
410
+ state.ultra = false;
411
+ if (state.history.length > baseLen) state.history.length = baseLen;
412
+ state.history.push({
413
+ role: "user",
414
+ content: "[Phiên ULTRA đã KẾT THÚC — mục tiêu cũ: " + goal + ". Bỏ qua mọi chỉ dẫn ULTRA trước đó, KHÔNG tự hành tiếp, KHÔNG phát token " + ULTRA_DONE + ". Chờ yêu cầu mới.]",
415
+ });
416
+ state.history.push({ role: "assistant", content: "OK, đã thoát chế độ ULTRA." });
417
+ persist();
401
418
  }
402
- if (state.ultra && i >= MAX_QUESTS) console.log(c.tool(" " + t.ultraMax));
403
- state.ultra = false;
404
419
  }
405
420
 
406
421
  // /init — quét dự án & sinh noob.md tổng quan (giống `/init` của Claude Code).
@@ -588,7 +603,9 @@ NGUYÊN TẮC:
588
603
  let timer = null;
589
604
  const tick = (label) => {
590
605
  const elapsed = ((Date.now() - t0) / 1000).toFixed(0);
591
- tui.status(c.dim(`${label}… ${elapsed}s`));
606
+ // Chèn token usage realtime (↑input ↓output) ngay cạnh spinner để người
607
+ // dùng thấy được số token đang cộng dồn trong khi model stream.
608
+ tui.status(c.dim(`${label}… ${elapsed}s · ${tokenMeter.format()}`));
592
609
  };
593
610
  const stopSpin = () => {
594
611
  if (timer) {
@@ -613,7 +630,8 @@ NGUYÊN TẮC:
613
630
  message: text,
614
631
  signal: abort.signal,
615
632
  onStatus: (s) => {
616
- if (!printer.started) tui.status(c.dim(" " + s));
633
+ // Kèm token meter để nhánh merge/search cũng thấy ↑input ↓output realtime.
634
+ if (!printer.started) tui.status(c.dim(` ${s} · ${tokenMeter.format()}`));
617
635
  },
618
636
  onDelta: (d) => {
619
637
  stopSpin();
package/src/subagent.js CHANGED
@@ -32,7 +32,13 @@ Ví dụ phân cấp: cha giao "build full app" → đẻ 1 sub-agent "build bac
32
32
  // Chạy một sub-agent. dispatchTool: hàm để thực thi tool con (chia sẻ với cha).
33
33
  // model: dùng chung model của cha. onLog: callback để log tiến độ ra UI cha.
34
34
  export async function runSubAgent({ task, context, model, signal, dispatchTool, depth = 1, onLog, tokenMeter }) {
35
- const sys = `Bạn là SUB-AGENT (depth=${depth}) được agent cha ủy thác MỘT nhiệm vụ cụ thể. Làm xong → trả lời NGẮN GỌN bằng Markdown tóm tắt KẾT QUẢ (file đã đụng, phát hiện, lỗi nếu có). Không tán gẫu. Không hỏi lại cha — tự quyết với thông tin được cấp.
35
+ const sys = `Bạn là SUB-AGENT (depth=${depth}) được agent cha ủy thác MỘT nhiệm vụ cụ thể.
36
+
37
+ # Cách làm việc
38
+ - Tự quyết với thông tin được cấp + tự khám phá filesystem (list_dir/glob/grep/read_file). KHÔNG hỏi lại cha.
39
+ - History của bạn TÁCH BIỆT với cha. Cha CHỈ thấy chuỗi trả lời cuối của bạn → hãy là một bản tóm tắt cô đọng (mục tiêu 1–2k token): mọi file đã đụng, phát hiện then chốt, lỗi/cảnh báo, và các đầu mối cha cần để hành động tiếp. Bỏ chi tiết quá trình thừa.
40
+ - Làm điều nhỏ nhất giải quyết trọn vẹn nhiệm vụ. Không drive-by refactor.
41
+ - Verify khi hợp lý (chạy build/test/lint). Báo trung thực phần đã/chưa verify.
36
42
 
37
43
  # NHIỆM VỤ
38
44
  ${task}
package/src/tokens.js CHANGED
@@ -25,33 +25,64 @@ export function countMessages(messages = []) {
25
25
 
26
26
  // Bộ đếm cộng dồn cho 1 phiên: input (prompt gửi đi) + output (text stream về).
27
27
  // Hỗ trợ cộng dồn theo delta để hiển thị realtime trong lúc stream.
28
+ //
29
+ // Vấn đề cũ: pushOutputDelta encode TOÀN buffer mỗi delta → O(N²) trên stream dài.
30
+ // Fix: sliding window. BPE chỉ phụ thuộc context cục bộ (vài chục byte) nên ta
31
+ // chỉ cần encode đoạn TAIL gần đây, phần trước đó "commit" dứt khoát. Safety
32
+ // window đủ rộng (256 chars) để qua mọi ranh giới token thực tế của cl100k_base
33
+ // (token dài nhất ~ vài chục byte).
34
+ const TAIL_WINDOW = 256;
35
+
28
36
  export class TokenMeter {
29
37
  constructor() {
30
38
  this.input = 0;
31
39
  this.output = 0;
32
- this._outBuf = ""; // gom delta để đếm theo batch (đỡ tốn CPU)
33
- this._outBufN = 0; // số token đã đếm từ _outBuf (để cộng dồn chính xác)
40
+ // Phần đầu output đã "commit" không đụng vào nữa.
41
+ this._committedChars = 0; // số tự đã đẩy qua khỏi tail window
42
+ this._committedTokens = 0; // tổng token tương ứng đã cộng vào this.output
43
+ // Tail buffer: TAIL_WINDOW ký tự cuối, dùng để re-encode khi có delta mới.
44
+ this._tail = "";
45
+ this._tailTokens = 0; // token count hiện tại của _tail (đã cộng vào this.output)
34
46
  }
35
47
  addInput(n) {
36
48
  this.input += Math.max(0, n | 0);
37
49
  }
38
- // Mỗi delta text từ stream: gom vào buffer, định kỳ encode lại để cập nhật
39
- // số token. Encode toàn buffer thay delta riêng lẻ chính xác hơn (BPE
40
- // gộp các byte qua ranh giới delta).
50
+ // Mỗi delta text từ stream: append vào tail, re-encode CHỈ tail (không toàn buffer).
51
+ // Nếu tail vượt 2×TAIL_WINDOW commit nửa đầu, giữ lại nửa sau.
41
52
  pushOutputDelta(text) {
42
53
  if (!text) return;
43
- this._outBuf += text;
44
- // Re-encode toàn bộ buffer hiện tại; cập nhật delta vào this.output.
45
- const total = countTokens(this._outBuf);
46
- if (total > this._outBufN) {
47
- this.output += total - this._outBufN;
48
- this._outBufN = total;
54
+ this._tail += text;
55
+ // Re-encode tail cập nhật phần token của tail.
56
+ const newTailTokens = countTokens(this._tail);
57
+ this.output += newTailTokens - this._tailTokens;
58
+ this._tailTokens = newTailTokens;
59
+ // Nếu tail quá lớn, commit nửa đầu để giữ tail bounded.
60
+ // Cắt tại ranh giới whitespace gần TAIL_WINDOW — BPE align tốt ở đây nên
61
+ // sai số head+tail vs encode liền mạch ≈ 0 (thay vì +1 token mỗi cut).
62
+ if (this._tail.length > TAIL_WINDOW * 2) {
63
+ let cutAt = this._tail.length - TAIL_WINDOW;
64
+ // Lùi cutAt về ranh giới whitespace gần nhất (tối đa 64 chars).
65
+ const minCut = Math.max(0, cutAt - 64);
66
+ while (cutAt > minCut && !/\s/.test(this._tail[cutAt])) cutAt--;
67
+ const head = this._tail.slice(0, cutAt);
68
+ const newTail = this._tail.slice(cutAt);
69
+ const headTokens = countTokens(head);
70
+ const newTailTokensAfterCut = countTokens(newTail);
71
+ // Điều chỉnh this.output để giữ tổng nhất quán với re-encode rời.
72
+ const delta = headTokens + newTailTokensAfterCut - this._tailTokens;
73
+ this.output += delta;
74
+ this._committedChars += head.length;
75
+ this._committedTokens += headTokens;
76
+ this._tail = newTail;
77
+ this._tailTokens = newTailTokensAfterCut;
49
78
  }
50
79
  }
51
80
  // Kết thúc một lượt output → reset buffer (bắt đầu lượt mới).
52
81
  endOutput() {
53
- this._outBuf = "";
54
- this._outBufN = 0;
82
+ this._committedChars = 0;
83
+ this._committedTokens = 0;
84
+ this._tail = "";
85
+ this._tailTokens = 0;
55
86
  }
56
87
  get total() {
57
88
  return this.input + this.output;
@@ -64,7 +95,9 @@ export class TokenMeter {
64
95
  reset() {
65
96
  this.input = 0;
66
97
  this.output = 0;
67
- this._outBuf = "";
68
- this._outBufN = 0;
98
+ this._committedChars = 0;
99
+ this._committedTokens = 0;
100
+ this._tail = "";
101
+ this._tailTokens = 0;
69
102
  }
70
103
  }
package/src/tools.js CHANGED
@@ -29,8 +29,16 @@ function killBgTree(child) {
29
29
  } catch {}
30
30
  }
31
31
  // Đừng để tiến trình nền sống mồ côi sau khi CLI thoát.
32
- process.on("exit", () => {
32
+ // - 'exit' bắt được normal exit + process.exit() (gồm cả nhánh Ctrl+C lần 2 ở repl).
33
+ // - SIGTERM mặc định KHÔNG trigger 'exit' → process chết im, bg leak. Bắt riêng.
34
+ // - SIGINT đã có handler ở repl.js, không đụng vào để không phá UX "Ctrl+C lần 1 = abort".
35
+ function cleanupBg() {
33
36
  for (const p of bg.values()) killBgTree(p.child);
37
+ }
38
+ process.on("exit", cleanupBg);
39
+ process.on("SIGTERM", () => {
40
+ cleanupBg();
41
+ process.exit(143);
34
42
  });
35
43
 
36
44
  export const TOOLS = {
@@ -74,7 +82,9 @@ export const TOOLS = {
74
82
  if (count === 0) continue;
75
83
  if (count > 1 && !replace_all)
76
84
  throw new Error(`old_string is not unique (${count} matches) in ${rel(file)}; set replace_all, or add surrounding lines to make it unique`);
77
- await fs.writeFile(file, applyExact(cand, cand === old_string ? new_string : adapt(new_string)), "utf8");
85
+ // LUÔN adapt new_string về line ending của file (kể cả khi cand match raw old_string),
86
+ // tránh tạo file mix CRLF/LF làm git/editor hiển thị diff lạ → user tưởng 'Edited nhưng không apply'.
87
+ await fs.writeFile(file, applyExact(cand, adapt(new_string)), "utf8");
78
88
  return `Edited ${rel(file)} (${replace_all ? count : 1} replacement(s))`;
79
89
  }
80
90
 
@@ -131,34 +141,48 @@ export const TOOLS = {
131
141
  const rx = new RegExp(pattern, "i");
132
142
  const gRx = g ? globToRegExp(g) : null;
133
143
  const out = [];
134
- (function walk(dir) {
135
- let ents;
144
+ function scanFile(full) {
145
+ const relp = rel(full).split(path.sep).join("/");
146
+ if (gRx && !gRx.test(relp)) return;
147
+ let txt;
136
148
  try {
137
- ents = fssync.readdirSync(dir, { withFileTypes: true });
149
+ txt = fssync.readFileSync(full, "utf8");
138
150
  } catch {
139
151
  return;
140
152
  }
141
- for (const e of ents) {
142
- if (e.name === "node_modules" || e.name.startsWith(".git")) continue;
143
- const full = path.join(dir, e.name);
144
- if (e.isDirectory()) {
145
- walk(full);
146
- continue;
147
- }
148
- const relp = rel(full).split(path.sep).join("/");
149
- if (gRx && !gRx.test(relp)) continue;
150
- let txt;
153
+ if (txt.includes("\u0000")) return; // skip binary files
154
+ txt.split("\n").forEach((l, idx) => {
155
+ if (rx.test(l) && out.length < 200) out.push(`${relp}:${idx + 1}: ${l.trim().slice(0, 200)}`);
156
+ });
157
+ }
158
+ // path có thể là FILE hoặc DIR — stat trước để không nuốt câm khi user trỏ thẳng vào file.
159
+ let st;
160
+ try {
161
+ st = fssync.statSync(abs(p));
162
+ } catch {
163
+ return "No matches.";
164
+ }
165
+ if (st.isFile()) {
166
+ scanFile(abs(p));
167
+ } else {
168
+ (function walk(dir) {
169
+ let ents;
151
170
  try {
152
- txt = fssync.readFileSync(full, "utf8");
171
+ ents = fssync.readdirSync(dir, { withFileTypes: true });
153
172
  } catch {
154
- continue;
173
+ return;
155
174
  }
156
- if (txt.includes("\u0000")) continue; // skip binary files
157
- txt.split("\n").forEach((l, idx) => {
158
- if (rx.test(l) && out.length < 200) out.push(`${relp}:${idx + 1}: ${l.trim().slice(0, 200)}`);
159
- });
160
- }
161
- })(abs(p));
175
+ for (const e of ents) {
176
+ if (e.name === "node_modules" || e.name.startsWith(".git")) continue;
177
+ const full = path.join(dir, e.name);
178
+ if (e.isDirectory()) {
179
+ walk(full);
180
+ continue;
181
+ }
182
+ scanFile(full);
183
+ }
184
+ })(abs(p));
185
+ }
162
186
  return out.length ? clip(out.join("\n")) : "No matches.";
163
187
  },
164
188