@askthew/mcp-plugin 0.4.0 → 0.4.2

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 (45) hide show
  1. package/README.md +24 -13
  2. package/dist/auth-pending.test.d.ts +1 -0
  3. package/dist/auth-pending.test.js +56 -0
  4. package/dist/cli-actions.test.d.ts +1 -0
  5. package/dist/cli-actions.test.js +71 -0
  6. package/dist/cli.d.ts +9 -0
  7. package/dist/cli.js +293 -37
  8. package/dist/cli.test.d.ts +1 -0
  9. package/dist/cli.test.js +274 -0
  10. package/dist/free-tier-policy.test.d.ts +1 -0
  11. package/dist/free-tier-policy.test.js +57 -0
  12. package/dist/index.d.ts +47 -13
  13. package/dist/index.js +1103 -106
  14. package/dist/index.test.js +609 -6
  15. package/dist/install.d.ts +40 -0
  16. package/dist/install.js +155 -18
  17. package/dist/install.test.js +62 -2
  18. package/dist/lib/auth-pending.d.ts +23 -0
  19. package/dist/lib/auth-pending.js +36 -0
  20. package/dist/lib/cli-actions.d.ts +28 -0
  21. package/dist/lib/cli-actions.js +104 -0
  22. package/dist/lib/free-install-registration.d.ts +27 -0
  23. package/dist/lib/free-install-registration.js +52 -0
  24. package/dist/lib/free-tier-policy.d.ts +5 -1
  25. package/dist/lib/free-tier-policy.js +16 -1
  26. package/dist/lib/local-identity.d.ts +44 -0
  27. package/dist/lib/local-identity.js +81 -0
  28. package/dist/lib/local-store.d.ts +33 -2
  29. package/dist/lib/local-store.js +191 -19
  30. package/dist/lib/paths.d.ts +2 -0
  31. package/dist/lib/paths.js +6 -0
  32. package/dist/lib/telemetry.js +28 -2
  33. package/dist/lib/timeline-insights.d.ts +23 -0
  34. package/dist/lib/timeline-insights.js +115 -0
  35. package/dist/lib/upgrade-nudge.d.ts +1 -1
  36. package/dist/lib/upgrade-nudge.js +8 -1
  37. package/dist/local-identity.test.d.ts +1 -0
  38. package/dist/local-identity.test.js +29 -0
  39. package/dist/local-store.test.js +34 -0
  40. package/dist/scope.d.ts +1 -1
  41. package/dist/scope.js +56 -2
  42. package/dist/scope.test.js +17 -0
  43. package/dist/timeline-insights.test.d.ts +1 -0
  44. package/dist/timeline-insights.test.js +85 -0
  45. package/package.json +2 -2
package/dist/install.d.ts CHANGED
@@ -7,12 +7,21 @@ interface HostConfigInput {
7
7
  clientId?: string;
8
8
  clientLabel?: string;
9
9
  free?: boolean;
10
+ email?: string;
11
+ cwd?: string;
10
12
  }
11
13
  interface InstallHostConfigInput extends HostConfigInput {
12
14
  dryRun?: boolean;
13
15
  homeDirectory?: string;
14
16
  cwd?: string;
15
17
  }
