sidekick-chat 0.2.4 → 0.2.5

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/src/main.mjs CHANGED
@@ -1,609 +1,642 @@
1
- /**
2
- * 终端客户端的主流程:`sidekick` 进交互对话,`sidekick login` 存凭据,`sidekick ask "…"` 一次性问一句。
3
- *
4
- * 它只是 chat World 的又一个客户端,和手机 App、网页版共用同一份会话和未读,
5
- * 不新增任何后端接口(见 packages/world-chat/src/world.ts 的 `startHub`)。
6
- */
7
- import { createInterface } from 'node:readline';
8
- import { readFileSync } from 'node:fs';
9
- import { fileURLToPath } from 'node:url';
10
- import { AuthError, SidekickApi, loginWithPassword } from './api.mjs';
11
- import {
12
- DEFAULT_ALT,
13
- DEFAULT_URL,
14
- clearConfig,
15
- configPath,
16
- readConfig,
17
- resolveSettings,
18
- stripTrailingSlash,
19
- writeConfig,
20
- } from './config.mjs';
21
- import {
22
- dayLabel,
23
- formatConversation,
24
- formatMessage,
25
- colorsEnabled,
26
- oneLine,
27
- paint,
28
- plainMessage,
29
- } from './format.mjs';
30
- import { ask, askPassword } from './prompt.mjs';
31
- import { createQuotaReader, formatQuota } from './quota.mjs';
32
-
33
- const PACKAGE_VERSION = (() => {
34
- try {
35
- const file = fileURLToPath(new URL('../package.json', import.meta.url));
36
- return JSON.parse(readFileSync(file, 'utf8')).version;
37
- } catch {
38
- return '0.0.0';
39
- }
40
- })();
41
-
42
- const HELP = `Sidekick 终端客户端 ${PACKAGE_VERSION}
43
-
44
- 用法:
45
- sidekick 和秘书对话(进去后输入一行回车发一条)
46
- sidekick "今天有什么安排" 问一句,等回复打印完就退出
47
- sidekick ask --wait 20 "…" 同上,收到第一条后最多再等 20 秒后续消息
48
- sidekick login 登录(网页密码或设备令牌)
49
- sidekick logout 删掉本机保存的登录态
50
- sidekick help 这份说明
51
-
52
- 参数:
53
- --url <地址> 服务器地址,默认 ${DEFAULT_URL}
54
- --alt <地址> 备用线路,主线路连不上时自动换,默认 ${DEFAULT_ALT}
55
- --token <令牌> 设备令牌(部署 .env 的 SIDEKICK_DEVICE_TOKEN),跳过密码登录
56
- --password <密码> 网页登录密码(不给就在终端里问)
57
- --conv <会话> 指定会话:id、名字或 /conv 列表里的序号,默认和秘书的私聊
58
- --device <名字> 这台机器的署名,默认「终端」
59
- --wait <秒> 一次性模式:收到第一条后再等多久算说完,默认 6
60
- --json 一次性模式:每条消息输出一行 JSON
61
- --no-color 不上色
62
- --plain 纯文本:不上色、不重画输入行(老终端/管道/日志里用)
63
- -h, --help 这份说明
64
- -v, --version 版本
65
-
66
- 进交互后可用 /help 看命令,/conv 列会话,/to 切换,Ctrl-C 退出。
67
-
68
- 环境变量(优先于配置文件):SIDEKICK_URL、SIDEKICK_ALT、SIDEKICK_TOKEN、SIDEKICK_PASSWORD、SIDEKICK_DEVICE、SIDEKICK_COOKIE。
69
- 配置放在 ${configPath()}(权限 600)。`;
70
-
71
- /** 命令行参数解析:node 自带的 parseArgs,不引第三方依赖。 */
72
- function parseFlags(argv) {
73
- const flags = { positional: [] };
74
- const takesValue = new Set(['url', 'alt', 'token', 'password', 'conv', 'device', 'wait']);
75
- for (let i = 0; i < argv.length; i += 1) {
76
- const arg = argv[i];
77
- if (arg === '--') {
78
- flags.positional.push(...argv.slice(i + 1));
79
- break;
80
- }
81
- if (arg === '-h' || arg === '--help') {
82
- flags.help = true;
83
- continue;
84
- }
85
- if (arg === '-v' || arg === '--version') {
86
- flags.version = true;
87
- continue;
88
- }
89
- if (arg === '--json') {
90
- flags.json = true;
91
- continue;
92
- }
93
- if (arg === '--no-color') {
94
- flags.noColor = true;
95
- continue;
96
- }
97
- if (arg === '--plain') {
98
- flags.plain = true;
99
- continue;
100
- }
101
- if (arg.startsWith('--')) {
102
- const eq = arg.indexOf('=');
103
- const name = eq >= 0 ? arg.slice(2, eq) : arg.slice(2);
104
- if (!takesValue.has(name)) throw new Error(`不认识的参数:--${name}`);
105
- const value = eq >= 0 ? arg.slice(eq + 1) : argv[++i];
106
- if (value === undefined) throw new Error(`--${name} 缺一个值`);
107
- flags[name] = value;
108
- continue;
109
- }
110
- flags.positional.push(arg);
111
- }
112
- return flags;
113
- }
114
-
115
- const stdout = (text) => process.stdout.write(`${text}\n`);
116
-
117
- function fail(message) {
118
- process.stderr.write(`${message}\n`);
119
- process.exitCode = 1;
120
- }
121
-
122
- /** 登录:有令牌就用令牌,否则拿网页密码换 Cookie,最后都用 `/v1/team/state` 验一次。 */
123
- async function loginCommand(flags) {
124
- const current = resolveSettings(flags);
125
- const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY);
126
- let url = stripTrailingSlash(String(flags.url || current.url || DEFAULT_URL));
127
- if (interactive && !flags.url) {
128
- url = stripTrailingSlash(await ask(`服务器地址 [${current.url}]:`, { fallback: current.url }));
129
- }
130
-
131
- let token = String(flags.token || '').trim();
132
- let cookie = '';
133
- if (!token) {
134
- let password = String(flags.password || process.env.SIDEKICK_PASSWORD || '');
135
- if (!password) {
136
- if (!interactive) throw new Error('没有终端可问密码:请用 --password 或 --token');
137
- password = await askPassword('网页登录密码(和网页版同一个 CORTICO_WEB_PASSWORD):');
138
- if (!password) throw new Error('没输入密码');
139
- }
140
- cookie = await loginWithPassword(url, password);
141
- }
142
-
143
- const api = new SidekickApi({ url, alt: current.alt, token, cookie });
144
- const state = await api.state();
145
- const primary = state.bots.find((b) => b.primary);
146
- const cfg = { ...readConfig(), url, alt: current.alt, device: current.device || '终端', savedAt: new Date().toISOString() };
147
- if (token) {
148
- cfg.token = token;
149
- delete cfg.cookie;
150
- } else {
151
- cfg.cookie = cookie;
152
- delete cfg.token;
153
- }
154
- writeConfig(cfg);
155
- stdout(`登录成功:${url}`);
156
- stdout(`主 Bot:${primary?.name || state.primary};会话 ${state.conversations.length} 个。凭据存在 ${configPath()}`);
157
- return { url, token, cookie };
158
- }
159
-
160
- /** 一次性模式:发一句,把回复打印完就退出,方便脚本里用。 */
161
- async function askOnce(flags, text) {
162
- const settings = resolveSettings(flags);
163
- if (!settings.loggedIn) throw new AuthError('还没登录,先运行 sidekick login');
164
- const api = new SidekickApi(settings);
165
- const state = await api.state();
166
- const conv = pickConv(state, settings.conv);
167
- const idleMs = Math.max(1, Number(flags.wait || 6) || 6) * 1000;
168
- const sent = await api.send({ conv, text, device: settings.device });
169
-
170
- const ctl = new AbortController();
171
- const got = [];
172
- let idleTimer = null;
173
- let guardTimer = null;
174
- let finishing = false;
175
- const done = new Promise((resolve) => {
176
- const finish = () => {
177
- if (finishing) return;
178
- finishing = true;
179
- clearTimeout(idleTimer);
180
- clearTimeout(guardTimer);
181
- ctl.abort();
182
- resolve();
183
- };
184
- ctl.signal.addEventListener('abort', finish, { once: true });
185
- // 一直没回复也别吊着:默认 5 分钟(按 idle 时间的两倍放宽)。
186
- guardTimer = setTimeout(finish, Math.max(120_000, idleMs * 2));
187
- });
188
-
189
- api
190
- .stream({
191
- after: sent.seq,
192
- device: settings.device,
193
- signal: ctl.signal,
194
- onEvent(event, payload) {
195
- if (event === 'message') {
196
- if (payload.conv !== conv || payload.from === 'user') return;
197
- got.push(payload);
198
- if (flags.json) stdout(JSON.stringify(payload));
199
- else stdout(plainMessage(payload, state.bots));
200
- clearTimeout(idleTimer);
201
- idleTimer = setTimeout(() => ctl.abort(), idleMs);
202
- } else if (event === 'busy' && conv.startsWith('dm:') && payload.busy === false && got.length > 0) {
203
- // 私聊里这一轮结束了,不用再等 idle;群里别的成员还在忙,只认 idle。
204
- ctl.abort();
205
- }
206
- },
207
- })
208
- .catch((err) => {
209
- if (!ctl.signal.aborted) fail(`推流出错:${err instanceof Error ? err.message : String(err)}`);
210
- });
211
-
212
- await done;
213
- if (got.length === 0) {
214
- fail('没等到回复。检查服务器是否在跑,或者用 sidekick 进交互看看。');
215
- return;
216
- }
217
- }
218
-
219
- /** 按 id、名字或列表序号找一个会话;找不到返回 undefined。 */
220
- export function findConv(state, wanted) {
221
- const ref = String(wanted || '').trim();
222
- if (!ref) return undefined;
223
- const byId = state.conversations.find((c) => c.id === ref);
224
- if (byId) return byId;
225
- const byTitle = state.conversations.find((c) => c.title === ref);
226
- if (byTitle) return byTitle;
227
- const index = Number(ref);
228
- if (Number.isInteger(index) && index > 0 && index <= state.conversations.length) return state.conversations[index - 1];
229
- return undefined;
230
- }
231
-
232
- /** 按 id、名字或列表序号挑会话;挑不到就退回和主 Bot 的私聊。 */
233
- export function pickConv(state, wanted) {
234
- const primaryConv = `dm:${state.primary}`;
235
- const found = findConv(state, wanted);
236
- if (found) return found.id;
237
- if (state.conversations.some((c) => c.id === primaryConv)) return primaryConv;
238
- return state.conversations[0]?.id ?? primaryConv;
239
- }
240
-
241
- /** 交互模式。 */
242
- async function startChat(flags) {
243
- let settings = resolveSettings(flags);
244
- if (!settings.loggedIn) {
245
- if (!process.stdin.isTTY) throw new AuthError('还没登录,先运行 sidekick login');
246
- await loginCommand({ ...flags, url: flags.url || settings.url });
247
- settings = resolveSettings(flags);
248
- }
249
-
250
- let api = new SidekickApi(settings);
251
- let state;
252
- try {
253
- state = await api.state();
254
- } catch (err) {
255
- if (err instanceof AuthError) {
256
- if (!process.stdin.isTTY) throw new AuthError('登录态已失效,重新运行 sidekick login');
257
- await loginCommand({ ...flags, url: settings.url });
258
- settings = resolveSettings(flags);
259
- api = new SidekickApi(settings);
260
- state = await api.state();
261
- } else {
262
- throw err;
263
- }
264
- }
265
-
266
- // 老终端(不认 ANSI)和管道里别用转义序列:重画输入行会留下「[K」这类垃圾。
267
- const vt = Boolean(process.stdout.isTTY && process.stdin.isTTY) && process.env.TERM !== 'dumb' && !flags.plain && process.env.SIDEKICK_PLAIN !== '1';
268
- const color = flags.noColor || !vt ? false : colorsEnabled();
269
- const convTitle = (id) => state.conversations.find((c) => c.id === id)?.title || id;
270
- const convOf = (id) => state.conversations.find((c) => c.id === id);
271
- let convId = pickConv(state, settings.conv);
272
- let latest = state.latest || 0;
273
- let everConnected = false;
274
- // 「有人在看」才推进已读:调试时终端可以一直挂在那儿,而挂着的终端不该把手机上刚弹出的通知顶掉。
275
- // 有按键才算在场(120 秒内),不在场时只把进度攒在 seenSeq 里,等下次动手再一起报。
276
- const PRESENT_MS = 120_000;
277
- // 0 = 启动以来还没人碰过这台终端:刚起来的窗口也不算在场,只靠 printHistory 那个显式动作推一次。
278
- let lastInputAt = 0;
279
- let seenSeq = 0;
280
- const displayed = new Set();
281
- const ctl = new AbortController();
282
- let rl = null;
283
- let closed = false;
284
- let ui = null;
285
- let streamTask = Promise.resolve();
286
- const quotas = createQuotaReader(settings);
287
-
288
- const out = (text) => {
289
- if (ui) ui.out(text);
290
- else process.stdout.write(`${text}\n`);
291
- };
292
- const typing = (on) => ui?.typing(on);
293
-
294
- /** 把「已经打到屏幕上」的最新 seq 报成已读。 */
295
- function reportSeen() {
296
- if (!seenSeq) return;
297
- const seq = seenSeq;
298
- seenSeq = 0;
299
- api.read(convId, seq).catch(() => {});
300
- }
301
-
302
- /** 用户在这台终端上动了一下(敲键、发消息、切会话):算在场,并把攒着的已读补报上去。 */
303
- function markPresent() {
304
- lastInputAt = Date.now();
305
- reportSeen();
306
- }
307
-
308
- const present = () => lastInputAt > 0 && Date.now() - lastInputAt < PRESENT_MS;
309
-
310
- const printMessage = (message) => {
311
- const options = { bots: state.bots, color, imageUrl: (name) => api.imageUrl(name) };
312
- if (ui) ui.message(message, options);
313
- else out(formatMessage(message, options));
314
- };
315
- if (vt) {
316
- const { createChatUI } = await import('./tui.mjs');
317
- ui = createChatUI({ color, onInput: () => markPresent(), onSubmit: (text) => {
318
- markPresent();
319
- queue.push(() => text.startsWith('/') ? handleSlash(text) : sendText(text));
320
- void drain();
321
- }, onQuit: () => shutdown(0) });
322
- }
323
-
324
- async function printHistory(limit = 20) {
325
- const body = await api.history(convId, limit);
326
- let day = '';
327
- for (const m of body.messages) {
328
- const d = dayLabel(m.ts);
329
- if (d !== day) {
330
- out(paint(color, 'gray', `──── ${d} ────`));
331
- day = d;
332
- }
333
- printMessage(m);
334
- }
335
- latest = Math.max(latest, body.latest || 0);
336
- // 刚把这段历史打到屏幕上(启动、切会话、/history),已读位置就到这里。
337
- seenSeq = 0;
338
- if (body.latest) api.read(convId, body.latest).catch(() => {});
339
- if (body.messages.length === 0) out(paint(color, 'gray', '(这里还没有消息)'));
340
- }
341
-
342
- const primary = state.bots.find((b) => b.primary);
343
- out(`${paint(color, 'bold', `Sidekick · ${primary?.name || state.primary}`)} ${paint(color, 'gray', settings.url)}`);
344
- if (settings.conv && !findConv(state, settings.conv)) {
345
- out(paint(color, 'yellow', `找不到会话「${settings.conv}」,先用「${convTitle(convId)}」;/conv 看全部`));
346
- }
347
- out(paint(color, 'gray', `会话:${convTitle(convId)} · /help 看命令,/conv 列会话,Ctrl-C 退出`));
348
- await printHistory(30);
349
-
350
- function applyState(next) {
351
- if (!next) return;
352
- state = next;
353
- if (!convOf(convId)) {
354
- ui?.clearDrafts();
355
- convId = pickConv(state, '');
356
- }
357
- }
358
-
359
- function onMessage(m) {
360
- latest = Math.max(latest, m.seq || 0);
361
- typing(false);
362
- if (displayed.has(m.seq)) return;
363
- displayed.add(m.seq);
364
- if (displayed.size > 1000) displayed.delete(displayed.values().next().value);
365
- if (m.conv !== convId) {
366
- if (m.from !== 'user') {
367
- out(paint(color, 'gray', `· ${convTitle(m.conv)} 有新消息(${oneLine(m.text, 40) || '图片'}),/to 切过去`));
368
- }
369
- return;
370
- }
371
- printMessage(m);
372
- if (typeof m.seq === 'number') {
373
- if (present()) api.read(convId, m.seq).catch(() => {});
374
- else seenSeq = Math.max(seenSeq, m.seq);
375
- }
376
- }
377
-
378
- streamTask = api
379
- .stream({
380
- after: latest,
381
- device: settings.device,
382
- signal: ctl.signal,
383
- onStatus: (kind) => {
384
- if (kind === 'closed' || kind === 'error') ui?.clearDrafts();
385
- // 只在「连着的时候断了」和「重新接上」各说一句,退避重试期间不刷屏。
386
- if (kind === 'open') {
387
- if (everConnected) out(paint(color, 'green', '已重新连上'));
388
- everConnected = true;
389
- } else if (kind === 'error' && everConnected) {
390
- everConnected = false;
391
- out(paint(color, 'red', '连接断了,正在重连…'));
392
- }
393
- },
394
- onEvent(event, payload) {
395
- if (event === 'hello' || event === 'state') applyState(payload.state ?? payload);
396
- else if (event === 'message') onMessage(payload);
397
- else if (event === 'draft' && ui) {
398
- // End frames have no conversation; remove only the matching sender/id.
399
- if (payload.end || payload.conv === convId) ui.draft(payload, { bots: state.bots, color });
400
- else ui.draft({ ...payload, end: true }, { bots: state.bots, color });
401
- }
402
- else if (event === 'busy') typing(Boolean(payload.busy));
403
- },
404
- })
405
- .catch((err) => {
406
- if (ctl.signal.aborted) return;
407
- out(err instanceof AuthError ? '登录态已失效,重新运行 sidekick login' : `推流出错:${err.message}`);
408
- shutdown(1);
409
- });
410
-
411
- function switchConv(next) {
412
- ui?.clearDrafts();
413
- markPresent();
414
- convId = next;
415
- const cfg = { ...readConfig(), url: settings.url, conv: convId, device: settings.device };
416
- writeConfig(cfg);
417
- out(paint(color, 'green', `→ ${convTitle(convId)}`));
418
- return printHistory(30);
419
- }
420
-
421
- async function handleSlash(line) {
422
- const [raw, ...rest] = line.slice(1).trim().split(/\s+/);
423
- const cmd = (raw || '').toLowerCase();
424
- const arg = rest.join(' ');
425
- switch (cmd) {
426
- case 'help':
427
- case '?':
428
- out([
429
- '/conv 列出会话',
430
- '/to <序号|名字> 切换会话',
431
- '/history [n] 重印最近 n 条(默认 20)',
432
- '/who 名册和连接状态',
433
- '/usage 查询额度,查看剩余比例和重置时间',
434
- '/clear 清屏',
435
- '/quit 退出(也可以 Ctrl-C)',
436
- '直接输入文字就是发给当前会话。',
437
- ].join('\n'));
438
- return;
439
- case 'quit':
440
- case 'exit':
441
- case 'q':
442
- shutdown(0);
443
- return;
444
- case 'conv':
445
- case 'convs':
446
- case 'ls':
447
- out(
448
- state.conversations
449
- .map((c, i) => formatConversation(c, i + 1, { bots: state.bots, current: convId }))
450
- .join('\n') || '(还没有会话)',
451
- );
452
- return;
453
- case 'to':
454
- case 'switch':
455
- if (!arg) {
456
- out('用法:/to <序号或名字>,先 /conv 看一眼');
457
- return;
458
- }
459
- await switchConv(pickConv(state, arg));
460
- return;
461
- case 'history':
462
- case 'h':
463
- await printHistory(Math.max(1, Number(arg) || 20));
464
- return;
465
- case 'who':
466
- out(
467
- state.bots
468
- .map((b) => `${b.primary ? '*' : ' '}${b.name}(${b.id}) ${b.status}${b.busy ? ' 正在忙' : ''} — ${b.role || '无专长'}`)
469
- .join('\n'),
470
- );
471
- return;
472
- case 'usage':
473
- case 'quota':
474
- out(formatQuota(await quotas.refresh(), { detail: true }));
475
- return;
476
- case 'clear':
477
- if (ui) ui.clear();
478
- else out('(清屏已跳过:纯文本模式下不动终端)');
479
- return;
480
- default:
481
- out(paint(color, 'red', `不认识的命令:/${cmd}(/help 看命令)`));
482
- }
483
- }
484
-
485
- const queue = [];
486
- let busy = false;
487
- async function drain() {
488
- if (busy) return;
489
- const next = queue.shift();
490
- if (next === undefined) return;
491
- busy = true;
492
- try {
493
- await next();
494
- } catch (err) {
495
- out(paint(color, 'red', err instanceof Error ? err.message : String(err)));
496
- } finally {
497
- busy = false;
498
- void drain();
499
- }
500
- }
501
-
502
- async function sendText(text) {
503
- const res = await api.send({ conv: convId, text, device: settings.device });
504
- latest = Math.max(latest, res.seq);
505
- if (displayed.has(res.seq)) return;
506
- displayed.add(res.seq);
507
- printMessage({ seq: res.seq, conv: convId, ts: Date.now(), from: 'user', text });
508
- }
509
-
510
- if (ui) {
511
- ui.start();
512
- } else {
513
- rl = createInterface({ input: process.stdin, output: process.stdout, terminal: false });
514
- rl.on('line', (line) => {
515
- const text = line.trim();
516
- if (!text) return;
517
- markPresent();
518
- queue.push(() => (text.startsWith('/') ? handleSlash(text) : sendText(text)));
519
- void drain();
520
- });
521
- rl.on('SIGINT', () => shutdown(0));
522
- rl.on('close', () => {
523
- if (!closed) shutdown(0);
524
- });
525
- }
526
-
527
- function shutdown(code = 0) {
528
- if (closed) return;
529
- closed = true;
530
- reportSeen();
531
- typing(false);
532
- quotas.stop();
533
- ui?.stop();
534
- ctl.abort();
535
- try {
536
- rl?.close();
537
- } catch {
538
- /* 已经关了 */
539
- }
540
- process.exitCode = code;
541
- void streamTask.catch(() => {});
542
- // 让事件循环自己收尾:Windows 上在 TLS 句柄还没关完时就 process.exit(),
543
- // 会让 libuv 报「!(handle->flags & UV_HANDLE_CLOSING)」直接崩掉。
544
- // 万一有句柄赖着不放,1.5 秒后强退。
545
- setTimeout(() => process.exit(code), 1500).unref();
546
- }
547
- }
548
-
549
- export async function run(argv = process.argv.slice(2)) {
550
- let flags;
551
- try {
552
- flags = parseFlags(argv);
553
- } catch (err) {
554
- fail(`${err.message}\n\n${HELP}`);
555
- return;
556
- }
557
- if (flags.help) {
558
- stdout(HELP);
559
- return;
560
- }
561
- if (flags.version) {
562
- stdout(PACKAGE_VERSION);
563
- return;
564
- }
565
- if (flags.noColor) process.env.NO_COLOR = '1';
566
-
567
- const command = flags.positional[0];
568
- const rest = flags.positional.slice(1);
569
-
570
- process.on('unhandledRejection', (err) => {
571
- fail(err instanceof Error ? err.message : String(err));
572
- process.exit(1);
573
- });
574
-
575
- try {
576
- if (command === 'help') {
577
- stdout(HELP);
578
- return;
579
- }
580
- if (command === 'login') {
581
- await loginCommand(flags);
582
- return;
583
- }
584
- if (command === 'logout') {
585
- clearConfig();
586
- stdout(`已经删掉本机保存的登录态(${configPath()})`);
587
- return;
588
- }
589
- if (command === 'ask') {
590
- const text = rest.join(' ').trim();
591
- if (!text) throw new Error('用法:sidekick ask "要说的话"');
592
- await askOnce(flags, text);
593
- return;
594
- }
595
- if (command) {
596
- // 没有子命令就当成一句话发出去:sidekick "今天有什么安排"
597
- await askOnce(flags, flags.positional.join(' ').trim());
598
- return;
599
- }
600
- await startChat(flags);
601
- } catch (err) {
602
- if (err instanceof AuthError) {
603
- fail(`${err.message}`);
604
- process.exitCode = 2;
605
- return;
606
- }
607
- fail(err instanceof Error ? err.message : String(err));
608
- }
609
- }
1
+ /**
2
+ * 终端客户端的主流程:`sidekick` 进交互对话,`sidekick login` 存凭据,`sidekick ask "…"` 一次性问一句。
3
+ *
4
+ * 它只是 chat World 的又一个客户端,和手机 App、网页版共用同一份会话和未读,
5
+ * 不新增任何后端接口(见 packages/world-chat/src/world.ts 的 `startHub`)。
6
+ */
7
+ import { createInterface } from 'node:readline';
8
+ import { readFileSync } from 'node:fs';
9
+ import { fileURLToPath } from 'node:url';
10
+ import { AuthError, SidekickApi, loginWithPassword } from './api.mjs';
11
+ import {
12
+ DEFAULT_ALT,
13
+ DEFAULT_URL,
14
+ clearConfig,
15
+ configPath,
16
+ readConfig,
17
+ resolveSettings,
18
+ stripTrailingSlash,
19
+ writeConfig,
20
+ } from './config.mjs';
21
+ import {
22
+ dayLabel,
23
+ formatConversation,
24
+ formatMessage,
25
+ colorsEnabled,
26
+ oneLine,
27
+ paint,
28
+ plainMessage,
29
+ } from './format.mjs';
30
+ import { ask, askPassword } from './prompt.mjs';
31
+ import { createQuotaReader, formatQuota } from './quota.mjs';
32
+ import { clipboardImage, imageFile } from './images.mjs';
33
+
34
+ const PACKAGE_VERSION = (() => {
35
+ try {
36
+ const file = fileURLToPath(new URL('../package.json', import.meta.url));
37
+ return JSON.parse(readFileSync(file, 'utf8')).version;
38
+ } catch {
39
+ return '0.0.0';
40
+ }
41
+ })();
42
+
43
+ const HELP = `Sidekick 终端客户端 ${PACKAGE_VERSION}
44
+
45
+ 用法:
46
+ sidekick 和秘书对话(进去后输入一行回车发一条)
47
+ sidekick "今天有什么安排" 问一句,等回复打印完就退出
48
+ sidekick ask --wait 20 "…" 同上,收到第一条后最多再等 20 秒后续消息
49
+ sidekick login 登录(网页密码或设备令牌)
50
+ sidekick logout 删掉本机保存的登录态
51
+ sidekick help 这份说明
52
+
53
+ 参数:
54
+ --url <地址> 服务器地址,默认 ${DEFAULT_URL}
55
+ --alt <地址> 备用线路,主线路连不上时自动换,默认 ${DEFAULT_ALT}
56
+ --token <令牌> 设备令牌(部署 .env 的 SIDEKICK_DEVICE_TOKEN),跳过密码登录
57
+ --password <密码> 网页登录密码(不给就在终端里问)
58
+ --conv <会话> 指定会话:id、名字或 /conv 列表里的序号,默认和秘书的私聊
59
+ --device <名字> 这台机器的署名,默认「终端」
60
+ --wait <秒> 一次性模式:收到第一条后再等多久算说完,默认 6
61
+ --json 一次性模式:每条消息输出一行 JSON
62
+ --no-color 不上色
63
+ --plain 纯文本:不上色、不重画输入行(老终端/管道/日志里用)
64
+ -h, --help 这份说明
65
+ -v, --version 版本
66
+
67
+ 进交互后可用 /help 看命令,/conv 列会话,/to 切换,Ctrl-C 退出。
68
+
69
+ 环境变量(优先于配置文件):SIDEKICK_URL、SIDEKICK_ALT、SIDEKICK_TOKEN、SIDEKICK_PASSWORD、SIDEKICK_DEVICE、SIDEKICK_COOKIE。
70
+ 配置放在 ${configPath()}(权限 600)。`;
71
+
72
+ /** 命令行参数解析:node 自带的 parseArgs,不引第三方依赖。 */
73
+ function parseFlags(argv) {
74
+ const flags = { positional: [] };
75
+ const takesValue = new Set(['url', 'alt', 'token', 'password', 'conv', 'device', 'wait']);
76
+ for (let i = 0; i < argv.length; i += 1) {
77
+ const arg = argv[i];
78
+ if (arg === '--') {
79
+ flags.positional.push(...argv.slice(i + 1));
80
+ break;
81
+ }
82
+ if (arg === '-h' || arg === '--help') {
83
+ flags.help = true;
84
+ continue;
85
+ }
86
+ if (arg === '-v' || arg === '--version') {
87
+ flags.version = true;
88
+ continue;
89
+ }
90
+ if (arg === '--json') {
91
+ flags.json = true;
92
+ continue;
93
+ }
94
+ if (arg === '--no-color') {
95
+ flags.noColor = true;
96
+ continue;
97
+ }
98
+ if (arg === '--plain') {
99
+ flags.plain = true;
100
+ continue;
101
+ }
102
+ if (arg.startsWith('--')) {
103
+ const eq = arg.indexOf('=');
104
+ const name = eq >= 0 ? arg.slice(2, eq) : arg.slice(2);
105
+ if (!takesValue.has(name)) throw new Error(`不认识的参数:--${name}`);
106
+ const value = eq >= 0 ? arg.slice(eq + 1) : argv[++i];
107
+ if (value === undefined) throw new Error(`--${name} 缺一个值`);
108
+ flags[name] = value;
109
+ continue;
110
+ }
111
+ flags.positional.push(arg);
112
+ }
113
+ return flags;
114
+ }
115
+
116
+ const stdout = (text) => process.stdout.write(`${text}\n`);
117
+
118
+ function fail(message) {
119
+ process.stderr.write(`${message}\n`);
120
+ process.exitCode = 1;
121
+ }
122
+
123
+ /** 登录:有令牌就用令牌,否则拿网页密码换 Cookie,最后都用 `/v1/team/state` 验一次。 */
124
+ async function loginCommand(flags) {
125
+ const current = resolveSettings(flags);
126
+ const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY);
127
+ let url = stripTrailingSlash(String(flags.url || current.url || DEFAULT_URL));
128
+ if (interactive && !flags.url) {
129
+ url = stripTrailingSlash(await ask(`服务器地址 [${current.url}]:`, { fallback: current.url }));
130
+ }
131
+
132
+ let token = String(flags.token || '').trim();
133
+ let cookie = '';
134
+ if (!token) {
135
+ let password = String(flags.password || process.env.SIDEKICK_PASSWORD || '');
136
+ if (!password) {
137
+ if (!interactive) throw new Error('没有终端可问密码:请用 --password 或 --token');
138
+ password = await askPassword('网页登录密码(和网页版同一个 CORTICO_WEB_PASSWORD):');
139
+ if (!password) throw new Error('没输入密码');
140
+ }
141
+ cookie = await loginWithPassword(url, password);
142
+ }
143
+
144
+ const api = new SidekickApi({ url, alt: current.alt, token, cookie });
145
+ const state = await api.state();
146
+ const primary = state.bots.find((b) => b.primary);
147
+ const cfg = { ...readConfig(), url, alt: current.alt, device: current.device || '终端', savedAt: new Date().toISOString() };
148
+ if (token) {
149
+ cfg.token = token;
150
+ delete cfg.cookie;
151
+ } else {
152
+ cfg.cookie = cookie;
153
+ delete cfg.token;
154
+ }
155
+ writeConfig(cfg);
156
+ stdout(`登录成功:${url}`);
157
+ stdout(`主 Bot:${primary?.name || state.primary};会话 ${state.conversations.length} 个。凭据存在 ${configPath()}`);
158
+ return { url, token, cookie };
159
+ }
160
+
161
+ /** 一次性模式:发一句,把回复打印完就退出,方便脚本里用。 */
162
+ async function askOnce(flags, text) {
163
+ const settings = resolveSettings(flags);
164
+ if (!settings.loggedIn) throw new AuthError('还没登录,先运行 sidekick login');
165
+ const api = new SidekickApi(settings);
166
+ const state = await api.state();
167
+ const conv = pickConv(state, settings.conv);
168
+ const idleMs = Math.max(1, Number(flags.wait || 6) || 6) * 1000;
169
+ const sent = await api.send({ conv, text, device: settings.device });
170
+
171
+ const ctl = new AbortController();
172
+ const got = [];
173
+ let idleTimer = null;
174
+ let guardTimer = null;
175
+ let finishing = false;
176
+ const done = new Promise((resolve) => {
177
+ const finish = () => {
178
+ if (finishing) return;
179
+ finishing = true;
180
+ clearTimeout(idleTimer);
181
+ clearTimeout(guardTimer);
182
+ ctl.abort();
183
+ resolve();
184
+ };
185
+ ctl.signal.addEventListener('abort', finish, { once: true });
186
+ // 一直没回复也别吊着:默认 5 分钟(按 idle 时间的两倍放宽)。
187
+ guardTimer = setTimeout(finish, Math.max(120_000, idleMs * 2));
188
+ });
189
+
190
+ api
191
+ .stream({
192
+ after: sent.seq,
193
+ device: settings.device,
194
+ signal: ctl.signal,
195
+ onEvent(event, payload) {
196
+ if (event === 'message') {
197
+ if (payload.conv !== conv || payload.from === 'user') return;
198
+ got.push(payload);
199
+ if (flags.json) stdout(JSON.stringify(payload));
200
+ else stdout(plainMessage(payload, state.bots));
201
+ clearTimeout(idleTimer);
202
+ idleTimer = setTimeout(() => ctl.abort(), idleMs);
203
+ } else if (event === 'busy' && conv.startsWith('dm:') && payload.busy === false && got.length > 0) {
204
+ // 私聊里这一轮结束了,不用再等 idle;群里别的成员还在忙,只认 idle。
205
+ ctl.abort();
206
+ }
207
+ },
208
+ })
209
+ .catch((err) => {
210
+ if (!ctl.signal.aborted) fail(`推流出错:${err instanceof Error ? err.message : String(err)}`);
211
+ });
212
+
213
+ await done;
214
+ if (got.length === 0) {
215
+ fail('没等到回复。检查服务器是否在跑,或者用 sidekick 进交互看看。');
216
+ return;
217
+ }
218
+ }
219
+
220
+ /** 按 id、名字或列表序号找一个会话;找不到返回 undefined。 */
221
+ export function findConv(state, wanted) {
222
+ const ref = String(wanted || '').trim();
223
+ if (!ref) return undefined;
224
+ const byId = state.conversations.find((c) => c.id === ref);
225
+ if (byId) return byId;
226
+ const byTitle = state.conversations.find((c) => c.title === ref);
227
+ if (byTitle) return byTitle;
228
+ const index = Number(ref);
229
+ if (Number.isInteger(index) && index > 0 && index <= state.conversations.length) return state.conversations[index - 1];
230
+ return undefined;
231
+ }
232
+
233
+ /** 按 id、名字或列表序号挑会话;挑不到就退回和主 Bot 的私聊。 */
234
+ export function pickConv(state, wanted) {
235
+ const primaryConv = `dm:${state.primary}`;
236
+ const found = findConv(state, wanted);
237
+ if (found) return found.id;
238
+ if (state.conversations.some((c) => c.id === primaryConv)) return primaryConv;
239
+ return state.conversations[0]?.id ?? primaryConv;
240
+ }
241
+
242
+ /** 交互模式。 */
243
+ async function startChat(flags) {
244
+ let settings = resolveSettings(flags);
245
+ if (!settings.loggedIn) {
246
+ if (!process.stdin.isTTY) throw new AuthError('还没登录,先运行 sidekick login');
247
+ await loginCommand({ ...flags, url: flags.url || settings.url });
248
+ settings = resolveSettings(flags);
249
+ }
250
+
251
+ let api = new SidekickApi(settings);
252
+ let state;
253
+ try {
254
+ state = await api.state();
255
+ } catch (err) {
256
+ if (err instanceof AuthError) {
257
+ if (!process.stdin.isTTY) throw new AuthError('登录态已失效,重新运行 sidekick login');
258
+ await loginCommand({ ...flags, url: settings.url });
259
+ settings = resolveSettings(flags);
260
+ api = new SidekickApi(settings);
261
+ state = await api.state();
262
+ } else {
263
+ throw err;
264
+ }
265
+ }
266
+
267
+ // 老终端(不认 ANSI)和管道里别用转义序列:重画输入行会留下「[K」这类垃圾。
268
+ const vt = Boolean(process.stdout.isTTY && process.stdin.isTTY) && process.env.TERM !== 'dumb' && !flags.plain && process.env.SIDEKICK_PLAIN !== '1';
269
+ const color = flags.noColor || !vt ? false : colorsEnabled();
270
+ const convTitle = (id) => state.conversations.find((c) => c.id === id)?.title || id;
271
+ const convOf = (id) => state.conversations.find((c) => c.id === id);
272
+ let convId = pickConv(state, settings.conv);
273
+ let latest = state.latest || 0;
274
+ let everConnected = false;
275
+ // 「有人在看」才推进已读:调试时终端可以一直挂在那儿,而挂着的终端不该把手机上刚弹出的通知顶掉。
276
+ // 有按键才算在场(120 秒内),不在场时只把进度攒在 seenSeq 里,等下次动手再一起报。
277
+ const PRESENT_MS = 120_000;
278
+ // 0 = 启动以来还没人碰过这台终端:刚起来的窗口也不算在场,只靠 printHistory 那个显式动作推一次。
279
+ let lastInputAt = 0;
280
+ let seenSeq = 0;
281
+ const displayed = new Set();
282
+ const ctl = new AbortController();
283
+ let rl = null;
284
+ let closed = false;
285
+ let ui = null;
286
+ let streamTask = Promise.resolve();
287
+ const quotas = createQuotaReader(settings);
288
+ let pendingImages = [];
289
+ function addImage(image) {
290
+ if (pendingImages.length >= 4) throw new Error('每条消息最多 4 张图片,先发送或 /drop 清除');
291
+ pendingImages.push(image);
292
+ ui?.attachments(pendingImages.length);
293
+ if (!ui) out(`待发送图片 ${pendingImages.length}/4;/send 发送,/drop 清除`);
294
+ }
295
+
296
+ const out = (text) => {
297
+ if (ui) ui.out(text);
298
+ else process.stdout.write(`${text}\n`);
299
+ };
300
+ const typing = (on) => ui?.typing(on);
301
+
302
+ /** 把「已经打到屏幕上」的最新 seq 报成已读。 */
303
+ function reportSeen() {
304
+ if (!seenSeq) return;
305
+ const seq = seenSeq;
306
+ seenSeq = 0;
307
+ api.read(convId, seq).catch(() => {});
308
+ }
309
+
310
+ /** 用户在这台终端上动了一下(敲键、发消息、切会话):算在场,并把攒着的已读补报上去。 */
311
+ function markPresent() {
312
+ lastInputAt = Date.now();
313
+ reportSeen();
314
+ }
315
+
316
+ const present = () => lastInputAt > 0 && Date.now() - lastInputAt < PRESENT_MS;
317
+
318
+ const printMessage = (message) => {
319
+ const options = { bots: state.bots, color, imageUrl: (name) => api.imageUrl(name) };
320
+ if (ui) ui.message(message, options);
321
+ else out(formatMessage(message, options));
322
+ };
323
+ if (vt) {
324
+ const { createChatUI } = await import('./tui.mjs');
325
+ ui = createChatUI({ color, hasImages: () => pendingImages.length > 0,
326
+ onPasteImage: () => { queue.push(async () => addImage(await clipboardImage())); void drain(); },
327
+ onInput: () => markPresent(), onSubmit: (text) => {
328
+ markPresent();
329
+ queue.push(() => text.startsWith('/') ? handleSlash(text) : sendText(text));
330
+ void drain();
331
+ }, onQuit: () => shutdown(0) });
332
+ }
333
+
334
+ async function printHistory(limit = 20) {
335
+ const body = await api.history(convId, limit);
336
+ let day = '';
337
+ for (const m of body.messages) {
338
+ const d = dayLabel(m.ts);
339
+ if (d !== day) {
340
+ out(paint(color, 'gray', `──── ${d} ────`));
341
+ day = d;
342
+ }
343
+ printMessage(m);
344
+ }
345
+ latest = Math.max(latest, body.latest || 0);
346
+ // 刚把这段历史打到屏幕上(启动、切会话、/history),已读位置就到这里。
347
+ seenSeq = 0;
348
+ if (body.latest) api.read(convId, body.latest).catch(() => {});
349
+ if (body.messages.length === 0) out(paint(color, 'gray', '(这里还没有消息)'));
350
+ }
351
+
352
+ const primary = state.bots.find((b) => b.primary);
353
+ out(`${paint(color, 'bold', `Sidekick · ${primary?.name || state.primary}`)} ${paint(color, 'gray', settings.url)}`);
354
+ if (settings.conv && !findConv(state, settings.conv)) {
355
+ out(paint(color, 'yellow', `找不到会话「${settings.conv}」,先用「${convTitle(convId)}」;/conv 看全部`));
356
+ }
357
+ out(paint(color, 'gray', `会话:${convTitle(convId)} · /help 看命令,/conv 列会话,Ctrl-C 退出`));
358
+ await printHistory(30);
359
+
360
+ function applyState(next) {
361
+ if (!next) return;
362
+ state = next;
363
+ if (!convOf(convId)) {
364
+ ui?.clearDrafts();
365
+ convId = pickConv(state, '');
366
+ }
367
+ }
368
+
369
+ function onMessage(m) {
370
+ latest = Math.max(latest, m.seq || 0);
371
+ typing(false);
372
+ if (displayed.has(m.seq)) return;
373
+ displayed.add(m.seq);
374
+ if (displayed.size > 1000) displayed.delete(displayed.values().next().value);
375
+ if (m.conv !== convId) {
376
+ if (m.from !== 'user') {
377
+ out(paint(color, 'gray', `· ${convTitle(m.conv)} 有新消息(${oneLine(m.text, 40) || '图片'}),/to 切过去`));
378
+ }
379
+ return;
380
+ }
381
+ printMessage(m);
382
+ if (typeof m.seq === 'number') {
383
+ if (present()) api.read(convId, m.seq).catch(() => {});
384
+ else seenSeq = Math.max(seenSeq, m.seq);
385
+ }
386
+ }
387
+
388
+ streamTask = api
389
+ .stream({
390
+ after: latest,
391
+ device: settings.device,
392
+ signal: ctl.signal,
393
+ onStatus: (kind) => {
394
+ if (kind === 'closed' || kind === 'error') ui?.clearDrafts();
395
+ // 只在「连着的时候断了」和「重新接上」各说一句,退避重试期间不刷屏。
396
+ if (kind === 'open') {
397
+ if (everConnected) out(paint(color, 'green', '已重新连上'));
398
+ everConnected = true;
399
+ } else if (kind === 'error' && everConnected) {
400
+ everConnected = false;
401
+ out(paint(color, 'red', '连接断了,正在重连…'));
402
+ }
403
+ },
404
+ onEvent(event, payload) {
405
+ if (event === 'hello' || event === 'state') applyState(payload.state ?? payload);
406
+ else if (event === 'message') onMessage(payload);
407
+ else if (event === 'draft' && ui) {
408
+ // End frames have no conversation; remove only the matching sender/id.
409
+ if (payload.end || payload.conv === convId) ui.draft(payload, { bots: state.bots, color });
410
+ else ui.draft({ ...payload, end: true }, { bots: state.bots, color });
411
+ }
412
+ else if (event === 'busy') typing(Boolean(payload.busy));
413
+ },
414
+ })
415
+ .catch((err) => {
416
+ if (ctl.signal.aborted) return;
417
+ out(err instanceof AuthError ? '登录态已失效,重新运行 sidekick login' : `推流出错:${err.message}`);
418
+ shutdown(1);
419
+ });
420
+
421
+ function switchConv(next) {
422
+ if (pendingImages.length) throw new Error('还有待发送图片,请先发送或 /drop 清除再切换会话');
423
+ ui?.clearDrafts();
424
+ markPresent();
425
+ convId = next;
426
+ const cfg = { ...readConfig(), url: settings.url, conv: convId, device: settings.device };
427
+ writeConfig(cfg);
428
+ out(paint(color, 'green', `→ ${convTitle(convId)}`));
429
+ return printHistory(30);
430
+ }
431
+
432
+ async function handleSlash(line) {
433
+ const [raw, ...rest] = line.slice(1).trim().split(/\s+/);
434
+ const cmd = (raw || '').toLowerCase();
435
+ const arg = rest.join(' ');
436
+ switch (cmd) {
437
+ case 'paste':
438
+ addImage(await clipboardImage());
439
+ return;
440
+ case 'image':
441
+ if (!arg) throw new Error('用法:/image 图片路径');
442
+ addImage(await imageFile(arg));
443
+ return;
444
+ case 'drop':
445
+ pendingImages = [];
446
+ ui?.attachments(0);
447
+ out('已清除待发送图片');
448
+ return;
449
+ case 'send':
450
+ await sendText(arg);
451
+ return;
452
+ case 'help':
453
+ case '?':
454
+ out([
455
+ '/conv 列出会话',
456
+ '/to <序号|名字> 切换会话',
457
+ '/history [n] 重印最近 n 条(默认 20)',
458
+ '/who 名册和连接状态',
459
+ '/usage 查询额度,查看剩余比例和重置时间',
460
+ '/paste 添加剪贴板图片(也可 Ctrl+V)',
461
+ '/image <路径> 添加图片文件,最多 4 张、每张 6 MB',
462
+ '/send [文字] 发送待发送图片;也可直接输入文字或回车',
463
+ '/drop 清除待发送图片',
464
+ '/clear 清屏',
465
+ '/quit 退出(也可以 Ctrl-C)',
466
+ '直接输入文字就是发给当前会话。',
467
+ ].join('\n'));
468
+ return;
469
+ case 'quit':
470
+ case 'exit':
471
+ case 'q':
472
+ shutdown(0);
473
+ return;
474
+ case 'conv':
475
+ case 'convs':
476
+ case 'ls':
477
+ out(
478
+ state.conversations
479
+ .map((c, i) => formatConversation(c, i + 1, { bots: state.bots, current: convId }))
480
+ .join('\n') || '(还没有会话)',
481
+ );
482
+ return;
483
+ case 'to':
484
+ case 'switch':
485
+ if (!arg) {
486
+ out('用法:/to <序号或名字>,先 /conv 看一眼');
487
+ return;
488
+ }
489
+ await switchConv(pickConv(state, arg));
490
+ return;
491
+ case 'history':
492
+ case 'h':
493
+ await printHistory(Math.max(1, Number(arg) || 20));
494
+ return;
495
+ case 'who':
496
+ out(
497
+ state.bots
498
+ .map((b) => `${b.primary ? '*' : ' '}${b.name}(${b.id}) ${b.status}${b.busy ? ' 正在忙' : ''} — ${b.role || '无专长'}`)
499
+ .join('\n'),
500
+ );
501
+ return;
502
+ case 'usage':
503
+ case 'quota':
504
+ out(formatQuota(await quotas.refresh(), { detail: true }));
505
+ return;
506
+ case 'clear':
507
+ if (ui) ui.clear();
508
+ else out('(清屏已跳过:纯文本模式下不动终端)');
509
+ return;
510
+ default:
511
+ out(paint(color, 'red', `不认识的命令:/${cmd}(/help 看命令)`));
512
+ }
513
+ }
514
+
515
+ const queue = [];
516
+ let busy = false;
517
+ async function drain() {
518
+ if (busy) return;
519
+ const next = queue.shift();
520
+ if (next === undefined) return;
521
+ busy = true;
522
+ try {
523
+ await next();
524
+ } catch (err) {
525
+ out(paint(color, 'red', err instanceof Error ? err.message : String(err)));
526
+ } finally {
527
+ busy = false;
528
+ void drain();
529
+ }
530
+ }
531
+
532
+ async function sendText(text) {
533
+ const images = pendingImages.slice();
534
+ const res = await api.send({ conv: convId, text, device: settings.device, ...(images.length ? { images } : {}) });
535
+ pendingImages = [];
536
+ ui?.attachments(0);
537
+ latest = Math.max(latest, res.seq);
538
+ if (displayed.has(res.seq)) return;
539
+ displayed.add(res.seq);
540
+ printMessage({ seq: res.seq, conv: convId, ts: Date.now(), from: 'user', text, images: res.images });
541
+ }
542
+
543
+ if (ui) {
544
+ ui.start();
545
+ } else {
546
+ rl = createInterface({ input: process.stdin, output: process.stdout, terminal: false });
547
+ rl.on('line', (line) => {
548
+ const text = line.trim();
549
+ if (!text) return;
550
+ markPresent();
551
+ queue.push(() => (text.startsWith('/') ? handleSlash(text) : sendText(text)));
552
+ void drain();
553
+ });
554
+ rl.on('SIGINT', () => shutdown(0));
555
+ rl.on('close', () => {
556
+ if (!closed) shutdown(0);
557
+ });
558
+ }
559
+
560
+ function shutdown(code = 0) {
561
+ if (closed) return;
562
+ closed = true;
563
+ reportSeen();
564
+ typing(false);
565
+ quotas.stop();
566
+ ui?.stop();
567
+ ctl.abort();
568
+ try {
569
+ rl?.close();
570
+ } catch {
571
+ /* 已经关了 */
572
+ }
573
+ process.exitCode = code;
574
+ void streamTask.catch(() => {});
575
+ // 让事件循环自己收尾:Windows 上在 TLS 句柄还没关完时就 process.exit(),
576
+ // 会让 libuv 报「!(handle->flags & UV_HANDLE_CLOSING)」直接崩掉。
577
+ // 万一有句柄赖着不放,1.5 秒后强退。
578
+ setTimeout(() => process.exit(code), 1500).unref();
579
+ }
580
+ }
581
+
582
+ export async function run(argv = process.argv.slice(2)) {
583
+ let flags;
584
+ try {
585
+ flags = parseFlags(argv);
586
+ } catch (err) {
587
+ fail(`${err.message}\n\n${HELP}`);
588
+ return;
589
+ }
590
+ if (flags.help) {
591
+ stdout(HELP);
592
+ return;
593
+ }
594
+ if (flags.version) {
595
+ stdout(PACKAGE_VERSION);
596
+ return;
597
+ }
598
+ if (flags.noColor) process.env.NO_COLOR = '1';
599
+
600
+ const command = flags.positional[0];
601
+ const rest = flags.positional.slice(1);
602
+
603
+ process.on('unhandledRejection', (err) => {
604
+ fail(err instanceof Error ? err.message : String(err));
605
+ process.exit(1);
606
+ });
607
+
608
+ try {
609
+ if (command === 'help') {
610
+ stdout(HELP);
611
+ return;
612
+ }
613
+ if (command === 'login') {
614
+ await loginCommand(flags);
615
+ return;
616
+ }
617
+ if (command === 'logout') {
618
+ clearConfig();
619
+ stdout(`已经删掉本机保存的登录态(${configPath()})`);
620
+ return;
621
+ }
622
+ if (command === 'ask') {
623
+ const text = rest.join(' ').trim();
624
+ if (!text) throw new Error('用法:sidekick ask "要说的话"');
625
+ await askOnce(flags, text);
626
+ return;
627
+ }
628
+ if (command) {
629
+ // 没有子命令就当成一句话发出去:sidekick "今天有什么安排"
630
+ await askOnce(flags, flags.positional.join(' ').trim());
631
+ return;
632
+ }
633
+ await startChat(flags);
634
+ } catch (err) {
635
+ if (err instanceof AuthError) {
636
+ fail(`${err.message}`);
637
+ process.exitCode = 2;
638
+ return;
639
+ }
640
+ fail(err instanceof Error ? err.message : String(err));
641
+ }
642
+ }