dxai-cli 1.0.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.
@@ -0,0 +1,80 @@
1
+ import chalk from 'chalk';
2
+ import ora from 'ora';
3
+ import { createRequire } from 'node:module';
4
+
5
+ const { version } = createRequire(import.meta.url)('../package.json');
6
+
7
+ export const theme = {
8
+ accent: chalk.cyanBright,
9
+ success: chalk.green,
10
+ warn: chalk.yellow,
11
+ error: chalk.red,
12
+ dim: chalk.gray,
13
+ highlight: chalk.bold.cyanBright,
14
+ label: chalk.bold.cyan,
15
+ };
16
+
17
+ const BANNER = `
18
+ ${chalk.cyanBright(` ██████╗ ██╗ ██╗ █████╗ ██╗`)}
19
+ ${chalk.cyanBright(` ██╔══██╗╚██╗██╔╝██╔══██╗██║`)}
20
+ ${chalk.cyan( ` ██║ ██║ ╚███╔╝ ███████║██║`)}
21
+ ${chalk.cyan( ` ██║ ██║ ██╔██╗ ██╔══██║██║`)}
22
+ ${chalk.cyanBright(` ██████╔╝██╔╝ ██╗██║ ██║██║`)}
23
+ ${chalk.cyanBright(` ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚═╝`)}
24
+ ${chalk.dim(` ──────────── `)}${chalk.white.bold(`by`)}${chalk.dim(` ────────────`)}
25
+ ${chalk.cyan(` ██████╗ ██████╗ ██╗ ██╗`)}
26
+ ${chalk.cyan(` ██╔══██╗╚════██╗██║ ██║`)}
27
+ ${chalk.cyan(` ██║ ██║ █████╔╝██║ ██║`)}
28
+ ${chalk.cyan(` ██║ ██║ ╚═══██╗╚██╗ ██╔╝`)}
29
+ ${chalk.cyan(` ██████╔╝██████╔╝ ╚████╔╝`)}
30
+ ${chalk.cyan(` ╚═════╝ ╚═════╝ ╚═══╝`)}
31
+ ${chalk.dim(` ─────────────────────────────`)}
32
+ ${chalk.bold.white(` AI-Powered Dev Environment Setup`)}
33
+ ${chalk.dim(` v${version}`)}
34
+ `;
35
+
36
+ export function printBanner() {
37
+ console.log(BANNER);
38
+ }
39
+
40
+ export function sectionHeader(title) {
41
+ console.log();
42
+ console.log(theme.label(` ▸ ${title}`));
43
+ console.log(theme.dim(` ${'─'.repeat(40)}`));
44
+ }
45
+
46
+ export function successMsg(msg) {
47
+ console.log(theme.success(` ✓ ${msg}`));
48
+ }
49
+
50
+ export function warnMsg(msg) {
51
+ console.log(theme.warn(` ⚠ ${msg}`));
52
+ }
53
+
54
+ export function errorMsg(msg) {
55
+ console.log(theme.error(` ✗ ${msg}`));
56
+ }
57
+
58
+ export function infoMsg(msg) {
59
+ console.log(theme.dim(` ℹ ${msg}`));
60
+ }
61
+
62
+ // Run `fn` only when decorative output is allowed (i.e. not in --json mode).
63
+ export function quiet(runtime, fn) {
64
+ if (runtime.json) return;
65
+ fn();
66
+ }
67
+
68
+ // An ora spinner, or null in --json mode. Callers use `spinner?.stop()`.
69
+ export function startSpinner(runtime, text) {
70
+ return runtime.json ? null : ora({ text, color: 'cyan' }).start();
71
+ }
72
+
73
+ // Print the per-agent outcome of a writeMcpConfigs / writeProjectMcpConfigs call.
74
+ export function reportMcpResults(results, verb = 'added') {
75
+ for (const r of Object.values(results)) {
76
+ if (r.added > 0) successMsg(`${r.agent}: ${r.added} MCP server(s) ${verb}` + (r.path ? ` → ${r.path}` : ''));
77
+ if (r.skipped > 0) infoMsg(`${r.agent}: ${r.skipped} already configured, skipped`);
78
+ for (const err of r.errors || []) warnMsg(`${r.agent}: ${err.id} — ${err.error}`);
79
+ }
80
+ }
package/src/cleanup.js ADDED
@@ -0,0 +1,615 @@
1
+ import inquirer from 'inquirer';
2
+ import chalk from 'chalk';
3
+ import fs from 'fs-extra';
4
+ import path from 'path';
5
+
6
+ import {
7
+ printBanner, sectionHeader, successMsg, warnMsg,
8
+ errorMsg, infoMsg, quiet, startSpinner,
9
+ } from './branding.js';
10
+ import { detectOS, AGENT_DEFINITIONS } from './detect.js';
11
+ import { MCP_SERVERS } from './registry/mcp-servers.js';
12
+ import { SKILLS } from './registry/skills.js';
13
+ import {
14
+ scanJsonMcpConfig, removeJsonMcpServers,
15
+ scanTomlMcpConfig, removeTomlMcpServers,
16
+ scanClaudeCodeMcpServers, removeClaudeCodeMcpServers,
17
+ scanBackupFiles, scanSkillDirectories,
18
+ scanProjectFiles, isEmptyDir,
19
+ } from './config-remover.js';
20
+ import { normalizeOptions } from './runtime.js';
21
+ import { confirm } from './select.js';
22
+ import { readManifest, SYSTEM_MANIFEST_PATH, PROJECT_MANIFEST_PATH, writeManifest, unrecordMcp } from './manifest.js';
23
+
24
+ const KNOWN_MCP_IDS = MCP_SERVERS.map((s) => s.id);
25
+ const KNOWN_SKILL_IDS = SKILLS.map((s) => s.id);
26
+
27
+ // Build the candidate ID list for scanning. If the manifest has entries,
28
+ // prefer it (precise — we only target what dxai installed). Otherwise fall
29
+ // back to the full known set (legacy behavior for installs predating manifest).
30
+ function globalMcpPaths(agent, home) {
31
+ const legacy = typeof agent.legacyGlobalMcpPaths === 'function' ? agent.legacyGlobalMcpPaths(home) : [];
32
+ return [agent.globalMcpPath(home), ...legacy];
33
+ }
34
+
35
+ function candidateMcpIds(manifest, agentId) {
36
+ if (manifest && manifest.mcp[agentId]) {
37
+ const ids = Object.keys(manifest.mcp[agentId]);
38
+ if (ids.length > 0) return ids;
39
+ }
40
+ return KNOWN_MCP_IDS;
41
+ }
42
+
43
+ function candidateSkillIds(manifest) {
44
+ const fromManifest = Object.keys(manifest.skills || {});
45
+ return fromManifest.length > 0 ? fromManifest : KNOWN_SKILL_IDS;
46
+ }
47
+
48
+ // Checkbox values are `${agentId}::${serverId}` so one prompt can span agents;
49
+ // regroup the picks into { [agentId]: [serverId] }.
50
+ function groupPicks(picks) {
51
+ const out = {};
52
+ for (const pick of picks) {
53
+ const [agentId, serverId] = pick.split('::');
54
+ (out[agentId] ||= []).push(serverId);
55
+ }
56
+ return out;
57
+ }
58
+
59
+ function countIds(byAgent) {
60
+ return Object.values(byAgent).reduce((sum, ids) => sum + ids.length, 0);
61
+ }
62
+
63
+ // ── System Cleanup ──
64
+
65
+ // ctx: { nonInteractive, dryRun, json, includeBackups }
66
+ // Returns a structured report of what was (or would be) removed.
67
+ async function runSystemCleanup(home, ctx) {
68
+ const { nonInteractive, dryRun } = ctx;
69
+
70
+ quiet(ctx, () => sectionHeader('System Cleanup — Scanning'));
71
+
72
+ const spinner = startSpinner(ctx, 'Scanning global configs...');
73
+ const manifest = readManifest(SYSTEM_MANIFEST_PATH);
74
+
75
+ const agentFindings = [];
76
+ for (const agent of AGENT_DEFINITIONS) {
77
+ const finding = { agent, foundServers: [] };
78
+ const ids = candidateMcpIds(manifest, agent.id);
79
+
80
+ switch (agent.configFormat) {
81
+ case 'json': {
82
+ // Scan the current file plus any former location dxai used to write to.
83
+ const paths = globalMcpPaths(agent, home);
84
+ finding.foundServers = [...new Set(paths.flatMap((p) => scanJsonMcpConfig(p, agent.mcpKey, ids)))];
85
+ finding.configPath = paths[0];
86
+ break;
87
+ }
88
+ case 'toml': {
89
+ const configPath = agent.globalMcpPath(home);
90
+ finding.foundServers = scanTomlMcpConfig(configPath, ids);
91
+ finding.configPath = configPath;
92
+ break;
93
+ }
94
+ case 'cli': {
95
+ finding.foundServers = scanClaudeCodeMcpServers(ids);
96
+ break;
97
+ }
98
+ }
99
+
100
+ if (finding.foundServers.length > 0) {
101
+ agentFindings.push(finding);
102
+ }
103
+ }
104
+
105
+ const skillIds = candidateSkillIds(manifest);
106
+ const skillBaseDirs = [
107
+ path.join(home, '.cursor', 'skills'),
108
+ path.join(home, '.agents', 'skills'),
109
+ path.join(home, '.claude', 'skills'),
110
+ path.join(process.cwd(), '.cursor', 'skills'),
111
+ path.join(process.cwd(), '.agents', 'skills'),
112
+ path.join(process.cwd(), '.claude', 'skills'),
113
+ ];
114
+ const foundSkills = scanSkillDirectories(skillBaseDirs, skillIds);
115
+
116
+ // Scan backup files (only for file-based agents — CLI agents don't write backups,
117
+ // and their globalMcpPath sits in $HOME which would surface unrelated .bak files)
118
+ const backupTargets = AGENT_DEFINITIONS
119
+ .filter((a) => a.configFormat !== 'cli')
120
+ .map((a) => {
121
+ try { return a.globalMcpPath(home); } catch { return null; }
122
+ })
123
+ .filter(Boolean);
124
+ const foundBackups = scanBackupFiles(backupTargets);
125
+
126
+ spinner?.stop();
127
+
128
+ const totalMcpServers = agentFindings.reduce((sum, f) => sum + f.foundServers.length, 0);
129
+ if (totalMcpServers === 0 && foundSkills.length === 0 && foundBackups.length === 0) {
130
+ quiet(ctx, () => infoMsg('No dxai-managed system configurations found.'));
131
+ return { mcp: {}, skills: [], backups: [] };
132
+ }
133
+
134
+ // Select MCP servers to remove — everything found in non-interactive mode.
135
+ let mcpToRemove = {};
136
+ if (totalMcpServers > 0) {
137
+ if (nonInteractive) {
138
+ for (const finding of agentFindings) {
139
+ mcpToRemove[finding.agent.id] = [...finding.foundServers];
140
+ }
141
+ } else {
142
+ console.log();
143
+ sectionHeader('MCP Servers Found');
144
+
145
+ const mcpChoices = [];
146
+ for (const finding of agentFindings) {
147
+ mcpChoices.push(new inquirer.Separator(chalk.cyan(`\n ${finding.agent.name}`)));
148
+ for (const serverId of finding.foundServers) {
149
+ const server = MCP_SERVERS.find((s) => s.id === serverId);
150
+ const label = server ? server.name : serverId;
151
+ mcpChoices.push({
152
+ name: `${label} (${finding.agent.name})`,
153
+ value: `${finding.agent.id}::${serverId}`,
154
+ checked: true,
155
+ });
156
+ }
157
+ }
158
+
159
+ console.log();
160
+ const { selectedMcp } = await inquirer.prompt([
161
+ {
162
+ type: 'checkbox',
163
+ name: 'selectedMcp',
164
+ message: 'Select MCP servers to remove:',
165
+ choices: mcpChoices,
166
+ pageSize: 25,
167
+ loop: false,
168
+ },
169
+ ]);
170
+
171
+ mcpToRemove = groupPicks(selectedMcp);
172
+ }
173
+ }
174
+
175
+ // Select skills to remove — everything found in non-interactive mode.
176
+ let skillsToRemove = [];
177
+ if (foundSkills.length > 0) {
178
+ if (nonInteractive) {
179
+ skillsToRemove = foundSkills.map((s) => s.path);
180
+ } else {
181
+ console.log();
182
+ sectionHeader('Installed Skills Found');
183
+
184
+ const skillChoices = foundSkills.map((s) => {
185
+ const skill = SKILLS.find((sk) => sk.id === s.id);
186
+ const label = skill ? skill.name : s.id;
187
+ return { name: `${label} (${s.path})`, value: s.path, checked: true };
188
+ });
189
+
190
+ console.log();
191
+ const { selectedSkills } = await inquirer.prompt([
192
+ {
193
+ type: 'checkbox',
194
+ name: 'selectedSkills',
195
+ message: 'Select skills to remove:',
196
+ choices: skillChoices,
197
+ pageSize: 20,
198
+ loop: false,
199
+ },
200
+ ]);
201
+ skillsToRemove = selectedSkills;
202
+ }
203
+ }
204
+
205
+ // Backup files — non-interactive keeps them unless --backups was passed.
206
+ let deleteBackups = false;
207
+ if (foundBackups.length > 0) {
208
+ if (nonInteractive) {
209
+ deleteBackups = !!ctx.includeBackups;
210
+ } else {
211
+ console.log();
212
+ deleteBackups = await confirm(`Delete ${foundBackups.length} backup file(s)?`, { defaultValue: false });
213
+ }
214
+ }
215
+
216
+ const mcpCount = countIds(mcpToRemove);
217
+ if (mcpCount === 0 && skillsToRemove.length === 0 && !deleteBackups) {
218
+ quiet(ctx, () => infoMsg('Nothing selected for removal.'));
219
+ return { mcp: {}, skills: [], backups: [] };
220
+ }
221
+
222
+ // Summary & confirm (interactive only)
223
+ quiet(ctx, () => {
224
+ console.log();
225
+ sectionHeader(dryRun ? 'Cleanup Summary (dry run)' : 'Cleanup Summary');
226
+ if (mcpCount > 0) infoMsg(`MCP servers to remove: ${mcpCount}`);
227
+ if (skillsToRemove.length > 0) infoMsg(`Skills to remove: ${skillsToRemove.length}`);
228
+ if (deleteBackups) infoMsg(`Backup files to delete: ${foundBackups.length}`);
229
+ });
230
+
231
+ if (!nonInteractive && !dryRun) {
232
+ console.log();
233
+ if (!(await confirm('Proceed with cleanup?', { defaultValue: false }))) {
234
+ warnMsg('Cleanup cancelled.');
235
+ return { mcp: {}, skills: [], backups: [], cancelled: true };
236
+ }
237
+ }
238
+
239
+ const report = {
240
+ mcp: mcpToRemove,
241
+ skills: skillsToRemove,
242
+ backups: deleteBackups ? [...foundBackups] : [],
243
+ };
244
+
245
+ // Execute (skipped entirely on --dry-run)
246
+ if (dryRun) return report;
247
+
248
+ quiet(ctx, () => {
249
+ console.log();
250
+ sectionHeader('Removing');
251
+ });
252
+
253
+ for (const [agentId, serverIds] of Object.entries(mcpToRemove)) {
254
+ const agent = AGENT_DEFINITIONS.find((a) => a.id === agentId);
255
+ if (!agent) continue;
256
+
257
+ const spin = startSpinner(ctx, `Removing from ${agent.name}...`);
258
+
259
+ try {
260
+ switch (agent.configFormat) {
261
+ case 'json': {
262
+ let removed = 0;
263
+ for (const configPath of globalMcpPaths(agent, home)) {
264
+ if (fs.existsSync(configPath)) removed += removeJsonMcpServers(configPath, agent.mcpKey, serverIds).removed;
265
+ }
266
+ spin?.stop();
267
+ quiet(ctx, () => successMsg(`Removed ${removed} server(s) from ${agent.name}`));
268
+ break;
269
+ }
270
+ case 'toml': {
271
+ const configPath = agent.globalMcpPath(home);
272
+ const { removed } = removeTomlMcpServers(configPath, serverIds);
273
+ spin?.stop();
274
+ quiet(ctx, () => successMsg(`Removed ${removed} server(s) from ${agent.name}`));
275
+ break;
276
+ }
277
+ case 'cli': {
278
+ const { removed, errors } = removeClaudeCodeMcpServers(serverIds);
279
+ spin?.stop();
280
+ quiet(ctx, () => successMsg(`Removed ${removed} server(s) from ${agent.name}`));
281
+ for (const err of errors) {
282
+ quiet(ctx, () => warnMsg(`Failed to remove "${err.id}": ${err.error}`));
283
+ }
284
+ break;
285
+ }
286
+ }
287
+ } catch (err) {
288
+ spin?.stop();
289
+ quiet(ctx, () => errorMsg(`Failed to clean ${agent.name}: ${err.message}`));
290
+ }
291
+ }
292
+
293
+ for (const skillPath of skillsToRemove) {
294
+ try {
295
+ fs.removeSync(skillPath);
296
+ quiet(ctx, () => successMsg(`Removed skill: ${path.basename(skillPath)}`));
297
+ } catch (err) {
298
+ quiet(ctx, () => errorMsg(`Failed to remove ${skillPath}: ${err.message}`));
299
+ }
300
+ }
301
+
302
+ if (deleteBackups) {
303
+ for (const backupPath of foundBackups) {
304
+ try {
305
+ fs.removeSync(backupPath);
306
+ } catch { /* best-effort */ }
307
+ }
308
+ quiet(ctx, () => successMsg(`Deleted ${foundBackups.length} backup file(s)`));
309
+ }
310
+
311
+ // Prune the manifest so `list`/`status` reflect what was just removed —
312
+ // otherwise removed servers/skills linger forever as phantom drift.
313
+ pruneSystemManifest(mcpToRemove, skillsToRemove);
314
+
315
+ return report;
316
+ }
317
+
318
+ // Remove cleaned-up entries from the system manifest. `mcpToRemove` is
319
+ // { [agentId]: [serverId] }; `skillPaths` are the removed skill directories.
320
+ function pruneSystemManifest(mcpToRemove, skillPaths) {
321
+ for (const [agentId, serverIds] of Object.entries(mcpToRemove)) {
322
+ unrecordMcp(SYSTEM_MANIFEST_PATH, agentId, serverIds);
323
+ }
324
+
325
+ const m = readManifest(SYSTEM_MANIFEST_PATH);
326
+ let changed = false;
327
+ for (const skillPath of skillPaths) {
328
+ const skillId = path.basename(skillPath);
329
+ const skill = SKILLS.find((s) => s.id === skillId);
330
+ // Skills are recorded by id (recordSystemSkills); legacy manifests may
331
+ // still key them by display name, so try both.
332
+ for (const key of [skillId, skill?.name].filter(Boolean)) {
333
+ if (m.skills[key]) { delete m.skills[key]; changed = true; }
334
+ }
335
+ }
336
+
337
+ if (changed) writeManifest(SYSTEM_MANIFEST_PATH, m);
338
+ }
339
+
340
+ // ── Project Cleanup ──
341
+
342
+ // ctx: { nonInteractive, dryRun, json }
343
+ async function runProjectCleanup(ctx) {
344
+ const { nonInteractive, dryRun } = ctx;
345
+ const cwd = process.cwd();
346
+
347
+ quiet(ctx, () => sectionHeader('Project Cleanup — Scanning'));
348
+
349
+ const spinner = startSpinner(ctx, 'Scanning project files...');
350
+
351
+ const foundFiles = scanProjectFiles(cwd);
352
+
353
+ const projectMcpFindings = [];
354
+ for (const agent of AGENT_DEFINITIONS) {
355
+ if (!agent.projectMcpPath) continue;
356
+ const projectConfigPath = path.join(cwd, agent.projectMcpPath());
357
+ if (!fs.existsSync(projectConfigPath)) continue;
358
+
359
+ const foundServers = (agent.projectConfigFormat || agent.configFormat) === 'toml'
360
+ ? scanTomlMcpConfig(projectConfigPath, KNOWN_MCP_IDS)
361
+ : scanJsonMcpConfig(projectConfigPath, agent.projectMcpKey || agent.mcpKey, KNOWN_MCP_IDS);
362
+ if (foundServers.length > 0) {
363
+ projectMcpFindings.push({ agent, configPath: projectConfigPath, foundServers });
364
+ }
365
+ }
366
+
367
+ spinner?.stop();
368
+
369
+ if (foundFiles.length === 0 && projectMcpFindings.length === 0) {
370
+ quiet(ctx, () => infoMsg('No dxai-managed project files found in current directory.'));
371
+ return { files: [], skippedFiles: [], mcp: {} };
372
+ }
373
+
374
+ // Select project files to remove. Non-interactive mirrors the interactive
375
+ // defaults: files that may carry custom edits (CLAUDE.md, AGENTS.md, ...) are
376
+ // NOT removed automatically — they're reported as skipped instead.
377
+ let filesToRemove = [];
378
+ let skippedFiles = [];
379
+ if (foundFiles.length > 0) {
380
+ if (nonInteractive) {
381
+ filesToRemove = foundFiles.filter((f) => !f.mayHaveCustomEdits).map((f) => f.absolutePath);
382
+ skippedFiles = foundFiles.filter((f) => f.mayHaveCustomEdits).map((f) => f.relativePath);
383
+ } else {
384
+ console.log();
385
+ sectionHeader('Project Files Found');
386
+
387
+ const fileChoices = foundFiles.map((f) => {
388
+ const label = f.mayHaveCustomEdits
389
+ ? `${f.relativePath} (may contain custom edits)`
390
+ : f.relativePath;
391
+ return { name: label, value: f.absolutePath, checked: !f.mayHaveCustomEdits };
392
+ });
393
+
394
+ console.log();
395
+ const { selectedFiles } = await inquirer.prompt([
396
+ {
397
+ type: 'checkbox',
398
+ name: 'selectedFiles',
399
+ message: 'Select project files to remove:',
400
+ choices: fileChoices,
401
+ pageSize: 20,
402
+ loop: false,
403
+ },
404
+ ]);
405
+ filesToRemove = selectedFiles;
406
+ }
407
+ }
408
+
409
+ // Select project MCP servers to remove — everything found when non-interactive.
410
+ let projectMcpToRemove = {};
411
+ if (projectMcpFindings.length > 0) {
412
+ if (nonInteractive) {
413
+ for (const finding of projectMcpFindings) {
414
+ projectMcpToRemove[finding.agent.id] = [...finding.foundServers];
415
+ }
416
+ } else {
417
+ console.log();
418
+ sectionHeader('Project MCP Servers Found');
419
+
420
+ const mcpChoices = [];
421
+ for (const finding of projectMcpFindings) {
422
+ mcpChoices.push(new inquirer.Separator(chalk.cyan(`\n ${finding.agent.name}`) + chalk.dim(` — ${finding.configPath}`)));
423
+ for (const serverId of finding.foundServers) {
424
+ const server = MCP_SERVERS.find((s) => s.id === serverId);
425
+ const label = server ? server.name : serverId;
426
+ mcpChoices.push({
427
+ name: `${label}`,
428
+ value: `${finding.agent.id}::${serverId}`,
429
+ checked: true,
430
+ });
431
+ }
432
+ }
433
+
434
+ console.log();
435
+ const { selectedProjectMcp } = await inquirer.prompt([
436
+ {
437
+ type: 'checkbox',
438
+ name: 'selectedProjectMcp',
439
+ message: 'Select project MCP servers to remove:',
440
+ choices: mcpChoices,
441
+ pageSize: 25,
442
+ loop: false,
443
+ },
444
+ ]);
445
+
446
+ projectMcpToRemove = groupPicks(selectedProjectMcp);
447
+ }
448
+ }
449
+
450
+ const projectMcpCount = countIds(projectMcpToRemove);
451
+ if (filesToRemove.length === 0 && projectMcpCount === 0) {
452
+ quiet(ctx, () => infoMsg('Nothing selected for removal.'));
453
+ return { files: [], skippedFiles, mcp: {} };
454
+ }
455
+
456
+ // Summary & confirm (interactive only)
457
+ quiet(ctx, () => {
458
+ console.log();
459
+ sectionHeader(dryRun ? 'Cleanup Summary (dry run)' : 'Cleanup Summary');
460
+ if (filesToRemove.length > 0) infoMsg(`Files to remove: ${filesToRemove.length}`);
461
+ if (skippedFiles.length > 0) infoMsg(`Skipped (may contain custom edits): ${skippedFiles.join(', ')}`);
462
+ if (projectMcpCount > 0) infoMsg(`Project MCP servers to remove: ${projectMcpCount}`);
463
+ });
464
+
465
+ if (!nonInteractive && !dryRun) {
466
+ console.log();
467
+ if (!(await confirm('Proceed with cleanup?', { defaultValue: false }))) {
468
+ warnMsg('Cleanup cancelled.');
469
+ return { files: [], skippedFiles, mcp: {}, cancelled: true };
470
+ }
471
+ }
472
+
473
+ const report = {
474
+ files: filesToRemove.map((f) => path.relative(cwd, f)),
475
+ skippedFiles,
476
+ mcp: projectMcpToRemove,
477
+ };
478
+
479
+ // Execute (skipped entirely on --dry-run)
480
+ if (dryRun) return report;
481
+
482
+ quiet(ctx, () => {
483
+ console.log();
484
+ sectionHeader('Removing');
485
+ });
486
+
487
+ for (const filePath of filesToRemove) {
488
+ try {
489
+ fs.removeSync(filePath);
490
+ quiet(ctx, () => successMsg(`Removed: ${path.relative(cwd, filePath)}`));
491
+ } catch (err) {
492
+ quiet(ctx, () => errorMsg(`Failed to remove ${path.relative(cwd, filePath)}: ${err.message}`));
493
+ }
494
+ }
495
+
496
+ for (const [agentId, serverIds] of Object.entries(projectMcpToRemove)) {
497
+ const agent = AGENT_DEFINITIONS.find((a) => a.id === agentId);
498
+ if (!agent) continue;
499
+
500
+ const projectConfigPath = path.join(cwd, agent.projectMcpPath());
501
+ try {
502
+ const { removed } = (agent.projectConfigFormat || agent.configFormat) === 'toml'
503
+ ? removeTomlMcpServers(projectConfigPath, serverIds)
504
+ : removeJsonMcpServers(projectConfigPath, agent.projectMcpKey || agent.mcpKey, serverIds);
505
+ quiet(ctx, () => successMsg(`Removed ${removed} server(s) from project ${agent.name} config`));
506
+ } catch (err) {
507
+ quiet(ctx, () => errorMsg(`Failed to clean project ${agent.name} config: ${err.message}`));
508
+ }
509
+ }
510
+
511
+ const dirsToCheck = [
512
+ path.join(cwd, '.cursor', 'rules'),
513
+ path.join(cwd, '.cursor', 'commands'),
514
+ path.join(cwd, '.cursor'),
515
+ path.join(cwd, '.vscode'),
516
+ path.join(cwd, '.gemini'),
517
+ ];
518
+
519
+ for (const dir of dirsToCheck) {
520
+ if (isEmptyDir(dir)) {
521
+ try {
522
+ fs.removeSync(dir);
523
+ } catch { /* best-effort */ }
524
+ }
525
+ }
526
+
527
+ // Prune the project manifest for the files and MCP servers we removed.
528
+ pruneProjectManifest(cwd, filesToRemove, projectMcpToRemove);
529
+
530
+ return report;
531
+ }
532
+
533
+ function pruneProjectManifest(cwd, filePaths, projectMcpToRemove) {
534
+ const manifestPath = path.join(cwd, PROJECT_MANIFEST_PATH);
535
+ if (!fs.existsSync(manifestPath)) return;
536
+
537
+ for (const [agentId, serverIds] of Object.entries(projectMcpToRemove)) {
538
+ unrecordMcp(manifestPath, agentId, serverIds);
539
+ }
540
+
541
+ if (filePaths.length === 0) return;
542
+ const m = readManifest(manifestPath);
543
+ const removedRel = new Set(filePaths.map((f) => path.relative(cwd, f)));
544
+ const before = m.files.length;
545
+ m.files = m.files.filter((f) => !removedRel.has(f.relativePath));
546
+ if (m.files.length !== before) writeManifest(manifestPath, m);
547
+ }
548
+
549
+ // ── Main Cleanup Entry Point ──
550
+
551
+ const CLEANUP_SCOPES = ['system', 'project', 'both'];
552
+
553
+ // `dxai cleanup [scope]` — interactive by default; --yes/--json run without
554
+ // prompts (removing everything dxai-managed except custom-edit-prone files and
555
+ // backups), --dry-run reports without touching anything.
556
+ export async function cleanup(scopeArg, opts = {}) {
557
+ const runtime = normalizeOptions(opts);
558
+ const json = runtime.json;
559
+ const nonInteractive = runtime.nonInteractive || json;
560
+ const dryRun = runtime.dryRun;
561
+ const ctx = { nonInteractive, dryRun, json, includeBackups: !!opts.backups };
562
+
563
+ let scope = scopeArg;
564
+ if (scope && !CLEANUP_SCOPES.includes(scope)) {
565
+ throw new Error(`Unknown cleanup scope: ${scope}. Known: ${CLEANUP_SCOPES.join(', ')}`);
566
+ }
567
+
568
+ if (!json) {
569
+ printBanner();
570
+ sectionHeader(dryRun ? 'Cleanup (dry run)' : 'Cleanup');
571
+ console.log();
572
+ }
573
+
574
+ if (!scope) {
575
+ if (nonInteractive) {
576
+ scope = 'both';
577
+ } else {
578
+ const { picked } = await inquirer.prompt([
579
+ {
580
+ type: 'list',
581
+ name: 'picked',
582
+ message: 'What would you like to clean up?',
583
+ choices: [
584
+ { name: 'System — global MCP configs, skills, backups', value: 'system' },
585
+ { name: 'Project — project files, rules, commands', value: 'project' },
586
+ { name: 'Both — system and project', value: 'both' },
587
+ ],
588
+ },
589
+ ]);
590
+ scope = picked;
591
+ }
592
+ }
593
+
594
+ const { home } = detectOS();
595
+ const report = { ok: true, dryRun, scope, system: null, project: null };
596
+
597
+ if (scope === 'system' || scope === 'both') {
598
+ report.system = await runSystemCleanup(home, ctx);
599
+ }
600
+
601
+ if (scope === 'project' || scope === 'both') {
602
+ report.project = await runProjectCleanup(ctx);
603
+ }
604
+
605
+ if (json) {
606
+ process.stdout.write(JSON.stringify(report, null, 2) + '\n');
607
+ return report;
608
+ }
609
+
610
+ console.log();
611
+ if (dryRun) warnMsg('Dry run — no files were changed.');
612
+ else successMsg('Cleanup complete.');
613
+ console.log();
614
+ return report;
615
+ }