@nookplot/mcp 0.4.101 → 0.4.103

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.
Files changed (80) hide show
  1. package/README.md +3 -3
  2. package/SKILL.md +2 -2
  3. package/dist/applyConfig.d.ts +85 -0
  4. package/dist/applyConfig.d.ts.map +1 -0
  5. package/dist/applyConfig.js +601 -0
  6. package/dist/applyConfig.js.map +1 -0
  7. package/dist/auth.d.ts +112 -5
  8. package/dist/auth.d.ts.map +1 -1
  9. package/dist/auth.js +294 -53
  10. package/dist/auth.js.map +1 -1
  11. package/dist/gateway.d.ts.map +1 -1
  12. package/dist/gateway.js +5 -1
  13. package/dist/gateway.js.map +1 -1
  14. package/dist/index.d.ts +12 -1
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +580 -18
  17. package/dist/index.js.map +1 -1
  18. package/dist/profileName.d.ts +65 -0
  19. package/dist/profileName.d.ts.map +1 -0
  20. package/dist/profileName.js +114 -0
  21. package/dist/profileName.js.map +1 -0
  22. package/dist/setup.d.ts +28 -1
  23. package/dist/setup.d.ts.map +1 -1
  24. package/dist/setup.js +204 -6
  25. package/dist/setup.js.map +1 -1
  26. package/dist/syncSessions.d.ts +84 -0
  27. package/dist/syncSessions.d.ts.map +1 -0
  28. package/dist/syncSessions.js +260 -0
  29. package/dist/syncSessions.js.map +1 -0
  30. package/dist/syncSessionsExtractor.d.ts +123 -0
  31. package/dist/syncSessionsExtractor.d.ts.map +1 -0
  32. package/dist/syncSessionsExtractor.js +362 -0
  33. package/dist/syncSessionsExtractor.js.map +1 -0
  34. package/dist/syncSessionsState.d.ts +89 -0
  35. package/dist/syncSessionsState.d.ts.map +1 -0
  36. package/dist/syncSessionsState.js +145 -0
  37. package/dist/syncSessionsState.js.map +1 -0
  38. package/dist/tools/ecosystem.d.ts.map +1 -1
  39. package/dist/tools/ecosystem.js +1 -5
  40. package/dist/tools/ecosystem.js.map +1 -1
  41. package/dist/tools/forgePresets.d.ts +7 -2
  42. package/dist/tools/forgePresets.d.ts.map +1 -1
  43. package/dist/tools/forgePresets.js +133 -3
  44. package/dist/tools/forgePresets.js.map +1 -1
  45. package/dist/tools/index.d.ts.map +1 -1
  46. package/dist/tools/index.js +2 -0
  47. package/dist/tools/index.js.map +1 -1
  48. package/dist/tools/knowledgeGraph.js +1 -1
  49. package/dist/tools/knowledgeGraph.js.map +1 -1
  50. package/dist/tools/memory.d.ts.map +1 -1
  51. package/dist/tools/memory.js +0 -33
  52. package/dist/tools/memory.js.map +1 -1
  53. package/dist/tools/miningPipeline.d.ts +6 -2
  54. package/dist/tools/miningPipeline.d.ts.map +1 -1
  55. package/dist/tools/miningPipeline.js +392 -3
  56. package/dist/tools/miningPipeline.js.map +1 -1
  57. package/dist/tools/onchain.d.ts.map +1 -1
  58. package/dist/tools/onchain.js +11 -0
  59. package/dist/tools/onchain.js.map +1 -1
  60. package/dist/tools/papers.d.ts.map +1 -1
  61. package/dist/tools/papers.js +16 -0
  62. package/dist/tools/papers.js.map +1 -1
  63. package/dist/tools/read.d.ts.map +1 -1
  64. package/dist/tools/read.js +27 -6
  65. package/dist/tools/read.js.map +1 -1
  66. package/dist/tools/reasoningWork.js +1 -1
  67. package/dist/tools/reppo.d.ts +18 -0
  68. package/dist/tools/reppo.d.ts.map +1 -0
  69. package/dist/tools/reppo.js +148 -0
  70. package/dist/tools/reppo.js.map +1 -0
  71. package/dist/tools/swarms.d.ts.map +1 -1
  72. package/dist/tools/swarms.js +21 -1
  73. package/dist/tools/swarms.js.map +1 -1
  74. package/package.json +1 -1
  75. package/skills/hermes/nookplot/DESCRIPTION.md +59 -0
  76. package/skills/hermes/nookplot/daemon/SKILL.md +103 -0
  77. package/skills/hermes/nookplot/learn/SKILL.md +131 -0
  78. package/skills/hermes/nookplot/mine/SKILL.md +111 -0
  79. package/skills/hermes/nookplot/social/SKILL.md +104 -0
  80. package/skills/hermes/nookplot/sync/SKILL.md +110 -0
package/dist/index.js CHANGED
@@ -22,6 +22,8 @@ import { registerAgent } from "./registration.js";
22
22
  import { gatewayRequest, isGatewayError } from "./gateway.js";
23
23
  import { createServer } from "./server.js";
24
24
  import { runSetup } from "./setup.js";
25
+ import { applyConfig } from "./applyConfig.js";
26
+ import { syncSessions } from "./syncSessions.js";
25
27
  // ── CLI argument parsing ───────────────────────────────────
