Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
192 changes: 192 additions & 0 deletions packages/rules/__tests__/init/generate-adapter.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
});
97 changes: 97 additions & 0 deletions packages/rules/lib/init/generate-adapter.js
Original file line number Diff line number Diff line change
@@ -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 };
Loading