@nextxai/cli 0.1.1 → 0.2.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.
@@ -9,21 +9,51 @@ 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
+ const farm = () => brand().platform === "nextfarm";
13
+ /** Signature headers per platform: `X-Nextfarm-Signature` on NextFarm, `…-Signature-256` otherwise (both sha256=HMAC(ts.body)). */
14
+ const sigHeader = () => (farm() ? "x-nextfarm-signature" : "x-nextx-signature-256");
15
+ const tsHeader = () => (farm() ? "x-nextfarm-timestamp" : "x-nextx-timestamp");
12
16
  export async function eventTypes() {
13
- return get("/webhook/public/event-types", { anonymous: true });
17
+ if (!farm())
18
+ return get("/webhook/public/event-types", { anonymous: true });
19
+ const r = await get("/webhook/public/event-types", { anonymous: true, verbatim: true });
20
+ return r.events.map((e) => ({
21
+ event: e.event, service: e.group, title: currentLang() === "en" ? e.title_en ?? e.title : e.title, description: e.description,
22
+ descriptionEn: e.description_en, planRequired: e.scope, payloadSchema: e.payload_schema, example: e.example,
23
+ }));
24
+ }
25
+ /** NextFarm endpoints carry `eventTypes` and no single module. */
26
+ async function listEndpoints() {
27
+ const list = await get("/webhook/endpoints");
28
+ return farm() ? list.map((e) => ({ ...e, events: e.eventTypes ?? [], status: e.isActive ? "active" : "disabled" })) : list;
29
+ }
30
+ /** NextFarm keeps deliveries per endpoint only; the CLI merges them. */
31
+ async function listDeliveries(o) {
32
+ if (!farm()) {
33
+ const path = o.endpoint ? `/webhook/endpoints/${(await findEndpoint(o.endpoint)).id}/deliveries` : "/webhook/deliveries";
34
+ const r = await get(path, { query: { status: o.status, pageSize: o.limit } });
35
+ return Array.isArray(r) ? r : r.items;
36
+ }
37
+ const eps = o.endpoint ? [await findEndpoint(o.endpoint)] : await listEndpoints();
38
+ const all = (await Promise.all(eps.map((e) => get(`/webhook/endpoints/${e.id}/deliveries`, { query: { limit: o.limit } })))).flat();
39
+ return all
40
+ .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 }))
41
+ .filter((d) => !o.status || d.status === o.status)
42
+ .sort((a, b) => b.createdAt.localeCompare(a.createdAt))
43
+ .slice(0, o.limit);
14
44
  }
15
45
  /** Checks both the NextX header (sha256=HMAC(ts.body)) and Standard Webhooks (v1,base64 HMAC(id.ts.body)). */
16
46
  export function verifySignature(secret, body, headers, toleranceSec = 300) {
17
47
  const h = Object.fromEntries(Object.entries(headers).map(([k, v]) => [k.toLowerCase(), v]));
18
48
  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"];
49
+ const ts = h[tsHeader()] ?? h["webhook-timestamp"];
20
50
  if (ts && Math.abs(Date.now() / 1000 - Number(ts)) > toleranceSec)
21
51
  return { ok: false, reason: tr("dấu thời gian quá cũ", "timestamp too old") };
22
- if (h["x-nextx-signature-256"] && ts) {
52
+ if (h[sigHeader()] && ts) {
23
53
  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);
54
+ const got = [h[sigHeader()], h[`${sigHeader()}-previous`]].filter(Boolean);
25
55
  if (got.some((g) => eq(g, expected)))
26
- return { ok: true, scheme: "Signature-256" };
56
+ return { ok: true, scheme: farm() ? "X-Nextfarm-Signature" : "Signature-256" };
27
57
  }
28
58
  if (h["webhook-signature"] && h["webhook-id"] && ts) {
29
59
  const key = secret.startsWith("whsec_") ? Buffer.from(secret.slice(6), "base64") : Buffer.from(secret);
@@ -34,7 +64,7 @@ export function verifySignature(secret, body, headers, toleranceSec = 300) {
34
64
  return { ok: false, reason: tr("chữ ký không khớp", "signature mismatch") };
35
65
  }
36
66
  async function findEndpoint(id) {
37
- const e = (await get("/webhook/endpoints")).find((x) => x.id.startsWith(id) || x.url === id);
67
+ const e = (await listEndpoints()).find((x) => x.id.startsWith(id) || x.url === id);
38
68
  if (!e)
39
69
  throw new CliError(tr("Không thấy endpoint này.", "Endpoint not found."), Exit.notFound);
40
70
  return e;
@@ -46,7 +76,7 @@ export function registerWebhooks(program) {
46
76
  let list = await eventTypes();
47
77
  if (o.module)
48
78
  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 ?? ""]))));
