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.
- package/LICENSE +21 -0
- package/README.md +172 -0
- package/bin/cli.js +272 -0
- package/package.json +64 -0
- package/src/auto-update.js +106 -0
- package/src/branding.js +80 -0
- package/src/cleanup.js +615 -0
- package/src/config-remover.js +325 -0
- package/src/config-writer.js +781 -0
- package/src/detect-project.js +316 -0
- package/src/detect.js +587 -0
- package/src/fs-atomic.js +35 -0
- package/src/handshake.js +123 -0
- package/src/index.js +966 -0
- package/src/inspect.js +283 -0
- package/src/manifest.js +179 -0
- package/src/mcp-cmd.js +282 -0
- package/src/net.js +72 -0
- package/src/profile.js +139 -0
- package/src/registry/automation-tools.js +6 -0
- package/src/registry/data/automation-tools.json +37 -0
- package/src/registry/data/mcp-servers.json +451 -0
- package/src/registry/data/skills.json +132 -0
- package/src/registry/loader.js +102 -0
- package/src/registry/mcp-registry.js +292 -0
- package/src/registry/mcp-servers.js +72 -0
- package/src/registry/skills.js +10 -0
- package/src/registry/stacks.js +769 -0
- package/src/registry/validate.js +209 -0
- package/src/rollback.js +182 -0
- package/src/runtime.js +40 -0
- package/src/select.js +72 -0
- package/src/update.js +126 -0
|
@@ -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
|
+
}
|