@askthew/mcp-plugin 0.4.7 → 0.4.9

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/install.js CHANGED
@@ -1,12 +1,44 @@
1
1
  import fs from "node:fs";
2
+ import { createRequire } from "node:module";
2
3
  import os from "node:os";
3
4
  import path from "node:path";
5
+ import { askTheWDataDir, installReceiptsPath, readJsonFile, writePrivateJson } from "./lib/paths.js";
4
6
  import { resolvePluginScope } from "./scope.js";
5
- const ASKTHEW_INSTRUCTIONS_START = "<!-- ASKTHEW_PLUGIN_INSTRUCTIONS_START -->";
6
- const ASKTHEW_INSTRUCTIONS_END = "<!-- ASKTHEW_PLUGIN_INSTRUCTIONS_END -->";
7
+ export const DEFAULT_FREE_SERVER_NAME = "askthew-free";
8
+ export const DEFAULT_WORKSPACE_SERVER_NAME = "askthew-workspace";
9
+ const LEGACY_DEFAULT_SERVER_NAME = "askthew";
10
+ const ASKTHEW_INSTRUCTIONS_START = "<!-- @askthew/mcp-plugin v1 - managed block, do not hand-edit -->";
11
+ const ASKTHEW_INSTRUCTIONS_END = "<!-- /@askthew/mcp-plugin v1 -->";
12
+ const LEGACY_ASKTHEW_INSTRUCTIONS_START = "<!-- ASKTHEW_PLUGIN_INSTRUCTIONS_START -->";
13
+ const LEGACY_ASKTHEW_INSTRUCTIONS_END = "<!-- ASKTHEW_PLUGIN_INSTRUCTIONS_END -->";
14
+ const INSTALL_RECEIPTS_SCHEMA_VERSION = 1;
15
+ const requirePackageJson = createRequire(import.meta.url);
7
16
  function isRecord(value) {
8
17
  return typeof value === "object" && value !== null && !Array.isArray(value);
9
18
  }
