From ffec2c5765da9db85c8307fb936ac1d21a181c5c Mon Sep 17 00:00:00 2001 From: JeremyDev87 Date: Fri, 10 Apr 2026 11:08:52 +0900 Subject: [PATCH] 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 --- .../__tests__/init/generate-adapter.test.js | 192 ++++++++++++++++++ packages/rules/lib/init/generate-adapter.js | 97 +++++++++ 2 files changed, 289 insertions(+) create mode 100644 packages/rules/__tests__/init/generate-adapter.test.js create mode 100644 packages/rules/lib/init/generate-adapter.js 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..351ac511 --- /dev/null +++ b/packages/rules/__tests__/init/generate-adapter.test.js @@ -0,0 +1,192 @@ +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-adapter-')); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('generates cursor adapter config', () => { + const result = generateAdapterConfigs(tmpDir, ['cursor']); + + assert.equal(result.generated.length, 1); + assert.equal(result.generated[0].tool, 'cursor'); + assert.equal(result.generated[0].action, 'created'); + + const outputPath = path.join(tmpDir, '.cursor', 'rules', 'codingbuddy.mdc'); + assert.ok(fs.existsSync(outputPath)); + + const content = fs.readFileSync(outputPath, 'utf-8'); + assert.ok(content.includes('Cursor')); + }); + + it('generates claude-code adapter config', () => { + const result = generateAdapterConfigs(tmpDir, ['claude-code']); + + assert.equal(result.generated.length, 1); + assert.equal(result.generated[0].tool, 'claude-code'); + assert.equal(result.generated[0].action, 'created'); + + const outputPath = path.join(tmpDir, '.claude', 'CLAUDE.md'); + assert.ok(fs.existsSync(outputPath)); + + const content = fs.readFileSync(outputPath, 'utf-8'); + assert.ok(content.includes('Claude')); + }); + + it('generates codex adapter config', () => { + const result = generateAdapterConfigs(tmpDir, ['codex']); + + assert.equal(result.generated.length, 1); + assert.equal(result.generated[0].tool, 'codex'); + assert.equal(result.generated[0].action, 'created'); + + const outputPath = path.join(tmpDir, '.codex', 'instructions.md'); + assert.ok(fs.existsSync(outputPath)); + + const content = fs.readFileSync(outputPath, 'utf-8'); + assert.ok(content.includes('Codex')); + }); + + it('generates antigravity adapter config', () => { + const result = generateAdapterConfigs(tmpDir, ['antigravity']); + + assert.equal(result.generated.length, 1); + assert.equal(result.generated[0].tool, 'antigravity'); + assert.equal(result.generated[0].action, 'created'); + + const outputPath = path.join(tmpDir, '.antigravity', 'instructions.md'); + assert.ok(fs.existsSync(outputPath)); + + const content = fs.readFileSync(outputPath, 'utf-8'); + assert.ok(content.includes('Antigravity')); + }); + + it('generates multiple adapter configs at once', () => { + const tools = ['cursor', 'claude-code', 'codex', 'antigravity']; + const result = generateAdapterConfigs(tmpDir, tools); + + assert.equal(result.generated.length, 4); + assert.equal(result.skipped.length, 0); + assert.equal(result.backedUp.length, 0); + + for (const entry of result.generated) { + assert.ok(fs.existsSync(entry.path)); + assert.equal(entry.action, 'created'); + } + }); + + it('skips unknown tools', () => { + const result = generateAdapterConfigs(tmpDir, ['unknown-tool']); + + assert.equal(result.generated.length, 0); + assert.equal(result.skipped.length, 1); + assert.equal(result.skipped[0].tool, 'unknown-tool'); + assert.equal(result.skipped[0].reason, 'unknown-tool'); + }); + + it('backs up existing configs before overwriting', () => { + const existingPath = path.join(tmpDir, '.codex', 'instructions.md'); + fs.mkdirSync(path.dirname(existingPath), { recursive: true }); + fs.writeFileSync(existingPath, 'existing content'); + + const result = generateAdapterConfigs(tmpDir, ['codex']); + + assert.equal(result.generated.length, 1); + assert.equal(result.generated[0].action, 'overwritten'); + assert.equal(result.backedUp.length, 1); + assert.equal(result.backedUp[0].tool, 'codex'); + + const backupPath = path.join(tmpDir, '.codingbuddy-backup', '.codex', 'instructions.md'); + assert.ok(fs.existsSync(backupPath)); + assert.equal(fs.readFileSync(backupPath, 'utf-8'), 'existing content'); + + // Original file was overwritten with new content + const newContent = fs.readFileSync(existingPath, 'utf-8'); + assert.notEqual(newContent, 'existing content'); + }); + + it('skips backup in force mode', () => { + const existingPath = path.join(tmpDir, '.codex', 'instructions.md'); + fs.mkdirSync(path.dirname(existingPath), { recursive: true }); + fs.writeFileSync(existingPath, 'existing content'); + + const result = generateAdapterConfigs(tmpDir, ['codex'], { force: true }); + + assert.equal(result.generated.length, 1); + assert.equal(result.generated[0].action, 'overwritten'); + assert.equal(result.backedUp.length, 0); + + const backupDir = path.join(tmpDir, '.codingbuddy-backup'); + assert.ok(!fs.existsSync(backupDir)); + }); + + it('dry-run mode returns what would be generated without writing', () => { + const result = generateAdapterConfigs(tmpDir, ['cursor', 'codex'], { dryRun: true }); + + assert.equal(result.generated.length, 2); + assert.equal(result.generated[0].action, 'create'); + assert.equal(result.generated[1].action, 'create'); + + assert.ok(!fs.existsSync(path.join(tmpDir, '.cursor', 'rules', 'codingbuddy.mdc'))); + assert.ok(!fs.existsSync(path.join(tmpDir, '.codex', 'instructions.md'))); + }); + + it('dry-run mode detects existing files as overwrite', () => { + const existingPath = path.join(tmpDir, '.codex', 'instructions.md'); + fs.mkdirSync(path.dirname(existingPath), { recursive: true }); + fs.writeFileSync(existingPath, 'existing content'); + + const result = generateAdapterConfigs(tmpDir, ['codex'], { dryRun: true }); + + assert.equal(result.generated.length, 1); + assert.equal(result.generated[0].action, 'overwrite'); + + // Original file unchanged + assert.equal(fs.readFileSync(existingPath, 'utf-8'), 'existing content'); + }); + + it('creates output directories if they do not exist', () => { + generateAdapterConfigs(tmpDir, ['cursor']); + + const rulesDir = path.join(tmpDir, '.cursor', 'rules'); + assert.ok(fs.existsSync(rulesDir)); + assert.ok(fs.statSync(rulesDir).isDirectory()); + }); + + it('returns empty results for empty detectedTools', () => { + const result = generateAdapterConfigs(tmpDir, []); + + assert.equal(result.generated.length, 0); + assert.equal(result.backedUp.length, 0); + assert.equal(result.skipped.length, 0); + }); + + it('defaults options to dryRun=false and force=false', () => { + const result = generateAdapterConfigs(tmpDir, ['codex']); + + assert.equal(result.generated.length, 1); + assert.equal(result.generated[0].action, 'created'); + assert.ok(fs.existsSync(path.join(tmpDir, '.codex', 'instructions.md'))); + }); + + it('ADAPTER_MAP contains all four supported tools', () => { + const tools = Object.keys(ADAPTER_MAP); + assert.ok(tools.includes('cursor')); + assert.ok(tools.includes('claude-code')); + assert.ok(tools.includes('codex')); + assert.ok(tools.includes('antigravity')); + assert.equal(tools.length, 4); + }); +}); diff --git a/packages/rules/lib/init/generate-adapter.js b/packages/rules/lib/init/generate-adapter.js new file mode 100644 index 00000000..2ce78654 --- /dev/null +++ b/packages/rules/lib/init/generate-adapter.js @@ -0,0 +1,97 @@ +'use strict'; + +const fs = require('node:fs'); +const path = require('node:path'); + +/** + * Mapping from tool name to adapter source file and output target path. + */ +const ADAPTER_MAP = { + cursor: { + adapterFile: 'cursor.md', + outputPath: path.join('.cursor', 'rules', 'codingbuddy.mdc'), + }, + 'claude-code': { + adapterFile: 'claude-code.md', + outputPath: path.join('.claude', 'CLAUDE.md'), + }, + codex: { + adapterFile: 'codex.md', + outputPath: path.join('.codex', 'instructions.md'), + }, + antigravity: { + adapterFile: 'antigravity.md', + outputPath: path.join('.antigravity', 'instructions.md'), + }, +}; + +/** + * Resolve the path to .ai-rules/adapters/ within this package. + * @returns {string} + */ +function getAdaptersDir() { + return path.resolve(__dirname, '../../.ai-rules/adapters'); +} + +/** + * Generate adapter-specific config files for each detected AI tool. + * @param {string} cwd - Target directory + * @param {string[]} detectedTools - Array of tool names (e.g. ['cursor', 'claude-code']) + * @param {{ dryRun?: boolean, force?: boolean }} options + * @returns {{ generated: Array<{tool: string, path: string, action: string}>, backedUp: Array<{tool: string, from: string, to: string}>, skipped: Array<{tool: string, reason: string}> }} + */ +function generateAdapterConfigs(cwd, detectedTools, options = {}) { + const { dryRun = false, force = false } = options; + const adaptersDir = getAdaptersDir(); + + const result = { + generated: [], + backedUp: [], + skipped: [], + }; + + for (const tool of detectedTools) { + const mapping = ADAPTER_MAP[tool]; + if (!mapping) { + result.skipped.push({ tool, reason: 'unknown-tool' }); + continue; + } + + const adapterPath = path.join(adaptersDir, mapping.adapterFile); + if (!fs.existsSync(adapterPath)) { + result.skipped.push({ tool, reason: 'adapter-not-found' }); + continue; + } + + const content = fs.readFileSync(adapterPath, 'utf-8'); + const outputPath = path.join(cwd, mapping.outputPath); + + if (dryRun) { + const action = fs.existsSync(outputPath) ? 'overwrite' : 'create'; + result.generated.push({ tool, path: outputPath, action }); + continue; + } + + const exists = fs.existsSync(outputPath); + + if (exists && !force) { + const backupDir = path.join(cwd, '.codingbuddy-backup'); + const backupPath = path.join(backupDir, mapping.outputPath); + fs.mkdirSync(path.dirname(backupPath), { recursive: true }); + fs.copyFileSync(outputPath, backupPath); + result.backedUp.push({ tool, from: outputPath, to: backupPath }); + } + + fs.mkdirSync(path.dirname(outputPath), { recursive: true }); + fs.writeFileSync(outputPath, content, 'utf-8'); + result.generated.push({ + tool, + path: outputPath, + action: exists ? 'overwritten' : 'created', + }); + } + + return result; +} + +module.exports = { generateAdapterConfigs, ADAPTER_MAP };