diff --git a/.gitignore b/.gitignore index 265f44f1..9a5fbed4 100644 --- a/.gitignore +++ b/.gitignore @@ -71,3 +71,6 @@ TASK.md __pycache__/ *.pyc .pytest_cache/ + +# codingbuddy init --team backups +.codingbuddy-backup/ diff --git a/packages/rules/__tests__/init/detect-tools.test.js b/packages/rules/__tests__/init/detect-tools.test.js new file mode 100644 index 00000000..955b0cb5 --- /dev/null +++ b/packages/rules/__tests__/init/detect-tools.test.js @@ -0,0 +1,82 @@ +const { describe, it, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); + +const { detectTools, AI_TOOLS } = require('../../lib/init/detect-tools'); + +describe('detectTools', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cb-detect-tools-')); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('returns all 6 tools', () => { + const result = detectTools(tmpDir); + assert.equal(result.length, 6); + const names = result.map(t => t.name); + assert.ok(names.includes('cursor')); + assert.ok(names.includes('claude-code')); + assert.ok(names.includes('codex')); + assert.ok(names.includes('antigravity')); + assert.ok(names.includes('amazon-q')); + assert.ok(names.includes('kiro')); + }); + + it('reports exists=false for empty directory', () => { + const result = detectTools(tmpDir); + for (const tool of result) { + assert.equal(tool.exists, false); + assert.equal(tool.hasConfig, false); + } + }); + + it('detects .cursor directory', () => { + fs.mkdirSync(path.join(tmpDir, '.cursor')); + const result = detectTools(tmpDir); + const cursor = result.find(t => t.name === 'cursor'); + assert.equal(cursor.exists, true); + assert.equal(cursor.hasConfig, false); + }); + + it('detects .cursor with rules indicator', () => { + fs.mkdirSync(path.join(tmpDir, '.cursor', 'rules'), { recursive: true }); + const result = detectTools(tmpDir); + const cursor = result.find(t => t.name === 'cursor'); + assert.equal(cursor.exists, true); + assert.equal(cursor.hasConfig, true); + }); + + it('detects .claude directory', () => { + fs.mkdirSync(path.join(tmpDir, '.claude')); + fs.writeFileSync(path.join(tmpDir, '.claude', 'settings.json'), '{}'); + const result = detectTools(tmpDir); + const claude = result.find(t => t.name === 'claude-code'); + assert.equal(claude.exists, true); + assert.equal(claude.hasConfig, true); + }); + + it('detects multiple tools simultaneously', () => { + fs.mkdirSync(path.join(tmpDir, '.cursor')); + fs.mkdirSync(path.join(tmpDir, '.claude')); + fs.mkdirSync(path.join(tmpDir, '.codex')); + const result = detectTools(tmpDir); + const detected = result.filter(t => t.exists); + assert.equal(detected.length, 3); + }); + + it('AI_TOOLS has correct structure', () => { + for (const tool of AI_TOOLS) { + assert.ok(tool.name, 'tool should have name'); + assert.ok(tool.configDir, 'tool should have configDir'); + assert.ok(tool.configDir.startsWith('.'), 'configDir should be a dotdir'); + assert.ok(tool.indicator, 'tool should have indicator'); + } + }); +}); diff --git a/packages/rules/__tests__/init/generate-adapter.test.js b/packages/rules/__tests__/init/generate-adapter.test.js new file mode 100644 index 00000000..c232a200 --- /dev/null +++ b/packages/rules/__tests__/init/generate-adapter.test.js @@ -0,0 +1,107 @@ +const { describe, it, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); + +const { generateAdapterConfigs, ADAPTER_MAP } = require('../../lib/init/generate-adapter'); + +describe('generateAdapterConfigs', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cb-gen-adapter-')); + // Create a minimal .ai-rules/adapters/ with a fake adapter + const adaptersDir = path.join(tmpDir, '.ai-rules', 'adapters'); + fs.mkdirSync(adaptersDir, { recursive: true }); + fs.writeFileSync(path.join(adaptersDir, 'cursor.md'), '# Cursor Adapter\nRules here.'); + fs.writeFileSync(path.join(adaptersDir, 'claude-code.md'), '# Claude Code Adapter'); + fs.writeFileSync(path.join(adaptersDir, 'codex.md'), '# Codex Adapter'); + fs.writeFileSync(path.join(adaptersDir, 'antigravity.md'), '# Antigravity Adapter'); + fs.writeFileSync(path.join(adaptersDir, 'q.md'), '# Q Adapter'); + fs.writeFileSync(path.join(adaptersDir, 'kiro.md'), '# Kiro Adapter'); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('generates config for a detected tool', () => { + const tools = [{ name: 'cursor' }]; + const result = generateAdapterConfigs(tmpDir, tools); + assert.equal(result.generated.length, 1); + assert.equal(result.generated[0].tool, 'cursor'); + assert.equal(result.generated[0].action, 'created'); + // File should exist + const targetPath = path.join(tmpDir, ADAPTER_MAP.cursor.target); + assert.ok(fs.existsSync(targetPath)); + assert.ok(fs.readFileSync(targetPath, 'utf-8').includes('Cursor Adapter')); + }); + + it('generates configs for multiple tools', () => { + const tools = [{ name: 'cursor' }, { name: 'claude-code' }, { name: 'codex' }]; + const result = generateAdapterConfigs(tmpDir, tools); + assert.equal(result.generated.length, 3); + }); + + it('skips unknown tools', () => { + const tools = [{ name: 'unknown-tool' }]; + const result = generateAdapterConfigs(tmpDir, tools); + assert.equal(result.generated.length, 0); + assert.equal(result.skipped.length, 1); + assert.equal(result.skipped[0].reason, 'no adapter mapping'); + }); + + it('backs up existing config before overwriting', () => { + // Create existing config + const targetDir = path.join(tmpDir, '.cursor', 'rules'); + fs.mkdirSync(targetDir, { recursive: true }); + fs.writeFileSync(path.join(targetDir, 'codingbuddy.mdc'), 'old content'); + + const tools = [{ name: 'cursor' }]; + const result = generateAdapterConfigs(tmpDir, tools); + assert.equal(result.backedUp.length, 1); + assert.equal(result.backedUp[0].tool, 'cursor'); + // Backup dir should exist + assert.ok(fs.existsSync(path.join(tmpDir, '.codingbuddy-backup'))); + }); + + it('skips backup with force option', () => { + const targetDir = path.join(tmpDir, '.cursor', 'rules'); + fs.mkdirSync(targetDir, { recursive: true }); + fs.writeFileSync(path.join(targetDir, 'codingbuddy.mdc'), 'old content'); + + const tools = [{ name: 'cursor' }]; + const result = generateAdapterConfigs(tmpDir, tools, { force: true }); + assert.equal(result.backedUp.length, 0); + assert.equal(result.generated.length, 1); + }); + + it('dry run does not write files', () => { + const tools = [{ name: 'cursor' }]; + const result = generateAdapterConfigs(tmpDir, tools, { dryRun: true }); + assert.equal(result.generated.length, 1); + assert.equal(result.generated[0].action, 'would-create'); + // File should NOT exist + const targetPath = path.join(tmpDir, ADAPTER_MAP.cursor.target); + assert.ok(!fs.existsSync(targetPath)); + }); + + it('skips when adapter template not found', () => { + // Remove the adapter file + fs.unlinkSync(path.join(tmpDir, '.ai-rules', 'adapters', 'cursor.md')); + const tools = [{ name: 'cursor' }]; + const result = generateAdapterConfigs(tmpDir, tools); + assert.equal(result.skipped.length, 1); + assert.equal(result.skipped[0].reason, 'adapter template not found'); + }); + + it('ADAPTER_MAP covers all expected tools', () => { + const expectedTools = ['cursor', 'claude-code', 'codex', 'antigravity', 'amazon-q', 'kiro']; + for (const tool of expectedTools) { + assert.ok(ADAPTER_MAP[tool], `${tool} should have adapter mapping`); + assert.ok(ADAPTER_MAP[tool].adapter, `${tool} should have adapter file`); + assert.ok(ADAPTER_MAP[tool].target, `${tool} should have target path`); + } + }); +}); diff --git a/packages/rules/__tests__/init/team.test.js b/packages/rules/__tests__/init/team.test.js new file mode 100644 index 00000000..af1330c3 --- /dev/null +++ b/packages/rules/__tests__/init/team.test.js @@ -0,0 +1,83 @@ +const { describe, it, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); + +const { runTeam } = require('../../lib/init/team'); + +describe('runTeam', () => { + let tmpDir; + let originalLog; + let logs; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cb-team-')); + // Capture console.log output + logs = []; + originalLog = console.log; + console.log = (...args) => logs.push(args.join(' ')); + }); + + afterEach(() => { + console.log = originalLog; + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('returns zero counts when no tools detected', async () => { + const result = await runTeam(tmpDir); + assert.equal(result.detected, 0); + assert.equal(result.generated, 0); + assert.ok(logs.some(l => l.includes('No AI tools detected'))); + }); + + it('detects tools and generates configs', async () => { + // Create .cursor dir and .ai-rules/adapters/cursor.md + fs.mkdirSync(path.join(tmpDir, '.cursor')); + const adaptersDir = path.join(tmpDir, '.ai-rules', 'adapters'); + fs.mkdirSync(adaptersDir, { recursive: true }); + fs.writeFileSync(path.join(adaptersDir, 'cursor.md'), '# Cursor rules'); + + const result = await runTeam(tmpDir); + assert.equal(result.detected, 1); + assert.equal(result.generated, 1); + assert.ok(logs.some(l => l.includes('cursor'))); + assert.ok(logs.some(l => l.includes('created'))); + }); + + it('dry-run does not write files', async () => { + fs.mkdirSync(path.join(tmpDir, '.cursor')); + const adaptersDir = path.join(tmpDir, '.ai-rules', 'adapters'); + fs.mkdirSync(adaptersDir, { recursive: true }); + fs.writeFileSync(path.join(adaptersDir, 'cursor.md'), '# Cursor rules'); + + const result = await runTeam(tmpDir, { dryRun: true }); + assert.equal(result.generated, 1); + assert.ok(logs.some(l => l.includes('would create'))); + // File should NOT exist + assert.ok(!fs.existsSync(path.join(tmpDir, '.cursor', 'rules', 'codingbuddy.mdc'))); + }); + + it('shows overwrite warning for tools with existing config', async () => { + fs.mkdirSync(path.join(tmpDir, '.cursor', 'rules'), { recursive: true }); + const adaptersDir = path.join(tmpDir, '.ai-rules', 'adapters'); + fs.mkdirSync(adaptersDir, { recursive: true }); + fs.writeFileSync(path.join(adaptersDir, 'cursor.md'), '# Cursor rules'); + + await runTeam(tmpDir); + assert.ok(logs.some(l => l.includes('will overwrite'))); + }); + + it('handles multiple tools', async () => { + fs.mkdirSync(path.join(tmpDir, '.cursor')); + fs.mkdirSync(path.join(tmpDir, '.claude')); + const adaptersDir = path.join(tmpDir, '.ai-rules', 'adapters'); + fs.mkdirSync(adaptersDir, { recursive: true }); + fs.writeFileSync(path.join(adaptersDir, 'cursor.md'), '# Cursor'); + fs.writeFileSync(path.join(adaptersDir, 'claude-code.md'), '# Claude'); + + const result = await runTeam(tmpDir); + assert.equal(result.detected, 2); + assert.equal(result.generated, 2); + }); +}); diff --git a/packages/rules/bin/cli.js b/packages/rules/bin/cli.js index 31560b9e..e867a352 100755 --- a/packages/rules/bin/cli.js +++ b/packages/rules/bin/cli.js @@ -16,13 +16,16 @@ Usage: codingbuddy [options] Commands: - init Initialize .ai-rules in the current project - validate Validate .ai-rules structure (agents JSON, rules markdown) - list-agents List available specialist agents + init Initialize .ai-rules in the current project + init --team Detect AI tools and generate adapter configs for all + validate Validate .ai-rules structure (agents JSON, rules markdown) + list-agents List available specialist agents Options: - --help, -h Show this help message - --version, -v Show version + --help, -h Show this help message + --version, -v Show version + --dry-run Preview changes without writing (init --team) + --force Overwrite without backup (init --team) `.trim(), ); } @@ -130,12 +133,23 @@ function validate() { console.log('\nAll validations passed'); } -function init() { - const { run } = require('../lib/init'); - run().catch(err => { - console.error('Error:', err.message); - process.exit(1); - }); +function init(flags) { + if (flags.team) { + const { runTeam } = require('../lib/init/team'); + runTeam(process.cwd(), { + dryRun: flags.dryRun, + force: flags.force, + }).catch(err => { + console.error('Error:', err.message); + process.exit(1); + }); + } else { + const { run } = require('../lib/init'); + run().catch(err => { + console.error('Error:', err.message); + process.exit(1); + }); + } } // --- Main --- @@ -153,9 +167,15 @@ if (command === '--version' || command === '-v') { process.exit(0); } +const flags = { + team: args.includes('--team'), + dryRun: args.includes('--dry-run'), + force: args.includes('--force'), +}; + switch (command) { case 'init': - init(); + init(flags); break; case 'validate': validate(); diff --git a/packages/rules/lib/init/detect-tools.js b/packages/rules/lib/init/detect-tools.js new file mode 100644 index 00000000..b7f0215f --- /dev/null +++ b/packages/rules/lib/init/detect-tools.js @@ -0,0 +1,40 @@ +'use strict'; + +const fs = require('node:fs'); +const path = require('node:path'); + +/** + * Supported AI coding tool definitions. + * Each entry maps a tool name to its config directory and indicator file. + */ +const AI_TOOLS = [ + { name: 'cursor', configDir: '.cursor', indicator: 'rules' }, + { name: 'claude-code', configDir: '.claude', indicator: 'settings.json' }, + { name: 'codex', configDir: '.codex', indicator: 'instructions.md' }, + { name: 'antigravity', configDir: '.antigravity', indicator: 'instructions.md' }, + { name: 'amazon-q', configDir: '.q', indicator: 'settings.json' }, + { name: 'kiro', configDir: '.kiro', indicator: 'settings.json' }, +]; + +/** + * Detect installed AI coding tools by scanning for their config directories. + * @param {string} cwd - Directory to scan + * @returns {Array<{ name: string, configDir: string, indicator: string, exists: boolean, hasConfig: boolean }>} + */ +function detectTools(cwd) { + return AI_TOOLS.map(tool => { + const dirPath = path.join(cwd, tool.configDir); + const exists = fs.existsSync(dirPath); + const hasConfig = + exists && fs.existsSync(path.join(dirPath, tool.indicator)); + return { + name: tool.name, + configDir: tool.configDir, + indicator: tool.indicator, + exists, + hasConfig, + }; + }); +} + +module.exports = { detectTools, AI_TOOLS }; diff --git a/packages/rules/lib/init/generate-adapter.js b/packages/rules/lib/init/generate-adapter.js new file mode 100644 index 00000000..5bff9349 --- /dev/null +++ b/packages/rules/lib/init/generate-adapter.js @@ -0,0 +1,76 @@ +'use strict'; + +const fs = require('node:fs'); +const path = require('node:path'); + +/** + * Maps tool names to their adapter template file and output target path. + */ +const ADAPTER_MAP = { + cursor: { adapter: 'cursor.md', target: '.cursor/rules/codingbuddy.mdc' }, + 'claude-code': { adapter: 'claude-code.md', target: '.claude/CLAUDE.md' }, + codex: { adapter: 'codex.md', target: '.codex/instructions.md' }, + antigravity: { adapter: 'antigravity.md', target: '.antigravity/instructions.md' }, + 'amazon-q': { adapter: 'q.md', target: '.q/rules/codingbuddy.md' }, + kiro: { adapter: 'kiro.md', target: '.kiro/rules/codingbuddy.md' }, +}; + +/** + * Generate adapter-specific configs for detected AI tools from shared .ai-rules. + * @param {string} cwd - Project root directory + * @param {Array<{ name: string }>} detectedTools - Tools to generate configs for + * @param {{ dryRun?: boolean, force?: boolean }} [options] + * @returns {{ generated: Array, backedUp: Array, skipped: Array }} + */ +function generateAdapterConfigs(cwd, detectedTools, options) { + const { dryRun = false, force = false } = options || {}; + const result = { generated: [], backedUp: [], skipped: [] }; + const adaptersDir = path.join(cwd, '.ai-rules', 'adapters'); + + for (const tool of detectedTools) { + if (!Object.hasOwn(ADAPTER_MAP, tool.name)) { + result.skipped.push({ tool: tool.name, reason: 'no adapter mapping' }); + continue; + } + + const mapping = ADAPTER_MAP[tool.name]; + const adapterPath = path.join(adaptersDir, mapping.adapter); + if (!fs.existsSync(adapterPath)) { + result.skipped.push({ tool: tool.name, reason: 'adapter template not found' }); + continue; + } + + const targetPath = path.join(cwd, mapping.target); + + if (dryRun) { + result.generated.push({ tool: tool.name, path: mapping.target, action: 'would-create' }); + continue; + } + + // Read adapter template (only when not dry-run) + const content = fs.readFileSync(adapterPath, 'utf-8'); + + // Backup existing config unless --force; skip symlinks for safety + if (fs.existsSync(targetPath) && !force) { + const stat = fs.lstatSync(targetPath); + if (!stat.isSymbolicLink()) { + const backupDir = path.join(cwd, '.codingbuddy-backup'); + fs.mkdirSync(backupDir, { recursive: true }); + const timestamp = Date.now(); + const backupName = path.basename(mapping.target) + '.' + timestamp + '.bak'; + const backupPath = path.join(backupDir, tool.name + '-' + backupName); + fs.copyFileSync(targetPath, backupPath); + result.backedUp.push({ tool: tool.name, from: mapping.target, to: backupPath }); + } + } + + // Write adapter config + fs.mkdirSync(path.dirname(targetPath), { recursive: true }); + fs.writeFileSync(targetPath, content, 'utf-8'); + result.generated.push({ tool: tool.name, path: mapping.target, action: 'created' }); + } + + return result; +} + +module.exports = { generateAdapterConfigs, ADAPTER_MAP }; diff --git a/packages/rules/lib/init/team.js b/packages/rules/lib/init/team.js new file mode 100644 index 00000000..90f143e4 --- /dev/null +++ b/packages/rules/lib/init/team.js @@ -0,0 +1,76 @@ +'use strict'; + +const { detectTools } = require('./detect-tools'); +const { generateAdapterConfigs } = require('./generate-adapter'); + +/** + * Run the team bootstrap flow: detect AI tools + generate adapter configs. + * @param {string} [cwd=process.cwd()] + * @param {{ dryRun?: boolean, force?: boolean }} [options] + * @returns {Promise<{ detected: number, generated: number, backedUp: number, skipped: number }>} + */ +async function runTeam(cwd, options) { + const targetDir = cwd || process.cwd(); + const { dryRun = false, force = false } = options || {}; + + console.log('\n codingbuddy init --team\n'); + + // Step 1: Detect AI tools + console.log(' Scanning for AI coding tools...'); + const tools = detectTools(targetDir); + const detected = tools.filter(t => t.exists); + const notDetected = tools.filter(t => !t.exists); + + if (detected.length > 0) { + console.log(` Found ${detected.length} tool(s):`); + for (const tool of detected) { + const status = tool.hasConfig + ? '\u26a0 has existing config \u2014 will overwrite (backup auto-created)' + : '(no config)'; + console.log(` \u2713 ${tool.name} ${status}`); + } + } + + if (notDetected.length > 0) { + console.log(` Not found: ${notDetected.map(t => t.name).join(', ')}`); + } + + // Step 2: Generate adapter configs + if (detected.length === 0) { + console.log('\n No AI tools detected. Nothing to configure.\n'); + return { detected: 0, generated: 0, backedUp: 0, skipped: 0 }; + } + + if (dryRun) { + console.log('\n Dry run \u2014 previewing changes:'); + } else { + console.log('\n Generating adapter configs...'); + } + + const result = generateAdapterConfigs(targetDir, detected, { dryRun, force }); + + for (const item of result.generated) { + const verb = dryRun ? 'would create' : 'created'; + console.log(` ${verb}: ${item.path}`); + } + + for (const item of result.backedUp) { + console.log(` backed up: ${item.from}`); + } + + for (const item of result.skipped) { + console.log(` skipped: ${item.tool} (${item.reason})`); + } + + const verb = dryRun ? 'would be generated' : 'generated'; + console.log(`\n Done! ${result.generated.length} config(s) ${verb}.\n`); + + return { + detected: detected.length, + generated: result.generated.length, + backedUp: result.backedUp.length, + skipped: result.skipped.length, + }; +} + +module.exports = { runTeam };