Skip to content

Commit ffec2c5

Browse files
committed
feat(cli): add adapter config generator from shared .ai-rules
- Add generateAdapterConfigs() for cursor, claude-code, codex, antigravity - Read adapter templates from .ai-rules/adapters/ and write to tool-specific paths - Backup existing configs to .codingbuddy-backup/ before overwriting - Support dry-run mode (preview without writing) and force mode (skip backup) - Add 14 unit tests covering all adapters, backup, dry-run, and force behavior Closes #1450
1 parent e4b33f4 commit ffec2c5

2 files changed

Lines changed: 289 additions & 0 deletions

File tree

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
const { describe, it, beforeEach, afterEach } = require('node:test');
2+
const assert = require('node:assert/strict');
3+
const fs = require('node:fs');
4+
const path = require('node:path');
5+
const os = require('node:os');
6+
7+
const { generateAdapterConfigs, ADAPTER_MAP } = require('../../lib/init/generate-adapter');
8+
9+
describe('generateAdapterConfigs', () => {
10+
let tmpDir;
11+
12+
beforeEach(() => {
13+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cb-adapter-'));
14+
});
15+
16+
afterEach(() => {
17+
fs.rmSync(tmpDir, { recursive: true, force: true });
18+
});
19+
20+
it('generates cursor adapter config', () => {
21+
const result = generateAdapterConfigs(tmpDir, ['cursor']);
22+
23+
assert.equal(result.generated.length, 1);
24+
assert.equal(result.generated[0].tool, 'cursor');
25+
assert.equal(result.generated[0].action, 'created');
26+
27+
const outputPath = path.join(tmpDir, '.cursor', 'rules', 'codingbuddy.mdc');
28+
assert.ok(fs.existsSync(outputPath));
29+
30+
const content = fs.readFileSync(outputPath, 'utf-8');
31+
assert.ok(content.includes('Cursor'));
32+
});
33+
34+
it('generates claude-code adapter config', () => {
35+
const result = generateAdapterConfigs(tmpDir, ['claude-code']);
36+
37+
assert.equal(result.generated.length, 1);
38+
assert.equal(result.generated[0].tool, 'claude-code');
39+
assert.equal(result.generated[0].action, 'created');
40+
41+
const outputPath = path.join(tmpDir, '.claude', 'CLAUDE.md');
42+
assert.ok(fs.existsSync(outputPath));
43+
44+
const content = fs.readFileSync(outputPath, 'utf-8');
45+
assert.ok(content.includes('Claude'));
46+
});
47+
48+
it('generates codex adapter config', () => {
49+
const result = generateAdapterConfigs(tmpDir, ['codex']);
50+
51+
assert.equal(result.generated.length, 1);
52+
assert.equal(result.generated[0].tool, 'codex');
53+
assert.equal(result.generated[0].action, 'created');
54+
55+
const outputPath = path.join(tmpDir, '.codex', 'instructions.md');
56+
assert.ok(fs.existsSync(outputPath));
57+
58+
const content = fs.readFileSync(outputPath, 'utf-8');
59+
assert.ok(content.includes('Codex'));
60+
});
61+
62+
it('generates antigravity adapter config', () => {
63+
const result = generateAdapterConfigs(tmpDir, ['antigravity']);
64+
65+
assert.equal(result.generated.length, 1);
66+
assert.equal(result.generated[0].tool, 'antigravity');
67+
assert.equal(result.generated[0].action, 'created');
68+
69+
const outputPath = path.join(tmpDir, '.antigravity', 'instructions.md');
70+
assert.ok(fs.existsSync(outputPath));
71+
72+
const content = fs.readFileSync(outputPath, 'utf-8');
73+
assert.ok(content.includes('Antigravity'));
74+
});
75+
76+
it('generates multiple adapter configs at once', () => {
77+
const tools = ['cursor', 'claude-code', 'codex', 'antigravity'];
78+
const result = generateAdapterConfigs(tmpDir, tools);
79+
80+
assert.equal(result.generated.length, 4);
81+
assert.equal(result.skipped.length, 0);
82+
assert.equal(result.backedUp.length, 0);
83+
84+
for (const entry of result.generated) {
85+
assert.ok(fs.existsSync(entry.path));
86+
assert.equal(entry.action, 'created');
87+
}
88+
});
89+
90+
it('skips unknown tools', () => {
91+
const result = generateAdapterConfigs(tmpDir, ['unknown-tool']);
92+
93+
assert.equal(result.generated.length, 0);
94+
assert.equal(result.skipped.length, 1);
95+
assert.equal(result.skipped[0].tool, 'unknown-tool');
96+
assert.equal(result.skipped[0].reason, 'unknown-tool');
97+
});
98+
99+
it('backs up existing configs before overwriting', () => {
100+
const existingPath = path.join(tmpDir, '.codex', 'instructions.md');
101+
fs.mkdirSync(path.dirname(existingPath), { recursive: true });
102+
fs.writeFileSync(existingPath, 'existing content');
103+
104+
const result = generateAdapterConfigs(tmpDir, ['codex']);
105+
106+
assert.equal(result.generated.length, 1);
107+
assert.equal(result.generated[0].action, 'overwritten');
108+
assert.equal(result.backedUp.length, 1);
109+
assert.equal(result.backedUp[0].tool, 'codex');
110+
111+
const backupPath = path.join(tmpDir, '.codingbuddy-backup', '.codex', 'instructions.md');
112+
assert.ok(fs.existsSync(backupPath));
113+
assert.equal(fs.readFileSync(backupPath, 'utf-8'), 'existing content');
114+
115+
// Original file was overwritten with new content
116+
const newContent = fs.readFileSync(existingPath, 'utf-8');
117+
assert.notEqual(newContent, 'existing content');
118+
});
119+
120+
it('skips backup in force mode', () => {
121+
const existingPath = path.join(tmpDir, '.codex', 'instructions.md');
122+
fs.mkdirSync(path.dirname(existingPath), { recursive: true });
123+
fs.writeFileSync(existingPath, 'existing content');
124+
125+
const result = generateAdapterConfigs(tmpDir, ['codex'], { force: true });
126+
127+
assert.equal(result.generated.length, 1);
128+
assert.equal(result.generated[0].action, 'overwritten');
129+
assert.equal(result.backedUp.length, 0);
130+
131+
const backupDir = path.join(tmpDir, '.codingbuddy-backup');
132+
assert.ok(!fs.existsSync(backupDir));
133+
});
134+
135+
it('dry-run mode returns what would be generated without writing', () => {
136+
const result = generateAdapterConfigs(tmpDir, ['cursor', 'codex'], { dryRun: true });
137+
138+
assert.equal(result.generated.length, 2);
139+
assert.equal(result.generated[0].action, 'create');
140+
assert.equal(result.generated[1].action, 'create');
141+
142+
assert.ok(!fs.existsSync(path.join(tmpDir, '.cursor', 'rules', 'codingbuddy.mdc')));
143+
assert.ok(!fs.existsSync(path.join(tmpDir, '.codex', 'instructions.md')));
144+
});
145+
146+
it('dry-run mode detects existing files as overwrite', () => {
147+
const existingPath = path.join(tmpDir, '.codex', 'instructions.md');
148+
fs.mkdirSync(path.dirname(existingPath), { recursive: true });
149+
fs.writeFileSync(existingPath, 'existing content');
150+
151+
const result = generateAdapterConfigs(tmpDir, ['codex'], { dryRun: true });
152+
153+
assert.equal(result.generated.length, 1);
154+
assert.equal(result.generated[0].action, 'overwrite');
155+
156+
// Original file unchanged
157+
assert.equal(fs.readFileSync(existingPath, 'utf-8'), 'existing content');
158+
});
159+
160+
it('creates output directories if they do not exist', () => {
161+
generateAdapterConfigs(tmpDir, ['cursor']);
162+
163+
const rulesDir = path.join(tmpDir, '.cursor', 'rules');
164+
assert.ok(fs.existsSync(rulesDir));
165+
assert.ok(fs.statSync(rulesDir).isDirectory());
166+
});
167+
168+
it('returns empty results for empty detectedTools', () => {
169+
const result = generateAdapterConfigs(tmpDir, []);
170+
171+
assert.equal(result.generated.length, 0);
172+
assert.equal(result.backedUp.length, 0);
173+
assert.equal(result.skipped.length, 0);
174+
});
175+
176+
it('defaults options to dryRun=false and force=false', () => {
177+
const result = generateAdapterConfigs(tmpDir, ['codex']);
178+
179+
assert.equal(result.generated.length, 1);
180+
assert.equal(result.generated[0].action, 'created');
181+
assert.ok(fs.existsSync(path.join(tmpDir, '.codex', 'instructions.md')));
182+
});
183+
184+
it('ADAPTER_MAP contains all four supported tools', () => {
185+
const tools = Object.keys(ADAPTER_MAP);
186+
assert.ok(tools.includes('cursor'));
187+
assert.ok(tools.includes('claude-code'));
188+
assert.ok(tools.includes('codex'));
189+
assert.ok(tools.includes('antigravity'));
190+
assert.equal(tools.length, 4);
191+
});
192+
});
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
'use strict';
2+
3+
const fs = require('node:fs');
4+
const path = require('node:path');
5+
6+
/**
7+
* Mapping from tool name to adapter source file and output target path.
8+
*/
9+
const ADAPTER_MAP = {
10+
cursor: {
11+
adapterFile: 'cursor.md',
12+
outputPath: path.join('.cursor', 'rules', 'codingbuddy.mdc'),
13+
},
14+
'claude-code': {
15+
adapterFile: 'claude-code.md',
16+
outputPath: path.join('.claude', 'CLAUDE.md'),
17+
},
18+
codex: {
19+
adapterFile: 'codex.md',
20+
outputPath: path.join('.codex', 'instructions.md'),
21+
},
22+
antigravity: {
23+
adapterFile: 'antigravity.md',
24+
outputPath: path.join('.antigravity', 'instructions.md'),
25+
},
26+
};
27+
28+
/**
29+
* Resolve the path to .ai-rules/adapters/ within this package.
30+
* @returns {string}
31+
*/
32+
function getAdaptersDir() {
33+
return path.resolve(__dirname, '../../.ai-rules/adapters');
34+
}
35+
36+
/**
37+
* Generate adapter-specific config files for each detected AI tool.
38+
* @param {string} cwd - Target directory
39+
* @param {string[]} detectedTools - Array of tool names (e.g. ['cursor', 'claude-code'])
40+
* @param {{ dryRun?: boolean, force?: boolean }} options
41+
* @returns {{ generated: Array<{tool: string, path: string, action: string}>, backedUp: Array<{tool: string, from: string, to: string}>, skipped: Array<{tool: string, reason: string}> }}
42+
*/
43+
function generateAdapterConfigs(cwd, detectedTools, options = {}) {
44+
const { dryRun = false, force = false } = options;
45+
const adaptersDir = getAdaptersDir();
46+
47+
const result = {
48+
generated: [],
49+
backedUp: [],
50+
skipped: [],
51+
};
52+
53+
for (const tool of detectedTools) {
54+
const mapping = ADAPTER_MAP[tool];
55+
if (!mapping) {
56+
result.skipped.push({ tool, reason: 'unknown-tool' });
57+
continue;
58+
}
59+
60+
const adapterPath = path.join(adaptersDir, mapping.adapterFile);
61+
if (!fs.existsSync(adapterPath)) {
62+
result.skipped.push({ tool, reason: 'adapter-not-found' });
63+
continue;
64+
}
65+
66+
const content = fs.readFileSync(adapterPath, 'utf-8');
67+
const outputPath = path.join(cwd, mapping.outputPath);
68+
69+
if (dryRun) {
70+
const action = fs.existsSync(outputPath) ? 'overwrite' : 'create';
71+
result.generated.push({ tool, path: outputPath, action });
72+
continue;
73+
}
74+
75+
const exists = fs.existsSync(outputPath);
76+
77+
if (exists && !force) {
78+
const backupDir = path.join(cwd, '.codingbuddy-backup');
79+
const backupPath = path.join(backupDir, mapping.outputPath);
80+
fs.mkdirSync(path.dirname(backupPath), { recursive: true });
81+
fs.copyFileSync(outputPath, backupPath);
82+
result.backedUp.push({ tool, from: outputPath, to: backupPath });
83+
}
84+
85+
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
86+
fs.writeFileSync(outputPath, content, 'utf-8');
87+
result.generated.push({
88+
tool,
89+
path: outputPath,
90+
action: exists ? 'overwritten' : 'created',
91+
});
92+
}
93+
94+
return result;
95+
}
96+
97+
module.exports = { generateAdapterConfigs, ADAPTER_MAP };

0 commit comments

Comments
 (0)