nemonicon-cli 0.1.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/dist/index.js +1634 -0
- package/package.json +34 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1634 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { Command as Command14 } from "commander";
|
|
5
|
+
|
|
6
|
+
// src/commands/auth.ts
|
|
7
|
+
import { Command } from "commander";
|
|
8
|
+
|
|
9
|
+
// src/lib/config.ts
|
|
10
|
+
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "fs";
|
|
11
|
+
import { homedir } from "os";
|
|
12
|
+
import { join } from "path";
|
|
13
|
+
var CONFIG_DIR = join(homedir(), ".nemonicon");
|
|
14
|
+
var CONFIG_FILE = join(CONFIG_DIR, "config.json");
|
|
15
|
+
function getConfigPath() {
|
|
16
|
+
return CONFIG_FILE;
|
|
17
|
+
}
|
|
18
|
+
function hasCredentials(c) {
|
|
19
|
+
return typeof c.token === "string" || typeof c.accessToken === "string";
|
|
20
|
+
}
|
|
21
|
+
function isShapedConfig(parsed) {
|
|
22
|
+
if (!parsed || typeof parsed !== "object") return false;
|
|
23
|
+
return typeof parsed.apiUrl === "string";
|
|
24
|
+
}
|
|
25
|
+
function loadPartialConfig() {
|
|
26
|
+
const envToken = process.env.NEMONICON_CLI_TOKEN;
|
|
27
|
+
const envUrl = process.env.NEMONICON_API_URL;
|
|
28
|
+
if (envToken) {
|
|
29
|
+
return {
|
|
30
|
+
token: envToken,
|
|
31
|
+
apiUrl: envUrl ?? "https://preview.nemonicon.com",
|
|
32
|
+
protectionBypass: process.env.NEMONICON_PROTECTION_BYPASS || void 0,
|
|
33
|
+
authMethod: "static"
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
if (!existsSync(CONFIG_FILE)) return null;
|
|
37
|
+
try {
|
|
38
|
+
const raw = readFileSync(CONFIG_FILE, "utf-8");
|
|
39
|
+
const parsed = JSON.parse(raw);
|
|
40
|
+
if (!isShapedConfig(parsed)) return null;
|
|
41
|
+
return parsed;
|
|
42
|
+
} catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
function loadConfig() {
|
|
47
|
+
const partial = loadPartialConfig();
|
|
48
|
+
if (!partial) return null;
|
|
49
|
+
if (!hasCredentials(partial)) return null;
|
|
50
|
+
return partial;
|
|
51
|
+
}
|
|
52
|
+
function saveConfig(config) {
|
|
53
|
+
if (!existsSync(CONFIG_DIR)) {
|
|
54
|
+
mkdirSync(CONFIG_DIR, { recursive: true, mode: 448 });
|
|
55
|
+
}
|
|
56
|
+
writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2) + "\n", {
|
|
57
|
+
encoding: "utf-8",
|
|
58
|
+
mode: 384
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
function updateTokens(patch) {
|
|
62
|
+
const current = loadConfig();
|
|
63
|
+
if (!current) {
|
|
64
|
+
throw new Error(
|
|
65
|
+
"Cannot update tokens: no existing config. Run `nemonicon login` first."
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
const merged = {
|
|
69
|
+
...current,
|
|
70
|
+
authMethod: "oauth",
|
|
71
|
+
accessToken: patch.accessToken,
|
|
72
|
+
refreshToken: patch.refreshToken,
|
|
73
|
+
accessTokenExpiresAt: patch.accessTokenExpiresAt,
|
|
74
|
+
clientId: patch.clientId ?? current.clientId
|
|
75
|
+
};
|
|
76
|
+
if (merged.accessToken) delete merged.token;
|
|
77
|
+
saveConfig(merged);
|
|
78
|
+
return merged;
|
|
79
|
+
}
|
|
80
|
+
function clearOauthTokens() {
|
|
81
|
+
const current = loadConfig();
|
|
82
|
+
if (!current) return null;
|
|
83
|
+
const next = { ...current };
|
|
84
|
+
delete next.accessToken;
|
|
85
|
+
delete next.refreshToken;
|
|
86
|
+
delete next.accessTokenExpiresAt;
|
|
87
|
+
delete next.authMethod;
|
|
88
|
+
saveConfig(next);
|
|
89
|
+
return next;
|
|
90
|
+
}
|
|
91
|
+
function deleteConfig() {
|
|
92
|
+
if (!existsSync(CONFIG_FILE)) return false;
|
|
93
|
+
unlinkSync(CONFIG_FILE);
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
function requireConfig() {
|
|
97
|
+
const config = loadConfig();
|
|
98
|
+
if (!config) {
|
|
99
|
+
console.error(
|
|
100
|
+
"Not authenticated. Run `nemonicon login` to sign in with your browser,"
|
|
101
|
+
);
|
|
102
|
+
console.error(
|
|
103
|
+
"or `nemonicon auth <token> --api-url <url>` to use a long-lived token."
|
|
104
|
+
);
|
|
105
|
+
process.exit(1);
|
|
106
|
+
}
|
|
107
|
+
return config;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// src/lib/oauth.ts
|
|
111
|
+
import { createHash, randomBytes } from "crypto";
|
|
112
|
+
import { spawn } from "child_process";
|
|
113
|
+
import { createServer } from "http";
|
|
114
|
+
var LOOPBACK_PORTS = [
|
|
115
|
+
33418,
|
|
116
|
+
33419,
|
|
117
|
+
33420,
|
|
118
|
+
33421,
|
|
119
|
+
33422,
|
|
120
|
+
33423,
|
|
121
|
+
33424,
|
|
122
|
+
33425,
|
|
123
|
+
33426,
|
|
124
|
+
33427
|
|
125
|
+
];
|
|
126
|
+
var REDIRECT_URIS = LOOPBACK_PORTS.map(
|
|
127
|
+
(p) => `http://127.0.0.1:${p}/callback`
|
|
128
|
+
);
|
|
129
|
+
var SCOPE = "mcp";
|
|
130
|
+
var CLIENT_NAME = "Nemonicon CLI";
|
|
131
|
+
var OauthError = class extends Error {
|
|
132
|
+
constructor(code, message) {
|
|
133
|
+
super(message);
|
|
134
|
+
this.code = code;
|
|
135
|
+
this.name = "OauthError";
|
|
136
|
+
}
|
|
137
|
+
code;
|
|
138
|
+
};
|
|
139
|
+
function buildHeaders(protectionBypass) {
|
|
140
|
+
const h = {
|
|
141
|
+
"Content-Type": "application/json",
|
|
142
|
+
Accept: "application/json"
|
|
143
|
+
};
|
|
144
|
+
if (protectionBypass) h["x-vercel-protection-bypass"] = protectionBypass;
|
|
145
|
+
return h;
|
|
146
|
+
}
|
|
147
|
+
function buildFormHeaders(protectionBypass) {
|
|
148
|
+
const h = {
|
|
149
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
150
|
+
Accept: "application/json"
|
|
151
|
+
};
|
|
152
|
+
if (protectionBypass) h["x-vercel-protection-bypass"] = protectionBypass;
|
|
153
|
+
return h;
|
|
154
|
+
}
|
|
155
|
+
async function discoverEndpoints(apiUrl, protectionBypass) {
|
|
156
|
+
const base = apiUrl.replace(/\/+$/, "");
|
|
157
|
+
const fallback = {
|
|
158
|
+
authorizationEndpoint: `${base}/api/oauth/authorize`,
|
|
159
|
+
tokenEndpoint: `${base}/api/oauth/token`,
|
|
160
|
+
registrationEndpoint: `${base}/api/oauth/register`
|
|
161
|
+
};
|
|
162
|
+
try {
|
|
163
|
+
const res = await fetch(`${base}/.well-known/oauth-authorization-server`, {
|
|
164
|
+
headers: buildHeaders(protectionBypass)
|
|
165
|
+
});
|
|
166
|
+
if (!res.ok) return fallback;
|
|
167
|
+
const meta = await res.json();
|
|
168
|
+
return {
|
|
169
|
+
authorizationEndpoint: typeof meta.authorization_endpoint === "string" ? meta.authorization_endpoint : fallback.authorizationEndpoint,
|
|
170
|
+
tokenEndpoint: typeof meta.token_endpoint === "string" ? meta.token_endpoint : fallback.tokenEndpoint,
|
|
171
|
+
registrationEndpoint: typeof meta.registration_endpoint === "string" ? meta.registration_endpoint : fallback.registrationEndpoint
|
|
172
|
+
};
|
|
173
|
+
} catch {
|
|
174
|
+
return fallback;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
async function registerClient(registrationEndpoint, protectionBypass) {
|
|
178
|
+
const res = await fetch(registrationEndpoint, {
|
|
179
|
+
method: "POST",
|
|
180
|
+
headers: buildHeaders(protectionBypass),
|
|
181
|
+
body: JSON.stringify({
|
|
182
|
+
client_name: CLIENT_NAME,
|
|
183
|
+
redirect_uris: REDIRECT_URIS,
|
|
184
|
+
grant_types: ["authorization_code", "refresh_token"],
|
|
185
|
+
response_types: ["code"],
|
|
186
|
+
token_endpoint_auth_method: "none",
|
|
187
|
+
scope: SCOPE
|
|
188
|
+
})
|
|
189
|
+
});
|
|
190
|
+
if (!res.ok) {
|
|
191
|
+
const text = await res.text().catch(() => "");
|
|
192
|
+
throw new OauthError(
|
|
193
|
+
"registration_failed",
|
|
194
|
+
`Client registration failed (${res.status}): ${text.slice(0, 500)}`
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
const body = await res.json();
|
|
198
|
+
if (typeof body.client_id !== "string") {
|
|
199
|
+
throw new OauthError(
|
|
200
|
+
"registration_failed",
|
|
201
|
+
"Registration response did not include a client_id."
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
return body.client_id;
|
|
205
|
+
}
|
|
206
|
+
function generatePkce() {
|
|
207
|
+
const codeVerifier = randomBytes(32).toString("base64url");
|
|
208
|
+
const codeChallenge = createHash("sha256").update(codeVerifier).digest("base64url");
|
|
209
|
+
const state = randomBytes(16).toString("base64url");
|
|
210
|
+
return { codeVerifier, codeChallenge, state };
|
|
211
|
+
}
|
|
212
|
+
function buildAuthorizeUrl(authorizationEndpoint, params) {
|
|
213
|
+
const url = new URL(authorizationEndpoint);
|
|
214
|
+
url.searchParams.set("response_type", "code");
|
|
215
|
+
url.searchParams.set("client_id", params.clientId);
|
|
216
|
+
url.searchParams.set("redirect_uri", params.redirectUri);
|
|
217
|
+
url.searchParams.set("scope", SCOPE);
|
|
218
|
+
url.searchParams.set("state", params.state);
|
|
219
|
+
url.searchParams.set("code_challenge", params.codeChallenge);
|
|
220
|
+
url.searchParams.set("code_challenge_method", "S256");
|
|
221
|
+
return url.toString();
|
|
222
|
+
}
|
|
223
|
+
var SUCCESS_HTML = `<!doctype html>
|
|
224
|
+
<html><head><meta charset="utf-8"><title>Nemonicon CLI</title>
|
|
225
|
+
<style>
|
|
226
|
+
body { font-family: system-ui, sans-serif; background: #0b0b0e; color: #e7e7ea;
|
|
227
|
+
display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; }
|
|
228
|
+
.box { text-align: center; padding: 2rem 3rem; border: 1px solid #2a2a30;
|
|
229
|
+
border-radius: 12px; background: #14141a; }
|
|
230
|
+
h1 { margin: 0 0 .5rem; font-size: 1.25rem; }
|
|
231
|
+
p { margin: 0; color: #9b9ba3; }
|
|
232
|
+
</style></head>
|
|
233
|
+
<body><div class="box"><h1>You're signed in.</h1><p>You can close this tab and return to your terminal.</p></div></body></html>`;
|
|
234
|
+
var ERROR_HTML = (msg) => `<!doctype html>
|
|
235
|
+
<html><head><meta charset="utf-8"><title>Nemonicon CLI</title>
|
|
236
|
+
<style>
|
|
237
|
+
body { font-family: system-ui, sans-serif; background: #0b0b0e; color: #e7e7ea;
|
|
238
|
+
display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; }
|
|
239
|
+
.box { text-align: center; padding: 2rem 3rem; border: 1px solid #4a1f24;
|
|
240
|
+
border-radius: 12px; background: #1a1115; max-width: 28rem; }
|
|
241
|
+
h1 { margin: 0 0 .5rem; font-size: 1.25rem; color: #ff8585; }
|
|
242
|
+
p { margin: 0; color: #9b9ba3; word-break: break-word; }
|
|
243
|
+
</style></head>
|
|
244
|
+
<body><div class="box"><h1>Sign-in failed.</h1><p>${msg}</p></div></body></html>`;
|
|
245
|
+
async function startCallbackServer() {
|
|
246
|
+
for (const port of LOOPBACK_PORTS) {
|
|
247
|
+
const server = createServer();
|
|
248
|
+
try {
|
|
249
|
+
await new Promise((resolve, reject) => {
|
|
250
|
+
const onError = (err) => {
|
|
251
|
+
server.removeListener("listening", onListening);
|
|
252
|
+
reject(err);
|
|
253
|
+
};
|
|
254
|
+
const onListening = () => {
|
|
255
|
+
server.removeListener("error", onError);
|
|
256
|
+
resolve();
|
|
257
|
+
};
|
|
258
|
+
server.once("error", onError);
|
|
259
|
+
server.once("listening", onListening);
|
|
260
|
+
server.listen(port, "127.0.0.1");
|
|
261
|
+
});
|
|
262
|
+
const addr = server.address();
|
|
263
|
+
return {
|
|
264
|
+
server,
|
|
265
|
+
port: addr.port,
|
|
266
|
+
redirectUri: `http://127.0.0.1:${addr.port}/callback`
|
|
267
|
+
};
|
|
268
|
+
} catch (err) {
|
|
269
|
+
const code = err.code;
|
|
270
|
+
if (code === "EADDRINUSE" || code === "EACCES") continue;
|
|
271
|
+
throw err;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
throw new OauthError(
|
|
275
|
+
"ports_busy",
|
|
276
|
+
`All loopback ports (${LOOPBACK_PORTS[0]}-${LOOPBACK_PORTS[LOOPBACK_PORTS.length - 1]}) are in use. Close any other Nemonicon CLI login attempts and retry.`
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
function waitForCallback(server, expectedState, timeoutMs = 5 * 60 * 1e3) {
|
|
280
|
+
return new Promise((resolve, reject) => {
|
|
281
|
+
const timer = setTimeout(() => {
|
|
282
|
+
cleanup();
|
|
283
|
+
reject(new OauthError("timeout", "Timed out waiting for browser callback (5 minutes)."));
|
|
284
|
+
}, timeoutMs);
|
|
285
|
+
const handler = (req, res) => {
|
|
286
|
+
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
287
|
+
if (url.pathname !== "/callback") {
|
|
288
|
+
res.statusCode = 404;
|
|
289
|
+
res.end("Not found");
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
const error = url.searchParams.get("error");
|
|
293
|
+
if (error) {
|
|
294
|
+
const desc = url.searchParams.get("error_description") ?? "no description provided";
|
|
295
|
+
res.statusCode = 400;
|
|
296
|
+
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
297
|
+
res.end(ERROR_HTML(`${error}: ${desc}`));
|
|
298
|
+
cleanup();
|
|
299
|
+
reject(new OauthError(error, `${error}: ${desc}`));
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
const code = url.searchParams.get("code");
|
|
303
|
+
const state = url.searchParams.get("state");
|
|
304
|
+
if (!code || !state) {
|
|
305
|
+
res.statusCode = 400;
|
|
306
|
+
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
307
|
+
res.end(ERROR_HTML("Missing code or state in callback."));
|
|
308
|
+
cleanup();
|
|
309
|
+
reject(new OauthError("invalid_callback", "Missing code or state."));
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
if (state !== expectedState) {
|
|
313
|
+
res.statusCode = 400;
|
|
314
|
+
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
315
|
+
res.end(ERROR_HTML("State mismatch \u2014 possible CSRF. Aborting."));
|
|
316
|
+
cleanup();
|
|
317
|
+
reject(new OauthError("state_mismatch", "State mismatch \u2014 possible CSRF."));
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
res.statusCode = 200;
|
|
321
|
+
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
322
|
+
res.end(SUCCESS_HTML);
|
|
323
|
+
cleanup();
|
|
324
|
+
resolve(code);
|
|
325
|
+
};
|
|
326
|
+
function cleanup() {
|
|
327
|
+
clearTimeout(timer);
|
|
328
|
+
server.removeListener("request", handler);
|
|
329
|
+
server.close();
|
|
330
|
+
}
|
|
331
|
+
server.on("request", handler);
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
function openBrowser(url) {
|
|
335
|
+
try {
|
|
336
|
+
const platform = process.platform;
|
|
337
|
+
let command;
|
|
338
|
+
let args;
|
|
339
|
+
if (platform === "darwin") {
|
|
340
|
+
command = "open";
|
|
341
|
+
args = [url];
|
|
342
|
+
} else if (platform === "win32") {
|
|
343
|
+
command = "cmd";
|
|
344
|
+
args = ["/c", "start", "", url];
|
|
345
|
+
} else {
|
|
346
|
+
command = "xdg-open";
|
|
347
|
+
args = [url];
|
|
348
|
+
}
|
|
349
|
+
const child = spawn(command, args, {
|
|
350
|
+
detached: true,
|
|
351
|
+
stdio: "ignore"
|
|
352
|
+
});
|
|
353
|
+
child.on("error", () => {
|
|
354
|
+
});
|
|
355
|
+
child.unref();
|
|
356
|
+
return true;
|
|
357
|
+
} catch {
|
|
358
|
+
return false;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
async function exchangeCode(tokenEndpoint, params, protectionBypass) {
|
|
362
|
+
const body = new URLSearchParams({
|
|
363
|
+
grant_type: "authorization_code",
|
|
364
|
+
code: params.code,
|
|
365
|
+
client_id: params.clientId,
|
|
366
|
+
redirect_uri: params.redirectUri,
|
|
367
|
+
code_verifier: params.codeVerifier
|
|
368
|
+
});
|
|
369
|
+
const res = await fetch(tokenEndpoint, {
|
|
370
|
+
method: "POST",
|
|
371
|
+
headers: buildFormHeaders(protectionBypass),
|
|
372
|
+
body: body.toString()
|
|
373
|
+
});
|
|
374
|
+
if (!res.ok) {
|
|
375
|
+
const text = await res.text().catch(() => "");
|
|
376
|
+
let parsed = {};
|
|
377
|
+
try {
|
|
378
|
+
parsed = JSON.parse(text);
|
|
379
|
+
} catch {
|
|
380
|
+
}
|
|
381
|
+
throw new OauthError(
|
|
382
|
+
parsed.error ?? "token_exchange_failed",
|
|
383
|
+
parsed.error_description ?? `Token exchange failed (${res.status}): ${text.slice(0, 500)}`
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
return await res.json();
|
|
387
|
+
}
|
|
388
|
+
async function refreshAccessToken(tokenEndpoint, params, protectionBypass) {
|
|
389
|
+
const body = new URLSearchParams({
|
|
390
|
+
grant_type: "refresh_token",
|
|
391
|
+
refresh_token: params.refreshToken,
|
|
392
|
+
client_id: params.clientId
|
|
393
|
+
});
|
|
394
|
+
const res = await fetch(tokenEndpoint, {
|
|
395
|
+
method: "POST",
|
|
396
|
+
headers: buildFormHeaders(protectionBypass),
|
|
397
|
+
body: body.toString()
|
|
398
|
+
});
|
|
399
|
+
if (!res.ok) {
|
|
400
|
+
const text = await res.text().catch(() => "");
|
|
401
|
+
let parsed = {};
|
|
402
|
+
try {
|
|
403
|
+
parsed = JSON.parse(text);
|
|
404
|
+
} catch {
|
|
405
|
+
}
|
|
406
|
+
throw new OauthError(
|
|
407
|
+
parsed.error ?? "refresh_failed",
|
|
408
|
+
parsed.error_description ?? `Token refresh failed (${res.status}): ${text.slice(0, 500)}`
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
return await res.json();
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// src/lib/api.ts
|
|
415
|
+
var REFRESH_SKEW_MS = 6e4;
|
|
416
|
+
var ApiClient = class {
|
|
417
|
+
baseUrl;
|
|
418
|
+
protectionBypass;
|
|
419
|
+
mode;
|
|
420
|
+
// Static-token mode
|
|
421
|
+
staticToken;
|
|
422
|
+
// OAuth mode
|
|
423
|
+
accessToken;
|
|
424
|
+
refreshToken;
|
|
425
|
+
expiresAt;
|
|
426
|
+
// epoch ms
|
|
427
|
+
clientId;
|
|
428
|
+
constructor(config) {
|
|
429
|
+
this.baseUrl = config.apiUrl.replace(/\/+$/, "");
|
|
430
|
+
this.protectionBypass = config.protectionBypass;
|
|
431
|
+
if (config.accessToken && config.refreshToken && config.clientId) {
|
|
432
|
+
this.mode = "oauth";
|
|
433
|
+
this.accessToken = config.accessToken;
|
|
434
|
+
this.refreshToken = config.refreshToken;
|
|
435
|
+
this.clientId = config.clientId;
|
|
436
|
+
this.expiresAt = config.accessTokenExpiresAt ? Date.parse(config.accessTokenExpiresAt) : 0;
|
|
437
|
+
} else if (config.token) {
|
|
438
|
+
this.mode = "static";
|
|
439
|
+
this.staticToken = config.token;
|
|
440
|
+
} else {
|
|
441
|
+
throw new Error(
|
|
442
|
+
"Invalid config: neither static token nor full OAuth credentials are present."
|
|
443
|
+
);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
get tokenEndpoint() {
|
|
447
|
+
return `${this.baseUrl}/api/oauth/token`;
|
|
448
|
+
}
|
|
449
|
+
currentToken() {
|
|
450
|
+
if (this.mode === "oauth") return this.accessToken;
|
|
451
|
+
return this.staticToken;
|
|
452
|
+
}
|
|
453
|
+
buildHeaders() {
|
|
454
|
+
const headers = {
|
|
455
|
+
Authorization: `Bearer ${this.currentToken()}`,
|
|
456
|
+
"Content-Type": "application/json"
|
|
457
|
+
};
|
|
458
|
+
if (this.protectionBypass) {
|
|
459
|
+
headers["x-vercel-protection-bypass"] = this.protectionBypass;
|
|
460
|
+
}
|
|
461
|
+
return headers;
|
|
462
|
+
}
|
|
463
|
+
/**
|
|
464
|
+
* If we're in OAuth mode and the access token is near expiry, refresh it.
|
|
465
|
+
* Pulls in any rotation that another concurrent CLI invocation may have
|
|
466
|
+
* persisted to disk before failing the rotation ourselves.
|
|
467
|
+
*/
|
|
468
|
+
async ensureFreshToken() {
|
|
469
|
+
if (this.mode !== "oauth") return;
|
|
470
|
+
const now = Date.now();
|
|
471
|
+
if (this.expiresAt && this.expiresAt - now > REFRESH_SKEW_MS) return;
|
|
472
|
+
await this.performRefresh();
|
|
473
|
+
}
|
|
474
|
+
async performRefresh() {
|
|
475
|
+
if (this.mode !== "oauth") return;
|
|
476
|
+
try {
|
|
477
|
+
await this.tryRefresh(this.refreshToken);
|
|
478
|
+
} catch (err) {
|
|
479
|
+
if (err instanceof OauthError && err.code === "invalid_grant") {
|
|
480
|
+
const fresh = loadConfig();
|
|
481
|
+
if (fresh?.refreshToken && fresh.refreshToken !== this.refreshToken) {
|
|
482
|
+
this.refreshToken = fresh.refreshToken;
|
|
483
|
+
this.accessToken = fresh.accessToken ?? this.accessToken;
|
|
484
|
+
this.expiresAt = fresh.accessTokenExpiresAt ? Date.parse(fresh.accessTokenExpiresAt) : 0;
|
|
485
|
+
try {
|
|
486
|
+
await this.tryRefresh(this.refreshToken);
|
|
487
|
+
return;
|
|
488
|
+
} catch (err2) {
|
|
489
|
+
this.handleRefreshFailure(err2);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
this.handleRefreshFailure(err);
|
|
493
|
+
}
|
|
494
|
+
this.handleRefreshFailure(err);
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
async tryRefresh(refreshToken) {
|
|
498
|
+
const tokens = await refreshAccessToken(
|
|
499
|
+
this.tokenEndpoint,
|
|
500
|
+
{ refreshToken, clientId: this.clientId },
|
|
501
|
+
this.protectionBypass
|
|
502
|
+
);
|
|
503
|
+
if (!tokens.refresh_token) {
|
|
504
|
+
throw new OauthError(
|
|
505
|
+
"refresh_failed",
|
|
506
|
+
"Server did not return a rotated refresh_token."
|
|
507
|
+
);
|
|
508
|
+
}
|
|
509
|
+
const expiresAtIso = new Date(
|
|
510
|
+
Date.now() + tokens.expires_in * 1e3
|
|
511
|
+
).toISOString();
|
|
512
|
+
this.accessToken = tokens.access_token;
|
|
513
|
+
this.refreshToken = tokens.refresh_token;
|
|
514
|
+
this.expiresAt = Date.parse(expiresAtIso);
|
|
515
|
+
updateTokens({
|
|
516
|
+
accessToken: tokens.access_token,
|
|
517
|
+
refreshToken: tokens.refresh_token,
|
|
518
|
+
accessTokenExpiresAt: expiresAtIso,
|
|
519
|
+
clientId: this.clientId
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
handleRefreshFailure(err) {
|
|
523
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
524
|
+
console.error(
|
|
525
|
+
`
|
|
526
|
+
Your Nemonicon session has expired (${msg}).
|
|
527
|
+
Run \`nemonicon login\` to sign in again.`
|
|
528
|
+
);
|
|
529
|
+
try {
|
|
530
|
+
clearOauthTokens();
|
|
531
|
+
} catch {
|
|
532
|
+
}
|
|
533
|
+
process.exit(1);
|
|
534
|
+
}
|
|
535
|
+
async request(path, options = {}, retryOn401 = true) {
|
|
536
|
+
await this.ensureFreshToken();
|
|
537
|
+
const url = `${this.baseUrl}${path}`;
|
|
538
|
+
let res;
|
|
539
|
+
try {
|
|
540
|
+
res = await fetch(url, {
|
|
541
|
+
...options,
|
|
542
|
+
headers: {
|
|
543
|
+
...this.buildHeaders(),
|
|
544
|
+
...options.headers
|
|
545
|
+
}
|
|
546
|
+
});
|
|
547
|
+
} catch (error) {
|
|
548
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
549
|
+
throw new Error(
|
|
550
|
+
`Network error reaching ${url}: ${msg}
|
|
551
|
+
Check that the API URL is correct and the server is reachable.`
|
|
552
|
+
);
|
|
553
|
+
}
|
|
554
|
+
if (res.status === 401 && this.mode === "oauth" && retryOn401) {
|
|
555
|
+
await this.performRefresh();
|
|
556
|
+
return this.request(path, options, false);
|
|
557
|
+
}
|
|
558
|
+
if (!res.ok) {
|
|
559
|
+
const text = await res.text().catch(() => "");
|
|
560
|
+
const detail = text || res.statusText;
|
|
561
|
+
let hint = "";
|
|
562
|
+
if (res.status === 401) {
|
|
563
|
+
if (this.mode === "oauth") {
|
|
564
|
+
hint = "\n Your session may have been revoked. Run `nemonicon login` to sign in again.";
|
|
565
|
+
} else {
|
|
566
|
+
hint = "\n Your token may be invalid or expired. Generate a new one at /settings/cli,\n or run `nemonicon login` to sign in via OAuth instead.";
|
|
567
|
+
}
|
|
568
|
+
} else if (res.status === 403) {
|
|
569
|
+
hint = "\n Access forbidden. If this is a Vercel preview deployment, you may need\n to pass --protection-bypass <secret> during auth.\n Find the secret in Vercel > Settings > Deployment Protection.";
|
|
570
|
+
} else if (res.status === 404) {
|
|
571
|
+
hint = "\n Endpoint not found. Check that the API URL is correct and the\n server has the latest deployment with CLI token support.";
|
|
572
|
+
} else if (res.status >= 500) {
|
|
573
|
+
hint = "\n Server error. The api_tokens table may not be migrated yet.";
|
|
574
|
+
}
|
|
575
|
+
throw new Error(
|
|
576
|
+
`API ${res.status} ${res.statusText} \u2014 ${path}
|
|
577
|
+
Response: ${detail.slice(0, 500)}${hint}`
|
|
578
|
+
);
|
|
579
|
+
}
|
|
580
|
+
if (res.status === 204) {
|
|
581
|
+
return void 0;
|
|
582
|
+
}
|
|
583
|
+
const contentType = res.headers.get("Content-Type") ?? "";
|
|
584
|
+
if (contentType.includes("text/html") && !path.includes("export?format=md")) {
|
|
585
|
+
throw new Error(
|
|
586
|
+
`Received HTML instead of JSON from ${path}.
|
|
587
|
+
This usually means Vercel Deployment Protection is blocking the request.
|
|
588
|
+
Re-run auth with --protection-bypass <secret> to fix this.
|
|
589
|
+
Find the secret in Vercel > Settings > Deployment Protection.`
|
|
590
|
+
);
|
|
591
|
+
}
|
|
592
|
+
if (contentType.includes("application/json")) {
|
|
593
|
+
return res.json();
|
|
594
|
+
}
|
|
595
|
+
return res.text();
|
|
596
|
+
}
|
|
597
|
+
async listTopics() {
|
|
598
|
+
return this.request("/api/knowledge?limit=100");
|
|
599
|
+
}
|
|
600
|
+
async createTopic(input) {
|
|
601
|
+
return this.request("/api/knowledge", {
|
|
602
|
+
method: "POST",
|
|
603
|
+
body: JSON.stringify(input)
|
|
604
|
+
});
|
|
605
|
+
}
|
|
606
|
+
async exportTopic(topicId, format = "md") {
|
|
607
|
+
return this.request(`/api/knowledge/${topicId}/export?format=${format}`);
|
|
608
|
+
}
|
|
609
|
+
async importMarkdown(content, title) {
|
|
610
|
+
return this.request("/api/knowledge/import", {
|
|
611
|
+
method: "POST",
|
|
612
|
+
body: JSON.stringify({
|
|
613
|
+
format: "markdown",
|
|
614
|
+
content,
|
|
615
|
+
...title ? { title } : {}
|
|
616
|
+
})
|
|
617
|
+
});
|
|
618
|
+
}
|
|
619
|
+
async bulkCreateOutline(topicId, items, parentId) {
|
|
620
|
+
return this.request(`/api/knowledge/${topicId}/outline/bulk`, {
|
|
621
|
+
method: "POST",
|
|
622
|
+
body: JSON.stringify({
|
|
623
|
+
items,
|
|
624
|
+
...parentId ? { parentId } : {}
|
|
625
|
+
})
|
|
626
|
+
});
|
|
627
|
+
}
|
|
628
|
+
/**
|
|
629
|
+
* Call an MCP tool via the JSON-RPC endpoint at /api/mcp.
|
|
630
|
+
*
|
|
631
|
+
* This is used for tools that don't have dedicated REST endpoints
|
|
632
|
+
* (`get_outline`, `update_item`, `delete_item`, `bulk_update_items`).
|
|
633
|
+
* The HTTP MCP server delegates to `lib/mcp/tools.ts` so semantics stay
|
|
634
|
+
* identical between stdio and HTTP transports.
|
|
635
|
+
*/
|
|
636
|
+
async callTool(name, args) {
|
|
637
|
+
const res = await this.request("/api/mcp", {
|
|
638
|
+
method: "POST",
|
|
639
|
+
body: JSON.stringify({
|
|
640
|
+
jsonrpc: "2.0",
|
|
641
|
+
id: Date.now(),
|
|
642
|
+
method: "tools/call",
|
|
643
|
+
params: { name, arguments: args }
|
|
644
|
+
})
|
|
645
|
+
});
|
|
646
|
+
if (res.error) {
|
|
647
|
+
throw new Error(`MCP error ${res.error.code}: ${res.error.message}`);
|
|
648
|
+
}
|
|
649
|
+
const result = res.result ?? {};
|
|
650
|
+
const content = Array.isArray(result.content) ? result.content : [];
|
|
651
|
+
const text = content.filter(
|
|
652
|
+
(c) => typeof c.text === "string"
|
|
653
|
+
).map((c) => c.text).join("\n");
|
|
654
|
+
return { text, isError: result.isError === true };
|
|
655
|
+
}
|
|
656
|
+
/** Fetch a topic outline as a structured tree (calls `get_outline` MCP tool). */
|
|
657
|
+
async getOutline(topicId) {
|
|
658
|
+
const res = await this.callTool("get_outline", { topicId });
|
|
659
|
+
if (res.isError) {
|
|
660
|
+
throw new Error(res.text);
|
|
661
|
+
}
|
|
662
|
+
return JSON.parse(res.text);
|
|
663
|
+
}
|
|
664
|
+
/** Update a single outline item (calls `update_item` MCP tool). */
|
|
665
|
+
async updateOutlineItem(itemId, payload) {
|
|
666
|
+
const args = { itemId };
|
|
667
|
+
if (payload.content !== void 0) args.content = payload.content;
|
|
668
|
+
if (payload.parentId !== void 0) args.parentId = payload.parentId;
|
|
669
|
+
if (payload.sortOrder !== void 0) args.sortOrder = payload.sortOrder;
|
|
670
|
+
const res = await this.callTool("update_item", args);
|
|
671
|
+
if (res.isError) {
|
|
672
|
+
throw new Error(res.text);
|
|
673
|
+
}
|
|
674
|
+
return { message: res.text };
|
|
675
|
+
}
|
|
676
|
+
/** Delete a single outline item (calls `delete_item` MCP tool). */
|
|
677
|
+
async deleteOutlineItem(itemId) {
|
|
678
|
+
const res = await this.callTool("delete_item", { itemId });
|
|
679
|
+
if (res.isError) {
|
|
680
|
+
throw new Error(res.text);
|
|
681
|
+
}
|
|
682
|
+
return { message: res.text };
|
|
683
|
+
}
|
|
684
|
+
/** Atomically apply multiple updates (calls `bulk_update_items` MCP tool). */
|
|
685
|
+
async bulkUpdateOutlineItems(topicId, updates) {
|
|
686
|
+
const res = await this.callTool("bulk_update_items", { topicId, updates });
|
|
687
|
+
if (res.isError) {
|
|
688
|
+
throw new Error(res.text);
|
|
689
|
+
}
|
|
690
|
+
return { message: res.text };
|
|
691
|
+
}
|
|
692
|
+
/** Connectivity and auth check. Returns the error message on failure. */
|
|
693
|
+
async verify() {
|
|
694
|
+
try {
|
|
695
|
+
await this.listTopics();
|
|
696
|
+
return { ok: true };
|
|
697
|
+
} catch (error) {
|
|
698
|
+
return {
|
|
699
|
+
ok: false,
|
|
700
|
+
error: error instanceof Error ? error.message : String(error)
|
|
701
|
+
};
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
};
|
|
705
|
+
|
|
706
|
+
// src/commands/auth.ts
|
|
707
|
+
import { createInterface } from "readline";
|
|
708
|
+
var DEFAULT_API_URL = "https://preview.nemonicon.com";
|
|
709
|
+
function prompt(question, defaultValue) {
|
|
710
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
711
|
+
const suffix = defaultValue ? ` (${defaultValue})` : "";
|
|
712
|
+
return new Promise((resolve) => {
|
|
713
|
+
rl.question(`${question}${suffix}: `, (answer) => {
|
|
714
|
+
rl.close();
|
|
715
|
+
resolve(answer.trim() || defaultValue || "");
|
|
716
|
+
});
|
|
717
|
+
});
|
|
718
|
+
}
|
|
719
|
+
var authCommand = new Command("auth").description("Authenticate the CLI with a token").argument("<token>", "API token from the Nemonicon settings page").option("--api-url <url>", "API server URL").option(
|
|
720
|
+
"--protection-bypass <secret>",
|
|
721
|
+
"Vercel Deployment Protection bypass secret (for preview deployments)"
|
|
722
|
+
).action(
|
|
723
|
+
async (token, options) => {
|
|
724
|
+
let apiUrl = options.apiUrl;
|
|
725
|
+
if (!apiUrl) {
|
|
726
|
+
apiUrl = await prompt("API URL", DEFAULT_API_URL);
|
|
727
|
+
}
|
|
728
|
+
apiUrl = apiUrl.replace(/\/+$/, "");
|
|
729
|
+
const config = {
|
|
730
|
+
apiUrl,
|
|
731
|
+
token,
|
|
732
|
+
protectionBypass: options.protectionBypass || void 0
|
|
733
|
+
};
|
|
734
|
+
saveConfig(config);
|
|
735
|
+
console.log(`Verifying token against ${apiUrl}...`);
|
|
736
|
+
console.log(` POST ${apiUrl}/api/knowledge?limit=100`);
|
|
737
|
+
const client = new ApiClient(config);
|
|
738
|
+
const result = await client.verify();
|
|
739
|
+
if (!result.ok) {
|
|
740
|
+
console.error("\nAuthentication failed:\n");
|
|
741
|
+
console.error(result.error);
|
|
742
|
+
console.error(
|
|
743
|
+
`
|
|
744
|
+
Config was saved to ~/.nemonicon/config.json \u2014 you can edit it manually.`
|
|
745
|
+
);
|
|
746
|
+
process.exit(1);
|
|
747
|
+
}
|
|
748
|
+
console.log("\nAuthenticated successfully.");
|
|
749
|
+
console.log(`Config saved to ~/.nemonicon/config.json`);
|
|
750
|
+
}
|
|
751
|
+
);
|
|
752
|
+
|
|
753
|
+
// src/commands/login.ts
|
|
754
|
+
import { Command as Command2 } from "commander";
|
|
755
|
+
import { createInterface as createInterface2 } from "readline";
|
|
756
|
+
var DEFAULT_API_URL2 = "https://preview.nemonicon.com";
|
|
757
|
+
function prompt2(question, defaultValue) {
|
|
758
|
+
const rl = createInterface2({ input: process.stdin, output: process.stdout });
|
|
759
|
+
const suffix = defaultValue ? ` (${defaultValue})` : "";
|
|
760
|
+
return new Promise((resolve) => {
|
|
761
|
+
rl.question(`${question}${suffix}: `, (answer) => {
|
|
762
|
+
rl.close();
|
|
763
|
+
resolve(answer.trim() || defaultValue || "");
|
|
764
|
+
});
|
|
765
|
+
});
|
|
766
|
+
}
|
|
767
|
+
var loginCommand = new Command2("login").description(
|
|
768
|
+
"Sign in to Nemonicon via your browser using OAuth 2.1 + PKCE"
|
|
769
|
+
).option("--api-url <url>", "API server URL").option(
|
|
770
|
+
"--protection-bypass <secret>",
|
|
771
|
+
"Vercel Deployment Protection bypass secret (for preview deployments)"
|
|
772
|
+
).option(
|
|
773
|
+
"--no-browser",
|
|
774
|
+
"Print the authorization URL instead of opening the browser automatically"
|
|
775
|
+
).action(
|
|
776
|
+
async (options) => {
|
|
777
|
+
const existing = loadPartialConfig();
|
|
778
|
+
let apiUrl = options.apiUrl ?? existing?.apiUrl;
|
|
779
|
+
if (!apiUrl) {
|
|
780
|
+
apiUrl = await prompt2("API URL", DEFAULT_API_URL2);
|
|
781
|
+
}
|
|
782
|
+
apiUrl = apiUrl.replace(/\/+$/, "");
|
|
783
|
+
const protectionBypass = options.protectionBypass ?? existing?.protectionBypass;
|
|
784
|
+
console.log(`Discovering OAuth endpoints at ${apiUrl}...`);
|
|
785
|
+
const endpoints = await discoverEndpoints(apiUrl, protectionBypass);
|
|
786
|
+
let clientId = existing?.clientId;
|
|
787
|
+
if (!clientId) {
|
|
788
|
+
console.log("Registering CLI as an OAuth client...");
|
|
789
|
+
clientId = await registerClient(
|
|
790
|
+
endpoints.registrationEndpoint,
|
|
791
|
+
protectionBypass
|
|
792
|
+
);
|
|
793
|
+
const next = {
|
|
794
|
+
apiUrl,
|
|
795
|
+
clientId,
|
|
796
|
+
protectionBypass,
|
|
797
|
+
...existing?.token ? { token: existing.token } : {},
|
|
798
|
+
...existing?.authMethod ? { authMethod: existing.authMethod } : {}
|
|
799
|
+
};
|
|
800
|
+
saveConfig(next);
|
|
801
|
+
}
|
|
802
|
+
console.log("Starting local callback server...");
|
|
803
|
+
const { server, redirectUri } = await startCallbackServer();
|
|
804
|
+
const pkce = generatePkce();
|
|
805
|
+
const authorizeUrl = buildAuthorizeUrl(endpoints.authorizationEndpoint, {
|
|
806
|
+
clientId,
|
|
807
|
+
redirectUri,
|
|
808
|
+
codeChallenge: pkce.codeChallenge,
|
|
809
|
+
state: pkce.state
|
|
810
|
+
});
|
|
811
|
+
console.log("");
|
|
812
|
+
const opened = options.browser !== false ? openBrowser(authorizeUrl) : false;
|
|
813
|
+
if (opened) {
|
|
814
|
+
console.log("Opened your browser to sign in. If nothing happened, visit:");
|
|
815
|
+
} else {
|
|
816
|
+
console.log("Open this URL in your browser to sign in:");
|
|
817
|
+
}
|
|
818
|
+
console.log(` ${authorizeUrl}`);
|
|
819
|
+
console.log("");
|
|
820
|
+
console.log("Waiting for sign-in to complete (5 min timeout)...");
|
|
821
|
+
let code;
|
|
822
|
+
try {
|
|
823
|
+
code = await waitForCallback(server, pkce.state);
|
|
824
|
+
} catch (err) {
|
|
825
|
+
server.close();
|
|
826
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
827
|
+
console.error(`
|
|
828
|
+
Sign-in failed: ${msg}`);
|
|
829
|
+
process.exit(1);
|
|
830
|
+
}
|
|
831
|
+
console.log("Exchanging authorization code for tokens...");
|
|
832
|
+
let tokens;
|
|
833
|
+
try {
|
|
834
|
+
tokens = await exchangeCode(
|
|
835
|
+
endpoints.tokenEndpoint,
|
|
836
|
+
{
|
|
837
|
+
code,
|
|
838
|
+
clientId,
|
|
839
|
+
redirectUri,
|
|
840
|
+
codeVerifier: pkce.codeVerifier
|
|
841
|
+
},
|
|
842
|
+
protectionBypass
|
|
843
|
+
);
|
|
844
|
+
} catch (err) {
|
|
845
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
846
|
+
console.error(`
|
|
847
|
+
Token exchange failed: ${msg}`);
|
|
848
|
+
process.exit(1);
|
|
849
|
+
}
|
|
850
|
+
if (!tokens.refresh_token) {
|
|
851
|
+
console.error(
|
|
852
|
+
"\nServer did not return a refresh_token. Refusing to save partial credentials."
|
|
853
|
+
);
|
|
854
|
+
process.exit(1);
|
|
855
|
+
}
|
|
856
|
+
const expiresAt = new Date(
|
|
857
|
+
Date.now() + tokens.expires_in * 1e3
|
|
858
|
+
).toISOString();
|
|
859
|
+
const finalConfig = {
|
|
860
|
+
apiUrl,
|
|
861
|
+
clientId,
|
|
862
|
+
protectionBypass,
|
|
863
|
+
authMethod: "oauth",
|
|
864
|
+
accessToken: tokens.access_token,
|
|
865
|
+
refreshToken: tokens.refresh_token,
|
|
866
|
+
accessTokenExpiresAt: expiresAt
|
|
867
|
+
};
|
|
868
|
+
saveConfig(finalConfig);
|
|
869
|
+
console.log("Verifying access...");
|
|
870
|
+
const client = new ApiClient(finalConfig);
|
|
871
|
+
const result = await client.verify();
|
|
872
|
+
if (!result.ok) {
|
|
873
|
+
console.error("\nAuthentication succeeded but verification failed:\n");
|
|
874
|
+
console.error(result.error);
|
|
875
|
+
console.error(
|
|
876
|
+
"\nConfig was saved to ~/.nemonicon/config.json \u2014 you can edit it manually."
|
|
877
|
+
);
|
|
878
|
+
process.exit(1);
|
|
879
|
+
}
|
|
880
|
+
console.log("\nSigned in successfully.");
|
|
881
|
+
console.log("Config saved to ~/.nemonicon/config.json");
|
|
882
|
+
}
|
|
883
|
+
);
|
|
884
|
+
|
|
885
|
+
// src/commands/logout.ts
|
|
886
|
+
import { Command as Command3 } from "commander";
|
|
887
|
+
var logoutCommand = new Command3("logout").description(
|
|
888
|
+
"Clear OAuth tokens from ~/.nemonicon/config.json (preserves apiUrl and clientId)"
|
|
889
|
+
).option(
|
|
890
|
+
"--purge",
|
|
891
|
+
"Delete the entire ~/.nemonicon/config.json file instead of just the tokens"
|
|
892
|
+
).action((options) => {
|
|
893
|
+
const existing = loadConfig();
|
|
894
|
+
if (!existing) {
|
|
895
|
+
console.log("Not signed in.");
|
|
896
|
+
return;
|
|
897
|
+
}
|
|
898
|
+
if (options.purge) {
|
|
899
|
+
const removed = deleteConfig();
|
|
900
|
+
if (removed) {
|
|
901
|
+
console.log(`Deleted ${getConfigPath()}.`);
|
|
902
|
+
} else {
|
|
903
|
+
console.log("No config file to remove.");
|
|
904
|
+
}
|
|
905
|
+
return;
|
|
906
|
+
}
|
|
907
|
+
const next = clearOauthTokens();
|
|
908
|
+
if (!next) {
|
|
909
|
+
console.log("No config file to update.");
|
|
910
|
+
return;
|
|
911
|
+
}
|
|
912
|
+
if (next.token) {
|
|
913
|
+
console.log(
|
|
914
|
+
"Cleared OAuth tokens. A static `token` is still set in your config \u2014 run `nemonicon logout --purge` to wipe everything."
|
|
915
|
+
);
|
|
916
|
+
} else {
|
|
917
|
+
console.log(
|
|
918
|
+
"Signed out. Run `nemonicon login` to sign in again. (apiUrl and clientId were preserved for faster re-login.)"
|
|
919
|
+
);
|
|
920
|
+
}
|
|
921
|
+
});
|
|
922
|
+
|
|
923
|
+
// src/commands/list.ts
|
|
924
|
+
import { Command as Command4 } from "commander";
|
|
925
|
+
var listCommand = new Command4("list").description("List all knowledge topics").option("--json", "Output as JSON").action(async (options) => {
|
|
926
|
+
const config = requireConfig();
|
|
927
|
+
const client = new ApiClient(config);
|
|
928
|
+
try {
|
|
929
|
+
const topics = await client.listTopics();
|
|
930
|
+
if (options.json) {
|
|
931
|
+
console.log(JSON.stringify(topics, null, 2));
|
|
932
|
+
return;
|
|
933
|
+
}
|
|
934
|
+
if (topics.length === 0) {
|
|
935
|
+
console.log("No topics found.");
|
|
936
|
+
return;
|
|
937
|
+
}
|
|
938
|
+
const header = "ID Title Tags Updated";
|
|
939
|
+
const rows = topics.map((t) => {
|
|
940
|
+
const tags = t.tags.length > 0 ? t.tags.join(", ") : "-";
|
|
941
|
+
const updated = new Date(t.updatedAt).toLocaleDateString();
|
|
942
|
+
return `${t.id} ${t.title} ${tags} ${updated}`;
|
|
943
|
+
});
|
|
944
|
+
console.log(header);
|
|
945
|
+
console.log("-".repeat(80));
|
|
946
|
+
for (const row of rows) {
|
|
947
|
+
console.log(row);
|
|
948
|
+
}
|
|
949
|
+
console.log(`
|
|
950
|
+
${topics.length} topic(s)`);
|
|
951
|
+
} catch (error) {
|
|
952
|
+
console.error(
|
|
953
|
+
"Failed to list topics:",
|
|
954
|
+
error instanceof Error ? error.message : error
|
|
955
|
+
);
|
|
956
|
+
process.exit(1);
|
|
957
|
+
}
|
|
958
|
+
});
|
|
959
|
+
|
|
960
|
+
// src/commands/show.ts
|
|
961
|
+
import { Command as Command5 } from "commander";
|
|
962
|
+
var showCommand = new Command5("show").description("Show a topic's outline as markdown").argument("<topicId>", "Topic ID to display").action(async (topicId) => {
|
|
963
|
+
const config = requireConfig();
|
|
964
|
+
const client = new ApiClient(config);
|
|
965
|
+
try {
|
|
966
|
+
const markdown = await client.exportTopic(topicId, "md");
|
|
967
|
+
console.log(markdown);
|
|
968
|
+
} catch (error) {
|
|
969
|
+
console.error(
|
|
970
|
+
"Failed to show topic:",
|
|
971
|
+
error instanceof Error ? error.message : error
|
|
972
|
+
);
|
|
973
|
+
process.exit(1);
|
|
974
|
+
}
|
|
975
|
+
});
|
|
976
|
+
|
|
977
|
+
// src/commands/outline.ts
|
|
978
|
+
import { Command as Command6 } from "commander";
|
|
979
|
+
function renderTree(nodes, depth = 0) {
|
|
980
|
+
const lines = [];
|
|
981
|
+
for (const node of nodes) {
|
|
982
|
+
const indent = " ".repeat(depth);
|
|
983
|
+
lines.push(`${indent}- ${node.content} [${node.id}]`);
|
|
984
|
+
if (node.children.length > 0) {
|
|
985
|
+
lines.push(...renderTree(node.children, depth + 1));
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
return lines;
|
|
989
|
+
}
|
|
990
|
+
var outlineCommand = new Command6("outline").description("Show a topic's outline as a tree with stable item IDs").argument("<topicId>", "Topic ID to display").option("--json", "Emit raw JSON instead of pretty-printed tree").action(async (topicId, options) => {
|
|
991
|
+
const config = requireConfig();
|
|
992
|
+
const client = new ApiClient(config);
|
|
993
|
+
try {
|
|
994
|
+
const tree = await client.getOutline(topicId);
|
|
995
|
+
if (options.json) {
|
|
996
|
+
console.log(JSON.stringify(tree, null, 2));
|
|
997
|
+
return;
|
|
998
|
+
}
|
|
999
|
+
console.log(`# ${tree.title} [${tree.topicId}]`);
|
|
1000
|
+
console.log("");
|
|
1001
|
+
const lines = renderTree(tree.items);
|
|
1002
|
+
if (lines.length === 0) {
|
|
1003
|
+
console.log("(empty topic)");
|
|
1004
|
+
} else {
|
|
1005
|
+
for (const line of lines) console.log(line);
|
|
1006
|
+
}
|
|
1007
|
+
} catch (error) {
|
|
1008
|
+
console.error(
|
|
1009
|
+
"Failed to fetch outline:",
|
|
1010
|
+
error instanceof Error ? error.message : error
|
|
1011
|
+
);
|
|
1012
|
+
process.exit(1);
|
|
1013
|
+
}
|
|
1014
|
+
});
|
|
1015
|
+
|
|
1016
|
+
// src/commands/create.ts
|
|
1017
|
+
import { Command as Command7 } from "commander";
|
|
1018
|
+
import { readFileSync as readFileSync2 } from "fs";
|
|
1019
|
+
var createCommand = new Command7("create").description("Create a new knowledge topic from a markdown file").argument("<file>", "Path to markdown file").option("--title <title>", "Override the topic title (default: from H1)").option("--description <desc>", "Topic description").option("--icon <icon>", "Topic icon (emoji)").option("--color <color>", "Topic color").option("--tags <tags>", "Comma-separated tags").action(
|
|
1020
|
+
async (file, options) => {
|
|
1021
|
+
const config = requireConfig();
|
|
1022
|
+
const client = new ApiClient(config);
|
|
1023
|
+
let content;
|
|
1024
|
+
try {
|
|
1025
|
+
content = readFileSync2(file, "utf-8");
|
|
1026
|
+
} catch {
|
|
1027
|
+
console.error(`Cannot read file: ${file}`);
|
|
1028
|
+
process.exit(1);
|
|
1029
|
+
}
|
|
1030
|
+
if (!content.trim()) {
|
|
1031
|
+
console.error("File is empty.");
|
|
1032
|
+
process.exit(1);
|
|
1033
|
+
}
|
|
1034
|
+
try {
|
|
1035
|
+
const result = await client.importMarkdown(content, options.title);
|
|
1036
|
+
console.log(
|
|
1037
|
+
JSON.stringify(
|
|
1038
|
+
{
|
|
1039
|
+
topicId: result.topic.id,
|
|
1040
|
+
title: result.topic.title,
|
|
1041
|
+
itemsCreated: result.itemsCreated
|
|
1042
|
+
},
|
|
1043
|
+
null,
|
|
1044
|
+
2
|
|
1045
|
+
)
|
|
1046
|
+
);
|
|
1047
|
+
} catch (error) {
|
|
1048
|
+
console.error(
|
|
1049
|
+
"Failed to create topic:",
|
|
1050
|
+
error instanceof Error ? error.message : error
|
|
1051
|
+
);
|
|
1052
|
+
process.exit(1);
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
);
|
|
1056
|
+
|
|
1057
|
+
// src/commands/append.ts
|
|
1058
|
+
import { Command as Command8 } from "commander";
|
|
1059
|
+
import { readFileSync as readFileSync3 } from "fs";
|
|
1060
|
+
|
|
1061
|
+
// src/lib/markdown.ts
|
|
1062
|
+
function markdownToTree(markdown) {
|
|
1063
|
+
const lines = markdown.split("\n");
|
|
1064
|
+
const root = { content: "", children: [] };
|
|
1065
|
+
const stack = [
|
|
1066
|
+
{ node: root, depth: -1 }
|
|
1067
|
+
];
|
|
1068
|
+
for (const raw of lines) {
|
|
1069
|
+
if (/^#\s+/.test(raw)) continue;
|
|
1070
|
+
const bullet = raw.match(/^(\s*)[-*+]\s+(.*)$/);
|
|
1071
|
+
if (!bullet) continue;
|
|
1072
|
+
const indent = bullet[1].replace(/\t/g, " ").length;
|
|
1073
|
+
const depth = Math.floor(indent / 2);
|
|
1074
|
+
const content = bullet[2];
|
|
1075
|
+
const node = { content, children: [] };
|
|
1076
|
+
while (stack.length > 1 && stack[stack.length - 1].depth >= depth) {
|
|
1077
|
+
stack.pop();
|
|
1078
|
+
}
|
|
1079
|
+
stack[stack.length - 1].node.children.push(node);
|
|
1080
|
+
stack.push({ node, depth });
|
|
1081
|
+
}
|
|
1082
|
+
return root.children;
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
// src/commands/append.ts
|
|
1086
|
+
var appendCommand = new Command8("append").description("Append outline items to an existing topic").argument("<topicId>", "Topic ID to append to").argument("<file>", "Path to markdown file with bullet items").option("--parent <parentId>", "Attach under a specific outline item").action(
|
|
1087
|
+
async (topicId, file, options) => {
|
|
1088
|
+
const config = requireConfig();
|
|
1089
|
+
const client = new ApiClient(config);
|
|
1090
|
+
let content;
|
|
1091
|
+
try {
|
|
1092
|
+
content = readFileSync3(file, "utf-8");
|
|
1093
|
+
} catch {
|
|
1094
|
+
console.error(`Cannot read file: ${file}`);
|
|
1095
|
+
process.exit(1);
|
|
1096
|
+
}
|
|
1097
|
+
if (!content.trim()) {
|
|
1098
|
+
console.error("File is empty.");
|
|
1099
|
+
process.exit(1);
|
|
1100
|
+
}
|
|
1101
|
+
const tree = markdownToTree(content);
|
|
1102
|
+
if (tree.length === 0) {
|
|
1103
|
+
console.error("No bullet items found in the markdown file.");
|
|
1104
|
+
process.exit(1);
|
|
1105
|
+
}
|
|
1106
|
+
try {
|
|
1107
|
+
const result = await client.bulkCreateOutline(
|
|
1108
|
+
topicId,
|
|
1109
|
+
tree,
|
|
1110
|
+
options.parent
|
|
1111
|
+
);
|
|
1112
|
+
console.log(JSON.stringify(result, null, 2));
|
|
1113
|
+
} catch (error) {
|
|
1114
|
+
console.error(
|
|
1115
|
+
"Failed to append items:",
|
|
1116
|
+
error instanceof Error ? error.message : error
|
|
1117
|
+
);
|
|
1118
|
+
process.exit(1);
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
);
|
|
1122
|
+
|
|
1123
|
+
// src/commands/update.ts
|
|
1124
|
+
import { Command as Command9 } from "commander";
|
|
1125
|
+
import { readFileSync as readFileSync4 } from "fs";
|
|
1126
|
+
var updateCommand = new Command9("update").description("Update a single outline item (content, parent, and/or sortOrder)").argument("<itemId>", "Item ID to update").option("--content <text>", "New text content for the item").option(
|
|
1127
|
+
"--content-file <path>",
|
|
1128
|
+
"Read new content from a file (use this for multi-line text)"
|
|
1129
|
+
).option(
|
|
1130
|
+
"--parent <uuid|null>",
|
|
1131
|
+
"Move under a different parent. Pass `null` to move to the topic root."
|
|
1132
|
+
).option("--sort <int>", "New sort order among siblings", (v) => parseInt(v, 10)).action(
|
|
1133
|
+
async (itemId, options) => {
|
|
1134
|
+
const config = requireConfig();
|
|
1135
|
+
const client = new ApiClient(config);
|
|
1136
|
+
const payload = {};
|
|
1137
|
+
if (options.content !== void 0 && options.contentFile !== void 0) {
|
|
1138
|
+
console.error("Pass either --content or --content-file, not both.");
|
|
1139
|
+
process.exit(1);
|
|
1140
|
+
}
|
|
1141
|
+
if (options.content !== void 0) {
|
|
1142
|
+
payload.content = options.content;
|
|
1143
|
+
} else if (options.contentFile !== void 0) {
|
|
1144
|
+
try {
|
|
1145
|
+
payload.content = readFileSync4(options.contentFile, "utf-8");
|
|
1146
|
+
} catch {
|
|
1147
|
+
console.error(`Cannot read file: ${options.contentFile}`);
|
|
1148
|
+
process.exit(1);
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
if (options.parent !== void 0) {
|
|
1152
|
+
payload.parentId = options.parent === "null" ? null : options.parent;
|
|
1153
|
+
}
|
|
1154
|
+
if (options.sort !== void 0) {
|
|
1155
|
+
if (!Number.isInteger(options.sort)) {
|
|
1156
|
+
console.error("--sort must be an integer.");
|
|
1157
|
+
process.exit(1);
|
|
1158
|
+
}
|
|
1159
|
+
payload.sortOrder = options.sort;
|
|
1160
|
+
}
|
|
1161
|
+
if (Object.keys(payload).length === 0) {
|
|
1162
|
+
console.error(
|
|
1163
|
+
"Provide at least one of --content, --content-file, --parent, or --sort."
|
|
1164
|
+
);
|
|
1165
|
+
process.exit(1);
|
|
1166
|
+
}
|
|
1167
|
+
try {
|
|
1168
|
+
const result = await client.updateOutlineItem(itemId, payload);
|
|
1169
|
+
console.log(result.message);
|
|
1170
|
+
} catch (error) {
|
|
1171
|
+
console.error(
|
|
1172
|
+
"Failed to update item:",
|
|
1173
|
+
error instanceof Error ? error.message : error
|
|
1174
|
+
);
|
|
1175
|
+
process.exit(1);
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
);
|
|
1179
|
+
|
|
1180
|
+
// src/commands/delete.ts
|
|
1181
|
+
import { Command as Command10 } from "commander";
|
|
1182
|
+
import { createInterface as createInterface3 } from "readline";
|
|
1183
|
+
function confirm(question) {
|
|
1184
|
+
const rl = createInterface3({ input: process.stdin, output: process.stdout });
|
|
1185
|
+
return new Promise((resolve) => {
|
|
1186
|
+
rl.question(`${question} [y/N] `, (answer) => {
|
|
1187
|
+
rl.close();
|
|
1188
|
+
resolve(/^y(es)?$/i.test(answer.trim()));
|
|
1189
|
+
});
|
|
1190
|
+
});
|
|
1191
|
+
}
|
|
1192
|
+
var deleteCommand = new Command10("delete").description(
|
|
1193
|
+
"Delete a single outline item. Direct children are reparented to the deleted item's grandparent."
|
|
1194
|
+
).argument("<itemId>", "Item ID to delete").option("--yes", "Skip the interactive confirmation prompt").action(async (itemId, options) => {
|
|
1195
|
+
const config = requireConfig();
|
|
1196
|
+
const client = new ApiClient(config);
|
|
1197
|
+
if (!options.yes) {
|
|
1198
|
+
const ok = await confirm(`Delete outline item ${itemId}?`);
|
|
1199
|
+
if (!ok) {
|
|
1200
|
+
console.log("Cancelled.");
|
|
1201
|
+
return;
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
try {
|
|
1205
|
+
const result = await client.deleteOutlineItem(itemId);
|
|
1206
|
+
console.log(result.message);
|
|
1207
|
+
} catch (error) {
|
|
1208
|
+
console.error(
|
|
1209
|
+
"Failed to delete item:",
|
|
1210
|
+
error instanceof Error ? error.message : error
|
|
1211
|
+
);
|
|
1212
|
+
process.exit(1);
|
|
1213
|
+
}
|
|
1214
|
+
});
|
|
1215
|
+
|
|
1216
|
+
// src/commands/move.ts
|
|
1217
|
+
import { Command as Command11 } from "commander";
|
|
1218
|
+
var moveCommand = new Command11("move").description(
|
|
1219
|
+
"Move an outline item to a different parent and/or sortOrder (alias for `update --parent/--sort`)"
|
|
1220
|
+
).argument("<itemId>", "Item ID to move").option(
|
|
1221
|
+
"--parent <uuid|null>",
|
|
1222
|
+
"New parent UUID, or `null` to move to the topic root"
|
|
1223
|
+
).option("--sort <int>", "New sort order among siblings", (v) => parseInt(v, 10)).action(
|
|
1224
|
+
async (itemId, options) => {
|
|
1225
|
+
const config = requireConfig();
|
|
1226
|
+
const client = new ApiClient(config);
|
|
1227
|
+
const payload = {};
|
|
1228
|
+
if (options.parent !== void 0) {
|
|
1229
|
+
payload.parentId = options.parent === "null" ? null : options.parent;
|
|
1230
|
+
}
|
|
1231
|
+
if (options.sort !== void 0) {
|
|
1232
|
+
if (!Number.isInteger(options.sort)) {
|
|
1233
|
+
console.error("--sort must be an integer.");
|
|
1234
|
+
process.exit(1);
|
|
1235
|
+
}
|
|
1236
|
+
payload.sortOrder = options.sort;
|
|
1237
|
+
}
|
|
1238
|
+
if (Object.keys(payload).length === 0) {
|
|
1239
|
+
console.error("Provide --parent and/or --sort.");
|
|
1240
|
+
process.exit(1);
|
|
1241
|
+
}
|
|
1242
|
+
try {
|
|
1243
|
+
const result = await client.updateOutlineItem(itemId, payload);
|
|
1244
|
+
console.log(result.message);
|
|
1245
|
+
} catch (error) {
|
|
1246
|
+
console.error(
|
|
1247
|
+
"Failed to move item:",
|
|
1248
|
+
error instanceof Error ? error.message : error
|
|
1249
|
+
);
|
|
1250
|
+
process.exit(1);
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
);
|
|
1254
|
+
|
|
1255
|
+
// src/commands/bulk-update.ts
|
|
1256
|
+
import { Command as Command12 } from "commander";
|
|
1257
|
+
import { readFileSync as readFileSync5 } from "fs";
|
|
1258
|
+
var bulkUpdateCommand = new Command12("bulk-update").description(
|
|
1259
|
+
"Atomically apply multiple updates to outline items in a single topic. The file should be JSON: an array of {id, content?, parentId?, sortOrder?} entries or an object {updates: [...]}."
|
|
1260
|
+
).argument("<topicId>", "Topic ID containing the items").argument("<file>", "JSON file with the update entries").action(async (topicId, file) => {
|
|
1261
|
+
const config = requireConfig();
|
|
1262
|
+
const client = new ApiClient(config);
|
|
1263
|
+
let raw;
|
|
1264
|
+
try {
|
|
1265
|
+
raw = readFileSync5(file, "utf-8");
|
|
1266
|
+
} catch {
|
|
1267
|
+
console.error(`Cannot read file: ${file}`);
|
|
1268
|
+
process.exit(1);
|
|
1269
|
+
}
|
|
1270
|
+
let parsed;
|
|
1271
|
+
try {
|
|
1272
|
+
parsed = JSON.parse(raw);
|
|
1273
|
+
} catch (error) {
|
|
1274
|
+
console.error(
|
|
1275
|
+
"Invalid JSON in file:",
|
|
1276
|
+
error instanceof Error ? error.message : error
|
|
1277
|
+
);
|
|
1278
|
+
process.exit(1);
|
|
1279
|
+
}
|
|
1280
|
+
let updates;
|
|
1281
|
+
if (Array.isArray(parsed)) {
|
|
1282
|
+
updates = parsed;
|
|
1283
|
+
} else if (parsed && typeof parsed === "object" && Array.isArray(parsed.updates)) {
|
|
1284
|
+
updates = parsed.updates;
|
|
1285
|
+
} else {
|
|
1286
|
+
console.error(
|
|
1287
|
+
'File must contain a JSON array or an object with an "updates" array.'
|
|
1288
|
+
);
|
|
1289
|
+
process.exit(1);
|
|
1290
|
+
}
|
|
1291
|
+
if (updates.length === 0) {
|
|
1292
|
+
console.error("No updates to apply.");
|
|
1293
|
+
process.exit(1);
|
|
1294
|
+
}
|
|
1295
|
+
try {
|
|
1296
|
+
const result = await client.bulkUpdateOutlineItems(topicId, updates);
|
|
1297
|
+
console.log(result.message);
|
|
1298
|
+
} catch (error) {
|
|
1299
|
+
console.error(
|
|
1300
|
+
"Failed to apply updates:",
|
|
1301
|
+
error instanceof Error ? error.message : error
|
|
1302
|
+
);
|
|
1303
|
+
process.exit(1);
|
|
1304
|
+
}
|
|
1305
|
+
});
|
|
1306
|
+
|
|
1307
|
+
// src/commands/mcp.ts
|
|
1308
|
+
import { Command as Command13 } from "commander";
|
|
1309
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
1310
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
1311
|
+
import {
|
|
1312
|
+
CallToolRequestSchema,
|
|
1313
|
+
ListToolsRequestSchema
|
|
1314
|
+
} from "@modelcontextprotocol/sdk/types.js";
|
|
1315
|
+
|
|
1316
|
+
// src/lib/tool-definitions.generated.ts
|
|
1317
|
+
var MCP_TOOLS = [
|
|
1318
|
+
{
|
|
1319
|
+
name: "list_topics",
|
|
1320
|
+
description: "List all knowledge topics for the authenticated user. Returns id, title, description, tags, and updatedAt for each topic. Use this to discover existing topics before creating new ones or appending items.",
|
|
1321
|
+
inputSchema: {
|
|
1322
|
+
type: "object",
|
|
1323
|
+
properties: {},
|
|
1324
|
+
additionalProperties: false
|
|
1325
|
+
}
|
|
1326
|
+
},
|
|
1327
|
+
{
|
|
1328
|
+
name: "get_topic",
|
|
1329
|
+
description: "Retrieve a knowledge topic and its full outline as markdown. Use this to read the contents of a topic for human review or to give the user context. If you need stable item IDs (for example to target a deep parent in `append_to_topic`, or to call `update_item`/`delete_item`), call `get_outline` instead \u2014 it returns a structured JSON tree with UUIDs.",
|
|
1330
|
+
inputSchema: {
|
|
1331
|
+
type: "object",
|
|
1332
|
+
properties: {
|
|
1333
|
+
topicId: {
|
|
1334
|
+
type: "string",
|
|
1335
|
+
description: "UUID of the topic to fetch."
|
|
1336
|
+
}
|
|
1337
|
+
},
|
|
1338
|
+
required: ["topicId"],
|
|
1339
|
+
additionalProperties: false
|
|
1340
|
+
}
|
|
1341
|
+
},
|
|
1342
|
+
{
|
|
1343
|
+
name: "get_outline",
|
|
1344
|
+
description: "Retrieve a knowledge topic's outline as a structured JSON tree, including each item's stable UUID. Use this when you need to target a specific node \u2014 e.g. as `parentId` for `append_to_topic`, or as `itemId` for `update_item`/`delete_item`. The response is JSON: `{ topicId, title, items: [{ id, content, sortOrder, children: [...] }] }`.",
|
|
1345
|
+
inputSchema: {
|
|
1346
|
+
type: "object",
|
|
1347
|
+
properties: {
|
|
1348
|
+
topicId: {
|
|
1349
|
+
type: "string",
|
|
1350
|
+
description: "UUID of the topic to fetch."
|
|
1351
|
+
}
|
|
1352
|
+
},
|
|
1353
|
+
required: ["topicId"],
|
|
1354
|
+
additionalProperties: false
|
|
1355
|
+
}
|
|
1356
|
+
},
|
|
1357
|
+
{
|
|
1358
|
+
name: "create_topic",
|
|
1359
|
+
description: "Create a new knowledge topic. Pass plain metadata (title, description, etc.) to make an empty topic, or pass markdown content to create a topic populated with an outline. The markdown should use a single H1 heading for the title (optional if `title` is provided) and bullet lists (`-`, `*`, `+`) with two-space indentation for hierarchy.",
|
|
1360
|
+
inputSchema: {
|
|
1361
|
+
type: "object",
|
|
1362
|
+
properties: {
|
|
1363
|
+
title: {
|
|
1364
|
+
type: "string",
|
|
1365
|
+
description: "Title of the topic. Required when `markdown` is omitted; otherwise overrides the H1 in the markdown."
|
|
1366
|
+
},
|
|
1367
|
+
description: {
|
|
1368
|
+
type: "string",
|
|
1369
|
+
description: "Optional description of the topic."
|
|
1370
|
+
},
|
|
1371
|
+
icon: {
|
|
1372
|
+
type: "string",
|
|
1373
|
+
description: "Optional emoji icon (max 8 chars)."
|
|
1374
|
+
},
|
|
1375
|
+
color: {
|
|
1376
|
+
type: "string",
|
|
1377
|
+
description: "Optional theme color token (max 32 chars)."
|
|
1378
|
+
},
|
|
1379
|
+
tags: {
|
|
1380
|
+
type: "array",
|
|
1381
|
+
items: { type: "string" },
|
|
1382
|
+
description: "Optional list of tag strings."
|
|
1383
|
+
},
|
|
1384
|
+
markdown: {
|
|
1385
|
+
type: "string",
|
|
1386
|
+
description: "Optional markdown body. When provided, the topic is created with an outline parsed from the bullets."
|
|
1387
|
+
}
|
|
1388
|
+
},
|
|
1389
|
+
additionalProperties: false
|
|
1390
|
+
}
|
|
1391
|
+
},
|
|
1392
|
+
{
|
|
1393
|
+
name: "append_to_topic",
|
|
1394
|
+
description: "Append outline items to an existing knowledge topic. Items are passed as a bullet-list markdown string (use `-`, `*`, or `+` with two-space indentation for nesting). Optionally attach the new items beneath an existing outline item via `parentId` \u2014 call `get_outline` first if you need to discover the UUID of a deep parent.",
|
|
1395
|
+
inputSchema: {
|
|
1396
|
+
type: "object",
|
|
1397
|
+
properties: {
|
|
1398
|
+
topicId: {
|
|
1399
|
+
type: "string",
|
|
1400
|
+
description: "UUID of the topic to append to."
|
|
1401
|
+
},
|
|
1402
|
+
markdown: {
|
|
1403
|
+
type: "string",
|
|
1404
|
+
description: "Markdown bullet list. Each top-level bullet becomes a new outline item; nested bullets (indented by two spaces) become child items."
|
|
1405
|
+
},
|
|
1406
|
+
parentId: {
|
|
1407
|
+
type: "string",
|
|
1408
|
+
description: "Optional outline item UUID. When provided, the new items are nested under this item instead of at the topic root."
|
|
1409
|
+
}
|
|
1410
|
+
},
|
|
1411
|
+
required: ["topicId", "markdown"],
|
|
1412
|
+
additionalProperties: false
|
|
1413
|
+
}
|
|
1414
|
+
},
|
|
1415
|
+
{
|
|
1416
|
+
name: "update_item",
|
|
1417
|
+
description: "Update a single outline item in place. Pass any subset of `content`, `parentId`, `sortOrder` \u2014 only the provided fields change. To move an item, pass `parentId` and/or `sortOrder`. Discover the item's UUID via `get_outline`.",
|
|
1418
|
+
inputSchema: {
|
|
1419
|
+
type: "object",
|
|
1420
|
+
properties: {
|
|
1421
|
+
itemId: {
|
|
1422
|
+
type: "string",
|
|
1423
|
+
description: "UUID of the outline item to update."
|
|
1424
|
+
},
|
|
1425
|
+
content: {
|
|
1426
|
+
type: "string",
|
|
1427
|
+
description: "New text content for the item."
|
|
1428
|
+
},
|
|
1429
|
+
parentId: {
|
|
1430
|
+
type: ["string", "null"],
|
|
1431
|
+
description: "New parent item UUID, or `null` to move the item to the topic root. Must be in the same topic; cycles are rejected."
|
|
1432
|
+
},
|
|
1433
|
+
sortOrder: {
|
|
1434
|
+
type: "integer",
|
|
1435
|
+
description: "New sort order among siblings."
|
|
1436
|
+
}
|
|
1437
|
+
},
|
|
1438
|
+
required: ["itemId"],
|
|
1439
|
+
additionalProperties: false
|
|
1440
|
+
}
|
|
1441
|
+
},
|
|
1442
|
+
{
|
|
1443
|
+
name: "delete_item",
|
|
1444
|
+
description: "Delete a single outline item. Any direct children of the deleted item are reparented to its grandparent (so they aren't orphaned). Discover the item's UUID via `get_outline`.",
|
|
1445
|
+
inputSchema: {
|
|
1446
|
+
type: "object",
|
|
1447
|
+
properties: {
|
|
1448
|
+
itemId: {
|
|
1449
|
+
type: "string",
|
|
1450
|
+
description: "UUID of the outline item to delete."
|
|
1451
|
+
}
|
|
1452
|
+
},
|
|
1453
|
+
required: ["itemId"],
|
|
1454
|
+
additionalProperties: false
|
|
1455
|
+
}
|
|
1456
|
+
},
|
|
1457
|
+
{
|
|
1458
|
+
name: "bulk_update_items",
|
|
1459
|
+
description: "Atomically update many outline items at once. Each entry can change `content`, `parentId`, and/or `sortOrder`. All updates run in a single transaction \u2014 if any item is rejected (e.g. cycle, foreign topic), nothing is applied.",
|
|
1460
|
+
inputSchema: {
|
|
1461
|
+
type: "object",
|
|
1462
|
+
properties: {
|
|
1463
|
+
topicId: {
|
|
1464
|
+
type: "string",
|
|
1465
|
+
description: "UUID of the topic that contains every item being updated."
|
|
1466
|
+
},
|
|
1467
|
+
updates: {
|
|
1468
|
+
type: "array",
|
|
1469
|
+
minItems: 1,
|
|
1470
|
+
maxItems: 500,
|
|
1471
|
+
description: "1 to 500 update entries.",
|
|
1472
|
+
items: {
|
|
1473
|
+
type: "object",
|
|
1474
|
+
properties: {
|
|
1475
|
+
id: { type: "string", description: "UUID of the item to update." },
|
|
1476
|
+
content: { type: "string" },
|
|
1477
|
+
parentId: { type: ["string", "null"] },
|
|
1478
|
+
sortOrder: { type: "integer" }
|
|
1479
|
+
},
|
|
1480
|
+
required: ["id"],
|
|
1481
|
+
additionalProperties: false
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1484
|
+
},
|
|
1485
|
+
required: ["topicId", "updates"],
|
|
1486
|
+
additionalProperties: false
|
|
1487
|
+
}
|
|
1488
|
+
}
|
|
1489
|
+
];
|
|
1490
|
+
|
|
1491
|
+
// src/commands/mcp.ts
|
|
1492
|
+
var mcpCommand = new Command13("mcp").description(
|
|
1493
|
+
"Run a Model Context Protocol server (stdio) so Claude can read and write your knowledge base"
|
|
1494
|
+
).action(async () => {
|
|
1495
|
+
const config = requireConfig();
|
|
1496
|
+
const client = new ApiClient(config);
|
|
1497
|
+
const server = new Server(
|
|
1498
|
+
{ name: "nemonicon", version: "0.1.0" },
|
|
1499
|
+
{ capabilities: { tools: {} } }
|
|
1500
|
+
);
|
|
1501
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
1502
|
+
tools: MCP_TOOLS.map((t) => ({ ...t }))
|
|
1503
|
+
}));
|
|
1504
|
+
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
1505
|
+
const { name, arguments: args } = req.params;
|
|
1506
|
+
const input = args ?? {};
|
|
1507
|
+
try {
|
|
1508
|
+
switch (name) {
|
|
1509
|
+
case "list_topics": {
|
|
1510
|
+
const topics = await client.listTopics();
|
|
1511
|
+
return {
|
|
1512
|
+
content: [
|
|
1513
|
+
{
|
|
1514
|
+
type: "text",
|
|
1515
|
+
text: topics.length === 0 ? "No topics yet." : topics.map(
|
|
1516
|
+
(t) => `- ${t.title} (id: ${t.id})${t.description ? ` \u2014 ${t.description}` : ""}`
|
|
1517
|
+
).join("\n")
|
|
1518
|
+
}
|
|
1519
|
+
]
|
|
1520
|
+
};
|
|
1521
|
+
}
|
|
1522
|
+
case "get_topic": {
|
|
1523
|
+
const topicId = String(input.topicId ?? "");
|
|
1524
|
+
if (!topicId) throw new Error("topicId is required");
|
|
1525
|
+
const md = await client.exportTopic(topicId, "md");
|
|
1526
|
+
return {
|
|
1527
|
+
content: [{ type: "text", text: md || "(empty topic)" }]
|
|
1528
|
+
};
|
|
1529
|
+
}
|
|
1530
|
+
case "create_topic": {
|
|
1531
|
+
const title = typeof input.title === "string" ? input.title : void 0;
|
|
1532
|
+
const markdown = typeof input.markdown === "string" ? input.markdown : void 0;
|
|
1533
|
+
if (markdown && markdown.trim().length > 0) {
|
|
1534
|
+
const result = await client.importMarkdown(markdown, title);
|
|
1535
|
+
return {
|
|
1536
|
+
content: [
|
|
1537
|
+
{
|
|
1538
|
+
type: "text",
|
|
1539
|
+
text: `Created topic "${result.topic.title}" (id: ${result.topic.id}) with ${result.itemsCreated} item(s).`
|
|
1540
|
+
}
|
|
1541
|
+
]
|
|
1542
|
+
};
|
|
1543
|
+
}
|
|
1544
|
+
if (!title) {
|
|
1545
|
+
throw new Error("Either `title` or `markdown` must be provided.");
|
|
1546
|
+
}
|
|
1547
|
+
const created = await client.createTopic({
|
|
1548
|
+
title,
|
|
1549
|
+
description: typeof input.description === "string" ? input.description : void 0,
|
|
1550
|
+
icon: typeof input.icon === "string" ? input.icon : void 0,
|
|
1551
|
+
color: typeof input.color === "string" ? input.color : void 0,
|
|
1552
|
+
tags: Array.isArray(input.tags) ? input.tags.filter((t) => typeof t === "string") : void 0
|
|
1553
|
+
});
|
|
1554
|
+
return {
|
|
1555
|
+
content: [
|
|
1556
|
+
{
|
|
1557
|
+
type: "text",
|
|
1558
|
+
text: `Created topic "${created.title}" (id: ${created.id}).`
|
|
1559
|
+
}
|
|
1560
|
+
]
|
|
1561
|
+
};
|
|
1562
|
+
}
|
|
1563
|
+
case "append_to_topic": {
|
|
1564
|
+
const topicId = String(input.topicId ?? "");
|
|
1565
|
+
const markdown = String(input.markdown ?? "");
|
|
1566
|
+
if (!topicId) throw new Error("topicId is required");
|
|
1567
|
+
if (!markdown.trim()) throw new Error("markdown is required");
|
|
1568
|
+
const tree = markdownToTree(markdown);
|
|
1569
|
+
if (tree.length === 0) {
|
|
1570
|
+
throw new Error(
|
|
1571
|
+
"No bullet items found. Use `-`, `*`, or `+` for list items, with two-space indentation for nesting."
|
|
1572
|
+
);
|
|
1573
|
+
}
|
|
1574
|
+
const parentId = typeof input.parentId === "string" && input.parentId ? input.parentId : void 0;
|
|
1575
|
+
const result = await client.bulkCreateOutline(
|
|
1576
|
+
topicId,
|
|
1577
|
+
tree,
|
|
1578
|
+
parentId
|
|
1579
|
+
);
|
|
1580
|
+
return {
|
|
1581
|
+
content: [
|
|
1582
|
+
{
|
|
1583
|
+
type: "text",
|
|
1584
|
+
text: `Appended ${result.itemsCreated} item(s) to topic ${topicId}${parentId ? ` under item ${parentId}` : ""}.`
|
|
1585
|
+
}
|
|
1586
|
+
]
|
|
1587
|
+
};
|
|
1588
|
+
}
|
|
1589
|
+
// The new tools (get_outline, update_item, delete_item,
|
|
1590
|
+
// bulk_update_items) live only on the HTTP MCP server. The stdio
|
|
1591
|
+
// server proxies them via JSON-RPC so semantics stay identical
|
|
1592
|
+
// between transports.
|
|
1593
|
+
case "get_outline":
|
|
1594
|
+
case "update_item":
|
|
1595
|
+
case "delete_item":
|
|
1596
|
+
case "bulk_update_items": {
|
|
1597
|
+
const res = await client.callTool(name, input);
|
|
1598
|
+
return {
|
|
1599
|
+
isError: res.isError,
|
|
1600
|
+
content: [{ type: "text", text: res.text }]
|
|
1601
|
+
};
|
|
1602
|
+
}
|
|
1603
|
+
default:
|
|
1604
|
+
throw new Error(`Unknown tool: ${name}`);
|
|
1605
|
+
}
|
|
1606
|
+
} catch (error) {
|
|
1607
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1608
|
+
return {
|
|
1609
|
+
isError: true,
|
|
1610
|
+
content: [{ type: "text", text: message }]
|
|
1611
|
+
};
|
|
1612
|
+
}
|
|
1613
|
+
});
|
|
1614
|
+
const transport = new StdioServerTransport();
|
|
1615
|
+
await server.connect(transport);
|
|
1616
|
+
});
|
|
1617
|
+
|
|
1618
|
+
// src/index.ts
|
|
1619
|
+
var program = new Command14();
|
|
1620
|
+
program.name("nemonicon").description("CLI tool for Nemonicon knowledge management").version("0.1.0");
|
|
1621
|
+
program.addCommand(loginCommand);
|
|
1622
|
+
program.addCommand(logoutCommand);
|
|
1623
|
+
program.addCommand(authCommand);
|
|
1624
|
+
program.addCommand(listCommand);
|
|
1625
|
+
program.addCommand(showCommand);
|
|
1626
|
+
program.addCommand(outlineCommand);
|
|
1627
|
+
program.addCommand(createCommand);
|
|
1628
|
+
program.addCommand(appendCommand);
|
|
1629
|
+
program.addCommand(updateCommand);
|
|
1630
|
+
program.addCommand(deleteCommand);
|
|
1631
|
+
program.addCommand(moveCommand);
|
|
1632
|
+
program.addCommand(bulkUpdateCommand);
|
|
1633
|
+
program.addCommand(mcpCommand);
|
|
1634
|
+
program.parse();
|