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,325 @@
1
+ import fs from 'fs-extra';
2
+ import path from 'path';
3
+ import { execFileSync } from 'child_process';
4
+ import { warnMsg } from './branding.js';
5
+ import { writeJsonAtomic, writeFileAtomic } from './fs-atomic.js';
6
+
7
+ function escapeRegExp(s) {
8
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
9
+ }
10
+
11
+ // Line-anchored [mcp_servers.<id>] header matcher — avoids matching a
12
+ // commented-out header or an id that is a prefix of another.
13
+ function tomlHeaderRe(id) {
14
+ return new RegExp(`^\\s*\\[mcp_servers\\.${escapeRegExp(id)}\\]`, 'm');
15
+ }
16
+
17
+ // ── JSON Config Scanning & Removal ──
18
+
19
+ // Scan a JSON config file for known MCP server IDs under a given key.
20
+ // Returns array of server IDs found.
21
+ export function scanJsonMcpConfig(filePath, mcpKey, knownIds) {
22
+ if (!fs.existsSync(filePath)) return [];
23
+
24
+ try {
25
+ const config = fs.readJsonSync(filePath);
26
+ if (!config[mcpKey] || typeof config[mcpKey] !== 'object') return [];
27
+
28
+ return knownIds.filter((id) => id in config[mcpKey]);
29
+ } catch (err) {
30
+ warnMsg(`Could not parse ${filePath}: ${err.message}`);
31
+ return [];
32
+ }
33
+ }
34
+
35
+ // Remove specific server IDs from a JSON MCP config.
36
+ // If the mcpKey object becomes empty, removes it entirely.
37
+ export function removeJsonMcpServers(filePath, mcpKey, idsToRemove) {
38
+ if (!fs.existsSync(filePath)) return { removed: 0 };
39
+
40
+ let config;
41
+ try {
42
+ config = fs.readJsonSync(filePath);
43
+ } catch (err) {
44
+ // Surface rather than silently report "removed 0" on a file we couldn't read.
45
+ throw new Error(`Could not parse ${filePath} as JSON: ${err.message}`, { cause: err });
46
+ }
47
+
48
+ if (!config[mcpKey] || typeof config[mcpKey] !== 'object') return { removed: 0 };
49
+
50
+ let removed = 0;
51
+ for (const id of idsToRemove) {
52
+ if (id in config[mcpKey]) {
53
+ delete config[mcpKey][id];
54
+ removed++;
55
+ }
56
+ }
57
+
58
+ if (Object.keys(config[mcpKey]).length === 0) {
59
+ delete config[mcpKey];
60
+ }
61
+
62
+ writeJsonAtomic(filePath, config, { spaces: 2 });
63
+ return { removed };
64
+ }
65
+
66
+ // ── TOML Config Scanning & Removal ──
67
+
68
+ // Scan a TOML config file for known MCP server sections.
69
+ // Looks for [mcp_servers.<id>] patterns.
70
+ export function scanTomlMcpConfig(filePath, knownIds) {
71
+ if (!fs.existsSync(filePath)) return [];
72
+
73
+ try {
74
+ const content = fs.readFileSync(filePath, 'utf-8');
75
+ return knownIds.filter((id) => tomlHeaderRe(id).test(content));
76
+ } catch {
77
+ return [];
78
+ }
79
+ }
80
+
81
+ // Remove TOML sections for specific server IDs.
82
+ // Removes from [mcp_servers.<id>] to the next section header or end of file.
83
+ export function removeTomlMcpServers(filePath, idsToRemove) {
84
+ if (!fs.existsSync(filePath)) return { removed: 0 };
85
+
86
+ let content;
87
+ try {
88
+ content = fs.readFileSync(filePath, 'utf-8');
89
+ } catch {
90
+ return { removed: 0 };
91
+ }
92
+
93
+ let removed = 0;
94
+ for (const id of idsToRemove) {
95
+ // Locate the header at the start of a line so a commented-out or
96
+ // prefix-colliding header isn't matched.
97
+ const headerMatch = tomlHeaderRe(id).exec(content);
98
+ if (!headerMatch) continue;
99
+ const idx = headerMatch.index;
100
+
101
+ // Find the end: next section header (line starting with [) or end of file
102
+ const afterHeader = content.indexOf('\n', idx);
103
+ if (afterHeader === -1) {
104
+ // Section header is at end of file
105
+ content = content.slice(0, idx).trimEnd() + '\n';
106
+ removed++;
107
+ continue;
108
+ }
109
+
110
+ const rest = content.slice(afterHeader + 1);
111
+ const nextSectionMatch = rest.match(/^(\[(?!\[))/m);
112
+ let endIdx;
113
+ if (nextSectionMatch) {
114
+ endIdx = afterHeader + 1 + nextSectionMatch.index;
115
+ } else {
116
+ endIdx = content.length;
117
+ }
118
+
119
+ // Also trim leading blank lines before the section
120
+ let startIdx = idx;
121
+ while (startIdx > 0 && content[startIdx - 1] === '\n') startIdx--;
122
+ if (startIdx > 0) startIdx++; // keep one newline
123
+
124
+ content = content.slice(0, startIdx) + content.slice(endIdx);
125
+ removed++;
126
+ }
127
+
128
+ content = content.trimEnd() + '\n';
129
+ writeFileAtomic(filePath, content);
130
+ return { removed };
131
+ }
132
+
133
+ // ── Claude Code CLI Scanning & Removal ──
134
+
135
+ // True when `id` appears in `claude mcp list` output as a whole token — a bare
136
+ // substring check would let "git" match "github" and skip/remove the wrong
137
+ // server. Boundaries are anything outside the id charset [A-Za-z0-9_-].
138
+ export function outputHasServerId(output, id) {
139
+ if (!output || !id) return false;
140
+ const re = new RegExp(`(^|[^A-Za-z0-9_-])${escapeRegExp(id)}([^A-Za-z0-9_-]|$)`, 'm');
141
+ return re.test(output);
142
+ }
143
+
144
+ // `claude mcp list` output, or '' when the CLI is missing/unresponsive.
145
+ // execFileSync (argv, no shell) — the previous `2>/dev/null || true` shell form
146
+ // silently broke on Windows cmd.exe, reading every server as "not configured".
147
+ export function listClaudeCodeMcpOutput() {
148
+ try {
149
+ return execFileSync('claude', ['mcp', 'list'], {
150
+ stdio: ['ignore', 'pipe', 'ignore'],
151
+ timeout: 10000,
152
+ }).toString();
153
+ } catch {
154
+ return '';
155
+ }
156
+ }
157
+
158
+ // Remove MCP servers from Claude Code via CLI.
159
+ export function removeClaudeCodeMcpServers(serverIds) {
160
+ let removed = 0;
161
+ const errors = [];
162
+
163
+ for (const id of serverIds) {
164
+ try {
165
+ // argv form (no shell) so an id can never be interpreted as a command.
166
+ execFileSync('claude', ['mcp', 'remove', id], {
167
+ stdio: 'pipe',
168
+ timeout: 10000,
169
+ });
170
+ removed++;
171
+ } catch (err) {
172
+ errors.push({ id, error: err.message });
173
+ }
174
+ }
175
+
176
+ return { removed, errors };
177
+ }
178
+
179
+ // Scan Claude Code for known MCP server IDs.
180
+ export function scanClaudeCodeMcpServers(knownIds) {
181
+ const output = listClaudeCodeMcpOutput();
182
+ if (!output) return [];
183
+
184
+ // Filter out cloud-managed servers (lines starting with "claude.ai ")
185
+ const localLines = output
186
+ .split('\n')
187
+ .filter((line) => !line.trim().startsWith('claude.ai '))
188
+ .join('\n');
189
+
190
+ return knownIds.filter((id) => outputHasServerId(localLines, id));
191
+ }
192
+
193
+ // ── Backup File Scanning ──
194
+
195
+ // Find .bak.* files alongside the given config file paths.
196
+ // Matches siblings of the form <basename>.bak.<ts> only — avoids surfacing
197
+ // unrelated backup files when a config sits in a shared dir like $HOME.
198
+ export function scanBackupFiles(configFilePaths) {
199
+ const backups = [];
200
+
201
+ for (const configPath of configFilePaths) {
202
+ const dir = path.dirname(configPath);
203
+ const base = path.basename(configPath);
204
+ if (!fs.existsSync(dir)) continue;
205
+
206
+ try {
207
+ const files = fs.readdirSync(dir);
208
+ const prefix = `${base}.bak.`;
209
+ for (const file of files) {
210
+ if (file.startsWith(prefix)) {
211
+ backups.push(path.join(dir, file));
212
+ }
213
+ }
214
+ } catch {
215
+ // Skip unreadable directories
216
+ }
217
+ }
218
+
219
+ return backups;
220
+ }
221
+
222
+ // ── Skill Directory Scanning ──
223
+
224
+ // Find installed skill directories matching known skill IDs.
225
+ export function scanSkillDirectories(baseDirs, knownSkillIds) {
226
+ const found = [];
227
+
228
+ for (const baseDir of baseDirs) {
229
+ if (!fs.existsSync(baseDir)) continue;
230
+
231
+ try {
232
+ const entries = fs.readdirSync(baseDir);
233
+ for (const entry of entries) {
234
+ const fullPath = path.join(baseDir, entry);
235
+ if (knownSkillIds.includes(entry) && fs.statSync(fullPath).isDirectory()) {
236
+ found.push({ id: entry, path: fullPath, baseDir });
237
+ }
238
+ }
239
+ } catch {
240
+ // Skip unreadable directories
241
+ }
242
+ }
243
+
244
+ return found;
245
+ }
246
+
247
+ // ── Project File Scanning ──
248
+
249
+ // Files that may contain user customizations
250
+ const CUSTOM_EDIT_FILES = new Set([
251
+ 'CLAUDE.md', 'GEMINI.md', 'AGENTS.md',
252
+ '.gitattributes', '.editorconfig',
253
+ ]);
254
+
255
+ // Known project files that dxai generates.
256
+ const KNOWN_PROJECT_FILES = [
257
+ 'CLAUDE.md',
258
+ 'GEMINI.md',
259
+ 'AGENTS.md',
260
+ '.gitattributes',
261
+ '.editorconfig',
262
+ '.cursorignore',
263
+ ];
264
+
265
+ // Scan for dxai-generated project files in the current working directory.
266
+ // Returns array of { relativePath, absolutePath, mayHaveCustomEdits }.
267
+ export function scanProjectFiles(cwd) {
268
+ const found = [];
269
+
270
+ for (const file of KNOWN_PROJECT_FILES) {
271
+ const fullPath = path.join(cwd, file);
272
+ if (fs.existsSync(fullPath)) {
273
+ found.push({
274
+ relativePath: file,
275
+ absolutePath: fullPath,
276
+ mayHaveCustomEdits: CUSTOM_EDIT_FILES.has(file),
277
+ });
278
+ }
279
+ }
280
+
281
+ const rulesDir = path.join(cwd, '.cursor', 'rules');
282
+ if (fs.existsSync(rulesDir)) {
283
+ try {
284
+ const files = fs.readdirSync(rulesDir).filter((f) => f.endsWith('.mdc'));
285
+ for (const file of files) {
286
+ found.push({
287
+ relativePath: path.join('.cursor', 'rules', file),
288
+ absolutePath: path.join(rulesDir, file),
289
+ mayHaveCustomEdits: false,
290
+ });
291
+ }
292
+ } catch {
293
+ // Skip
294
+ }
295
+ }
296
+
297
+ const commandsDir = path.join(cwd, '.cursor', 'commands');
298
+ if (fs.existsSync(commandsDir)) {
299
+ try {
300
+ const files = fs.readdirSync(commandsDir).filter((f) => f.endsWith('.md'));
301
+ for (const file of files) {
302
+ found.push({
303
+ relativePath: path.join('.cursor', 'commands', file),
304
+ absolutePath: path.join(commandsDir, file),
305
+ mayHaveCustomEdits: false,
306
+ });
307
+ }
308
+ } catch {
309
+ // Skip
310
+ }
311
+ }
312
+
313
+ return found;
314
+ }
315
+
316
+ // Check if a directory is empty (or only contains empty subdirectories).
317
+ export function isEmptyDir(dirPath) {
318
+ if (!fs.existsSync(dirPath)) return true;
319
+ try {
320
+ const entries = fs.readdirSync(dirPath);
321
+ return entries.length === 0;
322
+ } catch {
323
+ return true;
324
+ }
325
+ }