18
+ interface UninstallHostConfigInput {
19
+ hostType: SupportedHostType;
20
+ serverName?: string;
21
+ dryRun?: boolean;
22
+ homeDirectory?: string;
23
+ cwd?: string;
24
+ }
16
25
  export declare function resolveSettingsPath(input: {
17
26
  hostType: SupportedHostType;
18
27
  homeDirectory?: string;
@@ -21,15 +30,23 @@ export declare function createServerEntry(input: HostConfigInput): {
21
30
  command: string;
22
31
  args: string[];
23
32
  env: {
33
+ ASKTHEW_SERVICE_NAME?: string | undefined;
34
+ ASKTHEW_APP_PATH?: string | undefined;
35
+ ASKTHEW_REPO_ROOT?: string | undefined;
24
36
  ASKTHEW_HOST_TYPE: SupportedHostType;
25
37
  ASKTHEW_SERVER_NAME: string;
38
+ ASKTHEW_REPO_NAME: string;
26
39
  ASKTHEW_CLIENT_LABEL?: string | undefined;
27
40
  ASKTHEW_CLIENT_ID?: string | undefined;
28
41
  ASKTHEW_FREE_MODE: string;
29
42
  ASKTHEW_API_URL: string;
30
43
  } | {
44
+ ASKTHEW_SERVICE_NAME?: string | undefined;
45
+ ASKTHEW_APP_PATH?: string | undefined;
46
+ ASKTHEW_REPO_ROOT?: string | undefined;
31
47
  ASKTHEW_HOST_TYPE: SupportedHostType;
32
48
  ASKTHEW_SERVER_NAME: string;
49
+ ASKTHEW_REPO_NAME: string;
33
50
  ASKTHEW_CLIENT_LABEL?: string | undefined;
34
51
  ASKTHEW_CLIENT_ID?: string | undefined;
35
52
  ASKTHEW_INSTALL_TOKEN: string;
@@ -48,15 +65,23 @@ export declare function createHostConfigSnippet(input: HostConfigInput): {
48
65
  command: string;
49
66
  args: string[];
50
67
  env: {
68
+ ASKTHEW_SERVICE_NAME?: string | undefined;
69
+ ASKTHEW_APP_PATH?: string | undefined;
70
+ ASKTHEW_REPO_ROOT?: string | undefined;
51
71
  ASKTHEW_HOST_TYPE: SupportedHostType;
52
72
  ASKTHEW_SERVER_NAME: string;
73
+ ASKTHEW_REPO_NAME: string;
53
74
  ASKTHEW_CLIENT_LABEL?: string | undefined;
54
75
  ASKTHEW_CLIENT_ID?: string | undefined;
55
76
  ASKTHEW_FREE_MODE: string;
56
77
  ASKTHEW_API_URL: string;
57
78
  } | {
79
+ ASKTHEW_SERVICE_NAME?: string | undefined;
80
+ ASKTHEW_APP_PATH?: string | undefined;
81
+ ASKTHEW_REPO_ROOT?: string | undefined;
58
82
  ASKTHEW_HOST_TYPE: SupportedHostType;
59
83
  ASKTHEW_SERVER_NAME: string;
84
+ ASKTHEW_REPO_NAME: string;
60
85
  ASKTHEW_CLIENT_LABEL?: string | undefined;
61
86
  ASKTHEW_CLIENT_ID?: string | undefined;
62
87
  ASKTHEW_INSTALL_TOKEN: string;
@@ -82,6 +107,12 @@ export declare function installHostConfig(input: InstallHostConfigInput): {
82
107
  wroteFile: boolean;
83
108
  nextStep: string;
84
109
  };
110
+ export declare function uninstallHostConfig(input: UninstallHostConfigInput): {
111
+ settingsPath: string;
112
+ json: string;
113
+ removedServerName: string;
114
+ wroteFile: boolean;
115
+ };
85
116
  export declare function sendInstallHeartbeat(input: HostConfigInput & {
86
117
  cwd?: string;
87
118
  fetchImpl?: typeof fetch;
@@ -92,7 +123,16 @@ export declare function installBehaviorInstructions(input: {
92
123
  dryRun?: boolean;
93
124
  }): {
94
125
  path: string;
126
+ paths: string[];
95
127
  wroteFile: boolean;
96
128
  content: string;
97
129
  };
130
+ export declare function uninstallBehaviorInstructions(input: {
131
+ hostType: SupportedHostType;
132
+ cwd?: string;
133
+ dryRun?: boolean;
134
+ }): {
135
+ paths: string[];
136
+ wroteFile: boolean;
137
+ };
98
138
  export {};
package/dist/install.js CHANGED
@@ -18,6 +18,7 @@ export function resolveSettingsPath(input) {
18
18
  return path.join(homeDirectory, ".claude.json");
19
19
  }
20
20
  export function createServerEntry(input) {
21
+ const scope = resolvePluginScope(input.cwd ?? process.cwd());
21
22
  return {
22
23
  command: "npx",
23
24
  args: ["-y", "--prefer-online", "@askthew/mcp-plugin@latest"],
@@ -28,6 +29,10 @@ export function createServerEntry(input) {
28
29
  ...(input.clientLabel ? { ASKTHEW_CLIENT_LABEL: input.clientLabel } : {}),
29
30
  ASKTHEW_HOST_TYPE: input.hostType,
30
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 } : {}),
31
36
  },
32
37
  };
33
38
  }
@@ -139,6 +144,9 @@ export function formatInstallCommand(input) {
139
144
  ];
140
145
  if (input.free) {
141
146
  parts.push("--free");
147
+ if (input.email) {
148
+ parts.push("--email", JSON.stringify(input.email));
149
+ }
142
150
  }
143
151
  else {
144
152
  parts.splice(7, 0, "--token", JSON.stringify(input.token ?? ""));
@@ -179,6 +187,8 @@ export function installHostConfig(input) {
179
187
  serverName: input.serverName,
180
188
  clientId: input.clientId,
181
189
  clientLabel: input.clientLabel,
190
+ free: input.free,
191
+ cwd: input.cwd,
182
192
  };
183
193
  const json = input.hostType === "codex"
184
194
  ? mergeCodexSettings({
@@ -202,6 +212,65 @@ export function installHostConfig(input) {
202
212
  nextStep: verificationNextStep(input.hostType),
203
213
  };
204
214
  }
215
+ export function uninstallHostConfig(input) {
216
+ const settingsPath = resolveSettingsPath({
217
+ hostType: input.hostType,
218
+ homeDirectory: input.homeDirectory,
219
+ });
220
+ const serverName = input.serverName?.trim() || "askthew";
221
+ let json = "";
222
+ if (fs.existsSync(settingsPath)) {
223
+ const raw = fs.readFileSync(settingsPath, "utf8");
224
+ if (input.hostType === "codex") {
225
+ json = removeCodexTomlServer(raw, serverName);
226
+ if (serverName !== "askthew") {
227
+ json = removeCodexTomlServer(json, "askthew");
228
+ }
229
+ json = json ? `${json}\n` : "";
230
+ }
231
+ else {
232
+ const parsed = raw.trim() ? JSON.parse(raw) : {};
233
+ if (input.hostType === "claude_code") {
234
+ const cwd = path.resolve(input.cwd ?? process.cwd());
235
+ const existingProjects = isRecord(parsed.projects) ? parsed.projects : {};
236
+ const existingProject = isRecord(existingProjects[cwd]) ? existingProjects[cwd] : {};
237
+ const existingMcpServers = isRecord(existingProject.mcpServers) ? existingProject.mcpServers : {};
238
+ const nextServers = { ...existingMcpServers };
239
+ delete nextServers[serverName];
240
+ if (serverName !== "askthew")
241
+ delete nextServers.askthew;
242
+ json = JSON.stringify({
243
+ ...parsed,
244
+ projects: {
245
+ ...existingProjects,
246
+ [cwd]: {
247
+ ...existingProject,
248
+ mcpServers: nextServers,
249
+ },
250
+ },
251
+ }, null, 2);
252
+ }
253
+ else {
254
+ const existingMcpServers = isRecord(parsed.mcpServers) ? parsed.mcpServers : {};
255
+ const nextServers = { ...existingMcpServers };
256
+ delete nextServers[serverName];
257
+ if (serverName !== "askthew")
258
+ delete nextServers.askthew;
259
+ json = JSON.stringify({ ...parsed, mcpServers: nextServers }, null, 2);
260
+ }
261
+ json = `${json}\n`;
262
+ }
263
+ if (!input.dryRun) {
264
+ fs.writeFileSync(settingsPath, json, "utf8");
265
+ }
266
+ }
267
+ return {
268
+ settingsPath,
269
+ json,
270
+ removedServerName: serverName,
271
+ wroteFile: !input.dryRun,
272
+ };
273
+ }
205
274
  export async function sendInstallHeartbeat(input) {
206
275
  const fetcher = input.fetchImpl ?? fetch;
207
276
  const scope = resolvePluginScope(input.cwd ?? process.cwd());
@@ -225,8 +294,41 @@ export async function sendInstallHeartbeat(input) {
225
294
  });
226
295
  return response.ok;
227
296
  }
228
- function behaviorInstructions(hostType) {
297
+ function detectStackGuidance(cwd) {
298
+ const packagePath = path.join(cwd, "package.json");
299
+ if (!fs.existsSync(packagePath)) {
300
+ return [];
301
+ }
302
+ let manifest = {};
303
+ try {
304
+ manifest = JSON.parse(fs.readFileSync(packagePath, "utf8"));
305
+ }
306
+ catch {
307
+ return [];
308
+ }
309
+ const deps = {
310
+ ...(manifest.dependencies ?? {}),
311
+ ...(manifest.devDependencies ?? {}),
312
+ };
313
+ const names = new Set(Object.keys(deps));
314
+ const guidance = [];
315
+ if (names.has("next")) {
316
+ guidance.push("- Next.js detected: after changing route handlers, server actions, middleware, or cache behavior, capture `verification_result` with the command/result.");
317
+ }
318
+ if (names.has("express") || names.has("@types/express")) {
319
+ guidance.push("- Express detected: after changing middleware, request validation, or response envelopes, capture `verification_result` with the command/result.");
320
+ }
321
+ if (names.has("vite") || names.has("@vitejs/plugin-react")) {
322
+ guidance.push("- Vite detected: after changing client entrypoints, env handling, or build config, capture `verification_result` with the command/result.");
323
+ }
324
+ if (names.has("openai") || names.has("@openai/agents") || names.has("@ai-sdk/openai")) {
325
+ 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.");
326
+ }
327
+ return guidance.slice(0, 4);
328
+ }
329
+ function behaviorInstructions(hostType, cwd = process.cwd()) {
229
330
  const hostLabel = hostType === "claude_code" ? "Claude Code" : hostType === "cursor" ? "Cursor" : "Codex";
331
+ const stackGuidance = detectStackGuidance(cwd);
230
332
  return [
231
333
  ASKTHEW_INSTRUCTIONS_START,
232
334
  "",
@@ -241,10 +343,10 @@ function behaviorInstructions(hostType) {
241
343
  "",
242
344
  "Send an update:",
243
345
  "- after the user accepts or rejects product, architecture, or implementation direction",
244
- "- after meaningful implementation changes",
346
+ "- before using tools that write files, after meaningful implementation changes",
245
347
  "- after tests, builds, lint, or other verification",
246
- "- every 8-12 turns in long sessions",
247
348
  "- at the final summary",
349
+ ...(stackGuidance.length > 0 ? ["", "Stack-specific nudges:", ...stackGuidance] : []),
248
350
  "",
249
351
  "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.",
250
352
  "",
@@ -252,14 +354,14 @@ function behaviorInstructions(hostType) {
252
354
  "",
253
355
  ].join("\n");
254
356
  }
255
- function cursorBehaviorInstructions() {
357
+ function cursorBehaviorInstructions(cwd = process.cwd()) {
256
358
  return [
257
359
  "---",
258
360
  "description: Ask The W Plugin capture rules",
259
361
  "alwaysApply: true",
260
362
  "---",
261
363
  "",
262
- behaviorInstructions("cursor"),
364
+ behaviorInstructions("cursor", cwd),
263
365
  ].join("\n");
264
366
  }
265
367
  function upsertMarkedBlock(existing, block) {
@@ -273,21 +375,56 @@ function upsertMarkedBlock(existing, block) {
273
375
  }
274
376
  export function installBehaviorInstructions(input) {
275
377
  const cwd = path.resolve(input.cwd ?? process.cwd());
276
- const instructionsPath = input.hostType === "claude_code"
277
- ? path.join(cwd, "CLAUDE.md")
278
- : input.hostType === "cursor"
279
- ? path.join(cwd, ".cursor", "rules", "askthew.mdc")
280
- : path.join(cwd, "AGENTS.md");
281
- const block = input.hostType === "cursor" ? cursorBehaviorInstructions() : behaviorInstructions(input.hostType);
282
- const existing = fs.existsSync(instructionsPath) ? fs.readFileSync(instructionsPath, "utf8") : "";
283
- const next = input.hostType === "cursor" ? block : upsertMarkedBlock(existing, block);
284
- if (!input.dryRun) {
285
- fs.mkdirSync(path.dirname(instructionsPath), { recursive: true });
286
- fs.writeFileSync(instructionsPath, next, "utf8");
378
+ const markdownBlock = behaviorInstructions(input.hostType, cwd);
379
+ const markdownTargets = [path.join(cwd, "CLAUDE.md"), path.join(cwd, "AGENTS.md")];
380
+ const writtenPaths = [];
381
+ let primaryPath = input.hostType === "claude_code" ? markdownTargets[0] : markdownTargets[1];
382
+ for (const instructionsPath of markdownTargets) {
383
+ const existing = fs.existsSync(instructionsPath) ? fs.readFileSync(instructionsPath, "utf8") : "";
384
+ const next = upsertMarkedBlock(existing, markdownBlock);
385
+ if (!input.dryRun) {
386
+ fs.mkdirSync(path.dirname(instructionsPath), { recursive: true });
387
+ fs.writeFileSync(instructionsPath, next, "utf8");
388
+ }
389
+ writtenPaths.push(instructionsPath);
390
+ }
391
+ if (input.hostType === "cursor") {
392
+ const cursorPath = path.join(cwd, ".cursor", "rules", "askthew.mdc");
393
+ if (!input.dryRun) {
394
+ fs.mkdirSync(path.dirname(cursorPath), { recursive: true });
395
+ fs.writeFileSync(cursorPath, cursorBehaviorInstructions(cwd), "utf8");
396
+ }
397
+ writtenPaths.push(cursorPath);
398
+ primaryPath = cursorPath;
399
+ }
400
+ return {
401
+ path: primaryPath,
402
+ paths: writtenPaths,
403
+ wroteFile: !input.dryRun,
404
+ content: markdownBlock,
405
+ };
406
+ }
407
+ export function uninstallBehaviorInstructions(input) {
408
+ const cwd = path.resolve(input.cwd ?? process.cwd());
409
+ const markdownTargets = [path.join(cwd, "CLAUDE.md"), path.join(cwd, "AGENTS.md")];
410
+ const touchedPaths = [];
411
+ for (const instructionsPath of markdownTargets) {
412
+ if (!fs.existsSync(instructionsPath))
413
+ continue;
414
+ const existing = fs.readFileSync(instructionsPath, "utf8");
415
+ const next = existing.replace(new RegExp(`\\n?${ASKTHEW_INSTRUCTIONS_START}[\\s\\S]*?${ASKTHEW_INSTRUCTIONS_END}\\n?`, "g"), "\n").trimEnd() + "\n";
416
+ if (!input.dryRun)
417
+ fs.writeFileSync(instructionsPath, next, "utf8");
418
+ touchedPaths.push(instructionsPath);
419
+ }
420
+ const cursorPath = path.join(cwd, ".cursor", "rules", "askthew.mdc");
421
+ if (input.hostType === "cursor" && fs.existsSync(cursorPath)) {
422
+ if (!input.dryRun)
423
+ fs.rmSync(cursorPath, { force: true });
424
+ touchedPaths.push(cursorPath);
287
425
  }
288
426
  return {
289
- path: instructionsPath,
427
+ paths: touchedPaths,
290
428
  wroteFile: !input.dryRun,
291
- content: next,
292
429
  };
293
430
  }
@@ -3,8 +3,10 @@ import assert from "node:assert/strict";
3
3
  import fs from "node:fs";
4
4
  import os from "node:os";
5
5
  import path from "node:path";
6
- import { formatInstallCommand, installBehaviorInstructions, installHostConfig, mergeHostSettings, resolveSettingsPath, sendInstallHeartbeat, verificationNextStep, } from "./install.js";
6
+ import { formatInstallCommand, installBehaviorInstructions, installHostConfig, mergeHostSettings, resolveSettingsPath, sendInstallHeartbeat, uninstallBehaviorInstructions, uninstallHostConfig, verificationNextStep, } from "./install.js";
7
7
  test("mergeHostSettings preserves unrelated MCP servers and replaces askthew", () => {
8
+ const tempProject = fs.mkdtempSync(path.join(os.tmpdir(), "askthew-merge-project-"));
9
+ fs.writeFileSync(path.join(tempProject, "package.json"), "{}", "utf8");
8
10
  const merged = mergeHostSettings({
9
11
  existingSettings: {
10
12
  theme: "dark",
@@ -21,6 +23,7 @@ test("mergeHostSettings preserves unrelated MCP servers and replaces askthew", (
21
23
  token: "token-123",
22
24
  apiUrl: "https://askthew.example.com",
23
25
  serverName: "askthew_workspace_a",
26
+ cwd: tempProject,
24
27
  });
25
28
  assert.deepEqual(merged.theme, "dark");
26
29
  assert.deepEqual(Object.keys(merged.mcpServers ?? {}).sort(), ["askthew_workspace_a", "github"]);
@@ -32,8 +35,11 @@ test("mergeHostSettings preserves unrelated MCP servers and replaces askthew", (
32
35
  ASKTHEW_API_URL: "https://askthew.example.com",
33
36
  ASKTHEW_HOST_TYPE: "codex",
34
37
  ASKTHEW_SERVER_NAME: "askthew_workspace_a",
38
+ ASKTHEW_REPO_NAME: path.basename(tempProject),
39
+ ASKTHEW_REPO_ROOT: tempProject,
35
40
  },
36
41
  });
42
+ fs.rmSync(tempProject, { recursive: true, force: true });
37
43
  });
38
44
  test("installHostConfig writes Claude Code local MCP settings and stays idempotent", () => {
39
45
  const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "askthew-mcp-install-"));
@@ -204,21 +210,26 @@ test("sendInstallHeartbeat pings Ask The W after config install", async () => {
204
210
  });
205
211
  test("installBehaviorInstructions adds persistent Codex tracking rules without clobbering existing instructions", () => {
206
212
  const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "askthew-agent-instructions-"));
213
+ const claudePath = path.join(tempRoot, "CLAUDE.md");
207
214
  const agentsPath = path.join(tempRoot, "AGENTS.md");
208
215
  fs.writeFileSync(agentsPath, "# Existing instructions\n\nKeep this section.\n", "utf8");
209
216
  const result = installBehaviorInstructions({
210
217
  hostType: "codex",
211
218
  cwd: tempRoot,
212
219
  });
220
+ const claudeContents = fs.readFileSync(claudePath, "utf8");
213
221
  const contents = fs.readFileSync(agentsPath, "utf8");
214
222
  assert.equal(result.path, agentsPath);
223
+ assert.deepEqual(result.paths, [claudePath, agentsPath]);
224
+ assert.match(claudeContents, /Ask The W Plugin/);
225
+ assert.match(claudeContents, /At the start of every new Codex session in this repo/);
215
226
  assert.match(contents, /# Existing instructions/);
216
227
  assert.match(contents, /Ask The W Plugin/);
217
228
  assert.match(contents, /capture_session_signal/);
218
229
  assert.match(contents, /At the start of every new Codex session in this repo/);
219
230
  assert.match(contents, /before plan mode, exploration, or any normal reply/);
220
231
  assert.match(contents, /metadata\.recovered_missed_startup=true/);
221
- assert.match(contents, /every 8-12 turns/);
232
+ assert.match(contents, /before using tools that write files/);
222
233
  fs.rmSync(tempRoot, { recursive: true, force: true });
223
234
  });
224
235
  test("installBehaviorInstructions creates Cursor rule file", () => {
@@ -235,3 +246,52 @@ test("installBehaviorInstructions creates Cursor rule file", () => {
235
246
  assert.match(fs.readFileSync(result.path, "utf8"), /metadata\.recovered_missed_startup=true/);
236
247
  fs.rmSync(tempRoot, { recursive: true, force: true });
237
248
  });
249
+ test("installBehaviorInstructions adds stack-specific verification nudges", () => {
250
+ const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "askthew-stack-rules-"));
251
+ fs.writeFileSync(path.join(tempRoot, "package.json"), JSON.stringify({
252
+ dependencies: {
253
+ next: "^15.0.0",
254
+ openai: "^5.0.0",
255
+ vite: "^6.0.0",
256
+ },
257
+ }), "utf8");
258
+ const result = installBehaviorInstructions({
259
+ hostType: "codex",
260
+ cwd: tempRoot,
261
+ dryRun: true,
262
+ });
263
+ assert.match(result.content, /Next\.js detected/);
264
+ assert.match(result.content, /OpenAI SDK detected/);
265
+ assert.match(result.content, /Vite detected/);
266
+ fs.rmSync(tempRoot, { recursive: true, force: true });
267
+ });
268
+ test("uninstall removes host config and Ask The W agent instruction blocks", () => {
269
+ const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "askthew-uninstall-home-"));
270
+ const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "askthew-uninstall-project-"));
271
+ installHostConfig({
272
+ hostType: "codex",
273
+ token: "token-uninstall",
274
+ apiUrl: "https://askthew.example.com",
275
+ serverName: "askthew",
276
+ homeDirectory: tempHome,
277
+ });
278
+ installBehaviorInstructions({
279
+ hostType: "codex",
280
+ cwd: tempRoot,
281
+ });
282
+ const removed = uninstallHostConfig({
283
+ hostType: "codex",
284
+ serverName: "askthew",
285
+ homeDirectory: tempHome,
286
+ });
287
+ const instructions = uninstallBehaviorInstructions({
288
+ hostType: "codex",
289
+ cwd: tempRoot,
290
+ });
291
+ assert.doesNotMatch(fs.readFileSync(removed.settingsPath, "utf8"), /askthew/);
292
+ assert.equal(instructions.paths.length, 2);
293
+ assert.doesNotMatch(fs.readFileSync(path.join(tempRoot, "AGENTS.md"), "utf8"), /ASKTHEW_PLUGIN_INSTRUCTIONS_START/);
294
+ assert.doesNotMatch(fs.readFileSync(path.join(tempRoot, "CLAUDE.md"), "utf8"), /ASKTHEW_PLUGIN_INSTRUCTIONS_START/);
295
+ fs.rmSync(tempHome, { recursive: true, force: true });
296
+ fs.rmSync(tempRoot, { recursive: true, force: true });
297
+ });
@@ -0,0 +1,23 @@
1
+ type CliConfig = {
2
+ pendingAuth?: {
3
+ email: string;
4
+ requestId: string;
5
+ expiresAt: string;
6
+ telemetryOptOut?: boolean;
7
+ };
8
+ };
9
+ export declare function savePendingAuth(input: NonNullable<CliConfig["pendingAuth"]>, env?: NodeJS.ProcessEnv): void;
10
+ export declare function clearPendingAuth(env?: NodeJS.ProcessEnv): void;
11
+ export declare function pendingAuthForEmail(email: string, env?: NodeJS.ProcessEnv): {
12
+ email: string;
13
+ requestId: string;
14
+ expiresAt: string;
15
+ telemetryOptOut?: boolean;
16
+ } | null;
17
+ export declare function pendingAuth(env?: NodeJS.ProcessEnv): {
18
+ email: string;
19
+ requestId: string;
20
+ expiresAt: string;
21
+ telemetryOptOut?: boolean;
22
+ } | null;
23
+ export {};
@@ -0,0 +1,36 @@
1
+ import { configPath, readJsonFile, writePrivateJson } from "./paths.js";
2
+ function loadCliConfig(env = process.env) {
3
+ return readJsonFile(configPath(env)) ?? {};
4
+ }
5
+ export function savePendingAuth(input, env = process.env) {
6
+ const config = loadCliConfig(env);
7
+ writePrivateJson(configPath(env), {
8
+ ...config,
9
+ pendingAuth: input,
10
+ });
11
+ }
12
+ export function clearPendingAuth(env = process.env) {
13
+ const config = loadCliConfig(env);
14
+ if (!config.pendingAuth)
15
+ return;
16
+ const { pendingAuth: _pendingAuth, ...next } = config;
17
+ writePrivateJson(configPath(env), next);
18
+ }
19
+ export function pendingAuthForEmail(email, env = process.env) {
20
+ const pending = pendingAuth(env);
21
+ if (!pending || pending.email.toLowerCase() !== email.toLowerCase()) {
22
+ return null;
23
+ }
24
+ return pending;
25
+ }
26
+ export function pendingAuth(env = process.env) {
27
+ const pending = loadCliConfig(env).pendingAuth;
28
+ if (!pending) {
29
+ return null;
30
+ }
31
+ if (Number.isFinite(Date.parse(pending.expiresAt)) && Date.parse(pending.expiresAt) <= Date.now()) {
32
+ clearPendingAuth(env);
33
+ return null;
34
+ }
35
+ return pending;
36
+ }
@@ -0,0 +1,28 @@
1
+ import type { LocalStore } from "./local-store.js";
2
+ export declare function localScopeKey(cwd?: string): string;
3
+ export declare function installPreCommitHook(input?: {
4
+ cwd?: string;
5
+ }): string;
6
+ export declare function stagedFiles(input?: {
7
+ cwd?: string;
8
+ }): string[];
9
+ export declare function preCommitDecisionGap(input: {
10
+ store: LocalStore;
11
+ stagedFiles: string[];
12
+ now?: Date;
13
+ scopeKey?: string | null;
14
+ }): {
15
+ missing: boolean;
16
+ matchedSignals: number[];
17
+ };
18
+ export declare function isoWeek(date: Date): string;
19
+ export declare function buildWeeklyDigest(input: {
20
+ store: LocalStore;
21
+ now?: Date;
22
+ scopeKey?: string | null;
23
+ }): string;
24
+ export declare function writeWeeklyDigest(input: {
25
+ store: LocalStore;
26
+ now?: Date;
27
+ outputDir?: string;
28
+ }): string;
@@ -0,0 +1,104 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { configPath, readJsonFile } from "./paths.js";
6
+ import { resolvePluginScope } from "../scope.js";
7
+ function readConfig() {
8
+ return readJsonFile(configPath()) ?? {};
9
+ }
10
+ export function localScopeKey(cwd = process.cwd()) {
11
+ const scope = resolvePluginScope(cwd);
12
+ return [scope.repoRoot || scope.repoName || cwd, scope.appPath ?? "", scope.serviceName ?? ""]
13
+ .filter(Boolean)
14
+ .join("::")
15
+ .replace(/\s+/g, " ")
16
+ .slice(0, 500);
17
+ }
18
+ export function installPreCommitHook(input = {}) {
19
+ const cwd = path.resolve(input.cwd ?? process.cwd());
20
+ const gitDir = execFileSync("git", ["rev-parse", "--git-dir"], { cwd, encoding: "utf8" }).trim();
21
+ const hookPath = path.resolve(cwd, gitDir, "hooks", "pre-commit");
22
+ const hook = [
23
+ "#!/bin/sh",
24
+ "# Ask The W pre-commit decision prompt",
25
+ "npx -y --prefer-online @askthew/mcp-plugin@latest hook-check --pre-commit",
26
+ "",
27
+ ].join("\n");
28
+ fs.mkdirSync(path.dirname(hookPath), { recursive: true });
29
+ fs.writeFileSync(hookPath, hook, { encoding: "utf8", mode: 0o755 });
30
+ fs.chmodSync(hookPath, 0o755);
31
+ return hookPath;
32
+ }
33
+ export function stagedFiles(input = {}) {
34
+ const cwd = path.resolve(input.cwd ?? process.cwd());
35
+ return execFileSync("git", ["diff", "--cached", "--name-only"], { cwd, encoding: "utf8" })
36
+ .split("\n")
37
+ .map((line) => line.trim())
38
+ .filter(Boolean);
39
+ }
40
+ export function preCommitDecisionGap(input) {
41
+ const staged = new Set(input.stagedFiles);
42
+ if (staged.size === 0) {
43
+ return { missing: false, matchedSignals: [] };
44
+ }
45
+ const nowMs = (input.now ?? new Date()).getTime();
46
+ const scopeKey = input.scopeKey;
47
+ const recentImplementationSignals = input.store
48
+ .listSignals({ scopeKey, limit: 100000 })
49
+ .filter((signal) => signal.kind === "implementation_update")
50
+ .filter((signal) => nowMs - new Date(signal.capturedAt).getTime() <= 14 * 24 * 60 * 60 * 1000)
51
+ .filter((signal) => signal.filesTouched.some((file) => staged.has(file)));
52
+ if (recentImplementationSignals.length === 0) {
53
+ return { missing: false, matchedSignals: [] };
54
+ }
55
+ const matchedSignalIds = new Set(recentImplementationSignals.map((signal) => signal.id));
56
+ const linkedDecision = input.store.listDecisions({ scopeKey, limit: 100000 }).some((decision) => {
57
+ if (decision.sourceSignalIds.some((id) => matchedSignalIds.has(id)))
58
+ return true;
59
+ return decision.files.some((file) => staged.has(file));
60
+ });
61
+ return {
62
+ missing: !linkedDecision,
63
+ matchedSignals: Array.from(matchedSignalIds),
64
+ };
65
+ }
66
+ export function isoWeek(date) {
67
+ const utcDate = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
68
+ const day = utcDate.getUTCDay() || 7;
69
+ utcDate.setUTCDate(utcDate.getUTCDate() + 4 - day);
70
+ const yearStart = new Date(Date.UTC(utcDate.getUTCFullYear(), 0, 1));
71
+ const week = Math.ceil(((utcDate.getTime() - yearStart.getTime()) / 86400000 + 1) / 7);
72
+ return `${utcDate.getUTCFullYear()}-${String(week).padStart(2, "0")}`;
73
+ }
74
+ export function buildWeeklyDigest(input) {
75
+ const now = input.now ?? new Date();
76
+ const since = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000).toISOString();
77
+ const scopeKey = input.scopeKey;
78
+ const decisions = input.store.listDecisions({ scopeKey, since, limit: 100000 });
79
+ const signals = input.store.listSignals({ scopeKey, limit: 100000 }).filter((signal) => signal.capturedAt >= since);
80
+ const lines = [
81
+ `# Ask The W Weekly Decision Digest ${isoWeek(now)}`,
82
+ "",
83
+ `Signals captured: ${signals.length}`,
84
+ `Decisions captured: ${decisions.length}`,
85
+ "",
86
+ "## Decisions",
87
+ ...(decisions.length
88
+ ? decisions.map((decision) => `- ${decision.headline} (${decision.status})${decision.why ? ` - ${decision.why}` : ""}`)
89
+ : ["- No decisions captured this week."]),
90
+ ];
91
+ if (readConfig().digest?.footer !== false) {
92
+ lines.push("", "_Captured by Ask The W._");
93
+ }
94
+ return lines.join("\n");
95
+ }
96
+ export function writeWeeklyDigest(input) {
97
+ const now = input.now ?? new Date();
98
+ const configuredOutputDir = input.outputDir ?? process.env.ASKTHEW_DIGEST_DIR?.trim();
99
+ const outputDir = configuredOutputDir || path.join(os.homedir(), "Documents");
100
+ fs.mkdirSync(outputDir, { recursive: true });
101
+ const filePath = path.join(outputDir, `askthew-digest-${isoWeek(now)}.md`);
102
+ fs.writeFileSync(filePath, `${buildWeeklyDigest({ store: input.store, now, scopeKey: localScopeKey() })}\n`, "utf8");
103
+ return filePath;
104
+ }
@@ -0,0 +1,27 @@
1
+ import { type LocalInstallIdentity, type PublicInstallIdentity } from "./local-identity.js";
2
+ export interface FreeInstallRegistrationOptions {
3
+ apiUrl?: string;
4
+ fetchImpl?: typeof fetch;
5
+ }
6
+ export declare function registerFreeInstall(input: {
7
+ identity: LocalInstallIdentity;
8
+ deviceLabel?: string;
9
+ repo?: Record<string, unknown>;
10
+ options?: FreeInstallRegistrationOptions;
11
+ }): Promise<{
12
+ ok: boolean;
13
+ registeredAt: string;
14
+ }>;
15
+ export declare function tryRegisterFreeInstall(input: {
16
+ identity: LocalInstallIdentity;
17
+ deviceLabel?: string;
18
+ repo?: Record<string, unknown>;
19
+ options?: FreeInstallRegistrationOptions;
20
+ }): Promise<{
21
+ ok: boolean;
22
+ registeredAt: string;
23
+ } | {
24
+ ok: boolean;
25
+ error: string;
26
+ }>;
27
+ export declare function describeFreeIdentity(identity: PublicInstallIdentity): string;