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/lib/api.mjs CHANGED
@@ -1,22 +1,25 @@
1
- // tRPC 11 HTTP 调用最小封装(适配 backend 配置的 superjson transformer)。
1
+ // Minimal tRPC 11 HTTP client (matches the backend's superjson transformer).
2
2
  //
3
- // URL 形态:
3
+ // URL shapes:
4
4
  // - query: GET /trpc/<proc>?input=<urlencoded {json:<input>}>
5
5
  // - mutation: POST /trpc/<proc> body = {json:<input>}
6
6
  //
7
- // 响应形态(superjson):
8
- // - 成功:{ result: { data: { json: <T>, meta?: {...} } } }
9
- // - 失败:{ error: { json: { message, code, data: {...} } } }
7
+ // Response shapes (superjson):
8
+ // - success: { result: { data: { json: <T>, meta?: {...} } } }
9
+ // - failure: { error: { json: { message, code, data: {...} } } }
10
10
  //
11
- // 入参 / 出参的 superjson `meta` 字段用于 Date 等非 JSON 类型还原,CLI 用不上
12
- // (凭证 expiresAt 直接用 ISO 字符串),这里只取 `json` 字段。
11
+ // The superjson `meta` field on inputs/outputs restores non-JSON types such as
12
+ // Date, which the CLI doesn't need (credential expiresAt is a plain ISO string),
13
+ // so we only read the `json` field here.
13
14
  //
14
- // 零运行时依赖:用 Node 原生 fetchNode 18+),不引第三方 HTTP 库。
15
+ // Zero runtime dependencies: uses Node's native fetch (Node 18+), no third-party
16
+ // HTTP library.
15
17
 
