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/README.md +17 -8
- package/SKILL.md +6 -4
- package/bin/neodrop.mjs +257 -110
- package/lib/api.mjs +34 -27
- package/lib/chat.mjs +192 -0
- 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 +2 -2
- package/references/commands.md +99 -48
- 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
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
|
+
}
|
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,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "neodrop-cli",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "Neodrop (neodrop.ai) CLI — let your AI agent (Claude Code / Cursor / Codex) browse channels, read posts, manage subscriptions
|
|
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"
|