@nextxai/cli 0.1.1 → 0.3.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.
@@ -10,7 +10,10 @@ import { get, post, put, del } from "../core/http.js";
10
10
  import { getProfile, updateProfile } from "../core/config.js";
11
11
  import { hasTty, confirm, confirmTyped, select } from "../core/prompt.js";
12
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";
13
+ import { ask, attach, attachmentKind, composeText, confirmAction, listConversations, openConversation, renameConversation, pinConversation, deleteConversation, history, riskOf, confirmWord, } from "../ai/client.js";
14
+ import { LocalChat } from "../ai/local.js";
15
+ import { mcpToken } from "../auth/mcp-oauth.js";
16
+ import { defaultModel, resolveModel } from "../ai/models.js";
14
17
  // ── NEXTX.md ───────────────────────────────────────────────────────────────────────────────────────
15
18
  const NOTES_MAX = 4096;
16
19
  export function notesFileName() {
@@ -64,7 +67,8 @@ export function renderBlocks(blocks) {
64
67
  export function proposalCard(a) {
65
68
  const env = context().env === "prod" ? c.red(tr("GHI VÀO PROD", "WRITES TO PROD")) : c.yellow("SANDBOX");
66
69
  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 · ")) : "";
70
+ const tag = risk === "large" ? c.red(tr("GHI LỚN · ", "LARGE WRITE · ")) : risk === "finance" ? c.red(tr("TÀI CHÍNH · ", "FINANCE · "))
71
+ : risk === "device" ? c.red(tr("THIẾT BỊ · ", "DEVICE · ")) : "";
68
72
  const left = a.expiresAt ? Math.max(0, Math.round((Date.parse(a.expiresAt) - Date.now()) / 60000)) : 15;
69
73
  const lines = [
70
74
  c.yellow(`┌ ${tr("Đề xuất", "Proposal")} `) + tag + env + c.yellow(` · ${tr(`hết hạn sau ${left} phút`, `expires in ${left} min`)}`),
@@ -83,22 +87,59 @@ export function proposalCard(a) {
83
87
  * Rules (docs §2.6): read mode never writes; finance never from the terminal; no TTY ⇒ never;
84
88
  * large writes need the typed number; auto mode skips the y/N for normal writes only.
85
89
  */
90
+ /** Thẻ đăng nhập CI (login --ci): không có người bấm, máy chủ quyết theo chính sách duyệt trước (ci policy). */
91
+ async function isCiSession() {
92
+ const { currentAuth } = await import("../auth/session.js");
93
+ const a = await currentAuth().catch(() => undefined);
94
+ return a?.kind === "session" && a.session.via === "ci";
95
+ }
96
+ /** Lệnh để người xác nhận đề xuất sau: nền tảng mặc định mở lại hội thoại, nền tảng khác có thể theo id đề xuất. */
97
+ function confirmHint(nonce, conversationId) {
98
+ return `${brand().bin} -r ${conversationId}`;
99
+ }
100
+ /** 403 của cổng ghi CI ⇒ việc chưa ghi, đề xuất còn chờ người duyệt — báo rõ, không coi là lỗi chương trình. */
101
+ function ciDenied(e) {
102
+ const err = e;
103
+ return err instanceof CliError && err.exitCode === Exit.forbidden ? err.message : undefined;
104
+ }
86
105
  export async function handleProposal(sessionId, a, mode = context().mode) {
87
106
  if (!a.writes) {
88
107
  const r = await confirmAction(sessionId, a);
89
108
  return { written: false, message: r.message };
90
109
  }
91
110
  const risk = riskOf(a);
92
- const resume = `${brand().bin} -r ${sessionId}`;
111
+ // Đề xuất của nền tảng mặc định xác nhận bằng cách mở lại hội thoại.
112
+ let resume = `${brand().bin} -r ${sessionId}`;
93
113
  if (mode === "read") {
94
114
  info(c.dim(tr(" Chế độ chỉ đọc: không ghi. Đổi chế độ: --mode propose", " Read-only mode: nothing written. Switch with --mode propose")));
95
115
  return { written: false };
96
116
  }
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}`)));
117
+ if (risk === "finance" || risk === "device") {
118
+ info(c.dim(risk === "device"
119
+ ? tr(` Việc chạm thiết bị / tự động hoá chỉ xác nhận trong app: ${context().endpoints.app}`, ` Device and automation actions are confirmed in the app only: ${context().endpoints.app}`)
120
+ : 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
121
  return { written: false };
100
122
  }
101
123
  if (!hasTty()) {
124
+ // CI + --mode auto: gửi Xác nhận, máy chủ chỉ ghi trong chính sách duyệt trước của kho này (ci policy).
125
+ if (brand().platform === "nextx" && mode === "auto" && (await isCiSession())) {
126
+ try {
127
+ const r = await confirmAction(sessionId, a);
128
+ if (r.success)
129
+ ok(r.message || tr("Đã ghi (CI, trong phạm vi duyệt trước).", "Written (CI, within the pre-approved scope)."));
130
+ else
131
+ warn(r.message);
132
+ return { written: r.success, message: r.message };
133
+ }
134
+ catch (e) {
135
+ const why = ciDenied(e);
136
+ if (!why)
137
+ throw e;
138
+ info(` ${c.yellow("⚠")} ${why}`);
139
+ info(c.dim(tr(` Cho CI tự ghi: ${brand().bin} ci policy request`, ` Let CI write: ${brand().bin} ci policy request`)));
140
+ return { written: false, message: why };
141
+ }
142
+ }
102
143
  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
144
  return { written: false };
104
145
  }
@@ -154,17 +195,59 @@ export async function resolveConversation(o) {
154
195
  }
155
196
  export async function runRoot(o, words) {
156
197
  const printMode = o.print !== undefined && o.print !== false;
198
+ const spec = o.model ?? defaultModel();
199
+ const model = spec ? resolveModel(spec) : undefined;
157
200
  if (!printMode && hasTty() && outputFormat() === "text") {
158
201
  await requirePerson();
202
+ // The MCP grant may need a browser round-trip; do it before the chat UI owns the terminal.
203
+ if (model)
204
+ await mcpToken({ interactive: true });
159
205
  const { startRepl } = await import("../repl/index.js");
160
- return withWorkspace(context().workspace, () => startRepl({ flags: o, initialQuestion: words.join(" ").trim() || undefined }));
206
+ return withWorkspace(context().workspace, () => startRepl({ flags: o, initialQuestion: words.join(" ").trim() || undefined, model }));
161
207
  }
162
208
  const q = [typeof o.print === "string" ? o.print : "", ...words].join(" ").trim();
163
209
  const piped = await readStdin();
164
210
  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);
211
+ throw new CliError(tr(`Thiếu câu hỏi. Ví dụ: ${brand().bin} -p "${brand().example.vi}"`, `Missing question. Example: ${brand().bin} -p "${brand().example.en}"`), Exit.usage);
166
212
  await requirePerson();
167
- await withWorkspace(context().workspace, () => printAsk(o, q || tr("Xử lý nội dung đính kèm.", "Process the attached content."), piped));
213
+ const question = q || tr("Xử lý nội dung đính kèm.", "Process the attached content.");
214
+ await withWorkspace(context().workspace, () => (model ? printLocal(o, model, question, piped) : printAsk(o, question, piped)));
215
+ }
216
+ /** -p with --model: same output contract as the platform assistant, answered by the user's model. */
217
+ async function printLocal(o, model, question, piped) {
218
+ const attachments = [];
219
+ for (const f of (o.file ?? []).slice(0, 3)) {
220
+ if (attachmentKind(f) !== "text")
221
+ throw new CliError(tr(`Model riêng chỉ đọc tệp chữ: ${f}`, `Your own model reads text files only: ${f}`), Exit.usage);
222
+ attachments.push(await attach(f));
223
+ }
224
+ if (piped)
225
+ attachments.push({ path: "-", name: "stdin", kind: "text", text: piped.slice(0, 20_000) });
226
+ const fmt = outputFormat();
227
+ const chat = new LocalChat(model, hasTty(), context().mode === "read");
228
+ const outcome = await chat.ask({
229
+ text: composeText(question, attachments, findProjectNotes()?.text),
230
+ onChunk: (ch) => {
231
+ if (fmt === "stream-json")
232
+ return json(ch);
233
+ if (fmt !== "text")
234
+ return;
235
+ if (ch.type === "token")
236
+ process.stdout.write(ch.text ?? "");
237
+ else if (ch.type === "activity" && ch.activity && !o.quiet)
238
+ process.stderr.write(c.dim(`⏺ ${ch.activity.label}\n`));
239
+ },
240
+ });
241
+ if (fmt === "json")
242
+ json({ model: model.label, text: outcome.text, notices: outcome.notices, tools: outcome.activities.map((a) => a.toolName), workspace: getProfile(context().profile)?.workspaceCode ?? null, env: context().env, ended: outcome.ended });
243
+ else if (fmt === "text") {
244
+ if (outcome.text && !outcome.text.endsWith("\n"))
245
+ process.stdout.write("\n");
246
+ for (const n of outcome.notices)
247
+ process.stderr.write(`${c.yellow("⚠")} ${n.message}\n`);
248
+ }
249
+ if (outcome.ended === "error")
250
+ process.exitCode = Exit.general;
168
251
  }
169
252
  async function printAsk(o, question, piped) {
170
253
  const sessionId = await resolveConversation(o);
@@ -195,11 +278,26 @@ async function printAsk(o, question, piped) {
195
278
  },
196
279
  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
280
  });
198
- updateProfile(context().profile, { lastConversation: sessionId });
281
+ const conversationId = outcome.sessionId || sessionId;
282
+ updateProfile(context().profile, { lastConversation: conversationId });
199
283
  const proposal = outcome.proposedAction;
284
+ // Written in this run (CI pre-approval, or confirmed at the prompt) ⇒ nothing is left to confirm, exit 0.
285
+ let written = false;
200
286
  if (fmt === "json") {
287
+ const risk = proposal ? riskOf(proposal) : undefined;
288
+ const allowedRisk = context().mode !== "read" && risk !== "finance" && risk !== "device";
289
+ let applied;
290
+ // Nền tảng mặc định: phiên CI + --mode auto ⇒ gửi Xác nhận, máy chủ chỉ ghi trong chính sách duyệt trước (403 ⇒ chưa ghi, kèm lý do).
291
+ if (!applied && proposal?.writes && brand().platform === "nextx" && allowedRisk && context().mode === "auto" && !hasTty() && (await isCiSession()))
292
+ applied = await confirmAction(conversationId, proposal).then((r) => ({ written: r.success, message: r.message }), (e) => {
293
+ const why = ciDenied(e);
294
+ if (!why)
295
+ throw e;
296
+ return { written: false, message: why };
297
+ });
298
+ written = applied?.written ?? false;
201
299
  json({
202
- conversationId: sessionId,
300
+ conversationId,
203
301
  runId: outcome.runId ?? null,
204
302
  messageId: outcome.done?.messageId ?? null,
205
303
  text: outcome.text,
@@ -207,7 +305,9 @@ async function printAsk(o, question, piped) {
207
305
  followUps: outcome.done?.followUps ?? [],
208
306
  notices: outcome.notices,
209
307
  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}` }] : [],
308
+ proposals: proposal ? [{ kind: proposal.kind, label: proposal.label, prompt: proposal.confirmPrompt, writes: proposal.writes, expiresAt: proposal.expiresAt ?? null, risk: riskOf(proposal),
309
+ confirm: confirmHint(proposal.nonce, conversationId),
310
+ ...(applied ? { applied: { written: applied.written, message: applied.message } } : {}) }] : [],
211
311
  workspace: getProfile(context().profile)?.workspaceCode ?? null,
212
312
  env: context().env,
213
313
  ended: outcome.ended,
@@ -225,12 +325,12 @@ async function printAsk(o, question, piped) {
225
325
  if (proposal) {
226
326
  for (const l of proposalCard(proposal))
227
327
  stderr.write(`${l}\n`);
228
- await handleProposal(sessionId, proposal);
328
+ written = (await handleProposal(conversationId, proposal)).written;
229
329
  }
230
330
  }
231
331
  if (outcome.notices.some((n) => n.code === "ai_not_purchased"))
232
332
  process.exitCode = Exit.forbidden;
233
- else if (proposal?.writes)
333
+ else if (proposal?.writes && !written)
234
334
  process.exitCode = Exit.notConfirmed;
235
335
  else if (outcome.ended === "error")
236
336
  process.exitCode = Exit.general;
@@ -352,13 +452,16 @@ export function registerAi(program) {
352
452
  const head = await findChangeset(id);
353
453
  if (context().mode === "read")
354
454
  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);
455
+ const ci = !hasTty() && (await isCiSession());
456
+ if (!hasTty() && !ci)
457
+ throw new CliError(tr("Áp dụng bản nháp cần terminal tương tác (hoặc đăng nhập CI có phạm vi duyệt trước).", "Applying needs an interactive terminal (or a CI login with a pre-approved scope)."), Exit.notConfirmed);
357
458
  const env = context().env === "prod" ? c.red("PROD") : c.yellow("SANDBOX");
358
459
  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."));
460
+ if (!ci) {
461
+ const yes = head.risk >= 3 ? await confirmTyped("", tr("dong-y", "confirm")) : await confirm(tr("Áp dụng?", "Apply?"));
462
+ if (!yes)
463
+ return void info(tr("Không ghi gì.", "Nothing written."));
464
+ }
362
465
  const r = await post(`/ai/changesets/${head.id}/apply`, undefined, { query: { expectedVersion: head.version } });
363
466
  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
467
  });
@@ -464,7 +567,7 @@ export function registerAi(program) {
464
567
  });
