typegraph-mcp 0.9.0

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/cli.ts ADDED
@@ -0,0 +1,778 @@
1
+ #!/usr/bin/env npx tsx
2
+ /**
3
+ * typegraph-mcp CLI — Setup, verify, and run the TypeGraph MCP server.
4
+ *
5
+ * Usage:
6
+ * typegraph-mcp setup Install typegraph-mcp plugin into the current project
7
+ * typegraph-mcp check Run health checks (12 checks)
8
+ * typegraph-mcp test Run smoke tests (all 14 tools)
9
+ * typegraph-mcp start Start the MCP server (stdin/stdout)
10
+ *
11
+ * Options:
12
+ * --yes Skip confirmation prompts (accept all defaults)
13
+ * --help Show help
14
+ */
15
+
16
+ import * as fs from "node:fs";
17
+ import * as path from "node:path";
18
+ import { execSync } from "node:child_process";
19
+ import * as p from "@clack/prompts";
20
+ import { resolveConfig } from "./config.js";
21
+
22
+ // ─── Types ───────────────────────────────────────────────────────────────────
23
+
24
+ type AgentId = "claude-code" | "cursor" | "codex" | "gemini" | "copilot";
25
+
26
+ interface AgentDef {
27
+ name: string;
28
+ /** Files to include in the plugin directory (agent-specific) */
29
+ pluginFiles: string[];
30
+ /** Agent instruction file to update (null if agent has no instruction file) */
31
+ agentFile: string | null;
32
+ /** Whether this agent discovers skills from .agents/skills/ at project root */
33
+ needsAgentsSkills: boolean;
34
+ /** Detect if this agent is likely in use based on project files */
35
+ detect: (projectRoot: string) => boolean;
36
+ }
37
+
38
+ // ─── Constants ───────────────────────────────────────────────────────────────
39
+
40
+ const AGENT_SNIPPET = `
41
+ ## TypeScript Navigation (typegraph-mcp)
42
+
43
+ Use the \`ts_*\` MCP tools instead of grep/glob for navigating TypeScript code. They resolve through barrel files, re-exports, and project references — returning precise results, not string matches.
44
+
45
+ - **Point queries** (tsserver): \`ts_find_symbol\`, \`ts_definition\`, \`ts_references\`, \`ts_type_info\`, \`ts_navigate_to\`, \`ts_trace_chain\`, \`ts_blast_radius\`, \`ts_module_exports\`
46
+ - **Graph queries** (import graph): \`ts_dependency_tree\`, \`ts_dependents\`, \`ts_import_cycles\`, \`ts_shortest_path\`, \`ts_subgraph\`, \`ts_module_boundary\`
47
+ `.trimStart();
48
+
49
+ const SNIPPET_MARKER = "## TypeScript Navigation (typegraph-mcp)";
50
+
51
+ const PLUGIN_DIR_NAME = "plugins/typegraph-mcp";
52
+
53
+ const AGENT_IDS: AgentId[] = ["claude-code", "cursor", "codex", "gemini", "copilot"];
54
+
55
+ const AGENTS: Record<AgentId, AgentDef> = {
56
+ "claude-code": {
57
+ name: "Claude Code",
58
+ pluginFiles: [
59
+ ".claude-plugin/plugin.json",
60
+ ".mcp.json",
61
+ "hooks/hooks.json",
62
+ "scripts/ensure-deps.sh",
63
+ "commands/check.md",
64
+ "commands/test.md",
65
+ ],
66
+ agentFile: "CLAUDE.md",
67
+ needsAgentsSkills: false,
68
+ detect: (root) =>
69
+ fs.existsSync(path.join(root, "CLAUDE.md")) ||
70
+ fs.existsSync(path.join(root, ".claude")),
71
+ },
72
+ cursor: {
73
+ name: "Cursor",
74
+ pluginFiles: [".cursor-plugin/plugin.json"],
75
+ agentFile: null,
76
+ needsAgentsSkills: false,
77
+ detect: (root) => fs.existsSync(path.join(root, ".cursor")),
78
+ },
79
+ codex: {
80
+ name: "Codex CLI",
81
+ pluginFiles: [],
82
+ agentFile: "AGENTS.md",
83
+ needsAgentsSkills: true,
84
+ detect: (root) => fs.existsSync(path.join(root, "AGENTS.md")),
85
+ },
86
+ gemini: {
87
+ name: "Gemini CLI",
88
+ pluginFiles: ["gemini-extension.json"],
89
+ agentFile: "GEMINI.md",
90
+ needsAgentsSkills: true,
91
+ detect: (root) => fs.existsSync(path.join(root, "GEMINI.md")),
92
+ },
93
+ copilot: {
94
+ name: "GitHub Copilot",
95
+ pluginFiles: [],
96
+ agentFile: ".github/copilot-instructions.md",
97
+ needsAgentsSkills: true,
98
+ detect: (root) =>
99
+ fs.existsSync(path.join(root, ".github/copilot-instructions.md")),
100
+ },
101
+ };
102
+
103
+ /** Core files always installed (server, modules, config, package manifest) */
104
+ const CORE_FILES = [
105
+ "server.ts",
106
+ "module-graph.ts",
107
+ "tsserver-client.ts",
108
+ "graph-queries.ts",
109
+ "config.ts",
110
+ "check.ts",
111
+ "smoke-test.ts",
112
+ "cli.ts",
113
+ "package.json",
114
+ "pnpm-lock.yaml",
115
+ ];
116
+
117
+ /** Skill files inside plugin dir (Claude Code + Cursor discover from skills/) */
118
+ const SKILL_FILES = [
119
+ "skills/tool-selection/SKILL.md",
120
+ "skills/impact-analysis/SKILL.md",
121
+ "skills/refactor-safety/SKILL.md",
122
+ "skills/dependency-audit/SKILL.md",
123
+ "skills/code-exploration/SKILL.md",
124
+ ];
125
+
126
+
127
+ const SKILL_NAMES = [
128
+ "tool-selection",
129
+ "impact-analysis",
130
+ "refactor-safety",
131
+ "dependency-audit",
132
+ "code-exploration",
133
+ ];
134
+
135
+ const HELP = `
136
+ typegraph-mcp — Type-aware codebase navigation for AI coding agents.
137
+
138
+ Usage: typegraph-mcp <command> [options]
139
+
140
+ Commands:
141
+ setup Install typegraph-mcp plugin into the current project
142
+ remove Uninstall typegraph-mcp from the current project
143
+ check Run health checks (12 checks)
144
+ test Run smoke tests (all 14 tools)
145
+ start Start the MCP server (stdin/stdout)
146
+
147
+ Options:
148
+ --yes Skip confirmation prompts (accept all defaults)
149
+ --help Show this help
150
+ `.trim();
151
+
152
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
153
+
154
+ function copyFile(src: string, dest: string): void {
155
+ const destDir = path.dirname(dest);
156
+ if (!fs.existsSync(destDir)) {
157
+ fs.mkdirSync(destDir, { recursive: true });
158
+ }
159
+ fs.copyFileSync(src, dest);
160
+ // Preserve executable bit for scripts
161
+ if (src.endsWith(".sh")) {
162
+ fs.chmodSync(dest, 0o755);
163
+ }
164
+ }
165
+
166
+ // ─── MCP Server Registration ────────────────────────────────────────────────
167
+
168
+ const MCP_SERVER_ENTRY = {
169
+ command: "npx",
170
+ args: ["tsx", "./plugins/typegraph-mcp/server.ts"],
171
+ env: {
172
+ TYPEGRAPH_PROJECT_ROOT: ".",
173
+ TYPEGRAPH_TSCONFIG: "./tsconfig.json",
174
+ },
175
+ };
176
+
177
+ /** Register the typegraph MCP server in agent-specific config files */
178
+ function registerMcpServers(projectRoot: string, selectedAgents: AgentId[]): void {
179
+ if (selectedAgents.includes("cursor")) {
180
+ registerJsonMcp(projectRoot, ".cursor/mcp.json", "mcpServers");
181
+ }
182
+ if (selectedAgents.includes("codex")) {
183
+ registerCodexMcp(projectRoot);
184
+ }
185
+ if (selectedAgents.includes("copilot")) {
186
+ registerJsonMcp(projectRoot, ".vscode/mcp.json", "servers");
187
+ }
188
+ }
189
+
190
+ /** Deregister the typegraph MCP server from all agent config files */
191
+ function deregisterMcpServers(projectRoot: string): void {
192
+ deregisterJsonMcp(projectRoot, ".cursor/mcp.json", "mcpServers");
193
+ deregisterCodexMcp(projectRoot);
194
+ deregisterJsonMcp(projectRoot, ".vscode/mcp.json", "servers");
195
+ }
196
+
197
+ /** Register MCP server in a JSON config file (Cursor or Copilot format) */
198
+ function registerJsonMcp(projectRoot: string, configPath: string, rootKey: string): void {
199
+ const fullPath = path.resolve(projectRoot, configPath);
200
+ let config: Record<string, unknown> = {};
201
+
202
+ if (fs.existsSync(fullPath)) {
203
+ try {
204
+ config = JSON.parse(fs.readFileSync(fullPath, "utf-8"));
205
+ } catch {
206
+ p.log.warn(`Could not parse ${configPath} — skipping MCP registration`);
207
+ return;
208
+ }
209
+ }
210
+
211
+ const servers = (config[rootKey] as Record<string, unknown>) ?? {};
212
+ const entry: Record<string, unknown> = { ...MCP_SERVER_ENTRY };
213
+ // Copilot requires "type": "stdio"
214
+ if (rootKey === "servers") {
215
+ entry.type = "stdio";
216
+ }
217
+ servers["typegraph"] = entry;
218
+ config[rootKey] = servers;
219
+
220
+ const dir = path.dirname(fullPath);
221
+ if (!fs.existsSync(dir)) {
222
+ fs.mkdirSync(dir, { recursive: true });
223
+ }
224
+ fs.writeFileSync(fullPath, JSON.stringify(config, null, 2) + "\n");
225
+ p.log.success(`${configPath}: registered typegraph MCP server`);
226
+ }
227
+
228
+ /** Deregister MCP server from a JSON config file */
229
+ function deregisterJsonMcp(projectRoot: string, configPath: string, rootKey: string): void {
230
+ const fullPath = path.resolve(projectRoot, configPath);
231
+ if (!fs.existsSync(fullPath)) return;
232
+
233
+ try {
234
+ const config = JSON.parse(fs.readFileSync(fullPath, "utf-8"));
235
+ const servers = config[rootKey];
236
+ if (!servers || !servers["typegraph"]) return;
237
+
238
+ delete servers["typegraph"];
239
+
240
+ // Clean up empty objects
241
+ if (Object.keys(servers).length === 0) {
242
+ delete config[rootKey];
243
+ }
244
+
245
+ // If config is now empty, remove the file
246
+ if (Object.keys(config).length === 0) {
247
+ fs.unlinkSync(fullPath);
248
+ } else {
249
+ fs.writeFileSync(fullPath, JSON.stringify(config, null, 2) + "\n");
250
+ }
251
+ p.log.info(`${configPath}: removed typegraph MCP server`);
252
+ } catch {
253
+ // Ignore parse errors
254
+ }
255
+ }
256
+
257
+ /** Register MCP server in Codex CLI's TOML config */
258
+ function registerCodexMcp(projectRoot: string): void {
259
+ const configPath = ".codex/config.toml";
260
+ const fullPath = path.resolve(projectRoot, configPath);
261
+ let content = "";
262
+
263
+ if (fs.existsSync(fullPath)) {
264
+ content = fs.readFileSync(fullPath, "utf-8");
265
+ // Already registered?
266
+ if (content.includes("[mcp_servers.typegraph]")) {
267
+ p.log.info(`${configPath}: typegraph MCP server already registered`);
268
+ return;
269
+ }
270
+ }
271
+
272
+ const block = [
273
+ "",
274
+ "[mcp_servers.typegraph]",
275
+ 'command = "npx"',
276
+ 'args = ["tsx", "./plugins/typegraph-mcp/server.ts"]',
277
+ 'env = { TYPEGRAPH_PROJECT_ROOT = ".", TYPEGRAPH_TSCONFIG = "./tsconfig.json" }',
278
+ "",
279
+ ].join("\n");
280
+
281
+ const dir = path.dirname(fullPath);
282
+ if (!fs.existsSync(dir)) {
283
+ fs.mkdirSync(dir, { recursive: true });
284
+ }
285
+ const newContent = content ? content.trimEnd() + "\n" + block : block.trimStart();
286
+ fs.writeFileSync(fullPath, newContent);
287
+ p.log.success(`${configPath}: registered typegraph MCP server`);
288
+ }
289
+
290
+ /** Deregister MCP server from Codex CLI's TOML config */
291
+ function deregisterCodexMcp(projectRoot: string): void {
292
+ const configPath = ".codex/config.toml";
293
+ const fullPath = path.resolve(projectRoot, configPath);
294
+ if (!fs.existsSync(fullPath)) return;
295
+
296
+ let content = fs.readFileSync(fullPath, "utf-8");
297
+ if (!content.includes("[mcp_servers.typegraph]")) return;
298
+
299
+ // Remove the [mcp_servers.typegraph] section (stops at next section header or end of file)
300
+ content = content.replace(
301
+ /\n?\[mcp_servers\.typegraph\]\n[\s\S]*?(?=\n\[|$)/,
302
+ ""
303
+ );
304
+
305
+ // Clean up multiple trailing newlines
306
+ content = content.replace(/\n{3,}/g, "\n\n").trimEnd() + "\n";
307
+
308
+ if (content.trim() === "") {
309
+ fs.unlinkSync(fullPath);
310
+ } else {
311
+ fs.writeFileSync(fullPath, content);
312
+ }
313
+ p.log.info(`${configPath}: removed typegraph MCP server`);
314
+ }
315
+
316
+ // ─── Agent Selection ─────────────────────────────────────────────────────────
317
+
318
+ function detectAgents(projectRoot: string): AgentId[] {
319
+ return AGENT_IDS.filter((id) => AGENTS[id].detect(projectRoot));
320
+ }
321
+
322
+ async function selectAgents(projectRoot: string, yes: boolean): Promise<AgentId[]> {
323
+ const detected = detectAgents(projectRoot);
324
+
325
+ if (yes) {
326
+ const selected = detected.length > 0 ? detected : [...AGENT_IDS];
327
+ p.log.info(`Auto-selected: ${selected.map((id) => AGENTS[id].name).join(", ")}`);
328
+ return selected;
329
+ }
330
+
331
+ p.log.info("space = toggle | up/down = navigate | enter = confirm");
332
+
333
+ const result = await p.multiselect({
334
+ message: "Select which AI agents to configure:",
335
+ options: AGENT_IDS.map((id) => ({
336
+ value: id,
337
+ label: AGENTS[id].name,
338
+ hint: detected.includes(id) ? "detected" : undefined,
339
+ })),
340
+ initialValues: detected.length > 0 ? detected : [...AGENT_IDS],
341
+ required: false,
342
+ });
343
+
344
+ if (p.isCancel(result)) {
345
+ p.cancel("Setup cancelled.");
346
+ process.exit(0);
347
+ }
348
+
349
+ const selected = (result as AgentId[]).length > 0 ? (result as AgentId[]) : (detected.length > 0 ? detected : [...AGENT_IDS]);
350
+
351
+ p.log.info(`Selected: ${selected.map((id) => AGENTS[id].name).join(", ")}`);
352
+
353
+ return selected;
354
+ }
355
+
356
+ // ─── Setup Command ───────────────────────────────────────────────────────────
357
+
358
+ async function setup(yes: boolean): Promise<void> {
359
+ const sourceDir = import.meta.dirname;
360
+ const projectRoot = process.cwd();
361
+
362
+ process.stdout.write("\x1Bc"); // Clear terminal
363
+ p.intro("TypeGraph MCP Setup");
364
+
365
+ p.log.info(`Project: ${projectRoot}`);
366
+
367
+ // 1. Validate project
368
+ const pkgJsonPath = path.resolve(projectRoot, "package.json");
369
+ const tsconfigPath = path.resolve(projectRoot, "tsconfig.json");
370
+
371
+ if (!fs.existsSync(pkgJsonPath)) {
372
+ p.cancel("No package.json found. Run this from the root of your TypeScript project.");
373
+ process.exit(1);
374
+ }
375
+
376
+ if (!fs.existsSync(tsconfigPath)) {
377
+ p.cancel("No tsconfig.json found. typegraph-mcp requires a TypeScript project.");
378
+ process.exit(1);
379
+ }
380
+
381
+ p.log.success("Found package.json and tsconfig.json");
382
+
383
+ // 2. Check for existing installation
384
+ const targetDir = path.resolve(projectRoot, PLUGIN_DIR_NAME);
385
+ const isUpdate = fs.existsSync(targetDir);
386
+
387
+ if (isUpdate && !yes) {
388
+ const action = await p.select({
389
+ message: `${PLUGIN_DIR_NAME}/ already exists.`,
390
+ options: [
391
+ { value: "update", label: "Update", hint: "reinstall plugin files" },
392
+ { value: "remove", label: "Remove", hint: "uninstall typegraph-mcp from this project" },
393
+ { value: "exit", label: "Exit", hint: "keep existing installation" },
394
+ ],
395
+ });
396
+
397
+ if (p.isCancel(action)) {
398
+ p.cancel("Setup cancelled.");
399
+ process.exit(0);
400
+ }
401
+
402
+ if (action === "remove") {
403
+ await removePlugin(projectRoot, targetDir);
404
+ return;
405
+ }
406
+
407
+ if (action === "exit") {
408
+ p.outro("No changes made.");
409
+ return;
410
+ }
411
+ }
412
+
413
+ // 3. Agent selection
414
+ const selectedAgents = await selectAgents(projectRoot, yes);
415
+
416
+ const needsPluginSkills = selectedAgents.includes("claude-code") || selectedAgents.includes("cursor");
417
+ const needsAgentsSkills = selectedAgents.some((id) => AGENTS[id].needsAgentsSkills);
418
+
419
+ p.log.step(`Installing to ${PLUGIN_DIR_NAME}/...`);
420
+
421
+ const s = p.spinner();
422
+ s.start("Copying files...");
423
+
424
+ // Assemble file list based on selected agents
425
+ const filesToCopy = [...CORE_FILES];
426
+
427
+ // Skills are always needed (either for in-plugin discovery or as source for .agents/skills/ copies)
428
+ if (needsPluginSkills || needsAgentsSkills) {
429
+ filesToCopy.push(...SKILL_FILES);
430
+ }
431
+
432
+ // Add agent-specific files
433
+ for (const agentId of selectedAgents) {
434
+ filesToCopy.push(...AGENTS[agentId].pluginFiles);
435
+ }
436
+
437
+ // Copy files
438
+
439
+ let copied = 0;
440
+ for (const file of filesToCopy) {
441
+ const src = path.join(sourceDir, file);
442
+ const dest = path.join(targetDir, file);
443
+ if (fs.existsSync(src)) {
444
+ copyFile(src, dest);
445
+ copied++;
446
+ } else {
447
+ p.log.warn(`Source file not found: ${file}`);
448
+ }
449
+ }
450
+
451
+ s.message("Installing dependencies...");
452
+ try {
453
+ execSync("npm install", { cwd: targetDir, stdio: "pipe" });
454
+ s.stop(`${isUpdate ? "Updated" : "Installed"} ${copied} files with dependencies`);
455
+ } catch (err) {
456
+ s.stop(`${isUpdate ? "Updated" : "Installed"} ${copied} files`);
457
+ p.log.warn(`Dependency install failed: ${err instanceof Error ? err.message : String(err)}`);
458
+ p.log.info(`Run manually: cd ${PLUGIN_DIR_NAME} && npm install`);
459
+ }
460
+
461
+ // 4. Copy skills to .agents/skills/ for cross-platform discovery
462
+ if (needsAgentsSkills) {
463
+ const agentsNames = selectedAgents
464
+ .filter((id) => AGENTS[id].needsAgentsSkills)
465
+ .map((id) => AGENTS[id].name);
466
+
467
+ const agentsSkillsDir = path.resolve(projectRoot, ".agents/skills");
468
+ let copiedSkills = 0;
469
+ for (const skill of SKILL_NAMES) {
470
+ const src = path.join(targetDir, "skills", skill, "SKILL.md");
471
+ const destDir = path.join(agentsSkillsDir, skill);
472
+ const dest = path.join(destDir, "SKILL.md");
473
+ if (!fs.existsSync(src)) continue;
474
+ if (fs.existsSync(dest)) {
475
+ const srcContent = fs.readFileSync(src, "utf-8");
476
+ const destContent = fs.readFileSync(dest, "utf-8");
477
+ if (srcContent === destContent) continue;
478
+ }
479
+ fs.mkdirSync(destDir, { recursive: true });
480
+ fs.copyFileSync(src, dest);
481
+ copiedSkills++;
482
+ }
483
+ if (copiedSkills > 0) {
484
+ p.log.success(`Copied ${copiedSkills} skills to .agents/skills/ (${agentsNames.join(", ")})`);
485
+ } else {
486
+ p.log.info(".agents/skills/ already up to date");
487
+ }
488
+ }
489
+
490
+ // 5. Remove old .claude/mcp.json entry if Claude Code is selected
491
+ if (selectedAgents.includes("claude-code")) {
492
+ const mcpJsonPath = path.resolve(projectRoot, ".claude/mcp.json");
493
+ if (fs.existsSync(mcpJsonPath)) {
494
+ try {
495
+ const mcpJson = JSON.parse(fs.readFileSync(mcpJsonPath, "utf-8"));
496
+ if (mcpJson.mcpServers?.["typegraph"]) {
497
+ delete mcpJson.mcpServers["typegraph"];
498
+ fs.writeFileSync(mcpJsonPath, JSON.stringify(mcpJson, null, 2) + "\n");
499
+ p.log.info("Removed old typegraph entry from .claude/mcp.json");
500
+ }
501
+ } catch {
502
+ // Ignore parse errors
503
+ }
504
+ }
505
+ }
506
+
507
+ // 6. Agent instructions
508
+ await setupAgentInstructions(projectRoot, selectedAgents);
509
+
510
+ // 7. Register MCP server in agent-specific configs
511
+ registerMcpServers(projectRoot, selectedAgents);
512
+
513
+ // 8. Verification
514
+ await runVerification(targetDir, selectedAgents);
515
+ }
516
+
517
+ // ─── Remove Command ──────────────────────────────────────────────────────────
518
+
519
+ async function removePlugin(projectRoot: string, pluginDir: string): Promise<void> {
520
+ const s = p.spinner();
521
+ s.start("Removing typegraph-mcp...");
522
+
523
+ // 1. Remove plugin directory
524
+ if (fs.existsSync(pluginDir)) {
525
+ fs.rmSync(pluginDir, { recursive: true });
526
+ }
527
+
528
+ // 2. Remove .agents/skills/ entries (only typegraph-mcp skills, not the whole dir)
529
+ const agentsSkillsDir = path.resolve(projectRoot, ".agents/skills");
530
+ for (const skill of SKILL_NAMES) {
531
+ const skillDir = path.join(agentsSkillsDir, skill);
532
+ if (fs.existsSync(skillDir)) {
533
+ fs.rmSync(skillDir, { recursive: true });
534
+ }
535
+ }
536
+ // Clean up .agents/skills/ and .agents/ if empty
537
+ if (fs.existsSync(agentsSkillsDir) && fs.readdirSync(agentsSkillsDir).length === 0) {
538
+ fs.rmSync(agentsSkillsDir, { recursive: true });
539
+ const agentsDir = path.resolve(projectRoot, ".agents");
540
+ if (fs.existsSync(agentsDir) && fs.readdirSync(agentsDir).length === 0) {
541
+ fs.rmSync(agentsDir, { recursive: true });
542
+ }
543
+ }
544
+
545
+ // 3. Remove agent instruction snippet from all known agent files
546
+ const allAgentFiles = AGENT_IDS
547
+ .map((id) => AGENTS[id].agentFile)
548
+ .filter((f): f is string => f !== null);
549
+
550
+ const seenRealPaths = new Set<string>();
551
+ for (const agentFile of allAgentFiles) {
552
+ const filePath = path.resolve(projectRoot, agentFile);
553
+ if (!fs.existsSync(filePath)) continue;
554
+ const realPath = fs.realpathSync(filePath);
555
+ if (seenRealPaths.has(realPath)) continue;
556
+ seenRealPaths.add(realPath);
557
+
558
+ let content = fs.readFileSync(realPath, "utf-8");
559
+ if (content.includes(SNIPPET_MARKER)) {
560
+ // Remove the snippet block (from marker to end of the bullet list)
561
+ content = content.replace(/\n?## TypeScript Navigation \(typegraph-mcp\)\n[\s\S]*?(?=\n## |\n# |$)/, "");
562
+ // Clean up trailing whitespace
563
+ content = content.replace(/\n{3,}$/, "\n");
564
+ fs.writeFileSync(realPath, content);
565
+ }
566
+ }
567
+
568
+ // 4. Remove --plugin-dir ./plugins/typegraph-mcp from CLAUDE.md
569
+ const claudeMdPath = path.resolve(projectRoot, "CLAUDE.md");
570
+ if (fs.existsSync(claudeMdPath)) {
571
+ let content = fs.readFileSync(claudeMdPath, "utf-8");
572
+ content = content.replace(/ --plugin-dir \.\/plugins\/typegraph-mcp/g, "");
573
+ fs.writeFileSync(claudeMdPath, content);
574
+ }
575
+
576
+ s.stop("Removed typegraph-mcp");
577
+
578
+ // 5. Deregister MCP server from agent config files
579
+ deregisterMcpServers(projectRoot);
580
+
581
+ p.outro("typegraph-mcp has been uninstalled from this project.");
582
+ }
583
+
584
+ async function setupAgentInstructions(projectRoot: string, selectedAgents: AgentId[]): Promise<void> {
585
+ // Collect agent instruction files for selected agents
586
+ const agentFiles = selectedAgents
587
+ .map((id) => AGENTS[id].agentFile)
588
+ .filter((f): f is string => f !== null);
589
+
590
+ if (agentFiles.length === 0) {
591
+ return; // No agents with instruction files selected (e.g. Cursor only)
592
+ }
593
+
594
+ // Find existing files, resolve symlinks to deduplicate
595
+ const seenRealPaths = new Map<string, string>(); // realPath -> first agentFile name
596
+ const existingFiles: { file: string; realPath: string; hasSnippet: boolean }[] = [];
597
+ for (const agentFile of agentFiles) {
598
+ const filePath = path.resolve(projectRoot, agentFile);
599
+ if (!fs.existsSync(filePath)) continue;
600
+ const realPath = fs.realpathSync(filePath);
601
+
602
+ const previousFile = seenRealPaths.get(realPath);
603
+ if (previousFile) {
604
+ p.log.info(`${agentFile}: same file as ${previousFile} (skipped)`);
605
+ continue;
606
+ }
607
+ seenRealPaths.set(realPath, agentFile);
608
+ const content = fs.readFileSync(filePath, "utf-8");
609
+ existingFiles.push({ file: agentFile, realPath, hasSnippet: content.includes(SNIPPET_MARKER) });
610
+ }
611
+
612
+ if (existingFiles.length === 0) {
613
+ p.log.warn(`No agent instruction files found (${agentFiles.join(", ")})`);
614
+ p.note(AGENT_SNIPPET, "Add this snippet to your agent instructions file");
615
+ } else if (existingFiles.some((f) => f.hasSnippet)) {
616
+ for (const f of existingFiles) {
617
+ if (f.hasSnippet) {
618
+ p.log.info(`${f.file}: already has typegraph-mcp instructions`);
619
+ }
620
+ }
621
+ } else {
622
+ const target = existingFiles[0]!;
623
+ const content = fs.readFileSync(target.realPath, "utf-8");
624
+ const appendContent = (content.endsWith("\n") ? "" : "\n") + "\n" + AGENT_SNIPPET;
625
+ fs.appendFileSync(target.realPath, appendContent);
626
+ p.log.success(`${target.file}: appended typegraph-mcp instructions`);
627
+ }
628
+
629
+ // Update --plugin-dir line in CLAUDE.md if Claude Code is selected
630
+ if (selectedAgents.includes("claude-code")) {
631
+ const claudeMdPath = path.resolve(projectRoot, "CLAUDE.md");
632
+ if (fs.existsSync(claudeMdPath)) {
633
+ let content = fs.readFileSync(claudeMdPath, "utf-8");
634
+ const pluginDirPattern = /(`claude\s+)((?:--plugin-dir\s+\S+\s*)+)(`)/;
635
+ const match = content.match(pluginDirPattern);
636
+
637
+ if (match && !match[2]!.includes("./plugins/typegraph-mcp")) {
638
+ const existingFlags = match[2]!.trimEnd();
639
+ content = content.replace(
640
+ pluginDirPattern,
641
+ `$1${existingFlags} --plugin-dir ./plugins/typegraph-mcp$3`
642
+ );
643
+ fs.writeFileSync(claudeMdPath, content);
644
+ p.log.success("CLAUDE.md: added --plugin-dir ./plugins/typegraph-mcp");
645
+ } else if (match) {
646
+ p.log.info("CLAUDE.md: --plugin-dir already includes typegraph-mcp");
647
+ }
648
+ }
649
+ }
650
+ }
651
+
652
+ async function runVerification(pluginDir: string, selectedAgents: AgentId[]): Promise<void> {
653
+ const config = resolveConfig(pluginDir);
654
+
655
+ console.log("");
656
+ const { main: checkMain } = await import("./check.js");
657
+ const checkResult = await checkMain(config);
658
+
659
+ console.log("");
660
+
661
+ if (checkResult.failed > 0) {
662
+ p.cancel("Health check has failures — fix the issues above before running smoke tests.");
663
+ process.exit(1);
664
+ }
665
+
666
+ const { main: testMain } = await import("./smoke-test.js");
667
+ const testResult = await testMain(config);
668
+
669
+ console.log("");
670
+
671
+ if (checkResult.failed === 0 && testResult.failed === 0) {
672
+ if (selectedAgents.includes("claude-code")) {
673
+ p.outro("Setup complete! Run: claude --plugin-dir ./plugins/typegraph-mcp");
674
+ } else {
675
+ p.outro("Setup complete! typegraph-mcp tools are now available to your agents.");
676
+ }
677
+ } else {
678
+ p.cancel("Setup completed with issues. Fix the failures above and re-run.");
679
+ process.exit(1);
680
+ }
681
+ }
682
+
683
+ // ─── Remove Command (standalone) ─────────────────────────────────────────────
684
+
685
+ async function remove(yes: boolean): Promise<void> {
686
+ const projectRoot = process.cwd();
687
+ const pluginDir = path.resolve(projectRoot, PLUGIN_DIR_NAME);
688
+
689
+ process.stdout.write("\x1Bc");
690
+ p.intro("TypeGraph MCP Remove");
691
+
692
+ if (!fs.existsSync(pluginDir)) {
693
+ p.cancel("typegraph-mcp is not installed in this project.");
694
+ process.exit(1);
695
+ }
696
+
697
+ if (!yes) {
698
+ const confirmed = await p.confirm({ message: "Remove typegraph-mcp from this project?" });
699
+ if (p.isCancel(confirmed) || !confirmed) {
700
+ p.cancel("Removal cancelled.");
701
+ process.exit(0);
702
+ }
703
+ }
704
+
705
+ await removePlugin(projectRoot, pluginDir);
706
+ }
707
+
708
+ // ─── Check Command ───────────────────────────────────────────────────────────
709
+
710
+ async function check(): Promise<void> {
711
+ const { main: checkMain } = await import("./check.js");
712
+ const result = await checkMain();
713
+ process.exit(result.failed > 0 ? 1 : 0);
714
+ }
715
+
716
+ // ─── Test Command ────────────────────────────────────────────────────────────
717
+
718
+ async function test(): Promise<void> {
719
+ const { main: testMain } = await import("./smoke-test.js");
720
+ const result = await testMain();
721
+ process.exit(result.failed > 0 ? 1 : 0);
722
+ }
723
+
724
+ // ─── Start Command ───────────────────────────────────────────────────────────
725
+
726
+ async function start(): Promise<void> {
727
+ await import("./server.js");
728
+ }
729
+
730
+ // ─── CLI Dispatch ────────────────────────────────────────────────────────────
731
+
732
+ const args = process.argv.slice(2);
733
+ const command = args.find((a) => !a.startsWith("-"));
734
+ const yes = args.includes("--yes") || args.includes("-y");
735
+ const help = args.includes("--help") || args.includes("-h");
736
+
737
+ if (help || !command) {
738
+ console.log(HELP);
739
+ process.exit(0);
740
+ }
741
+
742
+ switch (command) {
743
+ case "setup":
744
+ setup(yes).catch((err) => {
745
+ console.error("Fatal:", err);
746
+ process.exit(1);
747
+ });
748
+ break;
749
+ case "remove":
750
+ remove(yes).catch((err) => {
751
+ console.error("Fatal:", err);
752
+ process.exit(1);
753
+ });
754
+ break;
755
+ case "check":
756
+ check().catch((err) => {
757
+ console.error("Fatal:", err);
758
+ process.exit(1);
759
+ });
760
+ break;
761
+ case "test":
762
+ test().catch((err) => {
763
+ console.error("Fatal:", err);
764
+ process.exit(1);
765
+ });
766
+ break;
767
+ case "start":
768
+ start().catch((err) => {
769
+ console.error("Fatal:", err);
770
+ process.exit(1);
771
+ });
772
+ break;
773
+ default:
774
+ console.log(`Unknown command: ${command}`);
775
+ console.log("");
776
+ console.log(HELP);
777
+ process.exit(1);
778
+ }