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/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 ADDED
@@ -0,0 +1,192 @@
1
+ // chat capability: send one message to Neodrop's conversational agent, wait for
2
+ // the reply to finish generating, and return the complete reply.
3
+ //
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.
17
+ //
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.
21
+
22
+ import { randomUUID } from "node:crypto";
23
+ import { trpcMutation, trpcQuery } from "./api.mjs";
24
+ import { note } from "./output.mjs";
25
+
26
+ const USER_AGENT = "neodrop-cli/1.0 (+https://github.com/NeoDropAI/neodrop-skills)";
27
+
28
+ function sleep(ms) {
29
+ return new Promise((resolve) => setTimeout(resolve, ms));
30
+ }
31
+
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.).
35
+ async function postChatMessage({ apiOrigin, token, sessionId, text, locale, signal }) {
36
+ const url = `${apiOrigin.replace(/\/+$/, "")}/api/chat`;
37
+ const body = {
38
+ sessionId,
39
+ locale,
40
+ message: {
41
+ id: randomUUID(),
42
+ role: "user",
43
+ parts: [{ type: "text", text }],
44
+ },
45
+ };
46
+ const res = await fetch(url, {
47
+ method: "POST",
48
+ headers: {
49
+ "content-type": "application/json",
50
+ "user-agent": USER_AGENT,
51
+ authorization: `Bearer ${token}`,
52
+ },
53
+ body: JSON.stringify(body),
54
+ signal,
55
+ });
56
+ if (res.status >= 400) {
57
+ let payload = null;
58
+ try {
59
+ payload = await res.json();
60
+ } catch {
61
+ // Non-JSON error body; fall back to the HTTP status
62
+ }
63
+ const msg = (payload && (payload.error || payload.message)) || `HTTP ${res.status}`;
64
+ const code = (payload && payload.code) || "";
65
+ throw new Error(code ? `[${code}] ${msg}` : `Send failed: ${msg}`);
66
+ }
67
+ return { response: res, userMessageId: body.message.id };
68
+ }
69
+
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.
73
+ async function drainStream(response) {
74
+ try {
75
+ if (!response.body) return;
76
+ for await (const _chunk of response.body) {
77
+ // Just wait for EOF, don't parse
78
+ }
79
+ } catch {
80
+ note("⚠ SSE connection dropped, falling back to polling for the reply…");
81
+ }
82
+ }
83
+
84
+ // Poll until the session has no live chat turn (the reply has finished /
85
+ // failed / been cancelled).
86
+ async function pollUntilTurnSettled({ apiOrigin, token, sessionId, deadline, intervalMs }) {
87
+ for (;;) {
88
+ const turn = await trpcQuery({ apiOrigin, token }, "session.getActiveChatTurn", {
89
+ sessionId,
90
+ });
91
+ if (!turn) return;
92
+ if (Date.now() > deadline) {
93
+ throw new Error(
94
+ `Timed out waiting for the reply (turn=${turn.id} still ${turn.status}). You can check the result later with chat history --session ${sessionId}.`,
95
+ );
96
+ }
97
+ await sleep(intervalMs);
98
+ }
99
+ }
100
+
101
+ function extractText(parts) {
102
+ if (!Array.isArray(parts)) return "";
103
+ return parts
104
+ .filter((p) => p && p.type === "text" && typeof p.text === "string")
105
+ .map((p) => p.text)
106
+ .join("");
107
+ }
108
+
109
+ /**
110
+ * Send one message and wait for the complete reply.
111
+ *
112
+ * @returns {Promise<{sessionId: string, reply: {text: string, parts: unknown[]} | null, newMessages: unknown[]}>}
113
+ * reply is the last assistant message among the newly added messages (text =
114
+ * its text parts joined); in cases such as a failed turn there may be no
115
+ * assistant reply, so reply is null and the caller decides based on newMessages.
116
+ */
117
+ export async function sendAndAwaitReply({
118
+ apiOrigin,
119
+ token,
120
+ sessionId,
121
+ text,
122
+ locale,
123
+ timeoutMs,
124
+ pollIntervalMs,
125
+ }) {
126
+ const deadline = Date.now() + timeoutMs;
127
+ const abort = AbortSignal.timeout(timeoutMs);
128
+
129
+ // Snapshot existing message ids before sending: the backend assigns the user
130
+ // message its own id (it does not echo the message.id we sent), so we can't use
131
+ // the client id as an anchor — we diff against "messages that appeared after
132
+ // sending".
133
+ const before = await trpcQuery({ apiOrigin, token }, "session.getMessages", {
134
+ sessionId,
135
+ });
136
+ const beforeIds = new Set(before.map((m) => m.id));
137
+
138
+ note(`→ Sending to session ${sessionId} …`);
139
+ const { response } = await postChatMessage({
140
+ apiOrigin,
141
+ token,
142
+ sessionId,
143
+ text,
144
+ locale,
145
+ signal: abort,
146
+ });
147
+
148
+ note("… generating reply");
149
+ await drainStream(response);
150
+ await pollUntilTurnSettled({
151
+ apiOrigin,
152
+ token,
153
+ sessionId,
154
+ deadline,
155
+ intervalMs: pollIntervalMs,
156
+ });
157
+
158
+ const messages = await trpcQuery({ apiOrigin, token }, "session.getMessages", {
159
+ sessionId,
160
+ });
161
+ // All messages added this round (the diff); dropping the user message we just
162
+ // sent leaves this round's reply (assistant text + intermediate artifacts such
163
+ // as tool / data cards).
164
+ const fresh = messages.filter((m) => !beforeIds.has(m.id));
165
+ const lastUserIdx = fresh.map((m) => m.role).lastIndexOf("user");
166
+ const newMessages = lastUserIdx >= 0 ? fresh.slice(lastUserIdx + 1) : fresh;
167
+ const lastAssistant = [...newMessages]
168
+ .reverse()
169
+ .find((m) => m.role === "assistant");
170
+
171
+ return {
172
+ sessionId,
173
+ reply: lastAssistant
174
+ ? { text: extractText(lastAssistant.parts), parts: lastAssistant.parts }
175
+ : null,
176
+ newMessages,
177
+ };
178
+ }
179
+
180
+ /** Create a new session (global assistant), or get the channel's assistant session per --channel. */
181
+ export async function resolveChatSession({ apiOrigin, token, channelId }) {
182
+ if (channelId) {
183
+ const session = await trpcMutation(
184
+ { apiOrigin, token },
185
+ "session.getOrCreateChannelAssistant",
186
+ { channelId },
187
+ );
188
+ return session.id;
189
+ }
190
+ const session = await trpcMutation({ apiOrigin, token }, "session.create", {});
191
+ return session.id;
192
+ }
@@ -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,7 +1,7 @@
1
1
  {
2
2
  "name": "neodrop-cli",
3
- "version": "1.1.0",
4
- "description": "Neodrop (neodrop.ai) CLI — let your AI agent (Claude Code / Cursor / Codex) browse channels, read posts, manage subscriptions and create channels as you. stdout is always valid JSON. npx neodrop-cli login and go.",
3
+ "version": "2.0.0",
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": {
7
7
  "neodrop": "bin/neodrop.mjs"