@nextxai/cli 0.0.0-stage → 0.1.0

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.
Files changed (43) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/LICENSE +21 -0
  3. package/README.md +53 -2
  4. package/dist/ai/client.js +221 -0
  5. package/dist/auth/ci.js +64 -0
  6. package/dist/auth/credentials.js +192 -0
  7. package/dist/auth/device.js +76 -0
  8. package/dist/auth/login.js +145 -0
  9. package/dist/auth/loopback.js +98 -0
  10. package/dist/auth/mcp-oauth.js +174 -0
  11. package/dist/auth/session.js +205 -0
  12. package/dist/bin/nextx.js +5 -0
  13. package/dist/commands/ai.js +514 -0
  14. package/dist/commands/api.js +87 -0
  15. package/dist/commands/auth.js +165 -0
  16. package/dist/commands/docs.js +55 -0
  17. package/dist/commands/help.js +48 -0
  18. package/dist/commands/keys.js +85 -0
  19. package/dist/commands/mcp.js +202 -0
  20. package/dist/commands/tools.js +186 -0
  21. package/dist/commands/util.js +224 -0
  22. package/dist/commands/webhooks.js +249 -0
  23. package/dist/commands/workspace.js +44 -0
  24. package/dist/core/brand.js +51 -0
  25. package/dist/core/config.js +52 -0
  26. package/dist/core/context.js +42 -0
  27. package/dist/core/errors.js +28 -0
  28. package/dist/core/http.js +170 -0
  29. package/dist/core/i18n.js +90 -0
  30. package/dist/core/lock.js +44 -0
  31. package/dist/core/output.js +74 -0
  32. package/dist/core/paths.js +29 -0
  33. package/dist/core/prompt.js +137 -0
  34. package/dist/core/version.js +13 -0
  35. package/dist/main.js +38 -0
  36. package/dist/mcp/client.js +114 -0
  37. package/dist/program.js +102 -0
  38. package/dist/repl/App.js +771 -0
  39. package/dist/repl/components.js +66 -0
  40. package/dist/repl/index.js +12 -0
  41. package/dist/repl/markdown.js +57 -0
  42. package/dist/repl/slash.js +53 -0
  43. package/package.json +62 -4
