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/README.md +1 -1
- package/SKILL.md +3 -2
- package/bin/neodrop.mjs +190 -132
- package/lib/api.mjs +34 -27
- package/lib/chat.mjs +97 -39
- package/lib/credentials.mjs +17 -13
- package/lib/install-skill.mjs +12 -9
- package/lib/origins.mjs +12 -8
- package/lib/output.mjs +3 -3
- package/lib/web-urls.mjs +14 -8
- package/package.json +1 -1
- package/references/commands.md +30 -3
- package/references/troubleshooting.md +3 -1
package/lib/api.mjs
CHANGED
|
@@ -1,22 +1,25 @@
|
|
|
1
|
-
// tRPC 11 HTTP
|
|
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
|
-
//
|
|
8
|
-
// -
|
|
9
|
-
// -
|
|
7
|
+
// Response shapes (superjson):
|
|
8
|
+
// - success: { result: { data: { json: <T>, meta?: {...} } } }
|
|
9
|
+
// - failure: { error: { json: { message, code, data: {...} } } }
|
|
10
10
|
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
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
|
-
//
|
|
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
|
|
18
|
-
// code
|
|
19
|
-
// CLI
|
|
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
|
|
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
|
|
37
|
-
//
|
|
38
|
-
//
|
|
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
|
-
//
|
|
50
|
-
//
|
|
51
|
-
// mutation
|
|
52
|
-
//
|
|
53
|
-
//
|
|
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
|
|
63
|
-
// self-signed certificate
|
|
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(
|
|
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(
|
|
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); //
|
|
100
|
-
// mutation
|
|
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
|
|
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
|
-
//
|
|
4
|
-
// 1.
|
|
5
|
-
// session.getOrCreateChannelAssistant
|
|
6
|
-
// 2.
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
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
|
-
//
|
|
14
|
-
// AI agent
|
|
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
|
|
27
|
-
//
|
|
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
|
-
//
|
|
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}` :
|
|
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
|
-
//
|
|
64
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
105
|
-
*
|
|
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
|
-
|
|
120
|
-
|
|
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
|
-
//
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
const
|
|
146
|
-
|
|
147
|
-
|
|
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
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
/**
|
|
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(
|
package/lib/credentials.mjs
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
|
-
//
|
|
1
|
+
// Local credentials (~/.neodrop/credentials.json, chmod 0600).
|
|
2
2
|
//
|
|
3
|
-
//
|
|
3
|
+
// Only a single active token is supported — switching server / account goes
|
|
4
|
+
// through logout → login.
|
|
4
5
|
//
|
|
5
|
-
//
|
|
6
|
-
// webOrigin string
|
|
7
|
-
// apiOrigin string API
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
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(
|
|
32
|
+
throw new Error(`Failed to parse ${FILE_PATH}: ${err.message}`);
|
|
31
33
|
}
|
|
32
34
|
}
|
|
33
35
|
|
|
34
|
-
//
|
|
35
|
-
//
|
|
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("
|
|
59
|
+
throw new Error("Not logged in. Run: npx neodrop-cli login");
|
|
56
60
|
}
|
|
57
61
|
return creds;
|
|
58
62
|
}
|
package/lib/install-skill.mjs
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
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
|
|
5
|
-
// references/
|
|
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
|
-
//
|
|
9
|
-
//
|
|
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
|
-
//
|
|
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(
|
|
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
|
|
1
|
+
// Neodrop deploys the web origin (product domain) and api origin (backend
|
|
2
|
+
// domain) decoupled.
|
|
2
3
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
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
|
|
7
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
1
|
+
// Unified output: stdout = JSON (an AI can JSON.parse directly), stderr = logs/notices.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
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
|
|
1
|
+
// Neodrop frontend URL assembly: once a user has an id, they get a clickable web
|
|
2
|
+
// link directly.
|
|
2
3
|
//
|
|
3
|
-
//
|
|
4
|
-
// `/
|
|
5
|
-
//
|
|
6
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
14
|
-
// `en`
|
|
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": "
|
|
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": {
|
package/references/commands.md
CHANGED
|
@@ -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
|
|
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
|
|
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
|