@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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.0
4
+
5
+ - `--model`: answer with your own model on your machine — Claude, Gemini, GPT, Grok or Ollama — using your workspace tools over MCP. Writes still only create a proposal you confirm. `model add|list|remove|default`, and `/model` switches inside the chat.
6
+ - AI tool access (`tools`, `--model`) now works in workspaces that allow verified AI apps only (the default): the CLI identifies itself with a client metadata document on the developer portal instead of registering a new client.
7
+ - `login --ci`: GitHub Actions / GitLab CI sign in with their OIDC token, no secret. Admins allow a repository with `ci trust add`.
8
+
3
9
  ## 0.1.1
4
10
 
5
11
  - x64 builds (Intel Mac, Linux, Windows) now target every x64 CPU, including older Intel Macs without AVX2.
package/dist/ai/client.js CHANGED
@@ -7,17 +7,21 @@ import { get, post, put, patch, del, requestRaw, errorFor, networkError } from "
7
7
  import { CliError, Exit } from "../core/errors.js";
8
8
  import { tr } from "../core/i18n.js";
9
9
  import { brand } from "../core/brand.js";
10
+ import * as nf from "./nextfarm.js";
11
+ /** NextFarm talks to its own assistant API (see nextfarm.ts); the other brands share this file's contract. */
12
+ const farm = () => brand().platform === "nextfarm";
10
13
  const BASE = "/operator/widget/support";
11
- export const openConversation = (forceNew) => post(`${BASE}/init`, { forceNew });
12
- export const listConversations = (limit = 30) => get(`${BASE}/conversations`, { query: { limit } });
13
- export const renameConversation = (id, title) => patch(`${BASE}/conversations/${id}/title`, { title });
14
- export const pinConversation = (id, pinned) => put(`${BASE}/conversations/${id}/pin`, { pinned });
15
- export const deleteConversation = (id) => del(`${BASE}/conversations/${id}`);
16
- export const history = (sessionId) => get(`${BASE}/history`, { query: { sessionId } });
17
- export const feedback = (messageId, helpful) => post(`${BASE}/messages/${messageId}/feedback`, { helpful });
18
- export const escalate = (sessionId, topic) => post(`${BASE}/escalate`, { sessionId, topic });
19
- export const usage = () => get(`${BASE}/usage`);
20
- export const confirmAction = (sessionId, a) => post(`${BASE}/${sessionId}/confirm-action`, { kind: a.kind, payloadJson: a.payloadJson, nonce: a.nonce });
14
+ export const openConversation = (forceNew) => farm() ? nf.openConversation(forceNew) : post(`${BASE}/init`, { forceNew });
15
+ export const listConversations = (limit = 30) => farm() ? nf.listConversations(limit) : get(`${BASE}/conversations`, { query: { limit } });
16
+ export const renameConversation = (id, title) => farm() ? nf.renameConversation(id, title) : patch(`${BASE}/conversations/${id}/title`, { title });
17
+ export const pinConversation = (id, pinned) => farm() ? nf.pinConversation(id, pinned) : put(`${BASE}/conversations/${id}/pin`, { pinned });
18
+ export const deleteConversation = (id) => farm() ? nf.deleteConversation(id) : del(`${BASE}/conversations/${id}`);
19
+ export const history = (sessionId) => farm() ? nf.history(sessionId) : get(`${BASE}/history`, { query: { sessionId } });
20
+ export const feedback = (messageId, helpful) => farm() ? nf.feedback(messageId, helpful) : post(`${BASE}/messages/${messageId}/feedback`, { helpful });
21
+ export const escalate = (sessionId, topic) => farm() ? nf.escalate(sessionId, topic) : post(`${BASE}/escalate`, { sessionId, topic });
22
+ export const usage = () => farm() ? nf.usage() : get(`${BASE}/usage`);
23
+ export const confirmAction = (sessionId, a) => farm() ? nf.confirmAction(sessionId, a) :
24
+ post(`${BASE}/${sessionId}/confirm-action`, { kind: a.kind, payloadJson: a.payloadJson, nonce: a.nonce });
21
25
  const TABLE = new Set([".xlsx", ".csv"]);
