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
148 changes: 148 additions & 0 deletions packages/rules/__tests__/init/detect-tools.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
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-test-tools-'));
});

afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});

it('returns all 6 tools for empty directory', () => {
const results = detectTools(tmpDir);
assert.equal(results.length, 6);
for (const tool of results) {
assert.equal(tool.exists, false);
assert.equal(tool.hasRules, false);
assert.deepEqual(tool.configFiles, []);
}
});

it('detects Cursor from .cursor/ directory', () => {
fs.mkdirSync(path.join(tmpDir, '.cursor'));
fs.writeFileSync(path.join(tmpDir, '.cursor', 'settings.json'), '{}');

const results = detectTools(tmpDir);
const cursor = results.find(t => t.name === 'Cursor');
assert.equal(cursor.exists, true);
assert.ok(cursor.configFiles.includes('settings.json'));
assert.equal(cursor.indicator, 'AI rules config');
});

it('detects Cursor from .cursorrules file', () => {
fs.writeFileSync(path.join(tmpDir, '.cursorrules'), 'rules content');

const results = detectTools(tmpDir);
const cursor = results.find(t => t.name === 'Cursor');
assert.equal(cursor.exists, true);
assert.equal(cursor.hasRules, true);
assert.ok(cursor.configFiles.includes('.cursorrules'));
});

it('detects Claude Code from .claude/ directory', () => {
fs.mkdirSync(path.join(tmpDir, '.claude'));
fs.writeFileSync(path.join(tmpDir, '.claude', 'settings.json'), '{}');

const results = detectTools(tmpDir);
const claude = results.find(t => t.name === 'Claude Code');
assert.equal(claude.exists, true);
assert.equal(claude.hasRules, false);
assert.ok(claude.configFiles.includes('settings.json'));
});

it('detects Claude Code hasRules when rules/ exists', () => {
fs.mkdirSync(path.join(tmpDir, '.claude', 'rules'), { recursive: true });

const results = detectTools(tmpDir);
const claude = results.find(t => t.name === 'Claude Code');
assert.equal(claude.exists, true);
assert.equal(claude.hasRules, true);
});

it('detects Codex from .codex/ directory', () => {
fs.mkdirSync(path.join(tmpDir, '.codex'));
fs.writeFileSync(path.join(tmpDir, '.codex', 'instructions.md'), '# Instructions');

const results = detectTools(tmpDir);
const codex = results.find(t => t.name === 'Codex');
assert.equal(codex.exists, true);
assert.equal(codex.hasRules, true);
});

it('detects Antigravity from .antigravity/ directory', () => {
fs.mkdirSync(path.join(tmpDir, '.antigravity'));
fs.writeFileSync(path.join(tmpDir, '.antigravity', 'config.json'), '{}');

const results = detectTools(tmpDir);
const ag = results.find(t => t.name === 'Antigravity');
assert.equal(ag.exists, true);
assert.ok(ag.configFiles.includes('config.json'));
});

it('detects Amazon Q from .q/ directory', () => {
fs.mkdirSync(path.join(tmpDir, '.q'));

const results = detectTools(tmpDir);
const q = results.find(t => t.name === 'Amazon Q');
assert.equal(q.exists, true);
assert.equal(q.hasRules, false);
});

it('detects Kiro from .kiro/ directory', () => {
fs.mkdirSync(path.join(tmpDir, '.kiro'));
fs.writeFileSync(path.join(tmpDir, '.kiro', 'config.json'), '{}');

const results = detectTools(tmpDir);
const kiro = results.find(t => t.name === 'Kiro');
assert.equal(kiro.exists, true);
assert.ok(kiro.configFiles.includes('config.json'));
});

it('detects multiple tools simultaneously', () => {
fs.mkdirSync(path.join(tmpDir, '.cursor'));
fs.mkdirSync(path.join(tmpDir, '.claude'));
fs.mkdirSync(path.join(tmpDir, '.codex'));

const results = detectTools(tmpDir);
const detected = results.filter(t => t.exists);
assert.equal(detected.length, 3);

const names = detected.map(t => t.name);
assert.ok(names.includes('Cursor'));
assert.ok(names.includes('Claude Code'));
assert.ok(names.includes('Codex'));
});

