neodrop-cli 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/api.mjs ADDED
@@ -0,0 +1,104 @@
1
+ // tRPC 11 HTTP 调用最小封装(适配 backend 配置的 superjson transformer)。
2
+ //
3
+ // URL 形态:
4
+ // - query: GET /trpc/<proc>?input=<urlencoded {json:<input>}>
5
+ // - mutation: POST /trpc/<proc> body = {json:<input>}
6
+ //
7
+ // 响应形态(superjson):
8
+ // - 成功:{ result: { data: { json: <T>, meta?: {...} } } }
9
+ // - 失败:{ error: { json: { message, code, data: {...} } } }
10
+ //
11
+ // 入参 / 出参的 superjson `meta` 字段用于 Date 等非 JSON 类型还原,CLI 用不上
12
+ // (凭证 expiresAt 直接用 ISO 字符串),这里只取 `json` 字段。
13
+ //
14
+ // 零运行时依赖:用 Node 原生 fetch(Node 18+),不引第三方 HTTP 库。
15
+
16
+ export class ApiError extends Error {
17
+ // tRPC 业务错误(HTTP >= 400 或 body.error 非空)。
18
+ // code 来自 tRPC 的错误码('UNAUTHORIZED' / 'NOT_FOUND' / 'BAD_REQUEST' 等),
19
+ // CLI 上层可据此分流(如 401 提示重 login)。
20
+ constructor(message, code = "", httpStatus = 0) {
21
+ super(code ? `[${code}] ${message}` : message);
22
+ this.name = "ApiError";
23
+ this.code = code;
24
+ this.httpStatus = httpStatus;
25
+ }
26
+ }
27
+
28
+ function buildUrl(apiOrigin, proc, inputValue) {
29
+ const base = `${apiOrigin.replace(/\/+$/, "")}/trpc/${proc}`;
30
+ if (inputValue === undefined) return base;
31
+ // superjson 入参 wrapper:{ json: <value> }
32
+ const qs = new URLSearchParams({ input: JSON.stringify({ json: inputValue }) });
33
+ return `${base}?${qs.toString()}`;
34
+ }
35
+
36
+ // Cloudflare WAF 看到默认 UA 会按 bot 拒(HTTP 403 + error code 1010)。给一个
37
+ // 老实的客户端身份——告诉 CF/origin「这是 neodrop-cli」,便于排查与白名单。
38
+ // 改 UA 不是为了伪装,是为了通过基础的 client fingerprint check。
39
+ const USER_AGENT = "neodrop-cli/1.0 (+https://github.com/NeoDropAI/neodrop-skills)";
40
+
41
+ async function doRequest({ method, url, token, body }) {
42
+ const headers = {
43
+ "content-type": "application/json",
44
+ "user-agent": USER_AGENT,
45
+ accept: "application/json",
46
+ };
47
+ if (token) headers.authorization = `Bearer ${token}`;
48
+
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 真正的网络层异常。
54
+ let lastErr;
55
+ for (let i = 0; i < 2; i++) {
56
+ try {
57
+ return await fetch(url, { method, headers, body, signal: AbortSignal.timeout(30000) });
58
+ } catch (err) {
59
+ lastErr = err;
60
+ }
61
+ }
62
+ // Node fetch 把真正的原因藏在 err.cause(如 ECONNREFUSED / ENOTFOUND /
63
+ // self-signed certificate);err.message 通常只是笼统的 "fetch failed"。
64
+ const cause = lastErr?.cause;
65
+ const detail = cause?.code || cause?.message || lastErr?.message || String(lastErr);
66
+ throw new Error(`连接失败:${detail}`);
67
+ }
68
+
69
+ async function handleResponse(res) {
70
+ const text = await res.text();
71
+ let body = null;
72
+ if (text) {
73
+ try {
74
+ body = JSON.parse(text);
75
+ } catch {
76
+ throw new Error(`非 JSON 响应(HTTP ${res.status}):${text.slice(0, 200)}`);
77
+ }
78
+ }
79
+
80
+ if (res.status >= 400 || (body && body.error)) {
81
+ const err = (body && body.error) || {};
82
+ const errJson = err.json || {};
83
+ const msg = errJson.message || err.message || `HTTP ${res.status}`;
84
+ const code = (errJson.data && errJson.data.code) || err.code || "";
85
+ throw new ApiError(msg, code, res.status);
86
+ }
87
+
88
+ // superjson 响应剥层
89
+ return body.result.data.json;
90
+ }
91
+
92
+ export async function trpcQuery(opts, proc, inputValue) {
93
+ const url = buildUrl(opts.apiOrigin, proc, inputValue);
94
+ const res = await doRequest({ method: "GET", url, token: opts.token });
95
+ return handleResponse(res);
96
+ }
97
+
98
+ 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}
101
+ const body = JSON.stringify({ json: inputValue === undefined ? null : inputValue });
102
+ const res = await doRequest({ method: "POST", url, token: opts.token, body });
103
+ return handleResponse(res);
104
+ }
@@ -0,0 +1,58 @@
1
+ // 本地凭证(~/.neodrop/credentials.json,chmod 0600)。
2
+ //
3
+ // 只支持单一 active token——切换 server / 换号都走 logout → login。
4
+ //
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 天
12
+ // createdAt string ISO 8601
13
+
14
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
15
+ import { homedir } from "node:os";
16
+ import { join } from "node:path";
17
+
18
+ const FILE_DIR = join(homedir(), ".neodrop");
19
+ const FILE_PATH = join(FILE_DIR, "credentials.json");
20
+
21
+ export function credentialsPath() {
22
+ return FILE_PATH;
23
+ }
24
+
25
+ export function readCredentials() {
26
+ if (!existsSync(FILE_PATH)) return null;
27
+ try {
28
+ return JSON.parse(readFileSync(FILE_PATH, "utf-8"));
29
+ } catch (err) {
30
+ throw new Error(`无法解析 ${FILE_PATH}:${err.message}`);
31
+ }
32
+ }
33
+
34
+ // 原子写:写临时文件 → chmod 0600 → rename。chmod 在 rename 之前完成,确保最终
35
+ // 文件出现的瞬间权限就是 0600,不存在其他进程能读到 world-readable 的窗口。
36
+ export function writeCredentials(creds) {
37
+ mkdirSync(FILE_DIR, { recursive: true });
38
+ const tmp = `${FILE_PATH}.tmp`;
39
+ writeFileSync(tmp, `${JSON.stringify(creds, null, 2)}\n`, { encoding: "utf-8", mode: 0o600 });
40
+ chmodSync(tmp, 0o600);
41
+ renameSync(tmp, FILE_PATH);
42
+ }
43
+
44
+ export function clearCredentials() {
45
+ try {
46
+ unlinkSync(FILE_PATH);
47
+ } catch (err) {
48
+ if (err.code !== "ENOENT") throw err;
49
+ }
50
+ }
51
+
52
+ export function requireCredentials() {
53
+ const creds = readCredentials();
54
+ if (creds === null) {
55
+ throw new Error("未登录。先运行:npx neodrop-cli login");
56
+ }
57
+ return creds;
58
+ }
@@ -0,0 +1,42 @@
1
+ // 把 SKILL.md + references/ 拷进 agent 的 skill 目录,让 `npx neodrop-cli` 一条命令
2
+ // 既装好 CLI 又装好 skill 描述。
3
+ //
4
+ // npm/npx 只分发「可执行文件」;而 Claude Code 等 agent 的 skill 是 SKILL.md +
5
+ // references/ 这组文件落进 agent 的 skill 目录后才会被路由。两条分发渠道本来是
6
+ // 分开的,这个命令把它们合并成一步。
7
+ //
8
+ // 默认目标 ~/.claude/skills/neodrop-cli/(Claude Code 约定);--dest 可改到别的
9
+ // agent 的 skill 目录。目录名固定 neodrop-cli,与 SKILL.md frontmatter 的 name 一致。
10
+
11
+ import { cpSync, existsSync, mkdirSync } from "node:fs";
12
+ import { homedir } from "node:os";
13
+ import { dirname, join } from "node:path";
14
+ import { fileURLToPath } from "node:url";
15
+
16
+ export function defaultSkillDest() {
17
+ return join(homedir(), ".claude", "skills", "neodrop-cli");
18
+ }
19
+
20
+ export function installSkill({ dest } = {}) {
21
+ // 包根目录 = 本文件所在 lib/ 的上一层(bin/ 与 lib/ 与 SKILL.md 同级)
22
+ const here = dirname(fileURLToPath(import.meta.url)); // .../lib
23
+ const pkgRoot = dirname(here);
24
+ const target = dest || defaultSkillDest();
25
+
26
+ const skillSrc = join(pkgRoot, "SKILL.md");
27
+ if (!existsSync(skillSrc)) {
28
+ throw new Error(`找不到 SKILL.md(期望在 ${skillSrc});npm 包可能未按 files 白名单打进 SKILL.md`);
29
+ }
30
+
31
+ mkdirSync(target, { recursive: true });
32
+ cpSync(skillSrc, join(target, "SKILL.md"));
33
+
34
+ const refSrc = join(pkgRoot, "references");
35
+ const copied = ["SKILL.md"];
36
+ if (existsSync(refSrc)) {
37
+ cpSync(refSrc, join(target, "references"), { recursive: true });
38
+ copied.push("references/");
39
+ }
40
+
41
+ return { target, copied };
42
+ }
@@ -0,0 +1,32 @@
1
+ // Neodrop 部署的 web origin(产品域)与 api origin(backend 域)解耦。
2
+ //
3
+ // 线上:web = https://neodrop.ai,api = https://api.neodrop.ai
4
+ // 本地 dev:web = http://localhost:4001,api = http://localhost:3001
5
+ //
6
+ // CLI 让用户显式传 --api;不传时按 web origin 启发式推断。self-host 用户默认
7
+ // 假设 backend 反代到 web 同域 /trpc/*,需要时用 --api 覆盖。
8
+
9
+ export function inferApiOrigin(webOrigin) {
10
+ let parsed;
11
+ try {
12
+ parsed = new URL(webOrigin);
13
+ } catch {
14
+ return webOrigin.replace(/\/+$/, "");
15
+ }
16
+ const host = (parsed.hostname || "").toLowerCase();
17
+ const port = parsed.port;
18
+ const scheme = parsed.protocol.replace(/:$/, "");
19
+
20
+ // 线上 neodrop.ai → api.neodrop.ai
21
+ if (host === "neodrop.ai") {
22
+ return `${scheme}://api.neodrop.ai`;
23
+ }
24
+
25
+ // 本地 dev:localhost:4001 / 127.0.0.1:4001 → 同 host 3001
26
+ if ((host === "localhost" || host === "127.0.0.1") && port === "4001") {
27
+ return `${scheme}://${host}:3001`;
28
+ }
29
+
30
+ // 其他(self-host 反代等):默认与 web 同域,假设 /trpc/* 反代到 backend。
31
+ return webOrigin.replace(/\/+$/, "");
32
+ }
package/lib/output.mjs ADDED
@@ -0,0 +1,19 @@
1
+ // 统一输出:stdout = JSON(AI 直接 JSON.parse),stderr = 日志/提示。
2
+ //
3
+ // 默认单行 JSON;--pretty 切 2 空格缩进 JSON——两者都是合法 JSON,AI 不需要
4
+ // flag 切换也能解析。
5
+
6
+ let pretty = false;
7
+
8
+ export function setPretty(value) {
9
+ pretty = Boolean(value);
10
+ }
11
+
12
+ export function emit(data) {
13
+ if (data === null || data === undefined) return;
14
+ process.stdout.write(`${JSON.stringify(data, null, pretty ? 2 : undefined)}\n`);
15
+ }
16
+
17
+ export function note(msg, end = "\n") {
18
+ process.stderr.write(msg + end);
19
+ }
@@ -0,0 +1,30 @@
1
+ // Neodrop 前端 URL 拼装:用户拿到 id 后能直接得到一条可点击的网页链接。
2
+ //
3
+ // 为什么放 skill 而不是后端 response:URL 路径(`/post/<id>` / `/channel/<id>` /
4
+ // `/user/<id>`)是前端的渲染契约,不是数据契约——`grain.id` 是稳定的数据;前端
5
+ // 明天可以把路由改成 `/post/<id>` 而无需后端发新版 API。把路由塞进 response 会让
6
+ // backend 反向依赖前端渲染。这里给 CLI 内部用,作为 stderr 上的人类可读提示。
7
+ //
8
+ // 路径权威源 = `apps/web/src/app/[locale]/<route>/[id]/page.tsx`:
9
+ // post / grain → `/post/<id>`
10
+ // channel → `/channel/<id>`
11
+ // user → `/user/<id>`
12
+ //
13
+ // 注意:CLI 不挂 locale 前缀(neodrop.ai 的 localePrefix='as-needed',默认 locale
14
+ // `en` 落到无前缀路径;其它 locale 由前端按用户偏好做客户端 redirect)。
15
+
16
+ function strip(origin) {
17
+ return origin.replace(/\/+$/, "");
18
+ }
19
+
20
+ export function postUrl(webOrigin, postId) {
21
+ return `${strip(webOrigin)}/post/${postId}`;
22
+ }
23
+
24
+ export function channelUrl(webOrigin, channelId) {
25
+ return `${strip(webOrigin)}/channel/${channelId}`;
26
+ }
27
+
28
+ export function userUrl(webOrigin, userId) {
29
+ return `${strip(webOrigin)}/user/${userId}`;
30
+ }
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
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.",
5
+ "type": "module",
6
+ "bin": {
7
+ "neodrop": "bin/neodrop.mjs"
8
+ },
9
+ "files": [
10
+ "bin/",
11
+ "lib/",
12
+ "SKILL.md",
13
+ "references/"
14
+ ],
15
+ "engines": {
16
+ "node": ">=18"
17
+ },
18
+ "keywords": [
19
+ "neodrop",
20
+ "cli",
21
+ "ai-agent",
22
+ "claude-code",
23
+ "skill",
24
+ "trpc"
25
+ ],
26
+ "homepage": "https://github.com/NeoDropAI/neodrop-skills/tree/main/skills/neodrop-cli",
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "git+https://github.com/NeoDropAI/neodrop-skills.git",
30
+ "directory": "skills/neodrop-cli"
31
+ },
32
+ "bugs": {
33
+ "url": "https://github.com/NeoDropAI/neodrop-skills/issues"
34
+ },
35
+ "license": "MIT"
36
+ }
@@ -0,0 +1,84 @@
1
+ # Login & credentials (auth)
2
+
3
+ ## The only login flow: session polling
4
+
5
+ ```bash
6
+ npx neodrop-cli login
7
+ ```
8
+
9
+ No flags, no mode branches. The CLI does this:
10
+
11
+ 1. Calls the backend `cliToken.startSession` to create a one-time session (256-bit random sessionId, valid 10 minutes), and receives a **private claim secret `pollSecret` handed only to this CLI process** (never in the URL).
12
+ 2. Prints a `https://neodrop.ai/cli-auth?session=<sid>` verification URL to stderr (the URL carries only the sessionId, no pollSecret).
13
+ 3. Polls `cliToken.pollSession` roughly every 2 s with `{sessionId, pollSecret}`, waiting for approval.
14
+ 4. The user opens the URL in any browser → signs in → lands on the consent page → sees the CLI's self-reported `clientName` → **ticks "this is the CLI I just started"** → approves.
15
+ 5. The CLI receives the PAT → writes it to `~/.neodrop/credentials.json` (`chmod 0600`).
16
+
17
+ Properties:
18
+
19
+ - **No auto-launched browser** — it only prints the URL; the user copies it (same machine / phone / another device).
20
+ - **No local HTTP server / callback** — the CLI polls the backend one-way; the browser never connects back to the CLI machine.
21
+ - **No token in a URL or browser history** — the plaintext token only ever travels over the CLI ↔ backend HTTPS API call.
22
+ - **Claiming requires the pollSecret** — polling must return the private `pollSecret` from `startSession` (the backend stores only its hash); the URL carries only the sessionId, so even a leaked/forwarded/screenshotted URL can't claim the token.
23
+ - **Single claim** — the moment the CLI's first poll receives the token, the backend wipes the `plaintextToken` field.
24
+ - **Session expires in 10 minutes** — if it lapses, rerun `login`.
25
+
26
+ ## Credential file
27
+
28
+ ```jsonc
29
+ ~/.neodrop/credentials.json
30
+ {
31
+ "webOrigin": "https://neodrop.ai",
32
+ "apiOrigin": "https://api.neodrop.ai",
33
+ "token": "grain_pat_…", // plaintext PAT, local only; mode 0600
34
+ "tokenId": "tok_…", // used for logout / remote revoke
35
+ "name": "Claude Code @ macbook", // client name shown on /settings/cli-tokens
36
+ "expiresAt": "2026-09-01T…Z", // 90 days by default
37
+ "createdAt": "2026-06-04T…Z"
38
+ }
39
+ ```
40
+
41
+ ## Across machines: scp the credential file
42
+
43
+ Cloud sandboxes / CI / remote agents / any machine with no browser but SSH access: log in once locally, then scp it over:
44
+
45
+ ```bash
46
+ # Local machine (has a browser, already logged in):
47
+ scp ~/.neodrop/credentials.json agent-box:~/.neodrop/credentials.json
48
+ ssh agent-box 'chmod 600 ~/.neodrop/credentials.json && npx neodrop-cli whoami'
49
+ ```
50
+
51
+ The credential file *is* the credential — the CLI has no dedicated `import` command because `cp` is enough. **A moved PAT equals your login identity**, so transfer it over SSH / an encrypted channel. A headless machine can `npx neodrop-cli logout` to revoke the token when done, then get a fresh one next time.
52
+
53
+ ## Security model
54
+
55
+ | Risk | Defense |
56
+ |---|---|
57
+ | Someone sends you a malicious cli-auth URL to trick you into approving | The consent page forces the "this is the CLI I just started" checkbox + displays `clientName` for you to recognize |
58
+ | A leaked session-id URL is replayed to claim the token | The browser-visible sessionId is separated from the CLI-private `pollSecret` — claiming requires the pollSecret (the backend verifies its hash), which the URL never contains; plus single-claim + 10-minute expiry |
59
+ | A PAT recursively issuing new PATs | `approveSession` rejects requests whose `ctx.sessionId.startsWith('pat:')`, letting only browser sessions through |
60
+ | A PAT file sitting in plaintext locally | Written with `chmod 0600`, per the `~/.aws/credentials` convention |
61
+ | `startSession` being spammed | Backend IP-level rate limit (10 / min) |
62
+ | Cross-user privilege escalation | Each procedure's own `where: { userId: ctx.userId }` guard |
63
+
64
+ The consent page **untrustedly** shows the CLI's self-reported `clientName` (any script can pass `--name "Claude Code"` to spoof it) — which is exactly why the confirmation checkbox is mandatory. **The user must personally confirm** the clientName matches the command they just ran.
65
+
66
+ ## Self-hosting
67
+
68
+ ```bash
69
+ # Option A — environment variable
70
+ NEODROP_SERVER=https://your-neodrop.example.com npx neodrop-cli login
71
+
72
+ # Option B — login flag
73
+ npx neodrop-cli login --server https://your-neodrop.example.com
74
+ ```
75
+
76
+ The API origin is inferred from the web origin heuristically (`neodrop.ai` → `api.neodrop.ai`; `localhost:4001` → `localhost:3001`; otherwise same as the web origin). If your API host differs, pass `--api <url>` or set `NEODROP_API`.
77
+
78
+ ## Revoke & rotate
79
+
80
+ - Local logout: `npx neodrop-cli logout` (remote revoke + clears the local credential).
81
+ - Web revoke: [neodrop.ai/settings/cli-tokens](https://neodrop.ai/settings/cli-tokens).
82
+ - See which PATs you've issued: `npx neodrop-cli tokens list`.
83
+ - Revoke a PAT elsewhere: `npx neodrop-cli tokens revoke <id>`.
84
+ - PATs last 90 days by default; just `login` again when one expires.
@@ -0,0 +1,74 @@
1
+ # neodrop-cli command reference
2
+
3
+ > Invocation convention: use `npx neodrop-cli <command>` (see [SKILL.md → How to invoke](../SKILL.md#how-to-invoke)).
4
+ > Equivalent explicit package + bin form: `npx -p neodrop-cli neodrop <command>`.
5
+ > Examples below are written as `neodrop <command>`.
6
+
7
+ ## identity
8
+
9
+ ```bash
10
+ neodrop me # current user info (user.getMe)
11
+ neodrop whoami # me + token metadata (credential + expiry)
12
+ neodrop tokens list # every PAT you've issued
13
+ neodrop tokens revoke <id> # revoke a PAT; if it's this machine's token, the local credential is cleared too
14
+ ```
15
+
16
+ ## channels
17
+
18
+ ### View / search
19
+
20
+ ```bash
21
+ neodrop channels list --mine # channels you own
22
+ neodrop channels list --locale en --limit 20 # public channels, paginated
23
+ neodrop channels get <channelId> # single channel detail (incl. requirement.public)
24
+ neodrop channels categories # all categories
25
+ neodrop channels by-category tech --sort latest --limit 20
26
+
27
+ neodrop channels search "AI weekly" --locale en --limit 10
28
+ ```
29
+
30
+ ### Write
31
+
32
+ ```bash
33
+ neodrop channels create --name "AI industry tracker" --description "..." --locale en
34
+ neodrop channels create --json '{"name":"X","locale":"en","type":"PRIVATE"}'
35
+ neodrop channels subscribe <channelId>
36
+ neodrop channels unsubscribe <channelId>
37
+ ```
38
+
39
+ **Default locale**: `channels list` defaults to `en`, matching the product's default public pool. `channels search` and `channels by-category` omit locale when `--locale` is not provided and let the backend decide. Pass `--locale en` (etc.) explicitly to query the public pool across locales.
40
+
41
+ ## posts
42
+
43
+ > The content unit is called a **post** on Neodrop. The old command name `grains` is kept as a backward-compatible alias, but new code should use `posts`.
44
+
45
+ ```bash
46
+ neodrop posts list --limit 10 # public feed
47
+ neodrop posts list --subscribed --limit 10 # your subscriptions (= neodrop feed --limit 10)
48
+ neodrop posts list --channel <channelId> --limit 10
49
+ neodrop posts get <postId>
50
+ neodrop posts search "Apple Intelligence" --limit 10
51
+
52
+ neodrop feed --limit 10 # alias for posts list --subscribed
53
+ ```
54
+
55
+ ## api
56
+
57
+ For tRPC procedures with no sugar command, fall back to `api`:
58
+
59
+ ```bash
60
+ neodrop api channel.update --json '{"id":"<chId>","name":"New name"}' --mutation
61
+ neodrop api user.getLinkedAccounts # any query
62
+ echo '{...}' | neodrop api channel.create --stdin --mutation
63
+ ```
64
+
65
+ - **Defaults to a GET query** — a mutation (write) MUST add `--mutation` explicitly, or the backend routes it as a query and rejects it.
66
+ - Pass complex input with `--json '...'` inline, or `--stdin` to read from standard input (great for heredocs / pipelines).
67
+ - To find a procedure's full name, check the main repo's `packages/backend/src/api/trpc/routers.ts`, or probe with `curl /api/trpc/<router>.<procedure>?input=...` against a dev backend.
68
+
69
+ ## Global
70
+
71
+ ```bash
72
+ neodrop --pretty <cmd> # indented JSON for humans (still valid JSON)
73
+ neodrop <cmd> --help # subcommand arguments
74
+ ```
@@ -0,0 +1,37 @@
1
+ # Troubleshooting
2
+
3
+ When the CLI errors, **first read the error code in brackets on stderr**, then match it below.
4
+
5
+ ## Auth / login
6
+
7
+ | Symptom | Meaning | Fix |
8
+ |---|---|---|
9
+ | `not logged in` | `~/.neodrop/credentials.json` wasn't found | Tell the user to run `npx neodrop-cli login` |
10
+ | Consent page says "invalid authorization request / missing valid session parameter" | The opened URL has no valid `?session=cas_...`. **Most often the CLI is outdated** — old versions used a callback mode and print `/cli-auth?callback=&state=&name=`, which the new session-polling consent page rejects; otherwise the `?session=` tail was dropped when copying | Check the URL shape: ① `?callback=&state=` (or those keys all empty) → the CLI is stale (npx hit a cached old version); force the latest with `npx neodrop-cli@latest login`, the new link looks like `?session=cas_...`; ② already `?session=` but partial → rerun `login` and copy the whole line. Links are valid 10 minutes; reissue if expired |
11
+ | `[UNAUTHORIZED]` | PAT is invalid (expired / revoked / user logged out) | Ask the user to `login` again; for headless environments see [auth.md](auth.md) |
12
+ | `[FORBIDDEN]` | The PAT lacks permission for this procedure (e.g. an admin procedure) | Don't re-login — a PAT is always a regular-user identity; switch commands or have a real admin do it |
13
+
14
+ ## Network / domains
15
+
16
+ | Symptom | Fix |
17
+ |---|---|
18
+ | `connection failed: ECONNREFUSED` | Backend isn't up / wrong port. Local dev: re-login with `NEODROP_SERVER=http://localhost:4001 npx neodrop-cli login` |
19
+ | `connection failed: ENOTFOUND` / `EAI_AGAIN` | DNS failure; check whether `apiOrigin` is wrong (see `whoami` output) |
20
+ | `connection failed: self-signed certificate` / `unable to verify ... certificate` | A self-hosted instance uses a self-signed cert; unsupported today — use LetsEncrypt or a reverse proxy with a valid cert |
21
+ | `connection failed: HeadersTimeoutError` / hangs ~30 s then errors | Network flakiness / unresponsive backend (the CLI times out a single request at 30 s and already retried once); try again later |
22
+
23
+ ## Backend business errors
24
+
25
+ | Symptom | Meaning | Fix |
26
+ |---|---|---|
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` |
29
+ | `[INTERNAL_SERVER_ERROR]` | The backend crashed | Retry once; if it persists, open an issue with the full stderr |
30
+
31
+ ## Environment
32
+
33
+ | Symptom | Fix |
34
+ |---|---|
35
+ | `npx: command not found` / unsupported syntax / `fetch is not defined` | Node is missing or too old; install Node 18+ (`node --version` to check) |
36
+ | `npx` stuck downloading / running a stale version | npx caches packages; force the latest with `npx neodrop-cli@latest <cmd>`, or install globally with `npm i -g neodrop-cli` |
37
+ | stdout looks empty / isn't JSON | The command actually failed — read stderr; the CLI only writes JSON to stdout on success |
@@ -0,0 +1,24 @@
1
+ # Neodrop URL → CLI command mapping
2
+
3
+ When the user pastes a Neodrop link and wants details, extract the id from the path and call the matching command below.
4
+
5
+ | URL pattern | id meaning | Call |
6
+ |---|---|---|
7
+ | `neodrop.ai/post/<id>` | post id | `posts get <id>` |
8
+ | `neodrop.ai/feed/<id>` | legacy post id | `posts get <id>` |
9
+ | `neodrop.ai/channel/<id>` | channelId | `channels get <id>` |
10
+ | `neodrop.ai/user/<id>` | userId | No dedicated sugar command — use `api user.getById --json '{"id":"<id>"}'` |
11
+ | `neodrop.ai/discover` | public discover page | `channels list --locale <l>` / `channels by-category <slug>`, per the user's context |
12
+ | `neodrop.ai/search?q=...` | site-wide search | `channels search "<q>"` + `posts search "<q>"` combined |
13
+
14
+ ## Reverse: how to give the user a link back
15
+
16
+ **Don't hand-craft from memory** — `posts get` / `channels get` / `me` already print a `🔗 <canonical-url>` line to stderr; quote that line directly.
17
+
18
+ Don't assemble `/grain/<id>` or legacy `/feed/<id>` (both migrate to `/post/<id>`) or guess other paths. If a command didn't print a canonical URL, derive it from the table above:
19
+
20
+ - channelId → `https://neodrop.ai/channel/<id>`
21
+ - postId → `https://neodrop.ai/post/<id>`
22
+ - userId → `https://neodrop.ai/user/<id>`
23
+
24
+ For a self-hosted instance, read the `webOrigin` from the credential (shown in `neodrop whoami` output).