neodrop-cli 1.1.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/neodrop.mjs CHANGED
@@ -1,19 +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
18
  import {
18
19
  clearCredentials,
19
20
  credentialsPath,
@@ -29,14 +30,14 @@ import { channelUrl, postUrl, userUrl } from "../lib/web-urls.mjs";
29
30
  const DEFAULT_SERVER = process.env.NEODROP_SERVER || "https://neodrop.ai";
30
31
  const ENV_API_OVERRIDE = process.env.NEODROP_API;
31
32
 
32
- // 用法错误退出码 2(区别于业务错误 1)。
33
+ // Usage error exit code 2 (distinct from business error 1).
33
34
  class UsageError extends Error {}
34
35
 
35
36
  function sleep(ms) {
36
37
  return new Promise((resolve) => setTimeout(resolve, ms));
37
38
  }
38
39
 
39
- // 客户端标识——授权页和 settings/cli-tokens 上显示给用户辨认。
40
+ // Client identifier — shown on the consent page and settings/cli-tokens so the user can recognize it.
40
41
  function detectClientName() {
41
42
  const host = hostname() || "host";
42
43
  const term = process.env.TERM_PROGRAM || "";
@@ -58,13 +59,13 @@ async function readStdin() {
58
59
  return Buffer.concat(chunks).toString("utf-8");
59
60
  }
60
61
 
61
- // `--json '<input>'` / `--stdin` 二选一(互斥,调用前已校验),都没给则返回 undefined
62
+ // One of `--json '<input>'` / `--stdin` (mutually exclusive, checked by the caller); returns undefined if neither is given.
62
63
  async function loadInput(values) {
63
64
  if (values.json) {
64
65
  try {
65
66
  return JSON.parse(values.json);
66
67
  } catch (err) {
67
- throw new UsageError(`--json 解析失败:${err.message}`);
68
+ throw new UsageError(`--json failed to parse: ${err.message}`);
68
69
  }
69
70
  }
70
71
  if (values.stdin) {
@@ -73,7 +74,7 @@ async function loadInput(values) {
73
74
  return undefined;
74
75
  }
75
76
 
76
- // parseArgs 包装:未知 flag / 缺值统一转成 UsageError(退出码 2)。
77
+ // parseArgs wrapper: unknown flags / missing values become a UsageError (exit code 2).
77
78
  function parse(argv, options) {
78
79
  try {
79
80
  return parseArgs({ args: argv, options, allowPositionals: true, strict: true });
@@ -85,7 +86,7 @@ function parse(argv, options) {
85
86
  function toLimit(value) {
86
87
  if (value === undefined) return undefined;
87
88
  const n = Number(value);
88
- if (!Number.isFinite(n)) throw new UsageError(`--limit 必须是数字,收到「${value}」`);
89
+ if (!Number.isFinite(n)) throw new UsageError(`--limit must be a number, got "${value}"`);
89
90
  return n;
90
91
  }
91
92
 
@@ -95,14 +96,14 @@ function requirePositional(positionals, index, hint) {
95
96
  return value;
96
97
  }
97
98
 
98
- // ---- 元命令 -----------------------------------------------------------
99
+ // ---- meta commands ----------------------------------------------------
99
100
 
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
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
106
107
  async function cmdLogin(argv) {
107
108
  const { values } = parse(argv, {
108
109
  server: { type: "string" },
@@ -111,39 +112,41 @@ async function cmdLogin(argv) {
111
112
  });
112
113
 
113
114
  const webOrigin = (values.server || DEFAULT_SERVER).replace(/\/+$/, "");
114
- // --api 显式 > NEODROP_API env > 启发式推断
115
+ // explicit --api > NEODROP_API env > heuristic inference
115
116
  const apiOrigin = values.api || ENV_API_OVERRIDE || inferApiOrigin(webOrigin);
116
117
  const clientName = values.name || detectClientName();
117
118
 
118
119
  note(`web = ${webOrigin}`);
119
120
  note(`api = ${apiOrigin}`);
120
121
 
121
- // 1. session
122
+ // 1. start a session
122
123
  const session = await trpcMutation({ apiOrigin, token: null }, "cliToken.startSession", {
123
124
  clientName,
124
125
  webOrigin,
125
126
  });
126
127
  const sessionId = session.sessionId;
127
- // pollSecret 是后端只下发给本 CLI 进程的私有领取凭据,不进 verification URL;
128
- // 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.
129
131
  const pollSecret = session.pollSecret;
130
132
  const verificationUrl = session.verificationUrl;
131
133
  const pollInterval = Math.max(1, Number(session.pollIntervalSeconds || 2));
132
134
 
133
135
  note("");
134
- note("👉 在任意浏览器(手机 / 笔记本 / 同机都行)打开下面 URL 完成授权:");
136
+ note("👉 Open the URL below in any browser (phone / laptop / this machine) to authorize:");
135
137
  note("");
136
- // URL 顶格单独成行(不加缩进):这条 URL ?session= 256bit 串、必然超 80 列,
137
- // 终端会折行。顶格单独一行最便于「三击选整行 / 鼠标拖整行」一次性把完整 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.
138
141
  note(verificationUrl);
139
142
  note("");
140
- note(" (URL 较长会折行,复制时连同结尾的 ?session=... 一起选全)");
141
- note(` 客户端名「${clientName}」(在授权页确认是本次启动的 CLI)`);
142
- 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.");
143
146
  note("");
144
147
 
145
- // 2. 轮询
146
- 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
147
150
  let waitedDots = 0;
148
151
  let token = null;
149
152
  let tokenId = null;
@@ -157,40 +160,40 @@ async function cmdLogin(argv) {
157
160
  pollSecret,
158
161
  });
159
162
  } catch (err) {
160
- // NOT_FOUND 一般是 session 已被后端清理;其它错误也直接抛
161
- 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}`);
162
165
  }
163
166
 
164
167
  const status = res.status;
165
168
  if (status === "APPROVED") {
166
169
  if (res.alreadyClaimed) {
167
170
  throw new Error(
168
- "授权 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.",
169
172
  );
170
173
  }
171
174
  token = res.token;
172
175
  tokenId = res.tokenId;
173
176
  const ea = res.tokenExpiresAt;
174
177
  expiresAt = typeof ea === "string" ? ea : ea ? String(ea) : null;
175
- 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");
176
179
  break;
177
180
  }
178
181
  if (status === "DENIED") {
179
- 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.");
180
183
  }
181
184
  if (status === "EXPIRED") {
182
- 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.");
183
186
  }
184
- // 还在 PENDING — 打点进度(每 5 poll 一个点,不刷屏)
187
+ // still PENDING — print a progress dot (one dot every 5 polls, to avoid spamming)
185
188
  waitedDots += 1;
186
189
  if (waitedDots % 5 === 0) note(".", "");
187
190
  }
188
191
 
189
- 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.");
190
193
 
191
194
  note("");
192
195
 
193
- // 3. 写凭证
196
+ // 3. write the credential
194
197
  writeCredentials({
195
198
  webOrigin,
196
199
  apiOrigin,
@@ -201,9 +204,9 @@ async function cmdLogin(argv) {
201
204
  createdAt: new Date().toISOString(),
202
205
  });
203
206
 
204
- // 4. 校验 + 输出
207
+ // 4. verify + output
205
208
  const me = await trpcQuery({ apiOrigin, token }, "user.getMe");
206
- note(`✅ 登录成功:${me.email || me.id || "<unknown>"}`);
209
+ note(`✅ Logged in: ${me.email || me.id || "<unknown>"}`);
207
210
  note(` credentials = ${credentialsPath()}`);
208
211
  emit({
209
212
  ok: true,
@@ -219,7 +222,7 @@ async function cmdLogin(argv) {
219
222
  async function cmdLogout() {
220
223
  const creds = readCredentials();
221
224
  if (creds === null) {
222
- note("未登录,无需登出。");
225
+ note("Not logged in — nothing to log out of.");
223
226
  emit({ ok: true, alreadyLoggedOut: true });
224
227
  return;
225
228
  }
@@ -229,9 +232,9 @@ async function cmdLogout() {
229
232
  id: creds.tokenId,
230
233
  });
231
234
  revoked = true;
232
- note(`✅ 已撤销 ${creds.tokenId}`);
235
+ note(`✅ Revoked ${creds.tokenId}`);
233
236
  } catch (err) {
234
- note(`⚠ 撤销失败(继续清本地凭证):${err.message}`);
237
+ note(`⚠ Revocation failed (clearing local credential anyway): ${err.message}`);
235
238
  }
236
239
  clearCredentials();
237
240
  emit({ ok: true, revoked });
@@ -266,24 +269,24 @@ async function cmdTokensList() {
266
269
 
267
270
  async function cmdTokensRevoke(argv) {
268
271
  const { positionals } = parse(argv, {});
269
- const id = requirePositional(positionals, 0, "用法:neodrop tokens revoke <id>");
272
+ const id = requirePositional(positionals, 0, "Usage: neodrop tokens revoke <id>");
270
273
  const { apiOrigin, token, creds } = authedCtx();
271
274
  const r = await trpcMutation({ apiOrigin, token }, "cliToken.revoke", { id });
272
275
  if (id === creds.tokenId) {
273
276
  clearCredentials();
274
- 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.)");
275
278
  }
276
279
  emit(r);
277
280
  }
278
281
 
279
- // ---- 频道命令 ---------------------------------------------------------
282
+ // ---- channel commands -------------------------------------------------
280
283
 
281
284
  async function cmdChannelsList(argv) {
282
285
  const { values } = parse(argv, {
283
286
  mine: { type: "boolean" },
284
287
  limit: { type: "string" },
285
288
  cursor: { type: "string" },
286
- locale: { type: "string", default: "en" }, // 缺省 en,与 Web 默认 locale 一致
289
+ locale: { type: "string", default: "en" }, // default en, matching the web default locale
287
290
  });
288
291
  const { apiOrigin, token } = authedCtx();
289
292
  if (values.mine) {
@@ -297,55 +300,106 @@ async function cmdChannelsList(argv) {
297
300
 
298
301
  async function cmdChannelsGet(argv) {
299
302
  const { positionals } = parse(argv, {});
300
- const id = requirePositional(positionals, 0, "用法:neodrop channels get <channelId>");
303
+ const id = requirePositional(positionals, 0, "Usage: neodrop channels get <channelId>");
301
304
  const { apiOrigin, token, creds } = authedCtx();
302
305
  emit(await trpcQuery({ apiOrigin, token }, "channel.getById", { id }));
303
306
  note(`🔗 ${channelUrl(creds.webOrigin, id)}`);
304
307
  }
305
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
+
306
315
  async function cmdChannelsCreate(argv) {
307
316
  const { values } = parse(argv, {
308
317
  name: { type: "string" },
318
+ prompt: { type: "string" },
309
319
  description: { type: "string" },
310
- type: { type: "string" },
311
320
  locale: { type: "string" },
321
+ carrier: { type: "string" },
322
+ wait: { type: "boolean" },
312
323
  json: { type: "string" },
313
324
  stdin: { type: "boolean" },
314
325
  });
315
326
  if (values.json && values.stdin) {
316
- throw new UsageError("--json --stdin 互斥,只能给一个");
327
+ throw new UsageError("--json and --stdin are mutually exclusive, pass only one");
317
328
  }
318
- if (values.type && values.type !== "PUBLIC" && values.type !== "PRIVATE") {
319
- 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(" | ")}`);
320
331
  }
321
- 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)
322
334
  let input = await loadInput(values);
323
335
  if (input === undefined) {
324
336
  if (!values.name) {
325
337
  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",
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.",
329
340
  );
330
341
  }
331
- input = { name: values.name };
332
- if (values.description) input.description = values.description;
333
- 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;
334
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}`);
335
370
  }
336
- 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 }));
337
391
  }
338
392
 
339
393
  async function cmdChannelsSubscribe(argv) {
340
394
  const { positionals } = parse(argv, {});
341
- const id = requirePositional(positionals, 0, "用法:neodrop channels subscribe <channelId>");
395
+ const id = requirePositional(positionals, 0, "Usage: neodrop channels subscribe <channelId>");
342
396
  const { apiOrigin, token } = authedCtx();
343
397
  emit(await trpcMutation({ apiOrigin, token }, "channel.subscribe", { channelId: id }));
344
398
  }
345
399
 
346
400
  async function cmdChannelsUnsubscribe(argv) {
347
401
  const { positionals } = parse(argv, {});
348
- const id = requirePositional(positionals, 0, "用法:neodrop channels unsubscribe <channelId>");
402
+ const id = requirePositional(positionals, 0, "Usage: neodrop channels unsubscribe <channelId>");
349
403
  const { apiOrigin, token } = authedCtx();
350
404
  emit(await trpcMutation({ apiOrigin, token }, "channel.unsubscribe", { channelId: id }));
351
405
  }
@@ -356,7 +410,7 @@ async function cmdChannelsSearch(argv) {
356
410
  locale: { type: "string" },
357
411
  strict: { type: "boolean" },
358
412
  });
359
- const query = requirePositional(positionals, 0, '用法:neodrop channels search "<query>"');
413
+ const query = requirePositional(positionals, 0, 'Usage: neodrop channels search "<query>"');
360
414
  const { apiOrigin, token } = authedCtx();
361
415
  const payload = { query };
362
416
  const limit = toLimit(values.limit);
@@ -378,9 +432,9 @@ async function cmdChannelsByCategory(argv) {
378
432
  locale: { type: "string" },
379
433
  sort: { type: "string" },
380
434
  });
381
- const slug = requirePositional(positionals, 0, "用法:neodrop channels by-category <slug>");
435
+ const slug = requirePositional(positionals, 0, "Usage: neodrop channels by-category <slug>");
382
436
  if (values.sort && values.sort !== "latest" && values.sort !== "popular") {
383
- throw new UsageError("--sort 只能是 latest popular");
437
+ throw new UsageError("--sort must be latest or popular");
384
438
  }
385
439
  const { apiOrigin, token } = authedCtx();
386
440
  const payload = { categorySlug: slug };
@@ -392,8 +446,8 @@ async function cmdChannelsByCategory(argv) {
392
446
  emit(await trpcQuery({ apiOrigin, token }, "channel.listByCategory", payload));
393
447
  }
394
448
 
395
- // ---- post 命令 --------------------------------------------------------
396
- // 命令面向用户的术语统一为 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.
397
451
 
398
452
  async function cmdPostsList(argv) {
399
453
  const { values } = parse(argv, {
@@ -418,7 +472,7 @@ async function cmdPostsList(argv) {
418
472
  emit(await trpcQuery({ apiOrigin, token }, "grain.list", payload));
419
473
  return;
420
474
  }
421
- // 否则 listRecent(公开 feed
475
+ // otherwise listRecent (public feed)
422
476
  const payload = { limit };
423
477
  if (values.cursor) payload.cursor = values.cursor;
424
478
  if (values.locale) payload.locale = values.locale;
@@ -427,7 +481,7 @@ async function cmdPostsList(argv) {
427
481
 
428
482
  async function cmdPostsGet(argv) {
429
483
  const { positionals } = parse(argv, {});
430
- const id = requirePositional(positionals, 0, "用法:neodrop posts get <postId>");
484
+ const id = requirePositional(positionals, 0, "Usage: neodrop posts get <postId>");
431
485
  const { apiOrigin, token, creds } = authedCtx();
432
486
  emit(await trpcQuery({ apiOrigin, token }, "grain.getById", { id }));
433
487
  note(`🔗 ${postUrl(creds.webOrigin, id)}`);
@@ -439,7 +493,7 @@ async function cmdPostsSearch(argv) {
439
493
  locale: { type: "string" },
440
494
  strict: { type: "boolean" },
441
495
  });
442
- const query = requirePositional(positionals, 0, '用法:neodrop posts search "<query>"');
496
+ const query = requirePositional(positionals, 0, 'Usage: neodrop posts search "<query>"');
443
497
  const { apiOrigin, token } = authedCtx();
444
498
  const payload = { query };
445
499
  const limit = toLimit(values.limit);
@@ -460,7 +514,84 @@ async function cmdFeed(argv) {
460
514
  emit(await trpcQuery({ apiOrigin, token }, "grain.listSubscribed", payload));
461
515
  }
462
516
 
463
- // ---- 兜底通道 ---------------------------------------------------------
517
+ // ---- chat commands ----------------------------------------------------
518
+
519
+ async function cmdChatSend(argv) {
520
+ const { values, positionals } = parse(argv, {
521
+ session: { type: "string" },
522
+ channel: { type: "string" },
523
+ locale: { type: "string", default: "en" },
524
+ timeout: { type: "string" },
525
+ "poll-interval": { type: "string" },
526
+ });
527
+ const text = requirePositional(
528
+ positionals,
529
+ 0,
530
+ 'Usage: neodrop chat "<message>" [--session <id> | --channel <id>] [--locale en] [--timeout <seconds>]\n' +
531
+ " neodrop chat history --session <id>",
532
+ );
533
+ if (values.session && values.channel) {
534
+ throw new UsageError("--session and --channel are mutually exclusive: --session continues an existing session, --channel gets that channel's assistant session");
535
+ }
536
+ const timeoutSec = values.timeout === undefined ? 600 : Number(values.timeout);
537
+ if (!Number.isFinite(timeoutSec) || timeoutSec <= 0) {
538
+ throw new UsageError(`--timeout must be a positive number of seconds, got "${values.timeout}"`);
539
+ }
540
+ const pollSec = values["poll-interval"] === undefined ? 2 : Number(values["poll-interval"]);
541
+ if (!Number.isFinite(pollSec) || pollSec <= 0) {
542
+ throw new UsageError(`--poll-interval must be a positive number of seconds, got "${values["poll-interval"]}"`);
543
+ }
544
+
545
+ const { apiOrigin, token } = authedCtx();
546
+ let sessionId = values.session;
547
+ if (!sessionId) {
548
+ sessionId = await resolveChatSession({ apiOrigin, token, channelId: values.channel });
549
+ note(`session ${sessionId} (continue with: neodrop chat "…" --session ${sessionId})`);
550
+ }
551
+
552
+ const result = await sendAndAwaitReply({
553
+ apiOrigin,
554
+ token,
555
+ sessionId,
556
+ text,
557
+ locale: values.locale,
558
+ timeoutMs: timeoutSec * 1000,
559
+ pollIntervalMs: pollSec * 1000,
560
+ });
561
+ if (!result.reply) {
562
+ note("⚠ No assistant text reply this turn (generation may have failed or been cancelled); newMessages holds every message added this turn.");
563
+ }
564
+ emit(result);
565
+ }
566
+
567
+ async function cmdChatHistory(argv) {
568
+ const { values } = parse(argv, {
569
+ session: { type: "string" },
570
+ });
571
+ if (!values.session) {
572
+ throw new UsageError("Usage: neodrop chat history --session <id>");
573
+ }
574
+ const { apiOrigin, token } = authedCtx();
575
+ emit(
576
+ await trpcQuery({ apiOrigin, token }, "session.getMessages", {
577
+ sessionId: values.session,
578
+ }),
579
+ );
580
+ }
581
+
582
+ async function cmdChatSessions() {
583
+ const { apiOrigin, token } = authedCtx();
584
+ emit(await trpcQuery({ apiOrigin, token }, "session.list"));
585
+ }
586
+
587
+ async function cmdChat(argv) {
588
+ const sub = argv[0];
589
+ if (sub === "history") return cmdChatHistory(argv.slice(1));
590
+ if (sub === "sessions") return cmdChatSessions();
591
+ return cmdChatSend(argv);
592
+ }
593
+
594
+ // ---- escape hatch -----------------------------------------------------
464
595
 
465
596
  async function cmdApi(argv) {
466
597
  const { values, positionals } = parse(argv, {
@@ -469,9 +600,9 @@ async function cmdApi(argv) {
469
600
  mutation: { type: "boolean" },
470
601
  });
471
602
  if (values.json && values.stdin) {
472
- throw new UsageError("--json --stdin 互斥,只能给一个");
603
+ throw new UsageError("--json and --stdin are mutually exclusive, pass only one");
473
604
  }
474
- const procedure = requirePositional(positionals, 0, "用法:neodrop api <procedure> [--json '...' | --stdin] [--mutation]");
605
+ const procedure = requirePositional(positionals, 0, "Usage: neodrop api <procedure> [--json '...' | --stdin] [--mutation]");
475
606
  const { apiOrigin, token } = authedCtx();
476
607
  const input = await loadInput(values);
477
608
  if (values.mutation) {
@@ -481,63 +612,75 @@ async function cmdApi(argv) {
481
612
  }
482
613
  }
483
614
 
484
- // ---- skill 安装 -------------------------------------------------------
615
+ // ---- skill install ----------------------------------------------------
485
616
 
486
617
  async function cmdInstallSkill(argv) {
487
618
  const { values } = parse(argv, {
488
619
  dest: { type: "string" },
489
620
  });
490
621
  const { target, copied } = installSkill({ dest: values.dest });
491
- note(`✅ 已安装 skill ${target}`);
492
- note(` 拷入:${copied.join("")}`);
493
- note(" 重启 Claude Code(或新开会话)后,AI 看到 Neodrop 相关提问会自动调本 skill");
622
+ note(`✅ Installed skill to ${target}`);
623
+ note(` Copied: ${copied.join(", ")}`);
624
+ note(" After restarting Claude Code (or opening a new session), the AI will route Neodrop-related questions to this skill automatically.");
494
625
  emit({ ok: true, target, copied });
495
626
  }
496
627
 
497
- // ---- 帮助 -------------------------------------------------------------
628
+ // ---- help -------------------------------------------------------------
498
629
 
499
- const HELP = `neodrop — Neodrop CLIAI agent 与人类共用,stdout = JSON
630
+ const HELP = `neodrop — Neodrop CLI (shared by AI agents and humans, stdout = JSON)
500
631
 
501
- 用法:
632
+ Usage:
502
633
  npx neodrop-cli <command> [args...]
503
634
 
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
- 频道:
635
+ Meta:
636
+ login [--server <url>] [--api <url>] [--name <name>] Authorize and write a PAT to ~/.neodrop/credentials.json
637
+ logout Revoke the PAT + delete the local credential
638
+ whoami Current token + user info
639
+ me Current user info (user.getMe)
640
+ tokens list List every PAT
641
+ tokens revoke <id> Revoke a specific PAT
642
+ install-skill [--dest <dir>] Install SKILL.md + references into the agent's skill dir
643
+ (default ${defaultSkillDest()})
644
+
645
+ Channels:
515
646
  channels list [--mine] [--limit N] [--cursor C] [--locale L]
516
647
  channels get <channelId>
517
- channels create --name <X> [--description <Y>] [--type PUBLIC|PRIVATE] [--locale L]
518
- channels create --json '{...}' | --stdin
648
+ channels create --name <X> [--prompt "<brief>"] [--description <Y>] [--locale L]
649
+ [--carrier Article|ImagePost|Podcast|Music|Video] [--wait]
650
+ Launch the creation Agent (async, a few minutes)
651
+ channels create-status <taskId> Check a creation task's progress / result
652
+ channels run <channelId> Manually trigger one run of content
519
653
  channels subscribe <channelId>
520
654
  channels unsubscribe <channelId>
521
655
  channels search "<query>" [--limit N] [--locale L] [--strict]
522
656
  channels categories
523
657
  channels by-category <slug> [--limit N] [--cursor C] [--locale L] [--sort latest|popular]
524
658
 
525
- 内容(Post):
659
+ Content (Post):
526
660
  posts list [--subscribed | --channel <id>] [--limit N] [--cursor C] [--locale L]
527
661
  posts get <postId>
528
662
  posts search "<query>" [--limit N] [--locale L] [--strict]
529
663
  feed [--limit N] [--cursor C] = posts list --subscribed
530
664
 
531
- 兜底:
665
+ Chat:
666
+ chat "<message>" [--session <id> | --channel <id>] [--locale L] [--timeout <seconds>]
667
+ Send a message to the AI assistant and wait for the full
668
+ reply (defaults to a new global-assistant session;
669
+ --session continues a session; --channel talks to that
670
+ channel's assistant)
671
+ chat history --session <id> View a session's full message list
672
+ chat sessions List my sessions
673
+
674
+ Escape hatch:
532
675
  api <procedure> [--json '...' | --stdin] [--mutation]
533
676
 
534
- 全局:
535
- --pretty 缩进 JSON 输出(仍是合法 JSON
677
+ Global:
678
+ --pretty Indented JSON output (still valid JSON)
536
679
 
537
- 环境变量:NEODROP_SERVERweb origin)/ NEODROP_APIapi origin
538
- 更多见 SKILL.md references/。`;
680
+ Environment variables: NEODROP_SERVER (web origin) / NEODROP_API (api origin)
681
+ See SKILL.md and references/ for more.`;
539
682
 
540
- // ---- 路由 -------------------------------------------------------------
683
+ // ---- routing ----------------------------------------------------------
541
684
 
542
685
  const TOKENS_SUB = {
543
686
  list: cmdTokensList,
@@ -547,6 +690,8 @@ const CHANNELS_SUB = {
547
690
  list: cmdChannelsList,
548
691
  get: cmdChannelsGet,
549
692
  create: cmdChannelsCreate,
693
+ "create-status": cmdChannelsCreateStatus,
694
+ run: cmdChannelsRun,
550
695
  subscribe: cmdChannelsSubscribe,
551
696
  unsubscribe: cmdChannelsUnsubscribe,
552
697
  search: cmdChannelsSearch,
@@ -562,17 +707,17 @@ const POSTS_SUB = {
562
707
  async function dispatchGroup(name, table, argv) {
563
708
  const sub = argv[0];
564
709
  if (!sub || sub === "--help" || sub === "-h") {
565
- throw new UsageError(`用法:neodrop ${name} <${Object.keys(table).join(" | ")}>`);
710
+ throw new UsageError(`Usage: neodrop ${name} <${Object.keys(table).join(" | ")}>`);
566
711
  }
567
712
  const handler = table[sub];
568
713
  if (!handler) {
569
- throw new UsageError(`未知子命令 ${name} ${sub}(可用:${Object.keys(table).join(" / ")})`);
714
+ throw new UsageError(`Unknown subcommand ${name} ${sub} (available: ${Object.keys(table).join(" / ")})`);
570
715
  }
571
716
  await handler(argv.slice(1));
572
717
  }
573
718
 
574
719
  async function dispatch(rawArgs) {
575
- // 全局 --pretty 可放任意位置:先剥出来,剩下的交给各命令解析。
720
+ // The global --pretty can appear anywhere: strip it out first, hand the rest to each command's parser.
576
721
  const pretty = rawArgs.includes("--pretty");
577
722
  setPretty(pretty);
578
723
  const args = rawArgs.filter((a) => a !== "--pretty");
@@ -597,17 +742,19 @@ async function dispatch(rawArgs) {
597
742
  return dispatchGroup("tokens", TOKENS_SUB, rest);
598
743
  case "channels":
599
744
  return dispatchGroup("channels", CHANNELS_SUB, rest);
600
- case "grains": // 向后兼容:旧命令名,已更名为 posts
745
+ case "grains": // backward compatible: old command name, renamed to posts
601
746
  case "posts":
602
747
  return dispatchGroup("posts", POSTS_SUB, rest);
603
748
  case "feed":
604
749
  return cmdFeed(rest);
750
+ case "chat":
751
+ return cmdChat(rest);
605
752
  case "api":
606
753
  return cmdApi(rest);
607
754
  case "install-skill":
608
755
  return cmdInstallSkill(rest);
609
756
  default:
610
- throw new UsageError(`未知命令「${cmd}」。运行 neodrop --help 看全部命令。`);
757
+ throw new UsageError(`Unknown command "${cmd}". Run neodrop --help to see every command.`);
611
758
  }
612
759
  }
613
760
 
@@ -620,7 +767,7 @@ async function main() {
620
767
  process.exitCode = 2;
621
768
  return;
622
769
  }
623
- // ApiErrortRPC 业务错)与其它运行时错误统一退出码 1
770
+ // ApiError (tRPC business error) and other runtime errors share exit code 1
624
771
  note(`✗ ${err.message}`);
625
772
  process.exitCode = 1;
626
773
  }