neodrop-cli 1.2.0 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/neodrop.mjs CHANGED
@@ -1,20 +1,20 @@
1
1
  #!/usr/bin/env node
2
- // neodrop CLI 入口。AI agent 与人类共用——直接打 Neodrop tRPC HTTP 接口
3
- // Bearer PAT 鉴权),不走 MCP
2
+ // neodrop CLI entry point. Shared by AI agents and humans talks to the Neodrop
3
+ // tRPC HTTP API directly (Bearer PAT auth), not over MCP.
4
4
  //
5
- // 调用:
5
+ // Invocation:
6
6
  // npx neodrop-cli <command> [args...]
7
- // 或全局安装后:neodrop <command> [args...]
7
+ // or, when installed globally: neodrop <command> [args...]
8
8
  //
9
- // 输出:
10
- // stdout = JSON(AI 直接 JSON.parse);--pretty 切缩进 JSON 给人看
11
- // stderr = 日志 / 进度 / 错误描述
12
- // 退出码:0 成功 / 1 业务错误 / 2 参数错误
9
+ // Output:
10
+ // stdout = JSON (parse it directly); --pretty switches to indented JSON for humans
11
+ // stderr = logs / progress / error descriptions
12
+ // Exit codes: 0 success / 1 business error / 2 usage error
13
13
 
14
14
  import { hostname } from "node:os";
15
15
  import { parseArgs } from "node:util";
16
16
  import { ApiError, trpcMutation, trpcQuery } from "../lib/api.mjs";
