@nextxai/cli 0.1.0 → 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.
@@ -0,0 +1,387 @@
1
+ // NextFarm assistant contract. Same exported shapes as client.ts, different server:
2
+ // POST /ai/agent-runs → GET /ai/agent-runs/{id}/events (SSE, resumable with Last-Event-ID)
3
+ // POST /ai/action-proposals/{id}/decision|undo (Idempotency-Key required)
4
+ // REST bodies are snake_case both ways; SSE event payloads are camelCase.
5
+ import { randomUUID } from "node:crypto";
6
+ import { readFileSync, statSync } from "node:fs";
7
+ import { basename, extname } from "node:path";
8
+ import { FormData } from "undici";
9
+ import { get, post, patch, del, requestRaw, errorFor } from "../core/http.js";
10
+ import { CliError, Exit } from "../core/errors.js";
11
+ import { currentLang, tr } from "../core/i18n.js";
12
+ /** First present key, snake or camel. */
13
+ function pick(o, ...keys) {
14
+ if (!o || typeof o !== "object")
15
+ return undefined;
16
+ for (const k of keys)
17
+ if (o[k] !== undefined && o[k] !== null)
18
+ return o[k];
19
+ return undefined;
20
+ }
21
+ // ── Conversations ───────────────────────────────────────────────────────────────────────────────────
22
+ /** A new conversation is created by the first run; "" means "start one". */
23
+ export const openConversation = async (_forceNew) => ({ sessionId: "", greeting: "", suggestedQuestions: [], status: "active" });
24
+ export async function listConversations(limit = 30) {
25
+ const r = await get("/ai/conversations", { query: { page: 1, page_size: Math.min(limit, 50) } });
26
+ return (pick(r, "items") ?? []).map((x) => ({
27
+ sessionId: String(pick(x, "conversation_id", "conversationId")),
28
+ title: pick(x, "title") ?? null,
29
+ lastPreview: pick(x, "title") ?? null,
30
+ isPinned: Boolean(pick(x, "pinned")),
31
+ status: pick(x, "status"),
32
+ updatedAt: String(pick(x, "last_message_at", "lastMessageAt", "updated_at", "updatedAt") ?? ""),
33
+ }));
34
+ }
35
+ export const renameConversation = (id, title) => patch(`/ai/conversations/${id}`, { title: title ?? "" });
36
+ export const pinConversation = (id, pinned) => patch(`/ai/conversations/${id}`, { pinned });
37
+ export const deleteConversation = (id) => del(`/ai/conversations/${id}`);
38
+ export async function history(sessionId) {
39
+ if (!sessionId)
40
+ return [];
41
+ const r = await get(`/ai/conversations/${sessionId}`);
42
+ return (pick(r, "messages") ?? []).map((m) => ({
43
+ id: String(pick(m, "message_id", "messageId")),
44
+ role: pick(m, "role") === "user" ? "User" : "Assistant",
45
+ text: pick(m, "text") ?? "",
46
+ createdAt: String(pick(m, "created_at", "createdAt") ?? ""),
47
+ }));
48
+ }
49
+ /** NextFarm rates a whole run, so `messageId` here is the run id (see `done.messageId` below). */
50
+ export const feedback = (runId, helpful) => post(`/ai/agent-runs/${runId}/feedback`, { rating: helpful ? "up" : "down" });
51
+ export const escalate = async (_sessionId, _topic) => {
52
+ throw new CliError(tr("NextFarm chưa có chuyển cho nhân viên hỗ trợ từ terminal.", "Handing over to a support agent is not available for NextFarm yet."), Exit.general);
53
+ };
54
+ export async function usage() {
55
+ const q = await get("/ai/assistant/quota");
56
+ const limit = Number(pick(q, "limit") ?? 0);
57
+ return {
58
+ planName: tr("Tuần này", "This week"),
59
+ used: Number(pick(q, "used") ?? 0),
60
+ limit,
61
+ remaining: Number(pick(q, "remaining") ?? 0),
62
+ resetsAt: nextMondayVietnam(),
63
+ enforced: limit > 0,
64
+ };
65
+ }
66
+ /** The weekly quota resets at 00:00 Monday, Vietnam time (UTC+7). */
67
+ export function nextMondayVietnam(now = new Date()) {
68
+ const vn = new Date(now.getTime() + 7 * 3600_000);
69
+ const days = ((8 - vn.getUTCDay()) % 7) || 7;
70
+ const d = new Date(Date.UTC(vn.getUTCFullYear(), vn.getUTCMonth(), vn.getUTCDate() + days));
71
+ return `${d.toISOString().slice(0, 10)}T00:00:00+07:00`;
72
+ }
73
+ // ── Attachments ─────────────────────────────────────────────────────────────────────────────────────
74
+ const IMAGE = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp" };
75
+ const DOCUMENT = {
76
+ ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
77
+ ".csv": "text/csv",
78
+ ".pdf": "application/pdf",
79
+ ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
80
+ };
81
+ const MAX_BYTES = 10 * 1024 * 1024;
82
+ /** Images go to /ai/attachments, documents to /ai/files; both return an id the run references. */
83
+ export async function attachBinary(path, kind) {
84
+ const name = basename(path);
85
+ const ext = extname(path).toLowerCase();
86
+ const size = statSync(path).size;
87
+ if (size > MAX_BYTES)
88
+ throw new CliError(tr("Tệp quá lớn (tối đa 10 MB).", "File too large (max 10 MB)."), Exit.usage);
89
+ const type = kind === "image" ? IMAGE[ext] : DOCUMENT[ext];
90
+ if (!type)
91
+ throw new CliError(tr(`Chưa hỗ trợ ${ext}.`, `Unsupported ${ext}.`), Exit.usage);
92
+ const form = new FormData();
93
+ form.append("file", new Blob([readFileSync(path)], { type }), name);
94
+ const r = await post(kind === "image" ? "/ai/attachments" : "/ai/files", form);
95
+ const id = String(pick(r, "attachment_id", "attachmentId", "id"));
96
+ return kind === "image"
97
+ ? { path, name, kind, imageKey: id, summary: `${Math.round(size / 1024)} KB` }
98
+ : { path, name, kind, fileKey: id, summary: `${Math.round(size / 1024)} KB` };
99
+ }
100
+ export const isDocument = (path) => extname(path).toLowerCase() in DOCUMENT;
101
+ /** Last proposal written from this process — `/undo` without an id targets it. */
102
+ export let lastWritten;
103
+ export async function ask(input) {
104
+ const attachmentIds = [...new Set(input.attachments?.flatMap((a) => [a.imageKey, a.fileKey]).filter(Boolean) ?? [])];
105
+ const created = await post("/ai/agent-runs", {
106
+ message: input.text,
107
+ route_key: "assistant.orchestrate",
108
+ app_id: "farm",
109
+ locale: currentLang(),
110
+ conversation_id: input.sessionId || null,
111
+ client_request_id: randomUUID(),
112
+ attachment_ids: attachmentIds,
113
+ }, { signal: input.signal });
114
+ const runId = String(pick(created, "run_id", "runId", "id"));
115
+ const outcome = {
116
+ text: "", notices: [], activities: [], ended: "error", runId,
117
+ sessionId: String(pick(created, "conversation_id", "conversationId") ?? input.sessionId ?? ""),
118
+ };
119
+ const proposals = [];
120
+ // The run lives on the server; a dropped stream reconnects from the last event instead of asking again.
121
+ let lastEventId;
122
+ for (let attempt = 0;; attempt++) {
123
+ if (attempt > 0) {
124
+ if (attempt > 5)
125
+ throw new CliError(tr("Mất kết nối giữa chừng.", "Connection lost mid-answer."), Exit.network);
126
+ input.onRetry?.(attempt);
127
+ await new Promise((r) => setTimeout(r, 1000 * attempt));
128
+ }
129
+ try {
130
+ const res = await requestRaw("GET", `/ai/agent-runs/${runId}/events`, {
131
+ signal: input.signal,
132
+ headers: { Accept: "text/event-stream", ...(lastEventId ? { "Last-Event-ID": lastEventId } : {}) },
133
+ });
134
+ if (!res.ok)
135
+ throw errorFor(res.status, await res.json().catch(() => undefined), res);
136
+ if (!res.body)
137
+ throw new CliError(tr("Máy chủ trả luồng rỗng.", "Empty stream from server."), Exit.general);
138
+ for await (const ev of events(res.body)) {
139
+ lastEventId = ev.id ?? lastEventId;
140
+ const chunk = toChunk(ev, outcome, proposals);
141
+ if (chunk)
142
+ input.onChunk(chunk);
143
+ if (outcome.done) {
144
+ outcome.proposedAction = proposals[0];
145
+ if (proposals.length > 1)
146
+ outcome.notices.push({ code: "proposals", message: tr(`Còn ${proposals.length - 1} đề xuất khác — xem trong app.`, `${proposals.length - 1} more proposals — see the app.`) });
147
+ return outcome;
148
+ }
149
+ }
150
+ throw new CliError(tr("Mất kết nối giữa chừng.", "Connection lost mid-answer."), Exit.network);
151
+ }
152
+ catch (e) {
153
+ if (input.signal?.aborted) {
154
+ await post(`/ai/agent-runs/${runId}/cancel`, {}).catch(() => undefined);
155
+ throw e;
156
+ }
157
+ if (!(e instanceof CliError) || e.exitCode !== Exit.network)
158
+ throw e;
159
+ }
160
+ }
161
+ }
162
+ function toChunk(ev, out, proposals) {
163
+ const p = ev.payload ?? {};
164
+ switch (ev.type) {
165
+ case "progress.updated": {
166
+ const activity = { kind: "tool", label: String(p.label ?? ""), toolName: p.toolName ?? null };
167
+ if (p.completed)
168
+ return undefined;
169
+ out.activities.push(activity);
170
+ return { type: "activity", activity, runId: out.runId };
171
+ }
172
+ case "assistant.message_delta": {
173
+ // Leading blank deltas would print empty lines before the answer.
174
+ const delta = out.text ? String(p.delta ?? "") : String(p.delta ?? "").trimStart();
175
+ if (!delta)
176
+ return undefined;
177
+ out.text += delta;
178
+ return { type: "token", text: delta };
179
+ }
180
+ case "assistant.message_completed": {
181
+ const text = String(pick(p.message, "text") ?? "");
182
+ // Some answers arrive whole, without deltas.
183
+ if (!out.text && text) {
184
+ out.text = text;
185
+ return { type: "token", text };
186
+ }
187
+ return undefined;
188
+ }
189
+ case "action.approval_required":
190
+ proposals.push(toProposal(String(p.proposalId), String(p.actionType), p.proposal));
191
+ return undefined;
192
+ case "ui.action_requested": {
193
+ // Clarifying question: { action: "ask", label, value: '{"options":[…]}' }. Options become numbered follow-ups (type 1, 2, 3).
194
+ if (p.action !== "ask" || typeof p.label !== "string")
195
+ return undefined;
196
+ let options = [];
197
+ try {
198
+ options = (JSON.parse(String(p.value ?? "{}")).options ?? []).filter((x) => typeof x === "string");
199
+ }
200
+ catch {
201
+ // question without options
202
+ }
203
+ out.followUps = options;
204
+ const text = `${out.text ? "\n\n" : ""}${p.label.trim()}${options.map((o, i) => `\n ${i + 1}. ${o}`).join("")}`;
205
+ out.text += text;
206
+ return { type: "token", text };
207
+ }
208
+ case "run.completed":
209
+ case "run.awaiting_user":
210
+ out.ended = "done";
211
+ out.done = { type: "done", messageId: out.runId, runId: out.runId, followUps: out.followUps ?? [] };
212
+ return out.done;
213
+ case "run.cancelled":
214
+ case "run.failed": {
215
+ const message = String(p.errorMessage ?? (ev.type === "run.cancelled" ? tr("Đã dừng.", "Stopped.") : tr("Trợ lý gặp lỗi, thử lại.", "The assistant failed, try again.")));
216
+ out.ended = "error";
217
+ out.done = { type: "error", code: String(p.errorCode ?? ev.type), message, text: message, runId: out.runId };
218
+ if (!out.text)
219
+ out.text = message;
220
+ return out.done;
221
+ }
222
+ default:
223
+ return undefined;
224
+ }
225
+ }
226
+ async function* events(stream) {
227
+ const decoder = new TextDecoder();
228
+ let buf = "";
229
+ for await (const part of stream) {
230
+ buf += decoder.decode(part, { stream: true });
231
+ let idx;
232
+ while ((idx = buf.indexOf("\n\n")) >= 0) {
233
+ const frame = buf.slice(0, idx);
234
+ buf = buf.slice(idx + 2);
235
+ const data = frame.split("\n").filter((l) => l.startsWith("data:")).map((l) => l.slice(5).trimStart()).join("\n");
236
+ if (!data)
237
+ continue;
238
+ try {
239
+ yield JSON.parse(data);
240
+ }
241
+ catch {
242
+ // heartbeat or partial frame
243
+ }
244
+ }
245
+ }
246
+ }
247
+ // ── Proposals ───────────────────────────────────────────────────────────────────────────────────────
248
+ /** Line kinds that move money or touch devices: never confirmed from the terminal (the server enforces it too). */
249
+ const FINANCE_KINDS = new Set(["cash", "settlement", "purchase_slip"]);
250
+ function kindsOf(v, acc = []) {
251
+ if (Array.isArray(v))
252
+ for (const x of v)
253
+ kindsOf(x, acc);
254
+ else if (v && typeof v === "object")
255
+ for (const [k, x] of Object.entries(v)) {
256
+ if (k === "kind" && typeof x === "string")
257
+ acc.push(x);
258
+ else
259
+ kindsOf(x, acc);
260
+ }
261
+ return acc;
262
+ }
263
+ function entriesOf(v) {
264
+ if (!v || typeof v !== "object")
265
+ return [];
266
+ if (Array.isArray(v.entries))
267
+ return v.entries;
268
+ for (const x of Object.values(v)) {
269
+ const found = entriesOf(x);
270
+ if (found.length)
271
+ return found;
272
+ }
273
+ return [];
274
+ }
275
+ export function riskOfNextFarm(actionType, proposal) {
276
+ if (/^(automation|iot|drone)\./.test(actionType))
277
+ return "device";
278
+ const kinds = kindsOf(proposal);
279
+ if (kinds.some((k) => k.startsWith("iot_")))
280
+ return "device";
281
+ if (kinds.some((k) => FINANCE_KINDS.has(k)))
282
+ return "finance";
283
+ if (entriesOf(proposal).length > 20 || kinds.some((k) => /close|return|delete|remove/.test(k)))
284
+ return "large";
285
+ return "normal";
286
+ }
287
+ function toProposal(proposalId, actionType, proposal) {
288
+ const entries = entriesOf(proposal);
289
+ const summary = pick(proposal, "summary", "title") ?? "";
290
+ const lines = entries.length
291
+ ? entries.map((e) => `+ ${pick(e, "label") ?? pick(pick(e, "display"), "title") ?? pick(e, "kind") ?? ""}`)
292
+ : summary ? [] : [JSON.stringify(proposal).slice(0, 300)];
293
+ return {
294
+ kind: actionType,
295
+ label: summary || (entries.length ? tr(`Ghi ${entries.length} mục`, `Write ${entries.length} entries`) : actionType),
296
+ confirmPrompt: lines.join("\n"),
297
+ payloadJson: JSON.stringify(proposal ?? {}),
298
+ writes: true,
299
+ nonce: proposalId,
300
+ recipientCount: entries.length > 1 ? entries.length : null,
301
+ risk: riskOfNextFarm(actionType, proposal),
302
+ };
303
+ }
304
+ const EXECUTED = new Set(["succeeded", "partial"]);
305
+ export async function confirmAction(_sessionId, a) {
306
+ const r = await post(`/ai/action-proposals/${a.nonce}/decision`, { approved: true }, { idempotencyKey: true });
307
+ const execution = String(pick(r, "execution") ?? "");
308
+ const lines = pick(r, "lines") ?? [];
309
+ const ok = lines.filter((l) => pick(l, "status") === "succeeded").length;
310
+ const failed = lines.filter((l) => ["failed", "outcome_unknown"].includes(String(pick(l, "status")))).length;
311
+ const success = EXECUTED.has(execution);
312
+ const codes = [...new Set(lines.filter((l) => pick(l, "code")).map((l) => String(pick(l, "code"))))];
313
+ if (success)
314
+ lastWritten = { proposalId: a.nonce, label: a.label };
315
+ const message = execution === "deterministic_command_required"
316
+ ? tr("Đã ghi nhận đồng ý. Việc này hoàn tất trong app NextFarm.", "Approval recorded. Finish this in the NextFarm app.")
317
+ : success
318
+ ? tr(`Đã ghi ${ok || lines.length} mục${failed ? `, ${failed} mục lỗi — xem trong app` : ""}.`, `Wrote ${ok || lines.length} entries${failed ? `, ${failed} failed — check the app` : ""}.`)
319
+ : tr(`Chưa ghi được (${[execution || "không rõ", ...codes].join(" · ")}).`, `Not written (${[execution || "unknown", ...codes].join(" · ")}).`);
320
+ return { success, message, messageId: a.nonce, kind: a.kind, writes: true };
321
+ }
322
+ export async function rejectAction(proposalId) {
323
+ await post(`/ai/action-proposals/${proposalId}/decision`, { approved: false }, { idempotencyKey: true });
324
+ }
325
+ /** What `/undo` would revert: the given proposal, or the last one written from this process. */
326
+ export function undoTarget(proposalId) {
327
+ const target = proposalId ? { proposalId, label: proposalId.slice(0, 8) } : lastWritten;
328
+ if (!target)
329
+ throw new CliError(tr("Chưa có lần ghi nào trong phiên này để hoàn tác.", "Nothing written in this session to undo."), Exit.notFound);
330
+ return target;
331
+ }
332
+ export async function undo(target) {
333
+ const r = await post(`/ai/action-proposals/${target.proposalId}/undo`, {}, { idempotencyKey: true });
334
+ const undone = (pick(r, "lines") ?? []).filter((l) => pick(l, "undone")).length;
335
+ return tr(`Đã hoàn tác${undone ? ` ${undone} mục` : ""}.`, `Undone${undone ? ` ${undone} entries` : ""}.`);
336
+ }
337
+ // ── Run trace, memory, schedules, brief ─────────────────────────────────────────────────────────────
338
+ export async function runTrace(runId) {
339
+ const r = await get(`/ai/agent-runs/${runId}`);
340
+ const status = String(pick(r, "status") ?? "");
341
+ const steps = (pick(r, "activities", "progress") ?? []).map((s, i) => ` ${i + 1}. ${pick(s, "label") ?? ""}${pick(s, "tool_name", "toolName") ? ` · ${pick(s, "tool_name", "toolName")}` : ""}`);
342
+ return [` ${tr("Lượt", "Run")} ${runId.slice(0, 8)} · ${status}`, ...(steps.length ? steps : [tr(" (không có bước công cụ)", " (no tool steps)")])];
343
+ }
344
+ export async function memories() {
345
+ const r = await get("/ai/memories");
346
+ return {
347
+ canDelete: Boolean(pick(r, "can_delete", "canDelete")),
348
+ items: (pick(r, "items") ?? []).map((m) => ({
349
+ id: String(pick(m, "id")),
350
+ display: `${pick(m, "key") ?? ""}: ${pick(m, "value") ?? ""}`,
351
+ kind: String(pick(m, "kind") ?? ""),
352
+ uses: Number(pick(m, "use_count", "useCount") ?? 0),
353
+ })),
354
+ };
355
+ }
356
+ export const forgetMemory = (id) => del(`/ai/memories/${id}`);
357
+ export async function schedules() {
358
+ const r = await get("/ai/scheduled-prompts");
359
+ const list = Array.isArray(r) ? r : (pick(r, "items") ?? []);
360
+ return list.map((s) => ({
361
+ id: String(pick(s, "id")),
362
+ title: String(pick(s, "title", "prompt") ?? ""),
363
+ when: `${(pick(s, "days_of_week", "daysOfWeek") ?? []).length ? `${tr("thứ", "days")} ${(pick(s, "days_of_week", "daysOfWeek") ?? []).join(",")}` : tr("hằng ngày", "daily")} ${pick(s, "time_of_day", "timeOfDay") ?? ""}`,
364
+ nextRunAt: String(pick(s, "next_run_at", "nextRunAt") ?? ""),
365
+ enabled: pick(s, "enabled") !== false,
366
+ }));
367
+ }
368
+ /** daily → every day; weekly N → ISO weekday N (1 = Monday). Running in the background needs the app's one-time consent. */
369
+ export async function createSchedule(question, frequency, timeOfDay, day) {
370
+ const r = await post("/ai/scheduled-prompts", {
371
+ title: question.slice(0, 60), question, time_of_day: timeOfDay, days_of_week: frequency === "weekly" ? [day ?? 1] : [], channels: ["app"], enabled: true,
372
+ });
373
+ return (await schedules()).find((x) => x.id === String(pick(r, "id"))) ?? { id: String(pick(r, "id")), title: question, when: timeOfDay, nextRunAt: "", enabled: true };
374
+ }
375
+ export const runSchedule = (id) => post(`/ai/scheduled-prompts/${id}/run`, {});
376
+ export const deleteSchedule = (id) => del(`/ai/scheduled-prompts/${id}`);
377
+ /** Latest morning brief, or undefined when none was sent yet. */
378
+ export async function latestBrief() {
379
+ const r = await get("/ai/briefs/latest");
380
+ if (!r)
381
+ return undefined;
382
+ return {
383
+ title: String(pick(r, "title", "subject") ?? tr("Bản tin sáng", "Morning brief")),
384
+ text: String(pick(r, "text", "body", "content", "summary") ?? ""),
385
+ createdAt: String(pick(r, "created_at", "createdAt") ?? ""),
386
+ };
387
+ }
package/dist/auth/ci.js CHANGED
@@ -53,7 +53,7 @@ export async function loginCi(opts) {
53
53
  const json = (await res.json().catch(() => ({})));
54
54
  if (!res.ok) {
55
55
  if (json.error === "invalid_grant" || json.error === "unauthorized_client")
56
- throw new CliError(tr(`Kho/nhánh này chưa được phép đăng nhập (${provider}). Quản trị khai báo ở ${ep.app} › Thiết lập › Ứng dụng & API › CI.`, `This repository/branch is not allowed to log in (${provider}). An admin can allow it at ${ep.app} › Settings › Apps & API › CI.`), Exit.auth, String(json.error_description ?? ""));
56
+ throw new CliError(tr(`Kho/nhánh này chưa được phép đăng nhập (${provider}). Quản trị khai báo ở ${ep.app} › ${brand().ciSettings.vi}.`, `This repository/branch is not allowed to log in (${provider}). An admin can allow it at ${ep.app} › ${brand().ciSettings.en}.`), Exit.auth, String(json.error_description ?? ""));
57
57
  throw errorFor(res.status, json, res);
58
58
  }
59
59
  const session = sessionFrom({ ...json, refresh_token: "ci" }, "ci");
@@ -1,7 +1,6 @@
1
1
  // Delegated OAuth for the hosted MCP server. Tool catalog and tool calls only accept tokens issued for the
2
- // MCP resource (RFC 8707), so the CLI registers itself like any MCP client: dynamic client registration
3
- // (public, PKCE, loopback), one consent in the browser, then refresh tokens.
4
- import { hostname } from "node:os";
2
+ // MCP resource (RFC 8707): public client identified by its metadata document URL, PKCE + loopback, one consent
3
+ // in the browser, then refresh tokens.
5
4
  import { fetch } from "undici";
6
5
  import { brand } from "../core/brand.js";
7
6
  import { context } from "../core/context.js";
@@ -46,6 +45,13 @@ async function discover() {
46
45
  throw errorFor(a.res.status, a.body, a.res);
47
46
  return { resource: r.body, as: a.body, asUrl };
48
47
  }
48
+ /**
49
+ * The CLI's client_id is the URL of its metadata document on the developer portal (Client ID Metadata Document).
50
+ * The portal's domain is trusted by the authorization server, so the CLI counts as a verified AI app and works in
51
+ * workspaces that allow verified apps only (the default) — a self-registered (DCR) client never would.
52
+ * Each environment uses its own portal (both are trusted by their authorization server).
53
+ */
54
+ export const cliClientId = () => `${context().endpoints.docs}/cli/oauth-client.json`;
49
55
  /** Scopes to ask for: everything the server offers, limited to the brand's modules. */
50
56
  function wantedScopes(offered) {
51
57
  const mods = brand().modules;
@@ -53,28 +59,9 @@ function wantedScopes(offered) {
53
59
  }
54
60
  export async function authorizeMcp(opts = {}) {
55
61
  const { resource, as, asUrl } = await discover();
56
- if (!as.registration_endpoint)
57
- throw new CliError(tr("Máy chủ không cho đăng ký client.", "The server does not allow client registration."), Exit.general);
58
62
  const p = pkce();
59
63
  const waiter = await waitForCode(p.state);
60
- const prev = readGrant();
61
- let clientId = prev?.authServer === asUrl ? prev.clientId : undefined;
62
- if (!clientId) {
63
- const reg = await json(as.registration_endpoint, {
64
- method: "POST",
65
- headers: { "Content-Type": "application/json" },
66
- body: JSON.stringify({
67
- client_name: `${brand().name} CLI (${hostname()})`,
68
- client_uri: context().endpoints.docs + "/docs/cli",
69
- redirect_uris: ["http://127.0.0.1/callback"],
70
- grant_types: ["authorization_code", "refresh_token"],
71
- token_endpoint_auth_method: "none",
72
- }),
73
- });
74
- if (!reg.res.ok)
75
- throw errorFor(reg.res.status, reg.body, reg.res);
76
- clientId = reg.body.client_id;
77
- }
64
+ const clientId = cliClientId();
78
65
  const scope = wantedScopes(resource.scopes_supported ?? []);
79
66
  const url = new URL(as.authorization_endpoint);
80
67
  url.search = new URLSearchParams({
@@ -2,6 +2,7 @@
2
2
  // Order: <PREFIX>_API_KEY → <PREFIX>_TOKEN → stored session of the profile. `status` prints which one is used.
3
3
  import { fetch } from "undici";
4
4
  import { brand, envVar } from "../core/brand.js";
5
+ import { snakeKeys, snakeOnWire } from "../core/case.js";
5
6
  import { context } from "../core/context.js";
6
7
  import { CliError, Exit } from "../core/errors.js";
7
8
  import { t } from "../core/i18n.js";
@@ -79,6 +80,11 @@ export function sessionFrom(body, via, prev) {
79
80
  * Refreshes the stored session. `staleAccess` is the token the caller saw: if another process already
80
81
  * replaced it while we waited for the lock, its result is reused instead of refreshing again.
81
82
  */
83
+ /** Body for `/iam/auth/refresh-token`. NextFarm IAM is snake_case: a camelCase key is ignored and answers 401 "required". */
84
+ export function refreshBody(url, refreshToken) {
85
+ const body = { refreshToken };
86
+ return JSON.stringify(snakeOnWire(url) ? snakeKeys(body) : body);
87
+ }
82
88
  export async function refreshSession(staleAccess) {
83
89
  const profile = context().profile;
84
90
  return withFileLock(lockFile(profile), async () => {
@@ -99,7 +105,7 @@ export async function refreshSession(staleAccess) {
99
105
  res = await fetch(url, {
100
106
  method: "POST",
101
107
  headers: { "Content-Type": "application/json", "X-Client-Type": "cli", "User-Agent": `${brand().bin}-cli` },
102
- body: JSON.stringify({ refreshToken: s.refreshToken }),
108
+ body: refreshBody(url, s.refreshToken),
103
109
  });
104
110
  }
105
111
  catch (e) {
@@ -0,0 +1,109 @@
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 } from "../core/http.js";
6
+ import { hasTty, confirm } from "../core/prompt.js";
7
+ import { requirePerson } from "../auth/session.js";
8
+ import * as nf from "../ai/nextfarm.js";
9
+ import { feedback, usage } from "../ai/client.js";
10
+ import { markdown } from "../repl/markdown.js";
11
+ import { runRoot } from "./ai.js";
12
+ const when = (s) => s.slice(0, 16).replace("T", " ");
13
+ export function registerAiNextFarm(ai, program) {
14
+ ai.command("why [runId]")
15
+ .description(tr("vì sao trợ lý trả lời vậy: các bước, công cụ đã gọi", "why the assistant answered that way: steps and tools"))
16
+ .action(async (runId) => {
17
+ await requirePerson();
18
+ const id = runId ?? (await get("/ai/agent-runs", { query: { page_size: 1 } })).items?.[0];
19
+ const rid = typeof id === "string" ? id : id?.run_id ?? id?.id;
20
+ if (!rid)
21
+ throw new CliError(tr("Chưa có lượt chạy nào.", "No runs yet."), Exit.notFound);
22
+ const lines = await nf.runTrace(rid);
23
+ emit({ runId: rid, steps: lines }, () => info(lines.join("\n")));
24
+ });
25
+ ai.command("apply <proposalId>").description(tr("xác nhận một đề xuất ghi (ghi thật)", "approve a proposed write (real write)"))
26
+ .action(async (proposalId) => {
27
+ await requirePerson();
28
+ if (hasTty() && !(await confirm(tr(`Ghi đề xuất ${proposalId.slice(0, 8)}?`, `Write proposal ${proposalId.slice(0, 8)}?`))))
29
+ return;
30
+ // Device, automation and money lines are refused by the server for the CLI (403 proposal.requires_app).
31
+ const r = await nf.confirmAction("", { kind: "", label: proposalId.slice(0, 8), confirmPrompt: "", payloadJson: "{}", writes: true, nonce: proposalId });
32
+ emit(r, () => (r.success ? ok(r.message) : info(c.yellow(`⚠ ${r.message}`))));
33
+ if (!r.success)
34
+ process.exitCode = Exit.notConfirmed;
35
+ });
36
+ ai.command("reject <proposalId>").description(tr("từ chối một đề xuất ghi", "reject a proposed write"))
37
+ .action(async (proposalId) => {
38
+ await nf.rejectAction(proposalId);
39
+ ok(tr("Đã từ chối.", "Rejected."));
40
+ });
41
+ ai.command("undo <proposalId>").description(tr("hoàn tác đề xuất đã ghi (trong 24 giờ)", "undo a written proposal (within 24 hours)"))
42
+ .action(async (proposalId) => {
43
+ if (hasTty() && !(await confirm(tr("Hoàn tác?", "Undo?"))))
44
+ return;
45
+ ok(await nf.undo(nf.undoTarget(proposalId)));
46
+ });
47
+ const mem = ai.command("memory").description(tr("trợ lý đang nhớ gì về trang trại", "what the assistant remembers about the farm"));
48
+ mem.command("list", { isDefault: true }).action(async () => {
49
+ const m = await nf.memories();
50
+ emit(m.items, () => info(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])) : tr("Trợ lý chưa nhớ gì.", "Nothing remembered yet.")));
51
+ });
52
+ mem.command("forget <id>").description(tr("quên một điều (chỉ chủ trại)", "forget one item (farm owner only)")).action(async (id) => {
53
+ const hit = (await nf.memories()).items.find((x) => x.id.startsWith(id));
54
+ if (!hit)
55
+ throw new CliError(tr("Không thấy điều nhớ này.", "Memory not found."), Exit.notFound);
56
+ await nf.forgetMemory(hit.id);
57
+ ok(tr("Đã quên.", "Forgotten."));
58
+ });
59
+ const sch = ai.command("schedule").description(tr("câu hỏi chạy định kỳ", "recurring questions"));
60
+ sch.command("list", { isDefault: true }).action(async () => {
61
+ const list = await nf.schedules();
62
+ emit(list, () => info(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, when(s.nextRunAt)]))));
63
+ });
64
+ sch.command("add <question>")
65
+ .requiredOption("--at <HH:mm>", tr("giờ chạy (giờ Việt Nam)", "time (Vietnam time)"))
66
+ .option("--weekday <1-7>", tr("chỉ một ngày trong tuần (1 = thứ Hai); bỏ trống = hằng ngày", "one weekday only (1 = Monday); omit = daily"))
67
+ .action(async (question, o) => {
68
+ const s = await nf.createSchedule(question, o.weekday ? "weekly" : "daily", o.at.padStart(5, "0"), o.weekday ? Number(o.weekday) : undefined);
69
+ emit(s, () => {
70
+ ok(tr(`Đã lên lịch · ${s.when}`, `Scheduled · ${s.when}`));
71
+ info(c.dim(tr(` Mở mục Trợ lý AI trong app ${brand().mobileApp} một lần để cho phép chạy nền.`, ` Open the AI assistant in the ${brand().mobileApp} app once to allow background runs.`)));
72
+ });
73
+ });
74
+ sch.command("run <id>").action(async (id) => {
75
+ const hit = (await nf.schedules()).find((s) => s.id.startsWith(id));
76
+ if (!hit)
77
+ throw new CliError(tr("Không thấy lịch.", "Schedule not found."), Exit.notFound);
78
+ await nf.runSchedule(hit.id);
79
+ ok(tr("Đã chạy.", "Run started."));
80
+ });
81
+ sch.command("delete <id>").action(async (id) => {
82
+ const hit = (await nf.schedules()).find((s) => s.id.startsWith(id));
83
+ if (!hit)
84
+ throw new CliError(tr("Không thấy lịch.", "Schedule not found."), Exit.notFound);
85
+ await nf.deleteSchedule(hit.id);
86
+ ok(tr("Đã xoá.", "Deleted."));
87
+ });
88
+ ai.command("brief").description(tr("bản tin sáng mới nhất", "latest morning brief"))
89
+ .action(async () => {
90
+ const b = await nf.latestBrief();
91
+ emit(b ?? null, () => info(b ? `${c.bold(b.title)} ${c.dim(when(b.createdAt))}\n${markdown(b.text)}` : tr("Chưa có bản tin sáng.", "No morning brief yet.")));
92
+ });
93
+ ai.command("pest <photo> [note...]").description(tr("chẩn đoán sâu bệnh từ ảnh", "diagnose pests and diseases from a photo"))
94
+ .action(async (photo, note) => {
95
+ const root = program.opts();
96
+ const q = 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.");
97
+ await runRoot({ ...root, print: q, file: [photo, ...(root.file ?? [])] }, []);
98
+ });
99
+ ai.command("usage").description(tr("câu hỏi AI còn lại tuần này", "AI questions left this week"))
100
+ .action(async () => {
101
+ const u = await usage();
102
+ emit(u, () => info(tr(`Tuần này: đã dùng ${u.used}/${u.limit} câu · còn ${u.remaining} · mở lại 00:00 thứ Hai`, `This week: ${u.used}/${u.limit} used · ${u.remaining} left · resets Monday 00:00`)));
103
+ });
104
+ ai.command("feedback <runId> <vote>").description(tr("báo câu trả lời tốt (up) / chưa tốt (down)", "rate an answer: up / down"))
105
+ .action(async (runId, vote) => {
106
+ await feedback(runId, vote === "up");
107
+ ok(tr("Cảm ơn bạn đã góp ý.", "Thanks for the feedback."));
108
+ });
109
+ }