@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.
@@ -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
- for (const pattern of SUPPORTED_PATTERNS) {
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
- let isLegacyRspack = false
80
- if (pattern.tool === 'rspack') {
81
- const version = allDeps['@rspack/cli'] || allDeps['@rspack/core']
82
- if (
83
- version &&
84
- (version.includes('0.3.') || version.includes('0.2.') || version.includes('0.1.'))
85
- ) {
86
- isLegacyRspack = true
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
- supported.push({
91
- tool: pattern.tool,
92
- configPath: file,
93
- label: `${pattern.label} (${file})${isLegacyRspack ? ' [Legacy]' : ''}`,
94
- isLegacyRspack,
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
- for (const meta of UNSUPPORTED_META) {
102
- if (!(meta.dep in allDeps)) continue
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
- unsupported.push(meta.name)
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 }
@@ -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.dependencies,
47
- ...pkg.devDependencies,
86
+ ...(pkg?.dependencies || {}),
87
+ ...(pkg?.devDependencies || {}),
88
+ ...(pkg?.peerDependencies || {}),
48
89
  }
49
90
 
50
- const supported: Framework[] = []
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 (deps.some(dep => dep in allDeps)) {
53
- supported.push(framework)
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
- const unsupported: { name: string; dep: string }[] = []
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 { supported, unsupported }
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 (v1: VS Code only)
2
+ // src/detect/ide.ts — IDE detection
3
3
  //
4
- // Detects ALL known IDEs, but only VS Code is fully supported.
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: 'Cursor', supported: false })
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: 'Trae', supported: false })
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
- // VS Code
50
- // Must ensure we haven't already flagged it as Trae/Cursor since they share this variable
51
- if (process.env.TERM_PROGRAM === 'vscode') {
52
- if (!detected.has('Trae') && !detected.has('Cursor')) {
53
- detected.set('vscode', { ide: SUPPORTED_IDE, supported: true })
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
- if (await exists(path.join(root, '.trae'))) {
59
- detected.set('Trae', { ide: 'Trae', supported: false })
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 (await exists(path.join(root, '.cursor'))) {
62
- detected.set('Cursor', { ide: 'Cursor', supported: false })
77
+ if (hasCursor && !detected.has('Cursor')) {
78
+ detected.set('Cursor', { ide: 'cursor', supported: true })
63
79
  }
64
- if (await exists(path.join(root, '.vscode'))) {
65
- // Only add vscode if it wasn't already caught by terminal,
66
- // or if the terminal is something else but .vscode exists.
67
- if (!detected.has('vscode')) {
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
- if (await exists(path.join(root, '.idea'))) {
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
- for (const [file, pm] of checks) {
22
- if (await exists(path.join(root, file))) {
23
- return pm
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
+ }