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,1790 @@
1
+ /**
2
+ * `imprint mcp ...` — audit and maintain Imprint-owned MCP registrations.
3
+ *
4
+ * This module intentionally scans config files directly instead of launching
5
+ * MCP clients. Some client list commands perform health checks and can spawn
6
+ * stdio servers, which is too side-effectful for a cleanup/audit command.
7
+ */
8
+
9
+ import {
10
+ existsSync,
11
+ mkdirSync,
12
+ readFileSync,
13
+ readdirSync,
14
+ renameSync,
15
+ rmSync,
16
+ statSync,
17
+ writeFileSync,
18
+ } from 'node:fs';
19
+ import { homedir } from 'node:os';
20
+ import {
21
+ dirname as pathDirname,
22
+ isAbsolute as pathIsAbsolute,
23
+ join as pathJoin,
24
+ relative as pathRelative,
25
+ resolve as pathResolve,
26
+ } from 'node:path';
27
+ import * as p from '@clack/prompts';
28
+ import YAML from 'yaml';
29
+ import { imprintHomeDir, localSiteDir } from './paths.ts';
30
+ import {
31
+ type TeachState,
32
+ type WorkflowState,
33
+ loadTeachState,
34
+ resolveTeachStatePath,
35
+ saveTeachState,
36
+ teachStatePath,
37
+ } from './teach-state.ts';
38
+
39
+ type McpClient = 'claude-code' | 'codex' | 'claude-desktop' | 'openclaw' | 'hermes';
40
+ type LocalDeleteMode = 'none' | 'tool' | 'site';
41
+ type IssueKind = 'incomplete' | 'missing-session' | 'orphan-session' | 'stale-registration';
42
+
43
+ const CLIENTS: McpClient[] = ['claude-code', 'codex', 'claude-desktop', 'openclaw', 'hermes'];
44
+ const DISABLED_STORE_VERSION = 1;
45
+
46
+ interface McpRegistration {
47
+ client: McpClient;
48
+ name: string;
49
+ site: string | null;
50
+ configPath: string;
51
+ scope?: string;
52
+ enabled: boolean;
53
+ command?: string;
54
+ args?: string[];
55
+ server?: Record<string, unknown>;
56
+ }
57
+
58
+ interface DisabledMcpRegistration {
59
+ client: McpClient;
60
+ name: string;
61
+ site: string | null;
62
+ configPath: string;
63
+ scope?: string;
64
+ command?: string;
65
+ args?: string[];
66
+ server?: Record<string, unknown>;
67
+ disabledAt: string;
68
+ }
69
+
70
+ interface DisabledStore {
71
+ version: number;
72
+ disabled: DisabledMcpRegistration[];
73
+ }
74
+
75
+ interface LocalToolStatus {
76
+ site: string;
77
+ toolName: string;
78
+ dir: string;
79
+ complete: boolean;
80
+ hasWorkflow: boolean;
81
+ hasPlaybook: boolean;
82
+ hasBackends: boolean;
83
+ hasCron: boolean;
84
+ }
85
+
86
+ interface LocalWorkflowStatus {
87
+ site: string;
88
+ name: string;
89
+ sessionPath: string | null;
90
+ redactedPath: string | null;
91
+ completedSteps: string[];
92
+ missingSession: boolean;
93
+ incomplete: boolean;
94
+ error?: string;
95
+ updatedAt: string;
96
+ }
97
+
98
+ interface LocalSiteStatus {
99
+ site: string;
100
+ dir: string;
101
+ tools: LocalToolStatus[];
102
+ workflows: LocalWorkflowStatus[];
103
+ orphanSessions: string[];
104
+ }
105
+
106
+ interface McpIssue {
107
+ kind: IssueKind;
108
+ site: string;
109
+ message: string;
110
+ client?: McpClient;
111
+ name?: string;
112
+ configPath?: string;
113
+ workflow?: string;
114
+ path?: string;
115
+ }
116
+
117
+ interface McpStatus {
118
+ imprintHome: string;
119
+ registrations: McpRegistration[];
120
+ disabled: DisabledMcpRegistration[];
121
+ sites: LocalSiteStatus[];
122
+ issues: McpIssue[];
123
+ }
124
+
125
+ interface MaintenanceContext {
126
+ homeDir: string;
127
+ cwd: string;
128
+ imprintHome: string;
129
+ }
130
+
131
+ interface ParsedArgs {
132
+ positionals: string[];
133
+ flags: Record<string, string | boolean>;
134
+ }
135
+
136
+ interface MutationResult {
137
+ changed: string[];
138
+ skipped: string[];
139
+ }
140
+
141
+ function defaultContext(opts: Partial<MaintenanceContext> = {}): MaintenanceContext {
142
+ return {
143
+ homeDir: opts.homeDir ?? homedir(),
144
+ cwd: opts.cwd ?? process.cwd(),
145
+ imprintHome: opts.imprintHome ?? imprintHomeDir(),
146
+ };
147
+ }
148
+
149
+ function parseSubArgs(argv: string[]): ParsedArgs {
150
+ const positionals: string[] = [];
151
+ const flags: Record<string, string | boolean> = {};
152
+ for (let i = 0; i < argv.length; i++) {
153
+ const a = argv[i] ?? '';
154
+ if (a.startsWith('--')) {
155
+ const eq = a.indexOf('=');
156
+ if (eq !== -1) {
157
+ flags[a.slice(2, eq)] = a.slice(eq + 1);
158
+ } else {
159
+ const next = argv[i + 1];
160
+ if (next !== undefined && !next.startsWith('--')) {
161
+ flags[a.slice(2)] = next;
162
+ i++;
163
+ } else {
164
+ flags[a.slice(2)] = true;
165
+ }
166
+ }
167
+ } else {
168
+ positionals.push(a);
169
+ }
170
+ }
171
+ return { positionals, flags };
172
+ }
173
+
174
+ const MCP_HELP = `imprint mcp — audit and clean up Imprint MCP registrations
175
+
176
+ USAGE
177
+ imprint mcp
178
+ imprint mcp status [--site <site>] [--json]
179
+ imprint mcp disable <server-or-site> [--client <name|all>] [--yes]
180
+ imprint mcp enable <server-or-site> [--client <name|all>] [--yes]
181
+ imprint mcp delete <server-or-site> [--client <name|all>] [--local none|tool|site] [--yes]
182
+ imprint mcp prune-state [--site <site>] [--missing-session] [--incomplete] [--yes]
183
+
184
+ DESCRIPTION
185
+ Manages only Imprint-owned MCP registrations: names beginning with
186
+ "imprint-" or commands that run "imprint mcp-server <site>".
187
+
188
+ Mutating subcommands require --yes in direct mode. Run "imprint mcp"
189
+ without a subcommand for the interactive cleanup flow.
190
+ `;
191
+
192
+ export async function runMcpCommand(argv: string[]): Promise<number> {
193
+ if (argv.length === 0) return await runInteractiveMcp();
194
+ if (argv.includes('--help') || argv.includes('-h')) {
195
+ console.log(MCP_HELP);
196
+ return 0;
197
+ }
198
+
199
+ const sub = argv[0] ?? '';
200
+ const rest = argv.slice(1);
201
+
202
+ switch (sub) {
203
+ case 'status':
204
+ case 'audit':
205
+ return cmdStatus(rest);
206
+ case 'disable':
207
+ return cmdDisable(rest);
208
+ case 'enable':
209
+ return cmdEnable(rest);
210
+ case 'delete':
211
+ case 'remove':
212
+ case 'rm':
213
+ return cmdDelete(rest);
214
+ case 'prune-state':
215
+ return cmdPruneState(rest);
216
+ default:
217
+ console.error(`error: unknown subcommand 'mcp ${sub}' — run \`imprint mcp --help\``);
218
+ return 2;
219
+ }
220
+ }
221
+
222
+ async function runInteractiveMcp(): Promise<number> {
223
+ p.intro('imprint mcp');
224
+ const ctx = defaultContext();
225
+ const status = scanMcpStatus(ctx);
226
+ p.log.info(formatMcpStatus(status));
227
+
228
+ const action = await p.select({
229
+ message: 'What would you like to do?',
230
+ options: [
231
+ ...(status.issues.length > 0 ? [{ value: 'fix-issue', label: 'Fix an issue' }] : []),
232
+ { value: 'disable', label: 'Disable a registration' },
233
+ { value: 'enable', label: 'Re-enable a disabled registration' },
234
+ { value: 'delete', label: 'Delete a registration' },
235
+ { value: 'prune-state', label: 'Prune stale teach state' },
236
+ { value: 'quit', label: 'Quit' },
237
+ ],
238
+ });
239
+ if (p.isCancel(action) || action === 'quit') {
240
+ p.outro('Done.');
241
+ return 0;
242
+ }
243
+
244
+ if (action === 'fix-issue') {
245
+ await runInteractiveIssueFix(status, ctx);
246
+ p.outro('Done.');
247
+ return 0;
248
+ }
249
+
250
+ if (action === 'prune-state') {
251
+ const result = pruneTeachState({ missingSession: true, incomplete: true });
252
+ reportMutation(result);
253
+ p.outro('Done.');
254
+ return 0;
255
+ }
256
+
257
+ const choices =
258
+ action === 'enable'
259
+ ? [
260
+ ...status.registrations
261
+ .filter((r) => r.client === 'codex' && !r.enabled)
262
+ .map((r) => ({
263
+ value: `registration:${registrationKey(r)}`,
264
+ label: registrationLabel(r),
265
+ })),
266
+ ...status.disabled.map((d) => ({
267
+ value: `snapshot:${disabledKey(d)}`,
268
+ label: disabledLabel(d),
269
+ })),
270
+ ]
271
+ : status.registrations
272
+ .filter((r) => action !== 'disable' || r.enabled)
273
+ .map((r) => ({
274
+ value: `registration:${registrationKey(r)}`,
275
+ label: registrationLabel(r),
276
+ }));
277
+
278
+ if (choices.length === 0) {
279
+ if (action === 'delete' && status.sites.length > 0) {
280
+ p.log.info('No active registrations found. You can still delete local Imprint artifacts.');
281
+ await runInteractiveLocalDelete(status);
282
+ p.outro('Done.');
283
+ return 0;
284
+ }
285
+ p.log.info(
286
+ action === 'enable' ? 'No disabled registrations found.' : 'No registrations found.',
287
+ );
288
+ p.outro('Done.');
289
+ return 0;
290
+ }
291
+
292
+ const target = await p.select({
293
+ message: `${String(action)[0]?.toUpperCase()}${String(action).slice(1)} which registration?`,
294
+ options: choices,
295
+ });
296
+ if (p.isCancel(target)) {
297
+ p.outro('Cancelled.');
298
+ return 0;
299
+ }
300
+
301
+ if (action === 'disable') {
302
+ const reg = findRegistrationChoice(status, String(target));
303
+ reportMutation(
304
+ reg ? disableRegistration(reg, ctx) : { changed: [], skipped: ['selection disappeared'] },
305
+ );
306
+ } else if (action === 'enable') {
307
+ if (String(target).startsWith('registration:')) {
308
+ const reg = findRegistrationChoice(status, String(target));
309
+ reportMutation(
310
+ reg ? enableRegistration(reg) : { changed: [], skipped: ['selection disappeared'] },
311
+ );
312
+ } else {
313
+ const snap = findDisabledChoice(status, String(target));
314
+ reportMutation(
315
+ snap
316
+ ? enableDisabledSnapshot(ctx, snap)
317
+ : { changed: [], skipped: ['selection disappeared'] },
318
+ );
319
+ }
320
+ } else if (action === 'delete') {
321
+ const local = await p.select({
322
+ message: 'Also delete local Imprint artifacts?',
323
+ options: [
324
+ { value: 'none', label: 'No, only delete external MCP registrations' },
325
+ { value: 'tool', label: 'Delete generated tool directories, keep recordings' },
326
+ { value: 'site', label: 'Delete the whole local site directory, including recordings' },
327
+ ],
328
+ initialValue: 'none',
329
+ });
330
+ if (p.isCancel(local)) {
331
+ p.outro('Cancelled.');
332
+ return 0;
333
+ }
334
+ if (local === 'site') {
335
+ const confirm = await p.confirm({
336
+ message:
337
+ 'This will permanently delete the local site directory, including raw recordings. Continue?',
338
+ });
339
+ if (p.isCancel(confirm) || !confirm) {
340
+ p.outro('Cancelled.');
341
+ return 0;
342
+ }
343
+ }
344
+ const reg = findRegistrationChoice(status, String(target));
345
+ reportMutation(
346
+ reg
347
+ ? deleteRegistration(reg, ctx, local as LocalDeleteMode)
348
+ : { changed: [], skipped: ['selection disappeared'] },
349
+ );
350
+ }
351
+
352
+ p.outro('Done.');
353
+ return 0;
354
+ }
355
+
356
+ async function runInteractiveIssueFix(status: McpStatus, ctx: MaintenanceContext): Promise<void> {
357
+ const SELECT_ALL = '__select_all__';
358
+ const SELECT_NONE = '__select_none__';
359
+
360
+ const issueOptions = status.issues.map((issue, index) => ({
361
+ value: String(index),
362
+ label: `${issue.kind}: ${issue.message}`,
363
+ }));
364
+
365
+ const choice = await p.multiselect<string>({
366
+ message:
367
+ 'Fix which issues? (space to toggle, enter to submit; pick "Select all" to fix everything)',
368
+ options: [
369
+ { value: SELECT_ALL, label: 'Select all issues' },
370
+ { value: SELECT_NONE, label: 'Select none (cancel)' },
371
+ ...issueOptions,
372
+ ],
373
+ required: false,
374
+ initialValues: [],
375
+ });
376
+ if (p.isCancel(choice)) return;
377
+
378
+ const selected = choice as string[];
379
+ if (selected.includes(SELECT_NONE) && !selected.includes(SELECT_ALL)) {
380
+ p.log.info('No issues selected.');
381
+ return;
382
+ }
383
+
384
+ const indices = selected.includes(SELECT_ALL)
385
+ ? status.issues.map((_, i) => i)
386
+ : selected
387
+ .filter((value) => value !== SELECT_ALL && value !== SELECT_NONE)
388
+ .map((value) => Number(value))
389
+ .filter((index) => Number.isInteger(index) && index >= 0 && index < status.issues.length);
390
+
391
+ if (indices.length === 0) {
392
+ p.log.info('No issues selected.');
393
+ return;
394
+ }
395
+
396
+ const orphanIssues = indices
397
+ .map((i) => status.issues[i])
398
+ .filter(
399
+ (issue): issue is McpIssue => !!issue && issue.kind === 'orphan-session' && !!issue.path,
400
+ );
401
+
402
+ let deleteOrphans = true;
403
+ if (orphanIssues.length > 0) {
404
+ const confirm = await p.confirm({
405
+ message: `Delete ${orphanIssues.length} orphan session file${orphanIssues.length === 1 ? '' : 's'}?`,
406
+ initialValue: false,
407
+ });
408
+ if (p.isCancel(confirm)) return;
409
+ deleteOrphans = confirm === true;
410
+ }
411
+
412
+ const aggregate: MutationResult = { changed: [], skipped: [] };
413
+ for (const index of indices) {
414
+ const issue = status.issues[index];
415
+ if (!issue) {
416
+ appendMutation(aggregate, { changed: [], skipped: ['selection disappeared'] });
417
+ continue;
418
+ }
419
+ appendMutation(aggregate, fixIssue(issue, status, ctx, { deleteOrphans }));
420
+ }
421
+ reportMutation(aggregate);
422
+ }
423
+
424
+ function fixIssue(
425
+ issue: McpIssue,
426
+ status: McpStatus,
427
+ ctx: MaintenanceContext,
428
+ opts: { deleteOrphans: boolean },
429
+ ): MutationResult {
430
+ if (issue.kind === 'stale-registration') {
431
+ const reg = status.registrations.find(
432
+ (r) =>
433
+ r.client === issue.client &&
434
+ r.name === issue.name &&
435
+ (!issue.configPath || r.configPath === issue.configPath),
436
+ );
437
+ return reg
438
+ ? deleteRegistration(reg, ctx, 'none')
439
+ : {
440
+ changed: [],
441
+ skipped: [`registration not found for ${issue.client ?? '?'}/${issue.name ?? '?'}`],
442
+ };
443
+ }
444
+
445
+ if ((issue.kind === 'incomplete' || issue.kind === 'missing-session') && issue.workflow) {
446
+ return pruneSingleTeachWorkflow(issue.site, issue.workflow);
447
+ }
448
+
449
+ if (issue.kind === 'orphan-session' && issue.path) {
450
+ if (!opts.deleteOrphans) {
451
+ return { changed: [], skipped: [`kept orphan session ${issue.path}`] };
452
+ }
453
+ return deleteOrphanSessionFile(issue.path);
454
+ }
455
+
456
+ return { changed: [], skipped: [`no automatic fix for ${issue.kind}`] };
457
+ }
458
+
459
+ async function runInteractiveLocalDelete(status: McpStatus): Promise<void> {
460
+ const selectedSite = await p.select({
461
+ message: 'Delete local artifacts for which site?',
462
+ options: status.sites.map((s) => {
463
+ const complete = s.tools.filter((t) => t.complete).length;
464
+ return {
465
+ value: s.site,
466
+ label: `${s.site} (${complete} complete tool${complete === 1 ? '' : 's'}, ${s.orphanSessions.length} orphan session${s.orphanSessions.length === 1 ? '' : 's'})`,
467
+ };
468
+ }),
469
+ });
470
+ if (p.isCancel(selectedSite)) return;
471
+
472
+ const local = await p.select({
473
+ message: 'What local artifacts should be deleted?',
474
+ options: [
475
+ { value: 'tool', label: 'Delete generated tool directories, keep recordings' },
476
+ { value: 'site', label: 'Delete the whole local site directory, including recordings' },
477
+ ],
478
+ initialValue: 'tool',
479
+ });
480
+ if (p.isCancel(local)) return;
481
+
482
+ if (local === 'site') {
483
+ const confirm = await p.confirm({
484
+ message:
485
+ 'This will permanently delete the local site directory, including raw recordings. Continue?',
486
+ });
487
+ if (p.isCancel(confirm) || !confirm) return;
488
+ }
489
+
490
+ reportMutation(deleteMcpTarget(String(selectedSite), { local: local as LocalDeleteMode }));
491
+ }
492
+
493
+ function cmdStatus(argv: string[]): number {
494
+ const { flags } = parseSubArgs(argv);
495
+ const site = typeof flags.site === 'string' ? flags.site : undefined;
496
+ const status = scanMcpStatus({ site });
497
+ if (flags.json === true) console.log(JSON.stringify(status, null, 2));
498
+ else console.log(formatMcpStatus(status));
499
+ return status.issues.some((i) => i.kind === 'stale-registration' || i.kind === 'missing-session')
500
+ ? 1
501
+ : 0;
502
+ }
503
+
504
+ function cmdDisable(argv: string[]): number {
505
+ const { positionals, flags } = parseSubArgs(argv);
506
+ const target = positionals[0];
507
+ if (!target) {
508
+ console.error('error: usage: imprint mcp disable <server-or-site> [--client <name|all>] --yes');
509
+ return 2;
510
+ }
511
+ if (flags.yes !== true) return requireYes('disable');
512
+ const client = parseClientFlag(flags.client);
513
+ if (client === null) return 2;
514
+ reportMutation(disableMcpTarget(target, { client }));
515
+ return 0;
516
+ }
517
+
518
+ function cmdEnable(argv: string[]): number {
519
+ const { positionals, flags } = parseSubArgs(argv);
520
+ const target = positionals[0];
521
+ if (!target) {
522
+ console.error('error: usage: imprint mcp enable <server-or-site> [--client <name|all>] --yes');
523
+ return 2;
524
+ }
525
+ if (flags.yes !== true) return requireYes('enable');
526
+ const client = parseClientFlag(flags.client);
527
+ if (client === null) return 2;
528
+ const result = enableMcpTarget(target, { client });
529
+ reportMutation(result);
530
+ return result.skipped.some((s) => s.includes('conflict')) ? 1 : 0;
531
+ }
532
+
533
+ function cmdDelete(argv: string[]): number {
534
+ const { positionals, flags } = parseSubArgs(argv);
535
+ const target = positionals[0];
536
+ if (!target) {
537
+ console.error(
538
+ 'error: usage: imprint mcp delete <server-or-site> [--client <name|all>] [--local none|tool|site] --yes',
539
+ );
540
+ return 2;
541
+ }
542
+ if (flags.yes !== true) return requireYes('delete');
543
+ const client = parseClientFlag(flags.client);
544
+ if (client === null) return 2;
545
+ const local = parseLocalFlag(flags.local);
546
+ if (local === null) return 2;
547
+ reportMutation(deleteMcpTarget(target, { client, local }));
548
+ return 0;
549
+ }
550
+
551
+ function cmdPruneState(argv: string[]): number {
552
+ const { flags } = parseSubArgs(argv);
553
+ if (flags.yes !== true) return requireYes('prune-state');
554
+ const site = typeof flags.site === 'string' ? flags.site : undefined;
555
+ const missingSession = flags['missing-session'] === true;
556
+ const incomplete = flags.incomplete === true;
557
+ reportMutation(
558
+ pruneTeachState({
559
+ site,
560
+ missingSession: missingSession || (!missingSession && !incomplete),
561
+ incomplete: incomplete || (!missingSession && !incomplete),
562
+ }),
563
+ );
564
+ return 0;
565
+ }
566
+
567
+ function requireYes(action: string): number {
568
+ console.error(
569
+ `error: \`imprint mcp ${action}\` mutates local config/state. Re-run with --yes, or run \`imprint mcp\` for the interactive flow.`,
570
+ );
571
+ return 2;
572
+ }
573
+
574
+ function parseClientFlag(raw: string | boolean | undefined): McpClient | 'all' | undefined | null {
575
+ if (raw === undefined) return undefined;
576
+ if (raw === true) {
577
+ console.error(
578
+ 'error: --client requires one of: all, claude-code, codex, claude-desktop, openclaw, hermes',
579
+ );
580
+ return null;
581
+ }
582
+ if (raw === 'all') return 'all';
583
+ if (typeof raw === 'string' && (CLIENTS as string[]).includes(raw)) return raw as McpClient;
584
+ console.error(
585
+ `error: unknown --client "${raw}" — use one of: all, claude-code, codex, claude-desktop, openclaw, hermes`,
586
+ );
587
+ return null;
588
+ }
589
+
590
+ function parseLocalFlag(raw: string | boolean | undefined): LocalDeleteMode | null {
591
+ if (raw === undefined) return 'none';
592
+ if (raw === true) {
593
+ console.error('error: --local requires one of: none, tool, site');
594
+ return null;
595
+ }
596
+ if (raw === 'none' || raw === 'tool' || raw === 'site') return raw;
597
+ console.error(`error: unknown --local "${raw}" — use one of: none, tool, site`);
598
+ return null;
599
+ }
600
+
601
+ function reportMutation(result: MutationResult): void {
602
+ for (const line of result.changed) console.log(`[imprint] ${line}`);
603
+ for (const line of result.skipped) console.log(`[imprint] skipped: ${line}`);
604
+ if (result.changed.length === 0 && result.skipped.length === 0) {
605
+ console.log('[imprint] nothing to change');
606
+ }
607
+ }
608
+
609
+ function registrationKey(reg: McpRegistration): string {
610
+ return [reg.client, reg.name, reg.configPath].join('\u0000');
611
+ }
612
+
613
+ function disabledKey(reg: DisabledMcpRegistration): string {
614
+ return [reg.client, reg.name, reg.configPath].join('\u0000');
615
+ }
616
+
617
+ function registrationLabel(reg: McpRegistration): string {
618
+ const state = reg.enabled ? 'enabled' : 'disabled';
619
+ const scope = reg.scope ? `, ${reg.scope}` : '';
620
+ return `${reg.name} (${reg.client}${scope}, ${state}${reg.site ? `, site ${reg.site}` : ''})`;
621
+ }
622
+
623
+ function disabledLabel(reg: DisabledMcpRegistration): string {
624
+ const scope = reg.scope ? `, ${reg.scope}` : '';
625
+ return `${reg.name} (${reg.client}${scope}${reg.site ? `, site ${reg.site}` : ''})`;
626
+ }
627
+
628
+ function findRegistrationChoice(status: McpStatus, choice: string): McpRegistration | undefined {
629
+ const key = choice.startsWith('registration:') ? choice.slice('registration:'.length) : choice;
630
+ return status.registrations.find((r) => registrationKey(r) === key);
631
+ }
632
+
633
+ function findDisabledChoice(
634
+ status: McpStatus,
635
+ choice: string,
636
+ ): DisabledMcpRegistration | undefined {
637
+ const key = choice.startsWith('snapshot:') ? choice.slice('snapshot:'.length) : choice;
638
+ return status.disabled.find((r) => disabledKey(r) === key);
639
+ }
640
+
641
+ export function scanMcpStatus(
642
+ opts: Partial<MaintenanceContext> & { site?: string } = {},
643
+ ): McpStatus {
644
+ const ctx = defaultContext(opts);
645
+ const registrations = scanRegistrations(ctx).filter((r) => !opts.site || r.site === opts.site);
646
+ const disabled = loadDisabledStore(ctx)
647
+ .disabled.filter((d) => !opts.site || d.site === opts.site)
648
+ .map(publicDisabledSnapshot);
649
+ const sites = scanLocalSites(ctx).filter((s) => !opts.site || s.site === opts.site);
650
+ const issues = collectIssues({ registrations, sites });
651
+ return { imprintHome: ctx.imprintHome, registrations, disabled, sites, issues };
652
+ }
653
+
654
+ function formatMcpStatus(status: McpStatus): string {
655
+ const lines: string[] = ['imprint MCP status', `IMPRINT_HOME: ${status.imprintHome}`, ''];
656
+
657
+ lines.push('Registrations:');
658
+ if (status.registrations.length === 0) {
659
+ lines.push(' none');
660
+ } else {
661
+ for (const r of status.registrations) {
662
+ const state = r.enabled ? 'enabled' : 'disabled';
663
+ lines.push(
664
+ ` ${r.client.padEnd(14)} ${r.name} (${state}${r.site ? `, site: ${r.site}` : ''})`,
665
+ );
666
+ }
667
+ }
668
+
669
+ lines.push('');
670
+ lines.push('Disabled snapshots:');
671
+ if (status.disabled.length === 0) {
672
+ lines.push(' none');
673
+ } else {
674
+ for (const d of status.disabled) {
675
+ lines.push(` ${d.client.padEnd(14)} ${d.name} (${d.site ?? 'unknown site'})`);
676
+ }
677
+ }
678
+
679
+ lines.push('');
680
+ lines.push('Local sites:');
681
+ if (status.sites.length === 0) {
682
+ lines.push(' none');
683
+ } else {
684
+ for (const s of status.sites) {
685
+ const complete = s.tools.filter((t) => t.complete).length;
686
+ const incomplete = s.workflows.filter((w) => w.incomplete).length;
687
+ const missing = s.workflows.filter((w) => w.missingSession).length;
688
+ lines.push(
689
+ ` ${s.site}: ${complete} complete tool${complete === 1 ? '' : 's'}, ${incomplete} incomplete workflow${incomplete === 1 ? '' : 's'}, ${missing} missing-session issue${missing === 1 ? '' : 's'}, ${s.orphanSessions.length} orphan session${s.orphanSessions.length === 1 ? '' : 's'}`,
690
+ );
691
+ }
692
+ }
693
+
694
+ lines.push('');
695
+ lines.push('Issues:');
696
+ if (status.issues.length === 0) {
697
+ lines.push(' none');
698
+ } else {
699
+ for (const issue of status.issues) {
700
+ lines.push(` ${issue.kind}: ${issue.message}`);
701
+ const hint = issueFixHint(issue);
702
+ if (hint) lines.push(` fix: ${hint}`);
703
+ }
704
+ }
705
+
706
+ return lines.join('\n');
707
+ }
708
+
709
+ function issueFixHint(issue: McpIssue): string | null {
710
+ switch (issue.kind) {
711
+ case 'stale-registration':
712
+ return `choose "Fix an issue" or run: imprint mcp delete ${issue.name ?? `imprint-${issue.site}`} --client ${issue.client ?? 'all'} --yes`;
713
+ case 'incomplete':
714
+ return `choose "Fix an issue" or run: imprint mcp prune-state --site ${issue.site} --incomplete --yes`;
715
+ case 'missing-session':
716
+ return `choose "Fix an issue" or run: imprint mcp prune-state --site ${issue.site} --missing-session --yes`;
717
+ case 'orphan-session':
718
+ return 'choose "Fix an issue" to delete this recording, or keep it if you still need it';
719
+ }
720
+ return null;
721
+ }
722
+
723
+ function scanRegistrations(ctx: MaintenanceContext): McpRegistration[] {
724
+ return [
725
+ ...scanCodex(ctx),
726
+ ...scanClaudeCode(ctx),
727
+ ...scanClaudeDesktop(ctx),
728
+ ...scanOpenClaw(ctx),
729
+ ...scanHermes(ctx),
730
+ ].sort((a, b) => `${a.client}:${a.name}`.localeCompare(`${b.client}:${b.name}`));
731
+ }
732
+
733
+ function scanLocalSites(ctx: MaintenanceContext): LocalSiteStatus[] {
734
+ if (!existsSync(ctx.imprintHome)) return [];
735
+ const sites: LocalSiteStatus[] = [];
736
+ for (const entry of readdirSync(ctx.imprintHome).sort()) {
737
+ if (entry === 'node_modules' || entry.startsWith('.')) continue;
738
+ const dir = pathJoin(ctx.imprintHome, entry);
739
+ if (!safeIsDir(dir)) continue;
740
+ sites.push(scanLocalSite(ctx, entry, dir));
741
+ }
742
+ return sites;
743
+ }
744
+
745
+ function scanLocalSite(ctx: MaintenanceContext, site: string, dir: string): LocalSiteStatus {
746
+ const tools: LocalToolStatus[] = [];
747
+ for (const entry of readdirSync(dir).sort()) {
748
+ if (entry === 'sessions' || entry === '_shared' || entry.startsWith('.')) continue;
749
+ const toolDir = pathJoin(dir, entry);
750
+ if (!safeIsDir(toolDir)) continue;
751
+ tools.push({
752
+ site,
753
+ toolName: entry,
754
+ dir: toolDir,
755
+ complete: existsSync(pathJoin(toolDir, 'index.ts')),
756
+ hasWorkflow: existsSync(pathJoin(toolDir, 'workflow.json')),
757
+ hasPlaybook: existsSync(pathJoin(toolDir, 'playbook.yaml')),
758
+ hasBackends: existsSync(pathJoin(toolDir, 'backends.json')),
759
+ hasCron: existsSync(pathJoin(toolDir, 'cron.json')),
760
+ });
761
+ }
762
+
763
+ const state = loadTeachState(site);
764
+ const workflows = Object.entries(state.workflows)
765
+ .map(([name, ws]) => workflowStatus(site, name, ws, tools))
766
+ .sort((a, b) => a.name.localeCompare(b.name));
767
+ const referenced = referencedSessionPaths(site, state);
768
+ const orphanSessions = discoverSessionFiles(pathJoin(dir, 'sessions')).filter(
769
+ (session) => !isReferencedSessionFile(site, session, ctx, referenced),
770
+ );
771
+
772
+ return { site, dir, tools, workflows, orphanSessions };
773
+ }
774
+
775
+ function workflowStatus(
776
+ site: string,
777
+ name: string,
778
+ ws: WorkflowState,
779
+ tools: LocalToolStatus[],
780
+ ): LocalWorkflowStatus {
781
+ const sessionPath = resolveTeachStatePath(site, ws.sessionPath);
782
+ const redactedPath = resolveTeachStatePath(site, ws.redactedPath);
783
+ const hasSession = !!sessionPath && existsSync(sessionPath);
784
+ const hasRedacted = !ws.redactedPath || (!!redactedPath && existsSync(redactedPath));
785
+ const matchingTool = tools.find(
786
+ (t) => t.toolName === name || workflowJsonToolName(t.dir) === name,
787
+ );
788
+ const completeTool = matchingTool?.complete === true;
789
+ const hasEmit = ws.completedSteps.includes('emit');
790
+ const hasRegister = ws.completedSteps.includes('register');
791
+
792
+ return {
793
+ site,
794
+ name,
795
+ sessionPath,
796
+ redactedPath,
797
+ completedSteps: [...ws.completedSteps],
798
+ missingSession: !hasSession || !hasRedacted,
799
+ incomplete: !hasEmit || !hasRegister || !completeTool,
800
+ error: ws.error,
801
+ updatedAt: ws.updatedAt,
802
+ };
803
+ }
804
+
805
+ function workflowJsonToolName(toolDir: string): string | null {
806
+ const path = pathJoin(toolDir, 'workflow.json');
807
+ if (!existsSync(path)) return null;
808
+ try {
809
+ const raw = JSON.parse(readFileSync(path, 'utf8')) as { toolName?: unknown };
810
+ return typeof raw.toolName === 'string' ? raw.toolName : null;
811
+ } catch {
812
+ return null;
813
+ }
814
+ }
815
+
816
+ function referencedSessionPaths(site: string, state: TeachState): Set<string> {
817
+ const out = new Set<string>();
818
+ for (const ws of Object.values(state.workflows)) {
819
+ for (const stored of [ws.sessionPath, ws.redactedPath, ws.triagedPath]) {
820
+ if (!stored) continue;
821
+ out.add(stored);
822
+ const resolved = resolveTeachStatePath(site, stored);
823
+ if (resolved) out.add(resolved);
824
+ }
825
+ }
826
+ return out;
827
+ }
828
+
829
+ function discoverSessionFiles(sessionDir: string): string[] {
830
+ if (!existsSync(sessionDir)) return [];
831
+ return readdirSync(sessionDir)
832
+ .filter((f) => (f.endsWith('.json') || f.endsWith('.jsonl')) && !f.includes('.triaged'))
833
+ .map((f) => pathJoin(sessionDir, f))
834
+ .sort();
835
+ }
836
+
837
+ function isReferencedSessionFile(
838
+ site: string,
839
+ absolutePath: string,
840
+ ctx: MaintenanceContext,
841
+ referenced: Set<string>,
842
+ ): boolean {
843
+ const candidates = [absolutePath, relativeToSite(site, absolutePath, ctx)];
844
+ if (absolutePath.endsWith('.jsonl')) {
845
+ const jsonPath = absolutePath.replace(/\.jsonl$/, '.json');
846
+ candidates.push(jsonPath, relativeToSite(site, jsonPath, ctx));
847
+ }
848
+ return candidates.some((candidate) => referenced.has(candidate));
849
+ }
850
+
851
+ function relativeToSite(site: string, absolutePath: string, ctx: MaintenanceContext): string {
852
+ const siteDir = pathJoin(ctx.imprintHome, site);
853
+ const prefix = `${siteDir}/`;
854
+ return absolutePath.startsWith(prefix) ? absolutePath.slice(prefix.length) : absolutePath;
855
+ }
856
+
857
+ function collectIssues(opts: {
858
+ registrations: McpRegistration[];
859
+ sites: LocalSiteStatus[];
860
+ }): McpIssue[] {
861
+ const issues: McpIssue[] = [];
862
+ const sitesByName = new Map(opts.sites.map((s) => [s.site, s]));
863
+
864
+ for (const site of opts.sites) {
865
+ for (const wf of site.workflows) {
866
+ if (wf.missingSession) {
867
+ issues.push({
868
+ kind: 'missing-session',
869
+ site: site.site,
870
+ workflow: wf.name,
871
+ message: `${site.site}/${wf.name} references a missing session file`,
872
+ path: wf.sessionPath ?? undefined,
873
+ });
874
+ }
875
+ if (wf.incomplete) {
876
+ issues.push({
877
+ kind: 'incomplete',
878
+ site: site.site,
879
+ workflow: wf.name,
880
+ message: `${site.site}/${wf.name} is incomplete (${wf.completedSteps.join(', ') || 'no completed steps'})`,
881
+ });
882
+ }
883
+ }
884
+ for (const session of site.orphanSessions) {
885
+ issues.push({
886
+ kind: 'orphan-session',
887
+ site: site.site,
888
+ message: `${site.site} has an untracked session ${session}`,
889
+ path: session,
890
+ });
891
+ }
892
+ }
893
+
894
+ for (const r of opts.registrations) {
895
+ if (!r.site) continue;
896
+ const site = sitesByName.get(r.site);
897
+ if (!site || site.tools.every((t) => !t.complete)) {
898
+ issues.push({
899
+ kind: 'stale-registration',
900
+ site: r.site,
901
+ client: r.client,
902
+ name: r.name,
903
+ configPath: r.configPath,
904
+ message: `${r.client}/${r.name} points at site "${r.site}" but no complete generated tool exists`,
905
+ });
906
+ }
907
+ }
908
+
909
+ return issues.sort((a, b) =>
910
+ `${a.kind}:${a.site}:${a.workflow ?? a.name ?? ''}`.localeCompare(
911
+ `${b.kind}:${b.site}:${b.workflow ?? b.name ?? ''}`,
912
+ ),
913
+ );
914
+ }
915
+
916
+ export function disableMcpTarget(
917
+ target: string,
918
+ opts: Partial<MaintenanceContext> & { client?: McpClient | 'all' } = {},
919
+ ): MutationResult {
920
+ const ctx = defaultContext(opts);
921
+ const regs = scanRegistrations(ctx).filter((r) => matchesTarget(r, target, opts.client));
922
+ const result: MutationResult = { changed: [], skipped: [] };
923
+ if (regs.length === 0) result.skipped.push(`no active registration matched "${target}"`);
924
+
925
+ for (const reg of regs) {
926
+ appendMutation(result, disableRegistration(reg, ctx));
927
+ }
928
+ return result;
929
+ }
930
+
931
+ function disableRegistration(reg: McpRegistration, ctx: MaintenanceContext): MutationResult {
932
+ const result: MutationResult = { changed: [], skipped: [] };
933
+ if (!reg.enabled) {
934
+ result.skipped.push(`${reg.client}/${reg.name} is already disabled`);
935
+ return result;
936
+ }
937
+ if (reg.client === 'codex') {
938
+ if (setCodexEnabled(reg.configPath, reg.name, false)) {
939
+ result.changed.push(`disabled ${reg.client}/${reg.name}`);
940
+ } else {
941
+ result.skipped.push(`could not disable ${reg.client}/${reg.name}`);
942
+ }
943
+ return result;
944
+ }
945
+ const server = reg.server ?? fallbackServerConfig(reg);
946
+ if (!server) {
947
+ result.skipped.push(`${reg.client}/${reg.name} is not restorable (missing server config)`);
948
+ return result;
949
+ }
950
+ const removed = removeRegistration(reg);
951
+ if (removed) {
952
+ addDisabledSnapshot(ctx, {
953
+ client: reg.client,
954
+ name: reg.name,
955
+ site: reg.site,
956
+ configPath: reg.configPath,
957
+ scope: reg.scope,
958
+ command: reg.command,
959
+ args: reg.args,
960
+ server,
961
+ disabledAt: new Date().toISOString(),
962
+ });
963
+ result.changed.push(`disabled ${reg.client}/${reg.name}`);
964
+ } else {
965
+ result.skipped.push(`could not disable ${reg.client}/${reg.name}`);
966
+ }
967
+ return result;
968
+ }
969
+
970
+ export function enableMcpTarget(
971
+ target: string,
972
+ opts: Partial<MaintenanceContext> & { client?: McpClient | 'all' } = {},
973
+ ): MutationResult {
974
+ const ctx = defaultContext(opts);
975
+ const result: MutationResult = { changed: [], skipped: [] };
976
+ const codexMatches = scanCodex(ctx).filter((r) => matchesTarget(r, target, opts.client));
977
+ for (const reg of codexMatches) {
978
+ appendMutation(result, enableRegistration(reg));
979
+ }
980
+
981
+ const store = loadDisabledStore(ctx);
982
+ const keep: DisabledMcpRegistration[] = [];
983
+ for (const snap of store.disabled) {
984
+ if (!matchesDisabledTarget(snap, target, opts.client)) {
985
+ keep.push(snap);
986
+ continue;
987
+ }
988
+ if (registrationExists(snap)) {
989
+ result.skipped.push(
990
+ `conflict: ${snap.client}/${snap.name} already exists in ${snap.configPath}`,
991
+ );
992
+ keep.push(snap);
993
+ continue;
994
+ }
995
+ if (restoreDisabledSnapshot(snap)) {
996
+ result.changed.push(`enabled ${snap.client}/${snap.name}`);
997
+ } else {
998
+ result.skipped.push(`could not restore ${snap.client}/${snap.name}`);
999
+ keep.push(snap);
1000
+ }
1001
+ }
1002
+ if (keep.length !== store.disabled.length) saveDisabledStore(ctx, { ...store, disabled: keep });
1003
+ if (result.changed.length === 0 && result.skipped.length === 0) {
1004
+ result.skipped.push(`no disabled registration matched "${target}"`);
1005
+ }
1006
+ return result;
1007
+ }
1008
+
1009
+ function enableRegistration(reg: McpRegistration): MutationResult {
1010
+ const result: MutationResult = { changed: [], skipped: [] };
1011
+ if (reg.client !== 'codex') {
1012
+ result.skipped.push(`${reg.client}/${reg.name} is not a native disabled registration`);
1013
+ } else if (reg.enabled) {
1014
+ result.skipped.push(`${reg.client}/${reg.name} is already enabled`);
1015
+ } else if (setCodexEnabled(reg.configPath, reg.name, true)) {
1016
+ result.changed.push(`enabled ${reg.client}/${reg.name}`);
1017
+ } else {
1018
+ result.skipped.push(`could not enable ${reg.client}/${reg.name}`);
1019
+ }
1020
+ return result;
1021
+ }
1022
+
1023
+ function enableDisabledSnapshot(
1024
+ ctx: MaintenanceContext,
1025
+ snap: DisabledMcpRegistration,
1026
+ ): MutationResult {
1027
+ const result: MutationResult = { changed: [], skipped: [] };
1028
+ const store = loadDisabledStore(ctx);
1029
+ const key = disabledKey(snap);
1030
+ const fullSnap = store.disabled.find((d) => disabledKey(d) === key);
1031
+ if (!fullSnap) {
1032
+ result.skipped.push(`no disabled registration matched ${snap.client}/${snap.name}`);
1033
+ return result;
1034
+ }
1035
+ if (registrationExists(fullSnap)) {
1036
+ result.skipped.push(
1037
+ `conflict: ${fullSnap.client}/${fullSnap.name} already exists in ${fullSnap.configPath}`,
1038
+ );
1039
+ return result;
1040
+ }
1041
+ if (restoreDisabledSnapshot(fullSnap)) {
1042
+ result.changed.push(`enabled ${fullSnap.client}/${fullSnap.name}`);
1043
+ saveDisabledStore(ctx, {
1044
+ ...store,
1045
+ disabled: store.disabled.filter((d) => disabledKey(d) !== key),
1046
+ });
1047
+ } else {
1048
+ result.skipped.push(`could not restore ${fullSnap.client}/${fullSnap.name}`);
1049
+ }
1050
+ return result;
1051
+ }
1052
+
1053
+ function deleteMcpTarget(
1054
+ target: string,
1055
+ opts: Partial<MaintenanceContext> & {
1056
+ client?: McpClient | 'all';
1057
+ local?: LocalDeleteMode;
1058
+ } = {},
1059
+ ): MutationResult {
1060
+ const ctx = defaultContext(opts);
1061
+ const regs = scanRegistrations(ctx).filter((r) => matchesTarget(r, target, opts.client));
1062
+ const result: MutationResult = { changed: [], skipped: [] };
1063
+ const local = opts.local ?? 'none';
1064
+ if (regs.length === 0 && local === 'none') {
1065
+ result.skipped.push(`no active registration matched "${target}"`);
1066
+ }
1067
+
1068
+ const sites = new Set<string>();
1069
+ for (const reg of regs) {
1070
+ if (reg.site) sites.add(reg.site);
1071
+ if (removeRegistration(reg)) {
1072
+ result.changed.push(`deleted ${reg.client}/${reg.name}`);
1073
+ } else {
1074
+ result.skipped.push(`could not delete ${reg.client}/${reg.name}`);
1075
+ }
1076
+ }
1077
+
1078
+ const targetSite = target.startsWith('imprint-') ? target.slice('imprint-'.length) : target;
1079
+ if (sites.size === 0 && local !== 'none') {
1080
+ const targetSiteDir = localSiteDirForContext(ctx, targetSite, result);
1081
+ if (targetSiteDir && existsSync(targetSiteDir)) sites.add(targetSite);
1082
+ }
1083
+
1084
+ if (local !== 'none') deleteLocalArtifactsForSites(ctx, sites, local, result);
1085
+
1086
+ return result;
1087
+ }
1088
+
1089
+ function deleteRegistration(
1090
+ reg: McpRegistration,
1091
+ ctx: MaintenanceContext,
1092
+ local: LocalDeleteMode,
1093
+ ): MutationResult {
1094
+ const result: MutationResult = { changed: [], skipped: [] };
1095
+ const sites = new Set<string>();
1096
+ if (reg.site) sites.add(reg.site);
1097
+ if (removeRegistration(reg)) {
1098
+ result.changed.push(`deleted ${reg.client}/${reg.name}`);
1099
+ } else {
1100
+ result.skipped.push(`could not delete ${reg.client}/${reg.name}`);
1101
+ }
1102
+ if (local !== 'none') deleteLocalArtifactsForSites(ctx, sites, local, result);
1103
+ return result;
1104
+ }
1105
+
1106
+ function deleteLocalArtifactsForSites(
1107
+ ctx: MaintenanceContext,
1108
+ sites: Set<string>,
1109
+ local: LocalDeleteMode,
1110
+ result: MutationResult,
1111
+ ): void {
1112
+ for (const site of sites) {
1113
+ const siteDir = localSiteDirForContext(ctx, site, result);
1114
+ if (!siteDir) continue;
1115
+ if (!existsSync(siteDir)) {
1116
+ result.skipped.push(`local site ${site} does not exist`);
1117
+ continue;
1118
+ }
1119
+ if (local === 'site') {
1120
+ rmSync(siteDir, { recursive: true, force: true });
1121
+ result.changed.push(`deleted local site ${siteDir}`);
1122
+ } else if (local === 'tool') {
1123
+ let count = 0;
1124
+ for (const entry of readdirSync(siteDir)) {
1125
+ if (entry === 'sessions' || entry === '_shared' || entry.startsWith('.')) continue;
1126
+ const toolDir = pathJoin(siteDir, entry);
1127
+ if (!safeIsDir(toolDir)) continue;
1128
+ rmSync(toolDir, { recursive: true, force: true });
1129
+ count++;
1130
+ }
1131
+ result.changed.push(
1132
+ `deleted ${count} generated tool director${count === 1 ? 'y' : 'ies'} under ${siteDir}`,
1133
+ );
1134
+ }
1135
+ }
1136
+ }
1137
+
1138
+ function localSiteDirForContext(
1139
+ ctx: MaintenanceContext,
1140
+ site: string,
1141
+ result: MutationResult,
1142
+ ): string | null {
1143
+ if (!site || site.includes('..') || site.includes('/') || site.includes('\\')) {
1144
+ result.skipped.push(
1145
+ `invalid local site "${site}": must not contain path separators or ".." sequences`,
1146
+ );
1147
+ return null;
1148
+ }
1149
+ const root = pathResolve(ctx.imprintHome);
1150
+ const siteDir = pathResolve(root, site);
1151
+ const relative = pathRelative(root, siteDir);
1152
+ if (relative === '' || relative.startsWith('..') || pathIsAbsolute(relative)) {
1153
+ result.skipped.push(`refusing to delete local site outside IMPRINT_HOME: ${site}`);
1154
+ return null;
1155
+ }
1156
+ return siteDir;
1157
+ }
1158
+
1159
+ function appendMutation(target: MutationResult, source: MutationResult): void {
1160
+ target.changed.push(...source.changed);
1161
+ target.skipped.push(...source.skipped);
1162
+ }
1163
+
1164
+ function pruneTeachState(
1165
+ opts: Partial<MaintenanceContext> & {
1166
+ site?: string;
1167
+ missingSession?: boolean;
1168
+ incomplete?: boolean;
1169
+ } = {},
1170
+ ): MutationResult {
1171
+ const ctx = defaultContext(opts);
1172
+ const result: MutationResult = { changed: [], skipped: [] };
1173
+ const sites = opts.site ? [opts.site] : scanLocalSites(ctx).map((s) => s.site);
1174
+ for (const site of sites) {
1175
+ const statePath = teachStatePath(site);
1176
+ if (!existsSync(statePath)) continue;
1177
+ const status = scanLocalSite(ctx, site, localSiteDir(site));
1178
+ const remove = new Set(
1179
+ status.workflows
1180
+ .filter(
1181
+ (wf) => (opts.missingSession && wf.missingSession) || (opts.incomplete && wf.incomplete),
1182
+ )
1183
+ .map((wf) => wf.name),
1184
+ );
1185
+ if (remove.size === 0) continue;
1186
+ const state = loadTeachState(site);
1187
+ for (const key of remove) delete state.workflows[key];
1188
+ saveTeachState(site, state);
1189
+ result.changed.push(
1190
+ `pruned ${remove.size} teach-state entr${remove.size === 1 ? 'y' : 'ies'} from ${site}`,
1191
+ );
1192
+ }
1193
+ if (result.changed.length === 0) result.skipped.push('no matching teach-state entries found');
1194
+ return result;
1195
+ }
1196
+
1197
+ function pruneSingleTeachWorkflow(site: string, workflow: string): MutationResult {
1198
+ const statePath = teachStatePath(site);
1199
+ if (!existsSync(statePath)) {
1200
+ return { changed: [], skipped: [`teach state for ${site} does not exist`] };
1201
+ }
1202
+ const state = loadTeachState(site);
1203
+ if (!(workflow in state.workflows)) {
1204
+ return { changed: [], skipped: [`${site}/${workflow} is not in teach state`] };
1205
+ }
1206
+ delete state.workflows[workflow];
1207
+ saveTeachState(site, state);
1208
+ return { changed: [`pruned teach-state entry ${site}/${workflow}`], skipped: [] };
1209
+ }
1210
+
1211
+ function deleteOrphanSessionFile(path: string): MutationResult {
1212
+ if (!existsSync(path))
1213
+ return { changed: [], skipped: [`orphan session ${path} no longer exists`] };
1214
+ rmSync(path, { force: true });
1215
+ return { changed: [`deleted orphan session ${path}`], skipped: [] };
1216
+ }
1217
+
1218
+ function matchesTarget(
1219
+ reg: McpRegistration,
1220
+ target: string,
1221
+ client: McpClient | 'all' | undefined,
1222
+ ): boolean {
1223
+ if (client && client !== 'all' && reg.client !== client) return false;
1224
+ const targetSite = target.startsWith('imprint-') ? target.slice('imprint-'.length) : target;
1225
+ return reg.name === target || reg.name === `imprint-${target}` || reg.site === targetSite;
1226
+ }
1227
+
1228
+ function matchesDisabledTarget(
1229
+ reg: DisabledMcpRegistration,
1230
+ target: string,
1231
+ client: McpClient | 'all' | undefined,
1232
+ ): boolean {
1233
+ if (client && client !== 'all' && reg.client !== client) return false;
1234
+ const targetSite = target.startsWith('imprint-') ? target.slice('imprint-'.length) : target;
1235
+ return reg.name === target || reg.name === `imprint-${target}` || reg.site === targetSite;
1236
+ }
1237
+
1238
+ function isImprintRegistration(name: string, command?: string, args?: string[]): boolean {
1239
+ return name.startsWith('imprint-') || extractMcpSite(command, args) !== null;
1240
+ }
1241
+
1242
+ function extractMcpSite(command?: string, args?: string[]): string | null {
1243
+ if (!command || !args) return null;
1244
+ const directImprint = command === 'imprint' || command.endsWith('/imprint');
1245
+ const bunRunsImprintCli =
1246
+ command === 'bun' &&
1247
+ args.some((arg) => arg === 'imprint' || arg.endsWith('/imprint') || arg.endsWith('src/cli.ts'));
1248
+ if (!directImprint && !bunRunsImprintCli) return null;
1249
+ const idx = args.indexOf('mcp-server');
1250
+ if (idx === -1) return null;
1251
+ const site = args[idx + 1];
1252
+ return site && !site.startsWith('-') ? site : null;
1253
+ }
1254
+
1255
+ function safeIsDir(path: string): boolean {
1256
+ try {
1257
+ return statSync(path).isDirectory();
1258
+ } catch {
1259
+ return false;
1260
+ }
1261
+ }
1262
+
1263
+ // ─── Codex TOML adapter ─────────────────────────────────────────────────────
1264
+
1265
+ function scanCodex(ctx: MaintenanceContext): McpRegistration[] {
1266
+ const configs = [
1267
+ { configPath: pathJoin(ctx.homeDir, '.codex', 'config.toml'), scope: 'user' },
1268
+ { configPath: pathJoin(ctx.cwd, '.codex', 'config.toml'), scope: 'project' },
1269
+ ];
1270
+ const seen = new Set<string>();
1271
+ return configs.flatMap(({ configPath, scope }) => {
1272
+ if (seen.has(configPath)) return [];
1273
+ seen.add(configPath);
1274
+ return scanCodexConfig(configPath, scope);
1275
+ });
1276
+ }
1277
+
1278
+ function scanCodexConfig(configPath: string, scope: string): McpRegistration[] {
1279
+ if (!existsSync(configPath)) return [];
1280
+ const src = readFileSync(configPath, 'utf8');
1281
+ const out: McpRegistration[] = [];
1282
+ for (const section of findTomlMcpSections(src)) {
1283
+ const parsed = parseTomlSection(section.body);
1284
+ const command = typeof parsed.command === 'string' ? parsed.command : undefined;
1285
+ const args = Array.isArray(parsed.args)
1286
+ ? parsed.args.filter((a) => typeof a === 'string')
1287
+ : undefined;
1288
+ if (!isImprintRegistration(section.name, command, args)) continue;
1289
+ out.push(
1290
+ withServerConfig(
1291
+ {
1292
+ client: 'codex',
1293
+ name: section.name,
1294
+ site: extractMcpSite(command, args) ?? siteFromName(section.name),
1295
+ configPath,
1296
+ scope,
1297
+ enabled: parsed.enabled !== false,
1298
+ command,
1299
+ args,
1300
+ },
1301
+ parsed,
1302
+ ),
1303
+ );
1304
+ }
1305
+ return out;
1306
+ }
1307
+
1308
+ function findTomlMcpSections(
1309
+ src: string,
1310
+ ): Array<{ name: string; start: number; end: number; body: string }> {
1311
+ return findTomlTableSections(src)
1312
+ .filter((section) => section.path.length === 2 && section.path[0] === 'mcp_servers')
1313
+ .map((section) => ({
1314
+ name: section.path[1] ?? '',
1315
+ start: section.start,
1316
+ end: section.end,
1317
+ body: section.body,
1318
+ }));
1319
+ }
1320
+
1321
+ function findTomlTableSections(
1322
+ src: string,
1323
+ ): Array<{ path: string[]; start: number; end: number; body: string }> {
1324
+ const lines = src.split(/(?<=\n)/);
1325
+ const sections: Array<{ path: string[]; startLine: number; endLine: number }> = [];
1326
+ for (let i = 0; i < lines.length; i++) {
1327
+ const m = lines[i]?.match(/^\s*\[([^\]]+)\]\s*$/);
1328
+ const path = m?.[1] ? parseTomlDottedKey(m[1]) : null;
1329
+ if (path) sections.push({ path, startLine: i, endLine: lines.length });
1330
+ }
1331
+ for (let i = 0; i < sections.length; i++) {
1332
+ const section = sections[i];
1333
+ if (!section) continue;
1334
+ for (let j = section.startLine + 1; j < lines.length; j++) {
1335
+ if (/^\s*\[/.test(lines[j] ?? '')) {
1336
+ section.endLine = j;
1337
+ break;
1338
+ }
1339
+ }
1340
+ }
1341
+ return sections.map((s) => ({
1342
+ path: s.path,
1343
+ start: lineOffset(lines, s.startLine),
1344
+ end: lineOffset(lines, s.endLine),
1345
+ body: lines.slice(s.startLine + 1, s.endLine).join(''),
1346
+ }));
1347
+ }
1348
+
1349
+ function parseTomlDottedKey(raw: string): string[] | null {
1350
+ const parts: string[] = [];
1351
+ let i = 0;
1352
+ const src = raw.trim();
1353
+ while (i < src.length) {
1354
+ while (/\s/.test(src[i] ?? '')) i++;
1355
+ const quote = src[i];
1356
+ if (quote === '"' || quote === "'") {
1357
+ i++;
1358
+ let value = '';
1359
+ while (i < src.length) {
1360
+ const ch = src[i] ?? '';
1361
+ if (ch === quote) {
1362
+ i++;
1363
+ break;
1364
+ }
1365
+ if (quote === '"' && ch === '\\' && i + 1 < src.length) {
1366
+ value += ch + (src[i + 1] ?? '');
1367
+ i += 2;
1368
+ } else {
1369
+ value += ch;
1370
+ i++;
1371
+ }
1372
+ }
1373
+ parts.push(unquoteTomlKey(`${quote}${value}${quote}`));
1374
+ } else {
1375
+ const start = i;
1376
+ while (i < src.length && src[i] !== '.') i++;
1377
+ const value = src.slice(start, i).trim();
1378
+ if (!value) return null;
1379
+ parts.push(value);
1380
+ }
1381
+ while (/\s/.test(src[i] ?? '')) i++;
1382
+ if (i >= src.length) break;
1383
+ if (src[i] !== '.') return null;
1384
+ i++;
1385
+ }
1386
+ return parts.length > 0 ? parts : null;
1387
+ }
1388
+
1389
+ function lineOffset(lines: string[], line: number): number {
1390
+ let offset = 0;
1391
+ for (let i = 0; i < line; i++) offset += lines[i]?.length ?? 0;
1392
+ return offset;
1393
+ }
1394
+
1395
+ function unquoteTomlKey(value: string): string {
1396
+ const v = value.trim();
1397
+ if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) {
1398
+ return v.slice(1, -1);
1399
+ }
1400
+ return v;
1401
+ }
1402
+
1403
+ function parseTomlSection(body: string): Record<string, unknown> {
1404
+ try {
1405
+ const parsed = Bun.TOML.parse(`[x]\n${body}`) as { x?: Record<string, unknown> };
1406
+ return parsed.x ?? {};
1407
+ } catch {
1408
+ return {};
1409
+ }
1410
+ }
1411
+
1412
+ export function setCodexEnabled(configPath: string, name: string, enabled: boolean): boolean {
1413
+ if (!existsSync(configPath)) return false;
1414
+ const src = readFileSync(configPath, 'utf8');
1415
+ const section = findTomlMcpSections(src).find((s) => s.name === name);
1416
+ if (!section) return false;
1417
+ const block = src.slice(section.start, section.end);
1418
+ const lines = block.split(/(?<=\n)/);
1419
+ const idx = lines.findIndex((line, i) => i > 0 && /^\s*enabled\s*=/.test(line));
1420
+ if (idx >= 0) {
1421
+ lines[idx] = `enabled = ${enabled ? 'true' : 'false'}\n`;
1422
+ } else {
1423
+ lines.splice(1, 0, `enabled = ${enabled ? 'true' : 'false'}\n`);
1424
+ }
1425
+ writeFileAtomic(
1426
+ configPath,
1427
+ `${src.slice(0, section.start)}${lines.join('')}${src.slice(section.end)}`,
1428
+ );
1429
+ return true;
1430
+ }
1431
+
1432
+ function removeCodexRegistration(configPath: string, name: string): boolean {
1433
+ if (!existsSync(configPath)) return false;
1434
+ const src = readFileSync(configPath, 'utf8');
1435
+ const sections = findTomlTableSections(src).filter(
1436
+ (s) => s.path[0] === 'mcp_servers' && s.path[1] === name,
1437
+ );
1438
+ if (sections.length === 0) return false;
1439
+ let next = src;
1440
+ for (const section of sections.sort((a, b) => b.start - a.start)) {
1441
+ next = `${next.slice(0, section.start)}${next.slice(section.end)}`;
1442
+ }
1443
+ writeFileAtomic(configPath, next);
1444
+ return true;
1445
+ }
1446
+
1447
+ // ─── JSON/YAML adapters ─────────────────────────────────────────────────────
1448
+
1449
+ function scanClaudeCode(ctx: MaintenanceContext): McpRegistration[] {
1450
+ const paths = [
1451
+ { path: pathJoin(ctx.homeDir, '.claude', 'settings.json'), scope: 'user' },
1452
+ { path: pathJoin(ctx.cwd, '.mcp.json'), scope: 'project' },
1453
+ ];
1454
+ return paths.flatMap(({ path, scope }) =>
1455
+ scanJsonMap(path, ['mcpServers'], 'claude-code', scope),
1456
+ );
1457
+ }
1458
+
1459
+ function scanClaudeDesktop(ctx: MaintenanceContext): McpRegistration[] {
1460
+ return scanJsonMap(
1461
+ pathJoin(ctx.homeDir, 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json'),
1462
+ ['mcpServers'],
1463
+ 'claude-desktop',
1464
+ );
1465
+ }
1466
+
1467
+ function scanOpenClaw(ctx: MaintenanceContext): McpRegistration[] {
1468
+ return scanJsonMap(
1469
+ pathJoin(ctx.homeDir, '.openclaw', 'openclaw.json'),
1470
+ ['mcp', 'servers'],
1471
+ 'openclaw',
1472
+ );
1473
+ }
1474
+
1475
+ function scanJsonMap(
1476
+ configPath: string,
1477
+ objectPath: string[],
1478
+ client: McpClient,
1479
+ scope?: string,
1480
+ ): McpRegistration[] {
1481
+ if (!existsSync(configPath)) return [];
1482
+ const root = readJsonObject(configPath);
1483
+ const servers = getNestedObject(root, objectPath, false);
1484
+ if (!servers) return [];
1485
+ const out: McpRegistration[] = [];
1486
+ for (const [name, value] of Object.entries(servers)) {
1487
+ if (!value || typeof value !== 'object') continue;
1488
+ const record = value as Record<string, unknown>;
1489
+ const command = typeof record.command === 'string' ? record.command : undefined;
1490
+ const args = Array.isArray(record.args)
1491
+ ? record.args.filter((a) => typeof a === 'string')
1492
+ : undefined;
1493
+ if (!isImprintRegistration(name, command, args)) continue;
1494
+ out.push(
1495
+ withServerConfig(
1496
+ {
1497
+ client,
1498
+ name,
1499
+ site: extractMcpSite(command, args) ?? siteFromName(name),
1500
+ configPath,
1501
+ scope,
1502
+ enabled: true,
1503
+ command,
1504
+ args,
1505
+ },
1506
+ record,
1507
+ ),
1508
+ );
1509
+ }
1510
+ return out;
1511
+ }
1512
+
1513
+ function scanHermes(ctx: MaintenanceContext): McpRegistration[] {
1514
+ const configPath = pathJoin(ctx.homeDir, '.hermes', 'config.yaml');
1515
+ if (!existsSync(configPath)) return [];
1516
+ const doc = YAML.parseDocument(readFileSync(configPath, 'utf8'));
1517
+ const servers = doc.get('mcp_servers', true);
1518
+ if (!YAML.isMap(servers)) return [];
1519
+ const out: McpRegistration[] = [];
1520
+ for (const item of servers.items) {
1521
+ const keyValue = (item.key as { value?: unknown } | null | undefined)?.value;
1522
+ const name = String(keyValue ?? item.key?.toString() ?? '');
1523
+ const value = (
1524
+ item.value as { toJSON?: () => unknown } | null | undefined
1525
+ )?.toJSON?.() as Record<string, unknown> | null;
1526
+ if (!value || typeof value !== 'object') continue;
1527
+ const command = typeof value.command === 'string' ? value.command : undefined;
1528
+ const args = Array.isArray(value.args)
1529
+ ? value.args.filter((a) => typeof a === 'string')
1530
+ : undefined;
1531
+ if (!isImprintRegistration(name, command, args)) continue;
1532
+ out.push(
1533
+ withServerConfig(
1534
+ {
1535
+ client: 'hermes',
1536
+ name,
1537
+ site: extractMcpSite(command, args) ?? siteFromName(name),
1538
+ configPath,
1539
+ enabled: true,
1540
+ command,
1541
+ args,
1542
+ },
1543
+ value,
1544
+ ),
1545
+ );
1546
+ }
1547
+ return out;
1548
+ }
1549
+
1550
+ function siteFromName(name: string): string | null {
1551
+ return name.startsWith('imprint-') ? name.slice('imprint-'.length) : null;
1552
+ }
1553
+
1554
+ function removeRegistration(reg: McpRegistration): boolean {
1555
+ switch (reg.client) {
1556
+ case 'codex':
1557
+ return removeCodexRegistration(reg.configPath, reg.name);
1558
+ case 'claude-code':
1559
+ return removeJsonRegistration(reg.configPath, ['mcpServers'], reg.name);
1560
+ case 'claude-desktop':
1561
+ return removeJsonRegistration(reg.configPath, ['mcpServers'], reg.name);
1562
+ case 'openclaw':
1563
+ return removeJsonRegistration(reg.configPath, ['mcp', 'servers'], reg.name);
1564
+ case 'hermes':
1565
+ return removeHermesRegistration(reg.configPath, reg.name);
1566
+ }
1567
+ }
1568
+
1569
+ function restoreDisabledSnapshot(snap: DisabledMcpRegistration): boolean {
1570
+ switch (snap.client) {
1571
+ case 'codex':
1572
+ return false;
1573
+ case 'claude-code':
1574
+ case 'claude-desktop':
1575
+ return restoreJsonRegistration(snap.configPath, ['mcpServers'], snap);
1576
+ case 'openclaw':
1577
+ return restoreJsonRegistration(snap.configPath, ['mcp', 'servers'], snap);
1578
+ case 'hermes':
1579
+ return restoreHermesRegistration(snap.configPath, snap);
1580
+ }
1581
+ }
1582
+
1583
+ function registrationExists(snap: DisabledMcpRegistration): boolean {
1584
+ switch (snap.client) {
1585
+ case 'codex':
1586
+ return scanCodex(defaultContext()).some((r) => r.name === snap.name);
1587
+ case 'claude-code':
1588
+ case 'claude-desktop': {
1589
+ const root = readJsonObject(snap.configPath);
1590
+ return snap.name in (getNestedObject(root, ['mcpServers'], false) ?? {});
1591
+ }
1592
+ case 'openclaw': {
1593
+ const root = readJsonObject(snap.configPath);
1594
+ return snap.name in (getNestedObject(root, ['mcp', 'servers'], false) ?? {});
1595
+ }
1596
+ case 'hermes': {
1597
+ if (!existsSync(snap.configPath)) return false;
1598
+ const doc = YAML.parseDocument(readFileSync(snap.configPath, 'utf8'));
1599
+ const servers = doc.get('mcp_servers', true);
1600
+ return YAML.isMap(servers) && servers.has(snap.name);
1601
+ }
1602
+ }
1603
+ }
1604
+
1605
+ function readJsonObject(path: string): Record<string, unknown> {
1606
+ if (!existsSync(path)) return {};
1607
+ try {
1608
+ const raw = JSON.parse(readFileSync(path, 'utf8'));
1609
+ return raw && typeof raw === 'object' && !Array.isArray(raw)
1610
+ ? (raw as Record<string, unknown>)
1611
+ : {};
1612
+ } catch {
1613
+ return {};
1614
+ }
1615
+ }
1616
+
1617
+ function getNestedObject(
1618
+ root: Record<string, unknown>,
1619
+ path: string[],
1620
+ create: boolean,
1621
+ ): Record<string, unknown> | null {
1622
+ let cur: Record<string, unknown> = root;
1623
+ for (const key of path) {
1624
+ const next = cur[key];
1625
+ if (!next || typeof next !== 'object' || Array.isArray(next)) {
1626
+ if (!create) return null;
1627
+ cur[key] = {};
1628
+ }
1629
+ cur = cur[key] as Record<string, unknown>;
1630
+ }
1631
+ return cur;
1632
+ }
1633
+
1634
+ function removeJsonRegistration(configPath: string, objectPath: string[], name: string): boolean {
1635
+ if (!existsSync(configPath)) return false;
1636
+ const root = readJsonObject(configPath);
1637
+ const servers = getNestedObject(root, objectPath, false);
1638
+ if (!servers || !(name in servers)) return false;
1639
+ delete servers[name];
1640
+ writeJsonAtomic(configPath, root);
1641
+ return true;
1642
+ }
1643
+
1644
+ function restoreJsonRegistration(
1645
+ configPath: string,
1646
+ objectPath: string[],
1647
+ snap: DisabledMcpRegistration,
1648
+ ): boolean {
1649
+ const root = readJsonObject(configPath);
1650
+ const servers = getNestedObject(root, objectPath, true);
1651
+ if (!servers || snap.name in servers) return false;
1652
+ const server = serverConfigFromSnapshot(snap);
1653
+ if (!server) return false;
1654
+ servers[snap.name] = server;
1655
+ writeJsonAtomic(configPath, root);
1656
+ return true;
1657
+ }
1658
+
1659
+ function removeHermesRegistration(configPath: string, name: string): boolean {
1660
+ if (!existsSync(configPath)) return false;
1661
+ const doc = YAML.parseDocument(readFileSync(configPath, 'utf8'));
1662
+ const servers = doc.get('mcp_servers', true);
1663
+ if (!YAML.isMap(servers) || !servers.has(name)) return false;
1664
+ servers.delete(name);
1665
+ writeFileAtomic(configPath, doc.toString());
1666
+ return true;
1667
+ }
1668
+
1669
+ function restoreHermesRegistration(configPath: string, snap: DisabledMcpRegistration): boolean {
1670
+ const doc = existsSync(configPath)
1671
+ ? YAML.parseDocument(readFileSync(configPath, 'utf8'))
1672
+ : YAML.parseDocument('{}\n');
1673
+ let servers = doc.get('mcp_servers', true);
1674
+ if (!YAML.isMap(servers)) {
1675
+ doc.set('mcp_servers', {});
1676
+ servers = doc.get('mcp_servers', true);
1677
+ }
1678
+ if (!YAML.isMap(servers) || servers.has(snap.name)) return false;
1679
+ const server = serverConfigFromSnapshot(snap);
1680
+ if (!server) return false;
1681
+ servers.set(snap.name, server);
1682
+ writeFileAtomic(configPath, doc.toString());
1683
+ return true;
1684
+ }
1685
+
1686
+ function serverConfigFromSnapshot(snap: DisabledMcpRegistration): Record<string, unknown> | null {
1687
+ if (isRecord(snap.server)) return cloneServerConfig(snap.server);
1688
+ if (typeof snap.command !== 'string') return null;
1689
+ return {
1690
+ command: snap.command,
1691
+ args: Array.isArray(snap.args) ? [...snap.args] : [],
1692
+ };
1693
+ }
1694
+
1695
+ function fallbackServerConfig(reg: McpRegistration): Record<string, unknown> | null {
1696
+ if (typeof reg.command !== 'string') return null;
1697
+ return {
1698
+ command: reg.command,
1699
+ args: Array.isArray(reg.args) ? [...reg.args] : [],
1700
+ };
1701
+ }
1702
+
1703
+ function isRecord(value: unknown): value is Record<string, unknown> {
1704
+ return !!value && typeof value === 'object' && !Array.isArray(value);
1705
+ }
1706
+
1707
+ function cloneServerConfig(value: Record<string, unknown>): Record<string, unknown> {
1708
+ return JSON.parse(JSON.stringify(value)) as Record<string, unknown>;
1709
+ }
1710
+
1711
+ function withServerConfig(
1712
+ reg: McpRegistration,
1713
+ server: Record<string, unknown> | undefined,
1714
+ ): McpRegistration {
1715
+ if (!server) return reg;
1716
+ Object.defineProperty(reg, 'server', {
1717
+ value: cloneServerConfig(server),
1718
+ enumerable: false,
1719
+ configurable: true,
1720
+ });
1721
+ return reg;
1722
+ }
1723
+
1724
+ function publicDisabledSnapshot(snap: DisabledMcpRegistration): DisabledMcpRegistration {
1725
+ return {
1726
+ client: snap.client,
1727
+ name: snap.name,
1728
+ site: snap.site,
1729
+ configPath: snap.configPath,
1730
+ scope: snap.scope,
1731
+ command: snap.command,
1732
+ args: snap.args,
1733
+ disabledAt: snap.disabledAt,
1734
+ };
1735
+ }
1736
+
1737
+ // ─── Disabled snapshot store ────────────────────────────────────────────────
1738
+
1739
+ function disabledStorePath(ctx: MaintenanceContext): string {
1740
+ return pathJoin(ctx.imprintHome, '.mcp-disabled.json');
1741
+ }
1742
+
1743
+ function loadDisabledStore(ctx: MaintenanceContext): DisabledStore {
1744
+ const path = disabledStorePath(ctx);
1745
+ if (!existsSync(path)) return { version: DISABLED_STORE_VERSION, disabled: [] };
1746
+ try {
1747
+ const raw = JSON.parse(readFileSync(path, 'utf8')) as DisabledStore;
1748
+ return {
1749
+ version: raw.version ?? DISABLED_STORE_VERSION,
1750
+ disabled: Array.isArray(raw.disabled) ? raw.disabled : [],
1751
+ };
1752
+ } catch {
1753
+ return { version: DISABLED_STORE_VERSION, disabled: [] };
1754
+ }
1755
+ }
1756
+
1757
+ function saveDisabledStore(ctx: MaintenanceContext, store: DisabledStore): void {
1758
+ mkdirSync(ctx.imprintHome, { recursive: true });
1759
+ writeJsonAtomic(disabledStorePath(ctx), {
1760
+ version: DISABLED_STORE_VERSION,
1761
+ disabled: store.disabled.sort((a, b) =>
1762
+ `${a.client}:${a.name}`.localeCompare(`${b.client}:${b.name}`),
1763
+ ),
1764
+ });
1765
+ }
1766
+
1767
+ function addDisabledSnapshot(ctx: MaintenanceContext, snap: DisabledMcpRegistration): void {
1768
+ const store = loadDisabledStore(ctx);
1769
+ store.disabled = store.disabled.filter(
1770
+ (d) => !(d.client === snap.client && d.name === snap.name && d.configPath === snap.configPath),
1771
+ );
1772
+ store.disabled.push(snap);
1773
+ saveDisabledStore(ctx, store);
1774
+ }
1775
+
1776
+ function writeJsonAtomic(path: string, value: unknown): void {
1777
+ writeFileAtomic(path, `${JSON.stringify(value, null, 2)}\n`);
1778
+ }
1779
+
1780
+ function writeFileAtomic(path: string, content: string): void {
1781
+ mkdirSync(pathDirname(path), { recursive: true });
1782
+ const tmp = `${path}.tmp`;
1783
+ writeFileSync(tmp, content, 'utf8');
1784
+ try {
1785
+ renameSync(tmp, path);
1786
+ } catch {
1787
+ rmSync(path, { force: true });
1788
+ renameSync(tmp, path);
1789
+ }
1790
+ }