22
26
  const IMAGE = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp" };
23
27
  const TEXT = new Set([".txt", ".md", ".json", ".log", ".yml", ".yaml", ".tsv", ".xml", ".html", ".sql"]);
@@ -25,6 +29,8 @@ const MAX_BYTES = 10 * 1024 * 1024;
25
29
  const MAX_TEXT = 20_000;
26
30
  export function attachmentKind(path) {
27
31
  const ext = extname(path).toLowerCase();
32
+ if (farm() && nf.isDocument(path))
33
+ return "table";
28
34
  if (TABLE.has(ext))
29
35
  return "table";
30
36
  if (ext in IMAGE)
@@ -61,6 +67,8 @@ export async function attach(path, sessionId) {
61
67
  const cut = raw.length > MAX_TEXT;
62
68
  return { path, name, kind, text: cut ? raw.slice(0, MAX_TEXT) : raw, summary: tr(`${raw.length.toLocaleString("vi-VN")} ký tự${cut ? ", đã cắt" : ""}`, `${raw.length} chars${cut ? ", truncated" : ""}`) };
63
69
  }
70
+ if (farm())
71
+ return nf.attachBinary(path, kind);
64
72
  if (size > MAX_BYTES)
65
73
  throw new CliError(tr("Tệp quá lớn (tối đa 10 MB).", "File too large (max 10 MB)."), Exit.usage);
66
74
  const body = readFileSync(path);
@@ -90,6 +98,8 @@ export function composeText(question, attachments, projectNotes) {
90
98
  * so the server replays the stored answer instead of answering (and charging) twice.
91
99
  */
92
100
  export async function ask(input) {
101
+ if (farm())
102
+ return nf.ask(input);
93
103
  const key = randomUUID();
94
104
  const body = {
95
105
  sessionId: input.sessionId,
@@ -187,6 +197,8 @@ const FINANCE = /payment|invoice|einvoice|finance|refund|payout|wallet|billing|t
187
197
  const LARGE = /delete|remove|merge|bulk|batch|mass|permission|role|group|xoa|gop|hang.?loat|campaign|broadcast/i;
188
198
  /** Large = deletes, merges, permission changes, > 20 records or recipients. Finance never runs from the terminal. */
189
199
  export function riskOf(a) {
200
+ if (a.risk)
201
+ return a.risk;
190
202
  if (FINANCE.test(a.kind))
191
203
  return "finance";
192
204
  if (LARGE.test(a.kind))
@@ -0,0 +1,286 @@
1
+ // --model: the agent loop runs on this machine with the user's own model; tools come from the workspace's
2
+ // MCP server. Writes follow MCP rules (a proposal with a confirmation link), so the loop never writes by itself.
3
+ // Emits the same StreamChunk events as the platform assistant, so the REPL and -p render both the same way.
4
+ import Anthropic from "@anthropic-ai/sdk";
5
+ import { fetch } from "undici";
6
+ import { CliError, Exit } from "../core/errors.js";
7
+ import { tr, currentLang } from "../core/i18n.js";
8
+ import { brand } from "../core/brand.js";
9
+ import { getProfile } from "../core/config.js";
10
+ import { context } from "../core/context.js";
11
+ import { networkError, serverMessage, userAgent } from "../core/http.js";
12
+ import { McpClient, resultValue } from "../mcp/client.js";
13
+ const MAX_STEPS = 25;
14
+ /** Tool output beyond this is cut with a note; one report can otherwise fill the model's context. */
15
+ const MAX_RESULT_CHARS = 60_000;
16
+ /** Models that accept the server-side refusal fallback ("default" routing). */
17
+ const FALLBACK_MODELS = /^claude-(fable-5-1|opus-5-5|opus-5|sonnet-5-5)$/;
18
+ /** Provider tool names allow [A-Za-z0-9_-] only; MCP names may carry dots. */
19
+ const wireName = (n) => n.replace(/[^A-Za-z0-9_-]/g, "__").slice(0, 64);
20
+ function systemPrompt(readOnly) {
21
+ const b = brand();
22
+ const p = getProfile(context().profile);
23
+ const lang = currentLang() === "en" ? "English" : "Vietnamese";
24
+ return [
25
+ `You are a terminal assistant for ${b.name}, working on the user's live workspace data through the tools provided.`,
26
+ `Workspace: ${p?.workspaceName ?? p?.workspaceCode ?? "-"} (${context().env}). Today: ${new Date().toISOString().slice(0, 10)}.`,
27
+ "Answer from tool results only; if no tool can answer, say so instead of guessing numbers.",
28
+ readOnly
29
+ ? "This session is read-only: do not try to change data."
30
+ : "Tools that change data only create a proposal and return a confirmation link. Nothing is saved until the user opens the link and confirms. Show the link and say clearly that the change is not made yet.",
31
+ `Reply in ${lang} unless the user writes in another language. Output is shown in a terminal: short paragraphs, plain Markdown lists and tables, no HTML.`,
32
+ ].join("\n");
33
+ }
34
+ function resultText(r) {
35
+ const v = resultValue(r);
36
+ const s = typeof v === "string" ? v : JSON.stringify(v);
37
+ return s.length > MAX_RESULT_CHARS ? `${s.slice(0, MAX_RESULT_CHARS)}\n[… ${s.length - MAX_RESULT_CHARS} more characters cut]` : s;
38
+ }
39
+ export class LocalChat {
40
+ model;
41
+ readOnly;
42
+ mcp;
43
+ tools;
44
+ claudeHistory = [];
45
+ chatHistory = [];
46
+ anthropic;
47
+ constructor(model, interactive, readOnly = false) {
48
+ this.model = model;
49
+ this.readOnly = readOnly;
50
+ this.mcp = new McpClient(interactive);
51
+ }
52
+ async loadTools() {
53
+ if (this.tools)
54
+ return this.tools;
55
+ const all = await this.mcp.listTools();
56
+ const usable = this.readOnly ? all.filter((t) => t.annotations?.readOnlyHint) : all;
57
+ const seen = new Set();
58
+ this.tools = usable
59
+ .map((tool) => ({ wire: wireName(tool.name), tool }))
60
+ .filter((t) => !seen.has(t.wire) && seen.add(t.wire));
61
+ return this.tools;
62
+ }
63
+ async runTool(wire, args, onChunk, outcome) {
64
+ const t = this.tools?.find((x) => x.wire === wire);
65
+ if (!t)
66
+ return { text: `Unknown tool ${wire}`, isError: true };
67
+ const label = t.tool.annotations?.title ?? t.tool.title ?? t.tool.name;
68
+ const started = Date.now();
69
+ const activity = { kind: "tool", label, toolName: t.tool.name, elapsedMs: null };
70
+ if (!args || typeof args !== "object" || Array.isArray(args))
71
+ return { text: "Tool input must be a JSON object.", isError: true };
72
+ try {
73
+ const r = await this.mcp.callTool(t.tool.name, args);
74
+ activity.elapsedMs = Date.now() - started;
75
+ outcome.activities.push(activity);
76
+ onChunk({ type: "activity", activity });
77
+ return { text: resultText(r), isError: !!r.isError };
78
+ }
79
+ catch (e) {
80
+ // An expired MCP grant ends the turn; other failures go back to the model so it can adjust.
81
+ if (e instanceof CliError && e.exitCode === Exit.auth)
82
+ throw e;
83
+ activity.elapsedMs = Date.now() - started;
84
+ outcome.activities.push(activity);
85
+ onChunk({ type: "activity", activity });
86
+ return { text: e.message, isError: true };
87
+ }
88
+ }
89
+ async ask(input) {
90
+ const tools = await this.loadTools();
91
+ const outcome = { text: "", notices: [], activities: [], ended: "done" };
92
+ if (this.model.api === "anthropic")
93
+ await this.askClaude(input, tools, outcome);
94
+ else
95
+ await this.askChat(input, tools, outcome);
96
+ outcome.done = { type: outcome.ended, text: outcome.text };
97
+ input.onChunk(outcome.done);
98
+ return outcome;
99
+ }
100
+ // ── Anthropic Messages ───────────────────────────────────────────────────────────────────────────
101
+ async askClaude(input, tools, outcome) {
102
+ this.anthropic ??= new Anthropic({ apiKey: this.model.apiKey, baseURL: this.model.baseUrl, defaultHeaders: { "User-Agent": userAgent() } });
103
+ const defs = tools.map((t) => ({
104
+ name: t.wire,
105
+ description: (t.tool.description ?? t.tool.title ?? t.tool.name).slice(0, 4000),
106
+ input_schema: (t.tool.inputSchema ?? { type: "object", properties: {} }),
107
+ }));
108
+ const fallback = FALLBACK_MODELS.test(this.model.model);
109
+ this.claudeHistory.push({ role: "user", content: input.text });
110
+ for (let step = 0; step < MAX_STEPS; step++) {
111
+ let msg;
112
+ try {
113
+ const stream = this.anthropic.beta.messages.stream({
114
+ model: this.model.model,
115
+ max_tokens: 32_000,
116
+ system: [{ type: "text", text: systemPrompt(this.readOnly), cache_control: { type: "ephemeral" } }],
117
+ tools: defs,
118
+ messages: this.claudeHistory,
119
+ ...(fallback ? { betas: ["server-side-fallback-2026-07-01"], fallbacks: "default" } : {}),
120
+ }, { signal: input.signal });
121
+ stream.on("text", (d) => {
122
+ outcome.text += d;
123
+ input.onChunk({ type: "token", text: d });
124
+ });
125
+ msg = await stream.finalMessage();
126
+ }
127
+ catch (e) {
128
+ throw anthropicError(e, input.signal);
129
+ }
130
+ // Append the whole content (thinking and fallback blocks included) so the next turn replays it unchanged.
131
+ this.claudeHistory.push({ role: "assistant", content: msg.content });
132
+ if (msg.stop_reason === "refusal") {
133
+ outcome.notices.push({ code: "model_refusal", message: tr("Model từ chối trả lời câu này.", "The model declined to answer this.") });
134
+ outcome.ended = "error";
135
+ return;
136
+ }
137
+ const uses = msg.content.filter((b) => b.type === "tool_use");
138
+ if (msg.stop_reason === "max_tokens") {
139
+ outcome.notices.push({ code: "model_max_tokens", message: tr("Câu trả lời bị cắt vì quá dài.", "The answer was cut off for length.") });
140
+ // A tool call cut mid-input must not run; close it so the history stays valid.
141
+ if (uses.length)
142
+ this.claudeHistory.push({ role: "user", content: uses.map((u) => ({ type: "tool_result", tool_use_id: u.id, content: "Not run: the call was cut off.", is_error: true })) });
143
+ return;
144
+ }
145
+ if (msg.stop_reason === "pause_turn")
146
+ continue;
147
+ if (msg.stop_reason !== "tool_use" || !uses.length)
148
+ return;
149
+ if (outcome.text && !outcome.text.endsWith("\n")) {
150
+ outcome.text += "\n";
151
+ input.onChunk({ type: "token", text: "\n" });
152
+ }
153
+ const results = await Promise.all(uses.map((u) => this.runTool(u.name, u.input, input.onChunk, outcome)));
154
+ this.claudeHistory.push({
155
+ role: "user",
156
+ content: uses.map((u, i) => ({ type: "tool_result", tool_use_id: u.id, content: results[i].text, ...(results[i].isError ? { is_error: true } : {}) })),
157
+ });
158
+ }
159
+ outcome.notices.push({ code: "model_steps", message: tr(`Dừng sau ${MAX_STEPS} bước gọi công cụ.`, `Stopped after ${MAX_STEPS} tool steps.`) });
160
+ }
161
+ // ── chat-completions (GPT, Gemini, Grok, Ollama) ─────────────────────────────────────────────────
162
+ async askChat(input, tools, outcome) {
163
+ const defs = tools.map((t) => ({
164
+ type: "function",
165
+ function: { name: t.wire, description: (t.tool.description ?? t.tool.title ?? t.tool.name).slice(0, 1000), parameters: t.tool.inputSchema ?? { type: "object", properties: {} } },
166
+ }));
167
+ if (!this.chatHistory.length)
168
+ this.chatHistory.push({ role: "system", content: systemPrompt(this.readOnly) });
169
+ this.chatHistory.push({ role: "user", content: input.text });
170
+ for (let step = 0; step < MAX_STEPS; step++) {
171
+ const turn = await this.chatTurn(defs, input, outcome);
172
+ this.chatHistory.push({ role: "assistant", content: turn.content || null, ...(turn.calls.length ? { tool_calls: turn.calls } : {}) });
173
+ if (turn.finish === "length") {
174
+ outcome.notices.push({ code: "model_max_tokens", message: tr("Câu trả lời bị cắt vì quá dài.", "The answer was cut off for length.") });
175
+ for (const call of turn.calls)
176
+ this.chatHistory.push({ role: "tool", tool_call_id: call.id, content: "Not run: the call was cut off." });
177
+ return;
178
+ }
179
+ if (!turn.calls.length)
180
+ return;
181
+ if (outcome.text && !outcome.text.endsWith("\n")) {
182
+ outcome.text += "\n";
183
+ input.onChunk({ type: "token", text: "\n" });
184
+ }
185
+ const results = await Promise.all(turn.calls.map((call) => {
186
+ let args;
187
+ try {
188
+ args = call.function.arguments ? JSON.parse(call.function.arguments) : {};
189
+ }
190
+ catch {
191
+ return Promise.resolve({ text: "Invalid JSON arguments; send a valid JSON object.", isError: true });
192
+ }
193
+ return this.runTool(call.function.name, args, input.onChunk, outcome);
194
+ }));
195
+ turn.calls.forEach((call, i) => this.chatHistory.push({ role: "tool", tool_call_id: call.id, content: results[i].isError ? `ERROR: ${results[i].text}` : results[i].text }));
196
+ }
197
+ outcome.notices.push({ code: "model_steps", message: tr(`Dừng sau ${MAX_STEPS} bước gọi công cụ.`, `Stopped after ${MAX_STEPS} tool steps.`) });
198
+ }
199
+ async chatTurn(defs, input, outcome) {
200
+ const url = `${this.model.baseUrl}/chat/completions`;
201
+ let res;
202
+ try {
203
+ res = await fetch(url, {
204
+ method: "POST",
205
+ headers: { "Content-Type": "application/json", Accept: "text/event-stream", "User-Agent": userAgent(), ...(this.model.apiKey ? { Authorization: `Bearer ${this.model.apiKey}` } : {}) },
206
+ body: JSON.stringify({ model: this.model.model, messages: this.chatHistory, ...(defs.length ? { tools: defs } : {}), stream: true }),
207
+ signal: input.signal,
208
+ });
209
+ }
210
+ catch (e) {
211
+ throw networkError(e, url);
212
+ }
213
+ if (!res.ok) {
214
+ const body = await res.json().catch(() => undefined);
215
+ throw providerError(this.model, res.status, serverMessage(body?.error ?? body).message);
216
+ }
217
+ let content = "";
218
+ let finish;
219
+ const calls = [];
220
+ const decoder = new TextDecoder();
221
+ let buf = "";
222
+ for await (const part of res.body ?? []) {
223
+ buf += decoder.decode(part, { stream: true });
224
+ let idx;
225
+ while ((idx = buf.indexOf("\n")) >= 0) {
226
+ const line = buf.slice(0, idx).trim();
227
+ buf = buf.slice(idx + 1);
228
+ if (!line.startsWith("data:"))
229
+ continue;
230
+ const data = line.slice(5).trim();
231
+ if (data === "[DONE]")
232
+ continue;
233
+ let ev;
234
+ try {
235
+ ev = JSON.parse(data);
236
+ }
237
+ catch {
238
+ continue;
239
+ }
240
+ const ch = ev.choices?.[0];
241
+ if (!ch)
242
+ continue;
243
+ if (ch.finish_reason)
244
+ finish = ch.finish_reason;
245
+ const d = ch.delta;
246
+ if (d?.content) {
247
+ content += d.content;
248
+ outcome.text += d.content;
249
+ input.onChunk({ type: "token", text: d.content });
250
+ }
251
+ for (const tc of d?.tool_calls ?? []) {
252
+ const i = tc.index ?? calls.length;
253
+ calls[i] ??= { id: tc.id ?? `call_${i}`, type: "function", function: { name: "", arguments: "" } };
254
+ if (tc.id)
255
+ calls[i].id = tc.id;
256
+ if (tc.function?.name)
257
+ calls[i].function.name += tc.function.name;
258
+ if (tc.function?.arguments)
259
+ calls[i].function.arguments += tc.function.arguments;
260
+ }
261
+ }
262
+ }
263
+ return { content, calls: calls.filter(Boolean), finish };
264
+ }
265
+ }
266
+ function providerError(m, status, message) {
267
+ const who = m.label;
268
+ if (status === 401 || status === 403)
269
+ return new CliError(tr(`${who}: khoá model bị từ chối.`, `${who}: the model key was rejected.`) + (message ? ` (${message})` : ""), Exit.auth, `${brand().bin} model add ${m.provider} --key-from-stdin`);
270
+ if (status === 404)
271
+ return new CliError(tr(`${who}: không có model này.`, `${who}: no such model.`) + (message ? ` (${message})` : ""), Exit.notFound);
272
+ if (status === 429)
273
+ return new CliError(tr(`${who}: nhà model đang giới hạn tốc độ hoặc hết hạn mức.`, `${who}: the provider is rate limiting or out of quota.`) + (message ? ` (${message})` : ""), Exit.quota);
274
+ return new CliError(`${who}: HTTP ${status}${message ? ` ${message}` : ""}`, Exit.general);
275
+ }
276
+ function anthropicError(e, signal) {
277
+ if (signal?.aborted || e instanceof Anthropic.APIUserAbortError)
278
+ return new CliError(tr("Đã dừng.", "Stopped."), Exit.interrupted);
279
+ if (e instanceof Anthropic.APIConnectionError)
280
+ return new CliError(tr("Không kết nối được tới nhà model.", "Could not reach the model provider."), Exit.network);
281
+ if (e instanceof Anthropic.APIError) {
282
+ const m = { provider: "claude", label: "claude" };
283
+ return providerError(m, e.status ?? 0, e.message);
284
+ }
285
+ return e;
286
+ }
@@ -0,0 +1,87 @@
1
+ // The user's own model (--model): which providers exist, where their keys live, how a spec like
2
+ // "claude", "gpt/<name>" or "ollama/qwen3:32b" resolves. Keys stay in the keychain and never go to the platform.
3
+ import { CliError, Exit } from "../core/errors.js";
4
+ import { tr } from "../core/i18n.js";
5
+ import { brand } from "../core/brand.js";
6
+ import { loadConfig, saveConfig } from "../core/config.js";
7
+ import { readSecret, writeSecret, deleteSecret } from "../auth/credentials.js";
8
+ // A→Z, no provider is favoured.
9
+ export const PROVIDERS = {
10
+ claude: { label: "Claude (Anthropic)", api: "anthropic", baseUrl: "https://api.anthropic.com", keyEnv: "ANTHROPIC_API_KEY", defaultModel: "claude-opus-5-5", needsKey: true },
11
+ gemini: { label: "Gemini (Google)", api: "chat", baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai", keyEnv: "GEMINI_API_KEY", needsKey: true },
12
+ gpt: { label: "GPT (OpenAI)", api: "chat", baseUrl: "https://api.openai.com/v1", keyEnv: "OPENAI_API_KEY", needsKey: true },
13
+ grok: { label: "Grok (xAI)", api: "chat", baseUrl: "https://api.x.ai/v1", keyEnv: "XAI_API_KEY", needsKey: true },
14
+ ollama: { label: "Ollama (máy bạn / your machine)", api: "chat", baseUrl: "http://localhost:11434", needsKey: false },
15
+ };
16
+ const stored = () => loadConfig();
17
+ function save(patch) {
18
+ const c = loadConfig();
19
+ patch(c);
20
+ saveConfig(c);
21
+ }
22
+ export const isProvider = (p) => p in PROVIDERS;
23
+ export function listModels() {
24
+ const s = stored();
25
+ return Object.entries(s.models ?? {}).map(([name, entry]) => ({
26
+ name,
27
+ entry,
28
+ hasKey: !PROVIDERS[entry.provider].needsKey || !!keyFor(entry.provider),
29
+ isDefault: s.defaultModel === name,
30
+ }));
31
+ }
32
+ export function addModel(provider, opts) {
33
+ if (PROVIDERS[provider].needsKey && opts.key)
34
+ writeSecret(`model:${provider}`, opts.key.trim());
35
+ save((s) => {
36
+ s.models = { ...s.models, [provider]: { provider, ...(opts.model ? { model: opts.model } : {}), ...(opts.url ? { url: opts.url.replace(/\/+$/, "") } : {}) } };
37
+ });
38
+ }
39
+ export function removeModel(name) {
40
+ const s = stored();
41
+ const entry = s.models?.[name];
42
+ if (!entry)
43
+ return false;
44
+ deleteSecret(`model:${entry.provider}`);
45
+ save((x) => {
46
+ delete x.models?.[name];
47
+ if (x.defaultModel === name)
48
+ delete x.defaultModel;
49
+ });
50
+ return true;
51
+ }
52
+ export function setDefaultModel(name) {
53
+ save((s) => {
54
+ if (name)
55
+ s.defaultModel = name;
56
+ else
57
+ delete s.defaultModel;
58
+ });
59
+ }
60
+ export const defaultModel = () => stored().defaultModel;
61
+ function keyFor(p) {
62
+ const env = PROVIDERS[p].keyEnv;
63
+ return (env && process.env[env]) || readSecret(`model:${p}`);
64
+ }
65
+ /** The CLI's own command name (or "default") means the platform assistant: returns undefined. */
66
+ export function resolveModel(spec) {
67
+ const s = spec.trim();
68
+ if (!s || s === brand().bin || s === "default")
69
+ return undefined;
70
+ const slash = s.indexOf("/");
71
+ const head = slash < 0 ? s : s.slice(0, slash);
72
+ const name = slash < 0 ? undefined : s.slice(slash + 1);
73
+ if (!isProvider(head))
74
+ throw new CliError(tr(`Không biết nhà model "${head}".`, `Unknown model provider "${head}".`), Exit.usage, tr(`Có: ${Object.keys(PROVIDERS).join(", ")} (vd --model ollama/qwen3:32b)`, `Available: ${Object.keys(PROVIDERS).join(", ")} (e.g. --model ollama/qwen3:32b)`));
75
+ const info = PROVIDERS[head];
76
+ const entry = stored().models?.[head];
77
+ const model = name || entry?.model || info.defaultModel;
78
+ if (!model)
79
+ throw new CliError(tr(`Cần tên model cho ${head}.`, `Name the ${head} model.`), Exit.usage, `${brand().bin} --model ${head}/<model> · ${brand().bin} model add ${head} --name <model>`);
80
+ const apiKey = keyFor(head);
81
+ if (info.needsKey && !apiKey)
82
+ throw new CliError(tr(`Chưa có khoá ${info.label}.`, `No ${info.label} key yet.`), Exit.auth, `${brand().bin} model add ${head} --key-from-stdin${info.keyEnv ? tr(` (hoặc đặt ${info.keyEnv})`, ` (or set ${info.keyEnv})`) : ""}`);
83
+ let baseUrl = entry?.url ?? info.baseUrl;
84
+ if (head === "ollama")
85
+ baseUrl = `${baseUrl.replace(/\/v1$/, "")}/v1`;
86
+ return { provider: head, api: info.api, model, baseUrl, apiKey, label: `${head}/${model}` };
87
+ }