19
+ export function packageVersion() {
20
+ try {
21
+ const manifest = requirePackageJson("../package.json");
22
+ return typeof manifest.version === "string" ? manifest.version : "unknown";
23
+ }
24
+ catch {
25
+ return "unknown";
26
+ }
27
+ }
28
+ export function defaultServerNameForTier(free) {
29
+ return free ? DEFAULT_FREE_SERVER_NAME : DEFAULT_WORKSPACE_SERVER_NAME;
30
+ }
31
+ function defaultServerNamesToRemove() {
32
+ return [DEFAULT_FREE_SERVER_NAME, DEFAULT_WORKSPACE_SERVER_NAME, LEGACY_DEFAULT_SERVER_NAME];
33
+ }
34
+ function packageSpecFromPin(env = process.env) {
35
+ const pin = env.ASKTHEW_PIN?.trim() || packageVersion();
36
+ if (!pin || pin === "unknown")
37
+ return "@askthew/mcp-plugin@latest";
38
+ if (pin.startsWith("@askthew/mcp-plugin@"))
39
+ return pin;
40
+ return `@askthew/mcp-plugin@${pin}`;
41
+ }
10
42
  export function resolveSettingsPath(input) {
11
43
  const homeDirectory = input.homeDirectory ?? os.homedir();
12
44
  if (input.hostType === "codex") {
@@ -19,21 +51,29 @@ export function resolveSettingsPath(input) {
19
51
  }
20
52
  export function createServerEntry(input) {
21
53
  const scope = resolvePluginScope(input.cwd ?? process.cwd());
54
+ const env = {
55
+ ASKTHEW_API_URL: input.apiUrl,
56
+ ...(input.free ? { ASKTHEW_FREE_MODE: "1" } : { ASKTHEW_INSTALL_TOKEN: input.token ?? "" }),
57
+ ...(input.clientId ? { ASKTHEW_CLIENT_ID: input.clientId } : {}),
58
+ ...(input.clientLabel ? { ASKTHEW_CLIENT_LABEL: input.clientLabel } : {}),
59
+ ASKTHEW_HOST_TYPE: input.hostType,
60
+ ASKTHEW_SERVER_NAME: input.serverName,
61
+ ASKTHEW_REPO_NAME: scope.repoName,
62
+ ...(scope.repoRoot ? { ASKTHEW_REPO_ROOT: scope.repoRoot } : {}),
63
+ ...(scope.appPath ? { ASKTHEW_APP_PATH: scope.appPath } : {}),
64
+ ...(scope.serviceName ? { ASKTHEW_SERVICE_NAME: scope.serviceName } : {}),
65
+ };
66
+ if (input.serverEntrypoint) {
67
+ return {
68
+ command: "node",
69
+ args: [path.resolve(input.serverEntrypoint)],
70
+ env,
71
+ };
72
+ }
22
73
  return {
23
74
  command: "npx",
24
- args: ["-y", "--prefer-online", "--package", "@askthew/mcp-plugin@latest", "askthew-mcp"],
25
- env: {
26
- ASKTHEW_API_URL: input.apiUrl,
27
- ...(input.free ? { ASKTHEW_FREE_MODE: "1" } : { ASKTHEW_INSTALL_TOKEN: input.token ?? "" }),
28
- ...(input.clientId ? { ASKTHEW_CLIENT_ID: input.clientId } : {}),
29
- ...(input.clientLabel ? { ASKTHEW_CLIENT_LABEL: input.clientLabel } : {}),
30
- ASKTHEW_HOST_TYPE: input.hostType,
31
- ASKTHEW_SERVER_NAME: input.serverName,
32
- ASKTHEW_REPO_NAME: scope.repoName,
33
- ...(scope.repoRoot ? { ASKTHEW_REPO_ROOT: scope.repoRoot } : {}),
34
- ...(scope.appPath ? { ASKTHEW_APP_PATH: scope.appPath } : {}),
35
- ...(scope.serviceName ? { ASKTHEW_SERVICE_NAME: scope.serviceName } : {}),
36
- },
75
+ args: ["-y", "--package", packageSpecFromPin(), "askthew-mcp"],
76
+ env,
37
77
  };
38
78
  }
39
79
  export function createHostConfigSnippet(input) {
@@ -81,14 +121,75 @@ function removeCodexTomlServer(content, serverName) {
81
121
  const sectionPattern = new RegExp(`\\n?\\[mcp_servers\\.(?:${escapedServerName}|${quotedServerName})\\]\\n[\\s\\S]*?(?=\\n\\[[^\\]]+\\]|$)`, "g");
82
122
  return content.replace(sectionPattern, "").trimEnd();
83
123
  }
124
+ function serverNamesForRemoval(serverName) {
125
+ const trimmed = serverName?.trim();
126
+ if (!trimmed)
127
+ return defaultServerNamesToRemove();
128
+ return trimmed === LEGACY_DEFAULT_SERVER_NAME
129
+ ? [LEGACY_DEFAULT_SERVER_NAME]
130
+ : [trimmed, LEGACY_DEFAULT_SERVER_NAME];
131
+ }
84
132
  function mergeCodexSettings(input) {
85
133
  let next = input.existingSettings.trimEnd();
86
- if (input.serverName !== "askthew") {
87
- next = removeCodexTomlServer(next, "askthew");
134
+ if (input.serverName !== LEGACY_DEFAULT_SERVER_NAME) {
135
+ next = removeCodexTomlServer(next, LEGACY_DEFAULT_SERVER_NAME);
88
136
  }
89
137
  next = removeCodexTomlServer(next, input.serverName);
90
138
  return `${next}${next ? "\n\n" : ""}${createCodexTomlSection(input)}\n`;
91
139
  }
140
+ function expandHome(inputPath, homeDirectory = os.homedir()) {
141
+ if (inputPath === "~")
142
+ return homeDirectory;
143
+ if (inputPath.startsWith("~/"))
144
+ return path.join(homeDirectory, inputPath.slice(2));
145
+ return inputPath;
146
+ }
147
+ function pathAliases(inputPath, homeDirectory = os.homedir()) {
148
+ const aliases = new Set();
149
+ const expanded = expandHome(inputPath, homeDirectory);
150
+ const resolved = path.resolve(expanded);
151
+ aliases.add(resolved);
152
+ if (resolved.startsWith("/private/tmp/"))
153
+ aliases.add(`/tmp/${resolved.slice("/private/tmp/".length)}`);
154
+ if (resolved.startsWith("/tmp/"))
155
+ aliases.add(`/private/tmp/${resolved.slice("/tmp/".length)}`);
156
+ try {
157
+ aliases.add(fs.realpathSync.native(resolved));
158
+ }
159
+ catch {
160
+ try {
161
+ aliases.add(fs.realpathSync(resolved));
162
+ }
163
+ catch {
164
+ // The project may have been deleted; lexical aliases are still useful.
165
+ }
166
+ }
167
+ return aliases;
168
+ }
169
+ function equivalentProjectPath(left, right, homeDirectory = os.homedir()) {
170
+ const leftAliases = pathAliases(left, homeDirectory);
171
+ const rightAliases = pathAliases(right, homeDirectory);
172
+ for (const alias of leftAliases) {
173
+ if (rightAliases.has(alias))
174
+ return true;
175
+ }
176
+ return false;
177
+ }
178
+ function claudeProjectKeys(input) {
179
+ const homeDirectory = input.homeDirectory ?? os.homedir();
180
+ const cwdAliases = new Set();
181
+ for (const cwd of input.cwds) {
182
+ for (const alias of pathAliases(cwd, homeDirectory)) {
183
+ cwdAliases.add(alias);
184
+ }
185
+ }
186
+ for (const projectKey of Object.keys(input.existingProjects)) {
187
+ if (input.cwds.some((cwd) => equivalentProjectPath(projectKey, cwd, homeDirectory))) {
188
+ cwdAliases.add(projectKey);
189
+ }
190
+ }
191
+ return Array.from(cwdAliases);
192
+ }
92
193
  function mergeClaudeCodeSettings(input) {
93
194
  const cwd = path.resolve(input.cwd ?? process.cwd());
94
195
  const existingSettings = isRecord(input.existingSettings) ? input.existingSettings : {};
@@ -96,8 +197,8 @@ function mergeClaudeCodeSettings(input) {
96
197
  const existingProject = isRecord(existingProjects[cwd]) ? existingProjects[cwd] : {};
97
198
  const existingMcpServers = isRecord(existingProject.mcpServers) ? existingProject.mcpServers : {};
98
199
  const nextMcpServers = { ...existingMcpServers };
99
- if (input.serverName !== "askthew" && "askthew" in nextMcpServers) {
100
- delete nextMcpServers.askthew;
200
+ if (input.serverName !== LEGACY_DEFAULT_SERVER_NAME && LEGACY_DEFAULT_SERVER_NAME in nextMcpServers) {
201
+ delete nextMcpServers[LEGACY_DEFAULT_SERVER_NAME];
101
202
  }
102
203
  return {
103
204
  ...existingSettings,
@@ -117,8 +218,8 @@ export function mergeHostSettings(input) {
117
218
  const existingSettings = isRecord(input.existingSettings) ? input.existingSettings : {};
118
219
  const existingMcpServers = isRecord(existingSettings.mcpServers) ? existingSettings.mcpServers : {};
119
220
  const nextMcpServers = { ...existingMcpServers };
120
- if (input.serverName !== "askthew" && "askthew" in nextMcpServers) {
121
- delete nextMcpServers.askthew;
221
+ if (input.serverName !== LEGACY_DEFAULT_SERVER_NAME && LEGACY_DEFAULT_SERVER_NAME in nextMcpServers) {
222
+ delete nextMcpServers[LEGACY_DEFAULT_SERVER_NAME];
122
223
  }
123
224
  return {
124
225
  ...existingSettings,
@@ -150,6 +251,9 @@ export function formatInstallCommand(input) {
150
251
  parts.push("--email", JSON.stringify(input.email));
151
252
  }
152
253
  }
254
+ if (input.serverEntrypoint) {
255
+ parts.push("--server-entrypoint", JSON.stringify(input.serverEntrypoint));
256
+ }
153
257
  return parts.join(" ");
154
258
  }
155
259
  export function verificationNextStep(hostType) {
@@ -188,6 +292,7 @@ export function installHostConfig(input) {
188
292
  clientLabel: input.clientLabel,
189
293
  free: input.free,
190
294
  cwd: input.cwd,
295
+ serverEntrypoint: input.serverEntrypoint,
191
296
  };
192
297
  const json = input.hostType === "codex"
193
298
  ? mergeCodexSettings({
@@ -211,12 +316,74 @@ export function installHostConfig(input) {
211
316
  nextStep: verificationNextStep(input.hostType),
212
317
  };
213
318
  }
319
+ function normalizeInstructionPaths(paths) {
320
+ return Array.from(new Set(paths.map((entry) => path.resolve(entry))));
321
+ }
322
+ function normalizeReceipt(receipt) {
323
+ return {
324
+ ...receipt,
325
+ settingsPath: path.resolve(receipt.settingsPath),
326
+ cwd: path.resolve(receipt.cwd),
327
+ instructionPaths: normalizeInstructionPaths(receipt.instructionPaths ?? []),
328
+ dataDir: path.resolve(receipt.dataDir || askTheWDataDir()),
329
+ serverEntrypoint: receipt.serverEntrypoint ? path.resolve(receipt.serverEntrypoint) : undefined,
330
+ };
331
+ }
332
+ function receiptKey(receipt) {
333
+ return `${receipt.hostType}\u0000${receipt.serverName}\u0000${path.resolve(receipt.cwd)}`;
334
+ }
335
+ export function readInstallReceipts(env = process.env) {
336
+ const parsed = readJsonFile(installReceiptsPath(env));
337
+ if (!parsed || parsed.schemaVersion !== INSTALL_RECEIPTS_SCHEMA_VERSION || !Array.isArray(parsed.installs)) {
338
+ return [];
339
+ }
340
+ return parsed.installs
341
+ .filter((receipt) => Boolean(receipt?.hostType && receipt.serverName && receipt.settingsPath && receipt.cwd))
342
+ .map(normalizeReceipt);
343
+ }
344
+ export function writeInstallReceipt(receipt, env = process.env) {
345
+ const nextReceipt = normalizeReceipt({
346
+ ...receipt,
347
+ dataDir: receipt.dataDir ?? askTheWDataDir(env),
348
+ installedAt: receipt.installedAt ?? new Date().toISOString(),
349
+ });
350
+ const receipts = readInstallReceipts(env).filter((entry) => receiptKey(entry) !== receiptKey(nextReceipt));
351
+ receipts.push(nextReceipt);
352
+ receipts.sort((left, right) => left.installedAt.localeCompare(right.installedAt));
353
+ writePrivateJson(installReceiptsPath(env), {
354
+ schemaVersion: INSTALL_RECEIPTS_SCHEMA_VERSION,
355
+ installs: receipts,
356
+ });
357
+ return nextReceipt;
358
+ }
359
+ export function findInstallReceipts(input, env = process.env) {
360
+ return readInstallReceipts(env).filter((receipt) => receipt.hostType === input.hostType &&
361
+ (!input.serverName || receipt.serverName === input.serverName));
362
+ }
363
+ export function removeInstallReceipts(input, env = process.env) {
364
+ const cwd = input.cwd ? path.resolve(input.cwd) : undefined;
365
+ const receipts = readInstallReceipts(env);
366
+ const next = receipts.filter((receipt) => {
367
+ if (receipt.hostType !== input.hostType)
368
+ return true;
369
+ if (input.serverName && receipt.serverName !== input.serverName)
370
+ return true;
371
+ if (cwd && receipt.cwd !== cwd)
372
+ return true;
373
+ return false;
374
+ });
375
+ writePrivateJson(installReceiptsPath(env), {
376
+ schemaVersion: INSTALL_RECEIPTS_SCHEMA_VERSION,
377
+ installs: next,
378
+ });
379
+ return receipts.length - next.length;
380
+ }
214
381
  export function uninstallHostConfig(input) {
215
382
  const settingsPath = resolveSettingsPath({
216
383
  hostType: input.hostType,
217
384
  homeDirectory: input.homeDirectory,
218
385
  });
219
- const serverName = input.serverName?.trim() || "askthew";
386
+ const serverNames = serverNamesForRemoval(input.serverName);
220
387
  let json = "";
221
388
  let foundConfigFile = false;
222
389
  let removedServer = false;
@@ -224,43 +391,55 @@ export function uninstallHostConfig(input) {
224
391
  foundConfigFile = true;
225
392
  const raw = fs.readFileSync(settingsPath, "utf8");
226
393
  if (input.hostType === "codex") {
227
- removedServer = raw !== removeCodexTomlServer(raw, serverName) || (serverName !== "askthew" && raw !== removeCodexTomlServer(raw, "askthew"));
228
- json = removeCodexTomlServer(raw, serverName);
229
- if (serverName !== "askthew") {
230
- json = removeCodexTomlServer(json, "askthew");
394
+ json = raw;
395
+ for (const serverName of serverNames) {
396
+ const before = json;
397
+ json = removeCodexTomlServer(json, serverName);
398
+ removedServer = removedServer || before !== json;
231
399
  }
232
400
  json = json ? `${json}\n` : "";
233
401
  }
234
402
  else {
235
403
  const parsed = raw.trim() ? JSON.parse(raw) : {};
236
404
  if (input.hostType === "claude_code") {
237
- const cwd = path.resolve(input.cwd ?? process.cwd());
405
+ const cwdInputs = input.cwds && input.cwds.length > 0 ? input.cwds : [input.cwd ?? process.cwd()];
238
406
  const existingProjects = isRecord(parsed.projects) ? parsed.projects : {};
239
- const existingProject = isRecord(existingProjects[cwd]) ? existingProjects[cwd] : {};
240
- const existingMcpServers = isRecord(existingProject.mcpServers) ? existingProject.mcpServers : {};
241
- const nextServers = { ...existingMcpServers };
242
- removedServer = serverName in nextServers || (serverName !== "askthew" && "askthew" in nextServers);
243
- delete nextServers[serverName];
244
- if (serverName !== "askthew")
245
- delete nextServers.askthew;
246
- json = JSON.stringify({
247
- ...parsed,
248
- projects: {
249
- ...existingProjects,
250
- [cwd]: {
407
+ const nextProjects = { ...existingProjects };
408
+ const projectKeys = claudeProjectKeys({
409
+ existingProjects,
410
+ cwds: cwdInputs,
411
+ homeDirectory: input.homeDirectory,
412
+ });
413
+ for (const cwd of projectKeys) {
414
+ const existingProject = isRecord(existingProjects[cwd]) ? existingProjects[cwd] : {};
415
+ const existingMcpServers = isRecord(existingProject.mcpServers) ? existingProject.mcpServers : {};
416
+ const nextServers = { ...existingMcpServers };
417
+ const beforeCount = Object.keys(nextServers).length;
418
+ for (const serverName of serverNames) {
419
+ delete nextServers[serverName];
420
+ }
421
+ const removedHere = Object.keys(nextServers).length !== beforeCount;
422
+ removedServer = removedServer || removedHere;
423
+ if (removedHere || isRecord(existingProjects[cwd])) {
424
+ nextProjects[cwd] = {
251
425
  ...existingProject,
252
426
  mcpServers: nextServers,
253
- },
254
- },
427
+ };
428
+ }
429
+ }
430
+ json = JSON.stringify({
431
+ ...parsed,
432
+ projects: nextProjects,
255
433
  }, null, 2);
256
434
  }
257
435
  else {
258
436
  const existingMcpServers = isRecord(parsed.mcpServers) ? parsed.mcpServers : {};
259
437
  const nextServers = { ...existingMcpServers };
260
- removedServer = serverName in nextServers || (serverName !== "askthew" && "askthew" in nextServers);
261
- delete nextServers[serverName];
262
- if (serverName !== "askthew")
263
- delete nextServers.askthew;
438
+ const beforeCount = Object.keys(nextServers).length;
439
+ for (const serverName of serverNames) {
440
+ delete nextServers[serverName];
441
+ }
442
+ removedServer = Object.keys(nextServers).length !== beforeCount;
264
443
  json = JSON.stringify({ ...parsed, mcpServers: nextServers }, null, 2);
265
444
  }
266
445
  json = `${json}\n`;
@@ -272,12 +451,147 @@ export function uninstallHostConfig(input) {
272
451
  return {
273
452
  settingsPath,
274
453
  json,
275
- removedServerName: serverName,
454
+ removedServerName: serverNames.join(", "),
455
+ removedServerNames: serverNames,
276
456
  foundConfigFile,
277
457
  removedServer,
278
458
  wroteFile: !input.dryRun && foundConfigFile,
279
459
  };
280
460
  }
461
+ function updateNpxPackageArgs(args, packageSpec) {
462
+ let changed = false;
463
+ const nextArgs = args.map((arg, index) => {
464
+ if (typeof arg !== "string")
465
+ return arg;
466
+ if (arg === "--package" && typeof args[index + 1] === "string")
467
+ return arg;
468
+ if (index > 0 && args[index - 1] === "--package" && arg.startsWith("@askthew/mcp-plugin@")) {
469
+ changed = changed || arg !== packageSpec;
470
+ return packageSpec;
471
+ }
472
+ if (arg.startsWith("@askthew/mcp-plugin@")) {
473
+ changed = changed || arg !== packageSpec;
474
+ return packageSpec;
475
+ }
476
+ return arg;
477
+ });
478
+ return { args: nextArgs, changed };
479
+ }
480
+ function updateServerEntryPackage(entry, packageSpec) {
481
+ if (!isRecord(entry) || !Array.isArray(entry.args))
482
+ return { entry, changed: false };
483
+ const updated = updateNpxPackageArgs(entry.args, packageSpec);
484
+ if (!updated.changed)
485
+ return { entry, changed: false };
486
+ return {
487
+ entry: {
488
+ ...entry,
489
+ args: updated.args,
490
+ },
491
+ changed: true,
492
+ };
493
+ }
494
+ function updateCodexPackageSpec(input) {
495
+ let changed = false;
496
+ let next = input.content;
497
+ for (const serverName of input.serverNames) {
498
+ const escapedServerName = serverName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
499
+ const quotedServerName = escapeTomlString(serverName).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
500
+ const sectionPattern = new RegExp(`(\\n?\\[mcp_servers\\.(?:${escapedServerName}|${quotedServerName})\\]\\n[\\s\\S]*?)(?=\\n\\[[^\\]]+\\]|$)`, "g");
501
+ next = next.replace(sectionPattern, (section) => {
502
+ const updated = section.replace(/@askthew\/mcp-plugin@[^"',\]\s]+/g, input.packageSpec);
503
+ changed = changed || updated !== section;
504
+ return updated;
505
+ });
506
+ }
507
+ return { json: next, changed };
508
+ }
509
+ export function upgradePinnedHostConfig(input) {
510
+ const settingsPath = resolveSettingsPath({
511
+ hostType: input.hostType,
512
+ homeDirectory: input.homeDirectory,
513
+ });
514
+ const packageSpec = input.packageSpec?.trim() || packageSpecFromPin();
515
+ const serverNames = serverNamesForRemoval(input.serverName);
516
+ let json = "";
517
+ let foundConfigFile = false;
518
+ let upgradedServer = false;
519
+ const upgradedServerNames = new Set();
520
+ if (fs.existsSync(settingsPath)) {
521
+ foundConfigFile = true;
522
+ const raw = fs.readFileSync(settingsPath, "utf8");
523
+ if (input.hostType === "codex") {
524
+ const updated = updateCodexPackageSpec({ content: raw, serverNames, packageSpec });
525
+ json = updated.json;
526
+ upgradedServer = updated.changed;
527
+ if (updated.changed) {
528
+ for (const serverName of serverNames)
529
+ upgradedServerNames.add(serverName);
530
+ }
531
+ }
532
+ else {
533
+ const parsed = raw.trim() ? JSON.parse(raw) : {};
534
+ if (input.hostType === "claude_code") {
535
+ const existingProjects = isRecord(parsed.projects) ? parsed.projects : {};
536
+ const cwdInputs = input.cwds && input.cwds.length > 0 ? input.cwds : [input.cwd ?? process.cwd()];
537
+ const projectKeys = claudeProjectKeys({
538
+ existingProjects,
539
+ cwds: cwdInputs,
540
+ homeDirectory: input.homeDirectory,
541
+ });
542
+ const nextProjects = { ...existingProjects };
543
+ for (const cwd of projectKeys) {
544
+ const existingProject = isRecord(existingProjects[cwd]) ? existingProjects[cwd] : {};
545
+ const existingMcpServers = isRecord(existingProject.mcpServers) ? existingProject.mcpServers : {};
546
+ const nextServers = { ...existingMcpServers };
547
+ let touchedProject = false;
548
+ for (const serverName of serverNames) {
549
+ const updated = updateServerEntryPackage(nextServers[serverName], packageSpec);
550
+ if (updated.changed) {
551
+ nextServers[serverName] = updated.entry;
552
+ upgradedServer = true;
553
+ touchedProject = true;
554
+ upgradedServerNames.add(serverName);
555
+ }
556
+ }
557
+ if (touchedProject) {
558
+ nextProjects[cwd] = {
559
+ ...existingProject,
560
+ mcpServers: nextServers,
561
+ };
562
+ }
563
+ }
564
+ json = JSON.stringify({ ...parsed, projects: nextProjects }, null, 2);
565
+ }
566
+ else {
567
+ const existingMcpServers = isRecord(parsed.mcpServers) ? parsed.mcpServers : {};
568
+ const nextServers = { ...existingMcpServers };
569
+ for (const serverName of serverNames) {
570
+ const updated = updateServerEntryPackage(nextServers[serverName], packageSpec);
571
+ if (updated.changed) {
572
+ nextServers[serverName] = updated.entry;
573
+ upgradedServer = true;
574
+ upgradedServerNames.add(serverName);
575
+ }
576
+ }
577
+ json = JSON.stringify({ ...parsed, mcpServers: nextServers }, null, 2);
578
+ }
579
+ json = `${json}\n`;
580
+ }
581
+ if (!input.dryRun && upgradedServer) {
582
+ fs.writeFileSync(settingsPath, json.endsWith("\n") ? json : `${json}\n`, "utf8");
583
+ }
584
+ }
585
+ return {
586
+ settingsPath,
587
+ json,
588
+ packageSpec,
589
+ foundConfigFile,
590
+ upgradedServer,
591
+ upgradedServerNames: Array.from(upgradedServerNames),
592
+ wroteFile: !input.dryRun && foundConfigFile && upgradedServer,
593
+ };
594
+ }
281
595
  export async function sendInstallHeartbeat(input) {
282
596
  const fetcher = input.fetchImpl ?? fetch;
283
597
  const scope = resolvePluginScope(input.cwd ?? process.cwd());
@@ -348,6 +662,16 @@ function behaviorInstructions(hostType, cwd = process.cwd()) {
348
662
  `- If the tool is unavailable, tell the user to restart or reload ${hostLabel}; do not use \`list_mcp_resources\` or \`list_mcp_resource_templates\` as pass/fail checks.`,
349
663
  "- If you realize later in the conversation that the startup call was missed, send it immediately with `metadata.recovered_missed_startup=true`.",
350
664
  "",
665
+ "Schema:",
666
+ "- `sessionId` (string, required): stable across one session. Derive as `<repo-name>-<YYYYMMDD>-<git-HEAD-short>`. Reuse for every call in the session.",
667
+ "- `sequence` (integer >= 0, required): start at 0, increment by 1 per call within the same `sessionId`.",
668
+ "- `kind` (enum, required): one of `setup_complete`, `session_checkpoint`, `direction_change`, `implementation_update`, `verification_result`, `final_summary`.",
669
+ "- `summary` (string <= 2000, required).",
670
+ "- `evidence`, `filesTouched`, `commandsRun`, `metadata`: optional.",
671
+ "",
672
+ "Example:",
673
+ '{ "sessionId": "thesisengine-20260508-a1b2c3d", "sequence": 0, "kind": "setup_complete", "summary": "..." }',
674
+ "",
351
675
  "Send an update:",
352
676
  "- after the user accepts or rejects product, architecture, or implementation direction",
353
677
  "- before using tools that write files, after meaningful implementation changes",
@@ -355,7 +679,7 @@ function behaviorInstructions(hostType, cwd = process.cwd()) {
355
679
  "- at the final summary",
356
680
  ...(stackGuidance.length > 0 ? ["", "Stack-specific nudges:", ...stackGuidance] : []),
357
681
  "",
358
- "Keep updates compact: short summary, minimal evidence excerpts, files touched, commands run, and useful metadata. Do not send full transcripts. Redact obvious secrets before sending.",
682
+ "Keep updates compact: short summary, minimal evidence excerpts, files touched, commands run, and useful metadata. Do not send full transcripts. Redact obvious secrets in evidence excerpts (commands, file paths, log lines). Server-side redaction (AWS, Stripe, GitHub, JWT, PEM, OpenAI/Anthropic, DSNs, emails, SSN) runs as a safety net, but agent-side redaction is still preferred.",
359
683
  "",
360
684
  ASKTHEW_INSTRUCTIONS_END,
361
685
  "",
@@ -372,10 +696,15 @@ function cursorBehaviorInstructions(cwd = process.cwd()) {
372
696
  ].join("\n");
373
697
  }
374
698
  function upsertMarkedBlock(existing, block) {
375
- const startIndex = existing.indexOf(ASKTHEW_INSTRUCTIONS_START);
376
- const endIndex = existing.indexOf(ASKTHEW_INSTRUCTIONS_END);
377
- if (startIndex !== -1 && endIndex !== -1 && endIndex > startIndex) {
378
- const afterEnd = endIndex + ASKTHEW_INSTRUCTIONS_END.length;
699
+ for (const [startMarker, endMarker] of [
700
+ [ASKTHEW_INSTRUCTIONS_START, ASKTHEW_INSTRUCTIONS_END],
701
+ [LEGACY_ASKTHEW_INSTRUCTIONS_START, LEGACY_ASKTHEW_INSTRUCTIONS_END],
702
+ ]) {
703
+ const startIndex = existing.indexOf(startMarker);
704
+ const endIndex = existing.indexOf(endMarker);
705
+ if (startIndex === -1 || endIndex === -1 || endIndex <= startIndex)
706
+ continue;
707
+ const afterEnd = endIndex + endMarker.length;
379
708
  return `${existing.slice(0, startIndex).trimEnd()}\n\n${block.trimEnd()}\n${existing.slice(afterEnd).trimStart()}`.trimEnd() + "\n";
380
709
  }
381
710
  return `${existing.trimEnd()}${existing.trim() ? "\n\n" : ""}${block.trimEnd()}\n`;
@@ -419,7 +748,14 @@ export function uninstallBehaviorInstructions(input) {
419
748
  if (!fs.existsSync(instructionsPath))
420
749
  continue;
421
750
  const existing = fs.readFileSync(instructionsPath, "utf8");
422
- const next = existing.replace(new RegExp(`\\n?${ASKTHEW_INSTRUCTIONS_START}[\\s\\S]*?${ASKTHEW_INSTRUCTIONS_END}\\n?`, "g"), "\n").trimEnd() + "\n";
751
+ let next = existing;
752
+ for (const [startMarker, endMarker] of [
753
+ [ASKTHEW_INSTRUCTIONS_START, ASKTHEW_INSTRUCTIONS_END],
754
+ [LEGACY_ASKTHEW_INSTRUCTIONS_START, LEGACY_ASKTHEW_INSTRUCTIONS_END],
755
+ ]) {
756
+ next = next.replace(new RegExp(`\\n?${startMarker.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[\\s\\S]*?${endMarker.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\n?`, "g"), "\n");
757
+ }
758
+ next = next.trimEnd() + "\n";
423
759
  if (!input.dryRun)
424
760
  fs.writeFileSync(instructionsPath, next, "utf8");
425
761
  touchedPaths.push(instructionsPath);
@@ -497,10 +497,21 @@ export class LocalStore {
497
497
  openDatabase() {
498
498
  const loaded = tryRequireBetterSqlite3();
499
499
  if (loaded) {
500
- fs.mkdirSync(path.dirname(this.storePath), { recursive: true, mode: 0o700 });
501
- this.db = new loaded(this.storePath);
502
- this.migrate();
503
- return;
500
+ try {
501
+ fs.mkdirSync(path.dirname(this.storePath), { recursive: true, mode: 0o700 });
502
+ this.db = new loaded(this.storePath);
503
+ this.migrate();
504
+ return;
505
+ }
506
+ catch {
507
+ try {
508
+ this.db?.close();
509
+ }
510
+ catch {
511
+ // Ignore close failures while falling back to the JSON store.
512
+ }
513
+ this.db = null;
514
+ }
504
515
  }
505
516
  this.jsonMode = true;
506
517
  this.jsonPath = jsonFallbackStorePath();
@@ -1,8 +1,10 @@
1
1
  export declare function askTheWDataDir(env?: NodeJS.ProcessEnv): string;
2
+ export declare function askTheWStateDir(env?: NodeJS.ProcessEnv): string;
2
3
  export declare function ensureAskTheWDataDir(env?: NodeJS.ProcessEnv): string;
3
4
  export declare function localStorePath(env?: NodeJS.ProcessEnv): string;
4
5
  export declare function identityPath(env?: NodeJS.ProcessEnv): string;
5
6
  export declare function configPath(env?: NodeJS.ProcessEnv): string;
6
7
  export declare function jsonFallbackStorePath(env?: NodeJS.ProcessEnv): string;
8
+ export declare function installReceiptsPath(env?: NodeJS.ProcessEnv): string;
7
9
  export declare function writePrivateJson(filePath: string, value: unknown): void;
8
10
  export declare function readJsonFile<T>(filePath: string): T | null;
package/dist/lib/paths.js CHANGED
@@ -12,6 +12,17 @@ export function askTheWDataDir(env = process.env) {
12
12
  }
13
13
  return path.join(os.homedir(), ".askthew");
14
14
  }
15
+ export function askTheWStateDir(env = process.env) {
16
+ const explicit = env.ASKTHEW_STATE_DIR?.trim();
17
+ if (explicit) {
18
+ return path.resolve(explicit);
19
+ }
20
+ const xdgStateHome = env.XDG_STATE_HOME?.trim();
21
+ if (xdgStateHome) {
22
+ return path.join(path.resolve(xdgStateHome), "askthew");
23
+ }
24
+ return path.join(os.homedir(), ".local", "state", "askthew");
25
+ }
15
26
  export function ensureAskTheWDataDir(env = process.env) {
16
27
  const dir = askTheWDataDir(env);
17
28
  fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
@@ -29,6 +40,9 @@ export function configPath(env = process.env) {
29
40
  export function jsonFallbackStorePath(env = process.env) {
30
41
  return path.join(askTheWDataDir(env), "store.json");
31
42
  }
43
+ export function installReceiptsPath(env = process.env) {
44
+ return path.join(askTheWStateDir(env), "install-receipts.json");
45
+ }
32
46
  export function writePrivateJson(filePath, value) {
33
47
  fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
34
48
  fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
@@ -22,7 +22,7 @@ export function paidFeatureNudge(tool) {
22
22
  pricingUrl: PRICING_URL,
23
23
  upgradeUrl: `https://askthew.com/plugin?utm_source=mcp-plugin&utm_medium=tool-nudge&utm_campaign=mcp-free&tool=${encodeURIComponent(tool)}`,
24
24
  supportEmail: SUPPORT_EMAIL,
25
- cta: "Run: npx -y --prefer-online --package @askthew/mcp-plugin@latest askthew-mcp upgrade",
25
+ cta: "Run: npx -y --prefer-online --package @askthew/mcp-plugin@latest askthew-mcp upgrade --browser",
26
26
  };
27
27
  }
28
28
  export function toolJson(value) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@askthew/mcp-plugin",
3
- "version": "0.4.7",
3
+ "version": "0.4.9",
4
4
  "private": false,
5
5
  "description": "Ask The W plugin connector for local-first coding-agent decisions, signals, and review.",
6
6
  "type": "module",