@noobdemon/noob-cli 1.0.4 → 1.0.6

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
@@ -7,7 +7,7 @@ import { t } from "../src/i18n.js";
7
7
  import { checkLatest, runUpdate, CURRENT } from "../src/update.js";
8
8
 
9
9
  const argv = process.argv.slice(2);
10
- const opts = { yolo: false, model: undefined, prompt: undefined };
10
+ const opts = { yolo: false, model: undefined, prompt: undefined, continue: false, resume: false };
11
11
  const positional = [];
12
12
 
13
13
  for (let i = 0; i < argv.length; i++) {
@@ -15,6 +15,9 @@ for (let i = 0; i < argv.length; i++) {
15
15
  if (a === "--yolo" || a === "-y") opts.yolo = true;
16
16
  else if (a === "--insecure-tls") process.env.NOOB_INSECURE_TLS = "1";
17
17
  else if (a === "--model" || a === "-m") opts.model = argv[++i];
18
+ else if (a === "--continue" || a === "-c") opts.continue = true;
19
+ else if (a === "--resume" || a === "-r") opts.resume = true;
20
+ else if (a.startsWith("--resume=")) opts.resume = a.slice("--resume=".length);
18
21
  else if (a === "--help" || a === "-h") {
19
22
  printHelp();
20
23
  process.exit(0);
@@ -90,6 +93,8 @@ Cách dùng:
90
93
  Tuỳ chọn:
91
94
  -m, --model <id> chọn mô hình (vd: gateway-claude-opus-4-7)
92
95
  -y, --yolo tự động duyệt sửa file & chạy lệnh (cẩn thận)
96
+ -c, --continue tiếp tục phiên gần nhất (resume)
97
+ -r, --resume[=id] chọn phiên để tiếp tục (không id = hiện danh sách)
93
98
  --insecure-tls tắt kiểm tra TLS (chỉ cho mạng có proxy chặn TLS)
94
99
  -h, --help hiện trợ giúp
95
100
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noobdemon/noob-cli",
3
- "version": "1.0.4",
3
+ "version": "1.0.6",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
package/src/i18n.js CHANGED
@@ -51,7 +51,9 @@ export const t = {
51
51
  cmdSearch: "/search bật/tắt chế độ tìm web",
52
52
  cmdChat: "/chat quay lại chế độ chat thường",
53
53
  cmdYolo: "/yolo bật/tắt tự duyệt (hoặc nhấn Shift+Tab)",
54
- cmdClear: "/clear /new xoá ngữ cảnh hội thoại",
54
+ cmdClear: "/clear /new xoá ngữ cảnh (mở phiên mới, phiên cũ vẫn resume được)",
55
+ cmdResume: "/resume [id] tiếp tục phiên cũ (không id = chọn từ danh sách)",
56
+ cmdSessions: "/sessions liệt kê các phiên đã lưu",
55
57
  cmdLogin: "/login <key> đăng nhập bằng API key",
56
58
  cmdLogout: "/logout đăng xuất",
57
59
  cmdUsage: "/usage xem hạn mức key còn lại",
@@ -81,6 +83,17 @@ export const t = {
81
83
  maxSteps: "_(đã dừng: chạm giới hạn số bước tool)_",
82
84
  toolDenied: "Người dùng từ chối thao tác này. Hãy đổi cách làm hoặc hỏi lại.",
83
85
 
86
+ // sessions (lưu lịch sử + resume)
87
+ sessionResumed: (id) => `Đã khôi phục phiên ${id}`,
88
+ sessionNonePrev: "Chưa có phiên nào trước đó — bắt đầu phiên mới.",
89
+ sessionNotFound: (id) => `Không tìm thấy phiên "${id}".`,
90
+ sessionEmpty: "Chưa có phiên đã lưu nào.",
91
+ sessionPickTitle: "Chọn phiên để tiếp tục:",
92
+ sessionListTitle: "Các phiên đã lưu:",
93
+ sessionPickPrompt: (n) => `chọn phiên [1-${n}], Enter để bỏ qua › `,
94
+ sessionPickBad: "lựa chọn không hợp lệ.",
95
+ sessionResumeHint: "/resume <id> để tiếp tục một phiên · hoặc chạy: noob -c (phiên gần nhất)",
96
+
84
97
  // update
85
98
  cmdUpdate: "/update cập nhật noob lên bản mới nhất",
86
99
  updateFound: (cur, lat) => `🆕 Có bản mới ${lat} (đang dùng ${cur}) — đang tự cập nhật nền…`,
package/src/repl.js CHANGED
@@ -10,6 +10,7 @@ import { c, banner, modelBadge, renderMarkdown, box } from "./ui.js";
10
10
  import { config } from "./config.js";
11
11
  import { t } from "./i18n.js";
12
12
  import { checkLatest, runUpdate, CURRENT } from "./update.js";
13
+ import * as sessions from "./sessions.js";
13
14
 
14
15
  export async function startRepl(opts = {}) {
15
16
  const state = {
@@ -20,53 +21,101 @@ export async function startRepl(opts = {}) {
20
21
  yolo: !!opts.yolo,
21
22
  };
22
23
 
23
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout, prompt: "" });
24
- let closed = false;
25
- const queue = []; // lines typed/piped, consumed in order
26
- let waiter = null; // resolver awaiting the next line
24
+ // Prompt = dòng trạng thái sống. Luôn phản ánh yolo + version theo thời gian
25
+ // thực (vẽ lại mỗi lượt và ngay khi Shift+Tab), nên không cần gõ /status.
26
+ const promptStr = (lead = true) => {
27
+ const nl = lead ? "\n" : "";
28
+ const yolo = state.yolo ? c.err("⚡ yolo ") : "";
29
+ return c.user(nl + t.promptYou) + yolo + c.dim("v" + CURRENT + " › ");
30
+ };
27
31
 
28
- rl.on("line", (line) => {
32
+ // ── Input layer — KHÔNG ĐƯỢC tự tắt ───────────────────────────────────────
33
+ // Trên Windows CMD, readline có thể phát sự kiện 'close' KHÔNG phải vì người
34
+ // dùng muốn thoát (console/keypress tranh chấp, hoặc một tiến trình con chạm
35
+ // vào console). Nếu để 'close' đó kết thúc vòng lặp → CLI "tự động tắt" giữa
36
+ // chừng (thường ngay sau khi hỏi quyền). Hơn nữa, một khi readline đã đóng,
37
+ // không còn handle nào giữ stdin → event loop cạn → Node thoát code 0.
38
+ //
39
+ // Khắc phục: trên TTY ta DỰNG LẠI readline khi gặp 'close' bất thường và giữ
40
+ // nguyên reader đang chờ (người dùng chỉ thấy prompt hiện lại). Chỉ dừng thật
41
+ // khi: /exit, Ctrl+C hai lần, hoặc EOF thật (stdin piped đã cạn / 'close' dồn
42
+ // dập nghĩa là stdin đã mất).
43
+ let rl;
44
+ let closed = false; // đã ngừng đọc vĩnh viễn
45
+ let exiting = false; // ta chủ động thoát (/exit, Ctrl+C ×2)
46
+ let lastPrompt = ""; // áp lại sau khi dựng lại interface
47
+ const queue = []; // lines đã gõ/pipe, dùng theo thứ tự
48
+ let waiter = null; // resolver đang chờ dòng kế tiếp
49
+ let closeBurst = 0;
50
+ let closeAt = 0;
51
+
52
+ function deliver(line) {
29
53
  if (waiter) {
30
54
  const w = waiter;
31
55
  waiter = null;
32
56
  w(line);
33
57
  } else {
34
- queue.push(line); // type-ahead / buffered input never dropped
58
+ queue.push(line); // type-ahead / buffered — không bao giờ mất
35
59
  }
36
- });
37
- rl.on("close", () => {
60
+ }
61
+ function endInput() {
38
62
  closed = true;
39
63
  if (waiter) {
40
64
  const w = waiter;
41
65
  waiter = null;
42
66
  w(null);
43
67
  }
44
- });
68
+ }
69
+ function buildRl() {
70
+ const r = readline.createInterface({ input: process.stdin, output: process.stdout, prompt: "" });
71
+ r.on("line", deliver);
72
+ r.on("close", () => {
73
+ if (exiting || !process.stdin.isTTY) return endInput(); // thoát thật
74
+ const now = Date.now();
75
+ if (now - closeAt > 1500) {
76
+ closeAt = now;
77
+ closeBurst = 0;
78
+ }
79
+ if (++closeBurst > 8) return endInput(); // stdin mất thật → khỏi quay vòng
80
+ // 'close' bất thường: dựng lại interface, hiện lại prompt; reader vẫn chờ.
81
+ rl = buildRl();
82
+ if (lastPrompt) {
83
+ rl.setPrompt(lastPrompt);
84
+ rl.prompt();
85
+ }
86
+ });
87
+ return r;
88
+ }
89
+ rl = buildRl();
45
90
 
46
- // Robust input: every line is captured by the 'line' event (nothing is lost
47
- // while a turn is processing) and handed out one at a time. Works for an
48
- // interactive TTY and for piped / non-TTY stdin (Git Bash, CI, etc.).
49
91
  function nextLine() {
50
92
  if (queue.length) return Promise.resolve(queue.shift());
51
93
  if (closed) return Promise.resolve(null);
52
94
  return new Promise((res) => (waiter = res));
53
95
  }
54
- function ask(promptStr) {
96
+ function ask(prompt) {
55
97
  if (closed && !queue.length) return Promise.resolve(null);
56
- rl.setPrompt(promptStr);
98
+ lastPrompt = prompt;
99
+ rl.setPrompt(prompt);
57
100
  rl.prompt();
58
101
  return nextLine();
59
102
  }
60
103
 
61
- // Shift+Tab — quick yolo toggle.
104
+ // Shift+Tab — bật/tắt yolo nhanh (best-effort; gắn vào stdin nên sống qua mọi
105
+ // lần dựng lại rl; bọc try/catch để không bao giờ làm hỏng input).
62
106
  if (process.stdin.isTTY) {
63
- readline.emitKeypressEvents(process.stdin);
64
- process.stdin.on("keypress", (_str, key) => {
65
- if (!key || key.name !== "tab" || !key.shift) return;
66
- state.yolo = !state.yolo;
67
- console.log(state.yolo ? c.err("\n " + t.yoloOn) : c.ok("\n " + t.yoloOff));
68
- rl.prompt(true);
69
- });
107
+ try {
108
+ readline.emitKeypressEvents(process.stdin);
109
+ process.stdin.on("keypress", (_str, key) => {
110
+ if (!key || key.name !== "tab" || !key.shift) return;
111
+ state.yolo = !state.yolo;
112
+ console.log(state.yolo ? c.err(" " + t.yoloOn) : c.ok(" " + t.yoloOff));
113
+ rl.setPrompt(promptStr(false)); // cập nhật dòng trạng thái ngay lập tức
114
+ rl.prompt(true);
115
+ });
116
+ } catch {
117
+ /* không có Shift+Tab cũng được — vẫn dùng /yolo */
118
+ }
70
119
  }
71
120
 
72
121
  let abort = null; // active turn controller
@@ -102,6 +151,69 @@ export async function startRepl(opts = {}) {
102
151
  if (!closed) rl.prompt(true);
103
152
  });
104
153
 
154
+ // ── phiên (session): lưu lịch sử + resume giống Claude Code ───────────────
155
+ let session = null;
156
+ const persist = () => {
157
+ if (!session || !state.history.length) return; // đừng lưu phiên rỗng
158
+ session.history = state.history; // giữ đồng bộ tuyệt đối với history sống
159
+ session.model = state.model.id;
160
+ sessions.save(session);
161
+ };
162
+ async function restore(s) {
163
+ session = s;
164
+ state.history = s.history || [];
165
+ state.mode = "chat";
166
+ if (s.model) {
167
+ const m = findModel(s.model);
168
+ if (m) state.model = m;
169
+ }
170
+ console.log(c.ok(" ✓ " + t.sessionResumed(s.id)));
171
+ const turns = state.history.filter((m) => m.role === "user");
172
+ const tail = turns.slice(-5);
173
+ const base = turns.length - tail.length;
174
+ tail.forEach((m, i) => console.log(c.dim(` ${base + i + 1}. `) + truncate(m.content, 70)));
175
+ console.log("");
176
+ }
177
+ async function pickSession() {
178
+ const items = sessions.list(20);
179
+ if (!items.length) {
180
+ console.log(c.dim(" " + t.sessionEmpty) + "\n");
181
+ return null;
182
+ }
183
+ console.log("\n" + chalk.bold(" " + t.sessionPickTitle));
184
+ items.forEach((s, i) =>
185
+ console.log(
186
+ c.accent(` ${String(i + 1).padStart(2)}. `) +
187
+ chalk.bold(s.title || "(trống)") +
188
+ c.dim(` · ${s.turns} lượt · ${relTime(s.updatedAt)} · ${shortPath(s.cwd)}`),
189
+ ),
190
+ );
191
+ const ans = ((await ask(c.tool(" " + t.sessionPickPrompt(items.length)))) ?? "").trim();
192
+ if (!ans) return null;
193
+ const idx = parseInt(ans, 10) - 1;
194
+ if (Number.isNaN(idx) || idx < 0 || idx >= items.length) {
195
+ console.log(c.err(" " + t.sessionPickBad) + "\n");
196
+ return null;
197
+ }
198
+ const full = sessions.load(items[idx].id);
199
+ if (full) await restore(full);
200
+ return full;
201
+ }
202
+ function listSessions() {
203
+ const items = sessions.list(20);
204
+ if (!items.length) return console.log(c.dim(" " + t.sessionEmpty));
205
+ console.log("\n" + chalk.bold(" " + t.sessionListTitle));
206
+ items.forEach((s) =>
207
+ console.log(
208
+ c.dim(" " + s.id.padEnd(20)) +
209
+ chalk.bold(s.title || "(trống)") +
210
+ c.dim(` · ${s.turns} lượt · ${relTime(s.updatedAt)}`),
211
+ ),
212
+ );
213
+ console.log(c.dim("\n " + t.sessionResumeHint) + "\n");
214
+ }
215
+ const startFresh = () => (session = sessions.newSession({ cwd: process.cwd(), model: state.model.id }));
216
+
105
217
  banner();
106
218
  printStatus(state);
107
219
  if (!config.apiKey) console.log("\n" + c.tool(" " + t.notLoggedIn) + "\n");
@@ -119,14 +231,36 @@ export async function startRepl(opts = {}) {
119
231
  .catch(() => {});
120
232
  }
121
233
 
234
+ // Khôi phục phiên theo cờ dòng lệnh, hoặc mở phiên mới.
235
+ if (opts.continue) {
236
+ const s = sessions.latest();
237
+ if (s) await restore(s);
238
+ else {
239
+ startFresh();
240
+ console.log(c.dim(" " + t.sessionNonePrev) + "\n");
241
+ }
242
+ } else if (opts.resume === true) {
243
+ if (!(await pickSession())) startFresh();
244
+ } else if (typeof opts.resume === "string") {
245
+ const s = sessions.load(opts.resume);
246
+ if (s) await restore(s);
247
+ else {
248
+ console.log(c.err(" " + t.sessionNotFound(opts.resume)) + "\n");
249
+ startFresh();
250
+ }
251
+ } else {
252
+ startFresh();
253
+ }
254
+
122
255
  if (opts.prompt) {
123
256
  console.log(c.user(t.promptYou) + c.dim("› ") + opts.prompt);
124
257
  await handle(opts.prompt);
258
+ persist();
125
259
  }
126
260
 
127
261
  // Main loop — runs until /exit, double Ctrl+C, or EOF. Never exits after a task.
128
262
  while (true) {
129
- const raw = await ask(c.user("\n" + t.promptYou) + (state.yolo ? c.err("⚡ ") : "") + c.dim("› "));
263
+ const raw = await ask(promptStr());
130
264
  if (raw == null) break; // stdin fully closed and drained
131
265
  const input = raw.trim();
132
266
  if (!input) continue;
@@ -140,10 +274,12 @@ export async function startRepl(opts = {}) {
140
274
  continue;
141
275
  }
142
276
  await handle(input);
277
+ persist(); // lưu sau mỗi lượt → resume được kể cả khi tắt đột ngột
143
278
  } catch (err) {
144
279
  printError(err);
145
280
  }
146
281
  }
282
+ exiting = true;
147
283
  rl.close();
148
284
  process.exit(0);
149
285
 
@@ -287,12 +423,32 @@ export async function startRepl(opts = {}) {
287
423
  break;
288
424
  case "clear":
289
425
  case "new":
426
+ persist(); // giữ lại phiên cũ trên đĩa
290
427
  state.history = [];
428
+ startFresh(); // phiên mới (phiên cũ vẫn resume được)
291
429
  console.clear();
292
430
  banner();
293
431
  printStatus(state);
294
432
  console.log(c.dim(" " + t.ctxCleared + "\n"));
295
433
  break;
434
+ case "resume":
435
+ if (arg) {
436
+ const s = sessions.load(arg);
437
+ if (s) await restore(s);
438
+ else console.log(c.err(" " + t.sessionNotFound(arg)));
439
+ } else {
440
+ await pickSession();
441
+ }
442
+ break;
443
+ case "continue": {
444
+ const s = sessions.latest();
445
+ if (s) await restore(s);
446
+ else console.log(c.dim(" " + t.sessionNonePrev));
447
+ break;
448
+ }
449
+ case "sessions":
450
+ listSessions();
451
+ break;
296
452
  case "cwd":
297
453
  console.log(c.dim(" " + process.cwd()));
298
454
  break;
@@ -306,6 +462,8 @@ export async function startRepl(opts = {}) {
306
462
  case "exit":
307
463
  case "quit":
308
464
  case "q":
465
+ persist();
466
+ exiting = true;
309
467
  console.log(c.dim(" " + t.bye));
310
468
  return true;
311
469
  default:
@@ -427,6 +585,8 @@ function printHelp() {
427
585
  " " + t.cmdUsage,
428
586
  " " + t.cmdUpdate,
429
587
  " " + t.cmdClear,
588
+ " " + t.cmdResume,
589
+ " " + t.cmdSessions,
430
590
  " " + t.cmdStatus,
431
591
  " " + t.cmdVersion,
432
592
  " " + t.cmdExit,
@@ -457,6 +617,15 @@ const shortCwd = () => {
457
617
  const p = process.cwd();
458
618
  return p.length > 48 ? "…" + p.slice(-47) : p;
459
619
  };
620
+ const shortPath = (p = "") => (p.length > 30 ? "…" + p.slice(-29) : p);
621
+ const relTime = (ts) => {
622
+ const m = Math.round((Date.now() - ts) / 60000);
623
+ if (m < 1) return "vừa xong";
624
+ if (m < 60) return m + " phút trước";
625
+ const h = Math.round(m / 60);
626
+ if (h < 24) return h + " giờ trước";
627
+ return Math.round(h / 24) + " ngày trước";
628
+ };
460
629
  const firstLine = (s) => (s.split("\n")[0] || "").slice(0, 100);
461
630
  const truncate = (s = "", n = 120) => (s.length > n ? s.slice(0, n) + "…" : s).replace(/\n/g, "⏎");
462
631
  const fmtTime = (iso) => {
@@ -0,0 +1,109 @@
1
+ // Lưu & khôi phục lịch sử hội thoại (giống --continue / --resume của Claude Code).
2
+ // Mỗi phiên là một file JSON trong ~/.noob/sessions/. Ghi sau mỗi lượt nên có rớt
3
+ // mạng / tắt máy vẫn resume được.
4
+ import fs from "node:fs";
5
+ import path from "node:path";
6
+ import os from "node:os";
7
+
8
+ const DIR = path.join(os.homedir(), ".noob", "sessions");
9
+
10
+ function ensure() {
11
+ try {
12
+ fs.mkdirSync(DIR, { recursive: true });
13
+ } catch {
14
+ /* best effort */
15
+ }
16
+ }
17
+
18
+ function genId() {
19
+ const d = new Date();
20
+ const p = (n) => String(n).padStart(2, "0");
21
+ const stamp = `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
22
+ return `${stamp}-${Math.random().toString(36).slice(2, 6)}`;
23
+ }
24
+
25
+ function titleFrom(history = []) {
26
+ const first = history.find((m) => m.role === "user");
27
+ if (!first) return "";
28
+ return String(first.content).replace(/\s+/g, " ").trim().slice(0, 60);
29
+ }
30
+
31
+ export function newSession({ cwd, model } = {}) {
32
+ const now = Date.now();
33
+ return {
34
+ id: genId(),
35
+ createdAt: now,
36
+ updatedAt: now,
37
+ cwd: cwd || process.cwd(),
38
+ model: model || "",
39
+ title: "",
40
+ history: [],
41
+ };
42
+ }
43
+
44
+ export function save(session) {
45
+ if (!session || !session.id) return false;
46
+ ensure();
47
+ session.updatedAt = Date.now();
48
+ if (!session.title) session.title = titleFrom(session.history);
49
+ try {
50
+ fs.writeFileSync(path.join(DIR, session.id + ".json"), JSON.stringify(session), "utf8");
51
+ return true;
52
+ } catch {
53
+ return false;
54
+ }
55
+ }
56
+
57
+ export function load(id) {
58
+ if (!id) return null;
59
+ try {
60
+ return JSON.parse(fs.readFileSync(path.join(DIR, id + ".json"), "utf8"));
61
+ } catch {
62
+ return null;
63
+ }
64
+ }
65
+
66
+ /** Tóm tắt nhẹ (không tải toàn bộ history vào view), sắp xếp mới nhất trước. */
67
+ export function list(limit = 30) {
68
+ ensure();
69
+ let files;
70
+ try {
71
+ files = fs.readdirSync(DIR).filter((f) => f.endsWith(".json"));
72
+ } catch {
73
+ return [];
74
+ }
75
+ const out = [];
76
+ for (const f of files) {
77
+ try {
78
+ const s = JSON.parse(fs.readFileSync(path.join(DIR, f), "utf8"));
79
+ out.push({
80
+ id: s.id,
81
+ updatedAt: s.updatedAt || s.createdAt || 0,
82
+ cwd: s.cwd || "",
83
+ model: s.model || "",
84
+ title: s.title || titleFrom(s.history || []),
85
+ turns: (s.history || []).filter((m) => m.role === "user").length,
86
+ });
87
+ } catch {
88
+ /* bỏ qua file hỏng */
89
+ }
90
+ }
91
+ out.sort((a, b) => b.updatedAt - a.updatedAt);
92
+ return out.slice(0, limit);
93
+ }
94
+
95
+ export function latest() {
96
+ const l = list(1);
97
+ return l.length ? load(l[0].id) : null;
98
+ }
99
+
100
+ export function remove(id) {
101
+ try {
102
+ fs.unlinkSync(path.join(DIR, id + ".json"));
103
+ return true;
104
+ } catch {
105
+ return false;
106
+ }
107
+ }
108
+
109
+ export const sessionsDir = DIR;
package/src/tools.js CHANGED
@@ -119,7 +119,10 @@ export const TOOLS = {
119
119
  const isWin = process.platform === "win32";
120
120
  const shell = isWin ? "powershell.exe" : "/bin/bash";
121
121
  const args = isWin ? ["-NoProfile", "-NonInteractive", "-Command", command] : ["-c", command];
122
- const child = spawn(shell, args, { cwd: cwd() });
122
+ // stdin: "ignore" tiến trình con KHÔNG được chạm vào console/stdin của
123
+ // CLI. Nếu để con kế thừa stdin, trên Windows nó có thể làm readline phát
124
+ // 'close' → CLI tự tắt. Đóng hẳn stdin con để tránh hoàn toàn.
125
+ const child = spawn(shell, args, { cwd: cwd(), stdio: ["ignore", "pipe", "pipe"] });
123
126
  let out = "";
124
127
  const killer = setTimeout(() => child.kill(), timeout);
125
128
  child.stdout.on("data", (d) => (out += d));