it('returns correct structure for each tool', () => {
const results = detectTools(tmpDir);
for (const tool of results) {
assert.ok(typeof tool.name === 'string');
assert.ok(typeof tool.configDir === 'string');
assert.ok(typeof tool.exists === 'boolean');
assert.ok(typeof tool.hasRules === 'boolean');
assert.ok(Array.isArray(tool.configFiles));
assert.ok(typeof tool.indicator === 'string');
}
});

it('lists config files in detected tool directory', () => {
fs.mkdirSync(path.join(tmpDir, '.claude'));
fs.writeFileSync(path.join(tmpDir, '.claude', 'settings.json'), '{}');
fs.writeFileSync(path.join(tmpDir, '.claude', 'CLAUDE.md'), '# Claude');

const results = detectTools(tmpDir);
const claude = results.find(t => t.name === 'Claude Code');
assert.ok(claude.configFiles.includes('settings.json'));
assert.ok(claude.configFiles.includes('CLAUDE.md'));
assert.equal(claude.configFiles.length, 2);
});
});
116 changes: 116 additions & 0 deletions packages/rules/lib/init/detect-tools.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
'use strict';

const fs = require('node:fs');
const path = require('node:path');

const AI_TOOLS = [
{
name: 'Cursor',
configDir: '.cursor',
indicator: 'AI rules config',
rulesPatterns: ['rules/', 'rules.md', 'rules.json'],
altPaths: ['.cursorrules'],
},
{
name: 'Claude Code',
configDir: '.claude',
indicator: 'Plugin/settings',
rulesPatterns: ['rules/'],
altPaths: [],
},
{
name: 'Codex',
configDir: '.codex',
indicator: 'Codex config',
rulesPatterns: ['instructions.md'],
altPaths: [],
},
{
name: 'Antigravity',
configDir: '.antigravity',
indicator: 'Gemini config',
rulesPatterns: ['rules/', 'config.json'],
altPaths: [],
},
{
name: 'Amazon Q',
configDir: '.q',
indicator: 'Q config',
rulesPatterns: ['rules/', 'settings.json'],
altPaths: [],
},
{
name: 'Kiro',
configDir: '.kiro',
indicator: 'Kiro config',
rulesPatterns: ['rules/', 'config.json'],
altPaths: [],
},
];

/**
* Detect installed AI coding tools in the given directory.
* @param {string} cwd - Directory to scan
* @returns {Array<{ name: string, configDir: string, exists: boolean, hasRules: boolean, configFiles: string[], indicator: string }>}
*/
function detectTools(cwd) {
return AI_TOOLS.map(tool => {
const dirPath = path.join(cwd, tool.configDir);
const dirExists = fs.existsSync(dirPath);

// Check alternative paths (e.g. .cursorrules)
const altExists = tool.altPaths.some(alt => fs.existsSync(path.join(cwd, alt)));
const exists = dirExists || altExists;

let configFiles = [];
let hasRules = false;

if (dirExists) {
configFiles = listConfigFiles(dirPath);
hasRules = tool.rulesPatterns.some(pattern => {
const fullPath = path.join(dirPath, pattern);
return fs.existsSync(fullPath);
});
}

if (altExists) {
for (const alt of tool.altPaths) {
if (fs.existsSync(path.join(cwd, alt))) {
configFiles.push(alt);
hasRules = true;
}
}
}

return {
name: tool.name,
configDir: tool.configDir,
exists,
hasRules,
configFiles,
indicator: tool.indicator,
};
});
}

/**
* List files in a config directory (non-recursive, top-level only).
* @param {string} dirPath - Directory to list
* @returns {string[]}
*/
function listConfigFiles(dirPath) {
try {
return fs.readdirSync(dirPath).filter(entry => {
const fullPath = path.join(dirPath, entry);
try {
return fs.statSync(fullPath).isFile();
} catch {
return false;
}
});
} catch {
return [];
}
}

module.exports = { detectTools, AI_TOOLS };
Loading