@inspecto-dev/cli 0.2.0-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,447 @@
1
+ // ============================================================
2
+ // src/commands/init.ts — Main init orchestrator (v1)
3
+ //
4
+ // v1 scope:
5
+ // - IDE: VS Code only
6
+ // - Framework: React / Vue
7
+ // - Build tools: Vite / Webpack / Rspack / esbuild / Rollup
8
+ // ============================================================
9
+ import path from 'node:path'
10
+ import { log } from '../utils/logger.js'
11
+ import { exists, writeJSON, readJSON } from '../utils/fs.js'
12
+ import { shell } from '../utils/exec.js'
13
+ import { detectPackageManager, getInstallCommand } from '../detect/package-manager.js'
14
+ import { detectBuildTools, resolveInjectionTarget } from '../detect/build-tool.js'
15
+ import { detectFrameworks } from '../detect/framework.js'
16
+ import { detectIDE } from '../detect/ide.js'
17
+ import { detectAITools, type AIToolDetection } from '../detect/ai-tool.js'
18
+ import { injectPlugin } from '../inject/ast-injector.js'
19
+ import { updateGitignore } from '../inject/gitignore.js'
20
+ import { installExtension } from '../inject/extension.js'
21
+ import type { InitOptions, InstallLock, Mutation, BuildToolDetection } from '../types.js'
22
+
23
+ /**
24
+ * Interactive prompt for IDE choice.
25
+ */
26
+ async function promptIDEChoice(
27
+ detections: { ide: string; supported: boolean }[],
28
+ ): Promise<{ ide: string; supported: boolean } | null> {
29
+ if (!process.stdin.isTTY) {
30
+ log.warn('Multiple IDEs detected but stdin is not interactive')
31
+ log.hint(`Using: ${detections[0]!.ide} (first match)`)
32
+ return detections[0]!
33
+ }
34
+
35
+ console.log()
36
+ console.log(' ? Detected multiple IDEs:')
37
+ detections.forEach((d, i) => {
38
+ const status = d.supported ? ' (supported)' : ' (unsupported/limited)'
39
+ console.log(` ${i + 1}. ${d.ide}${status}`)
40
+ })
41
+ console.log()
42
+
43
+ return new Promise(resolve => {
44
+ process.stdout.write(' > Your choice: ')
45
+
46
+ // We must resume stdin in case it was paused by a previous prompt
47
+ process.stdin.resume()
48
+ process.stdin.setEncoding('utf-8')
49
+
50
+ const onData = (data: Buffer) => {
51
+ const choice = parseInt(String(data).trim(), 10)
52
+
53
+ // Cleanup the listener to avoid memory leaks or multiple fires
54
+ process.stdin.off('data', onData)
55
+ process.stdin.pause() // Pause so the CLI can exit when done
56
+
57
+ if (choice >= 1 && choice <= detections.length) {
58
+ resolve(detections[choice - 1]!)
59
+ } else {
60
+ resolve(null)
61
+ }
62
+ }
63
+
64
+ process.stdin.on('data', onData)
65
+ })
66
+ }
67
+ /**
68
+ * Interactive prompt for AI tool choice.
69
+ */
70
+ async function promptAIToolChoice(detections: AIToolDetection[]): Promise<AIToolDetection | null> {
71
+ if (!process.stdin.isTTY) {
72
+ log.warn('Multiple AI tools detected but stdin is not interactive')
73
+ log.hint(`Using: ${detections[0]!.label} (first match)`)
74
+ return detections[0]!
75
+ }
76
+
77
+ console.log()
78
+ console.log(' ? Detected multiple AI tools:')
79
+ detections.forEach((d, i) => {
80
+ // Map toolModes array to human-readable labels
81
+ const modeLabels = d.toolModes.map(mode =>
82
+ mode === 'plugin' ? 'VS Code Extension' : 'Terminal CLI',
83
+ )
84
+ const modeStr = modeLabels.join(' & ')
85
+
86
+ const status = d.supported ? ` (supported ${modeStr})` : ` (unsupported/limited)`
87
+ console.log(` ${i + 1}. ${d.label}${status}`)
88
+ })
89
+ console.log()
90
+
91
+ return new Promise(resolve => {
92
+ process.stdout.write(' > Your choice: ')
93
+
94
+ // We must resume stdin in case it was paused by a previous prompt
95
+ process.stdin.resume()
96
+ process.stdin.setEncoding('utf-8')
97
+
98
+ const onData = (data: Buffer) => {
99
+ const choice = parseInt(String(data).trim(), 10)
100
+
101
+ // Cleanup the listener to avoid memory leaks or multiple fires
102
+ process.stdin.off('data', onData)
103
+ process.stdin.pause() // Pause so the CLI can exit when done
104
+
105
+ if (choice >= 1 && choice <= detections.length) {
106
+ resolve(detections[choice - 1]!)
107
+ } else {
108
+ resolve(null)
109
+ }
110
+ }
111
+
112
+ process.stdin.on('data', onData)
113
+ })
114
+ }
115
+
116
+ async function promptConfigChoice(
117
+ detections: BuildToolDetection[],
118
+ ): Promise<BuildToolDetection | null> {
119
+ if (!process.stdin.isTTY) {
120
+ log.warn('Multiple config files detected but stdin is not interactive')
121
+ log.hint(`Using: ${detections[0]!.label} (first match)`)
122
+ return detections[0]!
123
+ }
124
+
125
+ console.log()
126
+ console.log(' ? Detected multiple build tool configs:')
127
+ detections.forEach((d, i) => {
128
+ // Determine recommendation based on whether it seems like the "main" one
129
+ // We can infer Rsbuild > Rspack > Vite > Webpack as a priority if needed,
130
+ // but without package.json scripts analysis, we just highlight them plainly.
131
+ console.log(` ${i + 1}. ${d.label}`)
132
+ })
133
+ console.log(` ${detections.length + 1}. Skip (I'll configure manually)`)
134
+ console.log()
135
+
136
+ return new Promise(resolve => {
137
+ process.stdout.write(' > Your choice: ')
138
+
139
+ // We must resume stdin in case it was paused by a previous prompt
140
+ process.stdin.resume()
141
+ process.stdin.setEncoding('utf-8')
142
+
143
+ const onData = (data: Buffer) => {
144
+ const choice = parseInt(String(data).trim(), 10)
145
+
146
+ // Cleanup the listener to avoid memory leaks or multiple fires
147
+ process.stdin.off('data', onData)
148
+ process.stdin.pause() // Pause so the CLI can exit when done
149
+
150
+ if (choice >= 1 && choice <= detections.length) {
151
+ resolve(detections[choice - 1]!)
152
+ } else {
153
+ resolve(null)
154
+ }
155
+ }
156
+
157
+ process.stdin.on('data', onData)
158
+ })
159
+ }
160
+
161
+ export async function init(options: InitOptions): Promise<void> {
162
+ const root = process.cwd()
163
+ const mutations: Mutation[] = []
164
+
165
+ log.header('Inspecto Setup')
166
+
167
+ // ---- Step 1: Validate project ----
168
+ if (!(await exists(path.join(root, 'package.json')))) {
169
+ log.error('No package.json found in current directory')
170
+ log.hint('Run this command from your project root')
171
+ return
172
+ }
173
+
174
+ // ---- Step 2: Detect environment ----
175
+
176
+ // Package manager
177
+ const pm = await detectPackageManager(root)
178
+ log.success(`Detected package manager: ${pm}`)
179
+
180
+ // Framework detection (supported + unsupported)
181
+ const frameworkResult = await detectFrameworks(root)
182
+ if (frameworkResult.supported.length > 0) {
183
+ log.success(`Detected framework: ${frameworkResult.supported.join(', ')}`)
184
+ }
185
+ if (frameworkResult.unsupported.length > 0) {
186
+ const names = frameworkResult.unsupported.map(f => f.name).join(', ')
187
+ log.warn(`Detected ${names} — not supported in v1 (React / Vue only)`)
188
+ log.hint('Inspecto may still work but is not tested for this framework')
189
+ }
190
+ if (frameworkResult.supported.length === 0 && frameworkResult.unsupported.length === 0) {
191
+ log.warn('No frontend framework detected')
192
+ log.hint('Inspecto v1 supports React and Vue projects')
193
+ }
194
+
195
+ // Build tool detection (supported + unsupported)
196
+ const buildResult = await detectBuildTools(root)
197
+ if (buildResult.supported.length > 0) {
198
+ buildResult.supported.forEach(bt => log.success(`Detected: ${bt.label}`))
199
+ }
200
+ if (buildResult.unsupported.length > 0) {
201
+ const names = buildResult.unsupported.join(', ')
202
+ log.warn(`Detected ${names} — not supported in v1`)
203
+ log.hint('v1 supports: Vite, Webpack, Rspack, esbuild, Rollup')
204
+ log.hint('Meta-framework support (Next.js, Nuxt, etc.) is planned for v2')
205
+ }
206
+ if (buildResult.supported.length === 0 && buildResult.unsupported.length === 0) {
207
+ log.warn('No recognized build tool detected')
208
+ log.hint('v1 supports: Vite, Webpack, Rspack, esbuild, Rollup')
209
+ log.hint('Dependency will be installed but plugin injection will be skipped')
210
+ }
211
+
212
+ // IDE detection
213
+ const ideProbe = await detectIDE(root)
214
+ let selectedIDE: { ide: string; supported: boolean } | null = null
215
+
216
+ if (ideProbe.detected.length === 0) {
217
+ log.error('No IDE detected in current project')
218
+ log.hint('Please open this project in a supported IDE (like VS Code)')
219
+ // We could potentially block here, but we will allow the rest of the CLI to run
220
+ // for settings generation and build config injection.
221
+ } else if (ideProbe.detected.length === 1) {
222
+ selectedIDE = ideProbe.detected[0]!
223
+ } else {
224
+ // Has multiple
225
+ selectedIDE = await promptIDEChoice(ideProbe.detected)
226
+ }
227
+
228
+ if (selectedIDE) {
229
+ if (selectedIDE.supported) {
230
+ log.success(`Selected IDE: ${selectedIDE.ide}`)
231
+ } else {
232
+ log.warn(`Selected IDE: ${selectedIDE.ide}`)
233
+ log.hint(
234
+ `Note: Inspecto currently requires VS Code (or compatible forks) to function properly.`,
235
+ )
236
+ log.hint(`Features may be severely limited or unavailable in ${selectedIDE.ide}.`)
237
+ }
238
+ }
239
+
240
+ // AI Tool detection
241
+ const aiToolProbe = await detectAITools(root)
242
+ let selectedAITool: AIToolDetection | null = null
243
+
244
+ // If user passed --prefer, we trust it over probing, but if they didn't:
245
+ if (!options.prefer) {
246
+ if (aiToolProbe.detected.length === 0) {
247
+ log.warn('No supported AI tools detected')
248
+ log.hint('Inspecto works best with Claude Code, Trae CLI, or GitHub Copilot')
249
+ } else if (aiToolProbe.detected.length === 1) {
250
+ selectedAITool = aiToolProbe.detected[0]!
251
+ if (selectedAITool.supported) {
252
+ log.success(`Detected AI tool: ${selectedAITool.label}`)
253
+ }
254
+ } else {
255
+ // Has multiple AI Tools
256
+ selectedAITool = await promptAIToolChoice(aiToolProbe.detected)
257
+ if (selectedAITool) {
258
+ log.success(`Selected AI tool: ${selectedAITool.label}`)
259
+ }
260
+ }
261
+ }
262
+
263
+ // ---- Step 3: Install dependency ----
264
+ let installFailed = false
265
+ if (options.skipInstall) {
266
+ log.warn('Skipping dependency installation (--skip-install)')
267
+ } else {
268
+ const installCmd = getInstallCommand(pm, '@inspecto-dev/plugin')
269
+ if (options.dryRun) {
270
+ log.dryRun(`Would run: ${installCmd}`)
271
+ } else {
272
+ try {
273
+ const result = await shell(installCmd, root)
274
+ if (result.stderr && result.stderr.toLowerCase().includes('error')) {
275
+ throw new Error(result.stderr)
276
+ }
277
+ log.success('Installed @inspecto-dev/plugin as devDependency')
278
+ mutations.push({
279
+ type: 'dependency_added',
280
+ name: '@inspecto-dev/plugin',
281
+ dev: true,
282
+ })
283
+ } catch (err: any) {
284
+ installFailed = true
285
+ log.error(`Failed to install dependency: ${err?.message || 'Unknown error'}`)
286
+ log.hint(`Run manually: ${installCmd}`)
287
+ // We do not return here to allow the rest of the setup to continue,
288
+ // but we will show a warning at the end instead of the success message.
289
+ }
290
+ }
291
+ }
292
+
293
+ // ---- Step 4: Inject plugin into build config ----
294
+ let injectionFailed = false
295
+ if (buildResult.supported.length > 0) {
296
+ let target = resolveInjectionTarget(buildResult.supported)
297
+
298
+ if (target === 'ambiguous') {
299
+ target = await promptConfigChoice(buildResult.supported)
300
+ }
301
+
302
+ if (target) {
303
+ const result = await injectPlugin(root, target, options.dryRun)
304
+ if (result.success) {
305
+ mutations.push(...result.mutations)
306
+ } else {
307
+ injectionFailed = true
308
+ }
309
+ } else {
310
+ injectionFailed = true
311
+ log.warn('Skipping plugin injection (manual configuration required)')
312
+ }
313
+ }
314
+
315
+ // ---- Step 5: Generate default settings ----
316
+ const settingsDir = path.join(root, '.inspecto')
317
+ const settingsPath = path.join(settingsDir, 'settings.json')
318
+ const promptsPath = path.join(settingsDir, 'prompts.json')
319
+
320
+ if (await exists(settingsPath)) {
321
+ // Attempt to read the existing settings file to see if it's valid JSON
322
+ const existingSettings = await readJSON(settingsPath)
323
+ if (existingSettings === null) {
324
+ log.warn('.inspecto/settings.json exists but contains invalid JSON')
325
+ log.hint('Please fix the syntax errors manually, or delete it and re-run init')
326
+ } else {
327
+ log.success('.inspecto/settings.json already exists (skipped)')
328
+ }
329
+ } else {
330
+ // Only omit properties that can be auto-inferred
331
+ // The schema allows empty objects or just specifying the IDE/prefer
332
+ const defaultSettings: Record<string, unknown> = {}
333
+
334
+ if (selectedIDE && selectedIDE.supported) {
335
+ defaultSettings.ide = selectedIDE.ide === 'vscode' ? 'vscode' : selectedIDE.ide // Fallback string handling
336
+ }
337
+
338
+ if (options.prefer) {
339
+ defaultSettings.prefer = options.prefer
340
+ } else if (selectedAITool) {
341
+ defaultSettings.prefer = selectedAITool.id
342
+
343
+ // If the selected tool has a specific mode (like cli vs plugin), we can pre-configure the providers block
344
+ // to ensure it uses the intended mode, providing an optimal out-of-the-box experience.
345
+ if (selectedAITool.preferredMode) {
346
+ defaultSettings.providers = {
347
+ [selectedAITool.id]: {
348
+ type: selectedAITool.preferredMode,
349
+ },
350
+ }
351
+ }
352
+ }
353
+
354
+ if (options.dryRun) {
355
+ log.dryRun('Would create .inspecto/settings.json')
356
+ } else {
357
+ await writeJSON(settingsPath, defaultSettings)
358
+ log.success('Created .inspecto/settings.json')
359
+ mutations.push({ type: 'file_created', path: '.inspecto/settings.json' })
360
+ }
361
+ }
362
+
363
+ // Generate prompts.json to disable low-frequency intents by default
364
+ if (await exists(promptsPath)) {
365
+ log.success('.inspecto/prompts.json already exists (skipped)')
366
+ } else {
367
+ const defaultPrompts = [
368
+ { id: 'code-review', enabled: false },
369
+ { id: 'generate-test', enabled: false },
370
+ { id: 'performance', enabled: false },
371
+ ]
372
+
373
+ if (options.dryRun) {
374
+ log.dryRun('Would create .inspecto/prompts.json')
375
+ } else {
376
+ await writeJSON(promptsPath, defaultPrompts)
377
+ log.success('Created .inspecto/prompts.json (disabling low-frequency intents)')
378
+ mutations.push({ type: 'file_created', path: '.inspecto/prompts.json' })
379
+ }
380
+ }
381
+
382
+ // ---- Step 6: Update .gitignore ----
383
+ if (!options.dryRun) {
384
+ await updateGitignore(root, options.shared, options.dryRun)
385
+ mutations.push({
386
+ type: 'file_modified',
387
+ path: '.gitignore',
388
+ description: 'Appended .inspecto/ ignore rules',
389
+ })
390
+ } else {
391
+ log.dryRun('Would update .gitignore')
392
+ }
393
+
394
+ // ---- Step 7: Write install.lock ----
395
+ if (!options.dryRun && mutations.length > 0) {
396
+ const lock: InstallLock = {
397
+ version: '1.0.0',
398
+ created_at: new Date().toISOString(),
399
+ mutations,
400
+ }
401
+ await writeJSON(path.join(settingsDir, 'install.lock'), lock)
402
+ }
403
+
404
+ // ---- Step 8: Install VS Code extension ----
405
+ // Only attempt if IDE is VS Code or not detected (might be VS Code)
406
+ const shouldInstallExt =
407
+ !options.noExtension && (!selectedIDE || (selectedIDE && selectedIDE.supported))
408
+
409
+ let manualExtensionInstallNeeded = false
410
+
411
+ if (options.noExtension) {
412
+ log.warn('Skipping VS Code extension (--no-extension)')
413
+ } else if (!shouldInstallExt) {
414
+ // Unsupported IDE detected — skip extension, already warned above
415
+ } else {
416
+ const extMutation = await installExtension(options.dryRun)
417
+ if (extMutation && !options.dryRun) {
418
+ mutations.push(extMutation)
419
+
420
+ if (extMutation.manual_action_required) {
421
+ manualExtensionInstallNeeded = true
422
+ }
423
+
424
+ const lockPath = path.join(settingsDir, 'install.lock')
425
+ const lock = await readJSON<InstallLock>(lockPath)
426
+ if (lock) {
427
+ lock.mutations = mutations
428
+ await writeJSON(lockPath, lock)
429
+ }
430
+ } else if (extMutation === null && !options.dryRun) {
431
+ manualExtensionInstallNeeded = true
432
+ }
433
+ }
434
+
435
+ // ---- Done ----
436
+ if (options.dryRun) {
437
+ log.blank()
438
+ log.warn('Dry run complete. No files were modified.')
439
+ } else if (installFailed || injectionFailed || manualExtensionInstallNeeded) {
440
+ log.blank()
441
+ log.warn('Setup completed with some manual steps required.')
442
+ log.hint('Please check the logs above and complete the manual steps.')
443
+ log.blank()
444
+ } else {
445
+ log.ready('Ready! Hold Alt + Click any element to inspect.')
446
+ }
447
+ }
@@ -0,0 +1,124 @@
1
+ // ============================================================
2
+ // src/commands/teardown.ts — Precise uninstall with install.lock
3
+ // ============================================================
4
+ import path from 'node:path'
5
+ import { log } from '../utils/logger.js'
6
+ import { exists, readJSON, copyFile, removeDir, removeFile } from '../utils/fs.js'
7
+ import { shell } from '../utils/exec.js'
8
+ import { detectPackageManager, getUninstallCommand } from '../detect/package-manager.js'
9
+ import { cleanGitignore } from '../inject/gitignore.js'
10
+ import type { InstallLock } from '../types.js'
11
+
12
+ export async function teardown(): Promise<void> {
13
+ const root = process.cwd()
14
+
15
+ log.header('Inspecto Teardown')
16
+
17
+ const lockPath = path.join(root, '.inspecto', 'install.lock')
18
+ const lock = await readJSON<InstallLock>(lockPath)
19
+
20
+ if (!lock) {
21
+ // ---- Best-effort mode (no install.lock) ----
22
+ log.warn('No .inspecto/install.lock found. Running in best-effort mode.')
23
+ log.blank()
24
+
25
+ // Try to remove dependency
26
+ const pm = await detectPackageManager(root)
27
+ try {
28
+ const cmd = getUninstallCommand(pm, '@inspecto-dev/plugin')
29
+ await shell(cmd, root)
30
+ log.success('Removed @inspecto-dev/plugin from devDependencies')
31
+ } catch {
32
+ log.warn('Could not remove @inspecto-dev/plugin (may not be installed)')
33
+ }
34
+
35
+ // Remove .inspecto directory
36
+ if (await exists(path.join(root, '.inspecto'))) {
37
+ await removeDir(path.join(root, '.inspecto'))
38
+ log.success('Deleted .inspecto/ directory')
39
+ }
40
+
41
+ // Clean gitignore
42
+ await cleanGitignore(root)
43
+ log.success('Cleaned .gitignore entries')
44
+
45
+ // Warn about config file
46
+ log.warn('Cannot restore build config (no backup reference)')
47
+ log.hint('Please manually remove the inspecto() plugin from your build config')
48
+
49
+ log.blank()
50
+ return
51
+ }
52
+
53
+ // ---- Precise mode (with install.lock) ----
54
+ log.success('Reading .inspecto/install.lock...')
55
+ log.blank()
56
+
57
+ for (const mutation of lock.mutations) {
58
+ switch (mutation.type) {
59
+ case 'file_modified': {
60
+ if (mutation.backup && mutation.path) {
61
+ const backupPath = path.join(root, mutation.backup)
62
+ const targetPath = path.join(root, mutation.path)
63
+
64
+ if (mutation.path === '.gitignore') {
65
+ // For .gitignore, use the clean function instead of backup restore
66
+ await cleanGitignore(root)
67
+ log.success('Cleaned .gitignore entries')
68
+ // Remove the .gitignore backup if it exists
69
+ await removeFile(backupPath)
70
+ } else if (await exists(backupPath)) {
71
+ await copyFile(backupPath, targetPath)
72
+ await removeFile(backupPath)
73
+ log.success(`Restored ${mutation.path} from backup`)
74
+ } else {
75
+ log.warn(`Backup not found: ${mutation.backup}`)
76
+ log.hint(`Please manually remove inspecto from ${mutation.path}`)
77
+ }
78
+ } else if (mutation.path && mutation.path !== '.gitignore') {
79
+ log.warn(`Cannot auto-restore ${mutation.path} (no backup recorded)`)
80
+ log.hint(`Please manually remove the inspecto() plugin from ${mutation.path}`)
81
+ }
82
+ break
83
+ }
84
+
85
+ case 'file_created': {
86
+ // Will be cleaned up when we delete .inspecto/ directory
87
+ break
88
+ }
89
+
90
+ case 'dependency_added': {
91
+ if (mutation.name) {
92
+ const pm = await detectPackageManager(root)
93
+ try {
94
+ const cmd = getUninstallCommand(pm, mutation.name)
95
+ await shell(cmd, root)
96
+ log.success(`Removed ${mutation.name} from devDependencies`)
97
+ } catch {
98
+ log.warn(`Could not remove ${mutation.name}`)
99
+ }
100
+ }
101
+ break
102
+ }
103
+
104
+ case 'extension_installed': {
105
+ if (mutation.id) {
106
+ log.warn(`VS Code extension not auto-uninstalled`)
107
+ log.hint(`Run: code --uninstall-extension ${mutation.id}`)
108
+ }
109
+ break
110
+ }
111
+ }
112
+ }
113
+
114
+ // Remove .inspecto directory (includes install.lock, settings.json, cache.json)
115
+ await removeDir(path.join(root, '.inspecto'))
116
+ log.success('Deleted .inspecto/ directory')
117
+
118
+ // Clean .gitignore if not already handled
119
+ await cleanGitignore(root)
120
+
121
+ log.blank()
122
+ log.success('Done. All Inspecto traces removed.')
123
+ log.blank()
124
+ }
@@ -0,0 +1,127 @@
1
+ // ============================================================
2
+ // src/detect/ai-tool.ts — AI Tool detection (v1)
3
+ //
4
+ // Detects installed AI tools via PATH (CLI) or IDE extensions (Plugin).
5
+ // ============================================================
6
+ import path from 'node:path'
7
+ import { exists, readJSON } from '../utils/fs.js'
8
+ import { which } from '../utils/exec.js'
9
+ import type { AiTool } from '@inspecto-dev/types'
10
+
11
+ export interface AIToolDetection {
12
+ id: AiTool
13
+ label: string
14
+ supported: boolean
15
+ toolModes: Array<'cli' | 'plugin'>
16
+ // The primary mode that will be written to settings if selected
17
+ preferredMode: 'cli' | 'plugin'
18
+ }
19
+
20
+ const KNOWN_CLI_TOOLS: { id: AiTool; bin: string; label: string; supported: boolean }[] = [
21
+ { id: 'claude-code', bin: 'claude', label: 'Claude Code', supported: true },
22
+ { id: 'coco', bin: 'coco', label: 'Trae CLI (Coco)', supported: true },
23
+ { id: 'codex', bin: 'codex', label: 'Codex CLI', supported: true },
24
+ { id: 'gemini', bin: 'gemini', label: 'Gemini CLI', supported: true },
25
+ ]
26
+
27
+ const KNOWN_IDE_PLUGINS: { id: AiTool; extId: string; label: string; supported: boolean }[] = [
28
+ { id: 'claude-code', extId: 'anthropic.claude-code', label: 'Claude Code', supported: true },
29
+ { id: 'github-copilot', extId: 'github.copilot', label: 'GitHub Copilot', supported: true },
30
+ { id: 'codex', extId: 'openai.chatgpt', label: 'Codex (ChatGPT)', supported: true },
31
+ { id: 'gemini', extId: 'google.geminicodeassist', label: 'Gemini Code Assist', supported: true },
32
+ ]
33
+
34
+ export interface AIToolProbeResult {
35
+ detected: AIToolDetection[]
36
+ }
37
+
38
+ /**
39
+ * Detect all installed AI tools by checking PATH binaries and IDE extensions.
40
+ */
41
+ export async function detectAITools(root: string): Promise<AIToolProbeResult> {
42
+ // Use a map to merge duplicate CLI/Plugin detections for the same AI tool
43
+ const detectedMap = new Map<AiTool, AIToolDetection>()
44
+
45
+ // 1. Detect CLI tools
46
+ for (const tool of KNOWN_CLI_TOOLS) {
47
+ if (await which(tool.bin)) {
48
+ detectedMap.set(tool.id, {
49
+ id: tool.id,
50
+ label: tool.label,
51
+ supported: tool.supported,
52
+ toolModes: ['cli'],
53
+ preferredMode: 'cli',
54
+ })
55
+ }
56
+ }
57
+
58
+ // 2. Detect IDE plugins (VS Code extensions)
59
+ // Check the local workspace .vscode/extensions.json first (recommendations)
60
+ const extensionsJsonPath = path.join(root, '.vscode', 'extensions.json')
61
+ let recommendedExts: string[] = []
62
+ if (await exists(extensionsJsonPath)) {
63
+ try {
64
+ const extData = await readJSON<{ recommendations?: string[] }>(extensionsJsonPath)
65
+ if (extData && Array.isArray(extData.recommendations)) {
66
+ recommendedExts = extData.recommendations.map(e => e.toLowerCase())
67
+ }
68
+ } catch {
69
+ // ignore JSON parse errors here
70
+ }
71
+ }
72
+
73
+ // Check user's global VS Code extensions folder
74
+ const homeDir = process.env.HOME || process.env.USERPROFILE || ''
75
+ const globalExtDir = path.join(homeDir, '.vscode', 'extensions')
76
+ const globalExtExists = await exists(globalExtDir)
77
+
78
+ // Wait for all plugin checks to resolve
79
+ for (const plugin of KNOWN_IDE_PLUGINS) {
80
+ let isInstalled = false
81
+
82
+ // Check if it's explicitly recommended in the workspace
83
+ if (recommendedExts.includes(plugin.extId.toLowerCase())) {
84
+ isInstalled = true
85
+ }
86
+ // Otherwise try to find it in the global extensions folder by prefix
87
+ else if (globalExtExists) {
88
+ try {
89
+ const { readdir } = await import('node:fs/promises')
90
+ const folders = await readdir(globalExtDir)
91
+ if (
92
+ folders.some(f => {
93
+ const lower = f.toLowerCase()
94
+ return (
95
+ lower === plugin.extId.toLowerCase() ||
96
+ lower.startsWith(plugin.extId.toLowerCase() + '-')
97
+ )
98
+ })
99
+ ) {
100
+ isInstalled = true
101
+ }
102
+ } catch {
103
+ // Fallback or ignore
104
+ }
105
+ }
106
+
107
+ if (isInstalled) {
108
+ // If we already detected the CLI version of this tool, we append 'plugin' to the modes
109
+ // and set the preferredMode to 'plugin' since plugin integration is generally more seamless.
110
+ const existing = detectedMap.get(plugin.id)
111
+ if (existing) {
112
+ existing.toolModes.push('plugin')
113
+ existing.preferredMode = 'plugin'
114
+ } else {
115
+ detectedMap.set(plugin.id, {
116
+ id: plugin.id,
117
+ label: plugin.label,
118
+ supported: plugin.supported,
119
+ toolModes: ['plugin'],
120
+ preferredMode: 'plugin',
121
+ })
122
+ }
123
+ }
124
+ }
125
+
126
+ return { detected: Array.from(detectedMap.values()) }
127
+ }