Skip to content
Merged
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -71,3 +71,6 @@ TASK.md
__pycache__/
*.pyc
.pytest_cache/

# codingbuddy init --team backups
.codingbuddy-backup/
82 changes: 82 additions & 0 deletions packages/rules/__tests__/init/detect-tools.test.js
Original file line number Diff line number Diff line change
@@ -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');
}
});
});
107 changes: 107 additions & 0 deletions packages/rules/__tests__/init/generate-adapter.test.js
Original file line number Diff line number Diff line change
@@ -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`);
}
});
});
83 changes: 83 additions & 0 deletions packages/rules/__tests__/init/team.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
});
44 changes: 32 additions & 12 deletions packages/rules/bin/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,16 @@ Usage:
codingbuddy <command> [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(),
);
}
Expand Down Expand Up @@ -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 ---
Expand All @@ -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();
Expand Down
40 changes: 40 additions & 0 deletions packages/rules/lib/init/detect-tools.js
Original file line number Diff line number Diff line change
@@ -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 };
Loading
Loading