@inspecto-dev/cli 0.3.0 → 0.3.2

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.
@@ -1,4 +1,8 @@
1
- import { resolveOnboardingSession, applyResolvedOnboardingSession, buildDeferredOnboardResult } from '../onboarding/session.js'
1
+ import {
2
+ resolveOnboardingSession,
3
+ applyResolvedOnboardingSession,
4
+ buildDeferredOnboardResult,
5
+ } from '../onboarding/session.js'
2
6
  import { log } from '../utils/logger.js'
3
7
  import { writeCommandOutput } from '../utils/output.js'
4
8
  import type { OnboardCommandResult } from '../types.js'
@@ -13,6 +17,23 @@ export interface OnboardCommandOptions {
13
17
  noExtension?: boolean
14
18
  }
15
19
 
20
+ function printManualExtensionGuidance(result: OnboardCommandResult): void {
21
+ if (!result.ideExtension?.required || !result.ideExtension.manualRequired) {
22
+ return
23
+ }
24
+
25
+ log.warn('Complete the IDE extension install before verification.')
26
+ if (result.ideExtension.installCommand) {
27
+ log.hint(result.ideExtension.installCommand)
28
+ }
29
+ if (result.ideExtension.marketplaceUrl) {
30
+ log.hint(result.ideExtension.marketplaceUrl)
31
+ }
32
+ if (result.ideExtension.openVsxUrl) {
33
+ log.hint(result.ideExtension.openVsxUrl)
34
+ }
35
+ }
36
+
16
37
  function printOnboardResult(result: OnboardCommandResult): void {
17
38
  log.header('Inspecto Onboard')
18
39
  log.info(`Status: ${result.status}`)
@@ -28,6 +49,8 @@ function printOnboardResult(result: OnboardCommandResult): void {
28
49
  log.warn(result.confirmation.question)
29
50
  }
30
51
 
52
+ printManualExtensionGuidance(result)
53
+
31
54
  const extensionReady =
32
55
  !result.ideExtension?.required ||
33
56
  (result.ideExtension.installed && !result.ideExtension.manualRequired)
package/src/index.ts CHANGED
@@ -2,6 +2,7 @@ export { apply } from './commands/apply.js'
2
2
  export { detect } from './commands/detect.js'
3
3
  export { init } from './commands/init.js'
4
4
  export { collectDoctorResult, doctor } from './commands/doctor.js'
5
+ export { integrationDoctor } from './commands/integration-doctor.js'
5
6
  export { onboard } from './commands/onboard.js'
6
7
  export { plan } from './commands/plan.js'
7
8
  export { teardown } from './commands/teardown.js'
@@ -11,38 +11,27 @@
11
11
 
12
12
  import { which, run, shell } from '../utils/exec.js'
13
13
  import { exists } from '../utils/fs.js'
14
+ import {
15
+ getHostIdeBinaryCandidates,
16
+ getHostIdeBinaryName,
17
+ getHostIdeLabel,
18
+ isSupportedHostIde,
19
+ type SupportedHostIde,
20
+ } from '../integrations/capabilities.js'
14
21
  import { log } from '../utils/logger.js'
15
22
  import type { Mutation } from '../types.js'
16
23
 
17
24
  const EXTENSION_ID = 'inspecto.inspecto'
18
25
 
19
- /** Known VS Code binary locations by platform */
20
- const VSCODE_PATHS: Record<string, string[]> = {
21
- darwin: [
22
- '/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code',
23
- '/Applications/Visual Studio Code - Insiders.app/Contents/Resources/app/bin/code-insiders',
24
- `${process.env.HOME}/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code`,
25
- ],
26
- linux: ['/usr/bin/code', '/usr/share/code/bin/code', '/snap/bin/code', '/usr/bin/code-insiders'],
27
- win32: [
28
- `${process.env.LOCALAPPDATA}\\Programs\\Microsoft VS Code\\bin\\code.cmd`,
29
- `${process.env.LOCALAPPDATA}\\Programs\\Microsoft VS Code Insiders\\bin\\code-insiders.cmd`,
30
- `${process.env.PROGRAMFILES}\\Microsoft VS Code\\bin\\code.cmd`,
31
- ],
32
- }
33
-
34
26
  /** Try to find the VS Code binary at known filesystem paths */
35
27
  async function findVSCodeBinary(): Promise<string | null> {
36
- const platform = process.platform
37
- const candidates = VSCODE_PATHS[platform] || []
38
-
28
+ const candidates = getHostIdeBinaryCandidates('vscode')
39
29
  for (const candidate of candidates) {
40
30
  if (await exists(candidate)) {
41
31
  return candidate
42
32
  }
43
33
  }
44
34
 
45
- // Also check for code-insiders in PATH
46
35
  if (await which('code-insiders')) {
47
36
  return 'code-insiders'
48
37
  }
@@ -50,8 +39,88 @@ async function findVSCodeBinary(): Promise<string | null> {
50
39
  return null
51
40
  }
52
41
 
42
+ async function findIdeBinary(ide: SupportedHostIde): Promise<string | null> {
43
+ const binaryName = getHostIdeBinaryName(ide)
44
+ if (binaryName && (await which(binaryName))) {
45
+ return binaryName
46
+ }
47
+
48
+ if (ide === 'vscode') {
49
+ return findVSCodeBinary()
50
+ }
51
+
52
+ const candidates = getHostIdeBinaryCandidates(ide)
53
+ for (const candidate of candidates) {
54
+ if (await exists(candidate)) {
55
+ return candidate
56
+ }
57
+ }
58
+
59
+ return null
60
+ }
61
+
62
+ async function installAlternativeIdeExtension(
63
+ binaryPath: string,
64
+ ideLabel: string,
65
+ extensionRef: string,
66
+ quiet: boolean,
67
+ ): Promise<Mutation | null> {
68
+ try {
69
+ const { stdout } = await run(binaryPath, ['--list-extensions'])
70
+ if (extensionRef === EXTENSION_ID && stdout.toLowerCase().includes(EXTENSION_ID)) {
71
+ if (!quiet) {
72
+ log.success(`${ideLabel} extension already installed`)
73
+ }
74
+ return { type: 'extension_installed', id: EXTENSION_ID, description: 'already_installed' }
75
+ }
76
+ } catch {
77
+ // Continue to install attempt if listing fails.
78
+ }
79
+
80
+ try {
81
+ await run(binaryPath, ['--install-extension', extensionRef, '--force'])
82
+ if (!quiet) {
83
+ log.success(`${ideLabel} extension installed via CLI`)
84
+ }
85
+ return { type: 'extension_installed', id: extensionRef, description: 'installed_via_cli' }
86
+ } catch {
87
+ try {
88
+ const { stdout } = await run(binaryPath, ['--list-extensions'])
89
+ if (extensionRef === EXTENSION_ID && stdout.toLowerCase().includes(EXTENSION_ID)) {
90
+ if (!quiet) {
91
+ log.success(`${ideLabel} extension already installed`)
92
+ }
93
+ return { type: 'extension_installed', id: EXTENSION_ID, description: 'already_installed' }
94
+ }
95
+ } catch {
96
+ // Fall through to manual guidance.
97
+ }
98
+
99
+ return null
100
+ }
101
+ }
102
+
103
+ export async function resolveHostIdeBinary(ide: string): Promise<string | null> {
104
+ if (!isSupportedHostIde(ide)) return null
105
+ return findIdeBinary(ide)
106
+ }
107
+
108
+ export async function openIdeWorkspace(ide: string, cwd: string): Promise<boolean> {
109
+ const binaryPath = await resolveHostIdeBinary(ide)
110
+ if (!binaryPath) {
111
+ return false
112
+ }
113
+
114
+ try {
115
+ await run(binaryPath, ['--new-window', cwd])
116
+ return true
117
+ } catch {
118
+ return false
119
+ }
120
+ }
121
+
53
122
  /** Try to open a URI using the system default handler */
54
- async function tryOpenURI(uri: string): Promise<boolean> {
123
+ export async function openUri(uri: string): Promise<boolean> {
55
124
  try {
56
125
  const platform = process.platform
57
126
  if (platform === 'win32') {
@@ -73,6 +142,7 @@ export async function installExtension(
73
142
  dryRun: boolean,
74
143
  ide?: string,
75
144
  quiet = false,
145
+ extensionRef = EXTENSION_ID,
76
146
  ): Promise<Mutation | null> {
77
147
  if (dryRun) {
78
148
  if (!quiet) {
@@ -91,7 +161,7 @@ export async function installExtension(
91
161
  if (!quiet) {
92
162
  log.success('VS Code extension installed via CLI')
93
163
  }
94
- return { type: 'extension_installed', id: EXTENSION_ID }
164
+ return { type: 'extension_installed', id: EXTENSION_ID, description: 'installed_via_cli' }
95
165
  } catch {
96
166
  // Fall through to next level
97
167
  }
@@ -108,7 +178,7 @@ export async function installExtension(
108
178
  'Tip: Add "code" to your PATH to help Inspecto detect other AI tools in the future',
109
179
  )
110
180
  }
111
- return { type: 'extension_installed', id: EXTENSION_ID }
181
+ return { type: 'extension_installed', id: EXTENSION_ID, description: 'installed_via_cli' }
112
182
  } catch {
113
183
  // Fall through to next level
114
184
  }
@@ -116,12 +186,17 @@ export async function installExtension(
116
186
 
117
187
  // Level 3: URI scheme
118
188
  const uri = `vscode:extension/${EXTENSION_ID}`
119
- if (await tryOpenURI(uri)) {
189
+ if (await openUri(uri)) {
120
190
  if (!quiet) {
121
191
  log.warn('Opened extension page in VS Code')
122
192
  log.hint('Please click "Install" in the opened VS Code window to complete setup.')
123
193
  }
124
- return { type: 'extension_installed', id: EXTENSION_ID, manual_action_required: true }
194
+ return {
195
+ type: 'extension_installed',
196
+ id: EXTENSION_ID,
197
+ description: 'opened_install_page',
198
+ manual_action_required: true,
199
+ }
125
200
  }
126
201
 
127
202
  // Level 4: Manual fallback
@@ -136,6 +211,51 @@ export async function installExtension(
136
211
  return null
137
212
  }
138
213
 
214
+ if (ide === 'cursor' && process.platform === 'darwin') {
215
+ const cursorPath = await findIdeBinary('cursor')
216
+ if (cursorPath) {
217
+ const result = await installAlternativeIdeExtension(
218
+ cursorPath,
219
+ getHostIdeLabel('cursor'),
220
+ extensionRef,
221
+ quiet,
222
+ )
223
+ if (result) {
224
+ return result
225
+ }
226
+ }
227
+ }
228
+
229
+ if (ide === 'trae' && process.platform === 'darwin') {
230
+ const traePath = await findIdeBinary('trae')
231
+ if (traePath) {
232
+ const result = await installAlternativeIdeExtension(
233
+ traePath,
234
+ getHostIdeLabel('trae'),
235
+ extensionRef,
236
+ quiet,
237
+ )
238
+ if (result) {
239
+ return result
240
+ }
241
+ }
242
+ }
243
+
244
+ if (ide === 'trae-cn' && process.platform === 'darwin') {
245
+ const traeCnPath = await findIdeBinary('trae-cn')
246
+ if (traeCnPath) {
247
+ const result = await installAlternativeIdeExtension(
248
+ traeCnPath,
249
+ getHostIdeLabel('trae-cn'),
250
+ extensionRef,
251
+ quiet,
252
+ )
253
+ if (result) {
254
+ return result
255
+ }
256
+ }
257
+ }
258
+
139
259
  // Other IDEs: Prompt to install via VSIX
140
260
  if (!quiet) {
141
261
  log.warn(`Could not auto-install extension for ${ide}`)
@@ -0,0 +1,131 @@
1
+ import path from 'node:path'
2
+ import {
3
+ DUAL_MODE_PROVIDER_CAPABILITIES,
4
+ HOST_IDE_IDS,
5
+ getHostIdeLabel,
6
+ isSupportedHostIde,
7
+ type DualModeProvider,
8
+ type SupportedHostIde,
9
+ } from '@inspecto-dev/types'
10
+
11
+ type Platform = 'darwin' | 'linux' | 'win32'
12
+
13
+ interface HostIdeCapability {
14
+ label: string
15
+ artifactDir: string
16
+ extensionDir: string
17
+ binaryName?: string
18
+ binaryPaths?: Partial<Record<Platform, string[]>>
19
+ }
20
+
21
+ export const HOST_IDE_CAPABILITIES: Record<SupportedHostIde, HostIdeCapability> = {
22
+ vscode: {
23
+ label: 'VS Code',
24
+ artifactDir: '.vscode',
25
+ extensionDir: '.vscode/extensions',
26
+ binaryName: 'code',
27
+ binaryPaths: {
28
+ darwin: [
29
+ '/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code',
30
+ '/Applications/Visual Studio Code - Insiders.app/Contents/Resources/app/bin/code-insiders',
31
+ `${process.env.HOME}/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code`,
32
+ ],
33
+ linux: [
34
+ '/usr/bin/code',
35
+ '/usr/share/code/bin/code',
36
+ '/snap/bin/code',
37
+ '/usr/bin/code-insiders',
38
+ ],
39
+ win32: [
40
+ `${process.env.LOCALAPPDATA}\\Programs\\Microsoft VS Code\\bin\\code.cmd`,
41
+ `${process.env.LOCALAPPDATA}\\Programs\\Microsoft VS Code Insiders\\bin\\code-insiders.cmd`,
42
+ `${process.env.PROGRAMFILES}\\Microsoft VS Code\\bin\\code.cmd`,
43
+ ],
44
+ },
45
+ },
46
+ cursor: {
47
+ label: 'Cursor',
48
+ artifactDir: '.cursor',
49
+ extensionDir: '.cursor/extensions',
50
+ binaryName: 'cursor',
51
+ binaryPaths: {
52
+ darwin: [
53
+ '/Applications/Cursor.app/Contents/Resources/app/bin/cursor',
54
+ `${process.env.HOME}/Applications/Cursor.app/Contents/Resources/app/bin/cursor`,
55
+ ],
56
+ linux: ['/usr/bin/cursor', '/opt/Cursor/resources/app/bin/cursor'],
57
+ win32: [
58
+ `${process.env.LOCALAPPDATA}\\Programs\\Cursor\\resources\\app\\bin\\cursor.cmd`,
59
+ `${process.env.PROGRAMFILES}\\Cursor\\resources\\app\\bin\\cursor.cmd`,
60
+ ],
61
+ },
62
+ },
63
+ trae: {
64
+ label: 'Trae',
65
+ artifactDir: '.trae',
66
+ extensionDir: '.trae/extensions',
67
+ binaryName: 'trae',
68
+ binaryPaths: {
69
+ darwin: [
70
+ '/Applications/Trae.app/Contents/Resources/app/bin/trae',
71
+ `${process.env.HOME}/Applications/Trae.app/Contents/Resources/app/bin/trae`,
72
+ ],
73
+ linux: ['/usr/bin/trae', '/opt/Trae/resources/app/bin/trae'],
74
+ win32: [
75
+ `${process.env.LOCALAPPDATA}\\Programs\\Trae\\resources\\app\\bin\\trae.cmd`,
76
+ `${process.env.PROGRAMFILES}\\Trae\\resources\\app\\bin\\trae.cmd`,
77
+ ],
78
+ },
79
+ },
80
+ 'trae-cn': {
81
+ label: 'Trae CN',
82
+ artifactDir: '.trae-cn',
83
+ extensionDir: '.trae-cn/extensions',
84
+ binaryName: 'trae-cn',
85
+ binaryPaths: {
86
+ darwin: [
87
+ '/Applications/Trae CN.app/Contents/Resources/app/bin/trae-cn',
88
+ `${process.env.HOME}/Applications/Trae CN.app/Contents/Resources/app/bin/trae-cn`,
89
+ ],
90
+ linux: ['/usr/bin/trae-cn', '/opt/Trae CN/resources/app/bin/trae-cn'],
91
+ win32: [
92
+ `${process.env.LOCALAPPDATA}\\Programs\\Trae CN\\resources\\app\\bin\\trae-cn.cmd`,
93
+ `${process.env.PROGRAMFILES}\\Trae CN\\resources\\app\\bin\\trae-cn.cmd`,
94
+ ],
95
+ },
96
+ },
97
+ }
98
+
99
+ export { HOST_IDE_IDS, getHostIdeLabel, isSupportedHostIde }
100
+ export type { SupportedHostIde }
101
+
102
+ export type DualModeAssistant = DualModeProvider
103
+
104
+ export function getHostIdeResolutionSourceLabel(source: string): string {
105
+ if (source === 'explicit') return 'from --host-ide'
106
+ if (source === 'config') return 'from .inspecto settings'
107
+ if (source === 'env') return 'from IDE terminal environment'
108
+ if (source === 'artifact') return 'from project files'
109
+ return source
110
+ }
111
+
112
+ export function getHostIdeArtifactPath(ide: SupportedHostIde, cwd: string): string {
113
+ return path.join(cwd, HOST_IDE_CAPABILITIES[ide].artifactDir)
114
+ }
115
+
116
+ export function getHostIdeExtensionDir(ide: SupportedHostIde, homeDir: string): string {
117
+ return path.join(homeDir, HOST_IDE_CAPABILITIES[ide].extensionDir)
118
+ }
119
+
120
+ export function getHostIdeBinaryName(ide: SupportedHostIde): string | null {
121
+ return HOST_IDE_CAPABILITIES[ide].binaryName ?? null
122
+ }
123
+
124
+ export function getHostIdeBinaryCandidates(ide: SupportedHostIde): string[] {
125
+ const platform = process.platform as Platform
126
+ return HOST_IDE_CAPABILITIES[ide].binaryPaths?.[platform] ?? []
127
+ }
128
+
129
+ export function getDualModeAssistantCapability(assistant: string) {
130
+ return DUAL_MODE_PROVIDER_CAPABILITIES[assistant as DualModeProvider]
131
+ }
@@ -197,7 +197,8 @@ function buildConfirmation(
197
197
  const reasons: string[] = []
198
198
  if (session.targetWasHeuristic) reasons.push('a monorepo target was preselected')
199
199
  if (summary.manualFollowUp.length > 0) reasons.push('manual follow-up will remain after setup')
200
- if (options.noExtension || options.skipInstall) reasons.push('non-core setup steps are being skipped')
200
+ if (options.noExtension || options.skipInstall)
201
+ reasons.push('non-core setup steps are being skipped')
201
202
  if (session.supportedIdeCount > 1 || session.supportedProviderCount > 1) {
202
203
  reasons.push('multiple IDE or provider choices are still relevant')
203
204
  }
@@ -219,7 +220,9 @@ function buildPreApplyResult(
219
220
  session: ResolvedOnboardingSession,
220
221
  ): OnboardCommandResult {
221
222
  const diagnostics: OnboardingDiagnostics | undefined =
222
- session.summary.risks.length > 0 || session.summary.manualFollowUp.length > 0 || session.plan.blockers.length > 0
223
+ session.summary.risks.length > 0 ||
224
+ session.summary.manualFollowUp.length > 0 ||
225
+ session.plan.blockers.length > 0
223
226
  ? {
224
227
  warnings: session.summary.risks,
225
228
  errors: session.plan.blockers.map(item => item.message),
@@ -248,7 +251,9 @@ function buildExecutionResult(
248
251
  ): OnboardingExecutionResult {
249
252
  return {
250
253
  changedFiles: Array.from(
251
- new Set(applyResult.mutations.map(item => item.path).filter((value): value is string => !!value)),
254
+ new Set(
255
+ applyResult.mutations.map(item => item.path).filter((value): value is string => !!value),
256
+ ),
252
257
  ),
253
258
  installedDependencies: applyResult.mutations
254
259
  .map(item => item.name)
@@ -276,7 +281,11 @@ function buildExecutionDiagnostics(
276
281
  warnings.push('IDE extension installation still needs manual completion.')
277
282
  }
278
283
 
279
- if (warnings.length === 0 && errors.length === 0 && applyResult.postInstall.nextSteps.length === 0) {
284
+ if (
285
+ warnings.length === 0 &&
286
+ errors.length === 0 &&
287
+ applyResult.postInstall.nextSteps.length === 0
288
+ ) {
280
289
  return undefined
281
290
  }
282
291
 
@@ -0,0 +1,3 @@
1
+ export function exitProcess(code: number): never {
2
+ process.exit(code)
3
+ }
@@ -121,7 +121,12 @@ describe('apply onboarding flow', () => {
121
121
  'pnpm add -D @inspecto-dev/plugin @inspecto-dev/core',
122
122
  '/repo',
123
123
  )
124
- expect(astInjectorUtils.injectPlugin).toHaveBeenCalledWith('/repo', supportedBuild, false, false)
124
+ expect(astInjectorUtils.injectPlugin).toHaveBeenCalledWith(
125
+ '/repo',
126
+ supportedBuild,
127
+ false,
128
+ false,
129
+ )
125
130
  expect(fsUtils.writeJSON).toHaveBeenCalledWith('/repo/.inspecto/settings.local.json', {
126
131
  ide: 'vscode',
127
132
  'provider.default': 'codex.extension',
@@ -0,0 +1,205 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
2
+
3
+ const runMock = vi.fn()
4
+ const shellMock = vi.fn()
5
+ const whichMock = vi.fn()
6
+ const existsMock = vi.fn()
7
+ const logMock = {
8
+ dryRun: vi.fn(),
9
+ success: vi.fn(),
10
+ info: vi.fn(),
11
+ warn: vi.fn(),
12
+ hint: vi.fn(),
13
+ }
14
+
15
+ vi.mock('../src/utils/exec.js', () => ({
16
+ run: runMock,
17
+ shell: shellMock,
18
+ which: whichMock,
19
+ }))
20
+
21
+ vi.mock('../src/utils/fs.js', () => ({
22
+ exists: existsMock,
23
+ }))
24
+
25
+ vi.mock('../src/utils/logger.js', () => ({
26
+ log: logMock,
27
+ }))
28
+
29
+ describe('installExtension', () => {
30
+ beforeEach(() => {
31
+ vi.resetAllMocks()
32
+ shellMock.mockResolvedValue({ stdout: '', stderr: '' })
33
+ existsMock.mockResolvedValue(false)
34
+ })
35
+
36
+ it('treats an already-installed Cursor extension as success instead of showing manual install hints', async () => {
37
+ vi.stubEnv('TERM_PROGRAM', '')
38
+ vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin')
39
+ whichMock.mockImplementation(async bin => bin === 'cursor')
40
+ runMock.mockImplementation(async (_command, args: string[]) => {
41
+ if (args[0] === '--list-extensions') {
42
+ return { stdout: 'inspecto.inspecto\n', stderr: '' }
43
+ }
44
+ throw new Error('install should not run when already installed')
45
+ })
46
+
47
+ const { installExtension } = await import('../src/inject/extension.js')
48
+
49
+ await expect(installExtension(false, 'cursor')).resolves.toMatchObject({
50
+ type: 'extension_installed',
51
+ id: 'inspecto.inspecto',
52
+ })
53
+
54
+ expect(logMock.success).toHaveBeenCalledWith('Cursor extension already installed')
55
+ expect(logMock.warn).not.toHaveBeenCalledWith('Could not auto-install extension for cursor')
56
+ expect(runMock).toHaveBeenCalledWith('cursor', ['--list-extensions'])
57
+ })
58
+
59
+ it('installs the Trae CN extension via the app binary on macOS when available', async () => {
60
+ vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin')
61
+ whichMock.mockResolvedValue(false)
62
+ existsMock.mockImplementation(async filePath => {
63
+ return filePath === '/Applications/Trae CN.app/Contents/Resources/app/bin/trae-cn'
64
+ })
65
+ runMock.mockImplementation(async (_command, args: string[]) => {
66
+ if (args[0] === '--list-extensions') {
67
+ return { stdout: '', stderr: '' }
68
+ }
69
+ if (args[0] === '--install-extension') {
70
+ return { stdout: '', stderr: '' }
71
+ }
72
+ throw new Error(`unexpected args: ${args.join(' ')}`)
73
+ })
74
+
75
+ const { installExtension } = await import('../src/inject/extension.js')
76
+
77
+ await expect(installExtension(false, 'trae-cn')).resolves.toMatchObject({
78
+ type: 'extension_installed',
79
+ id: 'inspecto.inspecto',
80
+ })
81
+
82
+ expect(runMock).toHaveBeenCalledWith(
83
+ '/Applications/Trae CN.app/Contents/Resources/app/bin/trae-cn',
84
+ ['--install-extension', 'inspecto.inspecto', '--force'],
85
+ )
86
+ expect(logMock.success).toHaveBeenCalledWith('Trae CN extension installed via CLI')
87
+ })
88
+
89
+ it('installs the Trae extension via PATH when available', async () => {
90
+ vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin')
91
+ whichMock.mockImplementation(async bin => bin === 'trae')
92
+ runMock.mockImplementation(async (_command, args: string[]) => {
93
+ if (args[0] === '--list-extensions') {
94
+ return { stdout: '', stderr: '' }
95
+ }
96
+ if (args[0] === '--install-extension') {
97
+ return { stdout: '', stderr: '' }
98
+ }
99
+ throw new Error(`unexpected args: ${args.join(' ')}`)
100
+ })
101
+
102
+ const { installExtension } = await import('../src/inject/extension.js')
103
+
104
+ await expect(installExtension(false, 'trae')).resolves.toMatchObject({
105
+ type: 'extension_installed',
106
+ id: 'inspecto.inspecto',
107
+ description: 'installed_via_cli',
108
+ })
109
+
110
+ expect(runMock).toHaveBeenCalledWith('trae', [
111
+ '--install-extension',
112
+ 'inspecto.inspecto',
113
+ '--force',
114
+ ])
115
+ expect(logMock.success).toHaveBeenCalledWith('Trae extension installed via CLI')
116
+ })
117
+
118
+ it('installs a VSIX path in Trae CN when provided', async () => {
119
+ vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin')
120
+ whichMock.mockImplementation(async bin => bin === 'trae-cn')
121
+ runMock.mockImplementation(async (_command, args: string[]) => {
122
+ if (args[0] === '--list-extensions') {
123
+ return { stdout: '', stderr: '' }
124
+ }
125
+ if (args[0] === '--install-extension') {
126
+ return { stdout: '', stderr: '' }
127
+ }
128
+ throw new Error(`unexpected args: ${args.join(' ')}`)
129
+ })
130
+
131
+ const { installExtension } = await import('../src/inject/extension.js')
132
+
133
+ await expect(
134
+ installExtension(false, 'trae-cn', true, '/tmp/inspecto.vsix'),
135
+ ).resolves.toMatchObject({
136
+ type: 'extension_installed',
137
+ id: '/tmp/inspecto.vsix',
138
+ description: 'installed_via_cli',
139
+ })
140
+
141
+ expect(runMock).toHaveBeenCalledWith('trae-cn', [
142
+ '--install-extension',
143
+ '/tmp/inspecto.vsix',
144
+ '--force',
145
+ ])
146
+ })
147
+
148
+ it('treats Trae CN as already installed when install fails but a follow-up extension check succeeds', async () => {
149
+ vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin')
150
+ whichMock.mockResolvedValue(false)
151
+
152
+ let listCalls = 0
153
+ existsMock.mockImplementation(async filePath => {
154
+ return filePath === '/Applications/Trae CN.app/Contents/Resources/app/bin/trae-cn'
155
+ })
156
+ runMock.mockImplementation(async (_command, args: string[]) => {
157
+ if (args[0] === '--list-extensions') {
158
+ listCalls += 1
159
+ return {
160
+ stdout: listCalls === 1 ? '' : 'inspecto.inspecto\n',
161
+ stderr: '',
162
+ }
163
+ }
164
+ if (args[0] === '--install-extension') {
165
+ throw new Error('network failed')
166
+ }
167
+ throw new Error(`unexpected args: ${args.join(' ')}`)
168
+ })
169
+
170
+ const { installExtension } = await import('../src/inject/extension.js')
171
+
172
+ await expect(installExtension(false, 'trae-cn')).resolves.toMatchObject({
173
+ type: 'extension_installed',
174
+ id: 'inspecto.inspecto',
175
+ })
176
+
177
+ expect(logMock.success).toHaveBeenCalledWith('Trae CN extension already installed')
178
+ expect(logMock.warn).not.toHaveBeenCalledWith('Could not auto-install extension for trae-cn')
179
+ })
180
+ })
181
+
182
+ describe('openIdeWorkspace', () => {
183
+ beforeEach(() => {
184
+ vi.resetAllMocks()
185
+ existsMock.mockResolvedValue(false)
186
+ })
187
+
188
+ it('opens Trae CN in a new window for the requested workspace', async () => {
189
+ vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin')
190
+ whichMock.mockResolvedValue(false)
191
+ existsMock.mockImplementation(async filePath => {
192
+ return filePath === '/Applications/Trae CN.app/Contents/Resources/app/bin/trae-cn'
193
+ })
194
+ runMock.mockResolvedValue({ stdout: '', stderr: '' })
195
+
196
+ const { openIdeWorkspace } = await import('../src/inject/extension.js')
197
+
198
+ await expect(openIdeWorkspace('trae-cn', '/repo/app')).resolves.toBe(true)
199
+
200
+ expect(runMock).toHaveBeenCalledWith(
201
+ '/Applications/Trae CN.app/Contents/Resources/app/bin/trae-cn',
202
+ ['--new-window', '/repo/app'],
203
+ )
204
+ })
205
+ })