465
568
  ai.command("usage").description(tr("lượt AI tháng này", "AI turns this month"))
466
569
  .action(async () => {
467
- const u = await get("/operator/widget/support/usage");
570
+ const u = await (await import("../ai/client.js")).usage();
468
571
  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
572
  });
470
573
  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"))
@@ -1,4 +1,5 @@
1
1
  import { readFileSync } from "node:fs";
2
+ import { brand } from "../core/brand.js";
2
3
  import { context } from "../core/context.js";
3
4
  import { CliError, Exit } from "../core/errors.js";
4
5
  import { tr } from "../core/i18n.js";
@@ -25,7 +26,7 @@ export function select(value, path) {
25
26
  export function registerApi(program) {
26
27
  program
27
28
  .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
+ .description(tr(`gọi thẳng API (vd: api ${brand().apiExample})`, `call the API directly (e.g. api ${brand().apiExample})`))
29
30
  .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
31
  .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
32
  .option("--input <file>", tr("thân request từ tệp JSON (- = stdin)", "request body from a JSON file (- = stdin)"))
@@ -61,7 +62,7 @@ export function registerApi(program) {
61
62
  let page = Number(query?.pageNumber ?? query?.page ?? 1);
62
63
  for (;;) {
63
64
  const q = o.paginate ? { ...query, pageNumber: page } : query;
64
- const r = await request(method, path, { query: q, body, headers, idempotencyKey: o.idempotencyKey });
65
+ const r = await request(method, path, { query: q, body, headers, idempotencyKey: o.idempotencyKey, verbatim: true });
65
66
  if (o.include) {
66
67
  info(c.dim(`HTTP ${r.status}`));
67
68
  r.headers.forEach?.((v, k) => info(c.dim(`${k}: ${v}`)));
@@ -21,7 +21,7 @@ export function browserAvailable() {
21
21
  }
22
22
  export async function usage() {
23
23
  try {
24
- return await get("/operator/widget/support/usage");
24
+ return await (await import("../ai/client.js")).usage();
25
25
  }
26
26
  catch {
27
27
  return undefined;
@@ -31,8 +31,8 @@ function printLogin(r) {
31
31
  const p = getProfile(context().profile);
32
32
  ok(tr(`Đã đăng nhập ${c.bold(r.userName ?? "")} (${r.userEmail ?? ""})`, `Logged in as ${c.bold(r.userName ?? "")} (${r.userEmail ?? ""})`));
33
33
  info(` ${tr("Workspace", "Workspace")} ${c.bold(r.workspaceName ?? p?.workspaceName ?? "")} · ${context().env === "prod" ? "prod" : c.yellow("sandbox")}`);
34
- info(` ${tr("Phiên", "Session")} ${tr("tự gia hạn khi dùng, hết hạn nếu bỏ không 30 ngày", "renews while used, expires after 30 idle days")} · ${storageKind() === "keychain" ? "Keychain" : tr("tệp 0600", "0600 file")}`);
35
- info(c.dim(` ${tr("Thử", "Try")}: ${brand().bin} · ${brand().bin} -p "${tr("doanh thu hôm qua", "yesterday's revenue")}" · ${brand().bin} help`));
34
+ info(` ${tr("Phiên", "Session")} ${r.session.via === "ci" ? tr("15 phút, không gia hạn — hết thì đăng nhập CI lại", "15 minutes, no renewal — logs in again through CI when it ends") : tr("tự gia hạn khi dùng, hết hạn nếu bỏ không 30 ngày", "renews while used, expires after 30 idle days")} · ${storageKind() === "keychain" ? "Keychain" : tr("tệp 0600", "0600 file")}`);
35
+ info(c.dim(` ${tr("Thử", "Try")}: ${brand().bin} · ${brand().bin} -p "${tr(brand().example.vi, brand().example.en)}" · ${brand().bin} help`));
36
36
  }
37
37
  export function registerAuth(program) {
38
38
  const b = brand();
@@ -47,6 +47,10 @@ export function registerAuth(program) {
47
47
  .action(async (o) => {
48
48
  const ws = context().workspace;
49
49
  let r;
50
+ if ((o.ci && b.notYet?.includes("ci")) || (o.device && b.notYet?.includes("device"))) {
51
+ const { notYetError } = await import("../core/errors.js");
52
+ throw notYetError(tr(`${b.name} chưa mở ${o.ci ? "đăng nhập CI bằng OIDC" : "đăng nhập bằng mã thiết bị"}`, `${b.name} does not offer ${o.ci ? "CI OIDC login" : "device-code login"} yet`), o.ci ? tr(`dùng khoá API: ${b.envPrefix}_API_KEY`, `use an API key: ${b.envPrefix}_API_KEY`) : tr(`dùng ${b.bin} login --qr (quét bằng app ${b.mobileApp}) hoặc --no-browser`, `use ${b.bin} login --qr (scan with the ${b.mobileApp} app) or --no-browser`));
53
+ }
50
54
  if (o.ci)
51
55
  r = await loginCi({ workspace: ws });
52
56
  else if (o.device)
@@ -0,0 +1,98 @@
1
+ import { brand } from "../core/brand.js";
2
+ import { CliError, Exit } from "../core/errors.js";
3
+ import { tr } from "../core/i18n.js";
4
+ import { c, emit, info, ok, table } from "../core/output.js";
5
+ import { get, post } from "../core/http.js";
6
+ import { confirm, hasTty } from "../core/prompt.js";
7
+ import { context } from "../core/context.js";
8
+ import { requirePerson } from "../auth/session.js";
9
+ import { parseDuration } from "./keys.js";
10
+ const BASE = "/ai/ci-write-policies";
11
+ // Ví dụ + nơi duyệt theo nền tảng: có nền tảng ghi theo loại dòng nhật ký, nền tảng mặc định theo mã quyền ghi.
12
+ const exampleKinds = () => brand().examples.ciKinds;
13
+ function approvePlace() {
14
+ return tr("Thiết lập › Ứng dụng & API › CI", "Settings › Apps & API › CI");
15
+ }
16
+ const day = (s) => (s ? s.slice(0, 10) : "—");
17
+ const shortId = (id) => id.slice(-8);
18
+ function stateLabel(s) {
19
+ switch (s) {
20
+ case "active": return c.green(tr("đang hiệu lực", "active"));
21
+ case "pending": return c.yellow(tr("chờ duyệt", "pending"));
22
+ case "exhausted": return c.dim(tr("hết trần", "ceiling reached"));
23
+ case "expired": return c.dim(tr("hết hạn", "expired"));
24
+ default: return c.red(tr("đã thu hồi", "revoked"));
25
+ }
26
+ }
27
+ async function findPolicy(id) {
28
+ // Id UUID v7: 8 ký tự ĐẦU trùng nhau giữa các chính sách tạo cùng lúc (tiền tố thời gian) ⇒ bảng hiện 8 ký tự CUỐI, tìm khớp cả hai đầu.
29
+ const all = (await get(BASE)).items;
30
+ const hits = all.filter((x) => x.id === id || x.id.startsWith(id) || x.id.endsWith(id));
31
+ if (hits.length > 1)
32
+ throw new CliError(tr("Mã ngắn trùng nhiều chính sách — gõ thêm ký tự.", "Short id matches several policies — type more characters."), Exit.usage);
33
+ const p = hits[0];
34
+ if (!p)
35
+ throw new CliError(tr("Không thấy chính sách này.", "Policy not found."), Exit.notFound);
36
+ return p;
37
+ }
38
+ export function registerCiPolicy(ci) {
39
+ const policy = ci.command("policy")
40
+ .description(tr("chính sách duyệt trước: cho CI tự ghi trong trần", "pre-approval policies: let CI write within a ceiling"));
41
+ policy.hook("preAction", async () => { await requirePerson(); });
42
+ policy.command("list", { isDefault: true }).action(async () => {
43
+ const { items } = await get(BASE);
44
+ emit(items, () => info(items.length
45
+ ? table(["ID", tr("Kho", "Repository"), tr("Được ghi", "Kinds"), tr("Đã dùng", "Used"), tr("Hết hạn", "Expires"), ""], items.map((p) => [
46
+ shortId(p.id), p.repository ?? shortId(p.ciTrustId), p.kinds.join(", "), `${p.usedRecords}/${p.maxRecords}`, day(p.expiresAt), stateLabel(p.state),
47
+ ]))
48
+ : tr(`Chưa có chính sách — CI chỉ in link để xác nhận. Xin: ${brand().bin} ci policy request --trust <kho> --kinds ${exampleKinds()}`, `No policy — CI only prints a link to confirm. Request one: ${brand().bin} ci policy request --trust <repo> --kinds ${exampleKinds()}`)));
49
+ });
50
+ policy.command("kinds").description(tr("loại ghi đưa được vào chính sách", "kinds a policy can cover")).action(async () => {
51
+ const { items } = await get(`${BASE}/kinds`);
52
+ emit(items, () => info(items.join("\n")));
53
+ });
54
+ policy.command("show <id>").action(async (id) => {
55
+ const p = await findPolicy(id);
56
+ emit(p, () => {
57
+ info(`${c.bold(p.repository ?? p.ciTrustId)} ${stateLabel(p.state)}`);
58
+ info(tr(`Được ghi: ${p.kinds.join(", ")}`, `Kinds: ${p.kinds.join(", ")}`));
59
+ info(tr(`Đã dùng: ${p.usedRecords}/${p.maxRecords} · hết hạn ${day(p.expiresAt)} · ghi lần cuối ${day(p.lastUsedAt)}`, `Used: ${p.usedRecords}/${p.maxRecords} · expires ${day(p.expiresAt)} · last write ${day(p.lastUsedAt)}`));
60
+ if (p.note)
61
+ info(c.dim(p.note));
62
+ });
63
+ });
64
+ policy.command("request")
65
+ .description(tr("xin cho một kho CI tự ghi (người quản lý khoá API duyệt; có quyền thì có hiệu lực ngay)", "ask to let a CI repository write (an API-key manager approves; with that permission it is active at once)"))
66
+ .requiredOption("--trust <id|owner/repo>", tr("khai báo CI (xem: ci trust list)", "CI trust (see: ci trust list)"))
67
+ .requiredOption("--kinds <a,b>", tr("loại ghi (xem: ci policy kinds)", "kinds (see: ci policy kinds)"))
68
+ .option("--max <n>", tr("trần số bản ghi (1–1000)", "record ceiling (1–1000)"), "50")
69
+ .option("--expires <dur>", tr("thời hạn tối đa 30d", "lifetime, at most 30d"), "14d")
70
+ .option("--note <text>")
71
+ .option("--approve", tr("duyệt luôn (cần quyền quản lý khoá API)", "approve now (needs API-key manage permission)"))
72
+ .action(async (o) => {
73
+ const want = o.trust.trim().toLowerCase();
74
+ const trust = (await get("/iam/ci-trusts")).find((t) => !t.revokedAt && (t.id.startsWith(want) || t.repository === want));
75
+ if (!trust)
76
+ throw new CliError(tr(`Không thấy kho CI "${o.trust}".`, `No CI trust "${o.trust}".`), Exit.notFound, `${brand().bin} ci trust list`);
77
+ const max = Number(o.max);
78
+ if (!Number.isInteger(max) || max < 1 || max > 1000)
79
+ throw new CliError(tr("--max từ 1 đến 1000.", "--max must be 1–1000."), Exit.usage);
80
+ const p = await post(BASE, {
81
+ ciTrustId: trust.id, repository: trust.repository, kinds: o.kinds.split(",").map((k) => k.trim()).filter(Boolean),
82
+ maxRecords: max, expiresAt: parseDuration(o.expires), note: o.note ?? null, approve: !!o.approve,
83
+ });
84
+ emit(p, () => {
85
+ ok(p.state === "active"
86
+ ? tr(`CI của ${trust.repository} được tự ghi ${p.kinds.join(", ")} — tối đa ${p.maxRecords} bản ghi tới ${day(p.expiresAt)}.`, `CI of ${trust.repository} may write ${p.kinds.join(", ")} — up to ${p.maxRecords} records until ${day(p.expiresAt)}.`)
87
+ : tr(`Đã gửi duyệt (${shortId(p.id)}). Người quản lý duyệt tại ${context().endpoints.app} › ${approvePlace()}.`, `Sent for approval (${shortId(p.id)}). A manager approves in ${context().endpoints.app} › ${approvePlace()}.`));
88
+ });
89
+ });
90
+ policy.command("revoke <id>").description(tr("thu hồi (lượt ghi kế tiếp quay về chỉ in link)", "revoke (the next write goes back to a link)"))
91
+ .action(async (id) => {
92
+ const p = await findPolicy(id);
93
+ if (hasTty() && !(await confirm(tr(`Thu hồi chính sách của ${p.repository ?? shortId(p.id)}?`, `Revoke the policy for ${p.repository ?? shortId(p.id)}?`))))
94
+ return;
95
+ await post(`${BASE}/${p.id}/revoke`, {});
96
+ ok(tr("Đã thu hồi.", "Revoked."));
97
+ });
98
+ }
@@ -0,0 +1,96 @@
1
+ import { brand } from "../core/brand.js";
2
+ import { CliError, Exit } from "../core/errors.js";
3
+ import { tr } from "../core/i18n.js";
4
+ import { c, emit, info, ok, table } from "../core/output.js";
5
+ import { get, post } from "../core/http.js";
6
+ import { confirm, hasTty } from "../core/prompt.js";
7
+ import { requirePerson } from "../auth/session.js";
8
+ import { parseDuration } from "./keys.js";
9
+ import { registerCiPolicy } from "./ci-policy.js";
10
+ async function findTrust(id) {
11
+ const t = (await get("/iam/ci-trusts")).find((x) => x.id.startsWith(id) || x.repository === id);
12
+ if (!t)
13
+ throw new CliError(tr("Không thấy khai báo này.", "Not found."), Exit.notFound);
14
+ return t;
15
+ }
16
+ const when = (s) => (s ? s.slice(0, 16).replace("T", " ") : "—");
17
+ export function workflowSample(repo = "acme/web", ws = "my-workspace") {
18
+ const b = brand();
19
+ return `# .github/workflows/report.yml (${repo})
20
+ permissions:
21
+ id-token: write # cho phép lấy token OIDC — không cần secret nào
22
+ contents: read
23
+ jobs:
24
+ report:
25
+ runs-on: ubuntu-latest
26
+ steps:
27
+ - run: npx -y ${b.pkg} login --ci --workspace ${ws}
28
+ - run: npx -y ${b.pkg} -p "${tr(b.example.vi, b.example.en)}" --json > report.json`;
29
+ }
30
+ /** Mẫu CI có GHI: chỉ chạy được sau khi quản trị mở phạm vi (ci trust set --allow … --max-per-run …). */
31
+ export function workflowWriteSample(repo = "acme/web", ws = "my-workspace") {
32
+ const b = brand();
33
+ return `# .github/workflows/follow-up.yml (${repo})
34
+ # ${tr("Ghi chỉ trong chính sách duyệt trước (ci policy request); ngoài phạm vi thì để lại đề xuất chờ người duyệt (mã thoát 7).", "Writes only within an approved policy (ci policy request); anything else stays a proposal for a person (exit code 7).")}
35
+ permissions:
36
+ id-token: write
37
+ contents: read
38
+ jobs:
39
+ follow-up:
40
+ runs-on: ubuntu-latest
41
+ steps:
42
+ - run: npx -y ${b.pkg} login --ci --workspace ${ws}
43
+ - run: npx -y ${b.pkg} -p "${tr("tạo việc gọi lại cho khách quá hạn chăm sóc 7 ngày", "create call-back tasks for customers not contacted in 7 days")}" --mode auto`;
44
+ }
45
+ export function registerCi(program) {
46
+ const ci = program.command("ci").description(tr("đăng nhập từ CI không cần bí mật (OIDC)", "secretless CI login (OIDC)"));
47
+ const trust = ci.command("trust").description(tr("kho CI được phép đăng nhập workspace này", "CI repositories allowed to log in to this workspace"));
48
+ trust.hook("preAction", async () => {
49
+ if (brand().notYet?.includes("ci")) {
50
+ const { notYetError } = await import("../core/errors.js");
51
+ throw notYetError(tr(`${brand().name} chưa mở đăng nhập CI bằng OIDC`, `${brand().name} does not offer CI OIDC login yet`), tr(`dùng khoá API: ${brand().envPrefix}_API_KEY`, `use an API key: ${brand().envPrefix}_API_KEY`));
52
+ }
53
+ await requirePerson();
54
+ });
55
+ trust.command("list", { isDefault: true }).action(async () => {
56
+ const list = await get("/iam/ci-trusts");
57
+ emit(list, () => info(list.length
58
+ ? table(["ID", tr("Kho", "Repository"), "Ref", "Env", tr("Dùng lần cuối", "Last used"), ""], list.map((t) => [
59
+ t.id.slice(0, 8), `${t.provider}:${t.repository}`, t.refPattern, t.environment ?? "", when(t.lastUsedAt), t.revokedAt ? c.red(tr("đã thu hồi", "revoked")) : c.green("●"),
60
+ ]))
61
+ : tr(`Chưa có kho nào. Thêm: ${brand().bin} ci trust add --repo owner/repo`, `None yet. Add one: ${brand().bin} ci trust add --repo owner/repo`)));
62
+ });
63
+ trust.command("add")
64
+ .description(tr("cho một kho đăng nhập (token đứng tên bạn, sống 15 phút)", "allow a repository to log in (tokens act as you, live 15 minutes)"))
65
+ .requiredOption("--repo <owner/repo>")
66
+ .option("--provider <p>", "github | gitlab", "github")
67
+ .option("--ref <ref>", tr("nhánh hoặc tiền tố tag, vd main · refs/tags/v*", "branch or tag prefix, e.g. main · refs/tags/v*"), "main")
68
+ .option("--environment <name>", tr("environment GitHub bắt buộc (khuyên dùng cho nhánh chính)", "required GitHub environment (recommended for the main branch)"))
69
+ .option("--expires <dur>", tr("thời hạn: 90d, 1y", "lifetime: 90d, 1y"))
70
+ .option("--name <name>")
71
+ .action(async (o) => {
72
+ const t = await post("/iam/ci-trusts", {
73
+ provider: o.provider, repository: o.repo, refPattern: o.ref, environment: o.environment, name: o.name,
74
+ expiresAt: o.expires ? parseDuration(o.expires) : null,
75
+ });
76
+ emit(t, () => {
77
+ ok(tr(`Đã cho ${t.provider}:${t.repository} (${t.refPattern}) đăng nhập`, `Allowed ${t.provider}:${t.repository} (${t.refPattern})`));
78
+ info(c.dim(tr(`\nCho CI tự ghi trong trần: ${brand().bin} ci policy request --trust ${t.repository} --kinds <mã>`, `\nLet CI write within a ceiling: ${brand().bin} ci policy request --trust ${t.repository} --kinds <code>`)));
79
+ info(c.dim(tr("\nMẫu workflow:", "\nSample workflow:")));
80
+ info(workflowSample(t.repository));
81
+ });
82
+ });
83
+ trust.command("revoke <id>").description(tr("thu hồi (có hiệu lực ngay)", "revoke (immediate)")).action(async (id) => {
84
+ const t = await findTrust(id);
85
+ if (hasTty() && !(await confirm(tr(`Thu hồi ${t.repository}?`, `Revoke ${t.repository}?`))))
86
+ return;
87
+ await post(`/iam/ci-trusts/${t.id}/revoke`, {});
88
+ ok(tr("Đã thu hồi.", "Revoked."));
89
+ });
90
+ // Chính sách duyệt trước: cùng hợp đồng /ai/ci-write-policies trên mọi nền tảng.
91
+ registerCiPolicy(ci);
92
+ ci.command("example").description(tr("in mẫu workflow GitHub Actions", "print a sample GitHub Actions workflow"))
93
+ .option("--repo <owner/repo>").option("--workspace <code>")
94
+ .option("--write", tr("mẫu có tự ghi (cần chính sách: ci policy request)", "sample that writes (needs a policy: ci policy request)"))
95
+ .action((o) => info(o.write ? workflowWriteSample(o.repo, o.workspace) : workflowSample(o.repo, o.workspace)));
96
+ }
@@ -3,7 +3,7 @@ import { tr } from "../core/i18n.js";
3
3
  import { json } from "../core/output.js";
4
4
  import { VERSION } from "../core/version.js";
5
5
  import { Exit } from "../core/errors.js";
6
- import { SLASH, SHORTCUTS } from "../repl/slash.js";
6
+ import { SHORTCUTS, slashCommands } from "../repl/slash.js";
7
7
  const opt = (o) => ({ flags: o.flags, description: o.description, ...(o.defaultValue !== undefined ? { default: o.defaultValue } : {}), ...(o.argChoices ? { choices: o.argChoices } : {}) });
8
8
  function walk(cmd) {
9
9
  return {
@@ -25,7 +25,7 @@ export function helpTree(program) {
25
25
  version: VERSION,
26
26
  brand: b.id,
27
27
  root: walk(program),
28
- slashCommands: SLASH.map((s) => ({ name: `/${s.name}`, args: s.args ?? null, description: s.description() })),
28
+ slashCommands: slashCommands().map((s) => ({ name: `/${s.name}`, args: s.args ?? null, description: s.description() })),
29
29
  shortcuts: SHORTCUTS.map((s) => ({ keys: s.keys, description: s.description() })),
30
30
  exitCodes: Object.entries(Exit).map(([name, code]) => ({ name, code })),
31
31
  environment: ["API_KEY", "TOKEN", "PROFILE", "ENV", "WORKSPACE", "LANG", "DEBUG", "NO_UPDATE_CHECK", "NO_KEYCHAIN", "ID_TOKEN"].map((v) => `${b.envPrefix}_${v}`),
@@ -20,6 +20,7 @@ async function findKey(id) {
20
20
  throw new CliError(tr("Không thấy khoá này.", "Key not found."), Exit.notFound);
21
21
  return k;
22
22
  }
23
+ const scopeExample = () => brand().examples.scopes;
23
24
  export function registerKeys(program) {
24
25
  const keys = program.command("keys").description(tr("khoá API của workspace", "workspace API keys"));
25
26
  keys.hook("preAction", async () => void (await requirePerson()));
@@ -33,7 +34,7 @@ export function registerKeys(program) {
33
34
  keys.command("create")
34
35
  .description(tr("tạo khoá mới (in khoá đúng một lần)", "create a key (printed once)"))
35
36
  .requiredOption("--name <name>")
36
- .requiredOption("--scopes <codes>", tr("mã quyền, phẩy ngăn cách, vd crm.contacts.view,sales.orders.view", "permission codes, comma separated, e.g. crm.contacts.view,sales.orders.view"))
37
+ .requiredOption("--scopes <codes>", tr(`mã quyền, phẩy ngăn cách, vd ${scopeExample()}`, `permission codes, comma separated, e.g. ${scopeExample()}`))
37
38
  .option("--expires <dur>", tr("thời hạn: 30d, 6m, 1y", "lifetime: 30d, 6m, 1y"), "90d")
38
39
  .option("--description <text>")
39
40
  .action(async (o) => {
@@ -138,8 +138,10 @@ export function registerMcp(program) {
138
138
  }
139
139
  /** Line-delimited JSON-RPC on stdio ⇄ Streamable HTTP to the hosted server. Logs go to stderr only. */
140
140
  async function serveStdio() {
141
- const url = context().endpoints.mcp;
142
- let token = await mcpToken({ interactive: false });
141
+ // On some platforms an API key speaks MCP through the gateway (same read-only route rules as the key everywhere else).
142
+ let apiKey = undefined;
143
+ const url = apiKey ? `${context().endpoints.api}/api/v1/mcp` : context().endpoints.mcp;
144
+ let token = apiKey ?? (await mcpToken({ interactive: false }));
143
145
  let sessionId;
144
146
  const rl = createInterface({ input: process.stdin });
145
147
  const write = (m) => process.stdout.write(`${JSON.stringify(m)}\n`);
@@ -166,7 +168,7 @@ async function serveStdio() {
166
168
  });
167
169
  try {
168
170
  let res = await send();
169
- if (res.status === 401) {
171
+ if (res.status === 401 && !apiKey) {
170
172
  token = await mcpToken({ interactive: false });
171
173
  res = await send();
172
174
  }