@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.
@@ -0,0 +1,106 @@
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 { hasTty } from "../core/prompt.js";
6
+ import { PROVIDERS, addModel, defaultModel, isProvider, listModels, removeModel, resolveModel, setDefaultModel } from "../ai/models.js";
7
+ async function readAll() {
8
+ const chunks = [];
9
+ for await (const ch of process.stdin)
10
+ chunks.push(ch);
11
+ return Buffer.concat(chunks).toString("utf8").trim();
12
+ }
13
+ /** Reads a key from the terminal without echoing it. */
14
+ async function readHidden(question) {
15
+ process.stderr.write(question);
16
+ const stdin = process.stdin;
17
+ stdin.setRawMode?.(true);
18
+ stdin.resume();
19
+ return new Promise((resolve, reject) => {
20
+ let value = "";
21
+ const onData = (d) => {
22
+ for (const ch of d.toString("utf8")) {
23
+ if (ch === "\r" || ch === "\n") {
24
+ done();
25
+ return resolve(value.trim());
26
+ }
27
+ if (ch === "\u0003") {
28
+ done();
29
+ return reject(new CliError(tr("Đã huỷ.", "Cancelled."), Exit.interrupted));
30
+ }
31
+ if (ch === "\u007f")
32
+ value = value.slice(0, -1);
33
+ else
34
+ value += ch;
35
+ }
36
+ };
37
+ const done = () => {
38
+ stdin.off("data", onData);
39
+ stdin.setRawMode?.(false);
40
+ stdin.pause();
41
+ process.stderr.write("\n");
42
+ };
43
+ stdin.on("data", onData);
44
+ });
45
+ }
46
+ export function registerModel(program) {
47
+ const b = brand();
48
+ const model = program
49
+ .command("model")
50
+ .description(tr(`model của bạn cho --model (khoá lưu keychain, không gửi về ${b.name})`, `your own models for --model (keys stay in the keychain, never sent to ${b.name})`));
51
+ model
52
+ .command("list")
53
+ .description(tr("các model đã thêm", "models you added"))
54
+ .action(() => {
55
+ const rows = listModels();
56
+ const def = defaultModel();
57
+ emit({ default: def ?? b.bin, models: rows.map((r) => ({ name: r.name, provider: r.entry.provider, model: r.entry.model ?? PROVIDERS[r.entry.provider].defaultModel ?? null, url: r.entry.url ?? null, hasKey: r.hasKey })) }, () => {
58
+ info(table([tr("Tên", "Name"), tr("Nhà", "Provider"), "Model", tr("Khoá", "Key"), ""], [
59
+ [b.bin, b.name, b.aiName, "—", def ? "" : c.green(tr("mặc định", "default"))],
60
+ ...rows.map((r) => [r.name, PROVIDERS[r.entry.provider].label, r.entry.model ?? PROVIDERS[r.entry.provider].defaultModel ?? c.dim(tr("(nêu khi dùng)", "(name it when used)")), PROVIDERS[r.entry.provider].needsKey ? (r.hasKey ? c.green("✓") : c.yellow(tr("chưa có", "missing"))) : "—", r.isDefault ? c.green(tr("mặc định", "default")) : ""]),
61
+ ]));
62
+ if (!rows.length)
63
+ info(c.dim(`\n ${b.bin} model add claude --key-from-stdin < key.txt · ${b.bin} model add ollama --url http://localhost:11434 --name qwen3:32b`));
64
+ });
65
+ });
66
+ model
67
+ .command("add <provider>")
68
+ .description(tr(`thêm nhà model: ${Object.keys(PROVIDERS).join(", ")}`, `add a provider: ${Object.keys(PROVIDERS).join(", ")}`))
69
+ .option("--key-from-stdin", tr("đọc khoá API từ stdin (không bao giờ đưa khoá lên dòng lệnh)", "read the API key from stdin (never put keys on the command line)"))
70
+ .option("--name <model>", tr("model dùng khi chỉ ghi tên nhà, vd gpt → --name <tên model>", "model used when only the provider is given"))
71
+ .option("--url <url>", tr("địa chỉ máy chủ (Ollama hoặc cổng tương thích)", "server address (Ollama or a compatible endpoint)"))
72
+ .action(async (provider, o) => {
73
+ if (!isProvider(provider))
74
+ throw new CliError(tr(`Không biết nhà "${provider}".`, `Unknown provider "${provider}".`), Exit.usage, Object.keys(PROVIDERS).join(", "));
75
+ const info0 = PROVIDERS[provider];
76
+ let key;
77
+ if (info0.needsKey) {
78
+ if (o.keyFromStdin)
79
+ key = await readAll();
80
+ else if (hasTty())
81
+ key = await readHidden(tr(`Khoá API ${info0.label}: `, `${info0.label} API key: `));
82
+ else
83
+ throw new CliError(tr("Không có terminal: dùng --key-from-stdin.", "No terminal: use --key-from-stdin."), Exit.usage);
84
+ if (!key)
85
+ throw new CliError(tr("Khoá rỗng.", "Empty key."), Exit.usage);
86
+ }
87
+ addModel(provider, { key, model: o.name, url: o.url });
88
+ ok(tr(`Đã thêm ${info0.label}. Dùng: ${b.bin} --model ${provider}${o.name || info0.defaultModel ? "" : "/<model>"}`, `Added ${info0.label}. Use: ${b.bin} --model ${provider}${o.name || info0.defaultModel ? "" : "/<model>"}`));
89
+ });
90
+ model
91
+ .command("remove <name>")
92
+ .description(tr("gỡ model và xoá khoá khỏi keychain", "remove a model and delete its key"))
93
+ .action((name) => {
94
+ if (!removeModel(name))
95
+ throw new CliError(tr(`Chưa có model "${name}".`, `No model "${name}".`), Exit.notFound);
96
+ ok(tr(`Đã gỡ ${name}.`, `Removed ${name}.`));
97
+ });
98
+ model
99
+ .command("default <name>")
100
+ .description(tr(`model mặc định khi mở chat (${b.bin} = ${b.aiName})`, `model used by default (${b.bin} = ${b.aiName})`))
101
+ .action((name) => {
102
+ const m = resolveModel(name);
103
+ setDefaultModel(m ? name : undefined);
104
+ ok(tr(`Mặc định: ${m ? m.label : b.aiName}`, `Default: ${m ? m.label : b.aiName}`));
105
+ });
106
+ }
@@ -11,9 +11,13 @@ import { authorizeMcp, readGrant, revokeGrant } from "../auth/mcp-oauth.js";
11
11
  import { browserAvailable } from "./auth.js";
