@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.
- package/.turbo/turbo-build.log +19 -0
- package/.turbo/turbo-test.log +15 -0
- package/CHANGELOG.md +14 -0
- package/LICENSE +21 -0
- package/README.md +78 -0
- package/TESTING.md +109 -0
- package/bin/inspecto.js +3 -0
- package/dist/bin.d.ts +2 -0
- package/dist/bin.js +83 -0
- package/dist/chunk-4RR7PTRN.js +1306 -0
- package/dist/index.d.ts +40 -0
- package/dist/index.js +10 -0
- package/package.json +38 -0
- package/src/bin.ts +89 -0
- package/src/commands/doctor.ts +185 -0
- package/src/commands/init.ts +447 -0
- package/src/commands/teardown.ts +124 -0
- package/src/detect/ai-tool.ts +127 -0
- package/src/detect/build-tool.ts +123 -0
- package/src/detect/framework.ts +65 -0
- package/src/detect/ide.ts +78 -0
- package/src/detect/package-manager.ts +56 -0
- package/src/index.ts +5 -0
- package/src/inject/ast-injector.ts +300 -0
- package/src/inject/extension.ts +140 -0
- package/src/inject/gitignore.ts +76 -0
- package/src/types.ts +48 -0
- package/src/utils/exec.ts +44 -0
- package/src/utils/fs.ts +69 -0
- package/src/utils/logger.ts +64 -0
- package/tests/framework.test.ts +65 -0
- package/tests/ide.test.ts +94 -0
- package/tsconfig.json +13 -0
- package/tsup.config.ts +10 -0
|
@@ -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
|
+
}
|