@askthew/mcp-plugin 0.2.8 → 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 +65 -16
- 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 +412 -18
- 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 +59 -13
- package/dist/index.js +1736 -103
- package/dist/index.test.d.ts +1 -0
- package/dist/index.test.js +952 -0
- package/dist/install.d.ts +56 -1
- package/dist/install.js +171 -26
- package/dist/install.test.d.ts +1 -0
- package/dist/install.test.js +297 -0
- package/dist/lib/auth-magic-link.d.ts +22 -0
- package/dist/lib/auth-magic-link.js +43 -0
- 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 +23 -0
- package/dist/lib/free-tier-policy.js +68 -0
- package/dist/lib/local-identity.d.ts +44 -0
- package/dist/lib/local-identity.js +81 -0
- package/dist/lib/local-store.d.ts +130 -0
- package/dist/lib/local-store.js +595 -0
- package/dist/lib/loopback-auth.d.ts +8 -0
- package/dist/lib/loopback-auth.js +30 -0
- package/dist/lib/paths.d.ts +9 -0
- package/dist/lib/paths.js +50 -0
- package/dist/lib/telemetry.d.ts +25 -0
- package/dist/lib/telemetry.js +159 -0
- package/dist/lib/timeline-insights.d.ts +23 -0
- package/dist/lib/timeline-insights.js +115 -0
- package/dist/lib/tip-engine.d.ts +18 -0
- package/dist/lib/tip-engine.js +237 -0
- package/dist/lib/upgrade-nudge.d.ts +19 -0
- package/dist/lib/upgrade-nudge.js +37 -0
- package/dist/lib/upgrade-sync.d.ts +38 -0
- package/dist/lib/upgrade-sync.js +60 -0
- package/dist/local-identity.test.d.ts +1 -0
- package/dist/local-identity.test.js +29 -0
- package/dist/local-store.test.d.ts +1 -0
- package/dist/local-store.test.js +71 -0
- package/dist/scope.d.ts +1 -2
- package/dist/scope.js +56 -8
- package/dist/scope.test.d.ts +1 -0
- package/dist/scope.test.js +49 -0
- package/dist/timeline-insights.test.d.ts +1 -0
- package/dist/timeline-insights.test.js +85 -0
- package/dist/tip-engine.test.d.ts +1 -0
- package/dist/tip-engine.test.js +51 -0
- package/package.json +7 -10
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { formatInstallCommand, installBehaviorInstructions, installHostConfig, mergeHostSettings, resolveSettingsPath, sendInstallHeartbeat, uninstallBehaviorInstructions, uninstallHostConfig, verificationNextStep, } from "./install.js";
|
|
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");
|
|
10
|
+
const merged = mergeHostSettings({
|
|
11
|
+
existingSettings: {
|
|
12
|
+
theme: "dark",
|
|
13
|
+
mcpServers: {
|
|
14
|
+
github: {
|
|
15
|
+
command: "github-mcp",
|
|
16
|
+
},
|
|
17
|
+
askthew: {
|
|
18
|
+
command: "old-command",
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
hostType: "codex",
|
|
23
|
+
token: "token-123",
|
|
24
|
+
apiUrl: "https://askthew.example.com",
|
|
25
|
+
serverName: "askthew_workspace_a",
|
|
26
|
+
cwd: tempProject,
|
|
27
|
+
});
|
|
28
|
+
assert.deepEqual(merged.theme, "dark");
|
|
29
|
+
assert.deepEqual(Object.keys(merged.mcpServers ?? {}).sort(), ["askthew_workspace_a", "github"]);
|
|
30
|
+
assert.deepEqual(merged.mcpServers.askthew_workspace_a, {
|
|
31
|
+
command: "npx",
|
|
32
|
+
args: ["-y", "--prefer-online", "@askthew/mcp-plugin@latest"],
|
|
33
|
+
env: {
|
|
34
|
+
ASKTHEW_INSTALL_TOKEN: "token-123",
|
|
35
|
+
ASKTHEW_API_URL: "https://askthew.example.com",
|
|
36
|
+
ASKTHEW_HOST_TYPE: "codex",
|
|
37
|
+
ASKTHEW_SERVER_NAME: "askthew_workspace_a",
|
|
38
|
+
ASKTHEW_REPO_NAME: path.basename(tempProject),
|
|
39
|
+
ASKTHEW_REPO_ROOT: tempProject,
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
fs.rmSync(tempProject, { recursive: true, force: true });
|
|
43
|
+
});
|
|
44
|
+
test("installHostConfig writes Claude Code local MCP settings and stays idempotent", () => {
|
|
45
|
+
const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "askthew-mcp-install-"));
|
|
46
|
+
const tempProject = fs.mkdtempSync(path.join(os.tmpdir(), "askthew-claude-project-"));
|
|
47
|
+
const settingsPath = resolveSettingsPath({
|
|
48
|
+
hostType: "claude_code",
|
|
49
|
+
homeDirectory: tempHome,
|
|
50
|
+
});
|
|
51
|
+
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
|
|
52
|
+
fs.writeFileSync(settingsPath, JSON.stringify({
|
|
53
|
+
projects: {
|
|
54
|
+
[tempProject]: {
|
|
55
|
+
mcpServers: {
|
|
56
|
+
github: {
|
|
57
|
+
command: "github-mcp",
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
}, null, 2), "utf8");
|
|
63
|
+
const first = installHostConfig({
|
|
64
|
+
hostType: "claude_code",
|
|
65
|
+
token: "token-456",
|
|
66
|
+
apiUrl: "https://askthew.example.com",
|
|
67
|
+
serverName: "askthew_workspace_a",
|
|
68
|
+
clientId: "claude_code",
|
|
69
|
+
clientLabel: "Claude Code",
|
|
70
|
+
homeDirectory: tempHome,
|
|
71
|
+
cwd: tempProject,
|
|
72
|
+
});
|
|
73
|
+
const afterFirst = fs.readFileSync(settingsPath, "utf8");
|
|
74
|
+
const second = installHostConfig({
|
|
75
|
+
hostType: "claude_code",
|
|
76
|
+
token: "token-456",
|
|
77
|
+
apiUrl: "https://askthew.example.com",
|
|
78
|
+
serverName: "askthew_workspace_a",
|
|
79
|
+
clientId: "claude_code",
|
|
80
|
+
clientLabel: "Claude Code",
|
|
81
|
+
homeDirectory: tempHome,
|
|
82
|
+
cwd: tempProject,
|
|
83
|
+
});
|
|
84
|
+
const afterSecond = fs.readFileSync(settingsPath, "utf8");
|
|
85
|
+
const parsed = JSON.parse(afterSecond);
|
|
86
|
+
const projectServers = parsed.projects[tempProject].mcpServers;
|
|
87
|
+
assert.equal(first.settingsPath, settingsPath);
|
|
88
|
+
assert.equal(second.settingsPath, settingsPath);
|
|
89
|
+
assert.equal(afterFirst, afterSecond);
|
|
90
|
+
assert.deepEqual(Object.keys(projectServers ?? {}).sort(), ["askthew_workspace_a", "github"]);
|
|
91
|
+
assert.equal(projectServers.askthew_workspace_a.env.ASKTHEW_INSTALL_TOKEN, "token-456");
|
|
92
|
+
assert.equal(projectServers.askthew_workspace_a.env.ASKTHEW_CLIENT_ID, "claude_code");
|
|
93
|
+
assert.equal(projectServers.askthew_workspace_a.env.ASKTHEW_CLIENT_LABEL, "Claude Code");
|
|
94
|
+
fs.rmSync(tempHome, { recursive: true, force: true });
|
|
95
|
+
fs.rmSync(tempProject, { recursive: true, force: true });
|
|
96
|
+
});
|
|
97
|
+
test("installHostConfig writes Codex MCP servers to config.toml", () => {
|
|
98
|
+
const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "askthew-mcp-multi-workspace-"));
|
|
99
|
+
const settingsPath = resolveSettingsPath({
|
|
100
|
+
hostType: "codex",
|
|
101
|
+
homeDirectory: tempHome,
|
|
102
|
+
});
|
|
103
|
+
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
|
|
104
|
+
fs.writeFileSync(settingsPath, [
|
|
105
|
+
'model = "gpt-5"',
|
|
106
|
+
"",
|
|
107
|
+
"[mcp_servers.github]",
|
|
108
|
+
'command = "github-mcp"',
|
|
109
|
+
"",
|
|
110
|
+
].join("\n"), "utf8");
|
|
111
|
+
installHostConfig({
|
|
112
|
+
hostType: "codex",
|
|
113
|
+
token: "token-a",
|
|
114
|
+
apiUrl: "https://askthew.example.com",
|
|
115
|
+
serverName: "askthew_workspace_a",
|
|
116
|
+
homeDirectory: tempHome,
|
|
117
|
+
});
|
|
118
|
+
installHostConfig({
|
|
119
|
+
hostType: "codex",
|
|
120
|
+
token: "token-b",
|
|
121
|
+
apiUrl: "https://askthew.example.com",
|
|
122
|
+
serverName: "askthew_workspace_b",
|
|
123
|
+
homeDirectory: tempHome,
|
|
124
|
+
});
|
|
125
|
+
const toml = fs.readFileSync(settingsPath, "utf8");
|
|
126
|
+
assert.match(toml, /\[mcp_servers\.github\]/);
|
|
127
|
+
assert.match(toml, /\[mcp_servers\.askthew_workspace_a\]/);
|
|
128
|
+
assert.match(toml, /\[mcp_servers\.askthew_workspace_b\]/);
|
|
129
|
+
assert.match(toml, /ASKTHEW_INSTALL_TOKEN = "token-a"/);
|
|
130
|
+
assert.match(toml, /ASKTHEW_INSTALL_TOKEN = "token-b"/);
|
|
131
|
+
fs.rmSync(tempHome, { recursive: true, force: true });
|
|
132
|
+
});
|
|
133
|
+
test("installHostConfig dry run returns merged JSON without writing a file", () => {
|
|
134
|
+
const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "askthew-mcp-dry-run-"));
|
|
135
|
+
const settingsPath = resolveSettingsPath({
|
|
136
|
+
hostType: "codex",
|
|
137
|
+
homeDirectory: tempHome,
|
|
138
|
+
});
|
|
139
|
+
const result = installHostConfig({
|
|
140
|
+
hostType: "codex",
|
|
141
|
+
token: "token-789",
|
|
142
|
+
apiUrl: "https://askthew.example.com",
|
|
143
|
+
serverName: "askthew_workspace_a",
|
|
144
|
+
homeDirectory: tempHome,
|
|
145
|
+
dryRun: true,
|
|
146
|
+
});
|
|
147
|
+
assert.equal(result.wroteFile, false);
|
|
148
|
+
assert.equal(fs.existsSync(settingsPath), false);
|
|
149
|
+
assert.match(result.json, /ASKTHEW_INSTALL_TOKEN/);
|
|
150
|
+
assert.match(result.json, /\[mcp_servers\.askthew_workspace_a\]/);
|
|
151
|
+
fs.rmSync(tempHome, { recursive: true, force: true });
|
|
152
|
+
});
|
|
153
|
+
test("installHostConfig writes Cursor global mcp.json", () => {
|
|
154
|
+
const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "askthew-cursor-mcp-"));
|
|
155
|
+
const settingsPath = resolveSettingsPath({
|
|
156
|
+
hostType: "cursor",
|
|
157
|
+
homeDirectory: tempHome,
|
|
158
|
+
});
|
|
159
|
+
installHostConfig({
|
|
160
|
+
hostType: "cursor",
|
|
161
|
+
token: "token-cursor",
|
|
162
|
+
apiUrl: "https://askthew.example.com",
|
|
163
|
+
serverName: "askthew",
|
|
164
|
+
homeDirectory: tempHome,
|
|
165
|
+
});
|
|
166
|
+
const parsed = JSON.parse(fs.readFileSync(settingsPath, "utf8"));
|
|
167
|
+
assert.deepEqual(Object.keys(parsed.mcpServers ?? {}), ["askthew"]);
|
|
168
|
+
assert.equal(parsed.mcpServers.askthew.command, "npx");
|
|
169
|
+
assert.equal(parsed.mcpServers.askthew.env.ASKTHEW_HOST_TYPE, "cursor");
|
|
170
|
+
fs.rmSync(tempHome, { recursive: true, force: true });
|
|
171
|
+
});
|
|
172
|
+
test("formatInstallCommand emits the one-command guided install form", () => {
|
|
173
|
+
const command = formatInstallCommand({
|
|
174
|
+
hostType: "codex",
|
|
175
|
+
token: "token-abc",
|
|
176
|
+
apiUrl: "https://askthew.example.com",
|
|
177
|
+
serverName: "askthew_workspace_a",
|
|
178
|
+
});
|
|
179
|
+
assert.equal(command, 'npx -y --prefer-online @askthew/mcp-plugin@latest install --host codex --token "token-abc" --api-url "https://askthew.example.com" --server-name "askthew_workspace_a"');
|
|
180
|
+
});
|
|
181
|
+
test("verificationNextStep points users to every-session startup capture", () => {
|
|
182
|
+
const nextStep = verificationNextStep("codex");
|
|
183
|
+
assert.match(nextStep, /Refresh Ask The W/);
|
|
184
|
+
assert.match(nextStep, /every new Codex session in this repo/);
|
|
185
|
+
assert.match(nextStep, /before plan mode or exploration/);
|
|
186
|
+
assert.match(nextStep, /list_mcp_resources\/list_mcp_resource_templates may be empty/);
|
|
187
|
+
});
|
|
188
|
+
test("sendInstallHeartbeat pings Ask The W after config install", async () => {
|
|
189
|
+
const calls = [];
|
|
190
|
+
const ok = await sendInstallHeartbeat({
|
|
191
|
+
hostType: "codex",
|
|
192
|
+
token: "token-abc",
|
|
193
|
+
apiUrl: "https://askthew.example.com/",
|
|
194
|
+
serverName: "askthew",
|
|
195
|
+
cwd: process.cwd(),
|
|
196
|
+
fetchImpl: async (url, init) => {
|
|
197
|
+
calls.push({
|
|
198
|
+
url: String(url),
|
|
199
|
+
body: JSON.parse(String(init?.body ?? "{}")),
|
|
200
|
+
});
|
|
201
|
+
return new Response("{}", { status: 200 });
|
|
202
|
+
},
|
|
203
|
+
});
|
|
204
|
+
assert.equal(ok, true);
|
|
205
|
+
assert.equal(calls[0]?.url, "https://askthew.example.com/api/connectors/mcp/heartbeat");
|
|
206
|
+
assert.equal(calls[0]?.body.installToken, "token-abc");
|
|
207
|
+
assert.equal(calls[0]?.body.hostType, "codex");
|
|
208
|
+
assert.equal(calls[0]?.body.serverName, "askthew");
|
|
209
|
+
assert.equal(calls[0]?.body.clientId, "codex");
|
|
210
|
+
});
|
|
211
|
+
test("installBehaviorInstructions adds persistent Codex tracking rules without clobbering existing instructions", () => {
|
|
212
|
+
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "askthew-agent-instructions-"));
|
|
213
|
+
const claudePath = path.join(tempRoot, "CLAUDE.md");
|
|
214
|
+
const agentsPath = path.join(tempRoot, "AGENTS.md");
|
|
215
|
+
fs.writeFileSync(agentsPath, "# Existing instructions\n\nKeep this section.\n", "utf8");
|
|
216
|
+
const result = installBehaviorInstructions({
|
|
217
|
+
hostType: "codex",
|
|
218
|
+
cwd: tempRoot,
|
|
219
|
+
});
|
|
220
|
+
const claudeContents = fs.readFileSync(claudePath, "utf8");
|
|
221
|
+
const contents = fs.readFileSync(agentsPath, "utf8");
|
|
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/);
|
|
226
|
+
assert.match(contents, /# Existing instructions/);
|
|
227
|
+
assert.match(contents, /Ask The W Plugin/);
|
|
228
|
+
assert.match(contents, /capture_session_signal/);
|
|
229
|
+
assert.match(contents, /At the start of every new Codex session in this repo/);
|
|
230
|
+
assert.match(contents, /before plan mode, exploration, or any normal reply/);
|
|
231
|
+
assert.match(contents, /metadata\.recovered_missed_startup=true/);
|
|
232
|
+
assert.match(contents, /before using tools that write files/);
|
|
233
|
+
fs.rmSync(tempRoot, { recursive: true, force: true });
|
|
234
|
+
});
|
|
235
|
+
test("installBehaviorInstructions creates Cursor rule file", () => {
|
|
236
|
+
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "askthew-cursor-rules-"));
|
|
237
|
+
const result = installBehaviorInstructions({
|
|
238
|
+
hostType: "cursor",
|
|
239
|
+
cwd: tempRoot,
|
|
240
|
+
});
|
|
241
|
+
assert.equal(result.path, path.join(tempRoot, ".cursor", "rules", "askthew.mdc"));
|
|
242
|
+
assert.match(fs.readFileSync(result.path, "utf8"), /alwaysApply: true/);
|
|
243
|
+
assert.match(fs.readFileSync(result.path, "utf8"), /Ask The W Plugin/);
|
|
244
|
+
assert.match(fs.readFileSync(result.path, "utf8"), /At the start of every new Cursor session/);
|
|
245
|
+
assert.match(fs.readFileSync(result.path, "utf8"), /before plan mode, exploration, or any normal reply/);
|
|
246
|
+
assert.match(fs.readFileSync(result.path, "utf8"), /metadata\.recovered_missed_startup=true/);
|
|
247
|
+
fs.rmSync(tempRoot, { recursive: true, force: true });
|
|
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,22 @@
|
|
|
1
|
+
import type { CliCredentials } from "./free-tier-policy.js";
|
|
2
|
+
export interface MagicLinkClientOptions {
|
|
3
|
+
apiUrl?: string;
|
|
4
|
+
fetchImpl?: typeof fetch;
|
|
5
|
+
}
|
|
6
|
+
export declare function requestMagicLinkCode(input: {
|
|
7
|
+
email: string;
|
|
8
|
+
deviceLabel?: string;
|
|
9
|
+
apiUrl?: string;
|
|
10
|
+
fetchImpl?: typeof fetch;
|
|
11
|
+
}): Promise<{
|
|
12
|
+
requestId: string;
|
|
13
|
+
expiresAt: string;
|
|
14
|
+
devCode?: string;
|
|
15
|
+
}>;
|
|
16
|
+
export declare function verifyMagicLinkCode(input: {
|
|
17
|
+
requestId: string;
|
|
18
|
+
code: string;
|
|
19
|
+
apiUrl?: string;
|
|
20
|
+
fetchImpl?: typeof fetch;
|
|
21
|
+
telemetryOptOut?: boolean;
|
|
22
|
+
}): Promise<CliCredentials>;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { credentialsPath, writePrivateJson } from "./paths.js";
|
|
2
|
+
function baseUrl(apiUrl) {
|
|
3
|
+
return (apiUrl?.trim() || process.env.ASKTHEW_API_URL?.trim() || "https://app.askthew.com").replace(/\/$/, "");
|
|
4
|
+
}
|
|
5
|
+
async function requestJson(route, body, options) {
|
|
6
|
+
const fetcher = options.fetchImpl ?? fetch;
|
|
7
|
+
const response = await fetcher(`${baseUrl(options.apiUrl)}${route}`, {
|
|
8
|
+
method: "POST",
|
|
9
|
+
headers: { "Content-Type": "application/json" },
|
|
10
|
+
body: JSON.stringify(body),
|
|
11
|
+
});
|
|
12
|
+
const payload = await response.json().catch(() => null);
|
|
13
|
+
if (!response.ok) {
|
|
14
|
+
const message = payload && typeof payload === "object" && "error" in payload
|
|
15
|
+
? String(payload.error)
|
|
16
|
+
: "Ask The W auth request failed.";
|
|
17
|
+
throw new Error(message);
|
|
18
|
+
}
|
|
19
|
+
return payload;
|
|
20
|
+
}
|
|
21
|
+
export async function requestMagicLinkCode(input) {
|
|
22
|
+
return requestJson("/api/cli/v1/magic-link/request", {
|
|
23
|
+
email: input.email,
|
|
24
|
+
deviceLabel: input.deviceLabel,
|
|
25
|
+
}, input);
|
|
26
|
+
}
|
|
27
|
+
export async function verifyMagicLinkCode(input) {
|
|
28
|
+
const verified = await requestJson("/api/cli/v1/magic-link/verify", {
|
|
29
|
+
requestId: input.requestId,
|
|
30
|
+
code: input.code,
|
|
31
|
+
}, input);
|
|
32
|
+
const credentials = {
|
|
33
|
+
email: verified.email,
|
|
34
|
+
userId: verified.userId,
|
|
35
|
+
cliToken: verified.cliToken,
|
|
36
|
+
cliTokenId: verified.cliTokenId,
|
|
37
|
+
accountStatus: verified.accountStatus,
|
|
38
|
+
apiUrl: input.apiUrl,
|
|
39
|
+
telemetryOptOut: input.telemetryOptOut,
|
|
40
|
+
};
|
|
41
|
+
writePrivateJson(credentialsPath(), credentials);
|
|
42
|
+
return credentials;
|
|
43
|
+
}
|
|
@@ -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;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import { markLocalIdentityRegistered, } from "./local-identity.js";
|
|
3
|
+
function baseUrl(apiUrl) {
|
|
4
|
+
return (apiUrl?.trim() || process.env.ASKTHEW_API_URL?.trim() || "https://app.askthew.com").replace(/\/$/, "");
|
|
5
|
+
}
|
|
6
|
+
function hashClaimCode(code) {
|
|
7
|
+
return crypto.createHash("sha256").update(code.trim()).digest("hex");
|
|
8
|
+
}
|
|
9
|
+
function safeMessage(error) {
|
|
10
|
+
return error instanceof Error ? error.message : "Registration failed.";
|
|
11
|
+
}
|
|
12
|
+
export async function registerFreeInstall(input) {
|
|
13
|
+
const fetcher = input.options?.fetchImpl ?? fetch;
|
|
14
|
+
const response = await fetcher(`${baseUrl(input.options?.apiUrl ?? input.identity.apiUrl)}/api/cli/v1/free-installs/register`, {
|
|
15
|
+
method: "POST",
|
|
16
|
+
headers: { "Content-Type": "application/json" },
|
|
17
|
+
body: JSON.stringify({
|
|
18
|
+
installId: input.identity.installId,
|
|
19
|
+
publicKey: input.identity.publicKey,
|
|
20
|
+
claimCodeHash: hashClaimCode(input.identity.claimCode),
|
|
21
|
+
emailClaim: input.identity.emailClaim,
|
|
22
|
+
deviceLabel: input.deviceLabel,
|
|
23
|
+
repo: input.repo ?? {},
|
|
24
|
+
}),
|
|
25
|
+
});
|
|
26
|
+
const payload = await response.json().catch(() => null);
|
|
27
|
+
if (!response.ok) {
|
|
28
|
+
throw new Error(payload?.error ? String(payload.error) : "Free install registration failed.");
|
|
29
|
+
}
|
|
30
|
+
const registeredAt = payload && typeof payload === "object" && typeof payload.registeredAt === "string"
|
|
31
|
+
? payload.registeredAt
|
|
32
|
+
: new Date().toISOString();
|
|
33
|
+
markLocalIdentityRegistered({ registeredAt });
|
|
34
|
+
return { ok: true, registeredAt };
|
|
35
|
+
}
|
|
36
|
+
export async function tryRegisterFreeInstall(input) {
|
|
37
|
+
try {
|
|
38
|
+
return await registerFreeInstall(input);
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
markLocalIdentityRegistered({ registrationError: safeMessage(error) });
|
|
42
|
+
return { ok: false, error: safeMessage(error) };
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
export function describeFreeIdentity(identity) {
|
|
46
|
+
return [
|
|
47
|
+
`Install ID: ${identity.installId}`,
|
|
48
|
+
identity.emailClaim ? `Email claim: ${identity.emailClaim} (unverified)` : "Email claim: none",
|
|
49
|
+
`Claim code: ${identity.claimCode}`,
|
|
50
|
+
identity.registeredAt ? `Registered: ${identity.registeredAt}` : "Registered: pending",
|
|
51
|
+
].join("\n");
|
|
52
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { type LocalInstallIdentity } from "./local-identity.js";
|
|
2
|
+
export type McpMode = "paid" | "free" | "free_pending_auth" | "unauthenticated";
|
|
3
|
+
export interface CliCredentials {
|
|
4
|
+
email?: string;
|
|
5
|
+
userId: string;
|
|
6
|
+
cliToken: string;
|
|
7
|
+
cliTokenId: string;
|
|
8
|
+
apiUrl?: string;
|
|
9
|
+
telemetryOptOut?: boolean;
|
|
10
|
+
accountStatus?: "new_dormant" | "existing_active";
|
|
11
|
+
identityKind?: "legacy_token" | "local_install";
|
|
12
|
+
installId?: string;
|
|
13
|
+
localIdentity?: LocalInstallIdentity;
|
|
14
|
+
}
|
|
15
|
+
export interface ModeResolution {
|
|
16
|
+
mode: McpMode;
|
|
17
|
+
installToken?: string;
|
|
18
|
+
cliCredentials?: CliCredentials;
|
|
19
|
+
reason: string;
|
|
20
|
+
}
|
|
21
|
+
export declare function loadCliCredentials(env?: NodeJS.ProcessEnv): CliCredentials | null;
|
|
22
|
+
export declare function resolveMcpMode(env?: NodeJS.ProcessEnv): ModeResolution;
|
|
23
|
+
export declare function isTelemetryOptedOut(env?: NodeJS.ProcessEnv, credentials?: CliCredentials | null): boolean;
|