@vfvrpq/llm-cli 1.0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 vfvrpq
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,92 @@
1
+ # llm-cli —— GLM / DeepSeek 终端命令行客户端
2
+
3
+ 两个**零依赖**单文件脚本(Node.js ≥ 18),各连一家服务商;API Key 均由你本人手动填写,代码中不内置任何密钥。
4
+
5
+ | 脚本 | 服务商 | 默认模型 | 配置目录 | Key 环境变量 |
6
+ |---|---|---|---|---|
7
+ | `glm_cli.js` | 智谱 GLM | `glm-5.3-flash` | `~/.glm-cli` | `GLM_API_KEY`(或 `ZHIPUAI_API_KEY`) |
8
+ | `deepseek_cli.js` | DeepSeek | `deepseek-chat` | `~/.deepseek-cli` | `DEEPSEEK_API_KEY` |
9
+
10
+ Windows 下可用同目录的 `glm.bat` / `deepseek.bat` 启动,例如 `.\glm.bat chat`。
11
+
12
+ ## 安装(npm)
13
+
14
+ ```bash
15
+ npm i -g @vfvrpq/llm-cli # 全局安装,得到 glm-cli 与 deepseek-cli 两个命令
16
+ glm-cli --help
17
+
18
+ # 免安装直接运行
19
+ npx -p @vfvrpq/llm-cli glm-cli ask "你好"
20
+
21
+ # 或免 npm:下载仓库后直接运行
22
+ node glm_cli.js ask "你好"
23
+ ```
24
+
25
+ 下文示例中的 `node glm_cli.js` / `node deepseek_cli.js` 在全局安装后可分别换成 `glm-cli` / `deepseek-cli`。
26
+
27
+ ## 准备 API Key
28
+
29
+ - GLM(智谱):<https://open.bigmodel.cn> 控制台 → API Key(海外版 <https://z.ai>)
30
+ - DeepSeek:<https://platform.deepseek.com> → API Keys
31
+
32
+ ## 快速开始
33
+
34
+ ```bash
35
+ # GLM
36
+ node glm_cli.js config set api-key
37
+ node glm_cli.js ask "用一句话解释量子纠缠"
38
+ node glm_cli.js chat
39
+ node glm_cli.js models
40
+
41
+ # DeepSeek
42
+ node deepseek_cli.js config set api-key
43
+ node deepseek_cli.js ask "用一句话介绍你自己"
44
+ node deepseek_cli.js ask "九个点如何四条线相连" -m deepseek-reasoner
45
+ node deepseek_cli.js models
46
+ ```
47
+
48
+ ## 常用示例(两个脚本一致)
49
+
50
+ ```bash
51
+ # 附带文件提问(可多个 -f)
52
+ node glm_cli.js ask -f main.js "这个脚本有什么问题?"
53
+
54
+ # 管道输入(Windows 用 type,macOS/Linux 用 cat)
55
+ type error.log | node deepseek_cli.js ask "总结这个报错"
56
+
57
+ # 回答保存到文件
58
+ node glm_cli.js ask "写一首关于秋天的诗" -o poem.txt
59
+
60
+ # 多轮对话:指定模型/温度,继续上次对话
61
+ node glm_cli.js chat -m glm-4.6 -t 0.7
62
+ node deepseek_cli.js chat --resume chat-latest.json
63
+ ```
64
+
65
+ `chat` 中的斜杠命令:`/help` `/new` `/model <名称>` `/system <文本>` `/save [路径]` `/exit`
66
+
67
+ ## 参数说明(两个脚本一致)
68
+
69
+ | 参数 | 作用 |
70
+ |---|---|
71
+ | `-m, --model` | 模型名(默认值见上表) |
72
+ | `--api-key` | 本次使用的 Key(优先级最高) |
73
+ | `--base-url` | 接口地址 |
74
+ | `--system` | system 提示词 |
75
+ | `-t, --temperature` | 采样温度 |
76
+ | `--max-tokens` | 最大输出 token 数 |
77
+ | `--thinking on/off` | 深度思考开关(**仅 glm_cli.js**;`deepseek-reasoner` 自带思考无需设置) |
78
+ | `--no-stream` | 关闭流式输出 |
79
+ | `--no-color` / `--debug` | 关闭彩色 / 输出调试信息 |
80
+
81
+ 配置优先级:命令行参数 > 环境变量 > 配置文件 > 内置默认值。
82
+ 配置持久化:`config set model/base-url`、`config get`(Key 脱敏显示)、`config del`、`config path`。
83
+
84
+ ## 常见问题
85
+
86
+ - **401 未授权**:Key 没填、填错或未生效,重新 `config set api-key`。
87
+ - **402(DeepSeek)**:账户余额不足,去平台充值。
88
+ - **429 限流**:请求太频繁或额度不足,稍后再试。
89
+ - **超时**:流式模式等待间隔上限 120 秒、非流式整体 300 秒。
90
+ - **代理**:Node 的 fetch 默认不走 `HTTP_PROXY`/`HTTPS_PROXY`(Node ≥ 24 可设 `NODE_USE_ENV_PROXY=1`)。
91
+ - **GLM 海外站**:`node glm_cli.js config set base-url https://api.z.ai/api/paas/v4`。
92
+ - **安全**:Key 以明文存在本机配置文件(macOS/Linux 下权限 600),请勿分享该文件。
@@ -0,0 +1,605 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * deepseek-cli —— 在终端里直接使用 DeepSeek 模型(deepseek-chat / deepseek-reasoner)。
4
+ * 零依赖,需要 Node.js >= 18(内置 fetch)。
5
+ *
6
+ * 快速开始:
7
+ * node deepseek_cli.js config set api-key # 手动输入并保存 API Key
8
+ * node deepseek_cli.js ask "一句话介绍你自己"
9
+ * node deepseek_cli.js chat
10
+ * node deepseek_cli.js models
11
+ *
12
+ * Key 获取: https://platform.deepseek.com -> API Keys
13
+ */
14
+ "use strict";
15
+
16
+ const fs = require("node:fs");
17
+ const os = require("node:os");
18
+ const path = require("node:path");
19
+ const readline = require("node:readline");
20
+ const { Writable } = require("node:stream");
21
+
22
+ const VERSION = "1.0.0";
23
+
24
+ const DEFAULT_BASE_URL = "https://api.deepseek.com";
25
+ const DEFAULT_MODEL = "deepseek-chat";
26
+ const ENV_API_KEYS = ["DEEPSEEK_API_KEY"];
27
+
28
+ // 环境变量 DEEPSEEK_CLI_HOME 可把配置目录改到别处(便携 / 多账号场景)
29
+ const CONFIG_DIR = process.env.DEEPSEEK_CLI_HOME || path.join(os.homedir(), ".deepseek-cli");
30
+ const CONFIG_FILE = path.join(CONFIG_DIR, "config.json");
31
+ const HISTORY_FILE = path.join(CONFIG_DIR, "chat-latest.json");
32
+
33
+ const STREAM_IDLE_MS = 120000; // 流式模式下两次数据块之间的最大等待(毫秒)
34
+ const PLAIN_MS = 300000; // 非流式模式的整体等待(毫秒)
35
+
36
+ const HINTS = {
37
+ 401: "API Key 缺失、无效或未生效。请执行: node deepseek_cli.js config set api-key",
38
+ 403: "当前 Key 无权访问该模型,或账户额度不足。",
39
+ 404: "模型名或接口地址可能有误:用 --model 指定模型,--base-url 指定接口地址。",
40
+ 429: "请求过于频繁,或账户余额不足,请稍后再试。",
41
+ 402: "账户余额不足,请前往 platform.deepseek.com 充值。",
42
+ };
43
+
44
+ const TIMEOUT_REASON = "deepseek-cli-idle-timeout";
45
+
46
+ class ApiError extends Error {}
47
+
48
+ const colors = { dim: "", bold: "", red: "", green: "", cyan: "", reset: "" };
49
+
50
+ function enableColors(noColor) {
51
+ if (noColor || process.env.NO_COLOR || !process.stdout.isTTY) return;
52
+ colors.dim = "\x1b[2m";
53
+ colors.bold = "\x1b[1m";
54
+ colors.red = "\x1b[31m";
55
+ colors.green = "\x1b[32m";
56
+ colors.cyan = "\x1b[36m";
57
+ colors.reset = "\x1b[0m";
58
+ }
59
+
60
+ const state = { currentAbort: null };
61
+
62
+ // ---------------------------------------------------------------- 配置读写
63
+
64
+ function loadConfig() {
65
+ try {
66
+ return JSON.parse(fs.readFileSync(CONFIG_FILE, "utf8"));
67
+ } catch (e) {
68
+ if (e.code === "ENOENT") return {};
69
+ console.error(`警告:配置文件 ${CONFIG_FILE} 读取失败(${e.message}),将忽略已有配置。`);
70
+ return {};
71
+ }
72
+ }
73
+
74
+ function saveConfig(cfg) {
75
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
76
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(cfg, null, 2) + "\n", "utf8");
77
+ if (process.platform !== "win32") {
78
+ try { fs.chmodSync(CONFIG_FILE, 0o600); } catch {}
79
+ }
80
+ }
81
+
82
+ function resolveRuntime(args, cfg) {
83
+ let apiKey = args.api_key;
84
+ if (!apiKey) {
85
+ for (const k of ENV_API_KEYS) {
86
+ const v = process.env[k];
87
+ if (v) { apiKey = v; break; }
88
+ }
89
+ }
90
+ if (!apiKey) apiKey = cfg.api_key;
91
+ return {
92
+ apiKey,
93
+ model: args.model || cfg.model || DEFAULT_MODEL,
94
+ baseUrl: args.base_url || cfg.base_url || DEFAULT_BASE_URL,
95
+ };
96
+ }
97
+
98
+ function requireKey(apiKey) {
99
+ if (apiKey) return apiKey;
100
+ throw new ApiError(
101
+ "尚未配置 API Key,请任选其一:\n" +
102
+ " 1. node deepseek_cli.js config set api-key (推荐,保存后长期使用)\n" +
103
+ " 2. 设置环境变量 DEEPSEEK_API_KEY\n" +
104
+ " 3. 临时使用:--api-key <你的Key>\n" +
105
+ "Key 获取:https://platform.deepseek.com -> API Keys"
106
+ );
107
+ }
108
+
109
+ function mask(key) {
110
+ if (!key) return "(未设置)";
111
+ if (key.length <= 8) return key.slice(0, 2) + "****";
112
+ return key.slice(0, 6) + "..." + key.slice(-4);
113
+ }
114
+
115
+ // ---------------------------------------------------------------- HTTP / SSE
116
+
117
+ function isAbort(err) {
118
+ return err && (err.name === "AbortError" || err.code === "ABORT_ERR");
119
+ }
120
+
121
+ async function friendlyHTTPError(resp) {
122
+ let detail = "";
123
+ try {
124
+ const text = await resp.text();
125
+ try {
126
+ const obj = JSON.parse(text);
127
+ detail = (obj.error && obj.error.message) || text.trim();
128
+ } catch {
129
+ detail = text.trim();
130
+ }
131
+ } catch {}
132
+ let msg = `请求失败 [HTTP ${resp.status}]`;
133
+ if (detail) msg += `:${detail}`;
134
+ if (HINTS[resp.status]) msg += `\n提示:${HINTS[resp.status]}`;
135
+ return msg;
136
+ }
137
+
138
+ async function apiFetch(url, apiKey, payload, method, signal) {
139
+ const headers = { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" };
140
+ let body;
141
+ if (payload !== null && payload !== undefined) {
142
+ body = JSON.stringify(payload);
143
+ if (payload.stream) headers.Accept = "text/event-stream";
144
+ }
145
+ let resp;
146
+ try {
147
+ resp = await fetch(url, { method, headers, body, signal });
148
+ } catch (err) {
149
+ if (isAbort(err)) throw err;
150
+ const cause = err.cause ? (err.cause.message || String(err.cause)) : err.message;
151
+ throw new ApiError(`无法连接服务器:${cause}(请检查网络或 --base-url)`);
152
+ }
153
+ if (!resp.ok) throw new ApiError(await friendlyHTTPError(resp));
154
+ return resp;
155
+ }
156
+
157
+ async function* iterSSE(body) {
158
+ const decoder = new TextDecoder();
159
+ let buf = "";
160
+ for await (const chunk of body) {
161
+ buf += decoder.decode(chunk, { stream: true });
162
+ let idx;
163
+ while ((idx = buf.indexOf("\n")) >= 0) {
164
+ const line = buf.slice(0, idx).trim();
165
+ buf = buf.slice(idx + 1);
166
+ if (!line.startsWith("data:")) continue;
167
+ const data = line.slice(5).trim();
168
+ if (data === "[DONE]") return;
169
+ if (data) yield data;
170
+ }
171
+ }
172
+ }
173
+
174
+ function buildPayload(rt, messages, args) {
175
+ const payload = { model: rt.model, messages, stream: !args.no_stream };
176
+ if (args.temperature != null) payload.temperature = Number(args.temperature);
177
+ if (args.max_tokens) payload.max_tokens = Number(args.max_tokens);
178
+ if (args.debug) {
179
+ console.error(`[debug] ${rt.baseUrl.replace(/\/+$/, "")}/chat/completions`);
180
+ console.error(`[debug] ${JSON.stringify(payload)}`);
181
+ }
182
+ return payload;
183
+ }
184
+
185
+ // ---------------------------------------------------------------- 对话调用
186
+
187
+ async function chatCompletion(rt, payload) {
188
+ const url = rt.baseUrl.replace(/\/+$/, "") + "/chat/completions";
189
+ const stream = !!payload.stream;
190
+ const controller = new AbortController();
191
+ let timer = null;
192
+ const arm = () => {
193
+ clearTimeout(timer);
194
+ timer = setTimeout(() => controller.abort(TIMEOUT_REASON), stream ? STREAM_IDLE_MS : PLAIN_MS);
195
+ };
196
+ arm();
197
+ state.currentAbort = controller;
198
+ const contentParts = [];
199
+ const reasoningParts = [];
200
+ let usage = null;
201
+ try {
202
+ const resp = await apiFetch(url, rt.apiKey, payload, "POST", controller.signal);
203
+ if (stream) {
204
+ let inThinking = false;
205
+ for await (const data of iterSSE(resp.body)) {
206
+ arm();
207
+ let obj;
208
+ try { obj = JSON.parse(data); } catch { continue; }
209
+ if (obj.error) throw new ApiError("服务端返回错误:" + JSON.stringify(obj.error));
210
+ if (obj.usage) usage = obj.usage;
211
+ const choice = (obj.choices || [])[0];
212
+ if (!choice) continue;
213
+ const delta = choice.delta || {};
214
+ const rc = delta.reasoning_content;
215
+ if (rc) {
216
+ if (!inThinking) {
217
+ inThinking = true;
218
+ console.log(`${colors.dim}—— 思考 ——${colors.reset}`);
219
+ }
220
+ process.stdout.write(colors.dim + rc + colors.reset);
221
+ reasoningParts.push(rc);
222
+ }
223
+ const text = delta.content;
224
+ if (text) {
225
+ if (inThinking) {
226
+ inThinking = false;
227
+ console.log(`\n${colors.bold}—— 回答 ——${colors.reset}`);
228
+ }
229
+ process.stdout.write(text);
230
+ contentParts.push(text);
231
+ }
232
+ }
233
+ console.log();
234
+ } else {
235
+ const obj = await resp.json();
236
+ if (obj.error) throw new ApiError("服务端返回错误:" + JSON.stringify(obj.error));
237
+ const message = ((obj.choices || [])[0] || {}).message || {};
238
+ if (message.reasoning_content) {
239
+ console.log(`${colors.dim}—— 思考 ——\n${message.reasoning_content}${colors.reset}`);
240
+ reasoningParts.push(message.reasoning_content);
241
+ }
242
+ const content = message.content || "";
243
+ console.log(content);
244
+ contentParts.push(content);
245
+ usage = obj.usage || null;
246
+ }
247
+ } catch (err) {
248
+ if (isAbort(err)) {
249
+ if (controller.signal.reason === TIMEOUT_REASON) {
250
+ console.log(`\n${colors.dim}(等待数据超时,已中断)${colors.reset}`);
251
+ } else {
252
+ console.log(`\n${colors.dim}(已中断本次回复)${colors.reset}`);
253
+ }
254
+ } else {
255
+ throw err;
256
+ }
257
+ } finally {
258
+ clearTimeout(timer);
259
+ state.currentAbort = null;
260
+ }
261
+ return { content: contentParts.join(""), reasoning: reasoningParts.join(""), usage };
262
+ }
263
+
264
+ function printUsageLine(usage) {
265
+ if (usage && usage.prompt_tokens != null && usage.completion_tokens != null) {
266
+ console.log(`${colors.dim}[tokens] 输入 ${usage.prompt_tokens} · 输出 ${usage.completion_tokens}${colors.reset}`);
267
+ }
268
+ }
269
+
270
+ // ---------------------------------------------------------------- 子命令
271
+
272
+ function readQuestionFromFiles(files) {
273
+ return files.map((fp) => {
274
+ let text;
275
+ try {
276
+ text = fs.readFileSync(fp, "utf8");
277
+ } catch (e) {
278
+ throw new ApiError(`无法读取文件 ${fp}:${e.message}`);
279
+ }
280
+ return `文件 \`${path.basename(fp)}\` 内容:\n\`\`\`\n${text}\n\`\`\``;
281
+ });
282
+ }
283
+
284
+ async function cmdAsk(args, cfg) {
285
+ const rt = resolveRuntime(args, cfg);
286
+ requireKey(rt.apiKey);
287
+
288
+ let question = args._.join(" ").trim();
289
+ if (!question && !process.stdin.isTTY) {
290
+ try { question = fs.readFileSync(0, "utf8").trim(); } catch {}
291
+ }
292
+ if (args.file && args.file.length) {
293
+ question = (question ? question + "\n\n" : "") + readQuestionFromFiles(args.file).join("\n\n");
294
+ }
295
+ if (!question) {
296
+ throw new ApiError('请提供问题内容,例如:node deepseek_cli.js ask "你好"(或用管道传入;交互式多轮请用 chat 子命令)');
297
+ }
298
+
299
+ const messages = [];
300
+ if (args.system) messages.push({ role: "system", content: args.system });
301
+ messages.push({ role: "user", content: question });
302
+
303
+ const { content, usage } = await chatCompletion(rt, buildPayload(rt, messages, args));
304
+ if (args.output) {
305
+ fs.mkdirSync(path.dirname(path.resolve(args.output)), { recursive: true });
306
+ fs.writeFileSync(args.output, content, "utf8");
307
+ console.log(`${colors.dim}回答已保存到 ${args.output}${colors.reset}`);
308
+ }
309
+ printUsageLine(usage);
310
+ }
311
+
312
+ const SLASH_HELP = [
313
+ "命令:",
314
+ " /help 显示本帮助",
315
+ " /new 清空当前对话,重新开始",
316
+ " /model <名称> 临时切换模型",
317
+ " /system <文本> 设置/更新 system 提示词(不带文本则清除)",
318
+ " /save [路径] 保存当前对话记录为 JSON",
319
+ " /exit 退出(或 Ctrl+C / Ctrl+D)",
320
+ ].join("\n");
321
+
322
+ async function cmdChat(args, cfg) {
323
+ const rt = resolveRuntime(args, cfg);
324
+ requireKey(rt.apiKey);
325
+
326
+ let messages = args.system ? [{ role: "system", content: args.system }] : [];
327
+ if (args.resume) {
328
+ let data;
329
+ try {
330
+ data = JSON.parse(fs.readFileSync(args.resume, "utf8"));
331
+ } catch (e) {
332
+ throw new ApiError(`无法读取对话记录 ${args.resume}:${e.message}`);
333
+ }
334
+ messages = data.messages || messages;
335
+ if (data.model) rt.model = data.model;
336
+ }
337
+
338
+ console.log(`${colors.cyan}deepseek-cli ${VERSION} · 模型 ${rt.model} · ${rt.baseUrl}${colors.reset}`);
339
+ console.log(`${colors.dim}输入消息开始对话,/help 查看命令,/exit 退出。${colors.reset}`);
340
+
341
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
342
+ rl.setPrompt(`${colors.green}你${colors.reset} > `);
343
+ rl.prompt();
344
+
345
+ // 输入流结束(EOF)会自动关闭 readline,此时再 prompt 会抛 ERR_USE_AFTER_CLOSE
346
+ let rlClosed = false;
347
+ rl.on("close", () => { rlClosed = true; });
348
+ const safePrompt = () => { if (!rlClosed) rl.prompt(); };
349
+
350
+ // Ctrl+C:正在生成时中断本次回复,空闲时退出
351
+ rl.on("SIGINT", () => {
352
+ if (state.currentAbort) state.currentAbort.abort();
353
+ else rl.close();
354
+ });
355
+
356
+ try {
357
+ for await (const line of rl) {
358
+ const text = line.trim();
359
+ if (!text) { safePrompt(); continue; }
360
+
361
+ if (text.startsWith("/")) {
362
+ const cmd = text.split(/\s+/)[0].toLowerCase();
363
+ const rest = text.slice(cmd.length).trim();
364
+ if (["/exit", "/quit", "/q"].includes(cmd)) break;
365
+ else if (cmd === "/help") console.log(SLASH_HELP);
366
+ else if (cmd === "/new") {
367
+ messages = messages.filter((m) => m.role === "system");
368
+ console.log(`${colors.dim}已清空对话。${colors.reset}`);
369
+ } else if (cmd === "/model") {
370
+ if (rest) { rt.model = rest; console.log(`${colors.dim}已切换模型: ${rt.model}${colors.reset}`); }
371
+ else console.log(`当前模型: ${rt.model}`);
372
+ } else if (cmd === "/system") {
373
+ messages = messages.filter((m) => m.role !== "system");
374
+ if (rest) {
375
+ messages.unshift({ role: "system", content: rest });
376
+ console.log(`${colors.dim}system 已设置。${colors.reset}`);
377
+ } else {
378
+ console.log(`${colors.dim}system 已清除。${colors.reset}`);
379
+ }
380
+ } else if (cmd === "/save") {
381
+ const file = rest || HISTORY_FILE;
382
+ fs.mkdirSync(path.dirname(path.resolve(file)), { recursive: true });
383
+ fs.writeFileSync(file, JSON.stringify({ model: rt.model, messages }, null, 2) + "\n", "utf8");
384
+ console.log(`${colors.dim}对话已保存到 ${file}${colors.reset}`);
385
+ } else {
386
+ console.log(`${colors.dim}未知命令 ${cmd},输入 /help 查看帮助。${colors.reset}`);
387
+ }
388
+ safePrompt();
389
+ continue;
390
+ }
391
+
392
+ messages.push({ role: "user", content: text });
393
+ try {
394
+ const { content, usage } = await chatCompletion(rt, buildPayload(rt, messages, args));
395
+ if (content.trim()) messages.push({ role: "assistant", content });
396
+ else messages.pop(); // 回复为空(如被中断),移除未完成回合的用户消息
397
+ printUsageLine(usage);
398
+ } catch (e) {
399
+ if (e instanceof ApiError) {
400
+ console.error(`${colors.red}${e.message}${colors.reset}`);
401
+ messages.pop(); // 回合失败,移除未送达的用户消息
402
+ } else {
403
+ throw e;
404
+ }
405
+ }
406
+ safePrompt();
407
+ }
408
+ } finally {
409
+ rl.close();
410
+ }
411
+ }
412
+
413
+ async function cmdModels(args, cfg) {
414
+ const rt = resolveRuntime(args, cfg);
415
+ requireKey(rt.apiKey);
416
+ const url = rt.baseUrl.replace(/\/+$/, "") + "/models";
417
+ const controller = new AbortController();
418
+ const timer = setTimeout(() => controller.abort(TIMEOUT_REASON), 60000);
419
+ try {
420
+ const resp = await apiFetch(url, rt.apiKey, null, "GET", controller.signal);
421
+ const obj = await resp.json();
422
+ const items = obj.data || [];
423
+ if (!items.length) {
424
+ console.log("服务端未返回模型列表。请直接用 --model 指定模型名,或查阅 DeepSeek 文档。");
425
+ return;
426
+ }
427
+ for (const it of items) console.log(typeof it === "string" ? it : it.id);
428
+ } catch (err) {
429
+ if (isAbort(err)) throw new ApiError("请求超时(>60s)。");
430
+ throw err;
431
+ } finally {
432
+ clearTimeout(timer);
433
+ }
434
+ }
435
+
436
+ function promptHidden(question) {
437
+ return new Promise((resolve) => {
438
+ process.stderr.write(question);
439
+ const sink = new Writable({ write(_chunk, _enc, cb) { cb(); } });
440
+ const rl = readline.createInterface({ input: process.stdin, output: sink, terminal: true });
441
+ let done = false;
442
+ const finish = (answer) => {
443
+ if (done) return;
444
+ done = true;
445
+ rl.close();
446
+ process.stderr.write("\n");
447
+ resolve(String(answer || "").trim());
448
+ };
449
+ rl.on("close", () => finish(""));
450
+ rl.question("", finish);
451
+ });
452
+ }
453
+
454
+ async function cmdConfig(args, cfg) {
455
+ const [action, item, value] = args._;
456
+ if (action === "path") {
457
+ console.log(CONFIG_FILE);
458
+ return;
459
+ }
460
+
461
+ if (action === "get") {
462
+ console.log(`配置文件 : ${CONFIG_FILE}`);
463
+ console.log(`api_key : ${mask(cfg.api_key)}`);
464
+ console.log(`model : ${cfg.model || `(默认 ${DEFAULT_MODEL})`}`);
465
+ console.log(`base_url : ${cfg.base_url || `(默认 ${DEFAULT_BASE_URL})`}`);
466
+ return;
467
+ }
468
+
469
+ const items = { "api-key": "api_key", model: "model", "base-url": "base_url" };
470
+
471
+ if (action === "set") {
472
+ if (!item || !(item in items)) throw new ApiError("支持设置: api-key / model / base-url");
473
+ let v = value;
474
+ if (item === "api-key" && !v) v = await promptHidden("请输入 API Key(输入不会回显): ");
475
+ v = (v || "").trim();
476
+ if (!v) throw new ApiError(`缺少 ${item} 的值,例如: node deepseek_cli.js config set ${item} <值>`);
477
+ cfg[items[item]] = v;
478
+ saveConfig(cfg);
479
+ console.log(`已保存。配置文件: ${CONFIG_FILE}`);
480
+ return;
481
+ }
482
+
483
+ if (action === "del") {
484
+ if (!item || !(item in items)) throw new ApiError("支持删除: api-key / model / base-url");
485
+ delete cfg[items[item]];
486
+ saveConfig(cfg);
487
+ console.log("已删除。");
488
+ return;
489
+ }
490
+
491
+ throw new ApiError("用法: config set|get|del|path");
492
+ }
493
+
494
+ // ---------------------------------------------------------------- 命令行入口
495
+
496
+ const ALIASES = {
497
+ "-m": "model", "--model": "model",
498
+ "--api-key": "api_key",
499
+ "--base-url": "base_url",
500
+ "--system": "system",
501
+ "-t": "temperature", "--temperature": "temperature",
502
+ "--max-tokens": "max_tokens",
503
+ "-f": "file", "--file": "file",
504
+ "-o": "output", "--output": "output",
505
+ "--resume": "resume",
506
+ };
507
+
508
+ function printHelp() {
509
+ console.log(`deepseek-cli ${VERSION} —— 终端里直接使用 DeepSeek 模型(零依赖,Node.js >= 18)
510
+
511
+ 用法: node deepseek_cli.js <command> [参数] [选项]
512
+
513
+ 命令:
514
+ ask 单次提问: node deepseek_cli.js ask "问题"
515
+ chat 多轮交互对话
516
+ models 列出当前账号可用的模型
517
+ config 管理本地配置(API Key 等)
518
+
519
+ 模型:
520
+ deepseek-chat 默认模型,通用对话
521
+ deepseek-reasoner 深度推理模型,思考过程自动流式显示
522
+
523
+ 选项:
524
+ -m, --model <名称> 模型名(默认 ${DEFAULT_MODEL})
525
+ --api-key <Key> 本次使用的 API Key(优先级最高)
526
+ --base-url <地址> 接口地址(默认 ${DEFAULT_BASE_URL})
527
+ --system <文本> system 提示词
528
+ -t, --temperature <值> 采样温度
529
+ --max-tokens <数量> 最大输出 token 数
530
+ --no-stream 关闭流式输出
531
+ --no-color / --debug 关闭彩色 / 调试输出
532
+
533
+ 示例:
534
+ node deepseek_cli.js config set api-key # 保存 API Key(隐藏输入)
535
+ node deepseek_cli.js ask "用一句话解释量子纠缠"
536
+ node deepseek_cli.js ask "总结这份文档" -f report.txt
537
+ type report.txt | node deepseek_cli.js ask "总结这份文档"
538
+ node deepseek_cli.js ask "九个点四条线相连" -m deepseek-reasoner
539
+ node deepseek_cli.js chat --resume chat-latest.json
540
+ node deepseek_cli.js models`);
541
+ }
542
+
543
+ function parseArgs(argv) {
544
+ const opts = { _: [] };
545
+ for (let i = 0; i < argv.length; i++) {
546
+ const a = argv[i];
547
+ if (a === "-h" || a === "--help") { opts.help = true; continue; }
548
+ if (a === "-V" || a === "--version") { console.log(`deepseek-cli ${VERSION}`); process.exit(0); }
549
+ if (a === "--no-stream") { opts.no_stream = true; continue; }
550
+ if (a === "--no-color") { opts.no_color = true; continue; }
551
+ if (a === "--debug") { opts.debug = true; continue; }
552
+ const key = ALIASES[a];
553
+ if (!key) {
554
+ if (a.startsWith("-")) throw new ApiError(`未知参数: ${a}`);
555
+ opts._.push(a);
556
+ continue;
557
+ }
558
+ const val = argv[++i];
559
+ if (val === undefined) throw new ApiError(`参数 ${a} 缺少值`);
560
+ if (key === "file") (opts.file = opts.file || []).push(val);
561
+ else opts[key] = val;
562
+ }
563
+ return opts;
564
+ }
565
+
566
+ async function main() {
567
+ if (typeof fetch === "undefined") {
568
+ console.error("需要 Node.js 18+(内置 fetch)。当前 Node 版本过旧,请升级后使用。");
569
+ process.exitCode = 1;
570
+ return;
571
+ }
572
+ let args;
573
+ try {
574
+ args = parseArgs(process.argv.slice(2));
575
+ } catch (e) {
576
+ console.error(`${colors.red}${e.message}${colors.reset}`);
577
+ printHelp();
578
+ process.exitCode = 1;
579
+ return;
580
+ }
581
+ enableColors(args.no_color);
582
+ if (args.help || args._.length === 0) {
583
+ printHelp();
584
+ return;
585
+ }
586
+ const command = args._.shift();
587
+ const cfg = loadConfig();
588
+ try {
589
+ if (command === "ask") await cmdAsk(args, cfg);
590
+ else if (command === "chat") await cmdChat(args, cfg);
591
+ else if (command === "models") await cmdModels(args, cfg);
592
+ else if (command === "config") await cmdConfig(args, cfg);
593
+ else throw new ApiError(`未知命令 "${command}",可用:ask / chat / models / config`);
594
+ } catch (e) {
595
+ if (e instanceof ApiError) {
596
+ console.error(`${colors.red}${e.message}${colors.reset}`);
597
+ process.exitCode = 1;
598
+ } else {
599
+ console.error(e);
600
+ process.exitCode = 1;
601
+ }
602
+ }
603
+ }
604
+
605
+ main();
package/glm_cli.js ADDED
@@ -0,0 +1,607 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * glm-cli —— 在终端里直接使用智谱 GLM 系列模型。
4
+ * 零依赖,需要 Node.js >= 18(内置 fetch)。
5
+ *
6
+ * 快速开始:
7
+ * node glm_cli.js config set api-key # 手动输入并保存 API Key
8
+ * node glm_cli.js ask "一句话介绍你自己"
9
+ * node glm_cli.js chat
10
+ * node glm_cli.js models
11
+ *
12
+ * Key 获取: https://open.bigmodel.cn 控制台 -> API Key(海外站 https://z.ai)
13
+ */
14
+ "use strict";
15
+
16
+ const fs = require("node:fs");
17
+ const os = require("node:os");
18
+ const path = require("node:path");
19
+ const readline = require("node:readline");
20
+ const { Writable } = require("node:stream");
21
+
22
+ const VERSION = "1.0.0";
23
+
24
+ // 默认智谱国内站;使用海外 z.ai 时换成 https://api.z.ai/api/paas/v4
25
+ const DEFAULT_BASE_URL = "https://open.bigmodel.cn/api/paas/v4";
26
+ const DEFAULT_MODEL = "glm-5.3-flash";
27
+ const ENV_API_KEYS = ["GLM_API_KEY", "ZHIPUAI_API_KEY", "ZHIPU_API_KEY"];
28
+
29
+ // 环境变量 GLM_CLI_HOME 可把配置目录改到别处(便携 / 多账号场景)
30
+ const CONFIG_DIR = process.env.GLM_CLI_HOME || path.join(os.homedir(), ".glm-cli");
31
+ const CONFIG_FILE = path.join(CONFIG_DIR, "config.json");
32
+ const HISTORY_FILE = path.join(CONFIG_DIR, "chat-latest.json");
33
+
34
+ const STREAM_IDLE_MS = 120000; // 流式模式下两次数据块之间的最大等待(毫秒)
35
+ const PLAIN_MS = 300000; // 非流式模式的整体等待(毫秒)
36
+
37
+ const HINTS = {
38
+ 401: "API Key 缺失、无效或未生效。请执行: node glm_cli.js config set api-key",
39
+ 403: "当前 Key 无权访问该模型,或账号未开通对应服务。",
40
+ 404: "模型名或接口地址可能有误:用 --model 指定模型,--base-url 指定接口地址。",
41
+ 429: "请求过于频繁,或额度/资源包不足,请稍后再试。",
42
+ };
43
+
44
+ const TIMEOUT_REASON = "glm-cli-idle-timeout";
45
+
46
+ class ApiError extends Error {}
47
+
48
+ const colors = { dim: "", bold: "", red: "", green: "", cyan: "", reset: "" };
49
+
50
+ function enableColors(noColor) {
51
+ if (noColor || process.env.NO_COLOR || !process.stdout.isTTY) return;
52
+ colors.dim = "\x1b[2m";
53
+ colors.bold = "\x1b[1m";
54
+ colors.red = "\x1b[31m";
55
+ colors.green = "\x1b[32m";
56
+ colors.cyan = "\x1b[36m";
57
+ colors.reset = "\x1b[0m";
58
+ }
59
+
60
+ const state = { currentAbort: null };
61
+
62
+ // ---------------------------------------------------------------- 配置读写
63
+
64
+ function loadConfig() {
65
+ try {
66
+ return JSON.parse(fs.readFileSync(CONFIG_FILE, "utf8"));
67
+ } catch (e) {
68
+ if (e.code === "ENOENT") return {};
69
+ console.error(`警告:配置文件 ${CONFIG_FILE} 读取失败(${e.message}),将忽略已有配置。`);
70
+ return {};
71
+ }
72
+ }
73
+
74
+ function saveConfig(cfg) {
75
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
76
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(cfg, null, 2) + "\n", "utf8");
77
+ if (process.platform !== "win32") {
78
+ try { fs.chmodSync(CONFIG_FILE, 0o600); } catch {}
79
+ }
80
+ }
81
+
82
+ function resolveRuntime(args, cfg) {
83
+ let apiKey = args.api_key;
84
+ if (!apiKey) {
85
+ for (const k of ENV_API_KEYS) {
86
+ const v = process.env[k];
87
+ if (v) { apiKey = v; break; }
88
+ }
89
+ }
90
+ if (!apiKey) apiKey = cfg.api_key;
91
+ return {
92
+ apiKey,
93
+ model: args.model || cfg.model || DEFAULT_MODEL,
94
+ baseUrl: args.base_url || cfg.base_url || DEFAULT_BASE_URL,
95
+ };
96
+ }
97
+
98
+ function requireKey(apiKey) {
99
+ if (apiKey) return apiKey;
100
+ throw new ApiError(
101
+ "尚未配置 API Key,请任选其一:\n" +
102
+ " 1. node glm_cli.js config set api-key (推荐,保存后长期使用)\n" +
103
+ " 2. 设置环境变量 GLM_API_KEY\n" +
104
+ " 3. 临时使用:--api-key <你的Key>\n" +
105
+ "Key 获取:https://open.bigmodel.cn 控制台 -> API Key"
106
+ );
107
+ }
108
+
109
+ function mask(key) {
110
+ if (!key) return "(未设置)";
111
+ if (key.length <= 8) return key.slice(0, 2) + "****";
112
+ return key.slice(0, 6) + "..." + key.slice(-4);
113
+ }
114
+
115
+ // ---------------------------------------------------------------- HTTP / SSE
116
+
117
+ function isAbort(err) {
118
+ return err && (err.name === "AbortError" || err.code === "ABORT_ERR");
119
+ }
120
+
121
+ async function friendlyHTTPError(resp) {
122
+ let detail = "";
123
+ try {
124
+ const text = await resp.text();
125
+ try {
126
+ const obj = JSON.parse(text);
127
+ detail = (obj.error && obj.error.message) || text.trim();
128
+ } catch {
129
+ detail = text.trim();
130
+ }
131
+ } catch {}
132
+ let msg = `请求失败 [HTTP ${resp.status}]`;
133
+ if (detail) msg += `:${detail}`;
134
+ if (HINTS[resp.status]) msg += `\n提示:${HINTS[resp.status]}`;
135
+ return msg;
136
+ }
137
+
138
+ async function apiFetch(url, apiKey, payload, method, signal) {
139
+ const headers = { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" };
140
+ let body;
141
+ if (payload !== null && payload !== undefined) {
142
+ body = JSON.stringify(payload);
143
+ if (payload.stream) headers.Accept = "text/event-stream";
144
+ }
145
+ let resp;
146
+ try {
147
+ resp = await fetch(url, { method, headers, body, signal });
148
+ } catch (err) {
149
+ if (isAbort(err)) throw err;
150
+ const cause = err.cause ? (err.cause.message || String(err.cause)) : err.message;
151
+ throw new ApiError(`无法连接服务器:${cause}(请检查网络或 --base-url)`);
152
+ }
153
+ if (!resp.ok) throw new ApiError(await friendlyHTTPError(resp));
154
+ return resp;
155
+ }
156
+
157
+ async function* iterSSE(body) {
158
+ const decoder = new TextDecoder();
159
+ let buf = "";
160
+ for await (const chunk of body) {
161
+ buf += decoder.decode(chunk, { stream: true });
162
+ let idx;
163
+ while ((idx = buf.indexOf("\n")) >= 0) {
164
+ const line = buf.slice(0, idx).trim();
165
+ buf = buf.slice(idx + 1);
166
+ if (!line.startsWith("data:")) continue;
167
+ const data = line.slice(5).trim();
168
+ if (data === "[DONE]") return;
169
+ if (data) yield data;
170
+ }
171
+ }
172
+ }
173
+
174
+ function buildPayload(rt, messages, args) {
175
+ const payload = { model: rt.model, messages, stream: !args.no_stream };
176
+ if (args.temperature != null) payload.temperature = Number(args.temperature);
177
+ if (args.max_tokens) payload.max_tokens = Number(args.max_tokens);
178
+ if (args.thinking != null) {
179
+ payload.thinking = { type: args.thinking === "on" ? "enabled" : "disabled" };
180
+ }
181
+ if (args.debug) {
182
+ console.error(`[debug] ${rt.baseUrl.replace(/\/+$/, "")}/chat/completions`);
183
+ console.error(`[debug] ${JSON.stringify(payload)}`);
184
+ }
185
+ return payload;
186
+ }
187
+
188
+ // ---------------------------------------------------------------- 对话调用
189
+
190
+ async function chatCompletion(rt, payload) {
191
+ const url = rt.baseUrl.replace(/\/+$/, "") + "/chat/completions";
192
+ const stream = !!payload.stream;
193
+ const controller = new AbortController();
194
+ let timer = null;
195
+ const arm = () => {
196
+ clearTimeout(timer);
197
+ timer = setTimeout(() => controller.abort(TIMEOUT_REASON), stream ? STREAM_IDLE_MS : PLAIN_MS);
198
+ };
199
+ arm();
200
+ state.currentAbort = controller;
201
+ const contentParts = [];
202
+ const reasoningParts = [];
203
+ let usage = null;
204
+ try {
205
+ const resp = await apiFetch(url, rt.apiKey, payload, "POST", controller.signal);
206
+ if (stream) {
207
+ let inThinking = false;
208
+ for await (const data of iterSSE(resp.body)) {
209
+ arm();
210
+ let obj;
211
+ try { obj = JSON.parse(data); } catch { continue; }
212
+ if (obj.error) throw new ApiError("服务端返回错误:" + JSON.stringify(obj.error));
213
+ if (obj.usage) usage = obj.usage;
214
+ const choice = (obj.choices || [])[0];
215
+ if (!choice) continue;
216
+ const delta = choice.delta || {};
217
+ const rc = delta.reasoning_content;
218
+ if (rc) {
219
+ if (!inThinking) {
220
+ inThinking = true;
221
+ console.log(`${colors.dim}—— 思考 ——${colors.reset}`);
222
+ }
223
+ process.stdout.write(colors.dim + rc + colors.reset);
224
+ reasoningParts.push(rc);
225
+ }
226
+ const text = delta.content;
227
+ if (text) {
228
+ if (inThinking) {
229
+ inThinking = false;
230
+ console.log(`\n${colors.bold}—— 回答 ——${colors.reset}`);
231
+ }
232
+ process.stdout.write(text);
233
+ contentParts.push(text);
234
+ }
235
+ }
236
+ console.log();
237
+ } else {
238
+ const obj = await resp.json();
239
+ if (obj.error) throw new ApiError("服务端返回错误:" + JSON.stringify(obj.error));
240
+ const message = ((obj.choices || [])[0] || {}).message || {};
241
+ if (message.reasoning_content) {
242
+ console.log(`${colors.dim}—— 思考 ——\n${message.reasoning_content}${colors.reset}`);
243
+ reasoningParts.push(message.reasoning_content);
244
+ }
245
+ const content = message.content || "";
246
+ console.log(content);
247
+ contentParts.push(content);
248
+ usage = obj.usage || null;
249
+ }
250
+ } catch (err) {
251
+ if (isAbort(err)) {
252
+ if (controller.signal.reason === TIMEOUT_REASON) {
253
+ console.log(`\n${colors.dim}(等待数据超时,已中断)${colors.reset}`);
254
+ } else {
255
+ console.log(`\n${colors.dim}(已中断本次回复)${colors.reset}`);
256
+ }
257
+ } else {
258
+ throw err;
259
+ }
260
+ } finally {
261
+ clearTimeout(timer);
262
+ state.currentAbort = null;
263
+ }
264
+ return { content: contentParts.join(""), reasoning: reasoningParts.join(""), usage };
265
+ }
266
+
267
+ function printUsageLine(usage) {
268
+ if (usage && usage.prompt_tokens != null && usage.completion_tokens != null) {
269
+ console.log(`${colors.dim}[tokens] 输入 ${usage.prompt_tokens} · 输出 ${usage.completion_tokens}${colors.reset}`);
270
+ }
271
+ }
272
+
273
+ // ---------------------------------------------------------------- 子命令
274
+
275
+ function readQuestionFromFiles(files) {
276
+ return files.map((fp) => {
277
+ let text;
278
+ try {
279
+ text = fs.readFileSync(fp, "utf8");
280
+ } catch (e) {
281
+ throw new ApiError(`无法读取文件 ${fp}:${e.message}`);
282
+ }
283
+ return `文件 \`${path.basename(fp)}\` 内容:\n\`\`\`\n${text}\n\`\`\``;
284
+ });
285
+ }
286
+
287
+ async function cmdAsk(args, cfg) {
288
+ const rt = resolveRuntime(args, cfg);
289
+ requireKey(rt.apiKey);
290
+
291
+ let question = args._.join(" ").trim();
292
+ if (!question && !process.stdin.isTTY) {
293
+ try { question = fs.readFileSync(0, "utf8").trim(); } catch {}
294
+ }
295
+ if (args.file && args.file.length) {
296
+ question = (question ? question + "\n\n" : "") + readQuestionFromFiles(args.file).join("\n\n");
297
+ }
298
+ if (!question) {
299
+ throw new ApiError('请提供问题内容,例如:node glm_cli.js ask "你好"(或用管道传入;交互式多轮请用 chat 子命令)');
300
+ }
301
+
302
+ const messages = [];
303
+ if (args.system) messages.push({ role: "system", content: args.system });
304
+ messages.push({ role: "user", content: question });
305
+
306
+ const { content, usage } = await chatCompletion(rt, buildPayload(rt, messages, args));
307
+ if (args.output) {
308
+ fs.mkdirSync(path.dirname(path.resolve(args.output)), { recursive: true });
309
+ fs.writeFileSync(args.output, content, "utf8");
310
+ console.log(`${colors.dim}回答已保存到 ${args.output}${colors.reset}`);
311
+ }
312
+ printUsageLine(usage);
313
+ }
314
+
315
+ const SLASH_HELP = [
316
+ "命令:",
317
+ " /help 显示本帮助",
318
+ " /new 清空当前对话,重新开始",
319
+ " /model <名称> 临时切换模型",
320
+ " /system <文本> 设置/更新 system 提示词(不带文本则清除)",
321
+ " /save [路径] 保存当前对话记录为 JSON",
322
+ " /exit 退出(或 Ctrl+C / Ctrl+D)",
323
+ ].join("\n");
324
+
325
+ async function cmdChat(args, cfg) {
326
+ const rt = resolveRuntime(args, cfg);
327
+ requireKey(rt.apiKey);
328
+
329
+ let messages = args.system ? [{ role: "system", content: args.system }] : [];
330
+ if (args.resume) {
331
+ let data;
332
+ try {
333
+ data = JSON.parse(fs.readFileSync(args.resume, "utf8"));
334
+ } catch (e) {
335
+ throw new ApiError(`无法读取对话记录 ${args.resume}:${e.message}`);
336
+ }
337
+ messages = data.messages || messages;
338
+ if (data.model) rt.model = data.model;
339
+ }
340
+
341
+ console.log(`${colors.cyan}glm-cli ${VERSION} · 模型 ${rt.model} · ${rt.baseUrl}${colors.reset}`);
342
+ console.log(`${colors.dim}输入消息开始对话,/help 查看命令,/exit 退出。${colors.reset}`);
343
+
344
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
345
+ rl.setPrompt(`${colors.green}你${colors.reset} > `);
346
+ rl.prompt();
347
+
348
+ // 输入流结束(EOF)会自动关闭 readline,此时再 prompt 会抛 ERR_USE_AFTER_CLOSE
349
+ let rlClosed = false;
350
+ rl.on("close", () => { rlClosed = true; });
351
+ const safePrompt = () => { if (!rlClosed) rl.prompt(); };
352
+
353
+ // Ctrl+C:正在生成时中断本次回复,空闲时退出
354
+ rl.on("SIGINT", () => {
355
+ if (state.currentAbort) state.currentAbort.abort();
356
+ else rl.close();
357
+ });
358
+
359
+ try {
360
+ for await (const line of rl) {
361
+ const text = line.trim();
362
+ if (!text) { safePrompt(); continue; }
363
+
364
+ if (text.startsWith("/")) {
365
+ const cmd = text.split(/\s+/)[0].toLowerCase();
366
+ const rest = text.slice(cmd.length).trim();
367
+ if (["/exit", "/quit", "/q"].includes(cmd)) break;
368
+ else if (cmd === "/help") console.log(SLASH_HELP);
369
+ else if (cmd === "/new") {
370
+ messages = messages.filter((m) => m.role === "system");
371
+ console.log(`${colors.dim}已清空对话。${colors.reset}`);
372
+ } else if (cmd === "/model") {
373
+ if (rest) { rt.model = rest; console.log(`${colors.dim}已切换模型: ${rt.model}${colors.reset}`); }
374
+ else console.log(`当前模型: ${rt.model}`);
375
+ } else if (cmd === "/system") {
376
+ messages = messages.filter((m) => m.role !== "system");
377
+ if (rest) {
378
+ messages.unshift({ role: "system", content: rest });
379
+ console.log(`${colors.dim}system 已设置。${colors.reset}`);
380
+ } else {
381
+ console.log(`${colors.dim}system 已清除。${colors.reset}`);
382
+ }
383
+ } else if (cmd === "/save") {
384
+ const file = rest || HISTORY_FILE;
385
+ fs.mkdirSync(path.dirname(path.resolve(file)), { recursive: true });
386
+ fs.writeFileSync(file, JSON.stringify({ model: rt.model, messages }, null, 2) + "\n", "utf8");
387
+ console.log(`${colors.dim}对话已保存到 ${file}${colors.reset}`);
388
+ } else {
389
+ console.log(`${colors.dim}未知命令 ${cmd},输入 /help 查看帮助。${colors.reset}`);
390
+ }
391
+ safePrompt();
392
+ continue;
393
+ }
394
+
395
+ messages.push({ role: "user", content: text });
396
+ try {
397
+ const { content, usage } = await chatCompletion(rt, buildPayload(rt, messages, args));
398
+ if (content.trim()) messages.push({ role: "assistant", content });
399
+ else messages.pop(); // 回复为空(如被中断),移除未完成回合的用户消息
400
+ printUsageLine(usage);
401
+ } catch (e) {
402
+ if (e instanceof ApiError) {
403
+ console.error(`${colors.red}${e.message}${colors.reset}`);
404
+ messages.pop(); // 回合失败,移除未送达的用户消息
405
+ } else {
406
+ throw e;
407
+ }
408
+ }
409
+ safePrompt();
410
+ }
411
+ } finally {
412
+ rl.close();
413
+ }
414
+ }
415
+
416
+ async function cmdModels(args, cfg) {
417
+ const rt = resolveRuntime(args, cfg);
418
+ requireKey(rt.apiKey);
419
+ const url = rt.baseUrl.replace(/\/+$/, "") + "/models";
420
+ const controller = new AbortController();
421
+ const timer = setTimeout(() => controller.abort(TIMEOUT_REASON), 60000);
422
+ try {
423
+ const resp = await apiFetch(url, rt.apiKey, null, "GET", controller.signal);
424
+ const obj = await resp.json();
425
+ const items = obj.data || [];
426
+ if (!items.length) {
427
+ console.log("服务端未返回模型列表(该接口可能不支持 /models)。请直接用 --model 指定模型名,或查阅开放平台文档。");
428
+ return;
429
+ }
430
+ for (const it of items) console.log(typeof it === "string" ? it : it.id);
431
+ } catch (err) {
432
+ if (isAbort(err)) throw new ApiError("请求超时(>60s)。");
433
+ throw err;
434
+ } finally {
435
+ clearTimeout(timer);
436
+ }
437
+ }
438
+
439
+ function promptHidden(question) {
440
+ return new Promise((resolve) => {
441
+ process.stderr.write(question);
442
+ const sink = new Writable({ write(_chunk, _enc, cb) { cb(); } });
443
+ const rl = readline.createInterface({ input: process.stdin, output: sink, terminal: true });
444
+ let done = false;
445
+ const finish = (answer) => {
446
+ if (done) return;
447
+ done = true;
448
+ rl.close();
449
+ process.stderr.write("\n");
450
+ resolve(String(answer || "").trim());
451
+ };
452
+ rl.on("close", () => finish(""));
453
+ rl.question("", finish);
454
+ });
455
+ }
456
+
457
+ async function cmdConfig(args, cfg) {
458
+ const [action, item, value] = args._;
459
+ if (action === "path") {
460
+ console.log(CONFIG_FILE);
461
+ return;
462
+ }
463
+
464
+ if (action === "get") {
465
+ console.log(`配置文件 : ${CONFIG_FILE}`);
466
+ console.log(`api_key : ${mask(cfg.api_key)}`);
467
+ console.log(`model : ${cfg.model || `(默认 ${DEFAULT_MODEL})`}`);
468
+ console.log(`base_url : ${cfg.base_url || `(默认 ${DEFAULT_BASE_URL})`}`);
469
+ return;
470
+ }
471
+
472
+ const items = { "api-key": "api_key", model: "model", "base-url": "base_url" };
473
+
474
+ if (action === "set") {
475
+ if (!item || !(item in items)) throw new ApiError("支持设置: api-key / model / base-url");
476
+ let v = value;
477
+ if (item === "api-key" && !v) v = await promptHidden("请输入 API Key(输入不会回显): ");
478
+ v = (v || "").trim();
479
+ if (!v) throw new ApiError(`缺少 ${item} 的值,例如: node glm_cli.js config set ${item} <值>`);
480
+ cfg[items[item]] = v;
481
+ saveConfig(cfg);
482
+ console.log(`已保存。配置文件: ${CONFIG_FILE}`);
483
+ return;
484
+ }
485
+
486
+ if (action === "del") {
487
+ if (!item || !(item in items)) throw new ApiError("支持删除: api-key / model / base-url");
488
+ delete cfg[items[item]];
489
+ saveConfig(cfg);
490
+ console.log("已删除。");
491
+ return;
492
+ }
493
+
494
+ throw new ApiError("用法: config set|get|del|path");
495
+ }
496
+
497
+ // ---------------------------------------------------------------- 命令行入口
498
+
499
+ const ALIASES = {
500
+ "-m": "model", "--model": "model",
501
+ "--api-key": "api_key",
502
+ "--base-url": "base_url",
503
+ "--system": "system",
504
+ "-t": "temperature", "--temperature": "temperature",
505
+ "--max-tokens": "max_tokens",
506
+ "--thinking": "thinking",
507
+ "-f": "file", "--file": "file",
508
+ "-o": "output", "--output": "output",
509
+ "--resume": "resume",
510
+ };
511
+
512
+ function printHelp() {
513
+ console.log(`glm-cli ${VERSION} —— 终端里直接使用智谱 GLM 系列模型(零依赖,Node.js >= 18)
514
+
515
+ 用法: node glm_cli.js <command> [参数] [选项]
516
+
517
+ 命令:
518
+ ask 单次提问: node glm_cli.js ask "问题"
519
+ chat 多轮交互对话
520
+ models 列出当前账号可用的模型
521
+ config 管理本地配置(API Key 等)
522
+
523
+ 选项:
524
+ -m, --model <名称> 模型名(默认 ${DEFAULT_MODEL})
525
+ --api-key <Key> 本次使用的 API Key(优先级最高)
526
+ --base-url <地址> 接口地址(默认智谱国内站,海外 z.ai 见 README)
527
+ --system <文本> system 提示词
528
+ -t, --temperature <值> 采样温度
529
+ --max-tokens <数量> 最大输出 token 数
530
+ --thinking <on|off> 深度思考开关(对支持的模型生效)
531
+ --no-stream 关闭流式输出
532
+ --no-color / --debug 关闭彩色 / 调试输出
533
+
534
+ 示例:
535
+ node glm_cli.js config set api-key # 保存 API Key(隐藏输入)
536
+ node glm_cli.js ask "用一句话解释量子纠缠"
537
+ node glm_cli.js ask "总结这份文档" -f report.txt
538
+ type report.txt | node glm_cli.js ask "总结这份文档"
539
+ node glm_cli.js ask "写一首关于秋天的诗" -o poem.txt
540
+ node glm_cli.js chat --thinking on
541
+ node glm_cli.js chat --resume chat-latest.json
542
+ node glm_cli.js models`);
543
+ }
544
+
545
+ function parseArgs(argv) {
546
+ const opts = { _: [] };
547
+ for (let i = 0; i < argv.length; i++) {
548
+ const a = argv[i];
549
+ if (a === "-h" || a === "--help") { opts.help = true; continue; }
550
+ if (a === "-V" || a === "--version") { console.log(`glm-cli ${VERSION}`); process.exit(0); }
551
+ if (a === "--no-stream") { opts.no_stream = true; continue; }
552
+ if (a === "--no-color") { opts.no_color = true; continue; }
553
+ if (a === "--debug") { opts.debug = true; continue; }
554
+ const key = ALIASES[a];
555
+ if (!key) {
556
+ if (a.startsWith("-")) throw new ApiError(`未知参数: ${a}`);
557
+ opts._.push(a);
558
+ continue;
559
+ }
560
+ const val = argv[++i];
561
+ if (val === undefined) throw new ApiError(`参数 ${a} 缺少值`);
562
+ if (key === "file") (opts.file = opts.file || []).push(val);
563
+ else opts[key] = val;
564
+ }
565
+ return opts;
566
+ }
567
+
568
+ async function main() {
569
+ if (typeof fetch === "undefined") {
570
+ console.error("需要 Node.js 18+(内置 fetch)。当前 Node 版本过旧,请升级后使用。");
571
+ process.exitCode = 1;
572
+ return;
573
+ }
574
+ let args;
575
+ try {
576
+ args = parseArgs(process.argv.slice(2));
577
+ } catch (e) {
578
+ console.error(`${colors.red}${e.message}${colors.reset}`);
579
+ printHelp();
580
+ process.exitCode = 1;
581
+ return;
582
+ }
583
+ enableColors(args.no_color);
584
+ if (args.help || args._.length === 0) {
585
+ printHelp();
586
+ return;
587
+ }
588
+ const command = args._.shift();
589
+ const cfg = loadConfig();
590
+ try {
591
+ if (command === "ask") await cmdAsk(args, cfg);
592
+ else if (command === "chat") await cmdChat(args, cfg);
593
+ else if (command === "models") await cmdModels(args, cfg);
594
+ else if (command === "config") await cmdConfig(args, cfg);
595
+ else throw new ApiError(`未知命令 "${command}",可用:ask / chat / models / config`);
596
+ } catch (e) {
597
+ if (e instanceof ApiError) {
598
+ console.error(`${colors.red}${e.message}${colors.reset}`);
599
+ process.exitCode = 1;
600
+ } else {
601
+ console.error(e);
602
+ process.exitCode = 1;
603
+ }
604
+ }
605
+ }
606
+
607
+ main();
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "@vfvrpq/llm-cli",
3
+ "version": "1.0.0",
4
+ "description": "终端里直接使用智谱 GLM 与 DeepSeek 模型的零依赖命令行客户端(含 glm-cli 与 deepseek-cli 两个命令)",
5
+ "license": "MIT",
6
+ "author": "vfvrpq",
7
+ "type": "commonjs",
8
+ "bin": {
9
+ "glm-cli": "glm_cli.js",
10
+ "deepseek-cli": "deepseek_cli.js"
11
+ },
12
+ "files": [
13
+ "glm_cli.js",
14
+ "deepseek_cli.js",
15
+ "README.md",
16
+ "LICENSE"
17
+ ],
18
+ "engines": {
19
+ "node": ">=18"
20
+ },
21
+ "keywords": [
22
+ "glm",
23
+ "deepseek",
24
+ "zhipu",
25
+ "bigmodel",
26
+ "llm",
27
+ "cli",
28
+ "chat"
29
+ ],
30
+ "repository": {
31
+ "type": "git",
32
+ "url": "git+https://github.com/439436269-ctrl/llm-cli.git"
33
+ },
34
+ "bugs": {
35
+ "url": "https://github.com/439436269-ctrl/llm-cli/issues"
36
+ },
37
+ "homepage": "https://github.com/439436269-ctrl/llm-cli#readme"
38
+ }