@inspecto-dev/cli 0.2.0-alpha.1 → 0.2.0-alpha.3
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.
- package/.turbo/turbo-build.log +20 -19
- package/.turbo/turbo-test.log +4 -4
- package/CHANGELOG.md +22 -0
- package/README.md +5 -5
- package/dist/bin.js +50 -68
- package/dist/{chunk-DBXT75QF.js → chunk-HIL6365F.js} +718 -443
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -1
- package/package.json +3 -2
- package/src/bin.ts +84 -70
- package/src/commands/doctor.ts +38 -25
- package/src/commands/init.ts +107 -228
- package/src/commands/teardown.ts +13 -23
- package/src/detect/build-tool.ts +169 -21
- package/src/detect/framework.ts +71 -9
- package/src/detect/ide.ts +42 -21
- package/src/detect/package-manager.ts +10 -3
- package/src/detect/provider.ts +151 -0
- package/src/inject/ast-injector.ts +70 -231
- package/src/inject/extension.ts +49 -34
- package/src/inject/gitignore.ts +1 -1
- package/src/inject/strategies/esbuild.ts +35 -0
- package/src/inject/strategies/index.ts +16 -0
- package/src/inject/strategies/rollup.ts +35 -0
- package/src/inject/strategies/rsbuild.ts +29 -0
- package/src/inject/strategies/rspack.ts +34 -0
- package/src/inject/strategies/types.ts +35 -0
- package/src/inject/strategies/vite.ts +30 -0
- package/src/inject/strategies/webpack.ts +36 -0
- package/src/instructions.ts +55 -0
- package/src/prompts.ts +115 -0
- package/src/types.ts +4 -1
- package/tests/ide.test.ts +3 -3
- package/src/detect/ai-tool.ts +0 -127
package/src/detect/build-tool.ts
CHANGED
|
@@ -5,12 +5,48 @@
|
|
|
5
5
|
// Recognized but unsupported: Next.js / Nuxt / Remix / Astro / SvelteKit
|
|
6
6
|
// ============================================================
|
|
7
7
|
import path from 'node:path'
|
|
8
|
+
import { createRequire } from 'node:module'
|
|
8
9
|
import { exists, readJSON } from '../utils/fs.js'
|
|
9
10
|
import type { BuildTool, BuildToolDetection } from '../types.js'
|
|
10
11
|
|
|
11
12
|
interface PackageJSON {
|
|
12
13
|
dependencies?: Record<string, string>
|
|
13
14
|
devDependencies?: Record<string, string>
|
|
15
|
+
scripts?: Record<string, string>
|
|
16
|
+
version?: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Helper to check if a package can be resolved from the root directory.
|
|
21
|
+
* This handles monorepo hoisting and implicit dependencies.
|
|
22
|
+
*/
|
|
23
|
+
function isPackageResolvable(pkgName: string, root: string): boolean {
|
|
24
|
+
try {
|
|
25
|
+
const require = createRequire(path.join(root, 'package.json'))
|
|
26
|
+
try {
|
|
27
|
+
require.resolve(`${pkgName}/package.json`, { paths: [root] })
|
|
28
|
+
return true
|
|
29
|
+
} catch {
|
|
30
|
+
require.resolve(pkgName, { paths: [root] })
|
|
31
|
+
return true
|
|
32
|
+
}
|
|
33
|
+
} catch {
|
|
34
|
+
return false
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Attempts to read the actual version of a hoisted package from node_modules.
|
|
40
|
+
*/
|
|
41
|
+
async function getResolvedPackageVersion(pkgName: string, root: string): Promise<string | null> {
|
|
42
|
+
try {
|
|
43
|
+
const require = createRequire(path.join(root, 'package.json'))
|
|
44
|
+
const pkgJsonPath = require.resolve(`${pkgName}/package.json`, { paths: [root] })
|
|
45
|
+
const pkg = await readJSON<PackageJSON>(pkgJsonPath)
|
|
46
|
+
return pkg?.version || null
|
|
47
|
+
} catch {
|
|
48
|
+
return null
|
|
49
|
+
}
|
|
14
50
|
}
|
|
15
51
|
|
|
16
52
|
/** Supported build tools in v1 */
|
|
@@ -37,7 +73,7 @@ const SUPPORTED_PATTERNS: { tool: BuildTool; files: string[]; label: string }[]
|
|
|
37
73
|
},
|
|
38
74
|
{
|
|
39
75
|
tool: 'esbuild',
|
|
40
|
-
files: ['esbuild.config.js', 'esbuild.config.ts', 'esbuild.config.mjs'],
|
|
76
|
+
files: ['esbuild.config.js', 'esbuild.config.ts', 'esbuild.config.mjs', 'build.js', 'build.ts'],
|
|
41
77
|
label: 'esbuild',
|
|
42
78
|
},
|
|
43
79
|
{
|
|
@@ -73,39 +109,151 @@ export async function detectBuildTools(root: string): Promise<BuildToolResult> {
|
|
|
73
109
|
const pkg = await readJSON<PackageJSON>(path.join(root, 'package.json'))
|
|
74
110
|
const allDeps = { ...pkg?.dependencies, ...pkg?.devDependencies }
|
|
75
111
|
|
|
76
|
-
|
|
112
|
+
const supportedChecks = SUPPORTED_PATTERNS.map(async pattern => {
|
|
113
|
+
// 1. Check if the package.json has a dependency for this tool
|
|
114
|
+
let hasDep: boolean
|
|
115
|
+
let resolvedVersion: string | null = null
|
|
116
|
+
|
|
117
|
+
if (pattern.tool === 'rspack') {
|
|
118
|
+
const depName = allDeps['@rspack/cli'] ? '@rspack/cli' : '@rspack/core'
|
|
119
|
+
hasDep =
|
|
120
|
+
!!allDeps['@rspack/cli'] ||
|
|
121
|
+
!!allDeps['@rspack/core'] ||
|
|
122
|
+
isPackageResolvable('@rspack/core', root)
|
|
123
|
+
|
|
124
|
+
if (hasDep) {
|
|
125
|
+
resolvedVersion =
|
|
126
|
+
allDeps[depName] || (await getResolvedPackageVersion('@rspack/core', root))
|
|
127
|
+
}
|
|
128
|
+
} else if (pattern.tool === 'webpack') {
|
|
129
|
+
const depName = allDeps['webpack'] ? 'webpack' : 'webpack-cli'
|
|
130
|
+
hasDep =
|
|
131
|
+
!!allDeps['webpack'] || !!allDeps['webpack-cli'] || isPackageResolvable('webpack', root)
|
|
132
|
+
|
|
133
|
+
if (hasDep) {
|
|
134
|
+
resolvedVersion = allDeps[depName] || (await getResolvedPackageVersion('webpack', root))
|
|
135
|
+
}
|
|
136
|
+
} else if (pattern.tool === 'rsbuild') {
|
|
137
|
+
hasDep = !!allDeps['@rsbuild/core'] || isPackageResolvable('@rsbuild/core', root)
|
|
138
|
+
} else {
|
|
139
|
+
hasDep = !!allDeps[pattern.tool] || isPackageResolvable(pattern.tool, root)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// 2. Look for config files
|
|
143
|
+
let detectedFile = ''
|
|
144
|
+
|
|
145
|
+
// For esbuild, dependency is strictly required
|
|
146
|
+
if (pattern.tool === 'esbuild' && !hasDep) {
|
|
147
|
+
return null
|
|
148
|
+
}
|
|
149
|
+
|
|
77
150
|
for (const file of pattern.files) {
|
|
78
151
|
if (await exists(path.join(root, file))) {
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
152
|
+
detectedFile = file
|
|
153
|
+
break
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// 3. For esbuild, rollup, and webpack, if they are in dependencies but no standard config is found,
|
|
158
|
+
// we still consider them detected (as they are often used with custom scripts or config file names)
|
|
159
|
+
if (
|
|
160
|
+
hasDep &&
|
|
161
|
+
!detectedFile &&
|
|
162
|
+
(pattern.tool === 'esbuild' ||
|
|
163
|
+
pattern.tool === 'rollup' ||
|
|
164
|
+
pattern.tool === 'webpack' ||
|
|
165
|
+
pattern.tool === 'rspack' ||
|
|
166
|
+
pattern.tool === 'rsbuild')
|
|
167
|
+
) {
|
|
168
|
+
// Look at npm scripts to guess the build file
|
|
169
|
+
const scripts = pkg?.scripts || {}
|
|
170
|
+
for (const cmd of Object.values(scripts)) {
|
|
171
|
+
if (cmd.includes('node ')) {
|
|
172
|
+
const match = cmd.match(/node\s+([^\s]+\.(js|mjs|cjs|ts))/)
|
|
173
|
+
if (match && match[1]) {
|
|
174
|
+
if (await exists(path.join(root, match[1]))) {
|
|
175
|
+
// Only fallback to a bare node script if the script mentions the tool name somewhere
|
|
176
|
+
// or if it's explicitly inside a directory named after the tool (like /rspack-scripts/)
|
|
177
|
+
if (cmd.includes(pattern.tool) || match[1].includes(pattern.tool)) {
|
|
178
|
+
detectedFile = match[1]
|
|
179
|
+
break
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
} else if (cmd.includes(`${pattern.tool} `)) {
|
|
184
|
+
// If we see webpack/rspack in a script but didn't find the exact file above,
|
|
185
|
+
// let's try to extract a custom --config flag if provided
|
|
186
|
+
if (pattern.tool === 'webpack' || pattern.tool === 'rspack') {
|
|
187
|
+
const configMatch = cmd.match(/--config\s+([^\s]+)/)
|
|
188
|
+
if (configMatch && configMatch[1]) {
|
|
189
|
+
if (await exists(path.join(root, configMatch[1]))) {
|
|
190
|
+
detectedFile = configMatch[1]
|
|
191
|
+
break
|
|
192
|
+
}
|
|
193
|
+
}
|
|
87
194
|
}
|
|
195
|
+
|
|
196
|
+
if (!detectedFile) {
|
|
197
|
+
detectedFile = 'package.json (scripts)'
|
|
198
|
+
break
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (detectedFile) {
|
|
205
|
+
let isLegacyRspack = false
|
|
206
|
+
let isLegacyWebpack = false
|
|
207
|
+
|
|
208
|
+
if (pattern.tool === 'rspack') {
|
|
209
|
+
const version = resolvedVersion
|
|
210
|
+
if (
|
|
211
|
+
version &&
|
|
212
|
+
(version.includes('0.3.') || version.includes('0.2.') || version.includes('0.1.'))
|
|
213
|
+
) {
|
|
214
|
+
isLegacyRspack = true
|
|
215
|
+
}
|
|
216
|
+
} else if (pattern.tool === 'webpack') {
|
|
217
|
+
const version = resolvedVersion
|
|
218
|
+
if ((version && version.includes('^4')) || version?.startsWith('4.')) {
|
|
219
|
+
isLegacyWebpack = true
|
|
88
220
|
}
|
|
221
|
+
}
|
|
89
222
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
break // One match per tool
|
|
223
|
+
return {
|
|
224
|
+
tool: pattern.tool,
|
|
225
|
+
configPath: detectedFile,
|
|
226
|
+
label: `${pattern.label} (${detectedFile})${isLegacyRspack ? ' [Legacy]' : ''}${isLegacyWebpack ? ' [Webpack 4]' : ''}`,
|
|
227
|
+
isLegacyRspack,
|
|
228
|
+
isLegacyWebpack,
|
|
97
229
|
}
|
|
98
230
|
}
|
|
231
|
+
|
|
232
|
+
return null
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
const supportedResults = await Promise.all(supportedChecks)
|
|
236
|
+
for (const result of supportedResults) {
|
|
237
|
+
if (result) {
|
|
238
|
+
supported.push(result)
|
|
239
|
+
}
|
|
99
240
|
}
|
|
100
241
|
|
|
101
|
-
|
|
102
|
-
if (!(meta.dep in allDeps))
|
|
242
|
+
const unsupportedChecks = UNSUPPORTED_META.map(async meta => {
|
|
243
|
+
if (!(meta.dep in allDeps)) return null
|
|
103
244
|
for (const file of meta.files) {
|
|
104
245
|
if (await exists(path.join(root, file))) {
|
|
105
|
-
|
|
106
|
-
break
|
|
246
|
+
return meta.name
|
|
107
247
|
}
|
|
108
248
|
}
|
|
249
|
+
return null
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
const unsupportedResults = await Promise.all(unsupportedChecks)
|
|
253
|
+
for (const result of unsupportedResults) {
|
|
254
|
+
if (result) {
|
|
255
|
+
unsupported.push(result)
|
|
256
|
+
}
|
|
109
257
|
}
|
|
110
258
|
|
|
111
259
|
return { supported, unsupported }
|
package/src/detect/framework.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// Recognized but unsupported: Solid, Svelte, Angular, Preact, Lit
|
|
6
6
|
// ============================================================
|
|
7
7
|
import path from 'node:path'
|
|
8
|
+
import { createRequire } from 'node:module'
|
|
8
9
|
import { readJSON } from '../utils/fs.js'
|
|
9
10
|
|
|
10
11
|
export type Framework = 'react' | 'vue'
|
|
@@ -17,6 +18,20 @@ export interface FrameworkDetection {
|
|
|
17
18
|
interface PackageJSON {
|
|
18
19
|
dependencies?: Record<string, string>
|
|
19
20
|
devDependencies?: Record<string, string>
|
|
21
|
+
peerDependencies?: Record<string, string>
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Map meta-frameworks to their underlying UI frameworks
|
|
25
|
+
const META_FRAMEWORK_MAP: Record<string, Framework> = {
|
|
26
|
+
next: 'react',
|
|
27
|
+
nuxt: 'vue',
|
|
28
|
+
'@remix-run/react': 'react',
|
|
29
|
+
'@remix-run/dev': 'react',
|
|
30
|
+
'@vue/nuxt': 'vue',
|
|
31
|
+
'vite-plugin-vue': 'vue',
|
|
32
|
+
'@vitejs/plugin-vue': 'vue',
|
|
33
|
+
'@vitejs/plugin-react': 'react',
|
|
34
|
+
'@vitejs/plugin-react-swc': 'react',
|
|
20
35
|
}
|
|
21
36
|
|
|
22
37
|
/** Supported frameworks in v1 */
|
|
@@ -34,32 +49,79 @@ const UNSUPPORTED_FRAMEWORKS: { name: string; dep: string }[] = [
|
|
|
34
49
|
{ name: 'Lit', dep: 'lit' },
|
|
35
50
|
]
|
|
36
51
|
|
|
52
|
+
/**
|
|
53
|
+
* Helper to check if a package can be resolved from the root directory.
|
|
54
|
+
* This handles monorepo hoisting and implicit dependencies.
|
|
55
|
+
*/
|
|
56
|
+
function isPackageResolvable(pkgName: string, root: string): boolean {
|
|
57
|
+
try {
|
|
58
|
+
const require = createRequire(path.join(root, 'package.json'))
|
|
59
|
+
// Some packages might not expose package.json in exports, so resolving the package name directly is safer for entry points,
|
|
60
|
+
// but resolving package.json is generally safer to just check existence without executing code.
|
|
61
|
+
// We'll try resolving package.json first, and fallback to resolving the package root if possible.
|
|
62
|
+
try {
|
|
63
|
+
require.resolve(`${pkgName}/package.json`, { paths: [root] })
|
|
64
|
+
return true
|
|
65
|
+
} catch {
|
|
66
|
+
require.resolve(pkgName, { paths: [root] })
|
|
67
|
+
return true
|
|
68
|
+
}
|
|
69
|
+
} catch {
|
|
70
|
+
return false
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
37
74
|
/**
|
|
38
75
|
* Detect frontend frameworks.
|
|
76
|
+
* Uses a waterfall approach:
|
|
77
|
+
* 1. Checks package.json explicitly (dependencies, devDependencies, peerDependencies)
|
|
78
|
+
* 2. Checks meta-frameworks mapping (e.g. nuxt -> vue)
|
|
79
|
+
* 3. Uses Node.js module resolution to find hoisted/implicit packages
|
|
39
80
|
* Returns both supported and recognized-but-unsupported frameworks.
|
|
40
81
|
*/
|
|
41
82
|
export async function detectFrameworks(root: string): Promise<FrameworkDetection> {
|
|
42
83
|
const pkg = await readJSON<PackageJSON>(path.join(root, 'package.json'))
|
|
43
|
-
if (!pkg) return { supported: [], unsupported: [] }
|
|
44
84
|
|
|
45
85
|
const allDeps = {
|
|
46
|
-
...pkg
|
|
47
|
-
...pkg
|
|
86
|
+
...(pkg?.dependencies || {}),
|
|
87
|
+
...(pkg?.devDependencies || {}),
|
|
88
|
+
...(pkg?.peerDependencies || {}),
|
|
48
89
|
}
|
|
49
90
|
|
|
50
|
-
const
|
|
91
|
+
const supportedSet = new Set<Framework>()
|
|
92
|
+
const unsupported: { name: string; dep: string }[] = []
|
|
93
|
+
|
|
94
|
+
// Skip node resolution mock errors during unit tests
|
|
95
|
+
const isTest = root.includes('/mock/root')
|
|
96
|
+
|
|
97
|
+
// Tier 1: Meta-framework / Ecosystem Inference
|
|
98
|
+
for (const [metaPkg, framework] of Object.entries(META_FRAMEWORK_MAP)) {
|
|
99
|
+
if (metaPkg in allDeps || (!isTest && isPackageResolvable(metaPkg, root))) {
|
|
100
|
+
supportedSet.add(framework)
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Tier 2: Explicit Dependency & Node Resolution (Hoisting support)
|
|
51
105
|
for (const { framework, deps } of SUPPORTED_FRAMEWORKS) {
|
|
52
|
-
if (
|
|
53
|
-
|
|
106
|
+
if (supportedSet.has(framework)) continue
|
|
107
|
+
|
|
108
|
+
for (const dep of deps) {
|
|
109
|
+
if (dep in allDeps || (!isTest && isPackageResolvable(dep, root))) {
|
|
110
|
+
supportedSet.add(framework)
|
|
111
|
+
break
|
|
112
|
+
}
|
|
54
113
|
}
|
|
55
114
|
}
|
|
56
115
|
|
|
57
|
-
|
|
116
|
+
// Tier 3: Check unsupported frameworks
|
|
58
117
|
for (const fw of UNSUPPORTED_FRAMEWORKS) {
|
|
59
|
-
if (fw.dep in allDeps) {
|
|
118
|
+
if (fw.dep in allDeps || (!isTest && isPackageResolvable(fw.dep, root))) {
|
|
60
119
|
unsupported.push(fw)
|
|
61
120
|
}
|
|
62
121
|
}
|
|
63
122
|
|
|
64
|
-
return {
|
|
123
|
+
return {
|
|
124
|
+
supported: Array.from(supportedSet),
|
|
125
|
+
unsupported,
|
|
126
|
+
}
|
|
65
127
|
}
|
package/src/detect/ide.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// ============================================================
|
|
2
|
-
// src/detect/ide.ts — IDE detection
|
|
2
|
+
// src/detect/ide.ts — IDE detection
|
|
3
3
|
//
|
|
4
|
-
// Detects ALL known IDEs, but only VS Code
|
|
4
|
+
// Detects ALL known IDEs, but only VS Code, Cursor, and Trae are fully supported.
|
|
5
5
|
// Others are recognized and surfaced as unsupported.
|
|
6
6
|
// ============================================================
|
|
7
7
|
import path from 'node:path'
|
|
@@ -28,7 +28,7 @@ export async function detectIDE(root: string): Promise<IDEProbeResult> {
|
|
|
28
28
|
|
|
29
29
|
// Cursor
|
|
30
30
|
if (process.env.CURSOR_TRACE_DIR || process.env.CURSOR_CHANNEL) {
|
|
31
|
-
detected.set('Cursor', { ide: '
|
|
31
|
+
detected.set('Cursor', { ide: 'cursor', supported: true })
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
// Trae
|
|
@@ -38,7 +38,7 @@ export async function detectIDE(root: string): Promise<IDEProbeResult> {
|
|
|
38
38
|
process.env.COCO_IDE_PLUGIN_TYPE === 'Trae' ||
|
|
39
39
|
(process.env.npm_config_user_agent && process.env.npm_config_user_agent.includes('trae'))
|
|
40
40
|
) {
|
|
41
|
-
detected.set('Trae', { ide: '
|
|
41
|
+
detected.set('Trae', { ide: 'trae', supported: true })
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
// Zed
|
|
@@ -46,32 +46,53 @@ export async function detectIDE(root: string): Promise<IDEProbeResult> {
|
|
|
46
46
|
detected.set('Zed', { ide: 'Zed', supported: false })
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
//
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
49
|
+
// Windsurf
|
|
50
|
+
if (
|
|
51
|
+
process.env.WINDSURF_APP_DIR ||
|
|
52
|
+
process.env.WINDSURF_CHANNEL ||
|
|
53
|
+
process.env.__CFBundleIdentifier === 'com.codeium.windsurf' ||
|
|
54
|
+
(process.env.npm_config_user_agent && process.env.npm_config_user_agent.includes('windsurf'))
|
|
55
|
+
) {
|
|
56
|
+
detected.set('Windsurf', { ide: 'Windsurf', supported: false })
|
|
55
57
|
}
|
|
56
58
|
|
|
59
|
+
// VS Code
|
|
60
|
+
// We cannot rely purely on TERM_PROGRAM === 'vscode' because Cursor and Trae also use it.
|
|
61
|
+
// We should ONLY use it as a fallback if no other specific variables were found, and we'll do that at the end.
|
|
62
|
+
// if (process.env.TERM_PROGRAM === 'vscode') { ... }
|
|
63
|
+
|
|
57
64
|
// 2. Check Directory Artifacts (Indicates project has been opened in these IDEs)
|
|
58
|
-
|
|
59
|
-
|
|
65
|
+
const [hasTrae, hasCursor, hasVscode, hasIdea] = await Promise.all([
|
|
66
|
+
exists(path.join(root, '.trae')),
|
|
67
|
+
exists(path.join(root, '.cursor')),
|
|
68
|
+
exists(path.join(root, '.vscode')),
|
|
69
|
+
exists(path.join(root, '.idea')),
|
|
70
|
+
])
|
|
71
|
+
|
|
72
|
+
// If a directory artifact exists, add it to the detection list.
|
|
73
|
+
// This allows us to surface multiple options (e.g. if you are in Cursor but also have a .vscode folder).
|
|
74
|
+
if (hasTrae && !detected.has('Trae')) {
|
|
75
|
+
detected.set('Trae', { ide: 'trae', supported: true })
|
|
60
76
|
}
|
|
61
|
-
if (
|
|
62
|
-
detected.set('Cursor', { ide: '
|
|
77
|
+
if (hasCursor && !detected.has('Cursor')) {
|
|
78
|
+
detected.set('Cursor', { ide: 'cursor', supported: true })
|
|
63
79
|
}
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
detected.set('vscode', { ide: SUPPORTED_IDE, supported: true })
|
|
69
|
-
}
|
|
80
|
+
|
|
81
|
+
// Only add vscode based on .vscode directory if it wasn't already added.
|
|
82
|
+
if (hasVscode && !detected.has('vscode')) {
|
|
83
|
+
detected.set('vscode', { ide: SUPPORTED_IDE, supported: true })
|
|
70
84
|
}
|
|
71
|
-
|
|
85
|
+
|
|
86
|
+
if (hasIdea && !detected.has('JetBrains IDE')) {
|
|
72
87
|
detected.set('JetBrains IDE', { ide: 'JetBrains IDE', supported: false })
|
|
73
88
|
}
|
|
74
89
|
|
|
90
|
+
// Fallback: If we still don't have ANY IDE detected, but we are in a terminal that identifies as 'vscode'
|
|
91
|
+
// (which could be Cursor without its specific env vars, though rare), we fallback to 'vscode'
|
|
92
|
+
if (detected.size === 0 && process.env.TERM_PROGRAM === 'vscode') {
|
|
93
|
+
detected.set('vscode', { ide: SUPPORTED_IDE, supported: true })
|
|
94
|
+
}
|
|
95
|
+
|
|
75
96
|
return {
|
|
76
97
|
detected: Array.from(detected.values()),
|
|
77
98
|
}
|
|
@@ -18,9 +18,16 @@ export async function detectPackageManager(root: string): Promise<PackageManager
|
|
|
18
18
|
['package-lock.json', 'npm'],
|
|
19
19
|
]
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
21
|
+
const results = await Promise.all(
|
|
22
|
+
checks.map(async ([file, pm]) => {
|
|
23
|
+
const isExist = await exists(path.join(root, file))
|
|
24
|
+
return { isExist, pm }
|
|
25
|
+
}),
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
for (const result of results) {
|
|
29
|
+
if (result.isExist) {
|
|
30
|
+
return result.pm
|
|
24
31
|
}
|
|
25
32
|
}
|
|
26
33
|
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// src/detect/provider.ts — AI Provider detection (v1)
|
|
3
|
+
//
|
|
4
|
+
// Detects installed AI providers via PATH (CLI) or IDE extensions (Extension).
|
|
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 { Provider, ProviderMode } from '@inspecto-dev/types'
|
|
10
|
+
|
|
11
|
+
export interface ProviderDetection {
|
|
12
|
+
id: Provider
|
|
13
|
+
label: string
|
|
14
|
+
supported: boolean
|
|
15
|
+
providerModes: Array<'cli' | 'extension'>
|
|
16
|
+
// The primary mode that will be written to settings if selected
|
|
17
|
+
preferredMode: 'cli' | 'extension'
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const KNOWN_CLI_TOOLS: { id: Provider; 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: Provider; extId: string; label: string; supported: boolean }[] = [
|
|
28
|
+
{ id: 'claude-code', extId: 'anthropic.claude-code', label: 'Claude Code', supported: true },
|
|
29
|
+
{ id: '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 ProviderProbeResult {
|
|
35
|
+
detected: ProviderDetection[]
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Detect all installed AI tools by checking PATH binaries and IDE extensions.
|
|
40
|
+
*/
|
|
41
|
+
export async function detectProviders(root: string): Promise<ProviderProbeResult> {
|
|
42
|
+
// Use a map to merge duplicate CLI/Plugin detections for the same AI tool
|
|
43
|
+
const detectedMap = new Map<Provider, ProviderDetection>()
|
|
44
|
+
|
|
45
|
+
// 1. Detect CLI tools concurrently
|
|
46
|
+
const cliChecks = KNOWN_CLI_TOOLS.map(async tool => {
|
|
47
|
+
if (await which(tool.bin)) {
|
|
48
|
+
detectedMap.set(tool.id, {
|
|
49
|
+
id: tool.id,
|
|
50
|
+
label: tool.label,
|
|
51
|
+
supported: tool.supported,
|
|
52
|
+
providerModes: ['cli'],
|
|
53
|
+
preferredMode: 'cli',
|
|
54
|
+
})
|
|
55
|
+
}
|
|
56
|
+
})
|
|
57
|
+
await Promise.all(cliChecks)
|
|
58
|
+
|
|
59
|
+
// 2. Detect IDE plugins (VS Code extensions)
|
|
60
|
+
// Check the local workspace .vscode/extensions.json first (recommendations)
|
|
61
|
+
const extensionsJsonPath = path.join(root, '.vscode', 'extensions.json')
|
|
62
|
+
let recommendedExts: string[] = []
|
|
63
|
+
if (await exists(extensionsJsonPath)) {
|
|
64
|
+
try {
|
|
65
|
+
const extData = await readJSON<{ recommendations?: string[] }>(extensionsJsonPath)
|
|
66
|
+
if (extData && Array.isArray(extData.recommendations)) {
|
|
67
|
+
recommendedExts = extData.recommendations.map(e => e.toLowerCase())
|
|
68
|
+
}
|
|
69
|
+
} catch {
|
|
70
|
+
// ignore JSON parse errors here
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Check user's global VS Code extensions folder once
|
|
75
|
+
const homeDir = process.env.HOME || process.env.USERPROFILE || ''
|
|
76
|
+
const globalExtDir = path.join(homeDir, '.vscode', 'extensions')
|
|
77
|
+
const globalExtExists = await exists(globalExtDir)
|
|
78
|
+
let installedExtensionFolders: string[] = []
|
|
79
|
+
|
|
80
|
+
if (globalExtExists) {
|
|
81
|
+
try {
|
|
82
|
+
const { readdir } = await import('node:fs/promises')
|
|
83
|
+
installedExtensionFolders = await readdir(globalExtDir)
|
|
84
|
+
|
|
85
|
+
// Filter out obsolete extensions
|
|
86
|
+
const obsoletePath = path.join(globalExtDir, '.obsolete')
|
|
87
|
+
if (await exists(obsoletePath)) {
|
|
88
|
+
try {
|
|
89
|
+
const obsoleteData = await readJSON<Record<string, boolean>>(obsoletePath)
|
|
90
|
+
if (obsoleteData) {
|
|
91
|
+
const obsoleteKeys = Object.keys(obsoleteData)
|
|
92
|
+
installedExtensionFolders = installedExtensionFolders.filter(folder => {
|
|
93
|
+
// The folder name usually contains the version, e.g., 'github.copilot-1.2.3'
|
|
94
|
+
// but the obsolete key might just be the exact folder name
|
|
95
|
+
return !obsoleteKeys.includes(folder)
|
|
96
|
+
})
|
|
97
|
+
}
|
|
98
|
+
} catch {
|
|
99
|
+
// Ignore parse errors for .obsolete
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
} catch {
|
|
103
|
+
// Fallback or ignore
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Check all IDE plugins
|
|
108
|
+
for (const plugin of KNOWN_IDE_PLUGINS) {
|
|
109
|
+
let isInstalled = false
|
|
110
|
+
|
|
111
|
+
// Check if it's explicitly recommended in the workspace
|
|
112
|
+
if (recommendedExts.includes(plugin.extId.toLowerCase())) {
|
|
113
|
+
isInstalled = true
|
|
114
|
+
}
|
|
115
|
+
// Otherwise check our pre-fetched global extensions list
|
|
116
|
+
else if (
|
|
117
|
+
installedExtensionFolders.some(f => {
|
|
118
|
+
const lower = f.toLowerCase()
|
|
119
|
+
return (
|
|
120
|
+
lower === plugin.extId.toLowerCase() || lower.startsWith(plugin.extId.toLowerCase() + '-')
|
|
121
|
+
)
|
|
122
|
+
})
|
|
123
|
+
) {
|
|
124
|
+
// NOTE: We used to just check if the folder exists, but when a VS Code extension
|
|
125
|
+
// is uninstalled, the folder is not immediately removed. Instead, VS Code creates
|
|
126
|
+
// an `.obsolete` file in the `.vscode/extensions` directory containing the keys of
|
|
127
|
+
// uninstalled extensions, or it renames the extension folder.
|
|
128
|
+
isInstalled = true
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (isInstalled) {
|
|
132
|
+
// If we already detected the CLI version of this tool, we append 'extension' to the modes
|
|
133
|
+
// and set the preferredMode to 'extension' since extension integration is generally more seamless.
|
|
134
|
+
const existing = detectedMap.get(plugin.id)
|
|
135
|
+
if (existing) {
|
|
136
|
+
existing.providerModes.push('extension')
|
|
137
|
+
existing.preferredMode = 'extension'
|
|
138
|
+
} else {
|
|
139
|
+
detectedMap.set(plugin.id, {
|
|
140
|
+
id: plugin.id,
|
|
141
|
+
label: plugin.label,
|
|
142
|
+
supported: plugin.supported,
|
|
143
|
+
providerModes: ['extension'],
|
|
144
|
+
preferredMode: 'extension',
|
|
145
|
+
})
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return { detected: Array.from(detectedMap.values()) }
|
|
151
|
+
}
|