imprint-mcp 0.2.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.
Files changed (97) hide show
  1. package/CHANGELOG.md +168 -0
  2. package/LICENSE +21 -0
  3. package/README.md +322 -0
  4. package/examples/discoverandgo/README.md +57 -0
  5. package/examples/discoverandgo/book_discoverandgo_museum_pass/cron.json +8 -0
  6. package/examples/discoverandgo/book_discoverandgo_museum_pass/index.ts +89 -0
  7. package/examples/discoverandgo/book_discoverandgo_museum_pass/workflow.json +39 -0
  8. package/examples/echo/README.md +37 -0
  9. package/examples/echo/echo_test/index.ts +31 -0
  10. package/examples/google-flights/search_google_flights/index.ts +101 -0
  11. package/examples/google-flights/search_google_flights/parser.test.ts +140 -0
  12. package/examples/google-flights/search_google_flights/parser.ts +189 -0
  13. package/examples/google-flights/search_google_flights/playbook.yaml +130 -0
  14. package/examples/google-flights/search_google_flights/workflow.json +48 -0
  15. package/examples/google-hotels/search_google_hotels/index.ts +194 -0
  16. package/examples/google-hotels/search_google_hotels/parser.test.ts +168 -0
  17. package/examples/google-hotels/search_google_hotels/parser.ts +330 -0
  18. package/examples/google-hotels/search_google_hotels/playbook.yaml +125 -0
  19. package/examples/google-hotels/search_google_hotels/workflow.json +111 -0
  20. package/examples/namecheap-domains/search_namecheap_domains/index.ts +144 -0
  21. package/examples/namecheap-domains/search_namecheap_domains/parser.ts +380 -0
  22. package/examples/namecheap-domains/search_namecheap_domains/playbook.yaml +50 -0
  23. package/examples/namecheap-domains/search_namecheap_domains/request-transform.ts +136 -0
  24. package/examples/namecheap-domains/search_namecheap_domains/workflow.json +97 -0
  25. package/examples/southwest/README.md +81 -0
  26. package/examples/southwest/search_southwest_flights/backends.json +23 -0
  27. package/examples/southwest/search_southwest_flights/cron.json +19 -0
  28. package/examples/southwest/search_southwest_flights/index.ts +110 -0
  29. package/examples/southwest/search_southwest_flights/playbook.yaml +46 -0
  30. package/examples/southwest/search_southwest_flights/workflow.json +54 -0
  31. package/package.json +78 -0
  32. package/prompts/compile-agent.md +580 -0
  33. package/prompts/intent-detection.md +198 -0
  34. package/prompts/playbook-compilation.md +279 -0
  35. package/prompts/request-triage.md +74 -0
  36. package/prompts/tool-candidate-detection.md +104 -0
  37. package/src/cli.ts +1287 -0
  38. package/src/imprint/agent.ts +468 -0
  39. package/src/imprint/app-api-hosts.ts +53 -0
  40. package/src/imprint/backend-ladder.ts +568 -0
  41. package/src/imprint/check.ts +136 -0
  42. package/src/imprint/chromium.ts +211 -0
  43. package/src/imprint/claude-cli-compile.ts +640 -0
  44. package/src/imprint/cli-credential.ts +394 -0
  45. package/src/imprint/codex-cli-compile.ts +712 -0
  46. package/src/imprint/compile-agent-types.ts +40 -0
  47. package/src/imprint/compile-agent.ts +404 -0
  48. package/src/imprint/compile-tools.ts +1389 -0
  49. package/src/imprint/compile.ts +720 -0
  50. package/src/imprint/cookie-jar.ts +246 -0
  51. package/src/imprint/credential-bundle.ts +195 -0
  52. package/src/imprint/credential-extract.ts +290 -0
  53. package/src/imprint/credential-store.ts +707 -0
  54. package/src/imprint/cron.ts +312 -0
  55. package/src/imprint/doctor.ts +223 -0
  56. package/src/imprint/emit.ts +154 -0
  57. package/src/imprint/etld.ts +134 -0
  58. package/src/imprint/freeform-redact.ts +216 -0
  59. package/src/imprint/inject-listener.ts +137 -0
  60. package/src/imprint/install.ts +795 -0
  61. package/src/imprint/integrations.ts +385 -0
  62. package/src/imprint/is-compiled.ts +2 -0
  63. package/src/imprint/json-path.ts +100 -0
  64. package/src/imprint/llm.ts +998 -0
  65. package/src/imprint/load-json.ts +54 -0
  66. package/src/imprint/log.ts +33 -0
  67. package/src/imprint/login.ts +166 -0
  68. package/src/imprint/mcp-compile-server.ts +282 -0
  69. package/src/imprint/mcp-maintenance.ts +1790 -0
  70. package/src/imprint/mcp-server.ts +350 -0
  71. package/src/imprint/multi-progress.ts +69 -0
  72. package/src/imprint/notify.ts +155 -0
  73. package/src/imprint/paths.ts +64 -0
  74. package/src/imprint/playbook-parser.ts +21 -0
  75. package/src/imprint/playbook-runner.ts +465 -0
  76. package/src/imprint/probe-backends.ts +251 -0
  77. package/src/imprint/progress.ts +28 -0
  78. package/src/imprint/record.ts +470 -0
  79. package/src/imprint/redact.ts +550 -0
  80. package/src/imprint/replay-capture.ts +387 -0
  81. package/src/imprint/request-context.ts +66 -0
  82. package/src/imprint/runtime-link.ts +73 -0
  83. package/src/imprint/runtime.ts +942 -0
  84. package/src/imprint/sensitive-keys.ts +156 -0
  85. package/src/imprint/session-diff.ts +409 -0
  86. package/src/imprint/session-merge.ts +198 -0
  87. package/src/imprint/session-writer.ts +149 -0
  88. package/src/imprint/sites.ts +27 -0
  89. package/src/imprint/stealth-fetch.ts +434 -0
  90. package/src/imprint/teach-state.ts +235 -0
  91. package/src/imprint/teach.ts +2120 -0
  92. package/src/imprint/tool-candidates.ts +423 -0
  93. package/src/imprint/tool-loader.ts +186 -0
  94. package/src/imprint/tool-selection.ts +70 -0
  95. package/src/imprint/tracing.ts +508 -0
  96. package/src/imprint/types.ts +472 -0
  97. package/src/imprint/version.ts +21 -0
