Skip to content

Commit 248e01f

Browse files
committed
feat(init): Implement AI tool auto-detection during initialization for improved user experience
1 parent 67e7818 commit 248e01f

File tree

3 files changed

+388
-15
lines changed

3 files changed

+388
-15
lines changed

packages/cli/src/commands/init.ts

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
getProjectName,
1313
createAgentToolSymlinks,
1414
AI_TOOL_CONFIGS,
15+
getDefaultAIToolSelection,
1516
type AIToolKey,
1617
} from '../utils/template-helpers.js';
1718
import {
@@ -87,9 +88,9 @@ export async function initProject(skipPrompts = false, templateOption?: string,
8788
// Skip prompts if -y flag is used
8889
if (skipPrompts) {
8990
console.log(chalk.gray('Using defaults: quick start with standard template'));
90-
// Default to Claude Code + Copilot symlinks when using -y
91+
// Default to Copilot only (AGENTS.md) when using -y
9192
if (!agentToolsOption) {
92-
selectedAgentTools = ['claude', 'copilot'];
93+
selectedAgentTools = ['copilot'];
9394
}
9495
console.log('');
9596
} else if (!templateOption) {
@@ -219,10 +220,29 @@ export async function initProject(skipPrompts = false, templateOption?: string,
219220
// AI tool selection (skip only if -y flag is used or --agent-tools was provided)
220221
// Quick start should still ask this question - it's important for AI tool UX
221222
if (!skipPrompts && !agentToolsOption) {
223+
// Auto-detect installed AI tools for smart defaults
224+
const { defaults: detectedDefaults, detected: detectionResults } = await getDefaultAIToolSelection();
225+
const anyDetected = detectionResults.some(r => r.detected);
226+
227+
// Show detection info if any tools were found
228+
if (anyDetected) {
229+
console.log('');
230+
console.log(chalk.cyan('🔍 Detected AI tools:'));
231+
for (const result of detectionResults) {
232+
if (result.detected) {
233+
console.log(chalk.gray(` ${AI_TOOL_CONFIGS[result.tool].description}`));
234+
for (const reason of result.reasons) {
235+
console.log(chalk.gray(` └─ ${reason}`));
236+
}
237+
}
238+
}
239+
console.log('');
240+
}
241+
222242
const toolChoices = Object.entries(AI_TOOL_CONFIGS).map(([key, config]) => ({
223243
name: config.description,
224244
value: key as AIToolKey,
225-
checked: config.default,
245+
checked: detectedDefaults.includes(key as AIToolKey),
226246
}));
227247

228248
selectedAgentTools = await checkbox({

packages/cli/src/utils/template-helpers.ts

Lines changed: 230 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import * as fs from 'node:fs/promises';
22
import * as path from 'node:path';
3+
import { execSync } from 'node:child_process';
34
import chalk from 'chalk';
45

56
/**
@@ -11,55 +12,272 @@ export interface AIToolConfig {
1112
description: string; // Human-readable description for prompts
1213
default: boolean; // Whether to include by default in quick start
1314
usesSymlink: boolean; // Whether this tool uses a symlink (false for AGENTS.md itself)
15+
detection?: { // Optional auto-detection configuration
16+
commands?: string[]; // CLI commands to check (e.g., ['claude', 'claude-code'])
17+
configDirs?: string[]; // Config directories to check (e.g., ['.claude'])
18+
envVars?: string[]; // Environment variables to check (e.g., ['ANTHROPIC_API_KEY'])
19+
extensions?: string[]; // VS Code extension IDs to check
20+
};
1421
}
1522

16-
export type AIToolKey = 'claude' | 'gemini' | 'copilot' | 'cursor' | 'windsurf' | 'cline' | 'warp';
23+
export type AIToolKey = 'aider' | 'claude' | 'codex' | 'copilot' | 'cursor' | 'droid' | 'gemini' | 'opencode' | 'windsurf';
1724

1825
export const AI_TOOL_CONFIGS: Record<AIToolKey, AIToolConfig> = {
26+
aider: {
27+
file: 'AGENTS.md',
28+
description: 'Aider (uses AGENTS.md)',
29+
default: false,
30+
usesSymlink: false,
31+
detection: {
32+
commands: ['aider'],
33+
configDirs: ['.aider'],
34+
},
35+
},
1936
claude: {
2037
file: 'CLAUDE.md',
21-
description: 'Claude Code / Claude Desktop (CLAUDE.md)',
38+
description: 'Claude Code (CLAUDE.md)',
2239
default: true,
2340
usesSymlink: true,
41+
detection: {
42+
commands: ['claude'],
43+
configDirs: ['.claude'],
44+
envVars: ['ANTHROPIC_API_KEY'],
45+
},
2446
},
25-
gemini: {
26-
file: 'GEMINI.md',
27-
description: 'Gemini CLI (GEMINI.md)',
47+
codex: {
48+
file: 'AGENTS.md',
49+
description: 'Codex CLI by OpenAI (uses AGENTS.md)',
2850
default: false,
29-
usesSymlink: true,
51+
usesSymlink: false,
52+
detection: {
53+
commands: ['codex'],
54+
configDirs: ['.codex'],
55+
envVars: ['OPENAI_API_KEY'],
56+
},
3057
},
3158
copilot: {
3259
file: 'AGENTS.md',
3360
description: 'GitHub Copilot (AGENTS.md - default)',
3461
default: true,
3562
usesSymlink: false, // Primary file, no symlink needed
63+
detection: {
64+
commands: ['copilot'],
65+
envVars: ['GITHUB_TOKEN'],
66+
},
3667
},
3768
cursor: {
3869
file: 'AGENTS.md',
3970
description: 'Cursor (uses AGENTS.md)',
4071
default: false,
4172
usesSymlink: false,
73+
detection: {
74+
configDirs: ['.cursor', '.cursorules'],
75+
commands: ['cursor'],
76+
},
4277
},
43-
windsurf: {
78+
droid: {
4479
file: 'AGENTS.md',
45-
description: 'Windsurf (uses AGENTS.md)',
80+
description: 'Droid by Factory (uses AGENTS.md)',
4681
default: false,
4782
usesSymlink: false,
83+
detection: {
84+
commands: ['droid'],
85+
},
4886
},
49-
cline: {
87+
gemini: {
88+
file: 'GEMINI.md',
89+
description: 'Gemini CLI (GEMINI.md)',
90+
default: false,
91+
usesSymlink: true,
92+
detection: {
93+
commands: ['gemini'],
94+
configDirs: ['.gemini'],
95+
envVars: ['GOOGLE_API_KEY', 'GEMINI_API_KEY'],
96+
},
97+
},
98+
opencode: {
5099
file: 'AGENTS.md',
51-
description: 'Cline (uses AGENTS.md)',
100+
description: 'OpenCode (uses AGENTS.md)',
52101
default: false,
53102
usesSymlink: false,
103+
detection: {
104+
commands: ['opencode'],
105+
configDirs: ['.opencode'],
106+
},
54107
},
55-
warp: {
108+
windsurf: {
56109
file: 'AGENTS.md',
57-
description: 'Warp Terminal (uses AGENTS.md)',
110+
description: 'Windsurf (uses AGENTS.md)',
58111
default: false,
59112
usesSymlink: false,
113+
detection: {
114+
configDirs: ['.windsurf', '.windsurfrules'],
115+
commands: ['windsurf'],
116+
},
60117
},
61118
};
62119

120+
/**
121+
* Check if a command exists in PATH
122+
*/
123+
function commandExists(command: string): boolean {
124+
try {
125+
const which = process.platform === 'win32' ? 'where' : 'which';
126+
execSync(`${which} ${command}`, { stdio: 'ignore' });
127+
return true;
128+
} catch {
129+
return false;
130+
}
131+
}
132+
133+
/**
134+
* Check if a directory exists in the user's home directory
135+
*/
136+
async function configDirExists(dirName: string): Promise<boolean> {
137+
const homeDir = process.env.HOME || process.env.USERPROFILE || '';
138+
if (!homeDir) return false;
139+
140+
try {
141+
await fs.access(path.join(homeDir, dirName));
142+
return true;
143+
} catch {
144+
return false;
145+
}
146+
}
147+
148+
/**
149+
* Check if an environment variable is set
150+
*/
151+
function envVarExists(varName: string): boolean {
152+
return !!process.env[varName];
153+
}
154+
155+
/**
156+
* Check if a VS Code extension is installed
157+
* Note: This is a best-effort check - may not work in all environments
158+
*/
159+
async function extensionInstalled(extensionId: string): Promise<boolean> {
160+
const homeDir = process.env.HOME || process.env.USERPROFILE || '';
161+
if (!homeDir) return false;
162+
163+
// Check common VS Code extension directories
164+
const extensionDirs = [
165+
path.join(homeDir, '.vscode', 'extensions'),
166+
path.join(homeDir, '.vscode-server', 'extensions'),
167+
path.join(homeDir, '.cursor', 'extensions'),
168+
];
169+
170+
for (const extDir of extensionDirs) {
171+
try {
172+
const entries = await fs.readdir(extDir);
173+
// Extension folders are named like 'github.copilot-1.234.567'
174+
if (entries.some(e => e.toLowerCase().startsWith(extensionId.toLowerCase()))) {
175+
return true;
176+
}
177+
} catch {
178+
// Directory doesn't exist or not readable
179+
}
180+
}
181+
182+
return false;
183+
}
184+
185+
export interface DetectionResult {
186+
tool: AIToolKey;
187+
detected: boolean;
188+
reasons: string[];
189+
}
190+
191+
/**
192+
* Auto-detect installed AI tools
193+
* Returns detection results with reasons for each tool
194+
*/
195+
export async function detectInstalledAITools(): Promise<DetectionResult[]> {
196+
const results: DetectionResult[] = [];
197+
198+
for (const [toolKey, config] of Object.entries(AI_TOOL_CONFIGS)) {
199+
const reasons: string[] = [];
200+
const detection = config.detection;
201+
202+
if (!detection) {
203+
results.push({ tool: toolKey as AIToolKey, detected: false, reasons: [] });
204+
continue;
205+
}
206+
207+
// Check commands
208+
if (detection.commands) {
209+
for (const cmd of detection.commands) {
210+
if (commandExists(cmd)) {
211+
reasons.push(`'${cmd}' command found`);
212+
}
213+
}
214+
}
215+
216+
// Check config directories
217+
if (detection.configDirs) {
218+
for (const dir of detection.configDirs) {
219+
if (await configDirExists(dir)) {
220+
reasons.push(`~/${dir} directory found`);
221+
}
222+
}
223+
}
224+
225+
// Check environment variables
226+
if (detection.envVars) {
227+
for (const envVar of detection.envVars) {
228+
if (envVarExists(envVar)) {
229+
reasons.push(`${envVar} env var set`);
230+
}
231+
}
232+
}
233+
234+
// Check VS Code extensions
235+
if (detection.extensions) {
236+
for (const ext of detection.extensions) {
237+
if (await extensionInstalled(ext)) {
238+
reasons.push(`${ext} extension installed`);
239+
}
240+
}
241+
}
242+
243+
results.push({
244+
tool: toolKey as AIToolKey,
245+
detected: reasons.length > 0,
246+
reasons,
247+
});
248+
}
249+
250+
return results;
251+
}
252+
253+
/**
254+
* Get default selection for AI tools based on auto-detection
255+
* Falls back to copilot only (AGENTS.md) if nothing is detected
256+
*/
257+
export async function getDefaultAIToolSelection(): Promise<{ defaults: AIToolKey[]; detected: DetectionResult[] }> {
258+
const detectionResults = await detectInstalledAITools();
259+
const detectedTools = detectionResults
260+
.filter(r => r.detected)
261+
.map(r => r.tool);
262+
263+
// If any tools detected, use those as defaults
264+
if (detectedTools.length > 0) {
265+
// Always include copilot if it's detected or nothing else is (AGENTS.md is primary)
266+
const copilotDetected = detectedTools.includes('copilot');
267+
if (!copilotDetected) {
268+
// Check if any detected tool uses AGENTS.md
269+
const usesAgentsMd = detectedTools.some(t => !AI_TOOL_CONFIGS[t].usesSymlink);
270+
if (!usesAgentsMd) {
271+
detectedTools.push('copilot');
272+
}
273+
}
274+
return { defaults: detectedTools, detected: detectionResults };
275+
}
276+
277+
// Fall back to copilot only (AGENTS.md is the primary file)
278+
return { defaults: ['copilot'], detected: detectionResults };
279+
}
280+
63281
export interface SymlinkResult {
64282
file: string;
65283
created?: boolean;

0 commit comments

Comments
 (0)