neodrop-cli 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,629 @@
1
+ #!/usr/bin/env node
2
+ // neodrop CLI 入口。AI agent 与人类共用——直接打 Neodrop tRPC HTTP 接口
3
+ // (Bearer PAT 鉴权),不走 MCP。
4
+ //
5
+ // 调用:
6
+ // npx neodrop-cli <command> [args...]
7
+ // 或全局安装后:neodrop <command> [args...]
8
+ //
9
+ // 输出:
10
+ // stdout = JSON(AI 直接 JSON.parse);--pretty 切缩进 JSON 给人看
11
+ // stderr = 日志 / 进度 / 错误描述
12
+ // 退出码:0 成功 / 1 业务错误 / 2 参数错误
13
+
14
+ import { hostname } from "node:os";
15
+ import { parseArgs } from "node:util";
16
+ import { ApiError, trpcMutation, trpcQuery } from "../lib/api.mjs";
17
+ import {
18
+ clearCredentials,
19
+ credentialsPath,
20
+ readCredentials,
21
+ requireCredentials,
22
+ writeCredentials,
23
+ } from "../lib/credentials.mjs";
24
+ import { defaultSkillDest, installSkill } from "../lib/install-skill.mjs";
25
+ import { inferApiOrigin } from "../lib/origins.mjs";
26
+ import { emit, note, setPretty } from "../lib/output.mjs";
27
+ import { channelUrl, postUrl, userUrl } from "../lib/web-urls.mjs";
28
+
29
+ const DEFAULT_SERVER = process.env.NEODROP_SERVER || "https://neodrop.ai";
30
+ const ENV_API_OVERRIDE = process.env.NEODROP_API;
31
+
32
+ // 用法错误 → 退出码 2(区别于业务错误 1)。
33
+ class UsageError extends Error {}
34
+
35
+ function sleep(ms) {
36
+ return new Promise((resolve) => setTimeout(resolve, ms));
37
+ }
38
+
39
+ // 客户端标识——授权页和 settings/cli-tokens 上显示给用户辨认。
40
+ function detectClientName() {
41
+ const host = hostname() || "host";
42
+ const term = process.env.TERM_PROGRAM || "";
43
+ if (process.env.CLAUDECODE) return `Claude Code @ ${host}`;
44
+ if (process.env.CURSOR_TRACE_ID) return `Cursor @ ${host}`;
45
+ if (term === "vscode") return `VS Code @ ${host}`;
46
+ if (term === "WarpTerminal") return `Warp @ ${host}`;
47
+ return `neodrop-cli @ ${host}`;
48
+ }
49
+
50
+ function authedCtx() {
51
+ const creds = requireCredentials();
52
+ return { apiOrigin: creds.apiOrigin, token: creds.token, creds };
53
+ }
54
+
55
+ async function readStdin() {
56
+ const chunks = [];
57
+ for await (const chunk of process.stdin) chunks.push(chunk);
58
+ return Buffer.concat(chunks).toString("utf-8");
59
+ }
60
+
61
+ // `--json '<input>'` / `--stdin` 二选一(互斥,调用前已校验),都没给则返回 undefined。
62
+ async function loadInput(values) {
63
+ if (values.json) {
64
+ try {
65
+ return JSON.parse(values.json);
66
+ } catch (err) {
67
+ throw new UsageError(`--json 解析失败:${err.message}`);
68
+ }
69
+ }
70
+ if (values.stdin) {
71
+ return JSON.parse(await readStdin());
72
+ }
73
+ return undefined;
74
+ }
75
+
76
+ // parseArgs 包装:未知 flag / 缺值统一转成 UsageError(退出码 2)。
77
+ function parse(argv, options) {
78
+ try {
79
+ return parseArgs({ args: argv, options, allowPositionals: true, strict: true });
80
+ } catch (err) {
81
+ throw new UsageError(err.message);
82
+ }
83
+ }
84
+
85
+ function toLimit(value) {
86
+ if (value === undefined) return undefined;
87
+ const n = Number(value);
88
+ if (!Number.isFinite(n)) throw new UsageError(`--limit 必须是数字,收到「${value}」`);
89
+ return n;
90
+ }
91
+
92
+ function requirePositional(positionals, index, hint) {
93
+ const value = positionals[index];
94
+ if (value === undefined) throw new UsageError(hint);
95
+ return value;
96
+ }
97
+
98
+ // ---- 元命令 -----------------------------------------------------------
99
+
100
+ // 统一登录:session polling 模式。
101
+ // 1. startSession 拿到 sessionId + pollSecret + verification URL
102
+ // (pollSecret 只在本进程内持有,是 poll 领 token 的唯一凭据,不进 URL)
103
+ // 2. 打印 URL 给用户复制到浏览器(不自动拉起,不开本地 server,无 callback)
104
+ // 3. 带 pollSecret 轮询 pollSession 直到 APPROVED / DENIED / EXPIRED
105
+ // 4. 拿到 token 写入 ~/.neodrop/credentials.json
106
+ async function cmdLogin(argv) {
107
+ const { values } = parse(argv, {
108
+ server: { type: "string" },
109
+ api: { type: "string" },
110
+ name: { type: "string" },
111
+ });
112
+
113
+ const webOrigin = (values.server || DEFAULT_SERVER).replace(/\/+$/, "");
114
+ // --api 显式 > NEODROP_API env > 启发式推断
115
+ const apiOrigin = values.api || ENV_API_OVERRIDE || inferApiOrigin(webOrigin);
116
+ const clientName = values.name || detectClientName();
117
+
118
+ note(`web = ${webOrigin}`);
119
+ note(`api = ${apiOrigin}`);
120
+
121
+ // 1. 起 session
122
+ const session = await trpcMutation({ apiOrigin, token: null }, "cliToken.startSession", {
123
+ clientName,
124
+ webOrigin,
125
+ });
126
+ const sessionId = session.sessionId;
127
+ // pollSecret 是后端只下发给本 CLI 进程的私有领取凭据,不进 verification URL;
128
+ // poll 时必须回传它才能领到 token。URL 里只有 sessionId,截图/转发泄漏也领不走 token。
129
+ const pollSecret = session.pollSecret;
130
+ const verificationUrl = session.verificationUrl;
131
+ const pollInterval = Math.max(1, Number(session.pollIntervalSeconds || 2));
132
+
133
+ note("");
134
+ note("👉 在任意浏览器(手机 / 笔记本 / 同机都行)打开下面 URL 完成授权:");
135
+ note("");
136
+ // URL 顶格单独成行(不加缩进):这条 URL 含 ?session= 256bit 串、必然超 80 列,
137
+ // 终端会折行。顶格单独一行最便于「三击选整行 / 鼠标拖整行」一次性把完整 URL 复制走。
138
+ note(verificationUrl);
139
+ note("");
140
+ note(" (URL 较长会折行,复制时连同结尾的 ?session=... 一起选全)");
141
+ note(` 客户端名「${clientName}」(在授权页确认是本次启动的 CLI)`);
142
+ note(" 授权链接 10 分钟内有效。授权后回到这个终端继续——CLI 会自动检测。");
143
+ note("");
144
+
145
+ // 2. 轮询
146
+ const deadline = Date.now() + 10 * 60 * 1000; // 与后端 session 寿命对齐
147
+ let waitedDots = 0;
148
+ let token = null;
149
+ let tokenId = null;
150
+ let expiresAt = null;
151
+ while (Date.now() < deadline) {
152
+ await sleep(pollInterval * 1000);
153
+ let res;
154
+ try {
155
+ res = await trpcQuery({ apiOrigin, token: null }, "cliToken.pollSession", {
156
+ sessionId,
157
+ pollSecret,
158
+ });
159
+ } catch (err) {
160
+ // NOT_FOUND 一般是 session 已被后端清理;其它错误也直接抛
161
+ throw new Error(`轮询授权失败:${err.message}`);
162
+ }
163
+
164
+ const status = res.status;
165
+ if (status === "APPROVED") {
166
+ if (res.alreadyClaimed) {
167
+ throw new Error(
168
+ "授权 token 已被领走(极少触发,正常应是本 CLI 自己领)。请重新 `npx neodrop-cli login`。",
169
+ );
170
+ }
171
+ token = res.token;
172
+ tokenId = res.tokenId;
173
+ const ea = res.tokenExpiresAt;
174
+ expiresAt = typeof ea === "string" ? ea : ea ? String(ea) : null;
175
+ if (!token) throw new Error("授权返回 APPROVED 但缺 token;请重新 `npx neodrop-cli login`");
176
+ break;
177
+ }
178
+ if (status === "DENIED") {
179
+ throw new Error("授权被用户拒绝。如有需要请重新 `npx neodrop-cli login` 并核对客户端名。");
180
+ }
181
+ if (status === "EXPIRED") {
182
+ throw new Error("授权链接已过期(10 分钟内未授权)。请重新 `npx neodrop-cli login`。");
183
+ }
184
+ // 还在 PENDING — 打点进度(每 5 次 poll 一个点,不刷屏)
185
+ waitedDots += 1;
186
+ if (waitedDots % 5 === 0) note(".", "");
187
+ }
188
+
189
+ if (!token) throw new Error("等待授权超时。请重新 `npx neodrop-cli login`。");
190
+
191
+ note("");
192
+
193
+ // 3. 写凭证
194
+ writeCredentials({
195
+ webOrigin,
196
+ apiOrigin,
197
+ token,
198
+ tokenId: tokenId || "",
199
+ name: clientName,
200
+ expiresAt: expiresAt || "",
201
+ createdAt: new Date().toISOString(),
202
+ });
203
+
204
+ // 4. 校验 + 输出
205
+ const me = await trpcQuery({ apiOrigin, token }, "user.getMe");
206
+ note(`✅ 登录成功:${me.email || me.id || "<unknown>"}`);
207
+ note(` credentials = ${credentialsPath()}`);
208
+ emit({
209
+ ok: true,
210
+ webOrigin,
211
+ apiOrigin,
212
+ user: me,
213
+ tokenId,
214
+ tokenName: clientName,
215
+ expiresAt,
216
+ });
217
+ }
218
+
219
+ async function cmdLogout() {
220
+ const creds = readCredentials();
221
+ if (creds === null) {
222
+ note("未登录,无需登出。");
223
+ emit({ ok: true, alreadyLoggedOut: true });
224
+ return;
225
+ }
226
+ let revoked = false;
227
+ try {
228
+ await trpcMutation({ apiOrigin: creds.apiOrigin, token: creds.token }, "cliToken.revoke", {
229
+ id: creds.tokenId,
230
+ });
231
+ revoked = true;
232
+ note(`✅ 已撤销 ${creds.tokenId}`);
233
+ } catch (err) {
234
+ note(`⚠ 撤销失败(继续清本地凭证):${err.message}`);
235
+ }
236
+ clearCredentials();
237
+ emit({ ok: true, revoked });
238
+ }
239
+
240
+ async function cmdWhoami() {
241
+ const { apiOrigin, token, creds } = authedCtx();
242
+ const me = await trpcQuery({ apiOrigin, token }, "user.getMe");
243
+ emit({
244
+ webOrigin: creds.webOrigin,
245
+ apiOrigin,
246
+ tokenName: creds.name,
247
+ tokenId: creds.tokenId,
248
+ expiresAt: creds.expiresAt,
249
+ user: me,
250
+ });
251
+ }
252
+
253
+ async function cmdMe() {
254
+ const { apiOrigin, token, creds } = authedCtx();
255
+ const me = await trpcQuery({ apiOrigin, token }, "user.getMe");
256
+ emit(me);
257
+ if (me && typeof me === "object" && me.id) {
258
+ note(`🔗 ${userUrl(creds.webOrigin, me.id)}`);
259
+ }
260
+ }
261
+
262
+ async function cmdTokensList() {
263
+ const { apiOrigin, token } = authedCtx();
264
+ emit(await trpcQuery({ apiOrigin, token }, "cliToken.list"));
265
+ }
266
+
267
+ async function cmdTokensRevoke(argv) {
268
+ const { positionals } = parse(argv, {});
269
+ const id = requirePositional(positionals, 0, "用法:neodrop tokens revoke <id>");
270
+ const { apiOrigin, token, creds } = authedCtx();
271
+ const r = await trpcMutation({ apiOrigin, token }, "cliToken.revoke", { id });
272
+ if (id === creds.tokenId) {
273
+ clearCredentials();
274
+ note("(撤销的是本机当前 token,已清除本地凭证。需要重新 neodrop login。)");
275
+ }
276
+ emit(r);
277
+ }
278
+
279
+ // ---- 频道命令 ---------------------------------------------------------
280
+
281
+ async function cmdChannelsList(argv) {
282
+ const { values } = parse(argv, {
283
+ mine: { type: "boolean" },
284
+ limit: { type: "string" },
285
+ cursor: { type: "string" },
286
+ locale: { type: "string", default: "en" }, // 缺省 en,与 Web 默认 locale 一致
287
+ });
288
+ const { apiOrigin, token } = authedCtx();
289
+ if (values.mine) {
290
+ emit(await trpcQuery({ apiOrigin, token }, "channel.getMyChannels"));
291
+ return;
292
+ }
293
+ const payload = { limit: toLimit(values.limit) ?? 20, locale: values.locale };
294
+ if (values.cursor) payload.cursor = values.cursor;
295
+ emit(await trpcQuery({ apiOrigin, token }, "channel.list", payload));
296
+ }
297
+
298
+ async function cmdChannelsGet(argv) {
299
+ const { positionals } = parse(argv, {});
300
+ const id = requirePositional(positionals, 0, "用法:neodrop channels get <channelId>");
301
+ const { apiOrigin, token, creds } = authedCtx();
302
+ emit(await trpcQuery({ apiOrigin, token }, "channel.getById", { id }));
303
+ note(`🔗 ${channelUrl(creds.webOrigin, id)}`);
304
+ }
305
+
306
+ async function cmdChannelsCreate(argv) {
307
+ const { values } = parse(argv, {
308
+ name: { type: "string" },
309
+ description: { type: "string" },
310
+ type: { type: "string" },
311
+ locale: { type: "string" },
312
+ json: { type: "string" },
313
+ stdin: { type: "boolean" },
314
+ });
315
+ if (values.json && values.stdin) {
316
+ throw new UsageError("--json 与 --stdin 互斥,只能给一个");
317
+ }
318
+ if (values.type && values.type !== "PUBLIC" && values.type !== "PRIVATE") {
319
+ throw new UsageError("--type 只能是 PUBLIC 或 PRIVATE");
320
+ }
321
+ const { apiOrigin, token } = authedCtx();
322
+ let input = await loadInput(values);
323
+ if (input === undefined) {
324
+ if (!values.name) {
325
+ throw new UsageError(
326
+ "用法:neodrop channels create --name <X> [--description <Y>] [--type PUBLIC|PRIVATE] [--locale zh-cn]\n" +
327
+ "或:neodrop channels create --json '{\"name\":\"X\",\"locale\":\"zh-cn\"}'\n" +
328
+ "或:neodrop channels create --stdin",
329
+ );
330
+ }
331
+ input = { name: values.name };
332
+ if (values.description) input.description = values.description;
333
+ if (values.type) input.type = values.type;
334
+ if (values.locale) input.locale = values.locale;
335
+ }
336
+ emit(await trpcMutation({ apiOrigin, token }, "channel.create", input));
337
+ }
338
+
339
+ async function cmdChannelsSubscribe(argv) {
340
+ const { positionals } = parse(argv, {});
341
+ const id = requirePositional(positionals, 0, "用法:neodrop channels subscribe <channelId>");
342
+ const { apiOrigin, token } = authedCtx();
343
+ emit(await trpcMutation({ apiOrigin, token }, "channel.subscribe", { channelId: id }));
344
+ }
345
+
346
+ async function cmdChannelsUnsubscribe(argv) {
347
+ const { positionals } = parse(argv, {});
348
+ const id = requirePositional(positionals, 0, "用法:neodrop channels unsubscribe <channelId>");
349
+ const { apiOrigin, token } = authedCtx();
350
+ emit(await trpcMutation({ apiOrigin, token }, "channel.unsubscribe", { channelId: id }));
351
+ }
352
+
353
+ async function cmdChannelsSearch(argv) {
354
+ const { values, positionals } = parse(argv, {
355
+ limit: { type: "string" },
356
+ locale: { type: "string" },
357
+ strict: { type: "boolean" },
358
+ });
359
+ const query = requirePositional(positionals, 0, '用法:neodrop channels search "<query>"');
360
+ const { apiOrigin, token } = authedCtx();
361
+ const payload = { query };
362
+ const limit = toLimit(values.limit);
363
+ if (limit !== undefined) payload.limit = limit;
364
+ if (values.locale) payload.locale = values.locale;
365
+ if (values.strict) payload.strictLocale = true;
366
+ emit(await trpcQuery({ apiOrigin, token }, "channel.searchPublic", payload));
367
+ }
368
+
369
+ async function cmdChannelsCategories() {
370
+ const { apiOrigin, token } = authedCtx();
371
+ emit(await trpcQuery({ apiOrigin, token }, "channel.getCategories"));
372
+ }
373
+
374
+ async function cmdChannelsByCategory(argv) {
375
+ const { values, positionals } = parse(argv, {
376
+ limit: { type: "string" },
377
+ cursor: { type: "string" },
378
+ locale: { type: "string" },
379
+ sort: { type: "string" },
380
+ });
381
+ const slug = requirePositional(positionals, 0, "用法:neodrop channels by-category <slug>");
382
+ if (values.sort && values.sort !== "latest" && values.sort !== "popular") {
383
+ throw new UsageError("--sort 只能是 latest 或 popular");
384
+ }
385
+ const { apiOrigin, token } = authedCtx();
386
+ const payload = { categorySlug: slug };
387
+ const limit = toLimit(values.limit);
388
+ if (limit !== undefined) payload.limit = limit;
389
+ if (values.cursor) payload.cursor = values.cursor;
390
+ if (values.locale) payload.locale = values.locale;
391
+ if (values.sort) payload.sortBy = values.sort;
392
+ emit(await trpcQuery({ apiOrigin, token }, "channel.listByCategory", payload));
393
+ }
394
+
395
+ // ---- post 命令 --------------------------------------------------------
396
+ // 命令面向用户的术语统一为 post;tRPC procedure 名仍是后端契约 `grain.*`,不动。
397
+
398
+ async function cmdPostsList(argv) {
399
+ const { values } = parse(argv, {
400
+ channel: { type: "string" },
401
+ subscribed: { type: "boolean" },
402
+ limit: { type: "string" },
403
+ cursor: { type: "string" },
404
+ locale: { type: "string" },
405
+ });
406
+ const { apiOrigin, token } = authedCtx();
407
+ const limit = toLimit(values.limit) ?? 20;
408
+ if (values.subscribed) {
409
+ const payload = { limit };
410
+ if (values.cursor) payload.cursor = values.cursor;
411
+ if (values.channel) payload.channelId = values.channel;
412
+ emit(await trpcQuery({ apiOrigin, token }, "grain.listSubscribed", payload));
413
+ return;
414
+ }
415
+ if (values.channel) {
416
+ const payload = { channelId: values.channel, limit };
417
+ if (values.cursor) payload.cursor = values.cursor;
418
+ emit(await trpcQuery({ apiOrigin, token }, "grain.list", payload));
419
+ return;
420
+ }
421
+ // 否则 listRecent(公开 feed)
422
+ const payload = { limit };
423
+ if (values.cursor) payload.cursor = values.cursor;
424
+ if (values.locale) payload.locale = values.locale;
425
+ emit(await trpcQuery({ apiOrigin, token }, "grain.listRecent", payload));
426
+ }
427
+
428
+ async function cmdPostsGet(argv) {
429
+ const { positionals } = parse(argv, {});
430
+ const id = requirePositional(positionals, 0, "用法:neodrop posts get <postId>");
431
+ const { apiOrigin, token, creds } = authedCtx();
432
+ emit(await trpcQuery({ apiOrigin, token }, "grain.getById", { id }));
433
+ note(`🔗 ${postUrl(creds.webOrigin, id)}`);
434
+ }
435
+
436
+ async function cmdPostsSearch(argv) {
437
+ const { values, positionals } = parse(argv, {
438
+ limit: { type: "string" },
439
+ locale: { type: "string" },
440
+ strict: { type: "boolean" },
441
+ });
442
+ const query = requirePositional(positionals, 0, '用法:neodrop posts search "<query>"');
443
+ const { apiOrigin, token } = authedCtx();
444
+ const payload = { query };
445
+ const limit = toLimit(values.limit);
446
+ if (limit !== undefined) payload.limit = limit;
447
+ if (values.locale) payload.locale = values.locale;
448
+ if (values.strict) payload.strictLocale = true;
449
+ emit(await trpcQuery({ apiOrigin, token }, "grain.searchPublic", payload));
450
+ }
451
+
452
+ async function cmdFeed(argv) {
453
+ const { values } = parse(argv, {
454
+ limit: { type: "string" },
455
+ cursor: { type: "string" },
456
+ });
457
+ const { apiOrigin, token } = authedCtx();
458
+ const payload = { limit: toLimit(values.limit) ?? 20 };
459
+ if (values.cursor) payload.cursor = values.cursor;
460
+ emit(await trpcQuery({ apiOrigin, token }, "grain.listSubscribed", payload));
461
+ }
462
+
463
+ // ---- 兜底通道 ---------------------------------------------------------
464
+
465
+ async function cmdApi(argv) {
466
+ const { values, positionals } = parse(argv, {
467
+ json: { type: "string" },
468
+ stdin: { type: "boolean" },
469
+ mutation: { type: "boolean" },
470
+ });
471
+ if (values.json && values.stdin) {
472
+ throw new UsageError("--json 与 --stdin 互斥,只能给一个");
473
+ }
474
+ const procedure = requirePositional(positionals, 0, "用法:neodrop api <procedure> [--json '...' | --stdin] [--mutation]");
475
+ const { apiOrigin, token } = authedCtx();
476
+ const input = await loadInput(values);
477
+ if (values.mutation) {
478
+ emit(await trpcMutation({ apiOrigin, token }, procedure, input));
479
+ } else {
480
+ emit(await trpcQuery({ apiOrigin, token }, procedure, input));
481
+ }
482
+ }
483
+
484
+ // ---- skill 安装 -------------------------------------------------------
485
+
486
+ async function cmdInstallSkill(argv) {
487
+ const { values } = parse(argv, {
488
+ dest: { type: "string" },
489
+ });
490
+ const { target, copied } = installSkill({ dest: values.dest });
491
+ note(`✅ 已安装 skill 到 ${target}`);
492
+ note(` 拷入:${copied.join("、")}`);
493
+ note(" 重启 Claude Code(或新开会话)后,AI 看到 Neodrop 相关提问会自动调本 skill。");
494
+ emit({ ok: true, target, copied });
495
+ }
496
+
497
+ // ---- 帮助 -------------------------------------------------------------
498
+
499
+ const HELP = `neodrop — Neodrop CLI(AI agent 与人类共用,stdout = JSON)
500
+
501
+ 用法:
502
+ npx neodrop-cli <command> [args...]
503
+
504
+ 元命令:
505
+ login [--server <url>] [--api <url>] [--name <名>] 授权登录,写 PAT 到 ~/.neodrop/credentials.json
506
+ logout 撤销 PAT + 删本地凭证
507
+ whoami 当前 token + user 信息
508
+ me 当前用户信息(user.getMe)
509
+ tokens list 列出所有 PAT
510
+ tokens revoke <id> 撤销指定 PAT
511
+ install-skill [--dest <dir>] 把 SKILL.md + references 装进 agent skill 目录
512
+ (默认 ${defaultSkillDest()})
513
+
514
+ 频道:
515
+ channels list [--mine] [--limit N] [--cursor C] [--locale L]
516
+ channels get <channelId>
517
+ channels create --name <X> [--description <Y>] [--type PUBLIC|PRIVATE] [--locale L]
518
+ channels create --json '{...}' | --stdin
519
+ channels subscribe <channelId>
520
+ channels unsubscribe <channelId>
521
+ channels search "<query>" [--limit N] [--locale L] [--strict]
522
+ channels categories
523
+ channels by-category <slug> [--limit N] [--cursor C] [--locale L] [--sort latest|popular]
524
+
525
+ 内容(Post):
526
+ posts list [--subscribed | --channel <id>] [--limit N] [--cursor C] [--locale L]
527
+ posts get <postId>
528
+ posts search "<query>" [--limit N] [--locale L] [--strict]
529
+ feed [--limit N] [--cursor C] = posts list --subscribed
530
+
531
+ 兜底:
532
+ api <procedure> [--json '...' | --stdin] [--mutation]
533
+
534
+ 全局:
535
+ --pretty 缩进 JSON 输出(仍是合法 JSON)
536
+
537
+ 环境变量:NEODROP_SERVER(web origin)/ NEODROP_API(api origin)
538
+ 更多见 SKILL.md 与 references/。`;
539
+
540
+ // ---- 路由 -------------------------------------------------------------
541
+
542
+ const TOKENS_SUB = {
543
+ list: cmdTokensList,
544
+ revoke: cmdTokensRevoke,
545
+ };
546
+ const CHANNELS_SUB = {
547
+ list: cmdChannelsList,
548
+ get: cmdChannelsGet,
549
+ create: cmdChannelsCreate,
550
+ subscribe: cmdChannelsSubscribe,
551
+ unsubscribe: cmdChannelsUnsubscribe,
552
+ search: cmdChannelsSearch,
553
+ categories: cmdChannelsCategories,
554
+ "by-category": cmdChannelsByCategory,
555
+ };
556
+ const POSTS_SUB = {
557
+ list: cmdPostsList,
558
+ get: cmdPostsGet,
559
+ search: cmdPostsSearch,
560
+ };
561
+
562
+ async function dispatchGroup(name, table, argv) {
563
+ const sub = argv[0];
564
+ if (!sub || sub === "--help" || sub === "-h") {
565
+ throw new UsageError(`用法:neodrop ${name} <${Object.keys(table).join(" | ")}>`);
566
+ }
567
+ const handler = table[sub];
568
+ if (!handler) {
569
+ throw new UsageError(`未知子命令 ${name} ${sub}(可用:${Object.keys(table).join(" / ")})`);
570
+ }
571
+ await handler(argv.slice(1));
572
+ }
573
+
574
+ async function dispatch(rawArgs) {
575
+ // 全局 --pretty 可放任意位置:先剥出来,剩下的交给各命令解析。
576
+ const pretty = rawArgs.includes("--pretty");
577
+ setPretty(pretty);
578
+ const args = rawArgs.filter((a) => a !== "--pretty");
579
+
580
+ const cmd = args[0];
581
+ if (!cmd || cmd === "--help" || cmd === "-h" || cmd === "help") {
582
+ note(HELP);
583
+ return;
584
+ }
585
+
586
+ const rest = args.slice(1);
587
+ switch (cmd) {
588
+ case "login":
589
+ return cmdLogin(rest);
590
+ case "logout":
591
+ return cmdLogout();
592
+ case "whoami":
593
+ return cmdWhoami();
594
+ case "me":
595
+ return cmdMe();
596
+ case "tokens":
597
+ return dispatchGroup("tokens", TOKENS_SUB, rest);
598
+ case "channels":
599
+ return dispatchGroup("channels", CHANNELS_SUB, rest);
600
+ case "grains": // 向后兼容:旧命令名,已更名为 posts
601
+ case "posts":
602
+ return dispatchGroup("posts", POSTS_SUB, rest);
603
+ case "feed":
604
+ return cmdFeed(rest);
605
+ case "api":
606
+ return cmdApi(rest);
607
+ case "install-skill":
608
+ return cmdInstallSkill(rest);
609
+ default:
610
+ throw new UsageError(`未知命令「${cmd}」。运行 neodrop --help 看全部命令。`);
611
+ }
612
+ }
613
+
614
+ async function main() {
615
+ try {
616
+ await dispatch(process.argv.slice(2));
617
+ } catch (err) {
618
+ if (err instanceof UsageError) {
619
+ note(`✗ ${err.message}`);
620
+ process.exitCode = 2;
621
+ return;
622
+ }
623
+ // ApiError(tRPC 业务错)与其它运行时错误统一退出码 1
624
+ note(`✗ ${err.message}`);
625
+ process.exitCode = 1;
626
+ }
627
+ }
628
+
629
+ await main();