@nextxai/cli 0.1.1 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +19 -1
- package/dist/ai/client.js +32 -10
- package/dist/ai/local.js +286 -0
- package/dist/ai/models.js +87 -0
- package/dist/auth/ci.js +1 -1
- package/dist/auth/mcp-oauth.js +10 -23
- package/dist/auth/session.js +7 -1
- package/dist/commands/ai.js +122 -19
- package/dist/commands/api.js +3 -2
- package/dist/commands/auth.js +7 -3
- package/dist/commands/ci-policy.js +98 -0
- package/dist/commands/ci.js +96 -0
- package/dist/commands/help.js +2 -2
- package/dist/commands/keys.js +2 -1
- package/dist/commands/mcp.js +5 -3
- package/dist/commands/model.js +106 -0
- package/dist/commands/tools.js +5 -1
- package/dist/commands/util.js +1 -1
- package/dist/commands/webhooks.js +65 -14
- package/dist/core/brand.js +6 -3
- package/dist/core/errors.js +4 -0
- package/dist/core/http.js +15 -4
- package/dist/mcp/client.js +5 -2
- package/dist/program.js +5 -0
- package/dist/repl/App.js +53 -18
- package/dist/repl/index.js +1 -1
- package/dist/repl/slash.js +11 -7
- package/package.json +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.1
|
|
4
|
+
|
|
5
|
+
- Help examples (`keys create --scopes`, `ci policy`) show permission codes of your own product.
|
|
6
|
+
- Release: a version already on npm is skipped instead of failing the release.
|
|
7
|
+
|
|
8
|
+
## 0.3.0
|
|
9
|
+
|
|
10
|
+
- `ci policy request|list|show|revoke|kinds`: pre-approval policies let a CI repository write on its own — chosen write permissions, a total row ceiling (up to 1000), at most 30 days. Anyone requests; an API-key manager approves (or `--approve` if you are one). A CI session cannot request, approve or revoke a policy.
|
|
11
|
+
- In CI (`login --ci`, no terminal) with `--mode auto`, proposals are sent for confirmation and the server applies them only within an active policy; otherwise they stay a proposal (exit code 7). `-p --json` reports `proposals[0].applied` and exits 0 when written.
|
|
12
|
+
- A CI session calls the API directly with the permissions of the person who added the repository; it cannot create API keys, CI trusts, policies, sessions or other long-lived access.
|
|
13
|
+
- `ci trust add` no longer takes write options — use `ci policy request`. Policy ids are shown by their last 8 characters.
|
|
14
|
+
|
|
15
|
+
## 0.2.0
|
|
16
|
+
|
|
17
|
+
- `--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.
|
|
18
|
+
- 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.
|
|
19
|
+
- `login --ci`: GitHub Actions / GitLab CI sign in with their OIDC token, no secret. Admins allow a repository with `ci trust add`.
|
|
20
|
+
|
|
3
21
|
## 0.1.1
|
|
4
22
|
|
|
5
23
|
- x64 builds (Intel Mac, Linux, Windows) now target every x64 CPU, including older Intel Macs without AVX2.
|
|
@@ -9,7 +27,7 @@
|
|
|
9
27
|
|
|
10
28
|
First release.
|
|
11
29
|
|
|
12
|
-
- Chat with the assistant in the terminal
|
|
30
|
+
- Chat with the assistant in the terminal, with slash commands, file attachments, permission modes and confirmed writes.
|
|
13
31
|
- Login: browser (PKCE), QR via the mobile app, device code, API key, CI OIDC.
|
|
14
32
|
- `-p` / `--json` / `--output-format stream-json` for scripts and agents.
|
|
15
33
|
- `tools`, `api`, `keys`, `webhooks` (including `listen --forward-to` and `verify`), `mcp install|serve`, `doctor`, `completion`, `update`, `init`.
|
package/dist/ai/client.js
CHANGED
|
@@ -8,16 +8,36 @@ import { CliError, Exit } from "../core/errors.js";
|
|
|
8
8
|
import { tr } from "../core/i18n.js";
|
|
9
9
|
import { brand } from "../core/brand.js";
|
|
10
10
|
const BASE = "/operator/widget/support";
|
|
11
|
-
export const openConversation = (forceNew) =>
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
export const
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
export const
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
export const
|
|
11
|
+
export const openConversation = (forceNew) => {
|
|
12
|
+
return post(`${BASE}/init`, { forceNew });
|
|
13
|
+
};
|
|
14
|
+
export const listConversations = (limit = 30) => {
|
|
15
|
+
return get(`${BASE}/conversations`, { query: { limit } });
|
|
16
|
+
};
|
|
17
|
+
export const renameConversation = (id, title) => {
|
|
18
|
+
return patch(`${BASE}/conversations/${id}/title`, { title });
|
|
19
|
+
};
|
|
20
|
+
export const pinConversation = (id, pinned) => {
|
|
21
|
+
return put(`${BASE}/conversations/${id}/pin`, { pinned });
|
|
22
|
+
};
|
|
23
|
+
export const deleteConversation = (id) => {
|
|
24
|
+
return del(`${BASE}/conversations/${id}`);
|
|
25
|
+
};
|
|
26
|
+
export const history = (sessionId) => {
|
|
27
|
+
return get(`${BASE}/history`, { query: { sessionId } });
|
|
28
|
+
};
|
|
29
|
+
export const feedback = (messageId, helpful) => {
|
|
30
|
+
return post(`${BASE}/messages/${messageId}/feedback`, { helpful });
|
|
31
|
+
};
|
|
32
|
+
export const escalate = (sessionId, topic) => {
|
|
33
|
+
return post(`${BASE}/escalate`, { sessionId, topic });
|
|
34
|
+
};
|
|
35
|
+
export const usage = () => {
|
|
36
|
+
return get(`${BASE}/usage`);
|
|
37
|
+
};
|
|
38
|
+
export const confirmAction = (sessionId, a) => {
|
|
39
|
+
return post(`${BASE}/${sessionId}/confirm-action`, { kind: a.kind, payloadJson: a.payloadJson, nonce: a.nonce });
|
|
40
|
+
};
|
|
21
41
|
const TABLE = new Set([".xlsx", ".csv"]);
|
|
22
42
|
const IMAGE = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp" };
|
|
23
43
|
const TEXT = new Set([".txt", ".md", ".json", ".log", ".yml", ".yaml", ".tsv", ".xml", ".html", ".sql"]);
|
|
@@ -187,6 +207,8 @@ const FINANCE = /payment|invoice|einvoice|finance|refund|payout|wallet|billing|t
|
|
|
187
207
|
const LARGE = /delete|remove|merge|bulk|batch|mass|permission|role|group|xoa|gop|hang.?loat|campaign|broadcast/i;
|
|
188
208
|
/** Large = deletes, merges, permission changes, > 20 records or recipients. Finance never runs from the terminal. */
|
|
189
209
|
export function riskOf(a) {
|
|
210
|
+
if (a.risk)
|
|
211
|
+
return a.risk;
|
|
190
212
|
if (FINANCE.test(a.kind))
|
|
191
213
|
return "finance";
|
|
192
214
|
if (LARGE.test(a.kind))
|
package/dist/ai/local.js
ADDED
|
@@ -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
|
+
}
|
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} ›
|
|
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");
|
package/dist/auth/mcp-oauth.js
CHANGED
|
@@ -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)
|
|
3
|
-
//
|
|
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
|
|
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({
|
package/dist/auth/session.js
CHANGED
|
@@ -79,6 +79,12 @@ export function sessionFrom(body, via, prev) {
|
|
|
79
79
|
* Refreshes the stored session. `staleAccess` is the token the caller saw: if another process already
|
|
80
80
|
* replaced it while we waited for the lock, its result is reused instead of refreshing again.
|
|
81
81
|
*/
|
|
82
|
+
/** Body for `/iam/auth/refresh-token`. An IAM that speaks snake_case ignores a camelCase key and answers 401 "required". */
|
|
83
|
+
export function refreshBody(url, refreshToken) {
|
|
84
|
+
const body = { refreshToken };
|
|
85
|
+
void url;
|
|
86
|
+
return JSON.stringify(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:
|
|
108
|
+
body: refreshBody(url, s.refreshToken),
|
|
103
109
|
});
|
|
104
110
|
}
|
|
105
111
|
catch (e) {
|