@@ -0,0 +1,795 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { dirname as pathDirname, join as pathJoin, resolve as pathResolve } from 'node:path';
4
+ import * as p from '@clack/prompts';
5
+ import { parse as yamlParse, stringify as yamlStringify } from 'yaml';
6
+ import {
7
+ type McpServerConfig,
8
+ PLATFORMS,
9
+ type Platform,
10
+ buildMcpServerConfig,
11
+ buildRegistrationCommand,
12
+ buildUnregistrationCommand,
13
+ detectDirectBunImprintCommand,
14
+ detectImprintCommand,
15
+ generatePasteSnippet,
16
+ shellQuote,
17
+ } from './integrations.ts';
18
+ import { imprintHomeDir } from './paths.ts';
19
+ import { discoverTools } from './tool-loader.ts';
20
+ import type { Workflow } from './types.ts';
21
+
22
+ type InstallSource = 'local' | 'examples';
23
+ const installedMcpServerCache = new Map<Platform, InstalledMcpServer[]>();
24
+
25
+ interface InstallOptions {
26
+ site?: string;
27
+ platform?: Platform;
28
+ source?: InstallSource;
29
+ print?: boolean;
30
+ noInteractive?: boolean;
31
+ }
32
+
33
+ interface UninstallOptions {
34
+ site?: string;
35
+ platform?: Platform;
36
+ print?: boolean;
37
+ noInteractive?: boolean;
38
+ }
39
+
40
+ interface InstallableSite {
41
+ source: InstallSource;
42
+ assetRoot: string;
43
+ site: string;
44
+ toolNames: string[];
45
+ }
46
+
47
+ interface InstallResult {
48
+ platform: Platform;
49
+ site: string;
50
+ source: InstallSource;
51
+ assetRoot: string;
52
+ serverName: string;
53
+ message: string;
54
+ }
55
+
56
+ interface UninstallResult {
57
+ platform: Platform;
58
+ site: string;
59
+ serverName: string;
60
+ message: string;
61
+ configPath?: string;
62
+ removed: boolean;
63
+ }
64
+
65
+ type InstallTuiResult =
66
+ | ({ action: 'install' } & InstallResult)
67
+ | ({ action: 'uninstall' } & UninstallResult);
68
+
69
+ interface InstallTarget {
70
+ source: InstallSource;
71
+ assetRoot: string;
72
+ site: string;
73
+ workflows: Workflow[];
74
+ }
75
+
76
+ interface InstalledMcpServer {
77
+ platform: Platform;
78
+ site: string;
79
+ serverName: string;
80
+ configPath?: string;
81
+ source?: string;
82
+ }
83
+
84
+ interface ConfigRemovalResult {
85
+ path: string;
86
+ removed: boolean;
87
+ }
88
+
89
+ function examplesAssetRoot(): string {
90
+ return pathResolve(import.meta.dir, '..', '..', 'examples');
91
+ }
92
+
93
+ function defaultClaudeDesktopConfigPath(): string {
94
+ if (process.platform === 'darwin') {
95
+ return pathJoin(
96
+ homedir(),
97
+ 'Library',
98
+ 'Application Support',
99
+ 'Claude',
100
+ 'claude_desktop_config.json',
101
+ );
102
+ }
103
+ if (process.platform === 'win32') {
104
+ return pathJoin(
105
+ process.env.APPDATA ?? pathJoin(homedir(), 'AppData', 'Roaming'),
106
+ 'Claude',
107
+ 'claude_desktop_config.json',
108
+ );
109
+ }
110
+ return pathJoin(homedir(), '.config', 'Claude', 'claude_desktop_config.json');
111
+ }
112
+
113
+ function defaultOpenClawConfigPath(): string {
114
+ return pathJoin(homedir(), '.openclaw', 'openclaw.json');
115
+ }
116
+
117
+ function defaultHermesConfigPath(): string {
118
+ return pathJoin(homedir(), '.hermes', 'config.yaml');
119
+ }
120
+
121
+ export async function listInstallableSites(
122
+ source: InstallSource,
123
+ assetRoot: string,
124
+ onlySite?: string,
125
+ ): Promise<InstallableSite[]> {
126
+ const tools = await discoverTools(assetRoot, onlySite, '[imprint install]');
127
+ const grouped = new Map<string, string[]>();
128
+ for (const tool of tools) {
129
+ const toolNames = grouped.get(tool.site) ?? [];
130
+ toolNames.push(tool.workflow.toolName);
131
+ grouped.set(tool.site, toolNames);
132
+ }
133
+ return [...grouped.entries()]
134
+ .map(([site, toolNames]) => ({
135
+ source,
136
+ assetRoot,
137
+ site,
138
+ toolNames: toolNames.sort(),
139
+ }))
140
+ .sort((a, b) => `${a.source}:${a.site}`.localeCompare(`${b.source}:${b.site}`));
141
+ }
142
+
143
+ export function installMcpConfigFile(
144
+ platform: 'claude-desktop' | 'openclaw' | 'hermes',
145
+ server: McpServerConfig,
146
+ configPath?: string,
147
+ ): string {
148
+ switch (platform) {
149
+ case 'claude-desktop': {
150
+ const outPath = configPath ?? defaultClaudeDesktopConfigPath();
151
+ upsertJsonConfig(outPath, (config) => {
152
+ const root = asRecord(config);
153
+ const mcpServers = asRecord(root.mcpServers);
154
+ mcpServers[server.name] = mcpServerJson(server);
155
+ root.mcpServers = mcpServers;
156
+ return root;
157
+ });
158
+ return outPath;
159
+ }
160
+ case 'openclaw': {
161
+ const outPath = configPath ?? defaultOpenClawConfigPath();
162
+ upsertJsonConfig(outPath, (config) => {
163
+ const root = asRecord(config);
164
+ const mcp = asRecord(root.mcp);
165
+ const servers = asRecord(mcp.servers);
166
+ servers[server.name] = mcpServerJson(server);
167
+ mcp.servers = servers;
168
+ root.mcp = mcp;
169
+ return root;
170
+ });
171
+ return outPath;
172
+ }
173
+ case 'hermes': {
174
+ const outPath = configPath ?? defaultHermesConfigPath();
175
+ const parsed = readYamlRecord(outPath);
176
+ const mcpServers = asRecord(parsed.mcp_servers);
177
+ mcpServers[server.name] = mcpServerJson(server);
178
+ parsed.mcp_servers = mcpServers;
179
+ writeConfigFile(outPath, yamlStringify(parsed, { lineWidth: 0 }));
180
+ return outPath;
181
+ }
182
+ }
183
+ }
184
+
185
+ export function uninstallMcpConfigFile(
186
+ platform: 'claude-desktop' | 'openclaw' | 'hermes',
187
+ serverName: string,
188
+ configPath?: string,
189
+ ): ConfigRemovalResult {
190
+ switch (platform) {
191
+ case 'claude-desktop': {
192
+ const outPath = configPath ?? defaultClaudeDesktopConfigPath();
193
+ const removed = deleteJsonConfig(outPath, (config) => {
194
+ const root = asRecord(config);
195
+ const mcpServers = asRecord(root.mcpServers);
196
+ const existed = Object.hasOwn(mcpServers, serverName);
197
+ if (existed) delete mcpServers[serverName];
198
+ root.mcpServers = mcpServers;
199
+ return { config: root, removed: existed };
200
+ });
201
+ return { path: outPath, removed };
202
+ }
203
+ case 'openclaw': {
204
+ const outPath = configPath ?? defaultOpenClawConfigPath();
205
+ const removed = deleteJsonConfig(outPath, (config) => {
206
+ const root = asRecord(config);
207
+ const mcp = asRecord(root.mcp);
208
+ const servers = asRecord(mcp.servers);
209
+ const existed = Object.hasOwn(servers, serverName);
210
+ if (existed) delete servers[serverName];
211
+ mcp.servers = servers;
212
+ root.mcp = mcp;
213
+ return { config: root, removed: existed };
214
+ });
215
+ return { path: outPath, removed };
216
+ }
217
+ case 'hermes': {
218
+ const outPath = configPath ?? defaultHermesConfigPath();
219
+ if (!existsSync(outPath)) return { path: outPath, removed: false };
220
+ const parsed = readYamlRecord(outPath);
221
+ const mcpServers = asRecord(parsed.mcp_servers);
222
+ const removed = Object.hasOwn(mcpServers, serverName);
223
+ if (removed) {
224
+ delete mcpServers[serverName];
225
+ parsed.mcp_servers = mcpServers;
226
+ writeConfigFile(outPath, yamlStringify(parsed, { lineWidth: 0 }));
227
+ }
228
+ return { path: outPath, removed };
229
+ }
230
+ }
231
+ }
232
+
233
+ export async function installTui(): Promise<InstallTuiResult> {
234
+ const action = await p.select({
235
+ message: 'What would you like to do?',
236
+ options: [
237
+ { value: 'install' as const, label: 'Install an emitted MCP server' },
238
+ { value: 'uninstall' as const, label: 'Uninstall an MCP server' },
239
+ ],
240
+ });
241
+ if (p.isCancel(action)) {
242
+ p.outro('Cancelled.');
243
+ process.exit(0);
244
+ }
245
+
246
+ if (action === 'uninstall') {
247
+ return { action: 'uninstall', ...(await uninstall({})) };
248
+ }
249
+ return { action: 'install', ...(await install({})) };
250
+ }
251
+
252
+ export async function install(opts: InstallOptions = {}): Promise<InstallResult> {
253
+ const target = await resolveInstallTarget(opts);
254
+ const platform = await resolveInstallPlatform(opts);
255
+ const imprintCommand = configFilePlatform(platform)
256
+ ? detectDirectBunImprintCommand()
257
+ : detectImprintCommand();
258
+ const env = { IMPRINT_HOME: target.assetRoot };
259
+ const workflow = target.workflows[0];
260
+ if (!workflow) {
261
+ throw new Error(`No emitted workflows found for ${target.site}. Run \`imprint emit\` first.`);
262
+ }
263
+ const server = buildMcpServerConfig({
264
+ site: target.site,
265
+ imprintCommand,
266
+ env,
267
+ });
268
+
269
+ if (opts.print) {
270
+ console.log(
271
+ generatePasteSnippet({
272
+ site: target.site,
273
+ workflow,
274
+ workflows: target.workflows,
275
+ platform,
276
+ imprintCommand,
277
+ env,
278
+ }),
279
+ );
280
+ return {
281
+ platform,
282
+ site: target.site,
283
+ source: target.source,
284
+ assetRoot: target.assetRoot,
285
+ serverName: server.name,
286
+ message: `Printed ${server.name} ${platform} configuration.`,
287
+ };
288
+ }
289
+
290
+ const regCommand = buildRegistrationCommand({
291
+ site: target.site,
292
+ platform,
293
+ imprintCommand,
294
+ env,
295
+ });
296
+
297
+ let message: string;
298
+ if (regCommand) {
299
+ runRegistrationCommand(platform, server.name, regCommand);
300
+ message = `${server.name} installed in ${formatPlatform(platform)}.`;
301
+ } else {
302
+ const configPath = installMcpConfigFile(
303
+ platform as 'claude-desktop' | 'openclaw' | 'hermes',
304
+ server,
305
+ );
306
+ message = `${server.name} installed in ${formatPlatform(platform)} config: ${configPath}`;
307
+ }
308
+
309
+ installedMcpServerCache.delete(platform);
310
+ return {
311
+ platform,
312
+ site: target.site,
313
+ source: target.source,
314
+ assetRoot: target.assetRoot,
315
+ serverName: server.name,
316
+ message,
317
+ };
318
+ }
319
+
320
+ export async function uninstall(opts: UninstallOptions = {}): Promise<UninstallResult> {
321
+ const platform = await resolveInstallPlatform(
322
+ opts,
323
+ 'Uninstall this MCP server from where?',
324
+ 'uninstall',
325
+ );
326
+ const site = await resolveUninstallSite(opts, platform);
327
+ const serverName = serverNameForSite(site);
328
+
329
+ if (opts.print) {
330
+ console.log(generateUninstallSnippet(platform, site));
331
+ return {
332
+ platform,
333
+ site,
334
+ serverName,
335
+ message: `Printed ${serverName} ${formatPlatform(platform)} uninstall instructions.`,
336
+ removed: false,
337
+ };
338
+ }
339
+
340
+ const command = buildUnregistrationCommand({ site, platform });
341
+ let removed = true;
342
+ let configPath: string | undefined;
343
+ let message: string;
344
+ if (command) {
345
+ runUnregistrationCommand(serverName, command);
346
+ message = `${serverName} uninstalled from ${formatPlatform(platform)}.`;
347
+ } else {
348
+ const result = uninstallMcpConfigFile(
349
+ platform as 'claude-desktop' | 'openclaw' | 'hermes',
350
+ serverName,
351
+ );
352
+ removed = result.removed;
353
+ configPath = result.path;
354
+ message = removed
355
+ ? `${serverName} uninstalled from ${formatPlatform(platform)} config: ${result.path}`
356
+ : `${serverName} was not installed in ${formatPlatform(platform)} config: ${result.path}`;
357
+ }
358
+
359
+ installedMcpServerCache.delete(platform);
360
+ return { platform, site, serverName, message, configPath, removed };
361
+ }
362
+
363
+ async function resolveInstallTarget(opts: InstallOptions): Promise<InstallTarget> {
364
+ const localRoot = imprintHomeDir();
365
+ const roots = [
366
+ { source: 'local' as const, assetRoot: localRoot },
367
+ { source: 'examples' as const, assetRoot: examplesAssetRoot() },
368
+ ].filter((root) => !opts.source || root.source === opts.source);
369
+
370
+ const sites = (
371
+ await Promise.all(
372
+ roots.map((root) => listInstallableSites(root.source, root.assetRoot, opts.site)),
373
+ )
374
+ ).flat();
375
+ let selected: InstallableSite | undefined;
376
+
377
+ if (opts.site) {
378
+ const matches = sites.filter((site) => site.site === opts.site);
379
+ selected =
380
+ matches.find((match) => match.source === 'local') ??
381
+ matches.find((match) => match.source === 'examples');
382
+ if (!selected) {
383
+ const sourceHint = opts.source ? ` in ${opts.source}` : '';
384
+ throw new Error(
385
+ `No emitted tools found for site "${opts.site}"${sourceHint}. Run \`imprint emit\` first, or install a checked-in example with \`imprint install ${opts.site} --source examples\`.`,
386
+ );
387
+ }
388
+ } else {
389
+ if (opts.noInteractive) {
390
+ throw new Error('`imprint install --no-interactive` requires a <site> argument.');
391
+ }
392
+ if (sites.length === 0) {
393
+ throw new Error(
394
+ 'No emitted tools found. Run `imprint teach <site>` or `imprint emit <workflow.json>` first.',
395
+ );
396
+ }
397
+ const choice = await p.select({
398
+ message: 'Which emitted MCP server should be installed?',
399
+ options: sites.map((site) => ({
400
+ value: `${site.source}:${site.site}`,
401
+ label: `${site.site} (${site.source}, ${site.toolNames.length} tool${site.toolNames.length === 1 ? '' : 's'})`,
402
+ })),
403
+ });
404
+ if (p.isCancel(choice)) {
405
+ p.outro('Cancelled.');
406
+ process.exit(0);
407
+ }
408
+ const [source, site] = String(choice).split(':') as [InstallSource, string];
409
+ selected = sites.find((entry) => entry.source === source && entry.site === site);
410
+ }
411
+
412
+ if (!selected) throw new Error('No emitted MCP server selected.');
413
+ const tools = await discoverTools(selected.assetRoot, selected.site, '[imprint install]');
414
+ const workflows = tools.map((tool) => tool.workflow);
415
+ if (workflows.length === 0) {
416
+ throw new Error(
417
+ `No loadable emitted tools found for site "${selected.site}" at ${selected.assetRoot}.`,
418
+ );
419
+ }
420
+
421
+ return {
422
+ source: selected.source,
423
+ assetRoot: selected.assetRoot,
424
+ site: selected.site,
425
+ workflows,
426
+ };
427
+ }
428
+
429
+ async function resolveInstallPlatform(
430
+ opts: Pick<InstallOptions, 'platform' | 'noInteractive'>,
431
+ message = 'Install this MCP server where?',
432
+ verb = 'install',
433
+ ): Promise<Platform> {
434
+ if (opts.platform) return opts.platform;
435
+ if (opts.noInteractive) {
436
+ throw new Error(`\`imprint ${verb} --no-interactive\` requires --platform <name>.`);
437
+ }
438
+ const platformOptions =
439
+ verb === 'uninstall' ? listDetectedPlatformsWithInstalledServers() : listDetectedPlatforms();
440
+ if (platformOptions.length === 0) {
441
+ const intent =
442
+ verb === 'uninstall'
443
+ ? 'No installed Imprint MCP servers were found in detected AI platforms.'
444
+ : 'No supported AI platforms were detected on this system.';
445
+ throw new Error(`${intent} Pass --platform <name> to target a platform explicitly.`);
446
+ }
447
+ const choice = await p.select({
448
+ message,
449
+ options: platformOptions.map((platform) => ({
450
+ value: platform,
451
+ label: platformOptionLabel(platform, verb),
452
+ })),
453
+ });
454
+ if (p.isCancel(choice)) {
455
+ p.outro('Cancelled.');
456
+ process.exit(0);
457
+ }
458
+ return choice as Platform;
459
+ }
460
+
461
+ async function resolveUninstallSite(opts: UninstallOptions, platform: Platform): Promise<string> {
462
+ if (opts.site) return normalizeSiteInput(opts.site);
463
+ if (opts.noInteractive) {
464
+ throw new Error('`imprint uninstall --no-interactive` requires a <site> argument.');
465
+ }
466
+
467
+ const installed = listInstalledMcpServers(platform);
468
+ if (installed.length > 0) {
469
+ const choice = await p.select({
470
+ message: `Which MCP server should be uninstalled from ${formatPlatform(platform)}?`,
471
+ options: installed.map((server) => ({
472
+ value: server.site,
473
+ label: server.serverName,
474
+ })),
475
+ });
476
+ if (p.isCancel(choice)) {
477
+ p.outro('Cancelled.');
478
+ process.exit(0);
479
+ }
480
+ return String(choice);
481
+ }
482
+ throw new Error(`No installed Imprint MCP servers found in ${formatPlatform(platform)}.`);
483
+ }
484
+
485
+ function runRegistrationCommand(platform: Platform, serverName: string, command: string[]): void {
486
+ const result = Bun.spawnSync(command, { stdio: ['ignore', 'pipe', 'pipe'] });
487
+ if (result.exitCode === 0) return;
488
+
489
+ const output = `${result.stderr.toString()}\n${result.stdout.toString()}`;
490
+ if (output.includes('already exists')) {
491
+ const removeCommand =
492
+ platform === 'claude-code'
493
+ ? ['claude', 'mcp', 'remove', '--scope', 'user', serverName]
494
+ : platform === 'codex'
495
+ ? ['codex', 'mcp', 'remove', serverName]
496
+ : null;
497
+ if (removeCommand) {
498
+ Bun.spawnSync(removeCommand, { stdio: ['ignore', 'ignore', 'ignore'] });
499
+ const retry = Bun.spawnSync(command, { stdio: ['ignore', 'pipe', 'pipe'] });
500
+ if (retry.exitCode === 0) return;
501
+ throw new Error(retry.stderr.toString().trim() || `Command exited with ${retry.exitCode}`);
502
+ }
503
+ }
504
+
505
+ throw new Error(output.trim() || `Command exited with ${result.exitCode}`);
506
+ }
507
+
508
+ function runUnregistrationCommand(serverName: string, command: string[]): void {
509
+ const result = Bun.spawnSync(command, { stdio: ['ignore', 'pipe', 'pipe'] });
510
+ if (result.exitCode === 0) return;
511
+
512
+ const output = `${result.stderr.toString()}\n${result.stdout.toString()}`.trim();
513
+ if (isAlreadyAbsent(output)) return;
514
+ throw new Error(
515
+ output || `Failed to remove ${serverName}; command exited with ${result.exitCode}`,
516
+ );
517
+ }
518
+
519
+ function upsertJsonConfig(
520
+ path: string,
521
+ update: (config: unknown) => Record<string, unknown>,
522
+ ): void {
523
+ let parsed: unknown = {};
524
+ if (existsSync(path)) {
525
+ try {
526
+ parsed = JSON.parse(readFileSync(path, 'utf8'));
527
+ } catch {
528
+ throw new Error(
529
+ `Config at ${path} contains invalid JSON — fix it manually or delete it and retry.`,
530
+ );
531
+ }
532
+ }
533
+ writeConfigFile(path, `${JSON.stringify(update(parsed), null, 2)}\n`);
534
+ }
535
+
536
+ function deleteJsonConfig(
537
+ path: string,
538
+ update: (config: unknown) => { config: Record<string, unknown>; removed: boolean },
539
+ ): boolean {
540
+ if (!existsSync(path)) return false;
541
+ let parsed: unknown;
542
+ try {
543
+ parsed = JSON.parse(readFileSync(path, 'utf8'));
544
+ } catch {
545
+ throw new Error(
546
+ `Config at ${path} contains invalid JSON — fix it manually or delete it and retry.`,
547
+ );
548
+ }
549
+ const result = update(parsed);
550
+ if (result.removed) writeConfigFile(path, `${JSON.stringify(result.config, null, 2)}\n`);
551
+ return result.removed;
552
+ }
553
+
554
+ function readYamlRecord(path: string): Record<string, unknown> {
555
+ if (!existsSync(path)) return {};
556
+ let parsed: unknown;
557
+ try {
558
+ parsed = yamlParse(readFileSync(path, 'utf8'));
559
+ } catch {
560
+ throw new Error(
561
+ `Config at ${path} contains invalid YAML — fix it manually or delete it and retry.`,
562
+ );
563
+ }
564
+ return asRecord(parsed);
565
+ }
566
+
567
+ function writeConfigFile(path: string, contents: string): void {
568
+ mkdirSync(pathDirname(path), { recursive: true });
569
+ writeFileSync(path, contents.endsWith('\n') ? contents : `${contents}\n`, 'utf8');
570
+ }
571
+
572
+ function asRecord(value: unknown): Record<string, unknown> {
573
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
574
+ return value as Record<string, unknown>;
575
+ }
576
+ return {};
577
+ }
578
+
579
+ function mcpServerJson(server: McpServerConfig): Record<string, unknown> {
580
+ return {
581
+ command: server.command,
582
+ args: server.args,
583
+ ...(server.env ? { env: server.env } : {}),
584
+ };
585
+ }
586
+
587
+ function listInstalledMcpServers(platform: Platform): InstalledMcpServer[] {
588
+ const cached = installedMcpServerCache.get(platform);
589
+ if (cached) return [...cached];
590
+
591
+ const servers = (() => {
592
+ switch (platform) {
593
+ case 'claude-code':
594
+ return listCliInstalledServers(platform, ['claude', 'mcp', 'list']);
595
+ case 'codex':
596
+ return listCliInstalledServers(platform, ['codex', 'mcp', 'list']);
597
+ case 'claude-desktop':
598
+ return listJsonInstalledServers(platform, defaultClaudeDesktopConfigPath(), (root) =>
599
+ asRecord(root.mcpServers),
600
+ );
601
+ case 'openclaw':
602
+ return listJsonInstalledServers(platform, defaultOpenClawConfigPath(), (root) =>
603
+ asRecord(asRecord(root.mcp).servers),
604
+ );
605
+ case 'hermes': {
606
+ const configPath = defaultHermesConfigPath();
607
+ if (!existsSync(configPath)) return [];
608
+ try {
609
+ const parsed = readYamlRecord(configPath);
610
+ return serverNamesFromRecord(platform, configPath, asRecord(parsed.mcp_servers));
611
+ } catch {
612
+ return [];
613
+ }
614
+ }
615
+ }
616
+ })();
617
+ installedMcpServerCache.set(platform, servers);
618
+ return [...servers];
619
+ }
620
+
621
+ export function parseInstalledMcpServers(
622
+ platform: 'claude-code' | 'codex',
623
+ output: string,
624
+ ): InstalledMcpServer[] {
625
+ switch (platform) {
626
+ case 'claude-code':
627
+ return [...output.matchAll(/^((?:imprint-)[^:\s]+):/gm)].map((match) =>
628
+ installedServerFromName(platform, match[1] ?? '', 'claude mcp list'),
629
+ );
630
+ case 'codex':
631
+ return output
632
+ .split(/\r?\n/)
633
+ .map((line) => line.match(/^(imprint-[^\s]+)/)?.[1])
634
+ .filter((serverName): serverName is string => !!serverName)
635
+ .map((serverName) => installedServerFromName(platform, serverName, 'codex mcp list'));
636
+ }
637
+ }
638
+
639
+ function listDetectedPlatforms(): Platform[] {
640
+ return PLATFORMS.filter(isPlatformDetected);
641
+ }
642
+
643
+ function listDetectedPlatformsWithInstalledServers(): Platform[] {
644
+ return listDetectedPlatforms().filter((platform) => listInstalledMcpServers(platform).length > 0);
645
+ }
646
+
647
+ function isPlatformDetected(platform: Platform): boolean {
648
+ switch (platform) {
649
+ case 'claude-code':
650
+ return commandExists('claude');
651
+ case 'codex':
652
+ return commandExists('codex');
653
+ case 'claude-desktop':
654
+ return (
655
+ existsSync(defaultClaudeDesktopConfigPath()) ||
656
+ (process.platform === 'darwin' && existsSync('/Applications/Claude.app'))
657
+ );
658
+ case 'openclaw':
659
+ return commandExists('openclaw') || existsSync(pathJoin(homedir(), '.openclaw'));
660
+ case 'hermes':
661
+ return commandExists('hermes') || existsSync(pathJoin(homedir(), '.hermes'));
662
+ }
663
+ }
664
+
665
+ function commandExists(command: string): boolean {
666
+ return (
667
+ Bun.spawnSync(['which', command], { stdio: ['ignore', 'ignore', 'ignore'] }).exitCode === 0
668
+ );
669
+ }
670
+
671
+ function platformOptionLabel(platform: Platform, verb: string): string {
672
+ const label = formatPlatform(platform);
673
+ if (verb !== 'uninstall') return label;
674
+ const count = listInstalledMcpServers(platform).length;
675
+ return `${label} (${count} Imprint MCP${count === 1 ? '' : 's'})`;
676
+ }
677
+
678
+ function listCliInstalledServers(
679
+ platform: 'claude-code' | 'codex',
680
+ command: string[],
681
+ ): InstalledMcpServer[] {
682
+ const result = Bun.spawnSync(command, { stdio: ['ignore', 'pipe', 'pipe'] });
683
+ if (result.exitCode !== 0) return [];
684
+ const output = `${result.stdout.toString()}\n${result.stderr.toString()}`;
685
+ return parseInstalledMcpServers(platform, output);
686
+ }
687
+
688
+ function listJsonInstalledServers(
689
+ platform: Platform,
690
+ configPath: string,
691
+ selectServers: (root: Record<string, unknown>) => Record<string, unknown>,
692
+ ): InstalledMcpServer[] {
693
+ if (!existsSync(configPath)) return [];
694
+ try {
695
+ const root = asRecord(JSON.parse(readFileSync(configPath, 'utf8')));
696
+ return serverNamesFromRecord(platform, configPath, selectServers(root));
697
+ } catch {
698
+ return [];
699
+ }
700
+ }
701
+
702
+ function serverNamesFromRecord(
703
+ platform: Platform,
704
+ configPath: string,
705
+ servers: Record<string, unknown>,
706
+ ): InstalledMcpServer[] {
707
+ return Object.keys(servers)
708
+ .filter((serverName) => serverName.startsWith('imprint-'))
709
+ .sort()
710
+ .map((serverName) => ({
711
+ platform,
712
+ site: serverName.slice('imprint-'.length),
713
+ serverName,
714
+ configPath,
715
+ }));
716
+ }
717
+
718
+ function installedServerFromName(
719
+ platform: Platform,
720
+ serverName: string,
721
+ source?: string,
722
+ ): InstalledMcpServer {
723
+ return {
724
+ platform,
725
+ site: serverName.slice('imprint-'.length),
726
+ serverName,
727
+ ...(source ? { source } : {}),
728
+ };
729
+ }
730
+
731
+ function generateUninstallSnippet(platform: Platform, site: string): string {
732
+ const command = buildUnregistrationCommand({ site, platform });
733
+ const serverName = serverNameForSite(site);
734
+ if (command) {
735
+ return `Remove the ${serverName} tool: run \`${command.map(shellQuote).join(' ')}\`.`;
736
+ }
737
+
738
+ switch (platform) {
739
+ case 'claude-desktop':
740
+ return `Remove "${serverName}" from ~/Library/Application Support/Claude/claude_desktop_config.json under "mcpServers".`;
741
+ case 'openclaw':
742
+ return `Remove "${serverName}" from ~/.openclaw/openclaw.json under mcp.servers.`;
743
+ case 'hermes':
744
+ return `Remove "${serverName}" from ~/.hermes/config.yaml under mcp_servers.`;
745
+ case 'claude-code':
746
+ case 'codex':
747
+ throw new Error(`Missing uninstall command for ${platform}.`);
748
+ }
749
+ }
750
+
751
+ function configFilePlatform(platform: Platform): boolean {
752
+ return platform === 'claude-desktop' || platform === 'openclaw' || platform === 'hermes';
753
+ }
754
+
755
+ function serverNameForSite(site: string): string {
756
+ return `imprint-${normalizeSiteInput(site)}`;
757
+ }
758
+
759
+ const VALID_SITE_NAME = /^[a-z0-9][a-z0-9._-]*$/;
760
+
761
+ function normalizeSiteInput(value: string): string {
762
+ const trimmed = value.trim();
763
+ const site = trimmed.startsWith('imprint-') ? trimmed.slice('imprint-'.length) : trimmed;
764
+ if (!VALID_SITE_NAME.test(site)) {
765
+ throw new Error(
766
+ `Invalid site name "${site}" — must be lowercase alphanumeric with dots, underscores, or hyphens (e.g. "google-flights").`,
767
+ );
768
+ }
769
+ return site;
770
+ }
771
+
772
+ function isAlreadyAbsent(output: string): boolean {
773
+ const lower = output.toLowerCase();
774
+ return (
775
+ lower.includes('not found') ||
776
+ lower.includes('does not exist') ||
777
+ lower.includes('no such') ||
778
+ lower.includes('unknown server')
779
+ );
780
+ }
781
+
782
+ function formatPlatform(platform: Platform): string {
783
+ switch (platform) {
784
+ case 'claude-code':
785
+ return 'Claude Code';
786
+ case 'codex':
787
+ return 'Codex CLI';
788
+ case 'claude-desktop':
789
+ return 'Claude Desktop';
790
+ case 'openclaw':
791
+ return 'OpenClaw';
792
+ case 'hermes':
793
+ return 'Hermes';
794
+ }
795
+ }