16
18
  export class ApiError extends Error {
17
- // tRPC 业务错误(HTTP >= 400 body.error 非空)。
18
- // code 来自 tRPC 的错误码('UNAUTHORIZED' / 'NOT_FOUND' / 'BAD_REQUEST' 等),
19
- // CLI 上层可据此分流(如 401 提示重 login)。
19
+ // tRPC business error (HTTP >= 400 or a non-empty body.error).
20
+ // code comes from tRPC's error codes ('UNAUTHORIZED' / 'NOT_FOUND' /
21
+ // 'BAD_REQUEST', etc.), so the CLI can branch on it (e.g. prompt to re-login
22
+ // on 401).
20
23
  constructor(message, code = "", httpStatus = 0) {
21
24
  super(code ? `[${code}] ${message}` : message);
22
25
  this.name = "ApiError";
@@ -28,14 +31,15 @@ export class ApiError extends Error {
28
31
  function buildUrl(apiOrigin, proc, inputValue) {
29
32
  const base = `${apiOrigin.replace(/\/+$/, "")}/trpc/${proc}`;
30
33
  if (inputValue === undefined) return base;
31
- // superjson 入参 wrapper{ json: <value> }
34
+ // superjson input wrapper: { json: <value> }
32
35
  const qs = new URLSearchParams({ input: JSON.stringify({ json: inputValue }) });
33
36
  return `${base}?${qs.toString()}`;
34
37
  }
35
38
 
36
- // Cloudflare WAF 看到默认 UA 会按 bot 拒(HTTP 403 + error code 1010)。给一个
37
- // 老实的客户端身份——告诉 CF/origin「这是 neodrop-cli」,便于排查与白名单。
38
- // UA 不是为了伪装,是为了通过基础的 client fingerprint check。
39
+ // Cloudflare WAF rejects the default UA as a bot (HTTP 403 + error code 1010).
40
+ // Present an honest client identity — telling CF/origin "this is neodrop-cli"
41
+ // makes debugging and allowlisting easier. Setting the UA is not about
42
+ // disguise, it's about passing the basic client fingerprint check.
39
43
  const USER_AGENT = "neodrop-cli/1.0 (+https://github.com/NeoDropAI/neodrop-skills)";
40
44
 
41
45
  async function doRequest({ method, url, token, body }) {
@@ -46,11 +50,13 @@ async function doRequest({ method, url, token, body }) {
46
50
  };
47
51
  if (token) headers.authorization = `Bearer ${token}`;
48
52
 
49
- // 一次 transparent retry——线上 Cloudflare/upstream 偶发 TLS-layer 抖动
50
- // "EOF occurred in violation of protocol" 等)。mutation 也加 retry:tRPC
51
- // mutation 在网络抖动 + 业务层未提交时是幂等可重的(最多多签发一个 PAT/订阅,
52
- // 可接受代价)。注意 fetch 4xx/5xx 不抛错——那由 handleResponse 处理,
53
- // 这里只 retry 真正的网络层异常。
53
+ // One transparent retry — production Cloudflare/upstream occasionally has
54
+ // TLS-layer hiccups ("EOF occurred in violation of protocol", etc.). Retry is
55
+ // applied to mutations too: a tRPC mutation is idempotent-enough to retry when
56
+ // the network flaked before the business layer committed (worst case, one
57
+ // extra PAT/subscription is issued — an acceptable cost). Note that fetch does
58
+ // not throw on 4xx/5xx — that's handled by handleResponse; here we only retry
59
+ // true network-layer failures.
54
60
  let lastErr;
55
61
  for (let i = 0; i < 2; i++) {
56
62
  try {
@@ -59,11 +65,12 @@ async function doRequest({ method, url, token, body }) {
59
65
  lastErr = err;
60
66
  }
61
67
  }
62
- // Node fetch 把真正的原因藏在 err.cause(如 ECONNREFUSED / ENOTFOUND /
63
- // self-signed certificate);err.message 通常只是笼统的 "fetch failed"。
68
+ // Node fetch hides the real reason in err.cause (e.g. ECONNREFUSED /
69
+ // ENOTFOUND / self-signed certificate); err.message is usually just the
70
+ // generic "fetch failed".
64
71
  const cause = lastErr?.cause;
65
72
  const detail = cause?.code || cause?.message || lastErr?.message || String(lastErr);
66
- throw new Error(`连接失败:${detail}`);
73
+ throw new Error(`Connection failed: ${detail}`);
67
74
  }
68
75
 
69
76
  async function handleResponse(res) {
@@ -73,7 +80,7 @@ async function handleResponse(res) {
73
80
  try {
74
81
  body = JSON.parse(text);
75
82
  } catch {
76
- throw new Error(`非 JSON 响应(HTTP ${res.status}):${text.slice(0, 200)}`);
83
+ throw new Error(`Non-JSON response (HTTP ${res.status}): ${text.slice(0, 200)}`);
77
84
  }
78
85
  }
79
86
 
@@ -85,7 +92,7 @@ async function handleResponse(res) {
85
92
  throw new ApiError(msg, code, res.status);
86
93
  }
87
94
 
88
- // superjson 响应剥层
95
+ // Unwrap the superjson response
89
96
  return body.result.data.json;
90
97
  }
91
98
 
@@ -96,8 +103,8 @@ export async function trpcQuery(opts, proc, inputValue) {
96
103
  }
97
104
 
98
105
  export async function trpcMutation(opts, proc, inputValue) {
99
- const url = buildUrl(opts.apiOrigin, proc); // mutation 不走 query input
100
- // mutation 永远发 JSON body:input 为 undefined 时发 {"json": null}
106
+ const url = buildUrl(opts.apiOrigin, proc); // mutations don't use query input
107
+ // A mutation always sends a JSON body: {"json": null} when input is undefined
101
108
  const body = JSON.stringify({ json: inputValue === undefined ? null : inputValue });
102
109
  const res = await doRequest({ method: "POST", url, token: opts.token, body });
103
110
  return handleResponse(res);
package/lib/chat.mjs CHANGED
@@ -1,17 +1,23 @@
1
- // chat 能力:发一条消息给 Neodrop 的对话 agent,等回复生成完,拿完整回复。
1
+ // chat capability: send one message to Neodrop's conversational agent, wait for
2
+ // the reply to finish generating, and return the complete reply.
2
3
  //
3
- // 链路(全部走用户 PATBearer 鉴权):
4
- // 1. / 复用会话:session.create(全局助手)或
5
- // session.getOrCreateChannelAssistant(--channel 频道助手)——tRPC mutation
6
- // 2. 发送:POST /api/chat(非 tRPC Hono 端点,后端 2026-07 起支持 PAT Bearer)。
7
- // 响应是 SSE 流;回复由后端 BullMQ worker 生成、与本连接生命周期解耦,
8
- // CLI 不解析流内容,只把「流读到 EOF」当作大概率完成的信号。
9
- // 3. 收敛:轮询 session.getActiveChatTurn 直到 null(turn terminal——
10
- // SSE 中途断线也靠这层兜住),再 session.getMessages 取本次 user 消息
11
- // 之后新增的消息作为回复。最终一致以 getMessages 为准,不信任流。
4
+ // Flow (everything uses the user's PAT with Bearer auth):
5
+ // 1. Create / reuse a session: session.create (global assistant) or
6
+ // session.getOrCreateChannelAssistant (--channel assistant) — tRPC mutation.
7
+ // 2. Send: POST /api/chat (a non-tRPC Hono endpoint; the backend has supported
8
+ // PAT Bearer since 2026-07). The response is an SSE stream; the reply is
9
+ // generated by a backend BullMQ worker decoupled from this connection's
10
+ // lifecycle, so the CLI does not parse the stream contents and only treats
11
+ // "the stream reached EOF" as a likely-done signal.
12
+ // 3. Settle: poll session.getActiveChatTurn until null (the turn has reached a
13
+ // terminal state — this layer also covers a mid-stream SSE disconnect),
14
+ // then session.getMessages to take the messages added after this user
15
+ // message as the reply. Final consistency relies on getMessages, not the
16
+ // stream.
12
17
  //
13
- // 为什么不解析 SSEUIMessage chunk 协议类型多且随主仓演进,CLI 的消费者是
14
- // AI agent,要的是「一次拿到完整回复的 JSON」,不是逐 token 渲染。
18
+ // Why we don't parse the SSE: the UIMessage chunk protocol has many types and
19
+ // evolves with the main repo, and the CLI's consumer is an AI agent that wants
20
+ // "the complete reply as JSON in one shot", not token-by-token rendering.
15
21
 
16
22
  import { randomUUID } from "node:crypto";
17
23
  import { trpcMutation, trpcQuery } from "./api.mjs";
@@ -23,8 +29,9 @@ function sleep(ms) {
23
29
  return new Promise((resolve) => setTimeout(resolve, ms));
24
30
  }
25
31
 
26
- // POST /api/chat 发送消息。成功返回 Response(SSE 已开始);HTTP >= 400
27
- // 解析 JSON error 抛出(402 余额不足 / 401 未登录 / 400 消息非法等)。
32
+ // POST /api/chat to send a message. On success returns the Response (the SSE
33
+ // stream has started); on HTTP >= 400 it parses the JSON error and throws
34
+ // (402 insufficient balance / 401 not logged in / 400 invalid message, etc.).
28
35
  async function postChatMessage({ apiOrigin, token, sessionId, text, locale, signal }) {
29
36
  const url = `${apiOrigin.replace(/\/+$/, "")}/api/chat`;
30
37
  const body = {
@@ -51,29 +58,31 @@ async function postChatMessage({ apiOrigin, token, sessionId, text, locale, sign
51
58
  try {
52
59
  payload = await res.json();
53
60
  } catch {
54
- // JSON 错误体,用 HTTP 状态兜底
61
+ // Non-JSON error body; fall back to the HTTP status
55
62
  }
56
63
  const msg = (payload && (payload.error || payload.message)) || `HTTP ${res.status}`;
57
64
  const code = (payload && payload.code) || "";
58
- throw new Error(code ? `[${code}] ${msg}` : `发送失败:${msg}`);
65
+ throw new Error(code ? `[${code}] ${msg}` : `Send failed: ${msg}`);
59
66
  }
60
67
  return { response: res, userMessageId: body.message.id };
61
68
  }
62
69
 
63
- // SSE 流到 EOF(内容丢弃)。断线 / 超时都不算失败——后端 worker 与连接
64
- // 解耦,最终状态交给 getActiveChatTurn 轮询收敛。
70
+ // Read the SSE stream to EOF (contents discarded). A disconnect / timeout is not
71
+ // a failure — the backend worker is decoupled from the connection, and the final
72
+ // state is settled by polling getActiveChatTurn.
65
73
  async function drainStream(response) {
66
74
  try {
67
75
  if (!response.body) return;
68
76
  for await (const _chunk of response.body) {
69
- // 只等 EOF,不解析
77
+ // Just wait for EOF, don't parse
70
78
  }
71
79
  } catch {
72
- note("⚠ SSE 连接中断,转轮询等待回复…");
80
+ note("⚠ SSE connection dropped, falling back to polling for the reply…");
73
81
  }
74
82
  }
75
83
 
76
- // 轮询直到 session 没有 live chat turn(回复已生成完 / 失败 / 取消)。
84
+ // Poll until the session has no live chat turn (the reply has finished /
85
+ // failed / been cancelled).
77
86
  async function pollUntilTurnSettled({ apiOrigin, token, sessionId, deadline, intervalMs }) {
78
87
  for (;;) {
79
88
  const turn = await trpcQuery({ apiOrigin, token }, "session.getActiveChatTurn", {
@@ -82,7 +91,7 @@ async function pollUntilTurnSettled({ apiOrigin, token, sessionId, deadline, int
82
91
  if (!turn) return;
83
92
  if (Date.now() > deadline) {
84
93
  throw new Error(
85
- `等待回复超时(turn=${turn.id} 仍在 ${turn.status})。稍后可用 chat history --session ${sessionId} 查看结果。`,
94
+ `Timed out waiting for the reply (turn=${turn.id} still ${turn.status}). You can check the result later with chat history --session ${sessionId}.`,
86
95
  );
87
96
  }
88
97
  await sleep(intervalMs);
@@ -97,12 +106,50 @@ function extractText(parts) {
97
106
  .join("");
98
107
  }
99
108
 
109
+ // Noise the raw message payload carries but a CLI consumer never needs: multi-KB
110
+ // provider signatures, provider metadata, and per-call execution plumbing.
111
+ const NOISE_KEYS = new Set([
112
+ "providerMetadata",
113
+ "callProviderMetadata",
114
+ "resultProviderMetadata",
115
+ "signature",
116
+ "startedAt",
117
+ "finishedAt",
118
+ "durationMs",
119
+ "stepNumber",
120
+ "toolCallId",
121
+ ]);
122
+
123
+ function stripNoise(value) {
124
+ if (Array.isArray(value)) return value.map(stripNoise);
125
+ if (value && typeof value === "object") {
126
+ const out = {};
127
+ for (const [k, v] of Object.entries(value)) {
128
+ if (NOISE_KEYS.has(k)) continue;
129
+ out[k] = stripNoise(v);
130
+ }
131
+ return out;
132
+ }
133
+ return value;
134
+ }
135
+
136
+ // Slim a message for output: drop chain-of-thought `reasoning` parts (internal
137
+ // thinking, not the answer) and strip the noise keys above from what remains.
138
+ export function slimMessage(m) {
139
+ const cleaned = stripNoise(m);
140
+ if (Array.isArray(cleaned.parts)) {
141
+ cleaned.parts = cleaned.parts.filter((p) => p && p.type !== "reasoning");
142
+ }
143
+ return cleaned;
144
+ }
145
+
100
146
  /**
101
- * 发送一条消息并等待完整回复。
147
+ * Send one message and wait for the complete reply.
102
148
  *
103
149
  * @returns {Promise<{sessionId: string, reply: {text: string, parts: unknown[]} | null, newMessages: unknown[]}>}
104
- * reply 是本次新增消息里最后一条 assistant 消息(text = text parts 拼接);
105
- * turn 失败等场景下可能没有 assistant 回复,reply null,调用方看 newMessages 自行判断。
150
+ * reply is the last assistant message among the newly added messages (text =
151
+ * its text parts joined); in cases such as a failed turn there may be no
152
+ * assistant reply, so reply is null and the caller decides based on newMessages.
106
153
  */
107
154
  export async function sendAndAwaitReply({
108
155
  apiOrigin,
@@ -116,8 +163,17 @@ export async function sendAndAwaitReply({
116
163
  const deadline = Date.now() + timeoutMs;
117
164
  const abort = AbortSignal.timeout(timeoutMs);
118
165
 
119
- note(`→ 发送到 session ${sessionId} …`);
120
- const { response, userMessageId } = await postChatMessage({
166
+ // Snapshot existing message ids before sending: the backend assigns the user
167
+ // message its own id (it does not echo the message.id we sent), so we can't use
168
+ // the client id as an anchor — we diff against "messages that appeared after
169
+ // sending".
170
+ const before = await trpcQuery({ apiOrigin, token }, "session.getMessages", {
171
+ sessionId,
172
+ });
173
+ const beforeIds = new Set(before.map((m) => m.id));
174
+
175
+ note(`→ Sending to session ${sessionId} …`);
176
+ const { response } = await postChatMessage({
121
177
  apiOrigin,
122
178
  token,
123
179
  sessionId,
@@ -126,7 +182,7 @@ export async function sendAndAwaitReply({
126
182
  signal: abort,
127
183
  });
128
184
 
129
- note("… 回复生成中");
185
+ note("… generating reply");
130
186
  await drainStream(response);
131
187
  await pollUntilTurnSettled({
132
188
  apiOrigin,
@@ -139,23 +195,25 @@ export async function sendAndAwaitReply({
139
195
  const messages = await trpcQuery({ apiOrigin, token }, "session.getMessages", {
140
196
  sessionId,
141
197
  });
142
- // 本次 user 消息之后新增的所有消息 = 这轮回复(含 tool / data 卡片等中间产物)
143
- const anchor = messages.findIndex((m) => m.id === userMessageId);
144
- const newMessages = anchor >= 0 ? messages.slice(anchor + 1) : [];
145
- const lastAssistant = [...newMessages]
146
- .reverse()
147
- .find((m) => m.role === "assistant");
198
+ // All messages added this round (the diff); dropping the user message we just
199
+ // sent leaves this round's reply (assistant text + intermediate artifacts such
200
+ // as tool / data cards).
201
+ const fresh = messages.filter((m) => !beforeIds.has(m.id));
202
+ const lastUserIdx = fresh.map((m) => m.role).lastIndexOf("user");
203
+ const turn = lastUserIdx >= 0 ? fresh.slice(lastUserIdx + 1) : fresh;
204
+ const lastAssistant = [...turn].reverse().find((m) => m.role === "assistant");
148
205
 
149
206
  return {
150
207
  sessionId,
151
- reply: lastAssistant
152
- ? { text: extractText(lastAssistant.parts), parts: lastAssistant.parts }
153
- : null,
154
- newMessages,
208
+ // reply.text is the assistant's final text — empty when it only asked a
209
+ // clarifying question via a tool (read newMessages then). The structured
210
+ // content lives in newMessages, so reply carries just the text (no dup).
211
+ reply: lastAssistant ? { text: extractText(lastAssistant.parts) } : null,
212
+ newMessages: turn.map(slimMessage),
155
213
  };
156
214
  }
157
215
 
158
- /** 建新会话(全局助手),或按 --channel 拿该频道的助手会话。 */
216
+ /** Create a new session (global assistant), or get the channel's assistant session per --channel. */
159
217
  export async function resolveChatSession({ apiOrigin, token, channelId }) {
160
218
  if (channelId) {
161
219
  const session = await trpcMutation(
@@ -1,14 +1,16 @@
1
- // 本地凭证(~/.neodrop/credentials.jsonchmod 0600)。
1
+ // Local credentials (~/.neodrop/credentials.json, chmod 0600).
2
2
  //
3
- // 只支持单一 active token——切换 server / 换号都走 logout → login。
3
+ // Only a single active token is supported — switching server / account goes
4
+ // through logout → login.
4
5
  //
5
- // 凭证 schema
6
- // webOrigin string 产品域(neodrop.ai;本地 dev 4001
7
- // apiOrigin string API 域(api.neodrop.ai;本地 dev 3001)—— web 拆开是因为部署不同域
8
- // token string PAT 明文,仅本机;权限 0600
9
- // tokenId string 用于 logout / 远程撤销
10
- // name string /settings/cli-tokens 上展示的客户端名
11
- // expiresAt string ISO 8601,默认 90
6
+ // Credential schema:
7
+ // webOrigin string product origin (neodrop.ai; local dev 4001)
8
+ // apiOrigin string API origin (api.neodrop.ai; local dev 3001) split from
9
+ // web because they deploy on different domains
10
+ // token string PAT in plaintext, local machine only; mode 0600
11
+ // tokenId string used for logout / remote revocation
12
+ // name string client name shown on /settings/cli-tokens
13
+ // expiresAt string ISO 8601, defaults to 90 days
12
14
  // createdAt string ISO 8601
13
15
 
14
16
  import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
@@ -27,12 +29,14 @@ export function readCredentials() {
27
29
  try {
28
30
  return JSON.parse(readFileSync(FILE_PATH, "utf-8"));
29
31
  } catch (err) {
30
- throw new Error(`无法解析 ${FILE_PATH}:${err.message}`);
32
+ throw new Error(`Failed to parse ${FILE_PATH}: ${err.message}`);
31
33
  }
32
34
  }
33
35
 
34
- // 原子写:写临时文件 → chmod 0600 → rename。chmod rename 之前完成,确保最终
35
- // 文件出现的瞬间权限就是 0600,不存在其他进程能读到 world-readable 的窗口。
36
+ // Atomic write: write temp file → chmod 0600 → rename. The chmod completes
37
+ // before the rename, guaranteeing the final file has 0600 permissions the moment
38
+ // it appears — there is no window where another process could read it as
39
+ // world-readable.
36
40
  export function writeCredentials(creds) {
37
41
  mkdirSync(FILE_DIR, { recursive: true });
38
42
  const tmp = `${FILE_PATH}.tmp`;
@@ -52,7 +56,7 @@ export function clearCredentials() {
52
56
  export function requireCredentials() {
53
57
  const creds = readCredentials();
54
58
  if (creds === null) {
55
- throw new Error("未登录。先运行:npx neodrop-cli login");
59
+ throw new Error("Not logged in. Run: npx neodrop-cli login");
56
60
  }
57
61
  return creds;
58
62
  }
@@ -1,12 +1,15 @@
1
- // SKILL.md + references/ 拷进 agent skill 目录,让 `npx neodrop-cli` 一条命令
2
- // 既装好 CLI 又装好 skill 描述。
1
+ // Copy SKILL.md + references/ into the agent's skill directory so that a single
2
+ // `npx neodrop-cli` command installs both the CLI and the skill description.
3
3
  //
4
- // npm/npx 只分发「可执行文件」;而 Claude Code agent skill SKILL.md +
5
- // references/ 这组文件落进 agent skill 目录后才会被路由。两条分发渠道本来是
6
- // 分开的,这个命令把它们合并成一步。
4
+ // npm/npx only distributes "executables"; an agent skill for Claude Code and the
5
+ // like is the SKILL.md + references/ file set, which is only routed once it lands
6
+ // in the agent's skill directory. The two distribution channels are normally
7
+ // separate, and this command merges them into one step.
7
8
  //
8
- // 默认目标 ~/.claude/skills/neodrop-cli/(Claude Code 约定);--dest 可改到别的
9
- // agent skill 目录。目录名固定 neodrop-cli,与 SKILL.md frontmatter name 一致。
9
+ // The default target is ~/.claude/skills/neodrop-cli/ (the Claude Code
10
+ // convention); --dest can point at another agent's skill directory. The
11
+ // directory name is fixed to neodrop-cli, matching the name in SKILL.md's
12
+ // frontmatter.
10
13
 
11
14
  import { cpSync, existsSync, mkdirSync } from "node:fs";
12
15
  import { homedir } from "node:os";
@@ -18,14 +21,14 @@ export function defaultSkillDest() {
18
21
  }
19
22
 
20
23
  export function installSkill({ dest } = {}) {
21
- // 包根目录 = 本文件所在 lib/ 的上一层(bin/ lib/ SKILL.md 同级)
24
+ // Package root = the parent of this file's lib/ dir (bin/, lib/, and SKILL.md are siblings)
22
25
  const here = dirname(fileURLToPath(import.meta.url)); // .../lib
23
26
  const pkgRoot = dirname(here);
24
27
  const target = dest || defaultSkillDest();
25
28
 
26
29
  const skillSrc = join(pkgRoot, "SKILL.md");
27
30
  if (!existsSync(skillSrc)) {
28
- throw new Error(`找不到 SKILL.md(期望在 ${skillSrc});npm 包可能未按 files 白名单打进 SKILL.md`);
31
+ throw new Error(`SKILL.md not found (expected at ${skillSrc}); the npm package may not have included SKILL.md in its files allowlist`);
29
32
  }
30
33
 
31
34
  mkdirSync(target, { recursive: true });
package/lib/origins.mjs CHANGED
@@ -1,10 +1,13 @@
1
- // Neodrop 部署的 web origin(产品域)与 api originbackend 域)解耦。
1
+ // Neodrop deploys the web origin (product domain) and api origin (backend
2
+ // domain) decoupled.
2
3
  //
3
- // 线上:web = https://neodrop.aiapi = https://api.neodrop.ai
4
- // 本地 devweb = http://localhost:4001api = http://localhost:3001
4
+ // Production: web = https://neodrop.ai, api = https://api.neodrop.ai
5
+ // Local dev: web = http://localhost:4001, api = http://localhost:3001
5
6
  //
6
- // CLI 让用户显式传 --api;不传时按 web origin 启发式推断。self-host 用户默认
7
- // 假设 backend 反代到 web 同域 /trpc/*,需要时用 --api 覆盖。
7
+ // The CLI lets the user pass --api explicitly; when omitted it is inferred
8
+ // heuristically from the web origin. For self-hosted users the default assumes
9
+ // the backend is reverse-proxied on the same domain as web at /trpc/*, which can
10
+ // be overridden with --api when needed.
8
11
 
9
12
  export function inferApiOrigin(webOrigin) {
10
13
  let parsed;
@@ -17,16 +20,17 @@ export function inferApiOrigin(webOrigin) {
17
20
  const port = parsed.port;
18
21
  const scheme = parsed.protocol.replace(/:$/, "");
19
22
 
20
- // 线上 neodrop.ai → api.neodrop.ai
23
+ // Production neodrop.ai → api.neodrop.ai
21
24
  if (host === "neodrop.ai") {
22
25
  return `${scheme}://api.neodrop.ai`;
23
26
  }
24
27
 
25
- // 本地 devlocalhost:4001 / 127.0.0.1:4001 → host 3001
28
+ // Local dev: localhost:4001 / 127.0.0.1:4001 → same host on 3001
26
29
  if ((host === "localhost" || host === "127.0.0.1") && port === "4001") {
27
30
  return `${scheme}://${host}:3001`;
28
31
  }
29
32
 
30
- // 其他(self-host 反代等):默认与 web 同域,假设 /trpc/* 反代到 backend。
33
+ // Otherwise (self-host reverse proxy, etc.): default to the same domain as
34
+ // web, assuming /trpc/* is proxied to the backend.
31
35
  return webOrigin.replace(/\/+$/, "");
32
36
  }
package/lib/output.mjs CHANGED
@@ -1,7 +1,7 @@
1
- // 统一输出:stdout = JSONAI 直接 JSON.parse),stderr = 日志/提示。
1
+ // Unified output: stdout = JSON (an AI can JSON.parse directly), stderr = logs/notices.
2
2
  //
3
- // 默认单行 JSON;--pretty 2 空格缩进 JSON——两者都是合法 JSON,AI 不需要
4
- // flag 切换也能解析。
3
+ // Single-line JSON by default; --pretty switches to 2-space indented JSON both
4
+ // are valid JSON, so an AI can parse either without needing the flag.
5
5
 
6
6
  let pretty = false;
7
7
 
package/lib/web-urls.mjs CHANGED
@@ -1,17 +1,23 @@
1
- // Neodrop 前端 URL 拼装:用户拿到 id 后能直接得到一条可点击的网页链接。
1
+ // Neodrop frontend URL assembly: once a user has an id, they get a clickable web
2
+ // link directly.
2
3
  //
3
- // 为什么放 skill 而不是后端 response:URL 路径(`/post/<id>` / `/channel/<id>` /
4
- // `/user/<id>`)是前端的渲染契约,不是数据契约——`grain.id` 是稳定的数据;前端
5
- // 明天可以把路由改成 `/post/<id>` 而无需后端发新版 API。把路由塞进 response 会让
6
- // backend 反向依赖前端渲染。这里给 CLI 内部用,作为 stderr 上的人类可读提示。
4
+ // Why this lives in the skill rather than the backend response: URL paths
5
+ // (`/post/<id>` / `/channel/<id>` / `/user/<id>`) are a frontend rendering
6
+ // contract, not a data contract — `grain.id` is stable data; the frontend could
7
+ // change the route to `/post/<id>` tomorrow without the backend shipping a new
8
+ // API version. Baking routes into the response would make the backend depend
9
+ // backwards on frontend rendering. This is for CLI internal use, as a
10
+ // human-readable hint on stderr.
7
11
  //
8
- // 路径权威源 = `apps/web/src/app/[locale]/<route>/[id]/page.tsx`:
12
+ // Path source of truth = `apps/web/src/app/[locale]/<route>/[id]/page.tsx`:
9
13
  // post / grain → `/post/<id>`
10
14
  // channel → `/channel/<id>`
11
15
  // user → `/user/<id>`
12
16
  //
13
- // 注意:CLI 不挂 locale 前缀(neodrop.ai 的 localePrefix='as-needed',默认 locale
14
- // `en` 落到无前缀路径;其它 locale 由前端按用户偏好做客户端 redirect)。
17
+ // Note: the CLI attaches no locale prefix (neodrop.ai uses
18
+ // localePrefix='as-needed', so the default locale `en` maps to the prefix-less
19
+ // path; other locales are handled by a client-side redirect based on user
20
+ // preference).
15
21
 
16
22
  function strip(origin) {
17
23
  return origin.replace(/\/+$/, "");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "neodrop-cli",
3
- "version": "1.2.0",
3
+ "version": "2.0.1",
4
4
  "description": "Neodrop (neodrop.ai) CLI — let your AI agent (Claude Code / Cursor / Codex) browse channels, read posts, manage subscriptions, create channels and chat with Neodrop's AI assistants as you. stdout is always valid JSON. npx neodrop-cli login and go.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -27,12 +27,38 @@
27
27
  | `neodrop channels by-category <category> --sort latest --limit 20` | Channels in a category. |
28
28
  | `neodrop channels search "<query>" --locale en --limit 10` | Full-text search over the public pool. |
29
29
 
30
- **Create & subscribe**
30
+ **Create (async — runs the creation agent)**
31
+
32
+ `channels create` starts the **same creation-agent pipeline as the web wizard**: it immediately creates the channel in `DRAFT`, then an agent researches the topic, generates the runnable configuration (requirement / sources / schedule) and activates the channel. This usually takes **a few minutes** and consumes credits.
33
+
34
+ | Command | What it does |
35
+ |---|---|
36
+ | `neodrop channels create --name "<name>" --prompt "<brief>" --locale en` | Start creation; returns the `agentTask` right away (`task.id` + `task.channelId`). |
37
+ | `neodrop channels create --name "<name>" --prompt "<brief>" --wait` | Block until the task reaches a terminal status (polls every 15s, 20min cap). |
38
+ | `neodrop channels create-status <taskId>` | Poll an in-flight creation task. |
39
+
40
+ | Flag | Purpose |
41
+ |---|---|
42
+ | `--name` (required) | Channel name shown to users. |
43
+ | `--prompt` | Free-form creation instructions for the agent — topic, audience, style, cadence. Strongly recommended; without it the agent only has the name to go on. |
44
+ | `--description` | One-line channel intro (shown on the channel page; not the agent instructions). |
45
+ | `--carrier Article\|ImagePost\|Podcast\|Music\|Video` | Content modality; omit to let the platform default (Article). |
46
+ | `--json '{...}' \| --stdin` | Advanced — raw `agentTask.create` input (`channelName` / `channelDescription` / `description` (= prompt) / `locale` / `contentCarrier`). |
47
+
48
+ - Task `status`: `PENDING` / `RUNNING` → in progress; `COMPLETED` → done; `FAILED` → failed (exit 1 with `--wait`); `PAUSED` → out of credits, auto-resumes after topping up.
49
+ - New channels default to **PUBLIC** visibility; flip it later on the web manage page.
50
+ - A bare `channel.create` (shell only, stays `DRAFT` forever, no runnable config) is intentionally **not** a sugar command; if you really need it: `api channel.create --json '{"name":"X"}' --mutation`.
51
+
52
+ **Run (produce one issue now)**
53
+
54
+ | Command | What it does |
55
+ |---|---|
56
+ | `neodrop channels run <channelId>` | `channel.triggerRun` — manual production run. Requires a runnable config (creation finished; a `DRAFT` with config is auto-activated). Fails with a clear error while creation is still in progress. |
57
+
58
+ **Subscribe**
31
59
 
32
60
  | Command | What it does |
33
61
  |---|---|
34
- | `neodrop channels create --name "<name>" --description "<desc>" --locale en` | Create a channel from flags. |
35
- | `neodrop channels create --json '{"name":"X","locale":"en","type":"PRIVATE"}'` | Create a channel from a full JSON payload. |
36
62
  | `neodrop channels subscribe <channelId>` | Subscribe to a channel. |
37
63
  | `neodrop channels unsubscribe <channelId>` | Unsubscribe from a channel. |
38
64
 
@@ -89,6 +115,7 @@ For tRPC procedures with no sugar command, fall back to `api`:
89
115
 
90
116
  - **Defaults to a GET query** — every write MUST add `--mutation` explicitly.
91
117
  - To find a procedure's full name, check the main repo's `packages/backend/src/api/trpc/routers.ts`, or probe `curl /api/trpc/<router>.<procedure>?input=...` against a dev backend.
118
+ - **Field contracts are strict where it matters**: `channel.update` accepts exactly `{id, name?, description?, avatar?}` and `channel.create` exactly `{name, description?, type?, locale?}` — unknown keys are rejected with `BAD_REQUEST` (they are **not** a way to write channel configuration; the runnable config is owned by the creation agent, see [channels → Create](#channels)). Other procedures may still silently strip unknown keys (standard zod behavior), so don't infer "accepted" from a success response — verify the response body.
92
119
 
93
120
  ## Global flags
94
121
 
@@ -25,7 +25,9 @@ When the CLI errors, **first read the error code in brackets on stderr**, then m
25
25
  | Symptom | Meaning | Fix |
26
26
  |---|---|---|
27
27
  | `[NOT_FOUND]` | id / slug doesn't exist | List with `channels list` / `posts list` first to verify the id |
28
- | `[BAD_REQUEST]` | Input schema is wrong (most common with `--json`) | Read the stderr detail; cross-check `neodrop <cmd> --help`; for complex input, get it working with a sugar command first, then drop to `--json` |
28
+ | `[BAD_REQUEST]` | Input schema is wrong (most common with `--json`), including unknown keys on `channel.create` / `channel.update` — those two reject unrecognized fields instead of stripping them | Read the stderr detail; cross-check `neodrop <cmd> --help` and the field contracts in [commands.md#api](commands.md#api); for complex input, get it working with a sugar command first, then drop to `--json` |
29
+ | `[FORBIDDEN]` mentioning credits on `channels create` | Channel creation costs credits and the account balance is insufficient | Ask the user to top up / check in on the web app, then retry |
30
+ | Error saying the channel is still a draft with no runnable config on `channels run` | The channel is a bare `DRAFT` shell — creation never ran or is still in progress | If a creation task exists: poll `channels create-status <taskId>` until `COMPLETED`, then rerun. If the channel was created as a bare shell (old CLI ≤1.x or raw `api channel.create`): create it properly with `channels create --name ... --prompt ...` — a shell can't be activated in place |
29
31
  | `[INTERNAL_SERVER_ERROR]` | The backend crashed | Retry once; if it persists, open an issue with the full stderr |
30
32
 
31
33
  ## Environment