12
12
  let publicCache;
13
13
  export async function publicTools() {
14
- publicCache ??= (await get("/ai/public/tools", { anonymous: true })).filter((t) => !brand().modules || brand().modules.includes(t.module));
14
+ publicCache ??= (await loadPublicTools())
15
+ .filter((t) => !brand().modules || brand().modules.includes(t.module));
15
16
  return publicCache;
16
17
  }
18
+ async function loadPublicTools() {
19
+ return get("/ai/public/tools", { anonymous: true });
20
+ }
17
21
  /** MCP names may differ from catalog names (module prefix, dots); match loosely. */
18
22
  export function sameTool(a, b) {
19
23
  const n = (s) => s.toLowerCase().replace(/[.\-]/g, "_");
@@ -57,7 +57,7 @@ export async function doctor() {
57
57
  const me = await get("/iam/auth/me");
58
58
  add(tr("Danh tính", "Identity"), true, `${src} · ${me.email ?? ""} · ${me.workspace?.name ?? ""} · ${me.permissions?.length ?? 0} ${tr("quyền", "permissions")}`);
59
59
  try {
60
- const u = await get("/operator/widget/support/usage");
60
+ const u = await (await import("../ai/client.js")).usage();
61
61
  add(tr("Gói AI", "AI plan"), u.limit === 0 ? "warn" : true, `${u.planName ?? "-"}${u.limit ? ` · ${tr("còn", "left")} ${u.remaining}/${u.limit}` : ""}`);
62
62
  }
63
63
  catch {
@@ -9,21 +9,53 @@ import { c, emit, info, json, ok, table, warn } from "../core/output.js";
9
9
  import { get, post, del, requestRaw, errorFor } from "../core/http.js";
10
10
  import { confirm, hasTty } from "../core/prompt.js";
11
11
  const when = (iso) => (iso ? iso.slice(0, 19).replace("T", " ") : "");
12
+ /** Platforms whose webhook contract differs (event list shape, endpoints with many events, per-endpoint deliveries, test call). */
13
+ let farm = () => false;
14
+ /** Signature headers per platform: `…-Signature-256` by default (sha256=HMAC(ts.body)). */
15
+ function sigHeader() {
16
+ return "x-nextx-signature-256";
17
+ }
18
+ function tsHeader() {
19
+ return "x-nextx-timestamp";
20
+ }
21
+ function sigScheme() {
22
+ return "Signature-256";
23
+ }
12
24
  export async function eventTypes() {
13
25
  return get("/webhook/public/event-types", { anonymous: true });
14
26
  }
27
+ /** Some platforms send `eventTypes` per endpoint and no single module. */
28
+ async function listEndpoints() {
29
+ const list = await get("/webhook/endpoints");
30
+ return farm() ? list.map((e) => ({ ...e, events: e.eventTypes ?? [], status: e.isActive ? "active" : "disabled" })) : list;
31
+ }
32
+ /** Some platforms keep deliveries per endpoint only; the CLI merges them. */
33
+ async function listDeliveries(o) {
34
+ if (!farm()) {
35
+ const path = o.endpoint ? `/webhook/endpoints/${(await findEndpoint(o.endpoint)).id}/deliveries` : "/webhook/deliveries";
36
+ const r = await get(path, { query: { status: o.status, pageSize: o.limit } });
37
+ return Array.isArray(r) ? r : r.items;
38
+ }
39
+ const eps = o.endpoint ? [await findEndpoint(o.endpoint)] : await listEndpoints();
40
+ const all = (await Promise.all(eps.map((e) => get(`/webhook/endpoints/${e.id}/deliveries`, { query: { limit: o.limit } })))).flat();
41
+ return all
42
+ .map((d) => ({ id: d.id, eventType: d.eventType, status: d.status === "delivered" ? "success" : d.status, responseStatus: d.lastStatusCode, durationMs: d.lastDurationMs, retryCount: d.attemptCount, maxRetries: 8, deliveredAt: d.deliveredAt, createdAt: d.createdAt }))
43
+ .filter((d) => !o.status || d.status === o.status)
44
+ .sort((a, b) => b.createdAt.localeCompare(a.createdAt))
45
+ .slice(0, o.limit);
46
+ }
15
47
  /** Checks both the NextX header (sha256=HMAC(ts.body)) and Standard Webhooks (v1,base64 HMAC(id.ts.body)). */
16
48
  export function verifySignature(secret, body, headers, toleranceSec = 300) {
17
49
  const h = Object.fromEntries(Object.entries(headers).map(([k, v]) => [k.toLowerCase(), v]));
18
50
  const eq = (a, b) => a.length === b.length && timingSafeEqual(Buffer.from(a), Buffer.from(b));
19
- const ts = h["x-nextx-timestamp"] ?? h["webhook-timestamp"];
51
+ const ts = h[tsHeader()] ?? h["webhook-timestamp"];
20
52
  if (ts && Math.abs(Date.now() / 1000 - Number(ts)) > toleranceSec)
21
53
  return { ok: false, reason: tr("dấu thời gian quá cũ", "timestamp too old") };
22
- if (h["x-nextx-signature-256"] && ts) {
54
+ if (h[sigHeader()] && ts) {
23
55
  const expected = "sha256=" + createHmac("sha256", secret).update(`${ts}.${body}`).digest("hex");
24
- const got = [h["x-nextx-signature-256"], h["x-nextx-signature-256-previous"]].filter(Boolean);
56
+ const got = [h[sigHeader()], h[`${sigHeader()}-previous`]].filter(Boolean);
25
57
  if (got.some((g) => eq(g, expected)))
26
- return { ok: true, scheme: "Signature-256" };
58
+ return { ok: true, scheme: sigScheme() };
27
59
  }
28
60
  if (h["webhook-signature"] && h["webhook-id"] && ts) {
29
61
  const key = secret.startsWith("whsec_") ? Buffer.from(secret.slice(6), "base64") : Buffer.from(secret);
@@ -34,7 +66,7 @@ export function verifySignature(secret, body, headers, toleranceSec = 300) {
34
66
  return { ok: false, reason: tr("chữ ký không khớp", "signature mismatch") };
35
67
  }
36
68
  async function findEndpoint(id) {
37
- const e = (await get("/webhook/endpoints")).find((x) => x.id.startsWith(id) || x.url === id);
69
+ const e = (await listEndpoints()).find((x) => x.id.startsWith(id) || x.url === id);
38
70
  if (!e)
39
71
  throw new CliError(tr("Không thấy endpoint này.", "Endpoint not found."), Exit.notFound);
40
72
  return e;
@@ -46,7 +78,7 @@ export function registerWebhooks(program) {
46
78
  let list = await eventTypes();
47
79
  if (o.module)
48
80
  list = list.filter((e) => e.service.toLowerCase() === o.module.toLowerCase());
49
- emit(list, () => info(table([tr("Sự kiện", "Event"), tr("Phân hệ", "Module"), tr("Mô tả", "Description"), tr("Gói", "Plan")], list.map((e) => [e.event, e.service, (currentLang() === "en" ? e.descriptionEn ?? e.title : e.title), e.planRequired ?? ""]))));
81
+ emit(list, () => info(table([tr("Sự kiện", "Event"), tr("Phân hệ", "Module"), tr("Mô tả", "Description"), farm() ? tr("Quyền", "Permission") : tr("Gói", "Plan")], list.map((e) => [e.event, e.service, (currentLang() === "en" ? e.descriptionEn ?? e.title : e.title), e.planRequired ?? ""]))));
50
82
  });
51
83
  wh.command("schema <event>").description(tr("schema và ví dụ payload", "payload schema and example"))
52
84
  .action(async (event) => {
@@ -63,7 +95,7 @@ export function registerWebhooks(program) {
63
95
  });
64
96
  const ep = wh.command("endpoints").description(tr("endpoint nhận webhook của workspace", "your webhook endpoints"));
65
97
  ep.command("list", { isDefault: true }).action(async () => {
66
- const list = await get("/webhook/endpoints");
98
+ const list = await listEndpoints();
67
99
  emit(list, () => info(table(["ID", "URL", tr("Sự kiện", "Events"), tr("Trạng thái", "Status"), tr("Lỗi liên tiếp", "Failures")], list.map((e) => [e.id.slice(0, 8), e.url, e.events.length > 3 ? `${e.events.slice(0, 3).join(", ")} +${e.events.length - 3}` : e.events.join(", "), e.isActive ? c.green(e.status) : c.dim(e.status), e.consecutiveFailures || ""]))));
68
100
  });
69
101
  ep.command("create <url>")
@@ -71,6 +103,19 @@ export function registerWebhooks(program) {
71
103
  .option("--description <text>")
72
104
  .action(async (url, o) => {
73
105
  const events = o.events.split(",").map((s) => s.trim()).filter(Boolean);
106
+ if (farm()) {
107
+ const known = new Set((await eventTypes()).map((t) => t.event));
108
+ const unknown = events.filter((e) => !known.has(e));
109
+ if (unknown.length)
110
+ throw new CliError(tr(`Không có sự kiện: ${unknown.join(", ")}`, `Unknown events: ${unknown.join(", ")}`), Exit.usage, `${brand().bin} webhooks events`);
111
+ const r = await post("/webhook/endpoints", { url, description: o.description, eventTypes: events });
112
+ emit(r, () => {
113
+ ok(tr("Đã tạo endpoint.", "Endpoint created."));
114
+ if (r.secret)
115
+ info(` ${tr("Khoá ký", "Signing secret")}: ${c.bold(String(r.secret))} ${c.dim(tr("(chỉ hiện một lần)", "(shown once)"))}`);
116
+ });
117
+ return;
118
+ }
74
119
  const types = await eventTypes();
75
120
  const services = [...new Set(events.map((e) => types.find((t) => t.event === e)?.service).filter(Boolean))];
76
121
  if (services.length !== 1)
@@ -98,10 +143,18 @@ export function registerWebhooks(program) {
98
143
  wh.command("trigger <event>").description(tr("bắn một sự kiện mẫu tới endpoint", "send a sample event to an endpoint"))
99
144
  .option("--endpoint <id>", tr("endpoint đích (mặc định: endpoint đầu tiên nhận sự kiện này)", "target endpoint (default: first one subscribed)"))
100
145
  .action(async (event, o) => {
101
- const list = await get("/webhook/endpoints");
146
+ const list = await listEndpoints();
102
147
  const target = o.endpoint ? await findEndpoint(o.endpoint) : list.find((e) => e.events.includes(event));
103
148
  if (!target)
104
149
  throw new CliError(tr("Chưa có endpoint nào nhận sự kiện này.", "No endpoint subscribes to this event."), Exit.notFound, tr(`Dùng \`${brand().bin} webhooks listen --events ${event}\` để nghe về máy.`, `Use \`${brand().bin} webhooks listen --events ${event}\` to receive it locally.`));
150
+ if (farm()) {
151
+ // This platform sends a signed `webhook.test` and answers with the receiver's status straight away.
152
+ const t = await post(`/webhook/endpoints/${target.id}/test`, {});
153
+ emit(t, () => (t.ok ? ok : warn)(tr(`Đã gửi webhook.test → ${target.url}`, `Sent webhook.test → ${target.url}`) + ` · ${t.statusCode ? `HTTP ${t.statusCode}` : t.error ?? ""} · ${t.durationMs}ms`));
154
+ if (!t.ok)
155
+ process.exitCode = Exit.general;
156
+ return;
157
+ }
105
158
  const r = await post(`/webhook/endpoints/${target.id}/test`, { eventType: event });
106
159
  emit(r, () => ok(tr(`Đã gửi ${event} → ${target.url}`, `Sent ${event} → ${target.url}`) + (r.responseStatus ? ` · HTTP ${r.responseStatus}` : "")));
107
160
  });
@@ -110,15 +163,13 @@ export function registerWebhooks(program) {
110
163
  .option("--endpoint <id>")
111
164
  .option("--limit <n>", "", "30")
112
165
  .action(async (o) => {
113
- const path = o.endpoint ? `/webhook/endpoints/${(await findEndpoint(o.endpoint)).id}/deliveries` : "/webhook/deliveries";
114
- const r = await get(path, { query: { status: o.status, pageSize: o.limit } });
115
- const list = Array.isArray(r) ? r : r.items;
166
+ const list = await listDeliveries({ endpoint: o.endpoint, status: o.status, limit: Number(o.limit) });
116
167
  emit(list, () => info(table(["ID", tr("Sự kiện", "Event"), tr("Kết quả", "Result"), "HTTP", "ms", tr("Thử lại", "Retries"), tr("Lúc", "At")], list.map((d) => [
117
168
  d.id.slice(0, 8), d.eventType, d.status === "success" ? c.green(d.status) : d.status === "failed" ? c.red(d.status) : d.status, d.responseStatus ?? "", d.durationMs ?? "", `${d.retryCount}/${d.maxRetries}`, when(d.createdAt),
118
169
  ]))));
119
170
  });
120
171
  wh.command("retry <deliveryId>").description(tr("gửi lại một lượt giao", "retry a delivery")).action(async (id) => {
121
- const full = id.length >= 32 ? id : (await get("/webhook/deliveries", { query: { pageSize: 100 } }).then((r) => (Array.isArray(r) ? r : r.items).find((d) => d.id.startsWith(id))?.id));
172
+ const full = id.length >= 32 ? id : (await listDeliveries({ limit: 100 })).find((d) => d.id.startsWith(id))?.id;
122
173
  if (!full)
123
174
  throw new CliError(tr("Không thấy lượt giao này.", "Delivery not found."), Exit.notFound);
124
175
  const r = await post(`/webhook/deliveries/${full}/retry`, {});
@@ -139,9 +190,9 @@ export function registerWebhooks(program) {
139
190
  headers[h.slice(0, i).trim()] = h.slice(i + 1).trim();
140
191
  }
141
192
  if (o.signature)
142
- headers["x-nextx-signature-256"] = o.signature;
193
+ headers[sigHeader()] = o.signature;
143
194
  if (o.timestamp)
144
- headers["x-nextx-timestamp"] = o.timestamp;
195
+ headers[tsHeader()] = o.timestamp;
145
196
  const r = verifySignature(o.secret, body, headers);
146
197
  emit(r, () => (r.ok ? ok(tr(`Chữ ký hợp lệ (${r.scheme})`, `Valid signature (${r.scheme})`)) : warn(tr(`Không hợp lệ: ${r.reason}`, `Invalid: ${r.reason}`))));
147
198
  if (!r.ok)
@@ -1,8 +1,8 @@
1
1
  // One codebase, several branded binaries. The entry file (src/bin/<brand>.ts) picks the brand before
2
2
  // anything else is imported; every user-facing name, host and storage location comes from here.
3
- // <brand:nextx>
4
3
  const NEXTX = {
5
4
  id: "nextx",
5
+ platform: "nextx",
6
6
  bin: "nextx",
7
7
  name: "NextX",
8
8
  aiName: "NextX AI",
@@ -10,6 +10,10 @@ const NEXTX = {
10
10
  clientId: "nextx-cli",
11
11
  envPrefix: "NEXTX",
12
12
  mobileApp: "NextX",
13
+ examples: { scopes: "crm.contacts.view,sales.orders.view", ciKinds: "crm.tasks.create" },
14
+ example: { vi: "doanh thu hôm qua", en: "yesterday's revenue" },
15
+ apiExample: "GET /crm/contacts -f q=Minh",
16
+ ciSettings: { vi: "Thiết lập › Ứng dụng & API › CI", en: "Settings › Apps & API › CI" },
13
17
  modules: null,
14
18
  env: {
15
19
  prod: {
@@ -30,10 +34,9 @@ const NEXTX = {
30
34
  },
31
35
  },
32
36
  };
33
- // </brand:nextx>
34
37
  // Exported trees keep only their own brand (scripts/export.sh strips the other block and its line here).
35
38
  const BRANDS = {
36
- nextx: NEXTX, // <brand-line:nextx>
39
+ nextx: NEXTX,
37
40
  };
38
41
  let current = Object.values(BRANDS)[0];
39
42
  export function setBrand(id) {
@@ -11,6 +11,10 @@ export const Exit = {
11
11
  notFound: 8,
12
12
  interrupted: 130,
13
13
  };
14
+ /** The brand's server does not offer this sign-in method yet. */
15
+ export function notYetError(what, instead) {
16
+ return new CliError(`${what} — ${instead}`, Exit.usage);
17
+ }
14
18
  export class CliError extends Error {
15
19
  exitCode;
16
20
  hint;
package/dist/core/http.js CHANGED
@@ -1,13 +1,22 @@
1
1
  // Every call to the platform goes through here: identity headers, retry on expired token, error → exit code.
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { arch, platform } from "node:os";
4
- import { fetch, EnvHttpProxyAgent, setGlobalDispatcher } from "undici";
4
+ import { fetch, FormData, EnvHttpProxyAgent, setGlobalDispatcher } from "undici";
5
5
  import { brand } from "./brand.js";
6
6
  import { context } from "./context.js";
7
7
  import { CliError, Exit } from "./errors.js";
8
8
  import { t, currentLang } from "./i18n.js";
9
9
  import { VERSION } from "./version.js";
10
10
  import { currentAuth, refreshSession } from "../auth/session.js";
11
+ /** Key case on the wire: some services speak snake_case; the CLI keeps camelCase inside. */
12
+ function toWire(url, body) {
13
+ void url;
14
+ return body;
15
+ }
16
+ function fromWire(url, body) {
17
+ void url;
18
+ return body;
19
+ }
11
20
  setGlobalDispatcher(new EnvHttpProxyAgent());
12
21
  export const userAgent = () => `${brand().bin}-cli/${VERSION} (${platform()}; ${arch()}; node ${process.versions.node})`;
13
22
  export function apiPath(path) {
@@ -46,10 +55,11 @@ async function send(method, url, opts, auth) {
46
55
  };
47
56
  let body;
48
57
  if (opts.body !== undefined) {
49
- if (typeof opts.body === "string")
58
+ // Multipart: undici sets the boundary itself, so no Content-Type here.
59
+ if (opts.body instanceof FormData || typeof opts.body === "string")
50
60
  body = opts.body;
51
61
  else {
52
- body = JSON.stringify(opts.body);
62
+ body = JSON.stringify(opts.verbatim ? opts.body : toWire(url, opts.body));
53
63
  headers["Content-Type"] ??= "application/json";
54
64
  }
55
65
  }
@@ -158,7 +168,8 @@ export async function request(method, path, opts = {}) {
158
168
  const requestId = res.headers.get("x-request-id") ?? undefined;
159
169
  if (opts.raw)
160
170
  return { status: res.status, data: res, headers: res.headers, requestId };
161
- const data = await readBody(res);
171
+ const body = await readBody(res);
172
+ const data = opts.verbatim ? body : fromWire(res.url || path, body);
162
173
  if (!res.ok && !opts.allow?.includes(res.status))
163
174
  throw errorFor(res.status, data, res);
164
175
  return { status: res.status, data: data, headers: res.headers, requestId };
@@ -12,16 +12,19 @@ const PROTOCOL = "2025-06-18";
12
12
  export class McpClient {
13
13
  interactive;
14
14
  apiKey;
15
+ url;
15
16
  sessionId;
16
17
  nextId = 1;
17
18
  token;
18
19
  ready = false;
19
- constructor(interactive, apiKey) {
20
+ /** `url`: MCP endpoint other than the hosted OAuth server (API keys that go through the gateway's /api/v1/mcp). */
21
+ constructor(interactive, apiKey, url) {
20
22
  this.interactive = interactive;
21
23
  this.apiKey = apiKey;
24
+ this.url = url;
22
25
  }
23
26
  async rpc(method, params, notification = false) {
24
- const url = context().endpoints.mcp;
27
+ const url = this.url ?? context().endpoints.mcp;
25
28
  this.token ??= this.apiKey ?? (await mcpToken({ interactive: this.interactive }));
26
29
  const id = notification ? undefined : this.nextId++;
27
30
  const headers = {
package/dist/program.js CHANGED
@@ -8,6 +8,7 @@ import { tr } from "./core/i18n.js";
8
8
  import { registerAuth } from "./commands/auth.js";
9
9
  import { registerWorkspace } from "./commands/workspace.js";
10
10
  import { registerAi, runRoot } from "./commands/ai.js";
11
+ import { registerModel } from "./commands/model.js";
11
12
  import { registerTools } from "./commands/tools.js";
12
13
  import { registerApi } from "./commands/api.js";
13
14
  import { registerKeys } from "./commands/keys.js";
@@ -16,6 +17,7 @@ import { registerDocs } from "./commands/docs.js";
16
17
  import { registerMcp } from "./commands/mcp.js";
17
18
  import { registerUtil } from "./commands/util.js";
18
19
  import { registerHelp } from "./commands/help.js";
20
+ import { registerCi } from "./commands/ci.js";
19
21
  const collect = (v, prev = []) => [...prev, v];
20
22
  export function buildProgram() {
21
23
  const b = brand();
@@ -32,6 +34,7 @@ export function buildProgram() {
32
34
  .option("--sandbox", tr("dùng môi trường thử nghiệm", "use the sandbox environment"))
33
35
  .option("-w, --workspace <code>", tr("chạy lệnh này trên workspace khác", "run this command on another workspace"))
34
36
  .option("--agent <code>", tr("chọn Agent thay cho Trợ lý chung", "choose an Agent instead of the general assistant"))
37
+ .option("--model <provider/name>", tr("dùng model của bạn qua MCP: claude, gemini, gpt, grok, ollama/<tên>", "use your own model over MCP: claude, gemini, gpt, grok, ollama/<name>"))
35
38
  .addOption(new Option("--mode <mode>", tr("chế độ quyền", "permission mode")).choices(["read", "propose", "auto"]))
36
39
  .addOption(new Option("--output-format <format>", tr("định dạng đầu ra", "output format")).choices(["text", "json", "stream-json"]))
37
40
  .option("--json", tr("viết tắt của --output-format json", "shorthand for --output-format json"))
@@ -54,12 +57,14 @@ export function buildProgram() {
54
57
  registerAuth(program);
55
58
  registerWorkspace(program);
56
59
  registerAi(program);
60
+ registerModel(program);
57
61
  registerTools(program);
58
62
  registerApi(program);
59
63
  registerKeys(program);
60
64
  registerWebhooks(program);
61
65
  registerDocs(program);
62
66
  registerMcp(program);
67
+ registerCi(program);
63
68
  registerUtil(program);
64
69
  registerHelp(program);
65
70
  for (const sub of program.commands)
package/dist/repl/App.js CHANGED
@@ -19,7 +19,9 @@ import { deleteSession } from "../auth/credentials.js";
19
19
  import { findProjectNotes, notesFileName, proposalCard, renderBlocks, runLines } from "../commands/ai.js";
20
20
  import { markdown } from "./markdown.js";
21
21
  import { Editor, Menu, Select, edit } from "./components.js";
22
- import { SLASH, SHORTCUTS, matchSlash } from "./slash.js";
22
+ import { LocalChat } from "../ai/local.js";
23
+ import { listModels, resolveModel } from "../ai/models.js";
24
+ import { SHORTCUTS, SLASH, matchSlash, slashCommands } from "./slash.js";
23
25
  const MODES = ["propose", "auto", "read"];
24
26
  const modeLabel = (m) => m === "propose" ? c.cyan(tr("⏵⏵ đề xuất", "⏵⏵ propose")) : m === "auto" ? c.yellow(tr("⏵⏵ tự ghi việc thường", "⏵⏵ auto-write normal")) : c.dim(tr("⏸ chỉ đọc", "⏸ read only"));
25
27
  let seq = 0;
@@ -90,6 +92,7 @@ export function App(props) {
90
92
  const [modal, setModal] = useState(null);
91
93
  const [mode, setMode] = useState(context().mode);
92
94
  const [agent, setAgent] = useState(context().agent);
95
+ const [local, setLocal] = useState(() => (props.model ? new LocalChat(props.model, true, context().mode === "read") : undefined));
93
96
  const [sessionId, setSessionId] = useState(props.sessionId);
94
97
  const [pending, setPending] = useState([]);
95
98
  const [usageText, setUsageText] = useState("");
@@ -124,7 +127,7 @@ export function App(props) {
124
127
  const line2 = ` ${c.dim(`${process.cwd().replace(process.env.HOME ?? "", "~")}${notes ? ` (${notesFileName()})` : ""} · CLI ${VERSION}`)}`;
125
128
  const strip = (s) => s.replace(/\u001b\[[0-9;]*m/g, "");
126
129
  const pad = (s) => s + " ".repeat(Math.max(0, inner + 1 - [...strip(s)].length));
127
- const title = `─ ${b.aiName} `;
130
+ const title = local ? `─ ${b.name} · model: ${local.model.label} · ${tr("công cụ qua MCP", "tools over MCP")} ` : `─ ${b.aiName} `;
128
131
  say([
129
132
  c.cyan(`╭${title}${"─".repeat(Math.max(0, inner - title.length + 2))}╮`),
130
133
  `${c.cyan("│")}${pad(line1)} ${c.cyan("│")}`,
@@ -191,6 +194,8 @@ export function App(props) {
191
194
  say(proposalCard(p).join("\n"));
192
195
  if (mode === "read")
193
196
  return say(c.dim(tr(" Chế độ chỉ đọc: không ghi. Shift+Tab để đổi.", " Read-only mode: nothing written. Shift+Tab to change.")));
197
+ if (risk === "device")
198
+ return say(c.dim(tr(` Việc chạm thiết bị / tự động hoá chỉ xác nhận trong app ${b.mobileApp}: ${context().endpoints.app}`, ` Device and automation actions are confirmed in the ${b.mobileApp} app: ${context().endpoints.app}`)));
194
199
  if (risk === "finance")
195
200
  return say(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: ${context().endpoints.app}`)));
196
201
  const yes = mode === "auto" && risk === "normal" ? true : await askConfirm(p);
@@ -245,26 +250,38 @@ export function App(props) {
245
250
  const ctl = new AbortController();
246
251
  abortRef.current = ctl;
247
252
  try {
253
+ const onChunk = (ch) => {
254
+ const l = liveRef.current;
255
+ if (ch.type === "token")
256
+ l.text += ch.text ?? "";
257
+ else if (ch.type === "activity" && ch.activity)
258
+ l.activities.push(ch.activity);
259
+ else if (ch.type === "agent" && ch.agent)
260
+ l.agent = ch.agent;
261
+ l.reconnecting = undefined;
262
+ };
263
+ if (local) {
264
+ const skipped = atts.filter((a) => a.kind !== "text");
265
+ if (skipped.length)
266
+ say(c.yellow(tr(` ⚠ Model riêng chỉ đọc tệp chữ; bỏ qua ${skipped.map((a) => a.name).join(", ")}.`, ` ⚠ Your own model reads text files only; skipped ${skipped.map((a) => a.name).join(", ")}.`)));
267
+ const out = await local.ask({ text: composeText(clean, atts.filter((a) => a.kind === "text"), notes?.text), signal: ctl.signal, onChunk });
268
+ setLastOutcome(out);
269
+ say(renderAnswer(out));
270
+ return;
271
+ }
248
272
  const out = await ask({
249
273
  sessionId,
250
274
  text: composeText(clean, atts, notes?.text),
251
275
  attachments: atts,
252
276
  agent: thisAgent,
253
277
  signal: ctl.signal,
254
- onChunk: (ch) => {
255
- const l = liveRef.current;
256
- if (ch.type === "token")
257
- l.text += ch.text ?? "";
258
- else if (ch.type === "activity" && ch.activity)
259
- l.activities.push(ch.activity);
260
- else if (ch.type === "agent" && ch.agent)
261
- l.agent = ch.agent;
262
- l.reconnecting = undefined;
263
- },
278
+ onChunk,
264
279
  onRetry: (n) => liveRef.current && (liveRef.current.reconnecting = n),
265
280
  });
266
281
  setLastOutcome(out);
267
- updateProfile(context().profile, { lastConversation: sessionId });
282
+ if (out.sessionId && out.sessionId !== sessionId)
283
+ setSessionId(out.sessionId);
284
+ updateProfile(context().profile, { lastConversation: out.sessionId || sessionId });
268
285
  say(renderAnswer(out));
269
286
  if (out.proposedAction)
270
287
  await handleProposal(out.proposedAction);
@@ -320,11 +337,16 @@ export function App(props) {
320
337
  const cmd = (cmd0 ?? "").toLowerCase();
321
338
  const arg = rest.join(" ").trim();
322
339
  try {
340
+ // Commands that belong to another backend behave like unknown ones.
341
+ if (SLASH.some((s) => s.name === cmd) && !slashCommands().some((s) => s.name === cmd)) {
342
+ say(`${c.yellow("⚠")} ${tr(`Không có lệnh /${cmd}.`, `No command /${cmd}.`)} ${c.dim("/help")}`);
343
+ return;
344
+ }
323
345
  switch (cmd) {
324
346
  case "help":
325
347
  case "?": {
326
- const w = Math.max(...SLASH.map((s) => s.name.length)) + 2;
327
- say([...SLASH.map((s) => ` ${c.cyan(`/${s.name}`.padEnd(w + 1))} ${s.description()}`), "", ...SHORTCUTS.map((s) => ` ${s.keys.padEnd(30)} ${c.dim(s.description())}`)].join("\n"));
348
+ const w = Math.max(...slashCommands().map((s) => s.name.length)) + 2;
349
+ say([...slashCommands().map((s) => ` ${c.cyan(`/${s.name}`.padEnd(w + 1))} ${s.description()}`), "", ...SHORTCUTS.map((s) => ` ${s.keys.padEnd(30)} ${c.dim(s.description())}`)].join("\n"));
328
350
  break;
329
351
  }
330
352
  case "new": {
@@ -393,9 +415,22 @@ export function App(props) {
393
415
  say(` ${modeLabel(m)}`);
394
416
  break;
395
417
  }
396
- case "model":
397
- say(c.dim(tr(` Đang dùng ${b.aiName}. Model của bạn: chạy \`${b.bin} --model <tên>\` (xem \`${b.bin} model list\`).`, ` Using ${b.aiName}. Your own model: run \`${b.bin} --model <name>\` (see \`${b.bin} model list\`).`)));
418
+ case "model": {
419
+ if (!arg) {
420
+ const rows = listModels();
421
+ say([
422
+ ` ${tr("Đang dùng", "Using")} ${c.bold(local ? local.model.label : b.aiName)}`,
423
+ ` ${c.cyan(b.bin.padEnd(10))} ${b.aiName}`,
424
+ ...rows.map((r) => ` ${c.cyan(r.name.padEnd(10))} ${r.entry.model ?? ""}${r.hasKey ? "" : c.yellow(tr(" (chưa có khoá)", " (no key)"))}`),
425
+ c.dim(` /model ${b.bin} · /model claude · /model ollama/<model> · ${b.bin} model add <nhà>`),
426
+ ].join("\n"));
427
+ break;
428
+ }
429
+ const m = resolveModel(arg);
430
+ setLocal(m ? new LocalChat(m, true, mode === "read") : undefined);
431
+ say(`${c.green("✓")} ${m ? `${tr("Model của bạn", "Your model")}: ${m.label} · ${tr("công cụ qua MCP, ghi chỉ bằng link xác nhận, không trừ lượt AI", "tools over MCP, writes only via a confirmation link, no AI turns used")}` : b.aiName}`);
398
432
  break;
433
+ }
399
434
  case "attach":
400
435
  if (!arg)
401
436
  throw new Error(tr("Cần đường dẫn tệp.", "Pass a file path."));
@@ -765,7 +800,7 @@ export function App(props) {
765
800
  });
766
801
  // ── view ──────────────────────────────────────────────────────────────────────────────────────
767
802
  const p = profile();
768
- const footerRight = c.dim(`${p?.workspaceName ?? ""} · ${context().env}${usageText ? ` · ${usageText}` : ""}${agent ? ` · ${agent}` : ""}`);
803
+ const footerRight = c.dim(`${p?.workspaceName ?? ""} · ${context().env}${local ? ` · ${local.model.label}` : usageText ? ` · ${usageText}` : ""}${agent && !local ? ` · ${agent}` : ""}`);
769
804
  const lastReason = live?.activities.filter((a) => a.kind !== "tool").at(-1);
770
805
  return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Static, { items: done, children: (it) => _jsx(Text, { children: it.text }, it.id) }), live ? (_jsxs(Box, { flexDirection: "column", marginTop: 0, children: [live.agent && live.agent.code !== "general" ? _jsx(Text, { color: "cyan", children: `● ${live.agent.name}` }) : null, live.activities.filter((a) => a.kind === "tool").map((a, i) => (_jsx(Text, { color: "gray", children: ` ⏺ ${a.label}` }, i))), live.text ? _jsx(Text, { children: markdown(live.text).replace(/^/gm, " ") }) : null, _jsxs(Text, { color: "gray", children: [_jsx(Spinner, { type: "dots" }), " ", live.reconnecting ? tr(`Mất kết nối, đang nối lại (${live.reconnecting}/2)…`, `Reconnecting (${live.reconnecting}/2)…`) : (lastReason?.label ?? tr("Đang nghĩ…", "Thinking…")), " ", _jsx(Text, { dimColor: true, children: tr("Esc dừng", "Esc to stop") })] })] })) : null, modal?.kind === "select" ? (_jsx(Select, { title: modal.title, items: modal.items, initial: modal.initial, onPick: modal.resolve, hint: tr("↑↓ chọn · Enter · Esc huỷ", "↑↓ choose · Enter · Esc cancel") })) : modal?.kind === "confirm" ? (_jsx(Box, { marginTop: 0, children: riskOf(modal.proposal) === "large" ? (_jsxs(Text, { children: [c.yellow("└ "), tr(`Gõ ${c.bold(confirmWord(modal.proposal))} để xác nhận, Enter để huỷ`, `Type ${c.bold(confirmWord(modal.proposal))} to confirm, Enter to cancel`), " \u203A ", modal.typed, _jsx(Text, { inverse: true, children: " " })] })) : (_jsxs(Text, { children: [c.yellow("└ "), tr("Ghi?", "Write?"), " ", c.bold("[y]"), " ", tr("có", "yes"), " ", c.bold("[n]"), " ", tr("không", "no"), " ", c.bold("[d]"), " ", tr("xem dữ liệu", "show data")] })) })) : (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [pending.length ? _jsx(Text, { color: "gray", children: ` 📎 ${pending.map((a) => a.name).join(", ")}` }) : null, _jsx(Editor, { value: value, cursor: cursor, disabled: busy, placeholder: busy ? "" : tr(`Hỏi ${b.aiName}… (/ để xem lệnh)`, `Ask ${b.aiName}… (/ for commands)`) }), menuItems.length ? _jsx(Menu, { items: menuItems, index: menuIndex }) : null, _jsxs(Box, { justifyContent: "space-between", width: width, children: [_jsx(Text, { children: ` ${modeLabel(mode)} ${c.dim("(Shift+Tab)")}` }), _jsx(Text, { children: footerRight })] })] }))] }));
771
806
  }
@@ -5,7 +5,7 @@ import { resolveConversation } from "../commands/ai.js";
5
5
  export async function startRepl(opts) {
6
6
  const sessionId = await resolveConversation(opts.flags);
7
7
  const resumed = Boolean(opts.flags.continue || opts.flags.resume);
8
- const app = render(_jsx(App, { sessionId: sessionId, resumed: resumed, initialQuestion: opts.initialQuestion, files: (opts.flags.file ?? []).slice(0, 3) }), {
8
+ const app = render(_jsx(App, { sessionId: sessionId, resumed: resumed, initialQuestion: opts.initialQuestion, files: (opts.flags.file ?? []).slice(0, 3), model: opts.model }), {
9
9
  exitOnCtrlC: false,
10
10
  });
11
11
  await app.waitUntilExit();