@askthew/mcp-plugin 0.4.10 → 0.4.12

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
@@ -2,17 +2,12 @@ import fs from "node:fs";
2
2
  import { createRequire } from "node:module";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
- import { askTheWDataDir, installReceiptsPath, readJsonFile, writePrivateJson } from "./lib/paths.js";
5
+ import { askTheWDataDir, installMetadataPath, writePrivateJson } from "./lib/paths.js";
6
6
  import { resolvePluginScope } from "./scope.js";
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
7
  const requirePackageJson = createRequire(import.meta.url);
8
+ const INSTRUCTIONS_START = "<!-- ASKTHEW BEGIN -->";
9
+ const INSTRUCTIONS_END = "<!-- ASKTHEW END -->";
10
+ export const DEFAULT_SERVER_NAME = "askthew";
16
11
  function isRecord(value) {
17
12
  return typeof value === "object" && value !== null && !Array.isArray(value);
18
13
  }
@@ -25,12 +20,6 @@ export function packageVersion() {
25
20
  return "unknown";
26
21
  }
27
22
  }
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
23
  function packageSpecFromPin(env = process.env) {
35
24
  const pin = env.ASKTHEW_PIN?.trim() || packageVersion();
36
25
  if (!pin || pin === "unknown")
@@ -41,59 +30,35 @@ function packageSpecFromPin(env = process.env) {
41
30
  }
42
31
  export function resolveSettingsPath(input) {
43
32
  const homeDirectory = input.homeDirectory ?? os.homedir();
44
- if (input.hostType === "codex") {
33
+ if (input.hostType === "codex")
45
34
  return path.join(homeDirectory, ".codex", "config.toml");
46
- }
47
- if (input.hostType === "cursor") {
35
+ if (input.hostType === "cursor")
48
36
  return path.join(homeDirectory, ".cursor", "mcp.json");
49
- }
50
37
  return path.join(homeDirectory, ".claude.json");
51
38
  }
52
39
  export function createServerEntry(input) {
53
40
  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
41
  if (input.serverEntrypoint) {
67
42
  return {
68
43
  command: "node",
69
44
  args: [path.resolve(input.serverEntrypoint)],
70
- env,
45
+ env: serverEnvironment(input, scope),
71
46
  };
72
47
  }
73
48
  return {
74
49
  command: "npx",
75
50
  args: ["-y", "--package", packageSpecFromPin(), "askthew-mcp"],
76
- env,
51
+ env: serverEnvironment(input, scope),
77
52
  };
78
53
  }
79
- export function createHostConfigSnippet(input) {
80
- if (input.hostType === "codex") {
81
- const toml = createCodexTomlSection(input);
82
- return {
83
- settingsPath: resolveSettingsPath({ hostType: input.hostType }),
84
- snippet: toml,
85
- json: toml,
86
- };
87
- }
88
- const snippet = {
89
- mcpServers: {
90
- [input.serverName]: createServerEntry(input),
91
- },
92
- };
54
+ function serverEnvironment(input, scope) {
93
55
  return {
94
- settingsPath: resolveSettingsPath({ hostType: input.hostType }),
95
- snippet,
96
- json: JSON.stringify(snippet, null, 2),
56
+ ASKTHEW_API_URL: (input.apiUrl ?? "https://app.askthew.com").replace(/\/+$/, ""),
57
+ ASKTHEW_HOST_TYPE: input.hostType,
58
+ ASKTHEW_REPO_NAME: scope.repoName,
59
+ ...(scope.repoRoot ? { ASKTHEW_REPO_ROOT: scope.repoRoot } : {}),
60
+ ...(scope.appPath ? { ASKTHEW_APP_PATH: scope.appPath } : {}),
61
+ ...(scope.serviceName ? { ASKTHEW_SERVICE_NAME: scope.serviceName } : {}),
97
62
  };
98
63
  }
99
64
  function escapeTomlString(value) {
@@ -102,104 +67,37 @@ function escapeTomlString(value) {
102
67
  function tomlKey(value) {
103
68
  return /^[A-Za-z0-9_-]+$/.test(value) ? value : escapeTomlString(value);
104
69
  }
105
- function createCodexTomlSection(input) {
70
+ function codexTomlSection(input) {
106
71
  const entry = createServerEntry(input);
107
72
  const args = entry.args.map(escapeTomlString).join(", ");
108
73
  const env = Object.entries(entry.env)
109
74
  .map(([key, value]) => `${key} = ${escapeTomlString(String(value))}`)
110
75
  .join(", ");
111
76
  return [
112
- `[mcp_servers.${tomlKey(input.serverName)}]`,
77
+ `[mcp_servers.${tomlKey(input.serverName ?? DEFAULT_SERVER_NAME)}]`,
113
78
  `command = ${escapeTomlString(entry.command)}`,
114
79
  `args = [${args}]`,
115
80
  `env = { ${env} }`,
116
81
  ].join("\n");
117
82
  }
118
83
  function removeCodexTomlServer(content, serverName) {
119
- const escapedServerName = serverName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
120
- const quotedServerName = escapeTomlString(serverName).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
121
- const sectionPattern = new RegExp(`\\n?\\[mcp_servers\\.(?:${escapedServerName}|${quotedServerName})\\]\\n[\\s\\S]*?(?=\\n\\[[^\\]]+\\]|$)`, "g");
122
- return content.replace(sectionPattern, "").trimEnd();
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];
84
+ const escaped = serverName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
85
+ const quoted = escapeTomlString(serverName).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
86
+ const pattern = new RegExp(`\\n?\\[mcp_servers\\.(?:${escaped}|${quoted})\\]\\n[\\s\\S]*?(?=\\n\\[[^\\]]+\\]|$)`, "g");
87
+ return content.replace(pattern, "").trimEnd();
131
88
  }
132
89
  function mergeCodexSettings(input) {
133
- let next = input.existingSettings.trimEnd();
134
- if (input.serverName !== LEGACY_DEFAULT_SERVER_NAME) {
135
- next = removeCodexTomlServer(next, LEGACY_DEFAULT_SERVER_NAME);
136
- }
137
- next = removeCodexTomlServer(next, input.serverName);
138
- return `${next}${next ? "\n\n" : ""}${createCodexTomlSection(input)}\n`;
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);
90
+ const serverName = input.serverName ?? DEFAULT_SERVER_NAME;
91
+ const existing = removeCodexTomlServer(input.existingSettings.trimEnd(), serverName);
92
+ return `${existing}${existing ? "\n\n" : ""}${codexTomlSection(input)}\n`;
192
93
  }
193
94
  function mergeClaudeCodeSettings(input) {
194
95
  const cwd = path.resolve(input.cwd ?? process.cwd());
96
+ const serverName = input.serverName ?? DEFAULT_SERVER_NAME;
195
97
  const existingSettings = isRecord(input.existingSettings) ? input.existingSettings : {};
196
98
  const existingProjects = isRecord(existingSettings.projects) ? existingSettings.projects : {};
197
99
  const existingProject = isRecord(existingProjects[cwd]) ? existingProjects[cwd] : {};
198
100
  const existingMcpServers = isRecord(existingProject.mcpServers) ? existingProject.mcpServers : {};
199
- const nextMcpServers = { ...existingMcpServers };
200
- if (input.serverName !== LEGACY_DEFAULT_SERVER_NAME && LEGACY_DEFAULT_SERVER_NAME in nextMcpServers) {
201
- delete nextMcpServers[LEGACY_DEFAULT_SERVER_NAME];
202
- }
203
101
  return {
204
102
  ...existingSettings,
205
103
  projects: {
@@ -207,58 +105,46 @@ function mergeClaudeCodeSettings(input) {
207
105
  [cwd]: {
208
106
  ...existingProject,
209
107
  mcpServers: {
210
- ...nextMcpServers,
211
- [input.serverName]: createServerEntry(input),
108
+ ...existingMcpServers,
109
+ [serverName]: createServerEntry(input),
212
110
  },
213
111
  },
214
112
  },
215
113
  };
216
114
  }
217
- export function mergeHostSettings(input) {
115
+ function mergeCursorSettings(input) {
116
+ const serverName = input.serverName ?? DEFAULT_SERVER_NAME;
218
117
  const existingSettings = isRecord(input.existingSettings) ? input.existingSettings : {};
219
118
  const existingMcpServers = isRecord(existingSettings.mcpServers) ? existingSettings.mcpServers : {};
220
- const nextMcpServers = { ...existingMcpServers };
221
- if (input.serverName !== LEGACY_DEFAULT_SERVER_NAME && LEGACY_DEFAULT_SERVER_NAME in nextMcpServers) {
222
- delete nextMcpServers[LEGACY_DEFAULT_SERVER_NAME];
223
- }
224
119
  return {
225
120
  ...existingSettings,
226
121
  mcpServers: {
227
- ...nextMcpServers,
228
- [input.serverName]: createServerEntry(input),
122
+ ...existingMcpServers,
123
+ [serverName]: createServerEntry(input),
229
124
  },
230
125
  };
231
126
  }
232
- export function formatInstallCommand(input) {
233
- const parts = [
234
- "npx",
235
- "-y",
236
- "--prefer-online",
237
- "--package",
238
- "@askthew/mcp-plugin@latest",
239
- "askthew-mcp",
240
- "install",
241
- "--host",
242
- input.hostType,
243
- ...(input.free ? ["--free"] : ["--token", JSON.stringify(input.token ?? "")]),
244
- "--api-url",
245
- JSON.stringify(input.apiUrl),
246
- "--server-name",
247
- JSON.stringify(input.serverName),
248
- ];
249
- if (input.free) {
250
- if (input.email) {
251
- parts.push("--email", JSON.stringify(input.email));
252
- }
253
- }
254
- if (input.serverEntrypoint) {
255
- parts.push("--server-entrypoint", JSON.stringify(input.serverEntrypoint));
127
+ export function createHostConfigSnippet(input) {
128
+ if (input.hostType === "codex") {
129
+ const snippet = codexTomlSection(input);
130
+ return {
131
+ settingsPath: resolveSettingsPath({ hostType: input.hostType }),
132
+ snippet,
133
+ json: snippet,
134
+ language: "toml",
135
+ };
256
136
  }
257
- return parts.join(" ");
258
- }
259
- export function verificationNextStep(hostType) {
260
- const hostLabel = hostType === "claude_code" ? "Claude Code" : hostType === "cursor" ? "Cursor" : "Codex";
261
- return `Refresh Ask The W to confirm the plugin shows Installed. Restart or reload ${hostLabel} if it is already open. At the start of every new ${hostLabel} session in this repo, the installed project instructions tell the coding agent to send a setup_complete update before plan mode or exploration. Choose Always allow if ${hostLabel} asks for Ask The W tool permission. list_mcp_resources/list_mcp_resource_templates may be empty for this tool-driven connector and are not failure signals.`;
137
+ const snippet = {
138
+ mcpServers: {
139
+ [input.serverName ?? DEFAULT_SERVER_NAME]: createServerEntry(input),
140
+ },
141
+ };
142
+ return {
143
+ settingsPath: resolveSettingsPath({ hostType: input.hostType }),
144
+ snippet,
145
+ json: JSON.stringify(snippet, null, 2),
146
+ language: "json",
147
+ };
262
148
  }
263
149
  export function installHostConfig(input) {
264
150
  const settingsPath = resolveSettingsPath({
@@ -270,11 +156,9 @@ export function installHostConfig(input) {
270
156
  if (fs.existsSync(settingsPath)) {
271
157
  const raw = fs.readFileSync(settingsPath, "utf8");
272
158
  existingText = raw;
273
- if (raw.trim().length > 0) {
159
+ if (raw.trim().length > 0 && input.hostType !== "codex") {
274
160
  try {
275
- if (input.hostType !== "codex") {
276
- existingSettings = JSON.parse(raw);
277
- }
161
+ existingSettings = JSON.parse(raw);
278
162
  }
279
163
  catch (error) {
280
164
  const detail = error instanceof Error ? error.message : "Unknown parse failure.";
@@ -283,28 +167,17 @@ export function installHostConfig(input) {
283
167
  }
284
168
  }
285
169
  const hostInput = {
286
- existingSettings,
287
170
  hostType: input.hostType,
288
- token: input.token,
289
171
  apiUrl: input.apiUrl,
290
- serverName: input.serverName,
291
- clientId: input.clientId,
292
- clientLabel: input.clientLabel,
293
- free: input.free,
172
+ serverName: input.serverName ?? DEFAULT_SERVER_NAME,
294
173
  cwd: input.cwd,
295
174
  serverEntrypoint: input.serverEntrypoint,
296
175
  };
297
176
  const json = input.hostType === "codex"
298
- ? mergeCodexSettings({
299
- ...hostInput,
300
- existingSettings: existingText,
301
- })
177
+ ? mergeCodexSettings({ ...hostInput, existingSettings: existingText })
302
178
  : JSON.stringify(input.hostType === "claude_code"
303
- ? mergeClaudeCodeSettings({
304
- ...hostInput,
305
- cwd: input.cwd,
306
- })
307
- : mergeHostSettings(hostInput), null, 2);
179
+ ? mergeClaudeCodeSettings({ ...hostInput, existingSettings, cwd: input.cwd })
180
+ : mergeCursorSettings({ ...hostInput, existingSettings }), null, 2);
308
181
  if (!input.dryRun) {
309
182
  fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
310
183
  fs.writeFileSync(settingsPath, json.endsWith("\n") ? json : `${json}\n`, "utf8");
@@ -313,461 +186,114 @@ export function installHostConfig(input) {
313
186
  settingsPath,
314
187
  json,
315
188
  wroteFile: !input.dryRun,
316
- nextStep: verificationNextStep(input.hostType),
317
- };
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
189
  };
331
190
  }
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
- }
381
191
  export function uninstallHostConfig(input) {
382
192
  const settingsPath = resolveSettingsPath({
383
193
  hostType: input.hostType,
384
194
  homeDirectory: input.homeDirectory,
385
195
  });
386
- const serverNames = serverNamesForRemoval(input.serverName);
387
- let json = "";
388
- let foundConfigFile = false;
389
- let removedServer = false;
390
- if (fs.existsSync(settingsPath)) {
391
- foundConfigFile = true;
392
- const raw = fs.readFileSync(settingsPath, "utf8");
393
- if (input.hostType === "codex") {
394
- json = raw;
395
- for (const serverName of serverNames) {
396
- const before = json;
397
- json = removeCodexTomlServer(json, serverName);
398
- removedServer = removedServer || before !== json;
399
- }
400
- json = json ? `${json}\n` : "";
401
- }
402
- else {
403
- const parsed = raw.trim() ? JSON.parse(raw) : {};
404
- if (input.hostType === "claude_code") {
405
- const cwdInputs = input.cwds && input.cwds.length > 0 ? input.cwds : [input.cwd ?? process.cwd()];
406
- const existingProjects = isRecord(parsed.projects) ? parsed.projects : {};
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] = {
425
- ...existingProject,
426
- mcpServers: nextServers,
427
- };
428
- }
429
- }
430
- json = JSON.stringify({
431
- ...parsed,
432
- projects: nextProjects,
433
- }, null, 2);
434
- }
435
- else {
436
- const existingMcpServers = isRecord(parsed.mcpServers) ? parsed.mcpServers : {};
437
- const nextServers = { ...existingMcpServers };
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;
443
- json = JSON.stringify({ ...parsed, mcpServers: nextServers }, null, 2);
444
- }
445
- json = `${json}\n`;
446
- }
447
- if (!input.dryRun) {
448
- fs.writeFileSync(settingsPath, json, "utf8");
449
- }
450
- }
451
- return {
452
- settingsPath,
453
- json,
454
- removedServerName: serverNames.join(", "),
455
- removedServerNames: serverNames,
456
- foundConfigFile,
457
- removedServer,
458
- wroteFile: !input.dryRun && foundConfigFile,
459
- };
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
- });
196
+ const serverName = input.serverName ?? DEFAULT_SERVER_NAME;
197
+ if (!fs.existsSync(settingsPath)) {
198
+ return { settingsPath, removed: false, wroteFile: false };
506
199
  }
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
- }
200
+ const raw = fs.readFileSync(settingsPath, "utf8");
201
+ let next = raw;
202
+ let removed = false;
203
+ if (input.hostType === "codex") {
204
+ next = `${removeCodexTomlServer(raw, serverName)}\n`;
205
+ removed = next !== raw;
206
+ }
207
+ else {
208
+ const parsed = raw.trim() ? JSON.parse(raw) : {};
209
+ if (input.hostType === "claude_code") {
210
+ const cwd = path.resolve(input.cwd ?? process.cwd());
211
+ const projects = isRecord(parsed.projects) ? { ...parsed.projects } : {};
212
+ const project = isRecord(projects[cwd]) ? { ...projects[cwd] } : {};
213
+ const servers = isRecord(project.mcpServers) ? { ...project.mcpServers } : {};
214
+ removed = serverName in servers;
215
+ delete servers[serverName];
216
+ project.mcpServers = servers;
217
+ projects[cwd] = project;
218
+ next = `${JSON.stringify({ ...parsed, projects }, null, 2)}\n`;
531
219
  }
532
220
  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");
221
+ const servers = isRecord(parsed.mcpServers) ? { ...parsed.mcpServers } : {};
222
+ removed = serverName in servers;
223
+ delete servers[serverName];
224
+ next = `${JSON.stringify({ ...parsed, mcpServers: servers }, null, 2)}\n`;
583
225
  }
584
226
  }
585
- return {
586
- settingsPath,
587
- json,
588
- packageSpec,
589
- foundConfigFile,
590
- upgradedServer,
591
- upgradedServerNames: Array.from(upgradedServerNames),
592
- wroteFile: !input.dryRun && foundConfigFile && upgradedServer,
593
- };
227
+ if (!input.dryRun)
228
+ fs.writeFileSync(settingsPath, next, "utf8");
229
+ return { settingsPath, removed, wroteFile: !input.dryRun };
594
230
  }
595
- export async function sendInstallHeartbeat(input) {
596
- const fetcher = input.fetchImpl ?? fetch;
597
- const scope = resolvePluginScope(input.cwd ?? process.cwd());
598
- const apiUrl = input.apiUrl.replace(/\/$/, "");
599
- const response = await fetcher(`${apiUrl}/api/connectors/mcp/heartbeat`, {
600
- method: "POST",
601
- headers: {
602
- "Content-Type": "application/json",
603
- },
604
- body: JSON.stringify({
605
- installToken: input.token,
606
- clientId: input.clientId || input.hostType,
607
- clientLabel: input.clientLabel,
608
- hostType: input.hostType,
609
- serverName: input.serverName,
610
- repoName: scope.repoName,
611
- ...(scope.repoRoot ? { repoRoot: scope.repoRoot } : {}),
612
- ...(scope.appPath ? { appPath: scope.appPath } : {}),
613
- ...(scope.serviceName ? { serviceName: scope.serviceName } : {}),
614
- }),
615
- });
616
- return response.ok;
617
- }
618
- function detectStackGuidance(cwd) {
619
- const packagePath = path.join(cwd, "package.json");
620
- if (!fs.existsSync(packagePath)) {
621
- return [];
622
- }
623
- let manifest = {};
624
- try {
625
- manifest = JSON.parse(fs.readFileSync(packagePath, "utf8"));
626
- }
627
- catch {
628
- return [];
629
- }
630
- const deps = {
631
- ...(manifest.dependencies ?? {}),
632
- ...(manifest.devDependencies ?? {}),
633
- };
634
- const names = new Set(Object.keys(deps));
635
- const guidance = [];
636
- if (names.has("next")) {
637
- guidance.push("- Next.js detected: after changing route handlers, server actions, middleware, or cache behavior, capture `verification_result` with the command/result.");
638
- }
639
- if (names.has("express") || names.has("@types/express")) {
640
- guidance.push("- Express detected: after changing middleware, request validation, or response envelopes, capture `verification_result` with the command/result.");
641
- }
642
- if (names.has("vite") || names.has("@vitejs/plugin-react")) {
643
- guidance.push("- Vite detected: after changing client entrypoints, env handling, or build config, capture `verification_result` with the command/result.");
644
- }
645
- if (names.has("openai") || names.has("@openai/agents") || names.has("@ai-sdk/openai")) {
646
- guidance.push("- OpenAI SDK detected: after editing prompts, model calls, streaming/SSE, or schema-validated responses, capture `direction_change` for prompt choices and `verification_result` for checked outputs.");
647
- }
648
- return guidance.slice(0, 4);
231
+ export function instructionFileForHost(hostType, cwd = process.cwd()) {
232
+ return path.join(path.resolve(cwd), hostType === "claude_code" ? "CLAUDE.md" : "AGENTS.md");
649
233
  }
650
- function behaviorInstructions(hostType, cwd = process.cwd()) {
651
- const hostLabel = hostType === "claude_code" ? "Claude Code" : hostType === "cursor" ? "Cursor" : "Codex";
652
- const stackGuidance = detectStackGuidance(cwd);
234
+ export function installBehaviorInstructions(input) {
235
+ const hostLabel = input.hostType === "claude_code" ? "Claude Code" : input.hostType === "codex" ? "Codex" : "Cursor";
653
236
  return [
654
- ASKTHEW_INSTRUCTIONS_START,
655
- "",
656
- "## Ask The W Plugin",
237
+ INSTRUCTIONS_START,
238
+ `Ask The W is installed for ${hostLabel}.`,
657
239
  "",
658
- `This workspace uses the Ask The W Plugin. When working in this repo with ${hostLabel}, send compact Ask The W updates automatically with the MCP tool \`capture_session_signal\`.`,
659
- "",
660
- "Startup update:",
661
- `- At the start of every new ${hostLabel} session in this repo, before plan mode, exploration, or any normal reply, call \`capture_session_signal\` with \`kind: "setup_complete"\` if the tool is available.`,
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.`,
663
- "- If you realize later in the conversation that the startup call was missed, send it immediately with `metadata.recovered_missed_startup=true`.",
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
- "",
675
- "Send an update:",
676
- "- after the user accepts or rejects product, architecture, or implementation direction",
677
- "- before using tools that write files, after meaningful implementation changes",
678
- "- after tests, builds, lint, or other verification",
679
- "- at the final summary",
680
- ...(stackGuidance.length > 0 ? ["", "Stack-specific nudges:", ...stackGuidance] : []),
681
- "",
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.",
683
- "",
684
- ASKTHEW_INSTRUCTIONS_END,
240
+ "- After meaningful work, call `capture_session_signal` with a concise summary, bare evidence, `scopeKey`, and a ULID `idempotencyKey`.",
241
+ "- Capture direction changes, implementation checkpoints, verification results, and final summaries. Aim for 5-15 useful signals per session.",
242
+ "- When the user asks what happened or what changed, call `recap`. When they ask for a next-step nudge, call `coach`.",
243
+ "- When the user makes a durable architecture, API, deployment, or product decision, call `create_decision`.",
244
+ "- Do not send full transcripts. Redact obvious secrets before capture.",
245
+ INSTRUCTIONS_END,
685
246
  "",
686
247
  ].join("\n");
687
248
  }
688
- function cursorBehaviorInstructions(cwd = process.cwd()) {
689
- return [
690
- "---",
691
- "description: Ask The W Plugin capture rules",
692
- "alwaysApply: true",
693
- "---",
694
- "",
695
- behaviorInstructions("cursor", cwd),
696
- ].join("\n");
697
- }
698
- function upsertMarkedBlock(existing, block) {
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;
708
- return `${existing.slice(0, startIndex).trimEnd()}\n\n${block.trimEnd()}\n${existing.slice(afterEnd).trimStart()}`.trimEnd() + "\n";
249
+ function replaceMarkedBlock(existing, block) {
250
+ const start = existing.indexOf(INSTRUCTIONS_START);
251
+ const end = existing.indexOf(INSTRUCTIONS_END);
252
+ if (start >= 0 && end >= start) {
253
+ const after = end + INSTRUCTIONS_END.length;
254
+ return `${existing.slice(0, start).trimEnd()}\n\n${block}${existing.slice(after).trimStart()}`.trimEnd() + "\n";
709
255
  }
710
- return `${existing.trimEnd()}${existing.trim() ? "\n\n" : ""}${block.trimEnd()}\n`;
256
+ return `${existing.trimEnd()}${existing.trim() ? "\n\n" : ""}${block}`.trimEnd() + "\n";
711
257
  }
712
- export function installBehaviorInstructions(input) {
713
- const cwd = path.resolve(input.cwd ?? process.cwd());
714
- const markdownBlock = behaviorInstructions(input.hostType, cwd);
715
- const markdownTargets = [path.join(cwd, "CLAUDE.md"), path.join(cwd, "AGENTS.md")];
716
- const writtenPaths = [];
717
- let primaryPath = input.hostType === "claude_code" ? markdownTargets[0] : markdownTargets[1];
718
- for (const instructionsPath of markdownTargets) {
719
- const existing = fs.existsSync(instructionsPath) ? fs.readFileSync(instructionsPath, "utf8") : "";
720
- const next = upsertMarkedBlock(existing, markdownBlock);
721
- if (!input.dryRun) {
722
- fs.mkdirSync(path.dirname(instructionsPath), { recursive: true });
723
- fs.writeFileSync(instructionsPath, next, "utf8");
724
- }
725
- writtenPaths.push(instructionsPath);
726
- }
727
- if (input.hostType === "cursor") {
728
- const cursorPath = path.join(cwd, ".cursor", "rules", "askthew.mdc");
729
- if (!input.dryRun) {
730
- fs.mkdirSync(path.dirname(cursorPath), { recursive: true });
731
- fs.writeFileSync(cursorPath, cursorBehaviorInstructions(cwd), "utf8");
732
- }
733
- writtenPaths.push(cursorPath);
734
- primaryPath = cursorPath;
258
+ export function writeBehaviorInstructions(input) {
259
+ const filePath = instructionFileForHost(input.hostType, input.cwd);
260
+ const block = installBehaviorInstructions(input);
261
+ const existing = fs.existsSync(filePath) ? fs.readFileSync(filePath, "utf8") : "";
262
+ const next = replaceMarkedBlock(existing, block);
263
+ if (!input.dryRun) {
264
+ fs.writeFileSync(filePath, next, "utf8");
735
265
  }
736
266
  return {
737
- path: primaryPath,
738
- paths: writtenPaths,
267
+ filePath,
268
+ text: next,
739
269
  wroteFile: !input.dryRun,
740
- content: markdownBlock,
741
270
  };
742
271
  }
743
- export function uninstallBehaviorInstructions(input) {
744
- const cwd = path.resolve(input.cwd ?? process.cwd());
745
- const markdownTargets = [path.join(cwd, "CLAUDE.md"), path.join(cwd, "AGENTS.md")];
746
- const touchedPaths = [];
747
- for (const instructionsPath of markdownTargets) {
748
- if (!fs.existsSync(instructionsPath))
749
- continue;
750
- const existing = fs.readFileSync(instructionsPath, "utf8");
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";
759
- if (!input.dryRun)
760
- fs.writeFileSync(instructionsPath, next, "utf8");
761
- touchedPaths.push(instructionsPath);
762
- }
763
- const cursorPath = path.join(cwd, ".cursor", "rules", "askthew.mdc");
764
- if (input.hostType === "cursor" && fs.existsSync(cursorPath)) {
765
- if (!input.dryRun)
766
- fs.rmSync(cursorPath, { force: true });
767
- touchedPaths.push(cursorPath);
768
- }
769
- return {
770
- paths: touchedPaths,
771
- wroteFile: !input.dryRun,
272
+ export function writeInstallMetadata(input) {
273
+ const metadata = {
274
+ host: input.hostType,
275
+ version: packageVersion(),
276
+ api_url: input.apiUrl ?? "https://app.askthew.com",
277
+ tier: input.tier ?? "free",
278
+ workspace_id: input.workspaceId ?? null,
279
+ last_sync: new Date().toISOString(),
772
280
  };
281
+ writePrivateJson(installMetadataPath(input.env), metadata);
282
+ return metadata;
283
+ }
284
+ export function formatInstallCommand(input) {
285
+ return [
286
+ "npx",
287
+ "-y",
288
+ "--package",
289
+ "@askthew/mcp-plugin",
290
+ "askthew-mcp",
291
+ "install",
292
+ "--host",
293
+ input.hostType,
294
+ ...(input.bindToken ? ["--bind", input.bindToken] : []),
295
+ ].join(" ");
296
+ }
297
+ export function dataDirSummary(env = process.env) {
298
+ return askTheWDataDir(env);
773
299
  }