Skip to content

Commit 9b98281

Browse files
committed
feat(cli): add codingbuddy init --team for multi-AI tool setup (#1440)
- Add detect-tools.js: scan for 6 AI tool configs (.cursor, .claude, .codex, .antigravity, .q, .kiro) - Add generate-adapter.js: generate adapter configs from shared .ai-rules/ templates with backup and dry-run support - Add team.js: orchestrate detection + generation pipeline - Add --team, --dry-run, --force flags to CLI init command - 15 new tests (7 detect-tools + 8 generate-adapter), 47 total pass Closes #1440 Closes #1449 Closes #1450 Closes #1451
1 parent e4b33f4 commit 9b98281

6 files changed

Lines changed: 406 additions & 12 deletions

File tree

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
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 { detectTools, AI_TOOLS } = require('../../lib/init/detect-tools');
8+
9+
describe('detectTools', () => {
10+
let tmpDir;
11+
12+
beforeEach(() => {
13+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cb-detect-tools-'));
14+
});
15+
16+
afterEach(() => {
17+
fs.rmSync(tmpDir, { recursive: true, force: true });
18+
});
19+
20+
it('returns all 6 tools', () => {
21+
const result = detectTools(tmpDir);
22+
assert.equal(result.length, 6);
23+
const names = result.map(t => t.name);
24+
assert.ok(names.includes('cursor'));
25+
assert.ok(names.includes('claude-code'));
26+
assert.ok(names.includes('codex'));
27+
assert.ok(names.includes('antigravity'));
28+
assert.ok(names.includes('amazon-q'));
29+
assert.ok(names.includes('kiro'));
30+
});
31+
32+
it('reports exists=false for empty directory', () => {
33+
const result = detectTools(tmpDir);
34+
for (const tool of result) {
35+
assert.equal(tool.exists, false);
36+
assert.equal(tool.hasConfig, false);
37+
}
38+
});
39+
40+
it('detects .cursor directory', () => {
41+
fs.mkdirSync(path.join(tmpDir, '.cursor'));
42+
const result = detectTools(tmpDir);
43+
const cursor = result.find(t => t.name === 'cursor');
44+
assert.equal(cursor.exists, true);
45+
assert.equal(cursor.hasConfig, false);
46+
});
47+
48+
it('detects .cursor with rules indicator', () => {
49+
fs.mkdirSync(path.join(tmpDir, '.cursor', 'rules'), { recursive: true });
50+
const result = detectTools(tmpDir);
51+
const cursor = result.find(t => t.name === 'cursor');
52+
assert.equal(cursor.exists, true);
53+
assert.equal(cursor.hasConfig, true);
54+
});
55+
56+
it('detects .claude directory', () => {
57+
fs.mkdirSync(path.join(tmpDir, '.claude'));
58+
fs.writeFileSync(path.join(tmpDir, '.claude', 'settings.json'), '{}');
59+
const result = detectTools(tmpDir);
60+
const claude = result.find(t => t.name === 'claude-code');
61+
assert.equal(claude.exists, true);
62+
assert.equal(claude.hasConfig, true);
63+
});
64+
65+
it('detects multiple tools simultaneously', () => {
66+
fs.mkdirSync(path.join(tmpDir, '.cursor'));
67+
fs.mkdirSync(path.join(tmpDir, '.claude'));
68+
fs.mkdirSync(path.join(tmpDir, '.codex'));
69+
const result = detectTools(tmpDir);
70+
const detected = result.filter(t => t.exists);
71+
assert.equal(detected.length, 3);
72+
});
73+
74+
it('AI_TOOLS has correct structure', () => {
75+
for (const tool of AI_TOOLS) {
76+
assert.ok(tool.name, 'tool should have name');
77+
assert.ok(tool.configDir, 'tool should have configDir');
78+
assert.ok(tool.configDir.startsWith('.'), 'configDir should be a dotdir');
79+
assert.ok(tool.indicator, 'tool should have indicator');
80+
}
81+
});
82+
});
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
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-gen-adapter-'));
14+
// Create a minimal .ai-rules/adapters/ with a fake adapter
15+
const adaptersDir = path.join(tmpDir, '.ai-rules', 'adapters');
16+
fs.mkdirSync(adaptersDir, { recursive: true });
17+
fs.writeFileSync(path.join(adaptersDir, 'cursor.md'), '# Cursor Adapter\nRules here.');
18+
fs.writeFileSync(path.join(adaptersDir, 'claude-code.md'), '# Claude Code Adapter');
19+
fs.writeFileSync(path.join(adaptersDir, 'codex.md'), '# Codex Adapter');
20+
fs.writeFileSync(path.join(adaptersDir, 'antigravity.md'), '# Antigravity Adapter');
21+
fs.writeFileSync(path.join(adaptersDir, 'q.md'), '# Q Adapter');
22+
fs.writeFileSync(path.join(adaptersDir, 'kiro.md'), '# Kiro Adapter');
23+
});
24+
25+
afterEach(() => {
26+
fs.rmSync(tmpDir, { recursive: true, force: true });
27+
});
28+
29+
it('generates config for a detected tool', () => {
30+
const tools = [{ name: 'cursor' }];
31+
const result = generateAdapterConfigs(tmpDir, tools);
32+
assert.equal(result.generated.length, 1);
33+
assert.equal(result.generated[0].tool, 'cursor');
34+
assert.equal(result.generated[0].action, 'created');
35+
// File should exist
36+
const targetPath = path.join(tmpDir, ADAPTER_MAP.cursor.target);
37+
assert.ok(fs.existsSync(targetPath));
38+
assert.ok(fs.readFileSync(targetPath, 'utf-8').includes('Cursor Adapter'));
39+
});
40+
41+
it('generates configs for multiple tools', () => {
42+
const tools = [{ name: 'cursor' }, { name: 'claude-code' }, { name: 'codex' }];
43+
const result = generateAdapterConfigs(tmpDir, tools);
44+
assert.equal(result.generated.length, 3);
45+
});
46+
47+
it('skips unknown tools', () => {
48+
const tools = [{ name: 'unknown-tool' }];
49+
const result = generateAdapterConfigs(tmpDir, tools);
50+
assert.equal(result.generated.length, 0);
51+
assert.equal(result.skipped.length, 1);
52+
assert.equal(result.skipped[0].reason, 'no adapter mapping');
53+
});
54+
55+
it('backs up existing config before overwriting', () => {
56+
// Create existing config
57+
const targetDir = path.join(tmpDir, '.cursor', 'rules');
58+
fs.mkdirSync(targetDir, { recursive: true });
59+
fs.writeFileSync(path.join(targetDir, 'codingbuddy.mdc'), 'old content');
60+
61+
const tools = [{ name: 'cursor' }];
62+
const result = generateAdapterConfigs(tmpDir, tools);
63+
assert.equal(result.backedUp.length, 1);
64+
assert.equal(result.backedUp[0].tool, 'cursor');
65+
// Backup dir should exist
66+
assert.ok(fs.existsSync(path.join(tmpDir, '.codingbuddy-backup')));
67+
});
68+
69+
it('skips backup with force option', () => {
70+
const targetDir = path.join(tmpDir, '.cursor', 'rules');
71+
fs.mkdirSync(targetDir, { recursive: true });
72+
fs.writeFileSync(path.join(targetDir, 'codingbuddy.mdc'), 'old content');
73+
74+
const tools = [{ name: 'cursor' }];
75+
const result = generateAdapterConfigs(tmpDir, tools, { force: true });
76+
assert.equal(result.backedUp.length, 0);
77+
assert.equal(result.generated.length, 1);
78+
});
79+
80+
it('dry run does not write files', () => {
81+
const tools = [{ name: 'cursor' }];
82+
const result = generateAdapterConfigs(tmpDir, tools, { dryRun: true });
83+
assert.equal(result.generated.length, 1);
84+
assert.equal(result.generated[0].action, 'would-create');
85+
// File should NOT exist
86+
const targetPath = path.join(tmpDir, ADAPTER_MAP.cursor.target);
87+
assert.ok(!fs.existsSync(targetPath));
88+
});
89+
90+
it('skips when adapter template not found', () => {
91+
// Remove the adapter file
92+
fs.unlinkSync(path.join(tmpDir, '.ai-rules', 'adapters', 'cursor.md'));
93+
const tools = [{ name: 'cursor' }];
94+
const result = generateAdapterConfigs(tmpDir, tools);
95+
assert.equal(result.skipped.length, 1);
96+
assert.equal(result.skipped[0].reason, 'adapter template not found');
97+
});
98+
99+
it('ADAPTER_MAP covers all expected tools', () => {
100+
const expectedTools = ['cursor', 'claude-code', 'codex', 'antigravity', 'amazon-q', 'kiro'];
101+
for (const tool of expectedTools) {
102+
assert.ok(ADAPTER_MAP[tool], `${tool} should have adapter mapping`);
103+
assert.ok(ADAPTER_MAP[tool].adapter, `${tool} should have adapter file`);
104+
assert.ok(ADAPTER_MAP[tool].target, `${tool} should have target path`);
105+
}
106+
});
107+
});