@@ -0,0 +1,514 @@
1
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { homedir } from "node:os";
4
+ import { brand } from "../core/brand.js";
5
+ import { context } from "../core/context.js";
6
+ import { CliError, Exit } from "../core/errors.js";
7
+ import { tr } from "../core/i18n.js";
8
+ import { c, emit, info, json, ok, outputFormat, table, warn } from "../core/output.js";
9
+ import { get, post, put, del } from "../core/http.js";
10
+ import { getProfile, updateProfile } from "../core/config.js";
11
+ import { hasTty, confirm, confirmTyped, select } from "../core/prompt.js";
12
+ import { requirePerson, withWorkspace } from "../auth/session.js";
13
+ import { ask, attach, composeText, confirmAction, listConversations, openConversation, renameConversation, pinConversation, deleteConversation, history, riskOf, confirmWord, } from "../ai/client.js";
14
+ // ── NEXTX.md ───────────────────────────────────────────────────────────────────────────────────────
15
+ const NOTES_MAX = 4096;
16
+ export function notesFileName() {
17
+ return `${brand().bin.toUpperCase()}.md`;
18
+ }
19
+ /** Nearest NEXTX.md walking up from cwd, stopping at the git root or home. */
20
+ export function findProjectNotes(start = process.cwd()) {
21
+ let dir = start;
22
+ const home = homedir();
23
+ for (;;) {
24
+ const f = join(dir, notesFileName());
25
+ if (existsSync(f)) {
26
+ const text = readFileSync(f, "utf8");
27
+ return { path: f, text: text.length > NOTES_MAX ? text.slice(0, NOTES_MAX) : text };
28
+ }
29
+ if (existsSync(join(dir, ".git")) || dir === home)
30
+ return undefined;
31
+ const up = dirname(dir);
32
+ if (up === dir)
33
+ return undefined;
34
+ dir = up;
35
+ }
36
+ }
37
+ // ── Rendering helpers shared with the REPL fallback ────────────────────────────────────────────────
38
+ export function renderBlocks(blocks) {
39
+ const out = [];
40
+ for (const b of blocks ?? []) {
41
+ if (b.type === "table" && Array.isArray(b.rows) && b.columns?.length) {
42
+ const cols = b.columns;
43
+ const rows = b.rows.map((r) => cols.map((col) => {
44
+ const v = r[col.key ?? col.name ?? ""];
45
+ return typeof v === "number" ? v.toLocaleString("vi-VN") : v === null || v === undefined ? "" : String(v);
46
+ }));
47
+ out.push(table(cols.map((col) => col.label ?? col.name ?? col.key ?? ""), rows, cols.map((col) => (col.type === "number" || col.type === "money" ? "r" : "l"))));
48
+ if (b.total && b.total > rows.length)
49
+ out.push(c.dim(tr(` … ${rows.length}/${b.total} dòng`, ` … ${rows.length}/${b.total} rows`)));
50
+ }
51
+ else if (b.type === "steps" && b.steps?.length) {
52
+ b.steps.forEach((s, i) => out.push(` ${i + 1}. ${s}`));
53
+ }
54
+ else if (b.type === "file" && b.url) {
55
+ out.push(` ${c.cyan("⬇")} ${b.label ?? tr("Tệp", "File")}: ${b.url}`);
56
+ }
57
+ else if ((b.type === "button" || b.type === "link") && (b.url || b.routeHint)) {
58
+ const url = b.url ?? `${context().endpoints.app}${b.routeHint}`;
59
+ out.push(` ${c.cyan("↗")} ${b.label ?? ""} ${c.dim(url)}`);
60
+ }
61
+ }
62
+ return out;
63
+ }
64
+ export function proposalCard(a) {
65
+ const env = context().env === "prod" ? c.red(tr("GHI VÀO PROD", "WRITES TO PROD")) : c.yellow("SANDBOX");
66
+ const risk = riskOf(a);
67
+ const tag = risk === "large" ? c.red(tr("GHI LỚN · ", "LARGE WRITE · ")) : risk === "finance" ? c.red(tr("TÀI CHÍNH · ", "FINANCE · ")) : "";
68
+ const left = a.expiresAt ? Math.max(0, Math.round((Date.parse(a.expiresAt) - Date.now()) / 60000)) : 15;
69
+ const lines = [
70
+ c.yellow(`┌ ${tr("Đề xuất", "Proposal")} `) + tag + env + c.yellow(` · ${tr(`hết hạn sau ${left} phút`, `expires in ${left} min`)}`),
71
+ c.yellow("│ ") + c.bold(a.label),
72
+ ];
73
+ for (const l of a.confirmPrompt.split("\n").filter(Boolean))
74
+ lines.push(c.yellow("│ ") + l);
75
+ if (a.recipientCount)
76
+ lines.push(c.yellow("│ ") + tr(`Người nhận: ${a.recipientCount}`, `Recipients: ${a.recipientCount}`));
77
+ if (a.estimatedCost)
78
+ lines.push(c.yellow("│ ") + tr(`Chi phí ước tính: ${a.estimatedCost.toLocaleString("vi-VN")} ${a.costCurrency ?? ""}`, `Estimated cost: ${a.estimatedCost} ${a.costCurrency ?? ""}`));
79
+ return lines;
80
+ }
81
+ /**
82
+ * Decides and, when allowed, executes a proposed write. Returns true if it was written.
83
+ * Rules (docs §2.6): read mode never writes; finance never from the terminal; no TTY ⇒ never;
84
+ * large writes need the typed number; auto mode skips the y/N for normal writes only.
85
+ */
86
+ export async function handleProposal(sessionId, a, mode = context().mode) {
87
+ if (!a.writes) {
88
+ const r = await confirmAction(sessionId, a);
89
+ return { written: false, message: r.message };
90
+ }
91
+ const risk = riskOf(a);
92
+ const resume = `${brand().bin} -r ${sessionId}`;
93
+ if (mode === "read") {
94
+ info(c.dim(tr(" Chế độ chỉ đọc: không ghi. Đổi chế độ: --mode propose", " Read-only mode: nothing written. Switch with --mode propose")));
95
+ return { written: false };
96
+ }
97
+ if (risk === "finance") {
98
+ info(c.dim(tr(` Việc tài chính chỉ xác nhận trong app: ${context().endpoints.app}`, ` Finance actions are confirmed in the app only: ${context().endpoints.app}`)));
99
+ return { written: false };
100
+ }
101
+ if (!hasTty()) {
102
+ info(tr(` Chưa ghi. Xác nhận trong 15 phút: ${c.bold(resume)}`, ` Not written. Confirm within 15 minutes: ${c.bold(resume)}`));
103
+ return { written: false };
104
+ }
105
+ let yes;
106
+ if (risk === "large")
107
+ yes = await confirmTyped(c.yellow("└"), confirmWord(a));
108
+ else if (mode === "auto")
109
+ yes = true;
110
+ else
111
+ yes = await confirm(c.yellow("└ ") + tr("Ghi?", "Write?"));
112
+ if (!yes) {
113
+ info(c.dim(tr(` Không ghi gì. Đề xuất còn chờ tới khi hết hạn: ${resume}`, ` Nothing written. The proposal waits until it expires: ${resume}`)));
114
+ return { written: false };
115
+ }
116
+ const r = await confirmAction(sessionId, a);
117
+ if (r.success)
118
+ ok(r.message || tr("Đã ghi.", "Written."));
119
+ else
120
+ warn(r.message);
121
+ for (const l of renderBlocks(r.blocks))
122
+ info(l);
123
+ return { written: r.success, message: r.message };
124
+ }
125
+ // ── Root: REPL or -p ───────────────────────────────────────────────────────────────────────────────
126
+ async function readStdin() {
127
+ if (process.stdin.isTTY)
128
+ return "";
129
+ const chunks = [];
130
+ for await (const ch of process.stdin)
131
+ chunks.push(ch);
132
+ return Buffer.concat(chunks).toString("utf8");
133
+ }
134
+ export async function resolveConversation(o) {
135
+ const p = getProfile(context().profile);
136
+ if (typeof o.resume === "string")
137
+ return o.resume;
138
+ if (o.resume === true) {
139
+ const list = await listConversations(30);
140
+ if (!hasTty())
141
+ throw new CliError(tr("Cần id hội thoại khi không có terminal: -r <id>", "Pass a conversation id without a terminal: -r <id>"), Exit.usage);
142
+ const pick = await select(tr("Mở lại hội thoại:", "Resume conversation:"), list.map((x) => ({
143
+ label: `${x.isPinned ? "📌 " : ""}${x.title ?? x.lastPreview ?? x.sessionId}`.slice(0, 70),
144
+ hint: x.updatedAt.slice(0, 16).replace("T", " "),
145
+ value: x.sessionId,
146
+ })));
147
+ if (!pick)
148
+ throw new CliError(tr("Đã huỷ.", "Cancelled."), Exit.interrupted);
149
+ return pick;
150
+ }
151
+ if (o.continue && p?.lastConversation)
152
+ return p.lastConversation;
153
+ return (await openConversation(true)).sessionId;
154
+ }
155
+ export async function runRoot(o, words) {
156
+ const printMode = o.print !== undefined && o.print !== false;
157
+ if (!printMode && hasTty() && outputFormat() === "text") {
158
+ await requirePerson();
159
+ const { startRepl } = await import("../repl/index.js");
160
+ return withWorkspace(context().workspace, () => startRepl({ flags: o, initialQuestion: words.join(" ").trim() || undefined }));
161
+ }
162
+ const q = [typeof o.print === "string" ? o.print : "", ...words].join(" ").trim();
163
+ const piped = await readStdin();
164
+ if (!q && !piped)
165
+ throw new CliError(tr(`Thiếu câu hỏi. Ví dụ: ${brand().bin} -p "doanh thu hôm qua"`, `Missing question. Example: ${brand().bin} -p "yesterday's revenue"`), Exit.usage);
166
+ await requirePerson();
167
+ await withWorkspace(context().workspace, () => printAsk(o, q || tr("Xử lý nội dung đính kèm.", "Process the attached content."), piped));
168
+ }
169
+ async function printAsk(o, question, piped) {
170
+ const sessionId = await resolveConversation(o);
171
+ const attachments = [];
172
+ for (const f of (o.file ?? []).slice(0, 3))
173
+ attachments.push(await attach(f, sessionId));
174
+ if (piped)
175
+ attachments.push({ path: "-", name: "stdin", kind: "text", text: piped.slice(0, 20_000) });
176
+ const notes = findProjectNotes();
177
+ const fmt = outputFormat();
178
+ const text = composeText(question, attachments, notes?.text);
179
+ const stdout = process.stdout;
180
+ const stderr = process.stderr;
181
+ const outcome = await ask({
182
+ sessionId,
183
+ text,
184
+ attachments,
185
+ agent: context().agent,
186
+ onChunk: (ch) => {
187
+ if (fmt === "stream-json")
188
+ return json(ch);
189
+ if (fmt !== "text")
190
+ return;
191
+ if (ch.type === "token")
192
+ stdout.write(ch.text ?? "");
193
+ else if (ch.type === "activity" && ch.activity?.kind === "tool" && !o.quiet)
194
+ stderr.write(c.dim(`⏺ ${ch.activity.label}\n`));
195
+ },
196
+ onRetry: (n) => stderr.write(c.yellow(tr(`⚠ Mất kết nối, đang nối lại (${n}/2)…\n`, `⚠ Connection lost, reconnecting (${n}/2)…\n`))),
197
+ });
198
+ updateProfile(context().profile, { lastConversation: sessionId });
199
+ const proposal = outcome.proposedAction;
200
+ if (fmt === "json") {
201
+ json({
202
+ conversationId: sessionId,
203
+ runId: outcome.runId ?? null,
204
+ messageId: outcome.done?.messageId ?? null,
205
+ text: outcome.text,
206
+ blocks: outcome.done?.blocks ?? [],
207
+ followUps: outcome.done?.followUps ?? [],
208
+ notices: outcome.notices,
209
+ agent: outcome.agent ?? null,
210
+ proposals: proposal ? [{ kind: proposal.kind, label: proposal.label, prompt: proposal.confirmPrompt, writes: proposal.writes, expiresAt: proposal.expiresAt ?? null, risk: riskOf(proposal), confirm: `${brand().bin} -r ${sessionId}` }] : [],
211
+ workspace: getProfile(context().profile)?.workspaceCode ?? null,
212
+ env: context().env,
213
+ ended: outcome.ended,
214
+ });
215
+ }
216
+ else if (fmt === "text") {
217
+ if (outcome.text && !outcome.text.endsWith("\n"))
218
+ stdout.write("\n");
219
+ // Notices the answer already says in words are not repeated.
220
+ for (const n of outcome.notices)
221
+ if (n.message && !outcome.text.includes(n.message.slice(0, 24)))
222
+ stderr.write(`${c.yellow("⚠")} ${n.message}\n`);
223
+ for (const l of renderBlocks(outcome.done?.blocks?.filter((b) => b.type !== "text")))
224
+ stdout.write(`${l}\n`);
225
+ if (proposal) {
226
+ for (const l of proposalCard(proposal))
227
+ stderr.write(`${l}\n`);
228
+ await handleProposal(sessionId, proposal);
229
+ }
230
+ }
231
+ if (outcome.notices.some((n) => n.code === "ai_not_purchased"))
232
+ process.exitCode = Exit.forbidden;
233
+ else if (proposal?.writes)
234
+ process.exitCode = Exit.notConfirmed;
235
+ else if (outcome.ended === "error")
236
+ process.exitCode = Exit.general;
237
+ }
238
+ const short = (id) => id.slice(0, 8);
239
+ const when = (iso) => (iso ? iso.slice(0, 16).replace("T", " ") : "");
240
+ async function findChangeset(id) {
241
+ if (id.length >= 32)
242
+ return get(`/ai/changesets/${id}`);
243
+ const pending = await get("/ai/pending", { query: { take: 50 } });
244
+ const journal = await get("/ai/journal", { query: { pageSize: 50 } });
245
+ const hit = [...pending.changesets, ...journal.items].find((x) => x.id.startsWith(id));
246
+ if (!hit)
247
+ throw new CliError(tr("Không thấy bản nháp này.", "Changeset not found."), Exit.notFound);
248
+ return hit;
249
+ }
250
+ export function registerAi(program) {
251
+ const ai = program.command("ai").description(tr("trợ lý AI: hỏi, lịch sử, đề xuất ghi, bộ nhớ, lịch chạy", "AI assistant: ask, history, proposals, memory, schedules"));
252
+ ai.command("ask <question...>")
253
+ .description(tr("hỏi một câu không tương tác (giống -p)", "ask one question non-interactively (same as -p)"))
254
+ .option("--conversation <id>", tr("hỏi tiếp trong hội thoại này", "continue this conversation"))
255
+ .action(async (words, o) => {
256
+ const root = program.opts();
257
+ await runRoot({ ...root, print: words.join(" "), resume: o.conversation ?? root.resume }, []);
258
+ });
259
+ ai.command("history")
260
+ .description(tr("danh sách hội thoại", "list conversations"))
261
+ .option("--search <text>", tr("lọc theo chữ", "filter by text"))
262
+ .option("--limit <n>", tr("số dòng", "rows"), "30")
263
+ .action(async (o) => {
264
+ await requirePerson();
265
+ let list = await listConversations(Number(o.limit));
266
+ if (o.search)
267
+ list = list.filter((x) => `${x.title ?? ""} ${x.lastPreview ?? ""}`.toLowerCase().includes(o.search.toLowerCase()));
268
+ emit(list, () => info(table(["ID", tr("Tiêu đề", "Title"), "Agent", tr("Cập nhật", "Updated")], list.map((x) => [
269
+ short(x.sessionId), `${x.isPinned ? "📌 " : ""}${(x.title ?? x.lastPreview ?? "").slice(0, 60)}`, x.agentCode ?? "", when(x.updatedAt),
270
+ ]))));
271
+ });
272
+ ai.command("show <id>")
273
+ .description(tr("in toàn bộ một hội thoại", "print a whole conversation"))
274
+ .action(async (id) => {
275
+ await requirePerson();
276
+ const full = id.length >= 32 ? id : (await listConversations(100)).find((x) => x.sessionId.startsWith(id))?.sessionId;
277
+ if (!full)
278
+ throw new CliError(tr("Không thấy hội thoại.", "Conversation not found."), Exit.notFound);
279
+ const msgs = (await history(full)) ?? [];
280
+ emit(msgs, () => {
281
+ for (const m of msgs) {
282
+ info(m.role === "User" ? c.bold(`› ${m.text}`) : m.text);
283
+ for (const l of renderBlocks(m.blocks?.filter((b) => b.type !== "text")))
284
+ info(l);
285
+ info();
286
+ }
287
+ });
288
+ });
289
+ ai.command("rename <id> [title...]").description(tr("đặt tên hội thoại (bỏ trống = xoá tên)", "rename a conversation (empty = clear)"))
290
+ .action(async (id, title) => {
291
+ await renameConversation(id, title.join(" ") || null);
292
+ ok(tr("Đã đổi tên.", "Renamed."));
293
+ });
294
+ ai.command("pin <id>").option("--off", tr("bỏ ghim", "unpin")).description(tr("ghim hội thoại", "pin a conversation"))
295
+ .action(async (id, o) => {
296
+ await pinConversation(id, !o.off);
297
+ ok(o.off ? tr("Đã bỏ ghim.", "Unpinned.") : tr("Đã ghim.", "Pinned."));
298
+ });
299
+ ai.command("delete <id>").description(tr("xoá hẳn một hội thoại (không hoàn tác)", "delete a conversation permanently"))
300
+ .action(async (id) => {
301
+ if (hasTty() && !(await confirm(tr("Xoá hẳn hội thoại này?", "Delete this conversation permanently?"))))
302
+ return;
303
+ await deleteConversation(id);
304
+ ok(tr("Đã xoá.", "Deleted."));
305
+ });
306
+ ai.command("why [runId]")
307
+ .description(tr("vì sao AI trả lời vậy: các bước, công cụ đã gọi", "why the AI answered that way: steps and tools"))
308
+ .action(async (runId) => {
309
+ await requirePerson();
310
+ const id = runId ?? (await get("/ai/runs", { query: { limit: 1 } }))[0]?.id;
311
+ if (!id)
312
+ throw new CliError(tr("Chưa có lượt chạy nào.", "No runs yet."), Exit.notFound);
313
+ const run = await get(`/ai/runs/${id}`);
314
+ emit(run, () => printRun(run));
315
+ });
316
+ ai.command("runs").description(tr("các lượt chạy gần đây", "recent runs")).option("--limit <n>", "", "20")
317
+ .action(async (o) => {
318
+ const runs = await get("/ai/runs", { query: { limit: o.limit } });
319
+ emit(runs, () => info(table(["Run", tr("Trạng thái", "Status"), tr("Câu hỏi", "Question"), "s", tr("Lúc", "At")], runs.map((r) => [short(r.id), r.status, r.question.slice(0, 50), r.elapsedSeconds ?? "", when(r.createdAt)]))));
320
+ });
321
+ ai.command("pending").description(tr("bản nháp ghi và câu hỏi đang chờ bạn", "drafts and questions waiting for you"))
322
+ .action(async () => {
323
+ await requirePerson();
324
+ const p = await get("/ai/pending", { query: { take: 50 } });
325
+ emit(p, () => {
326
+ if (!p.changesets.length && !p.questions.length)
327
+ return info(tr("Không có việc nào đang chờ.", "Nothing is waiting."));
328
+ if (p.changesets.length)
329
+ info(table(["ID", tr("Việc", "Change"), tr("Trạng thái", "Status"), tr("Hết hạn", "Expires")], p.changesets.map((x) => [short(x.id), x.title, x.status, when(x.expiresAt)])));
330
+ if (p.questions.length)
331
+ info(tr(`${p.questions.length} câu hỏi của AI đang chờ trả lời — mở \`${brand().bin} -r\` để trả lời.`, `${p.questions.length} AI questions await answers — open \`${brand().bin} -r\`.`));
332
+ info(c.dim(` ${brand().bin} ai show-changeset <id> · ${brand().bin} ai apply <id> · ${brand().bin} ai reject <id>`));
333
+ });
334
+ });
335
+ ai.command("show-changeset <id>").description(tr("xem từng dòng của một bản nháp", "show the rows of a draft"))
336
+ .action(async (id) => {
337
+ const head = await findChangeset(id);
338
+ const items = await get(`/ai/changesets/${head.id}/items`, { query: { pageSize: 200 } });
339
+ emit({ head, items }, () => {
340
+ info(`${c.bold(head.title)} ${c.dim(head.status)} · ${items.totalCount} ${tr("dòng", "rows")}`);
341
+ for (const it of items.items) {
342
+ const sign = it.op === "delete" ? c.red("−") : it.op === "create" ? c.green("+") : c.yellow("~");
343
+ info(` ${sign} ${it.label}`);
344
+ if (it.op === "update" && it.before && it.after)
345
+ info(c.dim(` ${JSON.stringify(it.before)} → ${JSON.stringify(it.after)}`));
346
+ }
347
+ });
348
+ });
349
+ ai.command("apply <id>").description(tr("áp dụng một bản nháp (ghi thật)", "apply a draft (real write)"))
350
+ .action(async (id) => {
351
+ await requirePerson();
352
+ const head = await findChangeset(id);
353
+ if (context().mode === "read")
354
+ throw new CliError(tr("Chế độ chỉ đọc.", "Read-only mode."), Exit.forbidden);
355
+ if (!hasTty())
356
+ throw new CliError(tr("Áp dụng bản nháp cần terminal tương tác (hoặc chính sách duyệt trước).", "Applying needs an interactive terminal (or a pre-approval policy)."), Exit.notConfirmed);
357
+ const env = context().env === "prod" ? c.red("PROD") : c.yellow("SANDBOX");
358
+ info(`${c.bold(head.title)} · ${env}`);
359
+ const yes = head.risk >= 3 ? await confirmTyped("", tr("dong-y", "confirm")) : await confirm(tr("Áp dụng?", "Apply?"));
360
+ if (!yes)
361
+ return void info(tr("Không ghi gì.", "Nothing written."));
362
+ const r = await post(`/ai/changesets/${head.id}/apply`, undefined, { query: { expectedVersion: head.version } });
363
+ emit(r, () => ok(tr(`Đã áp dụng · hoàn tác: ${brand().bin} ai undo ${short(head.id)}`, `Applied · undo: ${brand().bin} ai undo ${short(head.id)}`)));
364
+ });
365
+ ai.command("reject <id>").description(tr("huỷ một bản nháp", "reject a draft"))
366
+ .action(async (id) => {
367
+ const head = await findChangeset(id);
368
+ await post(`/ai/changesets/${head.id}/reject`, {});
369
+ ok(tr("Đã huỷ bản nháp.", "Draft rejected."));
370
+ });
371
+ ai.command("undo <id>").description(tr("hoàn tác việc AI đã ghi (trong hạn)", "undo what the AI wrote (within the window)"))
372
+ .action(async (id) => {
373
+ const head = await findChangeset(id);
374
+ if (hasTty() && !(await confirm(tr(`Hoàn tác "${head.title}"?`, `Undo "${head.title}"?`))))
375
+ return;
376
+ const r = await post(`/ai/changesets/${head.id}/undo`, {});
377
+ emit(r, () => ok(tr(`Đã hoàn tác ${r.reverted ?? ""} dòng${r.conflicts ? `, ${r.conflicts} dòng xung đột (đã bị người khác sửa, không ghi đè)` : ""}.`, `Reverted ${r.reverted ?? ""} rows${r.conflicts ? `, ${r.conflicts} conflicts (changed by someone else, not overwritten)` : ""}.`)));
378
+ });
379
+ ai.command("journal").description(tr("nhật ký những gì AI đã ghi", "journal of what the AI wrote"))
380
+ .option("--module <m>", tr("lọc theo phân hệ", "filter by module"))
381
+ .option("--limit <n>", "", "20")
382
+ .action(async (o) => {
383
+ const j = await get("/ai/journal", { query: { module: o.module, pageSize: o.limit } });
384
+ emit(j, () => {
385
+ if (!j.items.length)
386
+ return info(tr("Chưa có gì trong nhật ký.", "The journal is empty."));
387
+ info(table(["ID", tr("Việc", "Change"), tr("Trạng thái", "Status"), tr("Hoàn tác tới", "Undo until"), tr("Lúc", "At")], j.items.map((x) => [short(x.id), x.title, x.status, x.undoable ? when(x.undoUntil) : "—", when(x.createdAt)])));
388
+ });
389
+ });
390
+ const mem = ai.command("memory").description(tr("AI đang nhớ gì về công ty", "what the AI remembers about your company"));
391
+ mem.command("list", { isDefault: true }).action(async () => {
392
+ const list = await get("/ai/memory");
393
+ emit(list, () => (list.length ? info(table(["ID", tr("Điều nhớ", "Memory"), tr("Loại", "Kind"), tr("Dùng", "Uses"), ""], list.map((m) => [short(m.id), m.display, m.kind, m.uses, m.lockedBy ? "🔒" : ""]))) : info(tr("AI chưa nhớ gì.", "Nothing remembered yet."))));
394
+ });
395
+ mem.command("edit <id> <text...>").description(tr("sửa câu chữ một điều nhớ", "edit a memory")).action(async (id, text) => {
396
+ const m = await resolveMemory(id);
397
+ await put(`/ai/memory/${m.id}`, { display: text.join(" ") });
398
+ ok(tr("Đã sửa.", "Updated."));
399
+ });
400
+ mem.command("lock <id>").option("--off", tr("mở khoá", "unlock")).description(tr("khoá để AI không tự sửa", "lock so the AI cannot change it"))
401
+ .action(async (id, o) => {
402
+ const m = await resolveMemory(id);
403
+ await put(`/ai/memory/${m.id}`, { lockedBy: o.off ? null : "user" });
404
+ ok(o.off ? tr("Đã mở khoá.", "Unlocked.") : tr("Đã khoá.", "Locked."));
405
+ });
406
+ mem.command("forget <id>").description(tr("bảo AI quên một điều", "make the AI forget something")).action(async (id) => {
407
+ const m = await resolveMemory(id);
408
+ await del(`/ai/memory/${m.id}`);
409
+ ok(tr("Đã quên.", "Forgotten."));
410
+ });
411
+ const sch = ai.command("schedule").description(tr("lên lịch một câu hỏi chạy định kỳ", "schedule a recurring question"));
412
+ sch.command("list", { isDefault: true }).action(async () => {
413
+ const list = await get("/ai/schedules");
414
+ emit(list, () => (list.length ? info(table(["ID", tr("Tên", "Title"), tr("Lịch", "When"), tr("Lần tới", "Next"), tr("Lần trước", "Last"), ""], list.map((s) => [short(s.id), s.title, `${s.frequency} ${s.timeOfDay}`, when(s.nextRunAt), `${when(s.lastRunAt)} ${s.lastStatus ?? ""}`, s.enabled ? "" : tr("tắt", "off")]))) : info(tr("Chưa có lịch nào.", "No schedules."))));
415
+ });
416
+ sch.command("add <question>")
417
+ .description(tr('vd: ai schedule add "doanh thu hôm qua" --daily 08:00', 'e.g. ai schedule add "yesterday\'s revenue" --daily 08:00'))
418
+ .option("--daily <HH:mm>")
419
+ .option("--weekly <dow@HH:mm>", tr("0=CN … 6=T7, vd 1@08:00", "0=Sun … 6=Sat, e.g. 1@08:00"))
420
+ .option("--monthly <dom@HH:mm>", tr("ngày 1–28, vd 1@08:00", "day 1–28, e.g. 1@08:00"))
421
+ .option("--title <title>")
422
+ .action(async (question, o) => {
423
+ const [frequency, spec] = o.weekly ? ["weekly", o.weekly] : o.monthly ? ["monthly", o.monthly] : ["daily", o.daily ?? "08:00"];
424
+ const [day, time] = spec.includes("@") ? spec.split("@") : [undefined, spec];
425
+ const s = await post("/ai/schedules", {
426
+ title: o.title ?? question.slice(0, 60), question, agentCode: context().agent ?? null, frequency, timeOfDay: time,
427
+ dayOfWeek: frequency === "weekly" ? Number(day) : null, dayOfMonth: frequency === "monthly" ? Number(day) : null, enabled: true,
428
+ });
429
+ emit(s, () => ok(tr(`Đã lên lịch · lần chạy tới ${when(s.nextRunAt)}`, `Scheduled · next run ${when(s.nextRunAt)}`)));
430
+ });
431
+ sch.command("run <id>").description(tr("chạy ngay một lần", "run once now")).action(async (id) => {
432
+ const s = await resolveSchedule(id);
433
+ const r = await post(`/ai/schedules/${s.id}/run`, {});
434
+ emit(r, () => ok(tr(`Đã chạy · xem: ${brand().bin} ai show ${r.lastSessionId ?? ""}`, `Ran · see: ${brand().bin} ai show ${r.lastSessionId ?? ""}`)));
435
+ });
436
+ sch.command("off <id>").description(tr("tắt lịch", "disable")).action(async (id) => toggleSchedule(id, false));
437
+ sch.command("on <id>").description(tr("bật lịch", "enable")).action(async (id) => toggleSchedule(id, true));
438
+ sch.command("delete <id>").description(tr("xoá lịch", "delete")).action(async (id) => {
439
+ const s = await resolveSchedule(id);
440
+ await del(`/ai/schedules/${s.id}`);
441
+ ok(tr("Đã xoá lịch.", "Schedule deleted."));
442
+ });
443
+ const lib = ai.command("library").description(tr("ảnh, tài liệu, tệp AI đã tạo", "images, documents and files the AI produced"));
444
+ lib.command("list", { isDefault: true }).option("--kind <kind>", "image | document | generated").action(async (o) => {
445
+ const list = await get("/ai/library", { query: { kind: o.kind } });
446
+ emit(list, () => info(table(["#", tr("Loại", "Kind"), tr("Tên", "Name"), tr("Lúc", "At")], list.map((x, i) => [i + 1, x.kind, x.name, when(x.createdAt)]))));
447
+ });
448
+ lib.command("get <n>").description(tr("tải mục thứ n về máy", "download item n")).option("-o, --out <file>")
449
+ .action(async (n, o) => {
450
+ const list = await get("/ai/library");
451
+ const item = list[Number(n) - 1];
452
+ if (!item)
453
+ throw new CliError(tr("Không có mục này.", "No such item."), Exit.notFound);
454
+ const { fetch } = await import("undici");
455
+ const res = await fetch(item.url);
456
+ const file = o.out ?? item.name;
457
+ writeFileSync(file, Buffer.from(await res.arrayBuffer()));
458
+ ok(tr(`Đã lưu ${file}`, `Saved ${file}`));
459
+ });
460
+ ai.command("agents").description(tr("các Agent bạn dùng được", "Agents available to you"))
461
+ .action(async () => {
462
+ const list = await get("/ai/agents");
463
+ emit(list, () => info(table(["Code", tr("Tên", "Name"), tr("Trạng thái", "Status"), tr("Mô tả", "Description")], list.map((a) => [a.code, a.name, a.status === "available" ? c.green(a.status) : c.dim(`${a.status}${a.requiredPlanName ? ` (${a.requiredPlanName})` : ""}`), a.description.slice(0, 60)]))));
464
+ });
465
+ ai.command("usage").description(tr("lượt AI tháng này", "AI turns this month"))
466
+ .action(async () => {
467
+ const u = await get("/operator/widget/support/usage");
468
+ emit(u, () => info(`${tr("Gói", "Plan")} ${c.bold(u.planName ?? "-")} · ${tr("đã dùng", "used")} ${u.used ?? 0}${u.limit ? `/${u.limit}` : ""} · ${tr("mở lại", "resets")} ${u.resetsAt?.slice(0, 10) ?? ""}`));
469
+ });
470
+ ai.command("feedback <messageId> <vote>").description(tr("báo câu trả lời tốt (up) / chưa tốt (down)", "rate an answer: up / down"))
471
+ .action(async (messageId, vote) => {
472
+ await post(`/operator/widget/support/messages/${messageId}/feedback`, { helpful: vote === "up" });
473
+ ok(tr("Cảm ơn bạn đã góp ý.", "Thanks for the feedback."));
474
+ });
475
+ ai.command("support <problem...>").description(tr("chuyển cho nhân viên hỗ trợ (mở ticket)", "hand over to a support agent (opens a ticket)"))
476
+ .action(async (problem) => {
477
+ const sessionId = getProfile(context().profile)?.lastConversation ?? (await openConversation(false)).sessionId;
478
+ const r = await post("/operator/widget/support/escalate", { sessionId, topic: problem.join(" ") });
479
+ emit(r, () => ok(tr(`Đã chuyển (${r.status}) · chờ khoảng ${r.estimatedWaitMinutes} phút`, `Handed over (${r.status}) · about ${r.estimatedWaitMinutes} min`)));
480
+ });
481
+ }
482
+ async function resolveMemory(id) {
483
+ const m = (await get("/ai/memory")).find((x) => x.id.startsWith(id));
484
+ if (!m)
485
+ throw new CliError(tr("Không thấy điều nhớ này.", "Memory not found."), Exit.notFound);
486
+ return m;
487
+ }
488
+ async function resolveSchedule(id) {
489
+ const s = (await get("/ai/schedules")).find((x) => x.id.startsWith(id));
490
+ if (!s)
491
+ throw new CliError(tr("Không thấy lịch này.", "Schedule not found."), Exit.notFound);
492
+ return s;
493
+ }
494
+ async function toggleSchedule(id, enabled) {
495
+ const s = await resolveSchedule(id);
496
+ await put(`/ai/schedules/${s.id}`, { title: s.title, question: s.question, agentCode: s.agentCode, frequency: s.frequency, timeOfDay: s.timeOfDay, dayOfWeek: s.dayOfWeek, dayOfMonth: s.dayOfMonth, enabled });
497
+ ok(enabled ? tr("Đã bật lịch.", "Enabled.") : tr("Đã tắt lịch.", "Disabled."));
498
+ }
499
+ export function runLines(run) {
500
+ const out = [c.bold(String(run.question ?? "")), c.dim(` ${run.status ?? ""} · ${run.elapsedSeconds ?? "?"}s · run ${String(run.id ?? "").slice(0, 8)}`)];
501
+ const steps = (run.steps ?? run.trace ?? run.events);
502
+ for (const s of steps ?? []) {
503
+ const mark = s.kind === "tool" ? c.cyan("⏺") : c.dim("·");
504
+ out.push(` ${mark} ${s.label ?? s.toolName ?? s.kind ?? ""}${s.elapsedMs ? c.dim(` +${(s.elapsedMs / 1000).toFixed(1)}s`) : ""}${s.error ? c.red(` ${s.error}`) : ""}`);
505
+ }
506
+ const tools = run.toolCalls;
507
+ for (const tc of tools ?? [])
508
+ out.push(` ${c.cyan("⏺")} ${tc.name ?? ""} ${c.dim(`${tc.status ?? ""} ${tc.durationMs ?? ""}ms`)}`);
509
+ return out;
510
+ }
511
+ export function printRun(run) {
512
+ for (const l of runLines(run))
513
+ info(l);
514
+ }
@@ -0,0 +1,87 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { context } from "../core/context.js";
3
+ import { CliError, Exit } from "../core/errors.js";
4
+ import { tr } from "../core/i18n.js";
5
+ import { c, info, json } from "../core/output.js";
6
+ import { apiPath, request } from "../core/http.js";
7
+ import { confirm, hasTty } from "../core/prompt.js";
8
+ import { parseArgs } from "./tools.js";
9
+ /** `.items[0].name` style selection, enough for scripting without a jq dependency. */
10
+ export function select(value, path) {
11
+ const parts = path.replace(/^\./, "").match(/[^.[\]]+|\[\d+\]/g) ?? [];
12
+ let v = value;
13
+ for (const p of parts) {
14
+ if (v === null || v === undefined)
15
+ return undefined;
16
+ if (p.startsWith("["))
17
+ v = v[Number(p.slice(1, -1))];
18
+ else if (Array.isArray(v))
19
+ v = v.map((x) => x?.[p]);
20
+ else
21
+ v = v[p];
22
+ }
23
+ return v;
24
+ }
25
+ export function registerApi(program) {
26
+ program
27
+ .command("api <method> <path>")
28
+ .description(tr("gọi thẳng API (vd: api GET /crm/contacts -f q=Minh)", "call the API directly (e.g. api GET /crm/contacts -f q=Minh)"))
29
+ .option("-f, --raw-field <key=value>", tr("tham số dạng chuỗi: query với GET/DELETE, thân JSON với POST/PUT/PATCH (lặp được)", "string parameter: query for GET/DELETE, JSON body for POST/PUT/PATCH (repeatable)"), (v, p = []) => [...p, v])
30
+ .option("-F, --field <key=value>", tr("như -f nhưng đoán kiểu: số, true/false, JSON", "like -f but typed: numbers, true/false, JSON"), (v, p = []) => [...p, v])
31
+ .option("--input <file>", tr("thân request từ tệp JSON (- = stdin)", "request body from a JSON file (- = stdin)"))
32
+ .option("-H, --header <K: V>", tr("thêm header (lặp được)", "extra header (repeatable)"), (v, p = []) => [...p, v])
33
+ .option("--paginate", tr("đi hết các trang (kết quả dạng items/pageNumber)", "follow all pages (items/pageNumber results)"))
34
+ .option("--select <path>", tr("chỉ in một phần, vd .items[0].name", "print only part of the result, e.g. .items[0].name"))
35
+ .option("-i, --include", tr("in cả mã trạng thái và header", "print status and headers too"))
36
+ .option("--idempotency-key <key>", tr("khoá chống trùng cho POST", "idempotency key for POST"))
37
+ .action(async (methodRaw, path, o) => {
38
+ const method = methodRaw.toUpperCase();
39
+ if (!["GET", "POST", "PUT", "PATCH", "DELETE"].includes(method))
40
+ throw new CliError(tr(`Phương thức không hợp lệ: ${methodRaw}`, `Invalid method: ${methodRaw}`), Exit.usage);
41
+ const fields = parseArgs({ raw: o.rawField, arg: o.field });
42
+ const headers = {};
43
+ for (const h of o.header ?? []) {
44
+ const i = h.indexOf(":");
45
+ if (i > 0)
46
+ headers[h.slice(0, i).trim()] = h.slice(i + 1).trim();
47
+ }
48
+ const hasBody = method !== "GET" && method !== "DELETE";
49
+ let body;
50
+ if (o.input)
51
+ body = JSON.parse(readFileSync(o.input === "-" ? 0 : o.input, "utf8"));
52
+ if (hasBody && Object.keys(fields).length)
53
+ body = { ...body, ...fields };
54
+ const query = hasBody ? undefined : Object.fromEntries(Object.entries(fields).map(([k, v]) => [k, typeof v === "object" ? JSON.stringify(v) : v]));
55
+ if (method !== "GET" && hasTty()) {
56
+ const env = context().env === "prod" ? c.red("PROD") : c.yellow("SANDBOX");
57
+ if (!(await confirm(`${method} ${apiPath(path)} · ${env}?`)))
58
+ return;
59
+ }
60
+ const out = [];
61
+ let page = Number(query?.pageNumber ?? query?.page ?? 1);
62
+ for (;;) {
63
+ const q = o.paginate ? { ...query, pageNumber: page } : query;
64
+ const r = await request(method, path, { query: q, body, headers, idempotencyKey: o.idempotencyKey });
65
+ if (o.include) {
66
+ info(c.dim(`HTTP ${r.status}`));
67
+ r.headers.forEach?.((v, k) => info(c.dim(`${k}: ${v}`)));
68
+ }
69
+ const d = r.data;
70
+ if (!o.paginate || !d || !Array.isArray(d.items)) {
71
+ out.push(r.data);
72
+ break;
73
+ }
74
+ out.push(...d.items);
75
+ const more = d.hasNextPage ?? (d.totalPages !== undefined ? page < d.totalPages : d.items.length > 0);
76
+ if (!more || d.items.length === 0)
77
+ break;
78
+ page++;
79
+ }
80
+ const value = o.paginate ? out : out[0];
81
+ const shown = o.select ? select(value, o.select) : value;
82
+ if (typeof shown === "string")
83
+ info(shown);
84
+ else if (shown !== undefined)
85
+ json(shown);
86
+ });
87
+ }