@testdriverai/agent 7.9.103-canary → 7.9.104-canary
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/agent/interface.js +7 -1
- package/agent/lib/commands.js +8 -6
- package/agent/lib/system.js +60 -6
- package/docs/docs.json +16 -1
- package/docs/v7/ai/agent.mdx +72 -0
- package/docs/v7/ai/mcp.mdx +228 -0
- package/docs/v7/ai/skills.mdx +73 -0
- package/docs/v7/find.mdx +2 -0
- package/interfaces/cli/commands/init.js +81 -2
- package/lib/init-project.js +57 -28
- package/lib/install-clients.js +470 -0
- package/mcp-server/dist/server.mjs +245 -66
- package/mcp-server/src/server.ts +250 -32
- package/package.json +1 -1
- package/sdk.js +14 -12
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-client installer for TestDriver.
|
|
3
|
+
*
|
|
4
|
+
* Wires the TestDriver MCP server, the test-creator agent, and the skills into
|
|
5
|
+
* the supported AI coding clients, each of which stores its configuration in a
|
|
6
|
+
* different place and format.
|
|
7
|
+
*
|
|
8
|
+
* The CLIENTS registry below is the single source of truth. Each entry knows:
|
|
9
|
+
* - where that client reads its MCP server config (path + JSON/TOML key),
|
|
10
|
+
* - where it reads project instructions / custom agents,
|
|
11
|
+
* - whether it has a native "skills" concept (a folder of SKILL.md files).
|
|
12
|
+
*
|
|
13
|
+
* Web-based clients (Lovable, Replit, v0) cannot be configured by writing local
|
|
14
|
+
* files, so they are marked `type: "web"` and `installClient` returns guidance
|
|
15
|
+
* instead of writing anything.
|
|
16
|
+
*
|
|
17
|
+
* All writers are MERGE-SAFE and IDEMPOTENT: existing config is preserved and
|
|
18
|
+
* re-running install does not duplicate the TestDriver entry.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const fs = require("fs");
|
|
22
|
+
const path = require("path");
|
|
23
|
+
const os = require("os");
|
|
24
|
+
|
|
25
|
+
const SERVER_NAME = "testdriver";
|
|
26
|
+
|
|
27
|
+
// Canonical stdio launch for the MCP server. `npx -p testdriverai testdriverai-mcp`
|
|
28
|
+
// matches the bin defined in package.json and the existing .vscode/mcp.json.
|
|
29
|
+
const MCP_COMMAND = "npx";
|
|
30
|
+
const MCP_ARGS = ["-p", "testdriverai", "testdriverai-mcp"];
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Build the per-client MCP server object. VS Code wants an explicit `type`,
|
|
34
|
+
* and uses an `${input:...}` reference for the key so it prompts; everyone else
|
|
35
|
+
* takes the API key from the environment via .env.
|
|
36
|
+
*/
|
|
37
|
+
function mcpServerEntry({ apiKeyRef } = {}) {
|
|
38
|
+
return {
|
|
39
|
+
command: MCP_COMMAND,
|
|
40
|
+
args: [...MCP_ARGS],
|
|
41
|
+
env: {
|
|
42
|
+
TD_API_KEY: apiKeyRef || "${TD_API_KEY}",
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// --- merge-safe JSON helpers -------------------------------------------------
|
|
48
|
+
|
|
49
|
+
function readJson(file) {
|
|
50
|
+
if (!fs.existsSync(file)) return null;
|
|
51
|
+
try {
|
|
52
|
+
const raw = fs.readFileSync(file, "utf8").trim();
|
|
53
|
+
if (!raw) return {};
|
|
54
|
+
return JSON.parse(raw);
|
|
55
|
+
} catch {
|
|
56
|
+
return undefined; // signals "exists but unparseable"
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function writeJson(file, obj) {
|
|
61
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
62
|
+
fs.writeFileSync(file, JSON.stringify(obj, null, 2) + "\n");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Merge the TestDriver server into a JSON MCP config under the given top-level
|
|
67
|
+
* key (e.g. "mcpServers", "servers", "context_servers"). Returns a status
|
|
68
|
+
* string. Does not clobber sibling servers or unrelated keys.
|
|
69
|
+
*/
|
|
70
|
+
function mergeJsonMcp(file, key, serverEntry, { inputs } = {}) {
|
|
71
|
+
const existing = readJson(file);
|
|
72
|
+
if (existing === undefined) {
|
|
73
|
+
return { status: "error", message: `Existing ${path.basename(file)} is not valid JSON; left untouched` };
|
|
74
|
+
}
|
|
75
|
+
const config = existing || {};
|
|
76
|
+
config[key] = config[key] || {};
|
|
77
|
+
|
|
78
|
+
const already = !!config[key][SERVER_NAME];
|
|
79
|
+
config[key][SERVER_NAME] = serverEntry;
|
|
80
|
+
|
|
81
|
+
// VS Code: register the secret prompt input once.
|
|
82
|
+
if (inputs) {
|
|
83
|
+
config.inputs = config.inputs || [];
|
|
84
|
+
for (const input of inputs) {
|
|
85
|
+
if (!config.inputs.some((i) => i.id === input.id)) {
|
|
86
|
+
config.inputs.push(input);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
writeJson(file, config);
|
|
92
|
+
return { status: already ? "updated" : "created", file };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// --- TOML (Codex) ------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Append a `[mcp_servers.testdriver]` table to a Codex config.toml if one is not
|
|
99
|
+
* already present. We avoid a TOML parser (none is bundled) and instead do a
|
|
100
|
+
* presence check + append, which is safe because we never edit existing tables.
|
|
101
|
+
*/
|
|
102
|
+
function mergeTomlMcp(file) {
|
|
103
|
+
let content = "";
|
|
104
|
+
if (fs.existsSync(file)) {
|
|
105
|
+
content = fs.readFileSync(file, "utf8");
|
|
106
|
+
if (content.includes(`[mcp_servers.${SERVER_NAME}]`)) {
|
|
107
|
+
return { status: "skipped", file, message: "already present" };
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
const argsToml = MCP_ARGS.map((a) => `"${a}"`).join(", ");
|
|
111
|
+
const block =
|
|
112
|
+
`\n[mcp_servers.${SERVER_NAME}]\n` +
|
|
113
|
+
`command = "${MCP_COMMAND}"\n` +
|
|
114
|
+
`args = [${argsToml}]\n` +
|
|
115
|
+
`env = { TD_API_KEY = "\${TD_API_KEY}" }\n`;
|
|
116
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
117
|
+
fs.writeFileSync(file, (content.trimEnd() + "\n" + block).replace(/^\n+/, ""));
|
|
118
|
+
return { status: "created", file };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// --- instructions + skills file helpers --------------------------------------
|
|
122
|
+
|
|
123
|
+
const AGENT_REF_BLOCK =
|
|
124
|
+
"## TestDriver\n\n" +
|
|
125
|
+
"This project uses [TestDriver](https://testdriver.ai) for AI-driven end-to-end testing.\n" +
|
|
126
|
+
"When asked to write, debug, or run UI tests, act as the TestDriver test-creator agent:\n" +
|
|
127
|
+
"drive the app through the TestDriver MCP tools (`session_start`, `find`, `click`, `type`,\n" +
|
|
128
|
+
"`assert`, `check`, ...), write the generated code to the test file after each step, and run\n" +
|
|
129
|
+
"the test with `vitest run` until it passes. The full agent definition lives in\n" +
|
|
130
|
+
"`.github/agents/testdriver.agent.md` and the skills in `.github/skills/`.\n";
|
|
131
|
+
|
|
132
|
+
const AGENT_MARKER = "<!-- testdriver:agent -->";
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Append a TestDriver reference block to a portable instructions file
|
|
136
|
+
* (AGENTS.md / CLAUDE.md / replit.md / .windsurfrules). Idempotent via marker.
|
|
137
|
+
*/
|
|
138
|
+
function appendInstructions(file) {
|
|
139
|
+
let content = "";
|
|
140
|
+
if (fs.existsSync(file)) {
|
|
141
|
+
content = fs.readFileSync(file, "utf8");
|
|
142
|
+
if (content.includes(AGENT_MARKER)) {
|
|
143
|
+
return { status: "skipped", file, message: "already present" };
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
const block = `${AGENT_MARKER}\n${AGENT_REF_BLOCK}${AGENT_MARKER}\n`;
|
|
147
|
+
const sep = content && !content.endsWith("\n\n") ? "\n\n" : "";
|
|
148
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
149
|
+
fs.writeFileSync(file, content + sep + block);
|
|
150
|
+
return { status: content ? "updated" : "created", file };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Copy SKILL.md folders from the source skills dir into a client's native
|
|
155
|
+
* skills directory. Returns the count copied.
|
|
156
|
+
*/
|
|
157
|
+
function copySkills(skillsSource, destDir) {
|
|
158
|
+
if (!skillsSource || !fs.existsSync(skillsSource)) return 0;
|
|
159
|
+
fs.mkdirSync(destDir, { recursive: true });
|
|
160
|
+
let count = 0;
|
|
161
|
+
for (const entry of fs.readdirSync(skillsSource)) {
|
|
162
|
+
const src = path.join(skillsSource, entry);
|
|
163
|
+
if (!fs.statSync(src).isDirectory()) continue;
|
|
164
|
+
const skillFile = path.join(src, "SKILL.md");
|
|
165
|
+
if (!fs.existsSync(skillFile)) continue;
|
|
166
|
+
const destSkillDir = path.join(destDir, entry);
|
|
167
|
+
fs.mkdirSync(destSkillDir, { recursive: true });
|
|
168
|
+
fs.copyFileSync(skillFile, path.join(destSkillDir, "SKILL.md"));
|
|
169
|
+
count++;
|
|
170
|
+
}
|
|
171
|
+
return count;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Copy the agent markdown into a client's agents directory, renaming to the
|
|
176
|
+
* client's expected suffix.
|
|
177
|
+
*/
|
|
178
|
+
function copyAgent(agentSource, destDir, suffix) {
|
|
179
|
+
if (!agentSource || !fs.existsSync(agentSource)) return 0;
|
|
180
|
+
fs.mkdirSync(destDir, { recursive: true });
|
|
181
|
+
let count = 0;
|
|
182
|
+
for (const file of fs.readdirSync(agentSource).filter((f) => f.endsWith(".md"))) {
|
|
183
|
+
const name = file.replace(/\.md$/, "");
|
|
184
|
+
fs.copyFileSync(
|
|
185
|
+
path.join(agentSource, file),
|
|
186
|
+
path.join(destDir, `${name}${suffix}`),
|
|
187
|
+
);
|
|
188
|
+
count++;
|
|
189
|
+
}
|
|
190
|
+
return count;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// --- client registry ---------------------------------------------------------
|
|
194
|
+
|
|
195
|
+
const HOME = os.homedir();
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Each client exposes an `install({ targetDir, apiKey, agentSource, skillsSource })`
|
|
199
|
+
* that performs the native wiring and returns an array of step results.
|
|
200
|
+
*/
|
|
201
|
+
const CLIENTS = {
|
|
202
|
+
"claude-code": {
|
|
203
|
+
label: "Claude Code (CLI)",
|
|
204
|
+
type: "local",
|
|
205
|
+
install({ targetDir, agentSource, skillsSource }) {
|
|
206
|
+
const steps = [];
|
|
207
|
+
steps.push(
|
|
208
|
+
mergeJsonMcp(path.join(targetDir, ".mcp.json"), "mcpServers", {
|
|
209
|
+
type: "stdio",
|
|
210
|
+
...mcpServerEntry(),
|
|
211
|
+
}),
|
|
212
|
+
);
|
|
213
|
+
const skills = copySkills(skillsSource, path.join(targetDir, ".claude", "skills"));
|
|
214
|
+
if (skills) steps.push({ status: "created", message: `${skills} skills → .claude/skills/` });
|
|
215
|
+
const agents = copyAgent(agentSource, path.join(targetDir, ".claude", "agents"), ".md");
|
|
216
|
+
if (agents) steps.push({ status: "created", message: `agent → .claude/agents/` });
|
|
217
|
+
return steps;
|
|
218
|
+
},
|
|
219
|
+
},
|
|
220
|
+
|
|
221
|
+
"claude-desktop": {
|
|
222
|
+
label: "Claude Desktop",
|
|
223
|
+
type: "local",
|
|
224
|
+
install() {
|
|
225
|
+
// Claude Desktop has a single, OS-specific, non-project config file.
|
|
226
|
+
const file =
|
|
227
|
+
process.platform === "darwin"
|
|
228
|
+
? path.join(HOME, "Library", "Application Support", "Claude", "claude_desktop_config.json")
|
|
229
|
+
: process.platform === "win32"
|
|
230
|
+
? path.join(process.env.APPDATA || path.join(HOME, "AppData", "Roaming"), "Claude", "claude_desktop_config.json")
|
|
231
|
+
: path.join(HOME, ".config", "Claude", "claude_desktop_config.json");
|
|
232
|
+
return [mergeJsonMcp(file, "mcpServers", mcpServerEntry())];
|
|
233
|
+
},
|
|
234
|
+
},
|
|
235
|
+
|
|
236
|
+
cursor: {
|
|
237
|
+
label: "Cursor",
|
|
238
|
+
type: "local",
|
|
239
|
+
install({ targetDir }) {
|
|
240
|
+
const steps = [];
|
|
241
|
+
steps.push(
|
|
242
|
+
mergeJsonMcp(path.join(targetDir, ".cursor", "mcp.json"), "mcpServers", {
|
|
243
|
+
type: "stdio",
|
|
244
|
+
...mcpServerEntry(),
|
|
245
|
+
}),
|
|
246
|
+
);
|
|
247
|
+
// Cursor has no native skills; fold the agent reference into a rule.
|
|
248
|
+
// Rules MUST use the .mdc extension.
|
|
249
|
+
const ruleFile = path.join(targetDir, ".cursor", "rules", "testdriver.mdc");
|
|
250
|
+
if (!fs.existsSync(ruleFile)) {
|
|
251
|
+
fs.mkdirSync(path.dirname(ruleFile), { recursive: true });
|
|
252
|
+
const rule =
|
|
253
|
+
"---\n" +
|
|
254
|
+
"description: TestDriver AI end-to-end testing agent\n" +
|
|
255
|
+
"alwaysApply: false\n" +
|
|
256
|
+
"---\n\n" +
|
|
257
|
+
AGENT_REF_BLOCK;
|
|
258
|
+
fs.writeFileSync(ruleFile, rule);
|
|
259
|
+
steps.push({ status: "created", file: ruleFile });
|
|
260
|
+
} else {
|
|
261
|
+
steps.push({ status: "skipped", file: ruleFile, message: "already present" });
|
|
262
|
+
}
|
|
263
|
+
return steps;
|
|
264
|
+
},
|
|
265
|
+
},
|
|
266
|
+
|
|
267
|
+
vscode: {
|
|
268
|
+
label: "VS Code (GitHub Copilot)",
|
|
269
|
+
type: "local",
|
|
270
|
+
install({ targetDir, agentSource }) {
|
|
271
|
+
const steps = [];
|
|
272
|
+
// VS Code uses `servers` (not mcpServers) and an `inputs` prompt for secrets.
|
|
273
|
+
steps.push(
|
|
274
|
+
mergeJsonMcp(
|
|
275
|
+
path.join(targetDir, ".vscode", "mcp.json"),
|
|
276
|
+
"servers",
|
|
277
|
+
{ type: "stdio", ...mcpServerEntry({ apiKeyRef: "${input:testdriver-api-key}" }) },
|
|
278
|
+
{
|
|
279
|
+
inputs: [
|
|
280
|
+
{
|
|
281
|
+
type: "promptString",
|
|
282
|
+
id: "testdriver-api-key",
|
|
283
|
+
description: "TestDriver API Key From https://console.testdriver.ai/team",
|
|
284
|
+
password: true,
|
|
285
|
+
},
|
|
286
|
+
],
|
|
287
|
+
},
|
|
288
|
+
),
|
|
289
|
+
);
|
|
290
|
+
const agents = copyAgent(agentSource, path.join(targetDir, ".github", "agents"), ".agent.md");
|
|
291
|
+
if (agents) steps.push({ status: "created", message: "agent → .github/agents/" });
|
|
292
|
+
return steps;
|
|
293
|
+
},
|
|
294
|
+
},
|
|
295
|
+
|
|
296
|
+
windsurf: {
|
|
297
|
+
label: "Windsurf",
|
|
298
|
+
type: "local",
|
|
299
|
+
install({ targetDir }) {
|
|
300
|
+
const steps = [];
|
|
301
|
+
// Windsurf MCP config is global-only.
|
|
302
|
+
steps.push(
|
|
303
|
+
mergeJsonMcp(
|
|
304
|
+
path.join(HOME, ".codeium", "windsurf", "mcp_config.json"),
|
|
305
|
+
"mcpServers",
|
|
306
|
+
mcpServerEntry(),
|
|
307
|
+
),
|
|
308
|
+
);
|
|
309
|
+
// Project rules: per-topic markdown under .windsurf/rules/.
|
|
310
|
+
const ruleFile = path.join(targetDir, ".windsurf", "rules", "testdriver.md");
|
|
311
|
+
if (!fs.existsSync(ruleFile)) {
|
|
312
|
+
fs.mkdirSync(path.dirname(ruleFile), { recursive: true });
|
|
313
|
+
fs.writeFileSync(ruleFile, AGENT_REF_BLOCK);
|
|
314
|
+
steps.push({ status: "created", file: ruleFile });
|
|
315
|
+
} else {
|
|
316
|
+
steps.push({ status: "skipped", file: ruleFile, message: "already present" });
|
|
317
|
+
}
|
|
318
|
+
return steps;
|
|
319
|
+
},
|
|
320
|
+
},
|
|
321
|
+
|
|
322
|
+
codex: {
|
|
323
|
+
label: "Codex (OpenAI CLI)",
|
|
324
|
+
type: "local",
|
|
325
|
+
install({ targetDir }) {
|
|
326
|
+
const steps = [];
|
|
327
|
+
// Codex uses TOML and a global config file.
|
|
328
|
+
steps.push(mergeTomlMcp(path.join(HOME, ".codex", "config.toml")));
|
|
329
|
+
// Instructions via portable AGENTS.md.
|
|
330
|
+
steps.push(appendInstructions(path.join(targetDir, "AGENTS.md")));
|
|
331
|
+
return steps;
|
|
332
|
+
},
|
|
333
|
+
},
|
|
334
|
+
|
|
335
|
+
zed: {
|
|
336
|
+
label: "Zed",
|
|
337
|
+
type: "local",
|
|
338
|
+
install({ targetDir, skillsSource }) {
|
|
339
|
+
const steps = [];
|
|
340
|
+
// Zed: project settings, `context_servers` key.
|
|
341
|
+
steps.push(
|
|
342
|
+
mergeJsonMcp(path.join(targetDir, ".zed", "settings.json"), "context_servers", mcpServerEntry()),
|
|
343
|
+
);
|
|
344
|
+
// Zed has native skills under <worktree>/.agents/skills/.
|
|
345
|
+
const skills = copySkills(skillsSource, path.join(targetDir, ".agents", "skills"));
|
|
346
|
+
if (skills) steps.push({ status: "created", message: `${skills} skills → .agents/skills/` });
|
|
347
|
+
steps.push(appendInstructions(path.join(targetDir, ".rules")));
|
|
348
|
+
return steps;
|
|
349
|
+
},
|
|
350
|
+
},
|
|
351
|
+
|
|
352
|
+
// --- web clients: documented, not auto-installable -------------------------
|
|
353
|
+
|
|
354
|
+
lovable: {
|
|
355
|
+
label: "Lovable",
|
|
356
|
+
type: "web",
|
|
357
|
+
install({ targetDir, skillsSource }) {
|
|
358
|
+
// Lovable reads AGENTS.md/SKILL.md from the connected GitHub repo, and the
|
|
359
|
+
// MCP server must be added via the Lovable web UI.
|
|
360
|
+
const steps = [];
|
|
361
|
+
steps.push(appendInstructions(path.join(targetDir, "AGENTS.md")));
|
|
362
|
+
const skills = copySkills(skillsSource, path.join(targetDir, ".lovable", "skills"));
|
|
363
|
+
if (skills) steps.push({ status: "created", message: `${skills} skills → .lovable/skills/` });
|
|
364
|
+
steps.push({
|
|
365
|
+
status: "manual",
|
|
366
|
+
message: "Add the TestDriver MCP server in Lovable: Settings → MCP → add remote/stdio server",
|
|
367
|
+
});
|
|
368
|
+
return steps;
|
|
369
|
+
},
|
|
370
|
+
},
|
|
371
|
+
|
|
372
|
+
replit: {
|
|
373
|
+
label: "Replit",
|
|
374
|
+
type: "web",
|
|
375
|
+
install({ targetDir }) {
|
|
376
|
+
const steps = [];
|
|
377
|
+
// Replit reads replit.md at the project root.
|
|
378
|
+
steps.push(appendInstructions(path.join(targetDir, "replit.md")));
|
|
379
|
+
steps.push({
|
|
380
|
+
status: "manual",
|
|
381
|
+
message: "Add the TestDriver MCP server in Replit: Tools → Integrations → MCP",
|
|
382
|
+
});
|
|
383
|
+
return steps;
|
|
384
|
+
},
|
|
385
|
+
},
|
|
386
|
+
|
|
387
|
+
v0: {
|
|
388
|
+
label: "v0 (Vercel)",
|
|
389
|
+
type: "web",
|
|
390
|
+
install() {
|
|
391
|
+
// v0 has no repo-file ingestion; everything is UI-driven.
|
|
392
|
+
return [
|
|
393
|
+
{
|
|
394
|
+
status: "manual",
|
|
395
|
+
message:
|
|
396
|
+
"Configure v0 in the web UI: add MCP at v0.app/chat/settings/mcp-connections, " +
|
|
397
|
+
"and paste the agent guidance into Instructions (the + in the prompt bar).",
|
|
398
|
+
},
|
|
399
|
+
];
|
|
400
|
+
},
|
|
401
|
+
},
|
|
402
|
+
};
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Resolve a list of client ids, accepting "all" and ignoring unknown ids.
|
|
406
|
+
* @returns {{ valid: string[], unknown: string[] }}
|
|
407
|
+
*/
|
|
408
|
+
function resolveClientIds(input) {
|
|
409
|
+
const ids = Array.isArray(input)
|
|
410
|
+
? input
|
|
411
|
+
: String(input || "")
|
|
412
|
+
.split(",")
|
|
413
|
+
.map((s) => s.trim())
|
|
414
|
+
.filter(Boolean);
|
|
415
|
+
if (ids.includes("all")) return { valid: Object.keys(CLIENTS), unknown: [] };
|
|
416
|
+
const valid = [];
|
|
417
|
+
const unknown = [];
|
|
418
|
+
for (const id of ids) {
|
|
419
|
+
if (CLIENTS[id]) valid.push(id);
|
|
420
|
+
else unknown.push(id);
|
|
421
|
+
}
|
|
422
|
+
return { valid, unknown };
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Install TestDriver into a single client.
|
|
427
|
+
* @returns {{ id, label, type, steps: Array }}
|
|
428
|
+
*/
|
|
429
|
+
function installClient(clientId, options) {
|
|
430
|
+
const client = CLIENTS[clientId];
|
|
431
|
+
if (!client) throw new Error(`Unknown client: ${clientId}`);
|
|
432
|
+
const steps = client.install(options) || [];
|
|
433
|
+
return { id: clientId, label: client.label, type: client.type, steps };
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Best-effort detection of which clients already have config in/around the
|
|
438
|
+
* target directory, used to pre-select the interactive picker.
|
|
439
|
+
*/
|
|
440
|
+
function detectClients(targetDir) {
|
|
441
|
+
const present = [];
|
|
442
|
+
const checks = {
|
|
443
|
+
"claude-code": [path.join(targetDir, ".mcp.json"), path.join(targetDir, ".claude")],
|
|
444
|
+
cursor: [path.join(targetDir, ".cursor")],
|
|
445
|
+
vscode: [path.join(targetDir, ".vscode")],
|
|
446
|
+
zed: [path.join(targetDir, ".zed")],
|
|
447
|
+
windsurf: [path.join(targetDir, ".windsurf"), path.join(HOME, ".codeium", "windsurf")],
|
|
448
|
+
codex: [path.join(HOME, ".codex")],
|
|
449
|
+
"claude-desktop": [
|
|
450
|
+
path.join(HOME, "Library", "Application Support", "Claude"),
|
|
451
|
+
path.join(HOME, ".config", "Claude"),
|
|
452
|
+
],
|
|
453
|
+
};
|
|
454
|
+
for (const [id, paths] of Object.entries(checks)) {
|
|
455
|
+
if (paths.some((p) => fs.existsSync(p))) present.push(id);
|
|
456
|
+
}
|
|
457
|
+
return present;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
module.exports = {
|
|
461
|
+
CLIENTS,
|
|
462
|
+
SERVER_NAME,
|
|
463
|
+
installClient,
|
|
464
|
+
resolveClientIds,
|
|
465
|
+
detectClients,
|
|
466
|
+
// exported for testing
|
|
467
|
+
mergeJsonMcp,
|
|
468
|
+
mergeTomlMcp,
|
|
469
|
+
appendInstructions,
|
|
470
|
+
};
|