packages/rules/bin/cli.js

Lines changed: 32 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,16 @@ Usage:
1616
codingbuddy <command> [options]
1717
1818
Commands:
19-
init Initialize .ai-rules in the current project
20-
validate Validate .ai-rules structure (agents JSON, rules markdown)
21-
list-agents List available specialist agents
19+
init Initialize .ai-rules in the current project
20+
init --team Detect AI tools and generate adapter configs for all
21+
validate Validate .ai-rules structure (agents JSON, rules markdown)
22+
list-agents List available specialist agents
2223
2324
Options:
24-
--help, -h Show this help message
25-
--version, -v Show version
25+
--help, -h Show this help message
26+
--version, -v Show version
27+
--dry-run Preview changes without writing (init --team)
28+
--force Overwrite without backup (init --team)
2629
`.trim(),
2730
);
2831
}
@@ -130,12 +133,23 @@ function validate() {
130133
console.log('\nAll validations passed');
131134
}
132135

133-
function init() {
134-
const { run } = require('../lib/init');
135-
run().catch(err => {
136-
console.error('Error:', err.message);
137-
process.exit(1);
138-
});
136+
function init(flags) {
137+
if (flags.team) {
138+
const { runTeam } = require('../lib/init/team');
139+
runTeam(process.cwd(), {
140+
dryRun: flags.dryRun,
141+
force: flags.force,
142+
}).catch(err => {
143+
console.error('Error:', err.message);
144+
process.exit(1);
145+
});
146+
} else {
147+
const { run } = require('../lib/init');
148+
run().catch(err => {
149+
console.error('Error:', err.message);
150+
process.exit(1);
151+
});
152+
}
139153
}
140154

141155
// --- Main ---
@@ -153,9 +167,15 @@ if (command === '--version' || command === '-v') {
153167
process.exit(0);
154168
}
155169

170+
const flags = {
171+
team: args.includes('--team'),
172+
dryRun: args.includes('--dry-run'),
173+
force: args.includes('--force'),
174+
};
175+
156176
switch (command) {
157177
case 'init':
158-
init();
178+
init(flags);
159179
break;
160180
case 'validate':
161181
validate();
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
'use strict';
2+
3+
const fs = require('node:fs');
4+
const path = require('node:path');
5+
6+
/**
7+
* Supported AI coding tool definitions.
8+
* Each entry maps a tool name to its config directory and indicator file.
9+
*/
10+
const AI_TOOLS = [
11+
{ name: 'cursor', configDir: '.cursor', indicator: 'rules' },
12+
{ name: 'claude-code', configDir: '.claude', indicator: 'settings.json' },
13+
{ name: 'codex', configDir: '.codex', indicator: 'instructions.md' },
14+
{ name: 'antigravity', configDir: '.antigravity', indicator: 'instructions.md' },
15+
{ name: 'amazon-q', configDir: '.q', indicator: 'settings.json' },
16+
{ name: 'kiro', configDir: '.kiro', indicator: 'settings.json' },
17+
];
18+
19+
/**
20+
* Detect installed AI coding tools by scanning for their config directories.
21+
* @param {string} cwd - Directory to scan
22+
* @returns {Array<{ name: string, configDir: string, indicator: string, exists: boolean, hasConfig: boolean }>}
23+
*/
24+
function detectTools(cwd) {
25+
return AI_TOOLS.map(tool => {
26+
const dirPath = path.join(cwd, tool.configDir);
27+
const exists = fs.existsSync(dirPath);
28+
const hasConfig =
29+
exists && fs.existsSync(path.join(dirPath, tool.indicator));
30+
return {
31+
name: tool.name,
32+
configDir: tool.configDir,
33+
indicator: tool.indicator,
34+
exists,
35+
hasConfig,
36+
};
37+
});
38+
}
39+
40+
module.exports = { detectTools, AI_TOOLS };
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
'use strict';
2+
3+
const fs = require('node:fs');
4+
const path = require('node:path');
5+
6+
/**
7+
* Maps tool names to their adapter template file and output target path.
8+
*/
9+
const ADAPTER_MAP = {
10+
cursor: { adapter: 'cursor.md', target: '.cursor/rules/codingbuddy.mdc' },
11+
'claude-code': { adapter: 'claude-code.md', target: '.claude/CLAUDE.md' },
12+
codex: { adapter: 'codex.md', target: '.codex/instructions.md' },
13+
antigravity: { adapter: 'antigravity.md', target: '.antigravity/instructions.md' },
14+
'amazon-q': { adapter: 'q.md', target: '.q/rules/codingbuddy.md' },
15+
kiro: { adapter: 'kiro.md', target: '.kiro/rules/codingbuddy.md' },
16+
};
17+
18+
/**
19+
* Generate adapter-specific configs for detected AI tools from shared .ai-rules.
20+
* @param {string} cwd - Project root directory
21+
* @param {Array<{ name: string }>} detectedTools - Tools to generate configs for
22+
* @param {{ dryRun?: boolean, force?: boolean }} [options]
23+
* @returns {{ generated: Array, backedUp: Array, skipped: Array }}
24+
*/
25+
function generateAdapterConfigs(cwd, detectedTools, options) {
26+
const { dryRun = false, force = false } = options || {};
27+
const result = { generated: [], backedUp: [], skipped: [] };
28+
const adaptersDir = path.join(cwd, '.ai-rules', 'adapters');
29+
30+
for (const tool of detectedTools) {
31+
const mapping = ADAPTER_MAP[tool.name];
32+
if (!mapping) {
33+
result.skipped.push({ tool: tool.name, reason: 'no adapter mapping' });
34+
continue;
35+
}
36+
37+
const adapterPath = path.join(adaptersDir, mapping.adapter);
38+
if (!fs.existsSync(adapterPath)) {
39+
result.skipped.push({ tool: tool.name, reason: 'adapter template not found' });
40+
continue;
41+
}
42+
43+
const targetPath = path.join(cwd, mapping.target);
44+
const content = fs.readFileSync(adapterPath, 'utf-8');
45+
46+
if (dryRun) {
47+
result.generated.push({ tool: tool.name, path: mapping.target, action: 'would-create' });
48+
continue;
49+
}
50+
51+
// Backup existing config unless --force
52+
if (fs.existsSync(targetPath) && !force) {
53+
const backupDir = path.join(cwd, '.codingbuddy-backup');
54+
fs.mkdirSync(backupDir, { recursive: true });
55+
const timestamp = Date.now();
56+
const backupName = path.basename(mapping.target) + '.' + timestamp + '.bak';
57+
const backupPath = path.join(backupDir, tool.name + '-' + backupName);
58+
fs.copyFileSync(targetPath, backupPath);
59+
result.backedUp.push({ tool: tool.name, from: mapping.target, to: backupPath });
60+
}
61+
62+
// Write adapter config
63+
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
64+
fs.writeFileSync(targetPath, content, 'utf-8');
65+
result.generated.push({ tool: tool.name, path: mapping.target, action: 'created' });
66+
}
67+
68+
return result;
69+
}
70+
71+
module.exports = { generateAdapterConfigs, ADAPTER_MAP };

0 commit comments

Comments
 (0)