79
+ 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
80
  });
51
81
  wh.command("schema <event>").description(tr("schema và ví dụ payload", "payload schema and example"))
52
82
  .action(async (event) => {
@@ -63,7 +93,7 @@ export function registerWebhooks(program) {
63
93
  });
64
94
  const ep = wh.command("endpoints").description(tr("endpoint nhận webhook của workspace", "your webhook endpoints"));
65
95
  ep.command("list", { isDefault: true }).action(async () => {
66
- const list = await get("/webhook/endpoints");
96
+ const list = await listEndpoints();
67
97
  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
98
  });
69
99
  ep.command("create <url>")
@@ -71,6 +101,19 @@ export function registerWebhooks(program) {
71
101
  .option("--description <text>")
72
102
  .action(async (url, o) => {
73
103
  const events = o.events.split(",").map((s) => s.trim()).filter(Boolean);
104
+ if (farm()) {
105
+ const known = new Set((await eventTypes()).map((t) => t.event));
106
+ const unknown = events.filter((e) => !known.has(e));
107
+ if (unknown.length)
108
+ throw new CliError(tr(`Không có sự kiện: ${unknown.join(", ")}`, `Unknown events: ${unknown.join(", ")}`), Exit.usage, `${brand().bin} webhooks events`);
109
+ const r = await post("/webhook/endpoints", { url, description: o.description, eventTypes: events });
110
+ emit(r, () => {
111
+ ok(tr("Đã tạo endpoint.", "Endpoint created."));
112
+ if (r.secret)
113
+ info(` ${tr("Khoá ký", "Signing secret")}: ${c.bold(String(r.secret))} ${c.dim(tr("(chỉ hiện một lần)", "(shown once)"))}`);
114
+ });
115
+ return;
116
+ }
74
117
  const types = await eventTypes();
75
118
  const services = [...new Set(events.map((e) => types.find((t) => t.event === e)?.service).filter(Boolean))];
76
119
  if (services.length !== 1)
@@ -98,10 +141,18 @@ export function registerWebhooks(program) {
98
141
  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
142
  .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
143
  .action(async (event, o) => {
101
- const list = await get("/webhook/endpoints");
144
+ const list = await listEndpoints();
102
145
  const target = o.endpoint ? await findEndpoint(o.endpoint) : list.find((e) => e.events.includes(event));
103
146
  if (!target)
104
147
  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.`));
148
+ if (farm()) {
149
+ // NextFarm sends a signed `webhook.test` and answers with the receiver's status straight away.
150
+ const t = await post(`/webhook/endpoints/${target.id}/test`, {});
151
+ 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`));
152
+ if (!t.ok)
153
+ process.exitCode = Exit.general;
154
+ return;
155
+ }
105
156
  const r = await post(`/webhook/endpoints/${target.id}/test`, { eventType: event });
106
157
  emit(r, () => ok(tr(`Đã gửi ${event} → ${target.url}`, `Sent ${event} → ${target.url}`) + (r.responseStatus ? ` · HTTP ${r.responseStatus}` : "")));
107
158
  });
@@ -110,15 +161,13 @@ export function registerWebhooks(program) {
110
161
  .option("--endpoint <id>")
111
162
  .option("--limit <n>", "", "30")
112
163
  .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;
164
+ const list = await listDeliveries({ endpoint: o.endpoint, status: o.status, limit: Number(o.limit) });
116
165
  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
166
  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
167
  ]))));