17
- import { resolveChatSession, sendAndAwaitReply } from "../lib/chat.mjs";
17
+ import { resolveChatSession, sendAndAwaitReply, slimMessage } from "../lib/chat.mjs";
18
18
  import {
19
19
  clearCredentials,
20
20
  credentialsPath,
@@ -30,14 +30,14 @@ import { channelUrl, postUrl, userUrl } from "../lib/web-urls.mjs";
30
30
  const DEFAULT_SERVER = process.env.NEODROP_SERVER || "https://neodrop.ai";
31
31
  const ENV_API_OVERRIDE = process.env.NEODROP_API;
32
32
 
33
- // 用法错误退出码 2(区别于业务错误 1)。
33
+ // Usage error exit code 2 (distinct from business error 1).
34
34
  class UsageError extends Error {}
35
35
 
36
36
  function sleep(ms) {
37
37
  return new Promise((resolve) => setTimeout(resolve, ms));
38
38
  }
39
39
 
40
- // 客户端标识——授权页和 settings/cli-tokens 上显示给用户辨认。
40
+ // Client identifier — shown on the consent page and settings/cli-tokens so the user can recognize it.
41
41
  function detectClientName() {
42
42
  const host = hostname() || "host";
43
43
  const term = process.env.TERM_PROGRAM || "";
@@ -59,13 +59,13 @@ async function readStdin() {
59
59
  return Buffer.concat(chunks).toString("utf-8");
60
60
  }
61
61
 
62
- // `--json '<input>'` / `--stdin` 二选一(互斥,调用前已校验),都没给则返回 undefined
62
+ // One of `--json '<input>'` / `--stdin` (mutually exclusive, checked by the caller); returns undefined if neither is given.
63
63
  async function loadInput(values) {
64
64
  if (values.json) {
65
65
  try {
66
66
  return JSON.parse(values.json);
67
67
  } catch (err) {
68
- throw new UsageError(`--json 解析失败:${err.message}`);
68
+ throw new UsageError(`--json failed to parse: ${err.message}`);
69
69
  }
70
70
  }
71
71
  if (values.stdin) {
@@ -74,7 +74,7 @@ async function loadInput(values) {
74
74
  return undefined;
75
75
  }
76
76
 
77
- // parseArgs 包装:未知 flag / 缺值统一转成 UsageError(退出码 2)。
77
+ // parseArgs wrapper: unknown flags / missing values become a UsageError (exit code 2).
78
78
  function parse(argv, options) {
79
79
  try {
80
80
  return parseArgs({ args: argv, options, allowPositionals: true, strict: true });
@@ -86,7 +86,7 @@ function parse(argv, options) {
86
86
  function toLimit(value) {
87
87
  if (value === undefined) return undefined;
88
88
  const n = Number(value);
89
- if (!Number.isFinite(n)) throw new UsageError(`--limit 必须是数字,收到「${value}」`);
89
+ if (!Number.isFinite(n)) throw new UsageError(`--limit must be a number, got "${value}"`);
90
90
  return n;
91
91
  }
92
92
 
@@ -96,14 +96,14 @@ function requirePositional(positionals, index, hint) {
96
96
  return value;
97
97
  }
98
98
 
99
- // ---- 元命令 -----------------------------------------------------------
99
+ // ---- meta commands ----------------------------------------------------
100
100
 
101
- // 统一登录:session polling 模式。
102
- // 1. startSession 拿到 sessionId + pollSecret + verification URL
103
- // pollSecret 只在本进程内持有,是 poll token 的唯一凭据,不进 URL
104
- // 2. 打印 URL 给用户复制到浏览器(不自动拉起,不开本地 server,无 callback
105
- // 3. pollSecret 轮询 pollSession 直到 APPROVED / DENIED / EXPIRED
106
- // 4. 拿到 token 写入 ~/.neodrop/credentials.json
101
+ // Unified login: session-polling flow.
102
+ // 1. startSession returns sessionId + pollSecret + verification URL
103
+ // (pollSecret is held only in this process — the sole credential for polling the token, never in the URL)
104
+ // 2. print the URL for the user to open in a browser (nothing is auto-launched, no local server, no callback)
105
+ // 3. poll pollSession with the pollSecret until APPROVED / DENIED / EXPIRED
106
+ // 4. on success, write the token to ~/.neodrop/credentials.json
107
107
  async function cmdLogin(argv) {
108
108
  const { values } = parse(argv, {
109
109
  server: { type: "string" },
@@ -112,39 +112,41 @@ async function cmdLogin(argv) {
112
112
  });
113
113
 
114
114
  const webOrigin = (values.server || DEFAULT_SERVER).replace(/\/+$/, "");
115
- // --api 显式 > NEODROP_API env > 启发式推断
115
+ // explicit --api > NEODROP_API env > heuristic inference
116
116
  const apiOrigin = values.api || ENV_API_OVERRIDE || inferApiOrigin(webOrigin);
117
117
  const clientName = values.name || detectClientName();
118
118
 
119
119
  note(`web = ${webOrigin}`);
120
120
  note(`api = ${apiOrigin}`);
121
121
 
122
- // 1. session
122
+ // 1. start a session
123
123
  const session = await trpcMutation({ apiOrigin, token: null }, "cliToken.startSession", {
124
124
  clientName,
125
125
  webOrigin,
126
126
  });
127
127
  const sessionId = session.sessionId;
128
- // pollSecret 是后端只下发给本 CLI 进程的私有领取凭据,不进 verification URL;
129
- // poll 时必须回传它才能领到 tokenURL 里只有 sessionId,截图/转发泄漏也领不走 token。
128
+ // pollSecret is a private claim credential the backend hands only to this CLI process; it is never in the
129
+ // verification URL. You must send it back when polling to claim the token. The URL carries only the sessionId,
130
+ // so a leaked screenshot / forward cannot claim the token.
130
131
  const pollSecret = session.pollSecret;
131
132
  const verificationUrl = session.verificationUrl;
132
133
  const pollInterval = Math.max(1, Number(session.pollIntervalSeconds || 2));
133
134
 
134
135
  note("");
135
- note("👉 在任意浏览器(手机 / 笔记本 / 同机都行)打开下面 URL 完成授权:");
136
+ note("👉 Open the URL below in any browser (phone / laptop / this machine) to authorize:");
136
137
  note("");
137
- // URL 顶格单独成行(不加缩进):这条 URL ?session= 256bit 串、必然超 80 列,
138
- // 终端会折行。顶格单独一行最便于「三击选整行 / 鼠标拖整行」一次性把完整 URL 复制走。
138
+ // The URL is printed flush-left on its own line (no indent): it contains a 256-bit ?session= string, always
139
+ // exceeds 80 columns and the terminal wraps it. Flush-left on its own line makes it easiest to triple-click /
140
+ // drag-select the whole line and copy the full URL in one go.
139
141
  note(verificationUrl);
140
142
  note("");
141
- note(" (URL 较长会折行,复制时连同结尾的 ?session=... 一起选全)");
142
- note(` 客户端名「${clientName}」(在授权页确认是本次启动的 CLI)`);
143
- note(" 授权链接 10 分钟内有效。授权后回到这个终端继续——CLI 会自动检测。");
143
+ note(" (Long URLs wrap — when copying, select all the way through the trailing ?session=...)");
144
+ note(` Client name "${clientName}" (confirm it on the consent page — it's this CLI launch)`);
145
+ note(" The link is valid for 10 minutes. After approving, return to this terminal — the CLI detects it automatically.");
144
146
  note("");
145
147
 
146
- // 2. 轮询
147
- const deadline = Date.now() + 10 * 60 * 1000; // 与后端 session 寿命对齐
148
+ // 2. poll
149
+ const deadline = Date.now() + 10 * 60 * 1000; // aligned with the backend session lifetime
148
150
  let waitedDots = 0;
149
151
  let token = null;
150
152
  let tokenId = null;
@@ -158,40 +160,40 @@ async function cmdLogin(argv) {
158
160
  pollSecret,
159
161
  });
160
162
  } catch (err) {
161
- // NOT_FOUND 一般是 session 已被后端清理;其它错误也直接抛
162
- throw new Error(`轮询授权失败:${err.message}`);
163
+ // NOT_FOUND usually means the backend already cleaned up the session; rethrow any other error too
164
+ throw new Error(`Failed to poll for authorization: ${err.message}`);
163
165
  }
164
166
 
165
167
  const status = res.status;
166
168
  if (status === "APPROVED") {
167
169
  if (res.alreadyClaimed) {
168
170
  throw new Error(
169
- "授权 token 已被领走(极少触发,正常应是本 CLI 自己领)。请重新 `npx neodrop-cli login`。",
171
+ "The authorization token was already claimed (rare — normally this CLI claims it itself). Please run `npx neodrop-cli login` again.",
170
172
  );
171
173
  }
172
174
  token = res.token;
173
175
  tokenId = res.tokenId;
174
176
  const ea = res.tokenExpiresAt;
175
177
  expiresAt = typeof ea === "string" ? ea : ea ? String(ea) : null;
176
- if (!token) throw new Error("授权返回 APPROVED 但缺 token;请重新 `npx neodrop-cli login`");
178
+ if (!token) throw new Error("Authorization returned APPROVED but no token; please run `npx neodrop-cli login` again");
177
179
  break;
178
180
  }
179
181
  if (status === "DENIED") {
180
- throw new Error("授权被用户拒绝。如有需要请重新 `npx neodrop-cli login` 并核对客户端名。");
182
+ throw new Error("Authorization was denied by the user. If needed, run `npx neodrop-cli login` again and check the client name.");
181
183
  }
182
184
  if (status === "EXPIRED") {
183
- throw new Error("授权链接已过期(10 分钟内未授权)。请重新 `npx neodrop-cli login`。");
185
+ throw new Error("The authorization link expired (not approved within 10 minutes). Please run `npx neodrop-cli login` again.");
184
186
  }
185
- // 还在 PENDING — 打点进度(每 5 poll 一个点,不刷屏)
187
+ // still PENDING — print a progress dot (one dot every 5 polls, to avoid spamming)
186
188
  waitedDots += 1;
187
189
  if (waitedDots % 5 === 0) note(".", "");
188
190
  }
189
191
 
190
- if (!token) throw new Error("等待授权超时。请重新 `npx neodrop-cli login`。");
192
+ if (!token) throw new Error("Timed out waiting for authorization. Please run `npx neodrop-cli login` again.");
191
193
 
192
194
  note("");
193
195
 
194
- // 3. 写凭证
196
+ // 3. write the credential
195
197
  writeCredentials({
196
198
  webOrigin,
197
199
  apiOrigin,
@@ -202,9 +204,9 @@ async function cmdLogin(argv) {
202
204
  createdAt: new Date().toISOString(),
203
205
  });
204
206
 
205
- // 4. 校验 + 输出
207
+ // 4. verify + output
206
208
  const me = await trpcQuery({ apiOrigin, token }, "user.getMe");
207
- note(`✅ 登录成功:${me.email || me.id || "<unknown>"}`);
209
+ note(`✅ Logged in: ${me.email || me.id || "<unknown>"}`);
208
210
  note(` credentials = ${credentialsPath()}`);
209
211
  emit({
210
212
  ok: true,
@@ -220,7 +222,7 @@ async function cmdLogin(argv) {
220
222
  async function cmdLogout() {
221
223
  const creds = readCredentials();
222
224
  if (creds === null) {
223
- note("未登录,无需登出。");
225
+ note("Not logged in — nothing to log out of.");
224
226
  emit({ ok: true, alreadyLoggedOut: true });
225
227
  return;
226
228
  }
@@ -230,9 +232,9 @@ async function cmdLogout() {
230
232
  id: creds.tokenId,
231
233
  });
232
234
  revoked = true;
233
- note(`✅ 已撤销 ${creds.tokenId}`);
235
+ note(`✅ Revoked ${creds.tokenId}`);
234
236
  } catch (err) {
235
- note(`⚠ 撤销失败(继续清本地凭证):${err.message}`);
237
+ note(`⚠ Revocation failed (clearing local credential anyway): ${err.message}`);
236
238
  }
237
239
  clearCredentials();
238
240
  emit({ ok: true, revoked });
@@ -267,24 +269,24 @@ async function cmdTokensList() {
267
269
 
268
270
  async function cmdTokensRevoke(argv) {
269
271
  const { positionals } = parse(argv, {});
270
- const id = requirePositional(positionals, 0, "用法:neodrop tokens revoke <id>");
272
+ const id = requirePositional(positionals, 0, "Usage: neodrop tokens revoke <id>");
271
273
  const { apiOrigin, token, creds } = authedCtx();
272
274
  const r = await trpcMutation({ apiOrigin, token }, "cliToken.revoke", { id });
273
275
  if (id === creds.tokenId) {
274
276
  clearCredentials();
275
- note("(撤销的是本机当前 token,已清除本地凭证。需要重新 neodrop login。)");
277
+ note("(That was this machine's current token — the local credential has been cleared. You'll need to run neodrop login again.)");
276
278
  }
277
279
  emit(r);
278
280
  }
279
281
 
280
- // ---- 频道命令 ---------------------------------------------------------
282
+ // ---- channel commands -------------------------------------------------
281
283
 
282
284
  async function cmdChannelsList(argv) {
283
285
  const { values } = parse(argv, {
284
286
  mine: { type: "boolean" },
285
287
  limit: { type: "string" },
286
288
  cursor: { type: "string" },
287
- locale: { type: "string", default: "en" }, // 缺省 en,与 Web 默认 locale 一致
289
+ locale: { type: "string", default: "en" }, // default en, matching the web default locale
288
290
  });
289
291
  const { apiOrigin, token } = authedCtx();
290
292
  if (values.mine) {
@@ -298,55 +300,106 @@ async function cmdChannelsList(argv) {
298
300
 
299
301
  async function cmdChannelsGet(argv) {
300
302
  const { positionals } = parse(argv, {});
301
- const id = requirePositional(positionals, 0, "用法:neodrop channels get <channelId>");
303
+ const id = requirePositional(positionals, 0, "Usage: neodrop channels get <channelId>");
302
304
  const { apiOrigin, token, creds } = authedCtx();
303
305
  emit(await trpcQuery({ apiOrigin, token }, "channel.getById", { id }));
304
306
  note(`🔗 ${channelUrl(creds.webOrigin, id)}`);
305
307
  }
306
308
 
309
+ // Creating a channel = launching a creation Agent task (agentTask.create), the same pipeline as the web
310
+ // create wizard. A bare channel.create only leaves an empty shell stuck forever in DRAFT with no runnable
311
+ // config (issue #7), so the sugar command no longer exposes it; if you truly need a shell, use
312
+ // `api channel.create --mutation`.
313
+ const CREATE_CARRIERS = ["Article", "ImagePost", "Podcast", "Music", "Video"];
314
+
307
315
  async function cmdChannelsCreate(argv) {
308
316
  const { values } = parse(argv, {
309
317
  name: { type: "string" },
318
+ prompt: { type: "string" },
310
319
  description: { type: "string" },
311
- type: { type: "string" },
312
320
  locale: { type: "string" },
321
+ carrier: { type: "string" },
322
+ wait: { type: "boolean" },
313
323
  json: { type: "string" },
314
324
  stdin: { type: "boolean" },
315
325
  });
316
326
  if (values.json && values.stdin) {
317
- throw new UsageError("--json --stdin 互斥,只能给一个");
327
+ throw new UsageError("--json and --stdin are mutually exclusive, pass only one");
318
328
  }
319
- if (values.type && values.type !== "PUBLIC" && values.type !== "PRIVATE") {
320
- throw new UsageError("--type 只能是 PUBLIC PRIVATE");
329
+ if (values.carrier && !CREATE_CARRIERS.includes(values.carrier)) {
330
+ throw new UsageError(`--carrier must be one of ${CREATE_CARRIERS.join(" | ")}`);
321
331
  }
322
- const { apiOrigin, token } = authedCtx();
332
+ const ctx = authedCtx();
333
+ // --json / --stdin passes the raw agentTask.create input through (advanced use; field contract in references/commands.md)
323
334
  let input = await loadInput(values);
324
335
  if (input === undefined) {
325
336
  if (!values.name) {
326
337
  throw new UsageError(
327
- "用法:neodrop channels create --name <X> [--description <Y>] [--type PUBLIC|PRIVATE] [--locale zh-cn]\n" +
328
- "或:neodrop channels create --json '{\"name\":\"X\",\"locale\":\"zh-cn\"}'\n" +
329
- "或:neodrop channels create --stdin",
338
+ 'Usage: neodrop channels create --name <channel name> [--prompt "<creation brief>"] [--description <one-line summary>] [--locale en] [--carrier Article|ImagePost|Podcast|Music|Video] [--wait]\n' +
339
+ "Creation is asynchronous (an Agent generates the channel config, usually a few minutes): by default it returns the task immediately — poll with channels create-status <taskId>; --wait blocks until creation finishes.",
330
340
  );
331
341
  }
332
- input = { name: values.name };
333
- if (values.description) input.description = values.description;
334
- if (values.type) input.type = values.type;
342
+ input = { channelName: values.name };
343
+ if (values.description) input.channelDescription = values.description;
344
+ if (values.prompt) input.description = values.prompt;
335
345
  if (values.locale) input.locale = values.locale;
346
+ if (values.carrier) input.contentCarrier = values.carrier;
347
+ }
348
+ const task = await trpcMutation(ctx, "agentTask.create", input);
349
+ note(`✅ Creation task started: task=${task.id} channel=${task.channelId ?? "<pending>"}`);
350
+ if (task.channelId) note(`🔗 ${channelUrl(ctx.creds.webOrigin, task.channelId)}`);
351
+
352
+ if (!values.wait) {
353
+ note(" The channel config is generated asynchronously by an Agent (usually a few minutes). Poll: neodrop channels create-status " + task.id);
354
+ emit(task);
355
+ return;
356
+ }
357
+
358
+ // --wait: poll to a terminal state (COMPLETED / FAILED), or stop at PAUSED (credits exhausted, awaiting top-up).
359
+ const deadline = Date.now() + 20 * 60 * 1000;
360
+ let current = task;
361
+ while (Date.now() < deadline) {
362
+ if (current.status === "COMPLETED" || current.status === "FAILED") break;
363
+ if (current.status === "PAUSED") {
364
+ note("⚠ Task paused (usually insufficient credits). It resumes automatically after a top-up; check later with channels create-status.");
365
+ break;
366
+ }
367
+ await sleep(15 * 1000);
368
+ current = await trpcQuery(ctx, "agentTask.getById", { id: task.id });
369
+ note(`… status=${current.status}`);
336
370
  }
337
- emit(await trpcMutation({ apiOrigin, token }, "channel.create", input));
371
+ if (current.status === "FAILED") process.exitCode = 1;
372
+ emit(current);
373
+ }
374
+
375
+ // Query a creation task's status (agentTask.getById). status: PENDING/RUNNING → in progress;
376
+ // COMPLETED → done; FAILED → failed; PAUSED → paused (insufficient credits, resumes after top-up).
377
+ async function cmdChannelsCreateStatus(argv) {
378
+ const { positionals } = parse(argv, {});
379
+ const id = requirePositional(positionals, 0, "Usage: neodrop channels create-status <taskId>");
380
+ const { apiOrigin, token } = authedCtx();
381
+ emit(await trpcQuery({ apiOrigin, token }, "agentTask.getById", { id }));
382
+ }
383
+
384
+ // Manually trigger a channel to produce one run of content (channel.triggerRun). The channel must have
385
+ // finished creation (or be DRAFT but already have a runnable config — the backend activates it automatically).
386
+ async function cmdChannelsRun(argv) {
387
+ const { positionals } = parse(argv, {});
388
+ const id = requirePositional(positionals, 0, "Usage: neodrop channels run <channelId>");
389
+ const { apiOrigin, token } = authedCtx();
390
+ emit(await trpcMutation({ apiOrigin, token }, "channel.triggerRun", { channelId: id }));
338
391
  }
339
392
 
340
393
  async function cmdChannelsSubscribe(argv) {
341
394
  const { positionals } = parse(argv, {});
342
- const id = requirePositional(positionals, 0, "用法:neodrop channels subscribe <channelId>");
395
+ const id = requirePositional(positionals, 0, "Usage: neodrop channels subscribe <channelId>");
343
396
  const { apiOrigin, token } = authedCtx();
344
397
  emit(await trpcMutation({ apiOrigin, token }, "channel.subscribe", { channelId: id }));
345
398
  }
346
399
 
347
400
  async function cmdChannelsUnsubscribe(argv) {
348
401
  const { positionals } = parse(argv, {});
349
- const id = requirePositional(positionals, 0, "用法:neodrop channels unsubscribe <channelId>");
402
+ const id = requirePositional(positionals, 0, "Usage: neodrop channels unsubscribe <channelId>");
350
403
  const { apiOrigin, token } = authedCtx();
351
404
  emit(await trpcMutation({ apiOrigin, token }, "channel.unsubscribe", { channelId: id }));
352
405
  }
@@ -357,7 +410,7 @@ async function cmdChannelsSearch(argv) {
357
410
  locale: { type: "string" },
358
411
  strict: { type: "boolean" },
359
412
  });
360
- const query = requirePositional(positionals, 0, '用法:neodrop channels search "<query>"');
413
+ const query = requirePositional(positionals, 0, 'Usage: neodrop channels search "<query>"');
361
414
  const { apiOrigin, token } = authedCtx();
362
415
  const payload = { query };
363
416
  const limit = toLimit(values.limit);
@@ -379,9 +432,9 @@ async function cmdChannelsByCategory(argv) {
379
432
  locale: { type: "string" },
380
433
  sort: { type: "string" },
381
434
  });
382
- const slug = requirePositional(positionals, 0, "用法:neodrop channels by-category <slug>");
435
+ const slug = requirePositional(positionals, 0, "Usage: neodrop channels by-category <slug>");
383
436
  if (values.sort && values.sort !== "latest" && values.sort !== "popular") {
384
- throw new UsageError("--sort 只能是 latest popular");
437
+ throw new UsageError("--sort must be latest or popular");
385
438
  }
386
439
  const { apiOrigin, token } = authedCtx();
387
440
  const payload = { categorySlug: slug };
@@ -393,8 +446,8 @@ async function cmdChannelsByCategory(argv) {
393
446
  emit(await trpcQuery({ apiOrigin, token }, "channel.listByCategory", payload));
394
447
  }
395
448
 
396
- // ---- post 命令 --------------------------------------------------------
397
- // 命令面向用户的术语统一为 posttRPC procedure 名仍是后端契约 `grain.*`,不动。
449
+ // ---- post commands ----------------------------------------------------
450
+ // The user-facing term is unified as post; the tRPC procedure names are still the backend contract `grain.*`, unchanged.
398
451
 
399
452
  async function cmdPostsList(argv) {
400
453
  const { values } = parse(argv, {
@@ -419,7 +472,7 @@ async function cmdPostsList(argv) {
419
472
  emit(await trpcQuery({ apiOrigin, token }, "grain.list", payload));
420
473
  return;
421
474
  }
422
- // 否则 listRecent(公开 feed
475
+ // otherwise listRecent (public feed)
423
476
  const payload = { limit };
424
477
  if (values.cursor) payload.cursor = values.cursor;
425
478
  if (values.locale) payload.locale = values.locale;
@@ -428,7 +481,7 @@ async function cmdPostsList(argv) {
428
481
 
429
482
  async function cmdPostsGet(argv) {
430
483
  const { positionals } = parse(argv, {});
431
- const id = requirePositional(positionals, 0, "用法:neodrop posts get <postId>");
484
+ const id = requirePositional(positionals, 0, "Usage: neodrop posts get <postId>");
432
485
  const { apiOrigin, token, creds } = authedCtx();
433
486
  emit(await trpcQuery({ apiOrigin, token }, "grain.getById", { id }));
434
487
  note(`🔗 ${postUrl(creds.webOrigin, id)}`);
@@ -440,7 +493,7 @@ async function cmdPostsSearch(argv) {
440
493
  locale: { type: "string" },
441
494
  strict: { type: "boolean" },
442
495
  });
443
- const query = requirePositional(positionals, 0, '用法:neodrop posts search "<query>"');
496
+ const query = requirePositional(positionals, 0, 'Usage: neodrop posts search "<query>"');
444
497
  const { apiOrigin, token } = authedCtx();
445
498
  const payload = { query };
446
499
  const limit = toLimit(values.limit);
@@ -461,7 +514,7 @@ async function cmdFeed(argv) {
461
514
  emit(await trpcQuery({ apiOrigin, token }, "grain.listSubscribed", payload));
462
515
  }
463
516
 
464
- // ---- chat 命令 --------------------------------------------------------
517
+ // ---- chat commands ----------------------------------------------------
465
518
 
466
519
  async function cmdChatSend(argv) {
467
520
  const { values, positionals } = parse(argv, {
@@ -474,26 +527,26 @@ async function cmdChatSend(argv) {
474
527
  const text = requirePositional(
475
528
  positionals,
476
529
  0,
477
- '用法:neodrop chat "<message>" [--session <id> | --channel <id>] [--locale en] [--timeout ]\n' +
478
- " neodrop chat history --session <id>",
530
+ 'Usage: neodrop chat "<message>" [--session <id> | --channel <id>] [--locale en] [--timeout <seconds>]\n' +
531
+ " neodrop chat history --session <id>",
479
532
  );
480
533
  if (values.session && values.channel) {
481
- throw new UsageError("--session --channel 互斥:--session 继续既有会话,--channel 拿该频道的助手会话");
534
+ throw new UsageError("--session and --channel are mutually exclusive: --session continues an existing session, --channel gets that channel's assistant session");
482
535
  }
483
536
  const timeoutSec = values.timeout === undefined ? 600 : Number(values.timeout);
484
537
  if (!Number.isFinite(timeoutSec) || timeoutSec <= 0) {
485
- throw new UsageError(`--timeout 必须是正数秒,收到「${values.timeout}」`);
538
+ throw new UsageError(`--timeout must be a positive number of seconds, got "${values.timeout}"`);
486
539
  }
487
540
  const pollSec = values["poll-interval"] === undefined ? 2 : Number(values["poll-interval"]);
488
541
  if (!Number.isFinite(pollSec) || pollSec <= 0) {
489
- throw new UsageError(`--poll-interval 必须是正数秒,收到「${values["poll-interval"]}」`);
542
+ throw new UsageError(`--poll-interval must be a positive number of seconds, got "${values["poll-interval"]}"`);
490
543
  }
491
544
 
492
545
  const { apiOrigin, token } = authedCtx();
493
546
  let sessionId = values.session;
494
547
  if (!sessionId) {
495
548
  sessionId = await resolveChatSession({ apiOrigin, token, channelId: values.channel });
496
- note(`会话 ${sessionId}(继续对话:neodrop chat "…" --session ${sessionId})`);
549
+ note(`session ${sessionId} (continue with: neodrop chat "…" --session ${sessionId})`);
497
550
  }
498
551
 
499
552
  const result = await sendAndAwaitReply({
@@ -506,7 +559,7 @@ async function cmdChatSend(argv) {
506
559
  pollIntervalMs: pollSec * 1000,
507
560
  });
508
561
  if (!result.reply) {
509
- note("⚠ 本轮没有 assistant 文本回复(可能生成失败或被取消),newMessages 里是本轮全部新增消息。");
562
+ note("⚠ No assistant text reply this turn (generation may have failed or been cancelled); newMessages holds every message added this turn.");
510
563
  }
511
564
  emit(result);
512
565
  }
@@ -516,14 +569,13 @@ async function cmdChatHistory(argv) {
516
569
  session: { type: "string" },
517
570
  });
518
571
  if (!values.session) {
519
- throw new UsageError("用法:neodrop chat history --session <id>");
572
+ throw new UsageError("Usage: neodrop chat history --session <id>");
520
573
  }
521
574
  const { apiOrigin, token } = authedCtx();
522
- emit(
523
- await trpcQuery({ apiOrigin, token }, "session.getMessages", {
524
- sessionId: values.session,
525
- }),
526
- );
575
+ const messages = await trpcQuery({ apiOrigin, token }, "session.getMessages", {
576
+ sessionId: values.session,
577
+ });
578
+ emit(messages.map(slimMessage));
527
579
  }
528
580
 
529
581
  async function cmdChatSessions() {
@@ -538,7 +590,7 @@ async function cmdChat(argv) {
538
590
  return cmdChatSend(argv);
539
591
  }
540
592
 
541
- // ---- 兜底通道 ---------------------------------------------------------
593
+ // ---- escape hatch -----------------------------------------------------
542
594
 
543
595
  async function cmdApi(argv) {
544
596
  const { values, positionals } = parse(argv, {
@@ -547,9 +599,9 @@ async function cmdApi(argv) {
547
599
  mutation: { type: "boolean" },
548
600
  });
549
601
  if (values.json && values.stdin) {
550
- throw new UsageError("--json --stdin 互斥,只能给一个");
602
+ throw new UsageError("--json and --stdin are mutually exclusive, pass only one");
551
603
  }
552
- const procedure = requirePositional(positionals, 0, "用法:neodrop api <procedure> [--json '...' | --stdin] [--mutation]");
604
+ const procedure = requirePositional(positionals, 0, "Usage: neodrop api <procedure> [--json '...' | --stdin] [--mutation]");
553
605
  const { apiOrigin, token } = authedCtx();
554
606
  const input = await loadInput(values);
555
607
  if (values.mutation) {
@@ -559,71 +611,75 @@ async function cmdApi(argv) {
559
611
  }
560
612
  }
561
613
 
562
- // ---- skill 安装 -------------------------------------------------------
614
+ // ---- skill install ----------------------------------------------------
563
615
 
564
616
  async function cmdInstallSkill(argv) {
565
617
  const { values } = parse(argv, {
566
618
  dest: { type: "string" },
567
619
  });
568
620
  const { target, copied } = installSkill({ dest: values.dest });
569
- note(`✅ 已安装 skill ${target}`);
570
- note(` 拷入:${copied.join("")}`);
571
- note(" 重启 Claude Code(或新开会话)后,AI 看到 Neodrop 相关提问会自动调本 skill");
621
+ note(`✅ Installed skill to ${target}`);
622
+ note(` Copied: ${copied.join(", ")}`);
623
+ note(" After restarting Claude Code (or opening a new session), the AI will route Neodrop-related questions to this skill automatically.");
572
624
  emit({ ok: true, target, copied });
573
625
  }
574
626
 
575
- // ---- 帮助 -------------------------------------------------------------
627
+ // ---- help -------------------------------------------------------------
576
628
 
577
- const HELP = `neodrop — Neodrop CLIAI agent 与人类共用,stdout = JSON
629
+ const HELP = `neodrop — Neodrop CLI (shared by AI agents and humans, stdout = JSON)
578
630
 
579
- 用法:
631
+ Usage:
580
632
  npx neodrop-cli <command> [args...]
581
633
 
582
- 元命令:
583
- login [--server <url>] [--api <url>] [--name <名>] 授权登录,写 PAT ~/.neodrop/credentials.json
584
- logout 撤销 PAT + 删本地凭证
585
- whoami 当前 token + user 信息
586
- me 当前用户信息(user.getMe
587
- tokens list 列出所有 PAT
588
- tokens revoke <id> 撤销指定 PAT
589
- install-skill [--dest <dir>] SKILL.md + references 装进 agent skill 目录
590
- (默认 ${defaultSkillDest()}
591
-
592
- 频道:
634
+ Meta:
635
+ login [--server <url>] [--api <url>] [--name <name>] Authorize and write a PAT to ~/.neodrop/credentials.json
636
+ logout Revoke the PAT + delete the local credential
637
+ whoami Current token + user info
638
+ me Current user info (user.getMe)
639
+ tokens list List every PAT
640
+ tokens revoke <id> Revoke a specific PAT
641
+ install-skill [--dest <dir>] Install SKILL.md + references into the agent's skill dir
642
+ (default ${defaultSkillDest()})
643
+
644
+ Channels:
593
645
  channels list [--mine] [--limit N] [--cursor C] [--locale L]
594
646
  channels get <channelId>
595
- channels create --name <X> [--description <Y>] [--type PUBLIC|PRIVATE] [--locale L]
596
- channels create --json '{...}' | --stdin
647
+ channels create --name <X> [--prompt "<brief>"] [--description <Y>] [--locale L]
648
+ [--carrier Article|ImagePost|Podcast|Music|Video] [--wait]
649
+ Launch the creation Agent (async, a few minutes)
650
+ channels create-status <taskId> Check a creation task's progress / result
651
+ channels run <channelId> Manually trigger one run of content
597
652
  channels subscribe <channelId>
598
653
  channels unsubscribe <channelId>
599
654
  channels search "<query>" [--limit N] [--locale L] [--strict]
600
655
  channels categories
601
656
  channels by-category <slug> [--limit N] [--cursor C] [--locale L] [--sort latest|popular]
602
657
 
603
- 内容(Post):
658
+ Content (Post):
604
659
  posts list [--subscribed | --channel <id>] [--limit N] [--cursor C] [--locale L]
605
660
  posts get <postId>
606
661
  posts search "<query>" [--limit N] [--locale L] [--strict]
607
662
  feed [--limit N] [--cursor C] = posts list --subscribed
608
663
 
609
- 对话(chat):
610
- chat "<message>" [--session <id> | --channel <id>] [--locale L] [--timeout ]
611
- 发消息给 AI 助手并等完整回复(默认新建全局
612
- 助手会话;--session 继续对话;--channel
613
- 该频道的助手聊)
614
- chat history --session <id> 查看会话全部消息
615
- chat sessions 列出我的会话
664
+ Chat:
665
+ chat "<message>" [--session <id> | --channel <id>] [--locale L] [--timeout <seconds>]
666
+ Send a message to the AI assistant and wait for the full
667
+ reply (defaults to a new global-assistant session;
668
+ --session continues a session; --channel talks to that
669
+ channel's assistant)
670
+ chat history --session <id> View a session's full message list
671
+ chat sessions List my sessions
616
672
 
617
- 兜底:
673
+ Escape hatch:
618
674
  api <procedure> [--json '...' | --stdin] [--mutation]
619
675
 
620
- 全局:
621
- --pretty 缩进 JSON 输出(仍是合法 JSON
676
+ Global:
677
+ --pretty Indented JSON output (still valid JSON)
622
678
 
623
- 环境变量:NEODROP_SERVERweb origin)/ NEODROP_APIapi origin
624
- 更多见 SKILL.md references/。`;
679
+ Environment variables: NEODROP_SERVER (web origin) / NEODROP_API (api origin)
680
+ See SKILL.md and references/ for more.`;
625
681
 
626
- // ---- 路由 -------------------------------------------------------------
682
+ // ---- routing ----------------------------------------------------------
627
683
 
628
684
  const TOKENS_SUB = {
629
685
  list: cmdTokensList,
@@ -633,6 +689,8 @@ const CHANNELS_SUB = {
633
689
  list: cmdChannelsList,
634
690
  get: cmdChannelsGet,
635
691
  create: cmdChannelsCreate,
692
+ "create-status": cmdChannelsCreateStatus,
693
+ run: cmdChannelsRun,
636
694
  subscribe: cmdChannelsSubscribe,
637
695
  unsubscribe: cmdChannelsUnsubscribe,
638
696
  search: cmdChannelsSearch,
@@ -648,17 +706,17 @@ const POSTS_SUB = {
648
706
  async function dispatchGroup(name, table, argv) {
649
707
  const sub = argv[0];
650
708
  if (!sub || sub === "--help" || sub === "-h") {
651
- throw new UsageError(`用法:neodrop ${name} <${Object.keys(table).join(" | ")}>`);
709
+ throw new UsageError(`Usage: neodrop ${name} <${Object.keys(table).join(" | ")}>`);
652
710
  }
653
711
  const handler = table[sub];
654
712
  if (!handler) {
655
- throw new UsageError(`未知子命令 ${name} ${sub}(可用:${Object.keys(table).join(" / ")})`);
713
+ throw new UsageError(`Unknown subcommand ${name} ${sub} (available: ${Object.keys(table).join(" / ")})`);
656
714
  }
657
715
  await handler(argv.slice(1));
658
716
  }
659
717
 
660
718
  async function dispatch(rawArgs) {
661
- // 全局 --pretty 可放任意位置:先剥出来,剩下的交给各命令解析。
719
+ // The global --pretty can appear anywhere: strip it out first, hand the rest to each command's parser.
662
720
  const pretty = rawArgs.includes("--pretty");
663
721
  setPretty(pretty);
664
722
  const args = rawArgs.filter((a) => a !== "--pretty");
@@ -683,7 +741,7 @@ async function dispatch(rawArgs) {
683
741
  return dispatchGroup("tokens", TOKENS_SUB, rest);
684
742
  case "channels":
685
743
  return dispatchGroup("channels", CHANNELS_SUB, rest);
686
- case "grains": // 向后兼容:旧命令名,已更名为 posts
744
+ case "grains": // backward compatible: old command name, renamed to posts
687
745
  case "posts":
688
746
  return dispatchGroup("posts", POSTS_SUB, rest);
689
747
  case "feed":
@@ -695,7 +753,7 @@ async function dispatch(rawArgs) {
695
753
  case "install-skill":
696
754
  return cmdInstallSkill(rest);
697
755
  default:
698
- throw new UsageError(`未知命令「${cmd}」。运行 neodrop --help 看全部命令。`);
756
+ throw new UsageError(`Unknown command "${cmd}". Run neodrop --help to see every command.`);
699
757
  }
700
758
  }
701
759
 
@@ -708,7 +766,7 @@ async function main() {
708
766
  process.exitCode = 2;
709
767
  return;
710
768
  }
711
- // ApiErrortRPC 业务错)与其它运行时错误统一退出码 1
769
+ // ApiError (tRPC business error) and other runtime errors share exit code 1
712
770
  note(`✗ ${err.message}`);
713
771
  process.exitCode = 1;
714
772
  }