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..21ed1ef1 --- /dev/null +++ b/packages/rules/__tests__/init/detect-tools.test.js @@ -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); + }); +}); diff --git a/packages/rules/lib/init/detect-tools.js b/packages/rules/lib/init/detect-tools.js new file mode 100644 index 00000000..fbf47adb --- /dev/null +++ b/packages/rules/lib/init/detect-tools.js @@ -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 };