119
168
  });
120
169
  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));
170
+ const full = id.length >= 32 ? id : (await listDeliveries({ limit: 100 })).find((d) => d.id.startsWith(id))?.id;
122
171
  if (!full)
123
172
  throw new CliError(tr("Không thấy lượt giao này.", "Delivery not found."), Exit.notFound);
124
173
  const r = await post(`/webhook/deliveries/${full}/retry`, {});
@@ -139,9 +188,9 @@ export function registerWebhooks(program) {
139
188
  headers[h.slice(0, i).trim()] = h.slice(i + 1).trim();
140
189
  }
141
190
  if (o.signature)
142
- headers["x-nextx-signature-256"] = o.signature;
191
+ headers[sigHeader()] = o.signature;
143
192
  if (o.timestamp)
144
- headers["x-nextx-timestamp"] = o.timestamp;
193
+ headers[tsHeader()] = o.timestamp;
145
194
  const r = verifySignature(o.secret, body, headers);
146
195
  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
196
  if (!r.ok)
@@ -3,6 +3,7 @@
3
3
  // <brand:nextx>
4
4
  const NEXTX = {
5
5
  id: "nextx",
6
+ platform: "nextx",
6
7
  bin: "nextx",
7
8
  name: "NextX",
8
9
  aiName: "NextX AI",
@@ -10,6 +11,9 @@ const NEXTX = {
10
11
  clientId: "nextx-cli",
11
12
  envPrefix: "NEXTX",
12
13
  mobileApp: "NextX",
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: {
@@ -0,0 +1,23 @@
1
+ // NextFarm services speak two JSON key styles: snake_case (iam, gis, iot, harvest, ai, report, hrm, connect, sales, einvoice)
2
+ // and camelCase (office, notification, operator, finance). The CLI works in camelCase like the NextFarm web app, so keys
3
+ // are converted on the wire for snake services only. Converting a camel service breaks writes silently: `task_id` does not
4
+ // bind to `TaskId`, the field takes its default and the server still answers 200.
5
+ import { brand } from "./brand.js";
6
+ const SNAKE_SERVICES = ["iam", "gis", "iot", "harvest", "ai", "report", "hrm", "connect", "sales", "einvoice", "webhook"];
7
+ const SNAKE_URL = new RegExp(`/v\\d+/(${SNAKE_SERVICES.join("|")})(/|$|\\?)`);
8
+ /** Snake services with camelCase routes: Excel import keyed by column titles, consent with [JsonPropertyName], billing forwarded to operator. */
9
+ const EXEMPT = [/\/v\d+\/gis\/import(\/|$|\?)/, /\/v\d+\/iam\/automation-rules(\/|$|\?)/, /\/v\d+\/iam\/billing(\/|$|\?)/];
10
+ export function snakeOnWire(url) {
11
+ return brand().platform === "nextfarm" && SNAKE_URL.test(url) && !EXEMPT.some((r) => r.test(url));
12
+ }
13
+ const toSnake = (k) => k.replace(/([a-z0-9])([A-Z])/g, "$1_$2").replace(/([A-Z])([A-Z][a-z])/g, "$1_$2").toLowerCase();
14
+ const toCamel = (k) => k.replace(/_([a-z0-9])/g, (_, ch) => ch.toUpperCase());
15
+ function convert(v, key) {
16
+ if (Array.isArray(v))
17
+ return v.map((x) => convert(x, key));
18
+ if (v && typeof v === "object" && Object.getPrototypeOf(v) === Object.prototype)
19
+ return Object.fromEntries(Object.entries(v).map(([k, x]) => [key(k), convert(x, key)]));
20
+ return v;
21
+ }
22
+ export const snakeKeys = (v) => convert(v, toSnake);
23
+ export const camelKeys = (v) => convert(v, toCamel);
@@ -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,12 +1,13 @@
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
+ import { snakeOnWire, snakeKeys, camelKeys } from "./case.js";
10
11
  import { currentAuth, refreshSession } from "../auth/session.js";
11
12
  setGlobalDispatcher(new EnvHttpProxyAgent());
12
13
  export const userAgent = () => `${brand().bin}-cli/${VERSION} (${platform()}; ${arch()}; node ${process.versions.node})`;
@@ -46,10 +47,11 @@ async function send(method, url, opts, auth) {
46
47
  };
47
48
  let body;
48
49
  if (opts.body !== undefined) {
49
- if (typeof opts.body === "string")
50
+ // Multipart: undici sets the boundary itself, so no Content-Type here.
51
+ if (opts.body instanceof FormData || typeof opts.body === "string")
50
52
  body = opts.body;
51
53
  else {
52
- body = JSON.stringify(opts.body);
54
+ body = JSON.stringify(!opts.verbatim && snakeOnWire(url) ? snakeKeys(opts.body) : opts.body);
53
55
  headers["Content-Type"] ??= "application/json";
54
56
  }
55
57
  }
@@ -158,7 +160,8 @@ export async function request(method, path, opts = {}) {
158
160
  const requestId = res.headers.get("x-request-id") ?? undefined;
159
161
  if (opts.raw)
160
162
  return { status: res.status, data: res, headers: res.headers, requestId };
161
- const data = await readBody(res);
163
+ const body = await readBody(res);
164
+ const data = !opts.verbatim && snakeOnWire(res.url || path) ? camelKeys(body) : body;
162
165
  if (!res.ok && !opts.allow?.includes(res.status))
163
166
  throw errorFor(res.status, data, res);
164
167
  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 (NextFarm API keys 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,10 @@ 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";
25
+ import * as nf from "../ai/nextfarm.js";
23
26
  const MODES = ["propose", "auto", "read"];
24
27
  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
28
  let seq = 0;
@@ -90,6 +93,7 @@ export function App(props) {
90
93
  const [modal, setModal] = useState(null);
91
94
  const [mode, setMode] = useState(context().mode);
92
95
  const [agent, setAgent] = useState(context().agent);
96
+ const [local, setLocal] = useState(() => (props.model ? new LocalChat(props.model, true, context().mode === "read") : undefined));
93
97
  const [sessionId, setSessionId] = useState(props.sessionId);
94
98
  const [pending, setPending] = useState([]);
95
99
  const [usageText, setUsageText] = useState("");
@@ -124,7 +128,7 @@ export function App(props) {
124
128
  const line2 = ` ${c.dim(`${process.cwd().replace(process.env.HOME ?? "", "~")}${notes ? ` (${notesFileName()})` : ""} · CLI ${VERSION}`)}`;
125
129
  const strip = (s) => s.replace(/\u001b\[[0-9;]*m/g, "");
126
130
  const pad = (s) => s + " ".repeat(Math.max(0, inner + 1 - [...strip(s)].length));
127
- const title = `─ ${b.aiName} `;
131
+ const title = local ? `─ ${b.name} · model: ${local.model.label} · ${tr("công cụ qua MCP", "tools over MCP")} ` : `─ ${b.aiName} `;
128
132
  say([
129
133
  c.cyan(`╭${title}${"─".repeat(Math.max(0, inner - title.length + 2))}╮`),
130
134
  `${c.cyan("│")}${pad(line1)} ${c.cyan("│")}`,
@@ -191,6 +195,8 @@ export function App(props) {
191
195
  say(proposalCard(p).join("\n"));
192
196
  if (mode === "read")
193
197
  return say(c.dim(tr(" Chế độ chỉ đọc: không ghi. Shift+Tab để đổi.", " Read-only mode: nothing written. Shift+Tab to change.")));
198
+ if (risk === "device")
199
+ 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
200
  if (risk === "finance")
195
201
  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
202
  const yes = mode === "auto" && risk === "normal" ? true : await askConfirm(p);
@@ -245,26 +251,38 @@ export function App(props) {
245
251
  const ctl = new AbortController();
246
252
  abortRef.current = ctl;
247
253
  try {
254
+ const 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
+ };
264
+ if (local) {
265
+ const skipped = atts.filter((a) => a.kind !== "text");
266
+ if (skipped.length)
267
+ 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(", ")}.`)));
268
+ const out = await local.ask({ text: composeText(clean, atts.filter((a) => a.kind === "text"), notes?.text), signal: ctl.signal, onChunk });
269
+ setLastOutcome(out);
270
+ say(renderAnswer(out));
271
+ return;
272
+ }
248
273
  const out = await ask({
249
274
  sessionId,
250
275
  text: composeText(clean, atts, notes?.text),
251
276
  attachments: atts,
252
277
  agent: thisAgent,
253
278
  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
- },
279
+ onChunk,
264
280
  onRetry: (n) => liveRef.current && (liveRef.current.reconnecting = n),
265
281
  });
266
282
  setLastOutcome(out);
267
- updateProfile(context().profile, { lastConversation: sessionId });
283
+ if (out.sessionId && out.sessionId !== sessionId)
284
+ setSessionId(out.sessionId);
285
+ updateProfile(context().profile, { lastConversation: out.sessionId || sessionId });
268
286
  say(renderAnswer(out));
269
287
  if (out.proposedAction)
270
288
  await handleProposal(out.proposedAction);
@@ -315,16 +333,88 @@ export function App(props) {
315
333
  say(`${c.green("✓")} ${tr(`Đã ghi vào ${relative(process.cwd(), target) || notesFileName()} — gửi kèm mọi câu hỏi trong thư mục này.`, `Saved to ${relative(process.cwd(), target) || notesFileName()} — sent with every question in this folder.`)}`);
316
334
  }
317
335
  // ── slash commands ────────────────────────────────────────────────────────────────────────────
336
+ /** NextFarm versions of the slash commands whose server contract differs. True = handled. */
337
+ async function farmSlash(cmd, arg) {
338
+ switch (cmd) {
339
+ case "undo": {
340
+ const target = nf.undoTarget(arg || undefined);
341
+ const ok = await pick(tr(`Hoàn tác "${target.label}"?`, `Undo "${target.label}"?`), [{ label: tr("Không", "No"), value: "n" }, { label: tr("Hoàn tác", "Undo"), value: "y" }]);
342
+ if (ok === "y")
343
+ say(`${c.green("✓")} ${await nf.undo(target)}`);
344
+ return true;
345
+ }
346
+ case "why": {
347
+ const id = arg || lastOutcome?.runId;
348
+ if (!id)
349
+ throw new Error(tr("Chưa có câu trả lời nào trong phiên này.", "No answer in this session yet."));
350
+ say((await nf.runTrace(id)).join("\n"));
351
+ return true;
352
+ }
353
+ case "memory": {
354
+ const m = await nf.memories();
355
+ const [sub, id] = arg.split(/\s+/);
356
+ if (sub === "forget" && id) {
357
+ const hit = m.items.find((x) => x.id.startsWith(id));
358
+ if (!hit)
359
+ throw new Error(tr("Không thấy điều nhớ này.", "Memory not found."));
360
+ await nf.forgetMemory(hit.id);
361
+ say(`${c.green("✓")} ${tr("Đã quên.", "Forgotten.")}`);
362
+ return true;
363
+ }
364
+ say(m.items.length ? table(["ID", tr("Điều nhớ", "Memory"), tr("Dùng", "Uses")], m.items.map((x) => [x.id.slice(0, 8), x.display, x.uses])) + (m.canDelete ? c.dim("\n /memory forget <id>") : "") : tr(" Trợ lý chưa nhớ gì về trang trại.", " Nothing remembered about the farm yet."));
365
+ return true;
366
+ }
367
+ case "schedule": {
368
+ const m = /^"([^"]+)"\s+(daily|weekly)\s+(?:([1-7])@)?(\d{1,2}:\d{2})$/.exec(arg);
369
+ if (m) {
370
+ const [, q, freq, day, time] = m;
371
+ const s = await nf.createSchedule(q, freq, time.padStart(5, "0"), day ? Number(day) : undefined);
372
+ say(`${c.green("✓")} ${tr(`Đã lên lịch · ${s.when}`, `Scheduled · ${s.when}`)}\n${c.dim(tr(` Mở mục Trợ lý AI trong app ${b.mobileApp} một lần để cho phép chạy nền.`, ` Open the AI assistant in the ${b.mobileApp} app once to allow background runs.`))}`);
373
+ }
374
+ else {
375
+ const list = await nf.schedules();
376
+ say((list.length ? table(["ID", tr("Tên", "Title"), tr("Lịch", "When"), tr("Lần tới", "Next")], list.map((s) => [s.id.slice(0, 8), s.title, s.when, s.nextRunAt.slice(0, 16).replace("T", " ")])) + "\n" : "") + c.dim(tr(' /schedule "nhật ký hôm qua" daily 07:00 · weekly 1@07:00 (1 = thứ Hai)', ' /schedule "yesterday\'s log" daily 07:00 · weekly 1@07:00 (1 = Monday)')));
377
+ }
378
+ return true;
379
+ }
380
+ case "sau-benh": {
381
+ if (!arg)
382
+ throw new Error(tr("Cần ảnh: /sau-benh <ảnh.jpg>", "Pass a photo: /sau-benh <photo.jpg>"));
383
+ const [path, ...note] = arg.split(/\s+/);
384
+ await addAttachment(path);
385
+ await send(note.length ? note.join(" ") : tr("Ảnh này cây bị sâu bệnh gì? Mức độ, cách xử lý và lô nào cần kiểm tra thêm.", "What pest or disease is in this photo? Severity, treatment and which plots to check."));
386
+ return true;
387
+ }
388
+ case "thoi-tiet":
389
+ await send(arg ? tr(`Thời tiết 3 ngày tới ở ${arg}, có ảnh hưởng gì tới việc phun, tưới, thu hoạch?`, `Weather for the next 3 days at ${arg} — any impact on spraying, irrigation, harvest?`)
390
+ : tr("Thời tiết 3 ngày tới ở trang trại, có ảnh hưởng gì tới việc phun, tưới, thu hoạch?", "Weather for the next 3 days at the farm — any impact on spraying, irrigation, harvest?"));
391
+ return true;
392
+ case "ban-tin": {
393
+ const brief = await nf.latestBrief();
394
+ say(brief ? `${c.bold(brief.title)} ${c.dim(brief.createdAt.slice(0, 16).replace("T", " "))}\n${markdown(brief.text)}` : tr(" Chưa có bản tin sáng nào. Bật trong app: Trợ lý AI › Bản tin sáng.", " No morning brief yet. Turn it on in the app: AI assistant › Morning brief."));
395
+ return true;
396
+ }
397
+ default:
398
+ return false;
399
+ }
400
+ }
318
401
  async function runSlash(line) {
319
402
  const [cmd0, ...rest] = line.slice(1).split(/\s+/);
320
403
  const cmd = (cmd0 ?? "").toLowerCase();
321
404
  const arg = rest.join(" ").trim();
322
405
  try {
406
+ // Commands that belong to another backend behave like unknown ones.
407
+ if (SLASH.some((s) => s.name === cmd) && !slashCommands().some((s) => s.name === cmd)) {
408
+ say(`${c.yellow("⚠")} ${tr(`Không có lệnh /${cmd}.`, `No command /${cmd}.`)} ${c.dim("/help")}`);
409
+ return;
410
+ }
411
+ if (b.platform === "nextfarm" && (await farmSlash(cmd, arg)))
412
+ return;
323
413
  switch (cmd) {
324
414
  case "help":
325
415
  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"));
416
+ const w = Math.max(...slashCommands().map((s) => s.name.length)) + 2;
417
+ 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
418
  break;
329
419
  }
330
420
  case "new": {
@@ -393,9 +483,22 @@ export function App(props) {
393
483
  say(` ${modeLabel(m)}`);
394
484
  break;
395
485
  }
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\`).`)));
486
+ case "model": {
487
+ if (!arg) {
488
+ const rows = listModels();
489
+ say([
490
+ ` ${tr("Đang dùng", "Using")} ${c.bold(local ? local.model.label : b.aiName)}`,
491
+ ` ${c.cyan(b.bin.padEnd(10))} ${b.aiName}`,
492
+ ...rows.map((r) => ` ${c.cyan(r.name.padEnd(10))} ${r.entry.model ?? ""}${r.hasKey ? "" : c.yellow(tr(" (chưa có khoá)", " (no key)"))}`),
493
+ c.dim(` /model ${b.bin} · /model claude · /model ollama/<model> · ${b.bin} model add <nhà>`),
494
+ ].join("\n"));
495
+ break;
496
+ }
497
+ const m = resolveModel(arg);
498
+ setLocal(m ? new LocalChat(m, true, mode === "read") : undefined);
499
+ 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
500
  break;
501
+ }
399
502
  case "attach":
400
503
  if (!arg)
401
504
  throw new Error(tr("Cần đường dẫn tệp.", "Pass a file path."));
@@ -765,7 +868,7 @@ export function App(props) {
765
868
  });
766
869
  // ── view ──────────────────────────────────────────────────────────────────────────────────────
767
870
  const p = profile();
768
- const footerRight = c.dim(`${p?.workspaceName ?? ""} · ${context().env}${usageText ? ` · ${usageText}` : ""}${agent ? ` · ${agent}` : ""}`);
871
+ const footerRight = c.dim(`${p?.workspaceName ?? ""} · ${context().env}${local ? ` · ${local.model.label}` : usageText ? ` · ${usageText}` : ""}${agent && !local ? ` · ${agent}` : ""}`);
769
872
  const lastReason = live?.activities.filter((a) => a.kind !== "tool").at(-1);
770
873
  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
874
  }
@@ -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();
@@ -9,22 +9,25 @@ export const SLASH = [
9
9
  { name: "rename", args: "<tên>", description: () => tr("Đặt tên hội thoại này", "Rename this conversation") },
10
10
  { name: "pin", description: () => tr("Ghim / bỏ ghim hội thoại", "Pin / unpin this conversation") },
11
11
  { name: "workspace", args: "[mã]", description: () => tr("Đổi workspace đang làm việc", "Switch workspace") },
12
- { name: "agent", args: "[mã]", description: () => tr("Chọn Agent: Trợ lý chung, Bán hàng, CSKH…", "Choose an Agent: general, sales, support…") },
12
+ { name: "agent", args: "[mã]", description: () => tr("Chọn Agent: Trợ lý chung, Bán hàng, CSKH…", "Choose an Agent: general, sales, support…"), platforms: ["nextx"] },
13
13
  { name: "mode", args: "read|propose|auto", description: () => tr("Chế độ quyền: chỉ đọc · đề xuất · tự ghi việc thường", "Permission mode: read · propose · auto") },
14
14
  { name: "model", args: "[tên]", description: () => tr(`Đổi model: ${brand().aiName} hoặc model của bạn`, `Switch model: ${brand().aiName} or your own`) },
15
15
  { name: "attach", args: "<tệp>", description: () => tr("Đính kèm tệp Excel / CSV / ảnh / tệp chữ", "Attach an Excel / CSV / image / text file") },
16
- { name: "pending", description: () => tr("Đề xuất ghi đang chờ xác nhận", "Writes waiting for confirmation") },
16
+ { name: "pending", description: () => tr("Đề xuất ghi đang chờ xác nhận", "Writes waiting for confirmation"), platforms: ["nextx"] },
17
17
  { name: "undo", args: "[id]", description: () => tr("Hoàn tác lần ghi gần nhất", "Undo the last write") },
18
- { name: "journal", description: () => tr("Nhật ký những gì AI đã ghi", "Journal of what the AI wrote") },
18
+ { name: "journal", description: () => tr("Nhật ký những gì AI đã ghi", "Journal of what the AI wrote"), platforms: ["nextx"] },
19
19
  { name: "why", args: "[runId]", 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, tools)") },
20
20
  { name: "memory", description: () => tr("AI đang nhớ gì về công ty — sửa, khoá, quên", "What the AI remembers — edit, lock, forget") },
21
21
  { name: "schedule", args: '"câu" daily 08:00', description: () => tr("Lên lịch một câu hỏi chạy hằng ngày / tuần / tháng", "Schedule a question daily / weekly / monthly") },
22
- { name: "library", description: () => tr("Ảnh, tài liệu, tệp AI đã tạo", "Images, documents and files the AI produced") },
22
+ { name: "library", description: () => tr("Ảnh, tài liệu, tệp AI đã tạo", "Images, documents and files the AI produced"), platforms: ["nextx"] },
23
+ { name: "sau-benh", args: "<ảnh>", platforms: ["nextfarm"], description: () => tr("Chẩn đoán sâu bệnh từ ảnh lá, quả, thân", "Diagnose pests and diseases from a photo") },
24
+ { name: "thoi-tiet", args: "[lô]", platforms: ["nextfarm"], description: () => tr("Thời tiết mấy ngày tới ở trang trại / lô", "Weather for the farm or a plot in the coming days") },
25
+ { name: "ban-tin", platforms: ["nextfarm"], description: () => tr("Bản tin sáng mới nhất của trang trại", "The farm's latest morning brief") },
23
26
  { name: "export", args: "md|json [tệp]", description: () => tr("Xuất hội thoại ra Markdown / JSON", "Export the conversation to Markdown / JSON") },
24
27
  { name: "tools", description: () => tr("Công cụ AI bạn đang được dùng (theo quyền thật)", "AI tools you can use (by your real permissions)") },
25
- { name: "usage", description: () => tr("Lượt AI tháng này", "AI turns this month") },
28
+ { name: "usage", description: () => brand().platform === "nextfarm" ? tr("Câu hỏi AI còn lại tuần này", "AI questions left this week") : tr("Lượt AI tháng này", "AI turns this month") },
26
29
  { name: "feedback", args: "up|down", description: () => tr("Báo câu trả lời vừa rồi tốt / chưa tốt", "Rate the last answer") },
27
- { name: "support", args: "<vấn đề>", description: () => tr("Chuyển cho nhân viên hỗ trợ (mở ticket)", "Hand over to a support agent (ticket)") },
30
+ { name: "support", args: "<vấn đề>", description: () => tr("Chuyển cho nhân viên hỗ trợ (mở ticket)", "Hand over to a support agent (ticket)"), platforms: ["nextx"] },
28
31
  { name: "status", description: () => tr("Tài khoản, workspace, môi trường, phiên bản", "Account, workspace, environment, version") },
29
32
  { name: "doctor", description: () => tr("Kiểm kết nối, đồng hồ, token, gói", "Check connectivity, clock, token, plan") },
30
33
  { name: "config", args: "[khoá giá_trị]", description: () => tr("Cài đặt: ngôn ngữ, màu, thông báo cập nhật", "Settings: language, color, update notices") },
@@ -47,7 +50,11 @@ export const SHORTCUTS = [
47
50
  { keys: "Ctrl+L", description: () => tr("Xoá màn", "Clear screen") },
48
51
  { keys: "Ctrl+C · Ctrl+D", description: () => tr("Huỷ dòng đang gõ (2 lần để thoát) · thoát", "Clear input (twice to exit) · exit") },
49
52
  ];
53
+ /** Commands of the current brand's backend — menu, `/help` and `help --json` all read this. */
54
+ export function slashCommands() {
55
+ return SLASH.filter((s) => !s.platforms || s.platforms.includes(brand().platform));
56
+ }
50
57
  export function matchSlash(prefix) {
51
58
  const p = prefix.replace(/^\//, "").toLowerCase();
52
- return SLASH.filter((s) => s.name.startsWith(p));
59
+ return slashCommands().filter((s) => s.name.startsWith(p));
53
60
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nextxai/cli",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "NextX in your terminal: chat with NextX AI on your workspace data, call the API, manage keys and webhooks",
5
5
  "keywords": [
6
6
  "nextx",
@@ -41,6 +41,7 @@
41
41
  "test": "tsx --test test/*.test.ts"
42
42
  },
43
43
  "dependencies": {
44
+ "@anthropic-ai/sdk": "0.134.0",
44
45
  "commander": "14.0.3",
45
46
  "ink": "6.3.1",
46
47
  "ink-spinner": "5.0.0",