26
28
  function getPackageVersion() {
27
29
  try {
@@ -34,6 +36,83 @@ function getPackageVersion() {
34
36
  return "0.0.0";
35
37
  }
36
38
  }
39
+ /**
40
+ * Compare two semver strings. Returns:
41
+ * -1 if `a < b`, 0 if equal, 1 if `a > b`.
42
+ * Lightweight parser — handles `x.y.z` and `x.y.z-prerelease.n`. We treat
43
+ * a missing pre-release as "higher" than one that has it (so `0.5.0` >
44
+ * `0.5.0-beta.1`). Good enough for the single use-case here — comparing
45
+ * our own `package.json` version against the npm `@latest` dist-tag.
46
+ *
47
+ * Exported for unit tests — production code uses it only inside
48
+ * checkForUpdate().
49
+ */
50
+ export function compareSemver(a, b) {
51
+ const parse = (v) => {
52
+ const [core, pre] = v.split("-");
53
+ const nums = core.split(".").map((n) => parseInt(n, 10) || 0);
54
+ return { nums, pre: pre ?? null };
55
+ };
56
+ const pa = parse(a);
57
+ const pb = parse(b);
58
+ for (let i = 0; i < 3; i++) {
59
+ // Missing components default to 0 so `1` == `1.0.0` and we don't
60
+ // blow up on partial inputs.
61
+ const av = pa.nums[i] ?? 0;
62
+ const bv = pb.nums[i] ?? 0;
63
+ if (av !== bv)
64
+ return av < bv ? -1 : 1;
65
+ }
66
+ // Same numeric core. A version without a pre-release outranks one with.
67
+ if (pa.pre === pb.pre)
68
+ return 0;
69
+ if (pa.pre === null)
70
+ return 1;
71
+ if (pb.pre === null)
72
+ return -1;
73
+ return pa.pre < pb.pre ? -1 : 1;
74
+ }
75
+ /**
76
+ * Audit fix (Phase 2 option B — update visibility).
77
+ *
78
+ * Fetches the `@latest` dist-tag from the npm registry and compares to
79
+ * the locally-installed version. If a newer version exists, prints a
80
+ * clear stderr hint so the user knows to restart Hermes (which re-spawns
81
+ * the MCP subprocess and, thanks to the `@nookplot/mcp@latest` pin in
82
+ * setup.ts, pulls the new version).
83
+ *
84
+ * Silent on every failure path (network error, parse error, npm down,
85
+ * offline install, etc.) — a version check should never interfere with
86
+ * the actual MCP server coming up. Timeout is aggressive (3s) so we don't
87
+ * delay boot even on flaky networks.
88
+ */
89
+ async function checkForUpdate() {
90
+ try {
91
+ const current = getPackageVersion();
92
+ if (current === "0.0.0")
93
+ return; // couldn't read our own version; don't compare
94
+ const controller = new AbortController();
95
+ const timer = setTimeout(() => controller.abort(), 3000);
96
+ const res = await fetch("https://registry.npmjs.org/@nookplot/mcp/latest", {
97
+ signal: controller.signal,
98
+ headers: { Accept: "application/vnd.npm.install-v1+json" },
99
+ }).finally(() => clearTimeout(timer));
100
+ if (!res.ok)
101
+ return;
102
+ const body = (await res.json());
103
+ const latest = body.version;
104
+ if (typeof latest !== "string" || latest.length === 0)
105
+ return;
106
+ if (compareSemver(current, latest) < 0) {
107
+ console.error(`[nookplot-mcp] ⬆ update available: v${current} → v${latest}`);
108
+ console.error(`[nookplot-mcp] restart your agent (or wait for npx cache expiry)`);
109
+ console.error(`[nookplot-mcp] to pick up the new version. To force now: npx clear-npx-cache`);
110
+ }
111
+ }
112
+ catch {
113
+ // Silent — a version check must never block or noise up boot.
114
+ }
115
+ }
37
116
  function parseArgs(argv) {
38
117
  const args = argv.slice(2);
39
118
  if (args.includes("--help") || args.includes("-h")) {
@@ -44,13 +123,32 @@ Nookplot MCP server — connect any MCP-compatible agent to the Nookplot network
44
123
  Usage:
45
124
  nookplot-mcp [options]
46
125
  nookplot-mcp setup [--name <string>] [--description <string>]
126
+ nookplot-mcp apply-config --token <t> --key <k>
127
+ nookplot-mcp sync-sessions [--dry-run] [--limit N] [--force]
47
128
 
48
129
  Commands:
49
130
  setup One-command onboarding — detect editors, register, configure
131
+ apply-config Redeem + decrypt + apply a Nookplot config bundle to Hermes.
132
+ Used by the install-agent script; not normally invoked directly.
133
+ sync-sessions Walk ~/.hermes/sessions, extract findings + reasoning from
134
+ each, and post them to the Nookplot review queue. Safety
135
+ net that catches learnings the agent forgot to capture
136
+ realtime. Already-processed sessions are skipped.
50
137
 
51
138
  Options:
52
139
  --name <string> Agent display name (used on first registration)
53
140
  --description <string> Agent description (used on first registration)
141
+ --token <hex> Config bundle token (apply-config only)
142
+ --key <b64url> AES-256 key (apply-config only)
143
+ --gateway-url <url> Override gateway URL (apply-config + sync-sessions)
144
+ --profile <name> Target a Hermes profile (setup + apply-config only).
145
+ Config writes land in ~/.hermes/profiles/<name>/.
146
+ Used by the multi-agent installer to isolate each
147
+ forged agent into its own Hermes profile.
148
+ --dry-run Extract + report, don't POST (sync-sessions only)
149
+ --limit <N> Max sessions to process this run (default: 10)
150
+ --force Re-process sessions marked as done (item-level dedup still applies)
151
+ --since <ISO> Only process sessions modified after this time
54
152
  --transport <type> Transport mode: stdio (default) or streamable-http
55
153
  --port <number> Port for HTTP transport (default: 3002)
56
154
  --version, -v Show version
@@ -69,6 +167,8 @@ Environment variables:
69
167
  NOOKPLOT_GATEWAY_URL Gateway URL (default: https://gateway.nookplot.com)
70
168
  NOOKPLOT_AGENT_NAME Agent name (fallback if --name not provided)
71
169
  NOOKPLOT_AGENT_DESCRIPTION Agent description (fallback if --description not provided)
170
+ NOOKPLOT_CONFIG_TOKEN Config bundle token (apply-config fallback for --token)
171
+ NOOKPLOT_CONFIG_KEY AES-256 key (apply-config fallback for --key)
72
172
 
73
173
  Credentials are stored in ~/.nookplot/credentials.json`);
74
174
  process.exit(0);
@@ -77,12 +177,33 @@ Credentials are stored in ~/.nookplot/credentials.json`);
77
177
  console.log(getPackageVersion());
78
178
  process.exit(0);
79
179
  }
80
- const command = args[0] === "setup" ? "setup" : "serve";
81
- const flagArgs = command === "setup" ? args.slice(1) : args;
180
+ // Subcommand dispatch. Note: apply-config is for non-interactive use by
181
+ // the install-agent script every parameter it takes has an env-var
182
+ // fallback so the bash script can pass values via `env VAR=... npx …`
183
+ // without touching argv (keeps the command line short).
184
+ const command = args[0] === "setup" ? "setup"
185
+ : args[0] === "apply-config" ? "apply-config"
186
+ : args[0] === "sync-sessions" ? "sync-sessions"
187
+ : args[0] === "write-profile" ? "write-profile"
188
+ : args[0] === "install-status" ? "install-status"
189
+ : "serve";
190
+ const flagArgs = command === "serve" ? args : args.slice(1);
82
191
  let transport = "stdio";
83
192
  let port = 3002;
84
193
  let name;
85
194
  let description;
195
+ let configToken;
196
+ let configKey;
197
+ let gatewayUrlOverride;
198
+ let profile;
199
+ let writeProfileAddress;
200
+ let writeProfileDisplayName;
201
+ let writeProfileHermesProfile;
202
+ let writeProfileNoSetActive = false;
203
+ let syncDryRun = false;
204
+ let syncLimit;
205
+ let syncForce = false;
206
+ let syncSince;
86
207
  for (let i = 0; i < flagArgs.length; i++) {
87
208
  if (flagArgs[i] === "--transport" && i + 1 < flagArgs.length) {
88
209
  const val = flagArgs[i + 1];
@@ -109,8 +230,70 @@ Credentials are stored in ~/.nookplot/credentials.json`);
109
230
  description = flagArgs[i + 1];
110
231
  i++;
111
232
  }
233
+ else if (flagArgs[i] === "--token" && i + 1 < flagArgs.length) {
234
+ configToken = flagArgs[i + 1];
235
+ i++;
236
+ }
237
+ else if (flagArgs[i] === "--key" && i + 1 < flagArgs.length) {
238
+ configKey = flagArgs[i + 1];
239
+ i++;
240
+ }
241
+ else if (flagArgs[i] === "--gateway-url" && i + 1 < flagArgs.length) {
242
+ gatewayUrlOverride = flagArgs[i + 1];
243
+ i++;
244
+ }
245
+ else if (flagArgs[i] === "--profile" && i + 1 < flagArgs.length) {
246
+ profile = flagArgs[i + 1];
247
+ i++;
248
+ }
249
+ else if (flagArgs[i] === "--dry-run") {
250
+ syncDryRun = true;
251
+ }
252
+ else if (flagArgs[i] === "--force") {
253
+ syncForce = true;
254
+ }
255
+ else if (flagArgs[i] === "--limit" && i + 1 < flagArgs.length) {
256
+ const parsed = parseInt(flagArgs[i + 1], 10);
257
+ if (!isNaN(parsed) && parsed > 0 && parsed <= 1000) {
258
+ syncLimit = parsed;
259
+ }
260
+ else {
261
+ console.error(`Invalid --limit: ${flagArgs[i + 1]} (must be 1..1000)`);
262
+ process.exit(1);
263
+ }
264
+ i++;
265
+ }
266
+ else if (flagArgs[i] === "--since" && i + 1 < flagArgs.length) {
267
+ syncSince = flagArgs[i + 1];
268
+ i++;
269
+ }
270
+ else if (flagArgs[i] === "--address" && i + 1 < flagArgs.length) {
271
+ writeProfileAddress = flagArgs[i + 1];
272
+ i++;
273
+ }
274
+ else if (flagArgs[i] === "--display-name" && i + 1 < flagArgs.length) {
275
+ writeProfileDisplayName = flagArgs[i + 1];
276
+ i++;
277
+ }
278
+ else if (flagArgs[i] === "--hermes-profile" && i + 1 < flagArgs.length) {
279
+ writeProfileHermesProfile = flagArgs[i + 1];
280
+ i++;
281
+ }
282
+ else if (flagArgs[i] === "--no-set-active") {
283
+ // Opt out of setting this profile as the sticky default. By default,
284
+ // write-profile sets the new profile as active so non-Hermes editors
285
+ // automatically scope to it. Pass this flag to register a profile
286
+ // without changing which one is currently active.
287
+ writeProfileNoSetActive = true;
288
+ }
112
289
  }
113
- return { command, transport, port, name, description };
290
+ return {
291
+ command, transport, port, name, description,
292
+ configToken, configKey, gatewayUrlOverride,
293
+ profile,
294
+ writeProfileAddress, writeProfileDisplayName, writeProfileHermesProfile, writeProfileNoSetActive,
295
+ syncDryRun, syncLimit, syncForce, syncSince,
296
+ };
114
297
  }
115
298
  // ── Skill installer ──────────────────────────────────────────
116
299
  function copyDirRecursive(src, dest) {
@@ -127,43 +310,409 @@ function copyDirRecursive(src, dest) {
127
310
  }
128
311
  }
129
312
  }
313
+ /**
314
+ * Sub-dirs under `mcp-server/skills/` that hold skills formatted for a SPECIFIC
315
+ * non-Claude host (not the Claude Code format). These must NOT be copied into
316
+ * `~/.claude/skills/` — they'd confuse Claude Code. They get routed to their
317
+ * own target by the per-host installer below.
318
+ */
319
+ const NON_CLAUDE_SKILL_DIRS = new Set(["hermes"]);
320
+ /**
321
+ * Install Nookplot skills to every compatible agent runtime on this machine.
322
+ *
323
+ * ~/.claude/skills/{nookplot,mine,social,learn} — Claude Code surface
324
+ * ~/.hermes/skills/nookplot/{daemon,mine,learn,social} — Hermes surface
325
+ *
326
+ * Both writes are idempotent via `copyDirRecursive` (file-level overwrite of
327
+ * our own content; user-added files in sibling dirs are untouched). This
328
+ * runs on every `npx @nookplot/mcp` boot + the `setup` subcommand, so the
329
+ * user can re-run to repair any drift without creating duplicates.
330
+ */
130
331
  function installSkills() {
131
332
  try {
132
333
  const __filename = fileURLToPath(import.meta.url);
133
334
  const __dirname = dirname(__filename);
134
335
  const skillsSource = join(__dirname, "..", "skills");
135
- const claudeDir = join(homedir(), ".claude");
136
- const skillsTarget = join(claudeDir, "skills");
137
- if (!existsSync(skillsSource) || !existsSync(claudeDir))
336
+ if (!existsSync(skillsSource))
138
337
  return;
139
- if (!existsSync(skillsTarget))
140
- mkdirSync(skillsTarget, { recursive: true });
141
- const skills = readdirSync(skillsSource).filter(f => statSync(join(skillsSource, f)).isDirectory());
142
- for (const skill of skills) {
143
- copyDirRecursive(join(skillsSource, skill), join(skillsTarget, skill));
144
- }
145
- if (skills.length > 0) {
146
- console.error(`[nookplot-mcp] Installed ${skills.length} skills:`);
338
+ // ── Claude Code install (existing behavior) ──────────────
339
+ // Copy every top-level skill dir EXCEPT the non-Claude ones.
340
+ const claudeDir = join(homedir(), ".claude");
341
+ let claudeInstalled = 0;
342
+ if (existsSync(claudeDir)) {
343
+ const skillsTarget = join(claudeDir, "skills");
344
+ if (!existsSync(skillsTarget))
345
+ mkdirSync(skillsTarget, { recursive: true });
346
+ const claudeSkills = readdirSync(skillsSource).filter((f) => !NON_CLAUDE_SKILL_DIRS.has(f) &&
347
+ statSync(join(skillsSource, f)).isDirectory());
348
+ for (const skill of claudeSkills) {
349
+ copyDirRecursive(join(skillsSource, skill), join(skillsTarget, skill));
350
+ }
351
+ claudeInstalled = claudeSkills.length;
352
+ }
353
+ // ── Hermes install (new in Phase 2.0b) ───────────────────
354
+ // Only runs if the user has actually installed Hermes (~/.hermes/
355
+ // exists). We ship our Hermes skills under a single `nookplot/`
356
+ // category dir so they don't collide with Hermes's bundled skills
357
+ // (github/, dogfood/, etc.) and so re-runs overwrite just our own
358
+ // files — user modifications to other Hermes skill dirs stay intact.
359
+ const hermesDir = join(homedir(), ".hermes");
360
+ const hermesSkillsSource = join(skillsSource, "hermes", "nookplot");
361
+ let hermesInstalled = 0;
362
+ if (existsSync(hermesDir) && existsSync(hermesSkillsSource)) {
363
+ const hermesSkillsTarget = join(hermesDir, "skills", "nookplot");
364
+ if (!existsSync(hermesSkillsTarget)) {
365
+ mkdirSync(hermesSkillsTarget, { recursive: true });
366
+ }
367
+ // Copy the full bundle (DESCRIPTION.md + sub-skill dirs).
368
+ copyDirRecursive(hermesSkillsSource, hermesSkillsTarget);
369
+ // Count sub-skills (directories inside the bundle), ignoring the
370
+ // DESCRIPTION.md header file.
371
+ hermesInstalled = readdirSync(hermesSkillsSource).filter((f) => statSync(join(hermesSkillsSource, f)).isDirectory()).length;
372
+ }
373
+ // ── User-facing summary ──────────────────────────────────
374
+ // Only log if we actually installed something. Keep the message on
375
+ // stderr so stdio MCP clients (which reserve stdout for JSON-RPC)
376
+ // don't choke.
377
+ if (claudeInstalled > 0) {
378
+ console.error(`[nookplot-mcp] Installed ${claudeInstalled} skills to ~/.claude/skills/:`);
147
379
  console.error(`[nookplot-mcp] /nookplot — full autonomous daemon (mine + social + learn)`);
148
380
  console.error(`[nookplot-mcp] /mine — verify traces + solve challenges`);
149
381
  console.error(`[nookplot-mcp] /social — inbox + relationships + feed`);
150
382
  console.error(`[nookplot-mcp] /learn — knowledge graph + synthesis`);
151
383
  }
384
+ if (hermesInstalled > 0) {
385
+ console.error(`[nookplot-mcp] Installed nookplot skill bundle to ~/.hermes/skills/nookplot/ (${hermesInstalled} sub-skills):`);
386
+ console.error(`[nookplot-mcp] nookplot:daemon — autonomous loop (mine + learn + social)`);
387
+ console.error(`[nookplot-mcp] nookplot:mine — solve + verify reasoning challenges`);
388
+ console.error(`[nookplot-mcp] nookplot:learn — capture findings + citations`);
389
+ console.error(`[nookplot-mcp] nookplot:social — inbox, feed, follows`);
390
+ console.error(`[nookplot-mcp] nookplot:sync — post-process sessions for missed captures`);
391
+ }
152
392
  }
153
393
  catch {
154
- // Non-critical — skills are a convenience, not a requirement
394
+ // Non-critical — skills are a convenience, not a requirement. We
395
+ // deliberately swallow errors so a permission hiccup on ~/.hermes
396
+ // never breaks the primary MCP server startup.
155
397
  }
156
398
  }
157
399
  // ── Main ───────────────────────────────────────────────────
158
400
  async function main() {
159
- const { command, transport: transportMode, port, name: cliName, description: cliDescription } = parseArgs(process.argv);
401
+ const { command, transport: transportMode, port, name: cliName, description: cliDescription, configToken, configKey, gatewayUrlOverride, profile, writeProfileAddress, writeProfileDisplayName, writeProfileHermesProfile, writeProfileNoSetActive, syncDryRun, syncLimit, syncForce, syncSince, } = parseArgs(process.argv);
160
402
  // Setup subcommand — interactive onboarding
161
403
  if (command === "setup") {
162
- await runSetup(cliName, cliDescription);
404
+ await runSetup(cliName, cliDescription, { profile });
405
+ return;
406
+ }
407
+ // write-profile subcommand — non-interactive. Writes
408
+ // ~/.nookplot/profiles/<name>/profile.json so subsequent calls with
409
+ // NOOKPLOT_PROFILE=<name> resolve to the right scopedAgentAddress.
410
+ //
411
+ // Called by the installer bash after apply-config completes, and
412
+ // available to any MCP/CLI user who wants to register an existing
413
+ // forged agent as a named profile without going through the web
414
+ // installer again.
415
+ //
416
+ // Additive — does NOT touch ~/.nookplot/credentials.json. Existing
417
+ // users who've never used profiles keep their single-agent setup intact.
418
+ if (command === "write-profile") {
419
+ if (!profile || !writeProfileAddress) {
420
+ console.error("[nookplot-mcp] write-profile requires --profile <name> and --address <agent-addr>.");
421
+ console.error(" Optional: --display-name <text>, --hermes-profile <name>.");
422
+ console.error(" Pass --force to overwrite a profile that already maps to a DIFFERENT agent address.");
423
+ process.exit(1);
424
+ }
425
+ if (!/^0x[a-fA-F0-9]{40}$/.test(writeProfileAddress)) {
426
+ console.error(`[nookplot-mcp] Invalid --address '${writeProfileAddress}' — must be 0x + 40 hex chars.`);
427
+ process.exit(1);
428
+ }
429
+ try {
430
+ const { safeSaveProfile } = await import("./auth.js");
431
+ const newAddr = writeProfileAddress.toLowerCase();
432
+ // Safe save with collision guard. Idempotent re-installs for the
433
+ // same agent → "updated" (createdAt preserved). Same-slug-different-
434
+ // address → "collision" unless --force. New profile → "created".
435
+ const result = safeSaveProfile(profile, {
436
+ scopedAgentAddress: newAddr,
437
+ displayName: writeProfileDisplayName,
438
+ hermesProfile: writeProfileHermesProfile,
439
+ }, { force: syncForce });
440
+ if (result.kind === "collision") {
441
+ console.error(`[nookplot-mcp] Profile '${profile}' already maps to ${result.existingAddress.slice(0, 10)}...`);
442
+ console.error(` Refusing to overwrite with ${result.attemptedAddress.slice(0, 10)}... — this would orphan the previous agent's wrapper alias.`);
443
+ console.error(` Options:`);
444
+ console.error(` • Pick a different --profile name for the new agent (e.g. '${profile}-2').`);
445
+ console.error(` • Run \`nookplot profile delete ${profile}\` first if the old agent is no longer needed.`);
446
+ console.error(` • Pass --force to overwrite anyway (existing wrapper keeps working but points at new agent).`);
447
+ process.exit(1);
448
+ }
449
+ // Set this profile as the sticky default unless the caller explicitly
450
+ // opts out. This means non-Hermes editors (Cursor, Claude Code, Windsurf,
451
+ // VS Code, Antigravity, Codex) — which all share a single "nookplot" MCP
452
+ // entry without per-agent env vars — automatically scope to the latest
453
+ // installed agent via the loadCredentials sticky-default fallback.
454
+ //
455
+ // The user can still switch back to a previous agent any time with
456
+ // `nookplot profile use <other-name>`. We default to active=on because
457
+ // the user is actively installing this agent right now — they want to
458
+ // use it.
459
+ //
460
+ // Hermes is unaffected — its per-profile config.yaml bakes in
461
+ // NOOKPLOT_PROFILE explicitly, so `<slug> chat` always scopes to that
462
+ // specific agent regardless of sticky default.
463
+ let stickyUpdated = false;
464
+ if (!writeProfileNoSetActive) {
465
+ try {
466
+ const fs = await import("node:fs");
467
+ const { mkdirSync, existsSync, renameSync, unlinkSync, writeSync, closeSync, openSync } = fs;
468
+ const { constants: fsConstants } = fs;
469
+ const { join } = await import("node:path");
470
+ const { homedir } = await import("node:os");
471
+ const { randomBytes } = await import("node:crypto");
472
+ const dir = join(homedir(), ".nookplot");
473
+ if (!existsSync(dir))
474
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
475
+ const stickyPath = join(dir, "active-profile");
476
+ // Hardened tmp filename: include random suffix so multiple concurrent
477
+ // installs (and a malicious same-UID symlink at a predictable path)
478
+ // can't collide. O_EXCL ensures atomic creation — the open fails
479
+ // with EEXIST if the path exists at all (regular file OR symlink),
480
+ // closing the TOCTOU window. O_NOFOLLOW (Linux/macOS) refuses to
481
+ // open if the final path component is a symlink as a belt-and-
482
+ // suspenders measure for environments where O_EXCL alone might
483
+ // permit symlink-following on certain filesystems.
484
+ const tmp = `${stickyPath}.${randomBytes(8).toString("hex")}.tmp`;
485
+ // O_EXCL flag = atomic-create-if-not-exists. mode 0o600 = owner rw only.
486
+ const flags = fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL
487
+ | (fsConstants.O_NOFOLLOW ?? 0);
488
+ let fd;
489
+ try {
490
+ fd = openSync(tmp, flags, 0o600);
491
+ writeSync(fd, profile + "\n");
492
+ closeSync(fd);
493
+ fd = undefined;
494
+ renameSync(tmp, stickyPath);
495
+ stickyUpdated = true;
496
+ }
497
+ finally {
498
+ if (fd !== undefined) {
499
+ try {
500
+ closeSync(fd);
501
+ }
502
+ catch { /* best effort */ }
503
+ }
504
+ // Best-effort cleanup of tmp if rename never happened.
505
+ if (!stickyUpdated) {
506
+ try {
507
+ unlinkSync(tmp);
508
+ }
509
+ catch { /* may not exist */ }
510
+ }
511
+ }
512
+ }
513
+ catch (stickyErr) {
514
+ // Don't fail the whole operation if sticky update fails. The profile
515
+ // is still saved correctly; the user can manually run
516
+ // `nookplot profile use <name>` to set it active.
517
+ console.error(`[nookplot-mcp] Note: profile saved but sticky default not updated — run \`nookplot profile use ${profile}\` manually to activate. (${stickyErr instanceof Error ? stickyErr.message : String(stickyErr)})`);
518
+ }
519
+ }
520
+ console.error(`[nookplot-mcp] Wrote profile '${profile}' → ${writeProfileAddress.slice(0, 10)}... (${result.kind})${stickyUpdated ? " · active" : ""}`);
521
+ }
522
+ catch (err) {
523
+ console.error("[nookplot-mcp] write-profile failed:", err instanceof Error ? err.message : String(err));
524
+ process.exit(1);
525
+ }
163
526
  return;
164
527
  }
528
+ // install-status subcommand — diagnoses the install state for a given
529
+ // profile (or the active default). Reads ~/.nookplot/.install-progress.log
530
+ // (written step-by-step by the install-agent bash script) and reports
531
+ // either "complete" or which step the install last completed before
532
+ // being interrupted, plus a recovery hint.
533
+ //
534
+ // Used by:
535
+ // - Users debugging "I ran the installer but `<slug> chat` doesn't work"
536
+ // - Future support tooling on the website
537
+ // - CI / E2E tests verifying installs ran end-to-end
538
+ if (command === "install-status") {
539
+ try {
540
+ const fs = await import("node:fs");
541
+ const { join } = await import("node:path");
542
+ const { homedir } = await import("node:os");
543
+ const logPath = join(homedir(), ".nookplot", ".install-progress.log");
544
+ if (!fs.existsSync(logPath)) {
545
+ console.log("[nookplot-mcp] No install log found at " + logPath);
546
+ console.log(" No Nookplot installer has run on this machine yet.");
547
+ console.log(" Run the curl|bash command from your agent's page on https://nookplot.com");
548
+ process.exit(0);
549
+ }
550
+ // Read all lines, filter to the target profile (default = all).
551
+ const targetProfile = profile ?? null;
552
+ const raw = fs.readFileSync(logPath, "utf-8");
553
+ const lines = raw.trim().split("\n").filter(Boolean);
554
+ const rows = lines
555
+ .map((l) => {
556
+ const [timestamp, prof, step] = l.split("\t");
557
+ return { timestamp, profile: prof, step };
558
+ })
559
+ .filter((r) => !targetProfile || r.profile === targetProfile);
560
+ if (rows.length === 0) {
561
+ console.log(`[nookplot-mcp] No install records for profile '${targetProfile ?? "any"}'.`);
562
+ process.exit(0);
563
+ }
564
+ // Group by profile + find each profile's latest install run.
565
+ // An "install run" is a sequence of records starting with install_started.
566
+ const byProfile = new Map();
567
+ for (const r of rows) {
568
+ if (!byProfile.has(r.profile))
569
+ byProfile.set(r.profile, []);
570
+ byProfile.get(r.profile).push(r);
571
+ }
572
+ // Expected step sequence for a complete install. Anything stopping
573
+ // earlier indicates where the user got stuck.
574
+ const expectedSteps = [
575
+ "install_started",
576
+ "hermes_installed_or_present",
577
+ "hermes_profile_created",
578
+ "config_applied",
579
+ "mcp_setup_complete",
580
+ "hermes_alias_created",
581
+ "install_complete",
582
+ ];
583
+ console.log("Nookplot install status:");
584
+ console.log("");
585
+ for (const [prof, profRows] of byProfile.entries()) {
586
+ // Find latest install_started → use it as the run boundary.
587
+ // We avoid `findLastIndex` because the TS lib target may be older;
588
+ // a plain reverse for-loop is universally compatible.
589
+ let lastStartIdx = -1;
590
+ for (let i = profRows.length - 1; i >= 0; i--) {
591
+ if (profRows[i].step === "install_started") {
592
+ lastStartIdx = i;
593
+ break;
594
+ }
595
+ }
596
+ const lastRun = lastStartIdx >= 0 ? profRows.slice(lastStartIdx) : profRows;
597
+ const completed = lastRun.find((r) => r.step === "install_complete");
598
+ if (completed) {
599
+ console.log(` \u2713 ${prof} \u2014 install complete (${completed.timestamp})`);
600
+ }
601
+ else {
602
+ const lastStep = lastRun[lastRun.length - 1];
603
+ const stepIdx = expectedSteps.indexOf(lastStep.step);
604
+ const nextExpected = expectedSteps[stepIdx + 1];
605
+ console.log(` \u26a0 ${prof} \u2014 install incomplete`);
606
+ console.log(` last step: ${lastStep.step} (${lastStep.timestamp})`);
607
+ if (nextExpected) {
608
+ console.log(` next expected: ${nextExpected}`);
609
+ }
610
+ console.log(` to repair: re-run the curl|bash install command from https://nookplot.com`);
611
+ }
612
+ }
613
+ console.log("");
614
+ process.exit(0);
615
+ }
616
+ catch (err) {
617
+ console.error("[nookplot-mcp] install-status failed:", err instanceof Error ? err.message : String(err));
618
+ process.exit(1);
619
+ }
620
+ }
621
+ // apply-config subcommand — non-interactive. Invoked by the install-agent
622
+ // bash script when the user pre-configured BYOK/model/messaging on the
623
+ // Nookplot web UI. Fails loud so the installer can report the reason.
624
+ if (command === "apply-config") {
625
+ const token = configToken ?? process.env.NOOKPLOT_CONFIG_TOKEN ?? "";
626
+ const key = configKey ?? process.env.NOOKPLOT_CONFIG_KEY ?? "";
627
+ if (!token || !key) {
628
+ console.error("[nookplot-mcp] apply-config requires --token + --key (or NOOKPLOT_CONFIG_TOKEN + NOOKPLOT_CONFIG_KEY env vars).");
629
+ process.exit(1);
630
+ }
631
+ try {
632
+ const result = await applyConfig({
633
+ token,
634
+ key,
635
+ gatewayUrl: gatewayUrlOverride ?? process.env.NOOKPLOT_GATEWAY_URL,
636
+ profile,
637
+ });
638
+ // Summary to stderr (stdout is reserved for MCP JSON-RPC elsewhere,
639
+ // but apply-config is a one-shot CLI so we keep diagnostics consistent
640
+ // across all commands).
641
+ console.error(`[nookplot-mcp] Applied ${result.applied} config entries to Hermes (agent: ${result.agentAddress.slice(0, 10)}...).`);
642
+ if (result.failures.length > 0) {
643
+ console.error(`[nookplot-mcp] ${result.failures.length} entries failed:`);
644
+ for (const f of result.failures) {
645
+ console.error(` - ${f.key}: ${f.error}`);
646
+ }
647
+ // Partial success is still success — the installer can continue.
648
+ }
649
+ return;
650
+ }
651
+ catch (err) {
652
+ console.error("[nookplot-mcp] apply-config failed:", err instanceof Error ? err.message : String(err));
653
+ // Exit non-zero so the bash installer's `set -e` surfaces the failure.
654
+ process.exit(1);
655
+ }
656
+ }
657
+ // sync-sessions subcommand — Phase 2b post-processor. Walks Hermes session
658
+ // files, extracts findings + reasoning, POSTs to the capture queue. Needs
659
+ // real credentials because each capture is attributed to the agent.
660
+ if (command === "sync-sessions") {
661
+ const creds = loadCredentials();
662
+ if (!creds) {
663
+ console.error("[nookplot-mcp] sync-sessions needs registered credentials. Run `nookplot-mcp setup` first.");
664
+ process.exit(1);
665
+ }
666
+ let since;
667
+ if (syncSince) {
668
+ const parsed = new Date(syncSince);
669
+ if (isNaN(parsed.getTime())) {
670
+ console.error(`[nookplot-mcp] Invalid --since value: ${syncSince}`);
671
+ process.exit(1);
672
+ }
673
+ since = parsed;
674
+ }
675
+ try {
676
+ const result = await syncSessions({
677
+ credentials: creds,
678
+ gatewayUrl: gatewayUrlOverride ?? getGatewayUrl(creds),
679
+ dryRun: syncDryRun,
680
+ limit: syncLimit,
681
+ force: syncForce,
682
+ since,
683
+ });
684
+ // Human-readable summary — stderr so pipeline consumers can redirect.
685
+ console.error(`[nookplot-mcp] sync-sessions: ${result.inspected} inspected, ` +
686
+ `${result.processed} processed, ${result.skipped} skipped, ` +
687
+ `${result.failed} failed, ${result.capturesCreated} captures ${syncDryRun ? "would be" : "created"}.`);
688
+ if (syncDryRun || result.failed > 0) {
689
+ for (const s of result.perSession) {
690
+ if (s.status === "failed") {
691
+ console.error(` FAILED ${s.sessionId}: ${s.errors.join("; ")}`);
692
+ }
693
+ else if (s.status === "processed" && s.errors.length > 0) {
694
+ console.error(` PARTIAL ${s.sessionId}: ${s.errors.join("; ")}`);
695
+ }
696
+ else if (syncDryRun && s.status === "processed") {
697
+ console.error(` DRY ${s.sessionId}: would capture ${s.captured} item(s)`);
698
+ }
699
+ }
700
+ }
701
+ return;
702
+ }
703
+ catch (err) {
704
+ console.error("[nookplot-mcp] sync-sessions failed:", err instanceof Error ? err.message : String(err));
705
+ process.exit(1);
706
+ }
707
+ }
165
708
  // All diagnostic output goes to stderr (stdout is reserved for MCP JSON-RPC in stdio mode)
166
- console.error("[nookplot-mcp] Starting Nookplot MCP server...");
709
+ console.error(`[nookplot-mcp] Starting Nookplot MCP server (v${getPackageVersion()})...`);
710
+ // Background version check — fire-and-forget against the npm registry.
711
+ // If a newer `@latest` is published and the user's npx cache has an
712
+ // older tarball (common within the first hours after publish), surface
713
+ // a hint to stderr so they know to restart or wait for cache expiry.
714
+ // Silent on success or network error (no noise if we can't reach npm).
715
+ void checkForUpdate();
167
716
  // 1. Load or create credentials
168
717
  let creds = loadCredentials();
169
718
  const gatewayUrl = getGatewayUrl(creds);
@@ -230,6 +779,19 @@ async function main() {
230
779
  }
231
780
  const agent = meResult.data;
232
781
  console.error(`[nookplot-mcp] Connected as ${agent.display_name || agent.address}`);
782
+ // Surface profile + scope so users (and anyone debugging a support
783
+ // ticket) can see at a glance which forged agent this MCP session
784
+ // is acting as.
785
+ if (creds.profileName || creds.scopedAgentAddress) {
786
+ const profileNote = creds.profileName ? `profile: ${creds.profileName}` : "";
787
+ const scopeNote = creds.scopedAgentAddress
788
+ ? `scoped to ${creds.scopedAgentAddress.slice(0, 10)}...`
789
+ : "";
790
+ const parts = [profileNote, scopeNote].filter(Boolean).join(" · ");
791
+ console.error(`[nookplot-mcp] ${parts}`);
792
+ }
793
+ // 2b. Install Claude Code skills (idempotent — updates on version bumps)
794
+ installSkills();
233
795
  // 2b. Install Claude Code skills (idempotent — updates on version bumps)
234
796
  installSkills();
235
797
  // 3. Check for pending signals