@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.
- package/README.md +24 -13
- package/dist/auth-pending.test.d.ts +1 -0
- package/dist/auth-pending.test.js +56 -0
- package/dist/cli-actions.test.d.ts +1 -0
- package/dist/cli-actions.test.js +71 -0
- package/dist/cli.d.ts +9 -0
- package/dist/cli.js +293 -37
- package/dist/cli.test.d.ts +1 -0
- package/dist/cli.test.js +274 -0
- package/dist/free-tier-policy.test.d.ts +1 -0
- package/dist/free-tier-policy.test.js +57 -0
- package/dist/index.d.ts +47 -13
- package/dist/index.js +1103 -106
- package/dist/index.test.js +609 -6
- package/dist/install.d.ts +40 -0
- package/dist/install.js +155 -18
- package/dist/install.test.js +62 -2
- package/dist/lib/auth-pending.d.ts +23 -0
- package/dist/lib/auth-pending.js +36 -0
- package/dist/lib/cli-actions.d.ts +28 -0
- package/dist/lib/cli-actions.js +104 -0
- package/dist/lib/free-install-registration.d.ts +27 -0
- package/dist/lib/free-install-registration.js +52 -0
- package/dist/lib/free-tier-policy.d.ts +5 -1
- package/dist/lib/free-tier-policy.js +16 -1
- package/dist/lib/local-identity.d.ts +44 -0
- package/dist/lib/local-identity.js +81 -0
- package/dist/lib/local-store.d.ts +33 -2
- package/dist/lib/local-store.js +191 -19
- package/dist/lib/paths.d.ts +2 -0
- package/dist/lib/paths.js +6 -0
- package/dist/lib/telemetry.js +28 -2
- package/dist/lib/timeline-insights.d.ts +23 -0
- package/dist/lib/timeline-insights.js +115 -0
- package/dist/lib/upgrade-nudge.d.ts +1 -1
- package/dist/lib/upgrade-nudge.js +8 -1
- package/dist/local-identity.test.d.ts +1 -0
- package/dist/local-identity.test.js +29 -0
- package/dist/local-store.test.js +34 -0
- package/dist/scope.d.ts +1 -1
- package/dist/scope.js +56 -2
- package/dist/scope.test.js +17 -0
- package/dist/timeline-insights.test.d.ts +1 -0
- package/dist/timeline-insights.test.js +85 -0
- 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
|
|
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
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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
|
-
|
|
427
|
+
paths: touchedPaths,
|
|
290
428
|
wroteFile: !input.dryRun,
|
|
291
|
-
content: next,
|
|
292
429
|
};
|
|
293
430
|
}
|
package/dist/install.test.js
CHANGED
|
@@ -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, /
|
|
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;
|