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,781 @@
1
+ import fs from 'fs-extra';
2
+ import path from 'path';
3
+ import os from 'os';
4
+ import { execFileSync } from 'child_process';
5
+ import { warnMsg, infoMsg } from './branding.js';
6
+ import { buildAgentsMd, buildClaudeMd, buildGeminiMd, buildCursorRule } from './registry/stacks.js';
7
+ import { isValidRepo, isValidSkillPath, isSafeId } from './registry/validate.js';
8
+ import { listClaudeCodeMcpOutput, outputHasServerId } from './config-remover.js';
9
+ import { writeFileAtomic, writeJsonAtomic } from './fs-atomic.js';
10
+ import { fetchText } from './net.js';
11
+
12
+ const HOME = os.homedir();
13
+
14
+ function escapeRegExp(s) {
15
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
16
+ }
17
+
18
+ // ── Input substitution ──
19
+ // Resolve a user-provided path: expand ~ and $HOME.
20
+ function resolveInputPath(value) {
21
+ if (typeof value !== 'string') return value;
22
+ let v = value.trim();
23
+ if (v === '~' || v.startsWith('~/')) v = path.join(HOME, v.slice(1));
24
+ v = v.replace(/\$HOME\b/g, HOME);
25
+ return v;
26
+ }
27
+
28
+ // Walk a config object (deep) and replace each placeholder string with its resolved value.
29
+ function substitutePlaceholders(config, replacements) {
30
+ if (!replacements || Object.keys(replacements).length === 0) return config;
31
+
32
+ const replace = (s) => {
33
+ let out = s;
34
+ for (const [placeholder, value] of Object.entries(replacements)) {
35
+ if (typeof out === 'string' && out.includes(placeholder)) {
36
+ out = out.split(placeholder).join(value);
37
+ }
38
+ }
39
+ return out;
40
+ };
41
+
42
+ if (typeof config === 'string') return replace(config);
43
+ if (Array.isArray(config)) return config.map((v) => substitutePlaceholders(v, replacements));
44
+ if (config && typeof config === 'object') {
45
+ const out = {};
46
+ for (const [k, v] of Object.entries(config)) out[k] = substitutePlaceholders(v, replacements);
47
+ return out;
48
+ }
49
+ return config;
50
+ }
51
+
52
+ // Build a placeholder→value map for one server entry, given user-provided inputs.
53
+ // Inputs is { [serverId]: { [inputKey]: value } }.
54
+ function buildReplacements(server, inputs) {
55
+ const out = {};
56
+ if (!server.requiresInput) return out;
57
+ const provided = (inputs && inputs[server.id]) || {};
58
+ for (const [key, def] of Object.entries(server.requiresInput)) {
59
+ if (!def.placeholder) continue;
60
+ const raw = provided[key] ?? def.default ?? '';
61
+ out[def.placeholder] = resolveInputPath(raw);
62
+ }
63
+ return out;
64
+ }
65
+
66
+ // ── Version pinning ──
67
+ // True for an npm package specifier (optionally scoped); false for paths, URLs, flags.
68
+ function isPackageSpec(tok) {
69
+ return /^(@[a-z0-9][\w.-]*\/)?[a-z0-9][\w.-]*$/i.test(tok);
70
+ }
71
+
72
+ // Append `@<version>` to a package specifier unless it already carries a version.
73
+ // For scoped names (`@scope/name`) the leading `@` is not a version marker.
74
+ function applyVersion(spec, version) {
75
+ const at = spec.lastIndexOf('@');
76
+ const hasVersion = spec.startsWith('@') ? at > 0 : at !== -1;
77
+ return hasVersion ? spec : `${spec}@${version}`;
78
+ }
79
+
80
+ // Pin the package token within an args array. Only tokens after the `npx` anchor
81
+ // are considered, so `mcp`/`add`/<id> in a `claude mcp add` command are never touched.
82
+ function pinArgs(args, version) {
83
+ const out = [...args];
84
+ const npxIdx = out.indexOf('npx');
85
+ const start = npxIdx === -1 ? 0 : npxIdx + 1;
86
+ for (let i = start; i < out.length; i++) {
87
+ const tok = out[i];
88
+ if (typeof tok !== 'string' || tok.startsWith('-')) continue;
89
+ if (!isPackageSpec(tok)) continue;
90
+ out[i] = applyVersion(tok, version);
91
+ break;
92
+ }
93
+ return out;
94
+ }
95
+
96
+ // Pin the package inside a TOML `args = [...]` block.
97
+ function pinTomlPackage(toml, version) {
98
+ return toml.replace(/args\s*=\s*\[([^\]]*)\]/, (_m, inner) => {
99
+ const parts = inner.split(',').map((s) => s.trim());
100
+ for (let i = 0; i < parts.length; i++) {
101
+ const mm = parts[i].match(/^"(.*)"$/);
102
+ if (!mm) continue;
103
+ const tok = mm[1];
104
+ if (tok.startsWith('-') || tok === 'npx' || !isPackageSpec(tok)) continue;
105
+ parts[i] = `"${applyVersion(tok, version)}"`;
106
+ break;
107
+ }
108
+ return `args = [${parts.join(', ')}]`;
109
+ });
110
+ }
111
+
112
+ // Apply a server's pinned `version` to a per-agent config. Handles command/args
113
+ // (npx-based stdio servers, incl. `claude mcp add` CLI) and the Codex TOML string.
114
+ // No-op for URL/remote configs or when `version` is absent → existing entries unchanged.
115
+ export function pinPackageVersion(config, version) {
116
+ if (!version || !config || typeof config !== 'object') return config;
117
+ if (Array.isArray(config.args)) {
118
+ const isNpx = config.command === 'npx' || config.args.includes('npx');
119
+ return isNpx ? { ...config, args: pinArgs(config.args, version) } : config;
120
+ }
121
+ if (typeof config.toml === 'string' && config.toml.includes('npx')) {
122
+ return { ...config, toml: pinTomlPackage(config.toml, version) };
123
+ }
124
+ return config;
125
+ }
126
+
127
+ // ── Backup helper ──
128
+ // How many `.bak.<ts>` snapshots to keep per file. Older ones are pruned on
129
+ // each new backup so repeated runs can't accumulate snapshots forever.
130
+ const MAX_BACKUPS_PER_FILE = 5;
131
+
132
+ function pruneOldBackups(filePath) {
133
+ const dir = path.dirname(filePath);
134
+ const prefix = `${path.basename(filePath)}.bak.`;
135
+ let siblings;
136
+ try {
137
+ siblings = fs.readdirSync(dir).filter((f) => f.startsWith(prefix));
138
+ } catch {
139
+ return;
140
+ }
141
+ // Timestamp suffixes sort lexicographically == chronologically.
142
+ siblings.sort().reverse();
143
+ for (const stale of siblings.slice(MAX_BACKUPS_PER_FILE)) {
144
+ try { fs.removeSync(path.join(dir, stale)); } catch { /* best-effort */ }
145
+ }
146
+ }
147
+
148
+ function backupFile(filePath) {
149
+ if (fs.existsSync(filePath)) {
150
+ const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
151
+ const backupPath = `${filePath}.bak.${ts}`;
152
+ fs.copySync(filePath, backupPath);
153
+ pruneOldBackups(filePath);
154
+ return backupPath;
155
+ }
156
+ return null;
157
+ }
158
+
159
+ // ── JSON Config Merge (Cursor, VS Code, Gemini, Windsurf) ──
160
+ function mergeJsonMcpConfig(filePath, mcpKey, newServers) {
161
+ let config = {};
162
+ if (fs.existsSync(filePath)) {
163
+ try {
164
+ config = fs.readJsonSync(filePath);
165
+ } catch (err) {
166
+ warnMsg(`Could not parse ${filePath} as JSON (${err.message}). Note: JSON with comments (JSONC) is not supported. Leaving existing file untouched and aborting merge.`);
167
+ throw new Error(`Refusing to overwrite malformed JSON at ${filePath}`, { cause: err });
168
+ }
169
+ }
170
+
171
+ if (!config[mcpKey]) config[mcpKey] = {};
172
+
173
+ let added = 0;
174
+ let skipped = 0;
175
+ const addedIds = [];
176
+
177
+ for (const [serverId, serverConfig] of Object.entries(newServers)) {
178
+ // Defensive: never let a poisoned registry id (e.g. "__proto__") become a key.
179
+ if (!isSafeId(serverId)) {
180
+ skipped++;
181
+ continue;
182
+ }
183
+ if (config[mcpKey][serverId]) {
184
+ skipped++;
185
+ } else {
186
+ config[mcpKey][serverId] = serverConfig;
187
+ added++;
188
+ addedIds.push(serverId);
189
+ }
190
+ }
191
+
192
+ // Nothing to change → don't touch the file (and don't mint a pointless backup).
193
+ if (added === 0) return { added, skipped, addedIds };
194
+
195
+ const backup = backupFile(filePath);
196
+ if (backup) infoMsg(`Backed up: ${path.basename(filePath)} → ${path.basename(backup)}`);
197
+
198
+ // Atomic write + 0600: configs may carry ${VAR} secret references, and a
199
+ // crash mid-write must never truncate the user's real config.
200
+ writeJsonAtomic(filePath, config, { spaces: 2, mode: 0o600 });
201
+
202
+ return { added, skipped, addedIds };
203
+ }
204
+
205
+ // ── TOML Config Merge (Codex CLI) ──
206
+ // NOTE: Codex TOML is handled by line-anchored string surgery, not a TOML
207
+ // parser — deliberately, so user comments and formatting survive our edits.
208
+ // Supported grammar: the `[mcp_servers.<id>]` blocks dxai itself generates
209
+ // (single-line `key = value` pairs, single-line arrays). Hand-written exotic
210
+ // TOML (multi-line arrays, dotted headers inside strings) is out of scope.
211
+ function mergeTomlMcpConfig(filePath, newTomlBlocks) {
212
+ let content = '';
213
+ if (fs.existsSync(filePath)) {
214
+ content = fs.readFileSync(filePath, 'utf-8');
215
+ }
216
+
217
+ let added = 0;
218
+ let skipped = 0;
219
+ const addedIds = [];
220
+
221
+ for (const { id, toml } of newTomlBlocks) {
222
+ // Already configured? Match the header at the start of a line so a
223
+ // commented-out block (`# [mcp_servers.foo]`) doesn't count as present.
224
+ const headerRe = new RegExp(`^\\s*\\[mcp_servers\\.${escapeRegExp(id)}\\]`, 'm');
225
+ if (headerRe.test(content)) {
226
+ skipped++;
227
+ } else {
228
+ content = content.trimEnd() + '\n\n' + toml + '\n';
229
+ added++;
230
+ addedIds.push(id);
231
+ }
232
+ }
233
+
234
+ // Nothing to change → don't touch the file (and don't mint a pointless backup).
235
+ if (added === 0) return { added, skipped, addedIds };
236
+
237
+ const backup = backupFile(filePath);
238
+ if (backup) infoMsg(`Backed up: ${path.basename(filePath)} → ${path.basename(backup)}`);
239
+
240
+ writeFileAtomic(filePath, content, { mode: 0o600 });
241
+
242
+ return { added, skipped, addedIds };
243
+ }
244
+
245
+ // ── Claude Code CLI Config ──
246
+ // Claude Code expands no variables at user scope, so `--env VAR=${VAR}` pairs are
247
+ // resolved from dxai's own environment here; pairs whose variable is unset are
248
+ // dropped (the needs-env summary tells the user what to export and re-run).
249
+ export function resolveClaudeEnvArgs(args, env = process.env) {
250
+ const out = [];
251
+ for (let i = 0; i < args.length; i++) {
252
+ if (args[i] === '--env' && i + 1 < args.length) {
253
+ const m = /^([A-Z_][A-Z0-9_]*)=\$\{([A-Z_][A-Z0-9_]*)\}$/.exec(args[i + 1]);
254
+ if (m) {
255
+ if (env[m[2]] !== undefined) out.push('--env', `${m[1]}=${env[m[2]]}`);
256
+ i++;
257
+ continue;
258
+ }
259
+ }
260
+ out.push(args[i]);
261
+ }
262
+ return out;
263
+ }
264
+
265
+ function configureClaudeCodeMcp(servers) {
266
+ let added = 0;
267
+ let skipped = 0;
268
+ const errors = [];
269
+ const addedIds = [];
270
+
271
+ // One list call for the whole batch — token-boundary matched per id so
272
+ // e.g. "git" can never be mistaken for an already-configured "github".
273
+ const existing = listClaudeCodeMcpOutput();
274
+
275
+ for (const { id, config } of servers) {
276
+ if (!config.command || config.command !== 'claude') continue;
277
+
278
+ try {
279
+ if (outputHasServerId(existing, id)) {
280
+ skipped++;
281
+ continue;
282
+ }
283
+
284
+ // Pass argv directly (no shell) so registry-derived args can never be
285
+ // interpreted as shell metacharacters. config.args is already ['mcp','add',…].
286
+ execFileSync('claude', resolveClaudeEnvArgs(config.args), {
287
+ stdio: 'pipe',
288
+ timeout: 15000,
289
+ env: { ...process.env },
290
+ });
291
+ added++;
292
+ addedIds.push(id);
293
+ } catch (err) {
294
+ errors.push({ id, error: err.message });
295
+ }
296
+ }
297
+
298
+ return { added, skipped, errors, addedIds };
299
+ }
300
+
301
+ // ── Dry-run preview helpers ──
302
+ // Given the same inputs writeMcpConfigs would receive, return a structured
303
+ // preview describing exactly what *would* change for each agent: target file
304
+ // path, server IDs that would be added, and server IDs that would be skipped
305
+ // because they're already present.
306
+ export function previewMcpConfigs(selectedAgents, selectedServers, mcpRegistry, inputs = {}) {
307
+ const previews = {};
308
+
309
+ for (const agent of selectedAgents) {
310
+ const ap = { agent: agent.name, agentId: agent.id, format: agent.configFormat };
311
+ const serversForAgent = resolveServersForAgent(agent, selectedServers, mcpRegistry, inputs);
312
+
313
+ if (serversForAgent.length === 0) {
314
+ previews[agent.id] = { ...ap, path: null, wouldAdd: [], wouldSkip: selectedServers, exists: false };
315
+ continue;
316
+ }
317
+
318
+ if (agent.configFormat === 'cli') {
319
+ previews[agent.id] = {
320
+ ...ap,
321
+ path: '<via `claude mcp add`>',
322
+ wouldAdd: serversForAgent.map((s) => s.id),
323
+ wouldSkip: [],
324
+ exists: true,
325
+ };
326
+ continue;
327
+ }
328
+
329
+ const filePath = agent.globalMcpPath(HOME);
330
+ let existing = {};
331
+ let existingToml = '';
332
+ const exists = fs.existsSync(filePath);
333
+
334
+ if (exists) {
335
+ if (agent.configFormat === 'json') {
336
+ try { existing = fs.readJsonSync(filePath); } catch { existing = {}; }
337
+ } else if (agent.configFormat === 'toml') {
338
+ existingToml = fs.readFileSync(filePath, 'utf-8');
339
+ }
340
+ }
341
+
342
+ const wouldAdd = [];
343
+ const wouldSkip = [];
344
+ for (const { id } of serversForAgent) {
345
+ if (agent.configFormat === 'json') {
346
+ const present = existing[agent.mcpKey] && id in existing[agent.mcpKey];
347
+ if (present) wouldSkip.push(id);
348
+ else wouldAdd.push(id);
349
+ } else if (agent.configFormat === 'toml') {
350
+ if (existingToml.includes(`[mcp_servers.${id}]`)) wouldSkip.push(id);
351
+ else wouldAdd.push(id);
352
+ }
353
+ }
354
+
355
+ previews[agent.id] = { ...ap, path: filePath, wouldAdd, wouldSkip, exists };
356
+ }
357
+ return previews;
358
+ }
359
+
360
+ // ── Main Config Writer — orchestrates per-agent ──
361
+
362
+ // The per-server configs to write for one agent: registry lookup, placeholder
363
+ // substitution, and version pinning. Servers with no config for this agent are
364
+ // dropped. Shared by the global, project, and dry-run preview paths so they can
365
+ // never disagree about what a server looks like.
366
+ // Agents whose config file has no documented variable interpolation get the
367
+ // real value from dxai's own environment at write time; unset variables keep the
368
+ // ${VAR} placeholder so the user can see what to fill in.
369
+ function substituteEnvLiterals(config) {
370
+ if (typeof config === 'string') {
371
+ return config.replace(/\$\{([A-Z_][A-Z0-9_]*)\}/g, (m, name) => (process.env[name] !== undefined ? process.env[name] : m));
372
+ }
373
+ if (Array.isArray(config)) return config.map(substituteEnvLiterals);
374
+ if (config && typeof config === 'object') {
375
+ const out = {};
376
+ for (const [k, v] of Object.entries(config)) out[k] = substituteEnvLiterals(v);
377
+ return out;
378
+ }
379
+ return config;
380
+ }
381
+
382
+ function resolveServersForAgent(agent, selectedServers, mcpRegistry, inputs, { project = false } = {}) {
383
+ const dialect = project ? (agent.projectMcpDialect || agent.mcpDialect) : agent.mcpDialect;
384
+ const out = [];
385
+ for (const serverId of selectedServers) {
386
+ const server = mcpRegistry.find((s) => s.id === serverId);
387
+ const block = project ? server?.projectConfigs?.[agent.id] : server?.configs?.[agent.id];
388
+ if (!server || !block) continue;
389
+ let config = substitutePlaceholders(block, buildReplacements(server, inputs));
390
+ if (server.version) config = pinPackageVersion(config, server.version);
391
+ if (dialect?.envRef === 'literal') config = substituteEnvLiterals(config);
392
+ out.push({ id: serverId, config });
393
+ }
394
+ return out;
395
+ }
396
+
397
+ function toServerMap(serversForAgent) {
398
+ const map = {};
399
+ for (const { id, config } of serversForAgent) map[id] = config;
400
+ return map;
401
+ }
402
+
403
+ export function writeMcpConfigs(selectedAgents, selectedServers, mcpRegistry, inputs = {}) {
404
+ const results = {};
405
+
406
+ for (const agent of selectedAgents) {
407
+ const agentResult = { agent: agent.name, added: 0, skipped: 0, errors: [], addedIds: [] };
408
+ const serversForAgent = resolveServersForAgent(agent, selectedServers, mcpRegistry, inputs);
409
+
410
+ if (serversForAgent.length === 0) {
411
+ agentResult.skipped = selectedServers.length;
412
+ results[agent.id] = agentResult;
413
+ continue;
414
+ }
415
+
416
+ try {
417
+ switch (agent.configFormat) {
418
+ case 'json': {
419
+ const configPath = agent.globalMcpPath(HOME);
420
+ const merged = mergeJsonMcpConfig(configPath, agent.mcpKey, toServerMap(serversForAgent));
421
+ Object.assign(agentResult, merged, { path: configPath });
422
+ break;
423
+ }
424
+
425
+ case 'toml': {
426
+ const tomlBlocks = serversForAgent
427
+ .filter(({ config }) => config.toml)
428
+ .map(({ id, config }) => ({ id, toml: config.toml }));
429
+ const configPath = agent.globalMcpPath(HOME);
430
+ const merged = mergeTomlMcpConfig(configPath, tomlBlocks);
431
+ Object.assign(agentResult, merged, { path: configPath });
432
+ break;
433
+ }
434
+
435
+ case 'cli': {
436
+ Object.assign(agentResult, configureClaudeCodeMcp(serversForAgent));
437
+ break;
438
+ }
439
+ }
440
+ } catch (err) {
441
+ agentResult.errors.push({ id: 'general', error: err.message });
442
+ }
443
+
444
+ results[agent.id] = agentResult;
445
+ }
446
+
447
+ return results;
448
+ }
449
+
450
+ // ── Cursor Rules Writer ──
451
+ export function writeCursorRules(selectedStacks, rulesMap, profile = null) {
452
+ const rulesDir = path.join(process.cwd(), '.cursor', 'rules');
453
+ fs.ensureDirSync(rulesDir);
454
+
455
+ const written = [];
456
+
457
+ // General rules always, with project context injected when a profile is known.
458
+ if (rulesMap.general) {
459
+ const content = buildCursorRule('general', profile) || rulesMap.general;
460
+ if (writeIfAbsent(path.join(rulesDir, 'general.mdc'), content)) written.push('general.mdc');
461
+ }
462
+
463
+ for (const stackId of selectedStacks) {
464
+ if (!rulesMap[stackId]) continue;
465
+ if (writeIfAbsent(path.join(rulesDir, `${stackId}.mdc`), rulesMap[stackId])) written.push(`${stackId}.mdc`);
466
+ }
467
+
468
+ return written;
469
+ }
470
+
471
+ // ── Cursor Commands Writer ──
472
+ // Cursor retired `.cursor/commands/*.md` in favour of skills: a slash command is
473
+ // now `.cursor/skills/<name>/SKILL.md` with `disable-model-invocation: true`
474
+ // (see https://cursor.com/help/customization/skills). Returns the relative
475
+ // paths written, under `.cursor/skills/`.
476
+ export function commandAsSkill(name, body) {
477
+ const title = body.split('\n').find((l) => l.startsWith('# '))?.replace(/^#\s+/, '').trim() || name;
478
+ return [
479
+ '---',
480
+ `name: ${name}`,
481
+ `description: ${title}. Invoke with /${name}.`,
482
+ 'disable-model-invocation: true',
483
+ '---',
484
+ '',
485
+ body.trimEnd(),
486
+ '',
487
+ ].join('\n');
488
+ }
489
+
490
+ export function writeCursorCommands(commandsMap) {
491
+ const skillsDir = path.join(process.cwd(), '.cursor', 'skills');
492
+ const written = [];
493
+
494
+ for (const [name, content] of Object.entries(commandsMap)) {
495
+ const target = path.join(skillsDir, name, 'SKILL.md');
496
+ if (fs.existsSync(target)) continue;
497
+ fs.ensureDirSync(path.dirname(target));
498
+ if (writeIfAbsent(target, commandAsSkill(name, content))) written.push(path.join(name, 'SKILL.md'));
499
+ }
500
+
501
+ return written;
502
+ }
503
+
504
+ // ── Static project files ──
505
+ // Written only when absent; never overwrite a file the user may have edited.
506
+ function writeIfAbsent(filePath, content) {
507
+ if (fs.existsSync(filePath)) return false;
508
+ fs.writeFileSync(filePath, content, 'utf-8');
509
+ return true;
510
+ }
511
+
512
+ const CURSORIGNORE_CONTENT = `# Dependencies
513
+ node_modules/
514
+ .pnp/
515
+ .pnp.js
516
+
517
+ # Build outputs
518
+ dist/
519
+ build/
520
+ .next/
521
+ out/
522
+ __pycache__/
523
+ *.pyc
524
+
525
+ # Environment
526
+ .env
527
+ .env.local
528
+ .env.production
529
+
530
+ # IDE
531
+ .idea/
532
+ *.swp
533
+ *.swo
534
+
535
+ # OS
536
+ .DS_Store
537
+ Thumbs.db
538
+
539
+ # Package locks (reduce noise)
540
+ package-lock.json
541
+ yarn.lock
542
+ pnpm-lock.yaml
543
+ `;
544
+
545
+ const GITATTRIBUTES_CONTENT = `# Auto detect text files and ensure LF line endings
546
+ * text=auto eol=lf
547
+
548
+ # Denote generated files that AI agents can skip
549
+ # (linguist-generated suppresses them from diffs/stats)
550
+ package-lock.json linguist-generated=true
551
+ yarn.lock linguist-generated=true
552
+ pnpm-lock.yaml linguist-generated=true
553
+ bun.lockb linguist-generated=true binary
554
+
555
+ # Diff drivers for common formats
556
+ *.md diff=markdown
557
+ *.css diff=css
558
+ *.html diff=html
559
+
560
+ # Binary files — don't diff or merge
561
+ *.png binary
562
+ *.jpg binary
563
+ *.jpeg binary
564
+ *.gif binary
565
+ *.ico binary
566
+ *.woff binary
567
+ *.woff2 binary
568
+ *.ttf binary
569
+ *.eot binary
570
+ *.pdf binary
571
+ *.zip binary
572
+ *.gz binary
573
+ *.tar binary
574
+
575
+ # Merge strategies — keep ours for lock files during rebases
576
+ package-lock.json merge=ours
577
+ yarn.lock merge=ours
578
+ pnpm-lock.yaml merge=ours
579
+ `;
580
+
581
+ const EDITORCONFIG_CONTENT = `# EditorConfig — consistent formatting across editors and AI agents
582
+ # https://editorconfig.org
583
+
584
+ root = true
585
+
586
+ [*]
587
+ indent_style = space
588
+ indent_size = 2
589
+ end_of_line = lf
590
+ charset = utf-8
591
+ trim_trailing_whitespace = true
592
+ insert_final_newline = true
593
+
594
+ [*.md]
595
+ trim_trailing_whitespace = false
596
+
597
+ [*.py]
598
+ indent_size = 4
599
+
600
+ [*.go]
601
+ indent_style = tab
602
+
603
+ [*.rs]
604
+ indent_size = 4
605
+
606
+ [Makefile]
607
+ indent_style = tab
608
+ `;
609
+
610
+ export function writeCursorIgnore() {
611
+ return writeIfAbsent(path.join(process.cwd(), '.cursorignore'), CURSORIGNORE_CONTENT);
612
+ }
613
+
614
+ export function writeGitattributes() {
615
+ return writeIfAbsent(path.join(process.cwd(), '.gitattributes'), GITATTRIBUTES_CONTENT);
616
+ }
617
+
618
+ export function writeEditorconfig() {
619
+ return writeIfAbsent(path.join(process.cwd(), '.editorconfig'), EDITORCONFIG_CONTENT);
620
+ }
621
+
622
+ export function writeAgentsMd(selectedStacks, profile = null) {
623
+ return writeIfAbsent(path.join(process.cwd(), 'AGENTS.md'), buildAgentsMd(selectedStacks, profile));
624
+ }
625
+
626
+ // ── CLAUDE.md / GEMINI.md Writer ──
627
+ export function writeProjectInstructions(selectedAgents, selectedStacks, profile = null, { importAgentsMd = false } = {}) {
628
+ const written = [];
629
+
630
+ const hasClaudeCode = selectedAgents.some((a) => a.id === 'claude-code');
631
+ const hasGemini = selectedAgents.some((a) => a.id === 'gemini');
632
+
633
+ if (hasClaudeCode && writeIfAbsent(path.join(process.cwd(), 'CLAUDE.md'), buildClaudeMd(selectedStacks, profile, { importAgentsMd }))) {
634
+ written.push('CLAUDE.md');
635
+ }
636
+ if (hasGemini && writeIfAbsent(path.join(process.cwd(), 'GEMINI.md'), buildGeminiMd(selectedStacks, profile))) {
637
+ written.push('GEMINI.md');
638
+ }
639
+
640
+ return written;
641
+ }
642
+
643
+ // ── Project-Level MCP Config Writer ──
644
+ export function writeProjectMcpConfigs(agentsWithProjectMcp, selectedServers, mcpRegistry, inputs = {}) {
645
+ const results = {};
646
+
647
+ for (const agent of agentsWithProjectMcp) {
648
+ const agentResult = { agent: agent.name, added: 0, skipped: 0, errors: [], addedIds: [] };
649
+ const serversForAgent = resolveServersForAgent(agent, selectedServers, mcpRegistry, inputs, { project: true });
650
+
651
+ if (serversForAgent.length === 0) {
652
+ agentResult.skipped = selectedServers.length;
653
+ results[agent.id] = agentResult;
654
+ continue;
655
+ }
656
+
657
+ try {
658
+ const configPath = path.join(process.cwd(), agent.projectMcpPath());
659
+ const format = agent.projectConfigFormat || agent.configFormat;
660
+ const merged = format === 'toml'
661
+ ? mergeTomlMcpConfig(configPath, serversForAgent.filter(({ config }) => config.toml).map(({ id, config }) => ({ id, toml: config.toml })))
662
+ : mergeJsonMcpConfig(configPath, agent.projectMcpKey || agent.mcpKey, toServerMap(serversForAgent));
663
+ Object.assign(agentResult, merged, { path: configPath });
664
+ } catch (err) {
665
+ agentResult.errors.push({ id: 'general', error: err.message });
666
+ }
667
+
668
+ results[agent.id] = agentResult;
669
+ }
670
+
671
+ return results;
672
+ }
673
+
674
+ // ── Skills Installer ──
675
+ // Download a skill's SKILL.md from its raw GitHub URL. Returns the markdown text.
676
+ // A non-2xx (e.g. a missing file → 404) rejects inside fetchText, and an empty
677
+ // body is treated as "not found" — so a failed download never writes a bogus
678
+ // SKILL.md to disk. `fetchImpl` is injectable so the fetch path is testable
679
+ // without a network. `skill.repo`/`skill.path` must be validated by the caller.
680
+ export async function downloadSkillMarkdown(skill, { fetchImpl = fetchText, timeoutMs = 15000 } = {}) {
681
+ const rawUrl = `https://raw.githubusercontent.com/${skill.repo}/main/${skill.path === '.' ? '' : skill.path + '/'}SKILL.md`;
682
+ const content = await fetchImpl(rawUrl, { timeoutMs });
683
+ if (!content || content.trim().length === 0) {
684
+ throw new Error('SKILL.md not found at source');
685
+ }
686
+ return content;
687
+ }
688
+
689
+ export async function installSkills(selectedSkills, skillRegistry, selectedAgents) {
690
+ const installed = [];
691
+ const errors = [];
692
+
693
+ // Target directories. `.agents/skills` is the cross-tool convention read
694
+ // natively by Codex, Cursor, Devin and Antigravity, so it is always the
695
+ // primary location. Claude Code only discovers `.claude/skills`, so when it is
696
+ // selected each skill is mirrored there as well.
697
+ const skillsBaseDir = path.join(process.cwd(), '.agents', 'skills');
698
+ const extraDirs = selectedAgents.some((a) => a.id === 'claude-code')
699
+ ? [path.join(process.cwd(), '.claude', 'skills')]
700
+ : [];
701
+
702
+ fs.ensureDirSync(skillsBaseDir);
703
+
704
+ const mirror = (skillId) => {
705
+ for (const dir of extraDirs) {
706
+ const dest = path.join(dir, skillId);
707
+ if (!fs.existsSync(path.join(dest, 'SKILL.md'))) fs.copySync(path.join(skillsBaseDir, skillId), dest);
708
+ }
709
+ };
710
+
711
+ // A skill counts as installed only once its SKILL.md is actually on disk —
712
+ // never trust an installer's exit code alone.
713
+ const hasSkillContent = (dir) => fs.existsSync(path.join(dir, 'SKILL.md'));
714
+
715
+ for (const skillId of selectedSkills) {
716
+ const skill = skillRegistry.find((s) => s.id === skillId);
717
+ if (!skill) continue;
718
+
719
+ // Registry data is untrusted (network-fetched). `repo`/`path` are
720
+ // interpolated into URLs and command arguments below, so reject anything
721
+ // that isn't a clean "owner/name" + safe sub-path before going further.
722
+ if (!isSafeId(skillId) || !isValidRepo(skill.repo) || !isValidSkillPath(skill.path)) {
723
+ errors.push({ id: skillId, name: skill.name, error: 'invalid skill repo/path in registry' });
724
+ continue;
725
+ }
726
+
727
+ const targetDir = path.join(skillsBaseDir, skillId);
728
+ if (hasSkillContent(targetDir)) {
729
+ mirror(skillId);
730
+ infoMsg(`Skill "${skill.name}" already installed, skipping`);
731
+ continue;
732
+ }
733
+ // A leftover empty dir from a previous failed run would otherwise block a
734
+ // retry — clear it so we can reinstall cleanly.
735
+ if (fs.existsSync(targetDir)) fs.removeSync(targetDir);
736
+
737
+ try {
738
+ const repoUrl = `https://github.com/${skill.repo}`;
739
+ const clonePath = skill.path === '.' ? '' : `/${skill.path}`;
740
+
741
+ // Try npx skills first. It clones the (often large) source repo, so allow
742
+ // a generous timeout, and verify SKILL.md landed before claiming success —
743
+ // a 0 exit code with no files written must NOT be reported as installed.
744
+ // execFileSync (argv form, no shell) so registry-derived URL segments can
745
+ // never be interpreted as shell metacharacters.
746
+ try {
747
+ execFileSync(
748
+ 'npx',
749
+ ['-y', 'skills', 'install', `${repoUrl}/tree/main${clonePath}`, '--dir', skillsBaseDir],
750
+ { stdio: 'pipe', timeout: 180000 }
751
+ );
752
+ if (hasSkillContent(targetDir)) {
753
+ mirror(skillId);
754
+ installed.push(skillId);
755
+ continue;
756
+ }
757
+ } catch {
758
+ // Fall back to manual download
759
+ }
760
+
761
+ // Manual fallback: create the skill dir and fetch SKILL.md with native
762
+ // fetch (no shell, no curl dependency). A 404/empty body throws, so it
763
+ // lands in the catch and the dir is cleaned up rather than left holding a
764
+ // bogus file. repo/path were validated above.
765
+ fs.ensureDirSync(targetDir);
766
+ try {
767
+ const content = await downloadSkillMarkdown(skill);
768
+ fs.writeFileSync(path.join(targetDir, 'SKILL.md'), content, 'utf-8');
769
+ mirror(skillId);
770
+ installed.push(skillId);
771
+ } catch (err) {
772
+ fs.removeSync(targetDir);
773
+ errors.push({ id: skillId, name: skill.name, error: err.message });
774
+ }
775
+ } catch (err) {
776
+ errors.push({ id: skillId, name: skill.name, error: err.message });
777
+ }
778
+ }
779
+
780
+ return { installed, errors, directory: skillsBaseDir, extraDirectories: extraDirs };
781
+ }