Skip to content

Commit 45c567d

Browse files
committed
feat(cli): add AI tool detector for installed coding assistants
- Add detectTools(cwd) to scan for 6 AI tools: Cursor, Claude Code, Codex, Antigravity, Amazon Q, Kiro - Report existence, config files, and rules presence for each tool - Support alternative paths (e.g. .cursorrules) - Add 12 unit tests covering all detection scenarios Closes #1449
1 parent e4b33f4 commit 45c567d

2 files changed

Lines changed: 264 additions & 0 deletions

File tree

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
const { describe, it, beforeEach, afterEach } = require('node:test');
2+
const assert = require('node:assert/strict');
3+
const fs = require('node:fs');
4+
const path = require('node:path');
5+
const os = require('node:os');
6+
7+
const { detectTools, AI_TOOLS } = require('../../lib/init/detect-tools');
8+
9+
describe('detectTools', () => {
10+
let tmpDir;
11+
12+
beforeEach(() => {
13+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cb-test-tools-'));
14+
});
15+
16+
afterEach(() => {
17+
fs.rmSync(tmpDir, { recursive: true, force: true });
18+
});
19+
20+
it('returns all 6 tools for empty directory', () => {
21+
const results = detectTools(tmpDir);
22+
assert.equal(results.length, 6);
23+
for (const tool of results) {
24+
assert.equal(tool.exists, false);
25+
assert.equal(tool.hasRules, false);
26+
assert.deepEqual(tool.configFiles, []);
27+
}
28+
});
29+
30+
it('detects Cursor from .cursor/ directory', () => {
31+
fs.mkdirSync(path.join(tmpDir, '.cursor'));
32+
fs.writeFileSync(path.join(tmpDir, '.cursor', 'settings.json'), '{}');
33+
34+
const results = detectTools(tmpDir);
35+
const cursor = results.find(t => t.name === 'Cursor');
36+
assert.equal(cursor.exists, true);
37+
assert.ok(cursor.configFiles.includes('settings.json'));
38+
assert.equal(cursor.indicator, 'AI rules config');
39+
});
40+
41+
it('detects Cursor from .cursorrules file', () => {
42+
fs.writeFileSync(path.join(tmpDir, '.cursorrules'), 'rules content');
43+
44+
const results = detectTools(tmpDir);
45+
const cursor = results.find(t => t.name === 'Cursor');
46+
assert.equal(cursor.exists, true);
47+
assert.equal(cursor.hasRules, true);
48+
assert.ok(cursor.configFiles.includes('.cursorrules'));
49+
});
50+
51+
it('detects Claude Code from .claude/ directory', () => {
52+
fs.mkdirSync(path.join(tmpDir, '.claude'));
53+
fs.writeFileSync(path.join(tmpDir, '.claude', 'settings.json'), '{}');
54+
55+
const results = detectTools(tmpDir);
56+
const claude = results.find(t => t.name === 'Claude Code');
57+
assert.equal(claude.exists, true);
58+
assert.equal(claude.hasRules, false);
59+
assert.ok(claude.configFiles.includes('settings.json'));
60+
});
61+
62+
it('detects Claude Code hasRules when rules/ exists', () => {
63+
fs.mkdirSync(path.join(tmpDir, '.claude', 'rules'), { recursive: true });
64+
65+
const results = detectTools(tmpDir);
66+
const claude = results.find(t => t.name === 'Claude Code');
67+
assert.equal(claude.exists, true);
68+
assert.equal(claude.hasRules, true);
69+
});
70+
71+
it('detects Codex from .codex/ directory', () => {
72+
fs.mkdirSync(path.join(tmpDir, '.codex'));
73+
fs.writeFileSync(path.join(tmpDir, '.codex', 'instructions.md'), '# Instructions');
74+
75+
const results = detectTools(tmpDir);
76+
const codex = results.find(t => t.name === 'Codex');
77+
assert.equal(codex.exists, true);
78+
assert.equal(codex.hasRules, true);
79+
});
80+
81+
it('detects Antigravity from .antigravity/ directory', () => {
82+
fs.mkdirSync(path.join(tmpDir, '.antigravity'));
83+
fs.writeFileSync(path.join(tmpDir, '.antigravity', 'config.json'), '{}');
84+
85+
const results = detectTools(tmpDir);
86+
const ag = results.find(t => t.name === 'Antigravity');
87+
assert.equal(ag.exists, true);
88+
assert.ok(ag.configFiles.includes('config.json'));
89+
});
90+
91+
it('detects Amazon Q from .q/ directory', () => {
92+
fs.mkdirSync(path.join(tmpDir, '.q'));
93+
94+
const results = detectTools(tmpDir);
95+
const q = results.find(t => t.name === 'Amazon Q');
96+
assert.equal(q.exists, true);
97+
assert.equal(q.hasRules, false);
98+
});
99+
100+
it('detects Kiro from .kiro/ directory', () => {
101+
fs.mkdirSync(path.join(tmpDir, '.kiro'));
102+
fs.writeFileSync(path.join(tmpDir, '.kiro', 'config.json'), '{}');
103+
104+
const results = detectTools(tmpDir);
105+
const kiro = results.find(t => t.name === 'Kiro');
106+
assert.equal(kiro.exists, true);
107+
assert.ok(kiro.configFiles.includes('config.json'));
108+
});
109+
110+
it('detects multiple tools simultaneously', () => {
111+
fs.mkdirSync(path.join(tmpDir, '.cursor'));
112+
fs.mkdirSync(path.join(tmpDir, '.claude'));
113+
fs.mkdirSync(path.join(tmpDir, '.codex'));
114+
115+
const results = detectTools(tmpDir);
116+
const detected = results.filter(t => t.exists);
117+
assert.equal(detected.length, 3);
118+
119+
const names = detected.map(t => t.name);
120+
assert.ok(names.includes('Cursor'));
121+
assert.ok(names.includes('Claude Code'));
122+
assert.ok(names.includes('Codex'));
123+
});
124+
125+
it('returns correct structure for each tool', () => {
126+
const results = detectTools(tmpDir);
127+
for (const tool of results) {
128+
assert.ok(typeof tool.name === 'string');
129+
assert.ok(typeof tool.configDir === 'string');
130+
assert.ok(typeof tool.exists === 'boolean');
131+
assert.ok(typeof tool.hasRules === 'boolean');
132+
assert.ok(Array.isArray(tool.configFiles));
133+
assert.ok(typeof tool.indicator === 'string');
134+
}
135+
});
136+
137+
it('lists config files in detected tool directory', () => {
138+
fs.mkdirSync(path.join(tmpDir, '.claude'));
139+
fs.writeFileSync(path.join(tmpDir, '.claude', 'settings.json'), '{}');
140+
fs.writeFileSync(path.join(tmpDir, '.claude', 'CLAUDE.md'), '# Claude');
141+
142+
const results = detectTools(tmpDir);
143+
const claude = results.find(t => t.name === 'Claude Code');
144+
assert.ok(claude.configFiles.includes('settings.json'));
145+
assert.ok(claude.configFiles.includes('CLAUDE.md'));
146+
assert.equal(claude.configFiles.length, 2);
147+
});
148+
});
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
'use strict';
2+
3+
const fs = require('node:fs');
4+
const path = require('node:path');
5+
6+
const AI_TOOLS = [
7+
{
8+
name: 'Cursor',
9+
configDir: '.cursor',
10+
indicator: 'AI rules config',
11+
rulesPatterns: ['rules/', 'rules.md', 'rules.json'],
12+
altPaths: ['.cursorrules'],
13+
},
14+
{
15+
name: 'Claude Code',
16+
configDir: '.claude',
17+
indicator: 'Plugin/settings',
18+
rulesPatterns: ['rules/'],
19+
altPaths: [],
20+
},
21+
{
22+
name: 'Codex',
23+
configDir: '.codex',
24+
indicator: 'Codex config',
25+
rulesPatterns: ['instructions.md'],
26+
altPaths: [],
27+
},
28+
{
29+
name: 'Antigravity',
30+
configDir: '.antigravity',
31+
indicator: 'Gemini config',
32+
rulesPatterns: ['rules/', 'config.json'],
33+
altPaths: [],
34+
},
35+
{
36+
name: 'Amazon Q',
37+
configDir: '.q',
38+
indicator: 'Q config',
39+
rulesPatterns: ['rules/', 'settings.json'],
40+
altPaths: [],
41+
},
42+
{
43+
name: 'Kiro',
44+
configDir: '.kiro',
45+
indicator: 'Kiro config',
46+
rulesPatterns: ['rules/', 'config.json'],
47+
altPaths: [],
48+
},
49+
];
50+
51+
/**
52+
* Detect installed AI coding tools in the given directory.
53+
* @param {string} cwd - Directory to scan
54+
* @returns {Array<{ name: string, configDir: string, exists: boolean, hasRules: boolean, configFiles: string[], indicator: string }>}
55+
*/
56+
function detectTools(cwd) {
57+
return AI_TOOLS.map(tool => {
58+
const dirPath = path.join(cwd, tool.configDir);
59+
const dirExists = fs.existsSync(dirPath);
60+
61+
// Check alternative paths (e.g. .cursorrules)
62+
const altExists = tool.altPaths.some(alt => fs.existsSync(path.join(cwd, alt)));
63+
const exists = dirExists || altExists;
64+
65+
let configFiles = [];
66+
let hasRules = false;
67+
68+
if (dirExists) {
69+
configFiles = listConfigFiles(dirPath);
70+
hasRules = tool.rulesPatterns.some(pattern => {
71+
const fullPath = path.join(dirPath, pattern);
72+
return fs.existsSync(fullPath);
73+
});
74+
}
75+
76+
if (altExists) {
77+
for (const alt of tool.altPaths) {
78+
if (fs.existsSync(path.join(cwd, alt))) {
79+
configFiles.push(alt);
80+
hasRules = true;
81+
}
82+
}
83+
}
84+
85+
return {
86+
name: tool.name,
87+
configDir: tool.configDir,
88+
exists,
89+
hasRules,
90+
configFiles,
91+
indicator: tool.indicator,
92+
};
93+
});
94+
}
95+
96+
/**
97+
* List files in a config directory (non-recursive, top-level only).
98+
* @param {string} dirPath - Directory to list
99+
* @returns {string[]}
100+
*/
101+
function listConfigFiles(dirPath) {
102+
try {
103+
return fs.readdirSync(dirPath).filter(entry => {
104+
const fullPath = path.join(dirPath, entry);
105+
try {
106+
return fs.statSync(fullPath).isFile();
107+
} catch {
108+
return false;
109+
}
110+
});
111+
} catch {
112+
return [];
113+
}
114+
}
115+
116+
module.exports = { detectTools, AI_TOOLS };

0 commit comments

Comments
 (0)