mirror of
http://10.0.2.1:3031/sauer/claude-code.git
synced 2026-06-30 11:16:58 +10:00
1495 lines
46 KiB
TypeScript
1495 lines
46 KiB
TypeScript
|
|
import type { Client } from '@modelcontextprotocol/sdk/client/index.js'
|
|||
|
|
import axios from 'axios'
|
|||
|
|
import { execa } from 'execa'
|
|||
|
|
import capitalize from 'lodash-es/capitalize.js'
|
|||
|
|
import memoize from 'lodash-es/memoize.js'
|
|||
|
|
import { createConnection } from 'net'
|
|||
|
|
import * as os from 'os'
|
|||
|
|
import { basename, join, sep as pathSeparator, resolve } from 'path'
|
|||
|
|
import { logEvent } from 'src/services/analytics/index.js'
|
|||
|
|
import { getIsScrollDraining, getOriginalCwd } from '../bootstrap/state.js'
|
|||
|
|
import { callIdeRpc } from '../services/mcp/client.js'
|
|||
|
|
import type {
|
|||
|
|
ConnectedMCPServer,
|
|||
|
|
MCPServerConnection,
|
|||
|
|
} from '../services/mcp/types.js'
|
|||
|
|
import { getGlobalConfig, saveGlobalConfig } from './config.js'
|
|||
|
|
import { env } from './env.js'
|
|||
|
|
import { getClaudeConfigHomeDir, isEnvTruthy } from './envUtils.js'
|
|||
|
|
import {
|
|||
|
|
execFileNoThrow,
|
|||
|
|
execFileNoThrowWithCwd,
|
|||
|
|
execSyncWithDefaults_DEPRECATED,
|
|||
|
|
} from './execFileNoThrow.js'
|
|||
|
|
import { getFsImplementation } from './fsOperations.js'
|
|||
|
|
import { getAncestorPidsAsync } from './genericProcessUtils.js'
|
|||
|
|
import { isJetBrainsPluginInstalledCached } from './jetbrains.js'
|
|||
|
|
import { logError } from './log.js'
|
|||
|
|
import { getPlatform } from './platform.js'
|
|||
|
|
import { lt } from './semver.js'
|
|||
|
|
|
|||
|
|
// Lazy: IdeOnboardingDialog.tsx pulls React/ink; only needed in interactive onboarding path
|
|||
|
|
/* eslint-disable @typescript-eslint/no-require-imports */
|
|||
|
|
const ideOnboardingDialog =
|
|||
|
|
(): typeof import('src/components/IdeOnboardingDialog.js') =>
|
|||
|
|
require('src/components/IdeOnboardingDialog.js')
|
|||
|
|
|
|||
|
|
import { createAbortController } from './abortController.js'
|
|||
|
|
import { logForDebugging } from './debug.js'
|
|||
|
|
import { envDynamic } from './envDynamic.js'
|
|||
|
|
import { errorMessage, isFsInaccessible } from './errors.js'
|
|||
|
|
/* eslint-enable @typescript-eslint/no-require-imports */
|
|||
|
|
import {
|
|||
|
|
checkWSLDistroMatch,
|
|||
|
|
WindowsToWSLConverter,
|
|||
|
|
} from './idePathConversion.js'
|
|||
|
|
import { sleep } from './sleep.js'
|
|||
|
|
import { jsonParse } from './slowOperations.js'
|
|||
|
|
|
|||
|
|
function isProcessRunning(pid: number): boolean {
|
|||
|
|
try {
|
|||
|
|
process.kill(pid, 0)
|
|||
|
|
return true
|
|||
|
|
} catch {
|
|||
|
|
return false
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Returns a function that lazily fetches our process's ancestor PID chain,
|
|||
|
|
// caching within the closure's lifetime. Callers should scope this to a
|
|||
|
|
// single detection pass — PIDs recycle and process trees change over time.
|
|||
|
|
function makeAncestorPidLookup(): () => Promise<Set<number>> {
|
|||
|
|
let promise: Promise<Set<number>> | null = null
|
|||
|
|
return () => {
|
|||
|
|
if (!promise) {
|
|||
|
|
promise = getAncestorPidsAsync(process.ppid, 10).then(
|
|||
|
|
pids => new Set(pids),
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
return promise
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
type LockfileJsonContent = {
|
|||
|
|
workspaceFolders?: string[]
|
|||
|
|
pid?: number
|
|||
|
|
ideName?: string
|
|||
|
|
transport?: 'ws' | 'sse'
|
|||
|
|
runningInWindows?: boolean
|
|||
|
|
authToken?: string
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
type IdeLockfileInfo = {
|
|||
|
|
workspaceFolders: string[]
|
|||
|
|
port: number
|
|||
|
|
pid?: number
|
|||
|
|
ideName?: string
|
|||
|
|
useWebSocket: boolean
|
|||
|
|
runningInWindows: boolean
|
|||
|
|
authToken?: string
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export type DetectedIDEInfo = {
|
|||
|
|
name: string
|
|||
|
|
port: number
|
|||
|
|
workspaceFolders: string[]
|
|||
|
|
url: string
|
|||
|
|
isValid: boolean
|
|||
|
|
authToken?: string
|
|||
|
|
ideRunningInWindows?: boolean
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export type IdeType =
|
|||
|
|
| 'cursor'
|
|||
|
|
| 'windsurf'
|
|||
|
|
| 'vscode'
|
|||
|
|
| 'pycharm'
|
|||
|
|
| 'intellij'
|
|||
|
|
| 'webstorm'
|
|||
|
|
| 'phpstorm'
|
|||
|
|
| 'rubymine'
|
|||
|
|
| 'clion'
|
|||
|
|
| 'goland'
|
|||
|
|
| 'rider'
|
|||
|
|
| 'datagrip'
|
|||
|
|
| 'appcode'
|
|||
|
|
| 'dataspell'
|
|||
|
|
| 'aqua'
|
|||
|
|
| 'gateway'
|
|||
|
|
| 'fleet'
|
|||
|
|
| 'androidstudio'
|
|||
|
|
|
|||
|
|
type IdeConfig = {
|
|||
|
|
ideKind: 'vscode' | 'jetbrains'
|
|||
|
|
displayName: string
|
|||
|
|
processKeywordsMac: string[]
|
|||
|
|
processKeywordsWindows: string[]
|
|||
|
|
processKeywordsLinux: string[]
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const supportedIdeConfigs: Record<IdeType, IdeConfig> = {
|
|||
|
|
cursor: {
|
|||
|
|
ideKind: 'vscode',
|
|||
|
|
displayName: 'Cursor',
|
|||
|
|
processKeywordsMac: ['Cursor Helper', 'Cursor.app'],
|
|||
|
|
processKeywordsWindows: ['cursor.exe'],
|
|||
|
|
processKeywordsLinux: ['cursor'],
|
|||
|
|
},
|
|||
|
|
windsurf: {
|
|||
|
|
ideKind: 'vscode',
|
|||
|
|
displayName: 'Windsurf',
|
|||
|
|
processKeywordsMac: ['Windsurf Helper', 'Windsurf.app'],
|
|||
|
|
processKeywordsWindows: ['windsurf.exe'],
|
|||
|
|
processKeywordsLinux: ['windsurf'],
|
|||
|
|
},
|
|||
|
|
vscode: {
|
|||
|
|
ideKind: 'vscode',
|
|||
|
|
displayName: 'VS Code',
|
|||
|
|
processKeywordsMac: ['Visual Studio Code', 'Code Helper'],
|
|||
|
|
processKeywordsWindows: ['code.exe'],
|
|||
|
|
processKeywordsLinux: ['code'],
|
|||
|
|
},
|
|||
|
|
intellij: {
|
|||
|
|
ideKind: 'jetbrains',
|
|||
|
|
displayName: 'IntelliJ IDEA',
|
|||
|
|
processKeywordsMac: ['IntelliJ IDEA'],
|
|||
|
|
processKeywordsWindows: ['idea64.exe'],
|
|||
|
|
processKeywordsLinux: ['idea', 'intellij'],
|
|||
|
|
},
|
|||
|
|
pycharm: {
|
|||
|
|
ideKind: 'jetbrains',
|
|||
|
|
displayName: 'PyCharm',
|
|||
|
|
processKeywordsMac: ['PyCharm'],
|
|||
|
|
processKeywordsWindows: ['pycharm64.exe'],
|
|||
|
|
processKeywordsLinux: ['pycharm'],
|
|||
|
|
},
|
|||
|
|
webstorm: {
|
|||
|
|
ideKind: 'jetbrains',
|
|||
|
|
displayName: 'WebStorm',
|
|||
|
|
processKeywordsMac: ['WebStorm'],
|
|||
|
|
processKeywordsWindows: ['webstorm64.exe'],
|
|||
|
|
processKeywordsLinux: ['webstorm'],
|
|||
|
|
},
|
|||
|
|
phpstorm: {
|
|||
|
|
ideKind: 'jetbrains',
|
|||
|
|
displayName: 'PhpStorm',
|
|||
|
|
processKeywordsMac: ['PhpStorm'],
|
|||
|
|
processKeywordsWindows: ['phpstorm64.exe'],
|
|||
|
|
processKeywordsLinux: ['phpstorm'],
|
|||
|
|
},
|
|||
|
|
rubymine: {
|
|||
|
|
ideKind: 'jetbrains',
|
|||
|
|
displayName: 'RubyMine',
|
|||
|
|
processKeywordsMac: ['RubyMine'],
|
|||
|
|
processKeywordsWindows: ['rubymine64.exe'],
|
|||
|
|
processKeywordsLinux: ['rubymine'],
|
|||
|
|
},
|
|||
|
|
clion: {
|
|||
|
|
ideKind: 'jetbrains',
|
|||
|
|
displayName: 'CLion',
|
|||
|
|
processKeywordsMac: ['CLion'],
|
|||
|
|
processKeywordsWindows: ['clion64.exe'],
|
|||
|
|
processKeywordsLinux: ['clion'],
|
|||
|
|
},
|
|||
|
|
goland: {
|
|||
|
|
ideKind: 'jetbrains',
|
|||
|
|
displayName: 'GoLand',
|
|||
|
|
processKeywordsMac: ['GoLand'],
|
|||
|
|
processKeywordsWindows: ['goland64.exe'],
|
|||
|
|
processKeywordsLinux: ['goland'],
|
|||
|
|
},
|
|||
|
|
rider: {
|
|||
|
|
ideKind: 'jetbrains',
|
|||
|
|
displayName: 'Rider',
|
|||
|
|
processKeywordsMac: ['Rider'],
|
|||
|
|
processKeywordsWindows: ['rider64.exe'],
|
|||
|
|
processKeywordsLinux: ['rider'],
|
|||
|
|
},
|
|||
|
|
datagrip: {
|
|||
|
|
ideKind: 'jetbrains',
|
|||
|
|
displayName: 'DataGrip',
|
|||
|
|
processKeywordsMac: ['DataGrip'],
|
|||
|
|
processKeywordsWindows: ['datagrip64.exe'],
|
|||
|
|
processKeywordsLinux: ['datagrip'],
|
|||
|
|
},
|
|||
|
|
appcode: {
|
|||
|
|
ideKind: 'jetbrains',
|
|||
|
|
displayName: 'AppCode',
|
|||
|
|
processKeywordsMac: ['AppCode'],
|
|||
|
|
processKeywordsWindows: ['appcode.exe'],
|
|||
|
|
processKeywordsLinux: ['appcode'],
|
|||
|
|
},
|
|||
|
|
dataspell: {
|
|||
|
|
ideKind: 'jetbrains',
|
|||
|
|
displayName: 'DataSpell',
|
|||
|
|
processKeywordsMac: ['DataSpell'],
|
|||
|
|
processKeywordsWindows: ['dataspell64.exe'],
|
|||
|
|
processKeywordsLinux: ['dataspell'],
|
|||
|
|
},
|
|||
|
|
aqua: {
|
|||
|
|
ideKind: 'jetbrains',
|
|||
|
|
displayName: 'Aqua',
|
|||
|
|
processKeywordsMac: [], // Do not auto-detect since aqua is too common
|
|||
|
|
processKeywordsWindows: ['aqua64.exe'],
|
|||
|
|
processKeywordsLinux: [],
|
|||
|
|
},
|
|||
|
|
gateway: {
|
|||
|
|
ideKind: 'jetbrains',
|
|||
|
|
displayName: 'Gateway',
|
|||
|
|
processKeywordsMac: [], // Do not auto-detect since gateway is too common
|
|||
|
|
processKeywordsWindows: ['gateway64.exe'],
|
|||
|
|
processKeywordsLinux: [],
|
|||
|
|
},
|
|||
|
|
fleet: {
|
|||
|
|
ideKind: 'jetbrains',
|
|||
|
|
displayName: 'Fleet',
|
|||
|
|
processKeywordsMac: [], // Do not auto-detect since fleet is too common
|
|||
|
|
processKeywordsWindows: ['fleet.exe'],
|
|||
|
|
processKeywordsLinux: [],
|
|||
|
|
},
|
|||
|
|
androidstudio: {
|
|||
|
|
ideKind: 'jetbrains',
|
|||
|
|
displayName: 'Android Studio',
|
|||
|
|
processKeywordsMac: ['Android Studio'],
|
|||
|
|
processKeywordsWindows: ['studio64.exe'],
|
|||
|
|
processKeywordsLinux: ['android-studio'],
|
|||
|
|
},
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export function isVSCodeIde(ide: IdeType | null): boolean {
|
|||
|
|
if (!ide) return false
|
|||
|
|
const config = supportedIdeConfigs[ide]
|
|||
|
|
return config && config.ideKind === 'vscode'
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export function isJetBrainsIde(ide: IdeType | null): boolean {
|
|||
|
|
if (!ide) return false
|
|||
|
|
const config = supportedIdeConfigs[ide]
|
|||
|
|
return config && config.ideKind === 'jetbrains'
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export const isSupportedVSCodeTerminal = memoize(() => {
|
|||
|
|
return isVSCodeIde(env.terminal as IdeType)
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
export const isSupportedJetBrainsTerminal = memoize(() => {
|
|||
|
|
return isJetBrainsIde(envDynamic.terminal as IdeType)
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
export const isSupportedTerminal = memoize(() => {
|
|||
|
|
return (
|
|||
|
|
isSupportedVSCodeTerminal() ||
|
|||
|
|
isSupportedJetBrainsTerminal() ||
|
|||
|
|
Boolean(process.env.FORCE_CODE_TERMINAL)
|
|||
|
|
)
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
export function getTerminalIdeType(): IdeType | null {
|
|||
|
|
if (!isSupportedTerminal()) {
|
|||
|
|
return null
|
|||
|
|
}
|
|||
|
|
return env.terminal as IdeType
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Gets sorted IDE lockfiles from ~/.claude/ide directory
|
|||
|
|
* @returns Array of full lockfile paths sorted by modification time (newest first)
|
|||
|
|
*/
|
|||
|
|
export async function getSortedIdeLockfiles(): Promise<string[]> {
|
|||
|
|
try {
|
|||
|
|
const ideLockFilePaths = await getIdeLockfilesPaths()
|
|||
|
|
|
|||
|
|
// Collect all lockfiles from all directories
|
|||
|
|
const allLockfiles: Array<{ path: string; mtime: Date }>[] =
|
|||
|
|
await Promise.all(
|
|||
|
|
ideLockFilePaths.map(async ideLockFilePath => {
|
|||
|
|
try {
|
|||
|
|
const entries = await getFsImplementation().readdir(ideLockFilePath)
|
|||
|
|
const lockEntries = entries.filter(file =>
|
|||
|
|
file.name.endsWith('.lock'),
|
|||
|
|
)
|
|||
|
|
// Stat all lockfiles in parallel; skip ones that fail
|
|||
|
|
const stats = await Promise.all(
|
|||
|
|
lockEntries.map(async file => {
|
|||
|
|
const fullPath = join(ideLockFilePath, file.name)
|
|||
|
|
try {
|
|||
|
|
const fileStat = await getFsImplementation().stat(fullPath)
|
|||
|
|
return { path: fullPath, mtime: fileStat.mtime }
|
|||
|
|
} catch {
|
|||
|
|
return null
|
|||
|
|
}
|
|||
|
|
}),
|
|||
|
|
)
|
|||
|
|
return stats.filter(s => s !== null)
|
|||
|
|
} catch (error) {
|
|||
|
|
// Candidate paths are pushed without pre-checking existence, so
|
|||
|
|
// missing/inaccessible dirs are expected here — skip silently.
|
|||
|
|
if (!isFsInaccessible(error)) {
|
|||
|
|
logError(error)
|
|||
|
|
}
|
|||
|
|
return []
|
|||
|
|
}
|
|||
|
|
}),
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
// Flatten and sort all lockfiles by last modified date (newest first)
|
|||
|
|
return allLockfiles
|
|||
|
|
.flat()
|
|||
|
|
.sort((a, b) => b.mtime.getTime() - a.mtime.getTime())
|
|||
|
|
.map(file => file.path)
|
|||
|
|
} catch (error) {
|
|||
|
|
logError(error as Error)
|
|||
|
|
return []
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function readIdeLockfile(path: string): Promise<IdeLockfileInfo | null> {
|
|||
|
|
try {
|
|||
|
|
const content = await getFsImplementation().readFile(path, {
|
|||
|
|
encoding: 'utf-8',
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
let workspaceFolders: string[] = []
|
|||
|
|
let pid: number | undefined
|
|||
|
|
let ideName: string | undefined
|
|||
|
|
let useWebSocket = false
|
|||
|
|
let runningInWindows = false
|
|||
|
|
let authToken: string | undefined
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const parsedContent = jsonParse(content) as LockfileJsonContent
|
|||
|
|
if (parsedContent.workspaceFolders) {
|
|||
|
|
workspaceFolders = parsedContent.workspaceFolders
|
|||
|
|
}
|
|||
|
|
pid = parsedContent.pid
|
|||
|
|
ideName = parsedContent.ideName
|
|||
|
|
useWebSocket = parsedContent.transport === 'ws'
|
|||
|
|
runningInWindows = parsedContent.runningInWindows === true
|
|||
|
|
authToken = parsedContent.authToken
|
|||
|
|
} catch (_) {
|
|||
|
|
// Older format- just a list of paths.
|
|||
|
|
workspaceFolders = content.split('\n').map(line => line.trim())
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Extract the port from the filename (e.g., 12345.lock -> 12345)
|
|||
|
|
const filename = path.split(pathSeparator).pop()
|
|||
|
|
if (!filename) return null
|
|||
|
|
|
|||
|
|
const port = filename.replace('.lock', '')
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
workspaceFolders,
|
|||
|
|
port: parseInt(port),
|
|||
|
|
pid,
|
|||
|
|
ideName,
|
|||
|
|
useWebSocket,
|
|||
|
|
runningInWindows,
|
|||
|
|
authToken,
|
|||
|
|
}
|
|||
|
|
} catch (error) {
|
|||
|
|
logError(error as Error)
|
|||
|
|
return null
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Checks if the IDE connection is responding by testing if the port is open
|
|||
|
|
* @param host Host to connect to
|
|||
|
|
* @param port Port to connect to
|
|||
|
|
* @param timeout Optional timeout in milliseconds (defaults to 500ms)
|
|||
|
|
* @returns true if the port is open, false otherwise
|
|||
|
|
*/
|
|||
|
|
async function checkIdeConnection(
|
|||
|
|
host: string,
|
|||
|
|
port: number,
|
|||
|
|
timeout = 500,
|
|||
|
|
): Promise<boolean> {
|
|||
|
|
try {
|
|||
|
|
return new Promise(resolve => {
|
|||
|
|
const socket = createConnection({
|
|||
|
|
host: host,
|
|||
|
|
port: port,
|
|||
|
|
timeout: timeout,
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
socket.on('connect', () => {
|
|||
|
|
socket.destroy()
|
|||
|
|
void resolve(true)
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
socket.on('error', () => {
|
|||
|
|
void resolve(false)
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
socket.on('timeout', () => {
|
|||
|
|
socket.destroy()
|
|||
|
|
void resolve(false)
|
|||
|
|
})
|
|||
|
|
})
|
|||
|
|
} catch (_) {
|
|||
|
|
// Invalid URL or other errors
|
|||
|
|
return false
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Resolve the Windows USERPROFILE path. WSL often doesn't pass USERPROFILE
|
|||
|
|
* through, so fall back to shelling out to powershell.exe. That spawn is
|
|||
|
|
* ~500ms–2s cold; the value is static per session.
|
|||
|
|
*/
|
|||
|
|
const getWindowsUserProfile = memoize(async (): Promise<string | undefined> => {
|
|||
|
|
if (process.env.USERPROFILE) return process.env.USERPROFILE
|
|||
|
|
const { stdout, code } = await execFileNoThrow('powershell.exe', [
|
|||
|
|
'-NoProfile',
|
|||
|
|
'-NonInteractive',
|
|||
|
|
'-Command',
|
|||
|
|
'$env:USERPROFILE',
|
|||
|
|
])
|
|||
|
|
if (code === 0 && stdout.trim()) return stdout.trim()
|
|||
|
|
logForDebugging(
|
|||
|
|
'Unable to get Windows USERPROFILE via PowerShell - IDE detection may be incomplete',
|
|||
|
|
)
|
|||
|
|
return undefined
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Gets the potential IDE lockfiles directories path based on platform.
|
|||
|
|
* Paths are not pre-checked for existence — the consumer readdirs each
|
|||
|
|
* and handles ENOENT. Pre-checking with stat() would double syscalls,
|
|||
|
|
* and on WSL (where /mnt/c access is 2-10x slower) the per-user-dir
|
|||
|
|
* stat loop compounded startup latency.
|
|||
|
|
*/
|
|||
|
|
export async function getIdeLockfilesPaths(): Promise<string[]> {
|
|||
|
|
const paths: string[] = [join(getClaudeConfigHomeDir(), 'ide')]
|
|||
|
|
|
|||
|
|
if (getPlatform() !== 'wsl') {
|
|||
|
|
return paths
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// For Windows, use heuristics to find the potential paths.
|
|||
|
|
// See https://learn.microsoft.com/en-us/windows/wsl/filesystems
|
|||
|
|
|
|||
|
|
const windowsHome = await getWindowsUserProfile()
|
|||
|
|
|
|||
|
|
if (windowsHome) {
|
|||
|
|
const converter = new WindowsToWSLConverter(process.env.WSL_DISTRO_NAME)
|
|||
|
|
const wslPath = converter.toLocalPath(windowsHome)
|
|||
|
|
paths.push(resolve(wslPath, '.claude', 'ide'))
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Construct the path based on the standard Windows WSL locations
|
|||
|
|
// This can fail if the current user does not have "List folder contents" permission on C:\Users
|
|||
|
|
try {
|
|||
|
|
const usersDir = '/mnt/c/Users'
|
|||
|
|
const userDirs = await getFsImplementation().readdir(usersDir)
|
|||
|
|
|
|||
|
|
for (const user of userDirs) {
|
|||
|
|
// Skip files (e.g. desktop.ini) — readdir on a file path throws ENOTDIR.
|
|||
|
|
// isFsInaccessible covers ENOTDIR, but pre-filtering here avoids the
|
|||
|
|
// cost of attempting to readdir non-directories. Symlinks are kept since
|
|||
|
|
// Windows creates junction points for user profiles.
|
|||
|
|
if (!user.isDirectory() && !user.isSymbolicLink()) {
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
if (
|
|||
|
|
user.name === 'Public' ||
|
|||
|
|
user.name === 'Default' ||
|
|||
|
|
user.name === 'Default User' ||
|
|||
|
|
user.name === 'All Users'
|
|||
|
|
) {
|
|||
|
|
continue // Skip system directories
|
|||
|
|
}
|
|||
|
|
paths.push(join(usersDir, user.name, '.claude', 'ide'))
|
|||
|
|
}
|
|||
|
|
} catch (error: unknown) {
|
|||
|
|
if (isFsInaccessible(error)) {
|
|||
|
|
// Expected on WSL when C: drive is not mounted or user lacks permissions
|
|||
|
|
logForDebugging(
|
|||
|
|
`WSL IDE lockfile path detection failed (${error.code}): ${errorMessage(error)}`,
|
|||
|
|
)
|
|||
|
|
} else {
|
|||
|
|
logError(error)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return paths
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Cleans up stale IDE lockfiles
|
|||
|
|
* - Removes lockfiles for processes that are no longer running
|
|||
|
|
* - Removes lockfiles for ports that are not responding
|
|||
|
|
*/
|
|||
|
|
export async function cleanupStaleIdeLockfiles(): Promise<void> {
|
|||
|
|
try {
|
|||
|
|
const lockfiles = await getSortedIdeLockfiles()
|
|||
|
|
|
|||
|
|
for (const lockfilePath of lockfiles) {
|
|||
|
|
const lockfileInfo = await readIdeLockfile(lockfilePath)
|
|||
|
|
|
|||
|
|
if (!lockfileInfo) {
|
|||
|
|
// If we can't read the lockfile, delete it
|
|||
|
|
try {
|
|||
|
|
await getFsImplementation().unlink(lockfilePath)
|
|||
|
|
} catch (error) {
|
|||
|
|
logError(error as Error)
|
|||
|
|
}
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const host = await detectHostIP(
|
|||
|
|
lockfileInfo.runningInWindows,
|
|||
|
|
lockfileInfo.port,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
let shouldDelete = false
|
|||
|
|
|
|||
|
|
if (lockfileInfo.pid) {
|
|||
|
|
// Check if the process is still running
|
|||
|
|
if (!isProcessRunning(lockfileInfo.pid)) {
|
|||
|
|
if (getPlatform() !== 'wsl') {
|
|||
|
|
shouldDelete = true
|
|||
|
|
} else {
|
|||
|
|
// The process id may not be reliable in wsl, so also check the connection
|
|||
|
|
const isResponding = await checkIdeConnection(
|
|||
|
|
host,
|
|||
|
|
lockfileInfo.port,
|
|||
|
|
)
|
|||
|
|
if (!isResponding) {
|
|||
|
|
shouldDelete = true
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
} else {
|
|||
|
|
// No PID, check if the URL is responding
|
|||
|
|
const isResponding = await checkIdeConnection(host, lockfileInfo.port)
|
|||
|
|
if (!isResponding) {
|
|||
|
|
shouldDelete = true
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (shouldDelete) {
|
|||
|
|
try {
|
|||
|
|
await getFsImplementation().unlink(lockfilePath)
|
|||
|
|
} catch (error) {
|
|||
|
|
logError(error as Error)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
} catch (error) {
|
|||
|
|
logError(error as Error)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export interface IDEExtensionInstallationStatus {
|
|||
|
|
installed: boolean
|
|||
|
|
error: string | null
|
|||
|
|
installedVersion: string | null
|
|||
|
|
ideType: IdeType | null
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export async function maybeInstallIDEExtension(
|
|||
|
|
ideType: IdeType,
|
|||
|
|
): Promise<IDEExtensionInstallationStatus | null> {
|
|||
|
|
try {
|
|||
|
|
// Install/update the extension
|
|||
|
|
const installedVersion = await installIDEExtension(ideType)
|
|||
|
|
// Only track successful installations
|
|||
|
|
logEvent('tengu_ext_installed', {})
|
|||
|
|
|
|||
|
|
// Set diff tool config to auto if it has not been set already
|
|||
|
|
const globalConfig = getGlobalConfig()
|
|||
|
|
if (!globalConfig.diffTool) {
|
|||
|
|
saveGlobalConfig(current => ({ ...current, diffTool: 'auto' }))
|
|||
|
|
}
|
|||
|
|
return {
|
|||
|
|
installed: true,
|
|||
|
|
error: null,
|
|||
|
|
installedVersion,
|
|||
|
|
ideType: ideType,
|
|||
|
|
}
|
|||
|
|
} catch (error) {
|
|||
|
|
logEvent('tengu_ext_install_error', {})
|
|||
|
|
// Handle installation errors
|
|||
|
|
const errorMessage = error instanceof Error ? error.message : String(error)
|
|||
|
|
logError(error as Error)
|
|||
|
|
return {
|
|||
|
|
installed: false,
|
|||
|
|
error: errorMessage,
|
|||
|
|
installedVersion: null,
|
|||
|
|
ideType: ideType,
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
let currentIDESearch: AbortController | null = null
|
|||
|
|
|
|||
|
|
export async function findAvailableIDE(): Promise<DetectedIDEInfo | null> {
|
|||
|
|
if (currentIDESearch) {
|
|||
|
|
currentIDESearch.abort()
|
|||
|
|
}
|
|||
|
|
currentIDESearch = createAbortController()
|
|||
|
|
const signal = currentIDESearch.signal
|
|||
|
|
|
|||
|
|
// Clean up stale IDE lockfiles first so we don't check them at all.
|
|||
|
|
await cleanupStaleIdeLockfiles()
|
|||
|
|
const startTime = Date.now()
|
|||
|
|
while (Date.now() - startTime < 30_000 && !signal.aborted) {
|
|||
|
|
// Skip iteration during scroll drain — detectIDEs reads lockfiles +
|
|||
|
|
// shells out to ps, competing for the event loop with scroll frames.
|
|||
|
|
// Next tick after scroll settles resumes the search.
|
|||
|
|
if (getIsScrollDraining()) {
|
|||
|
|
await sleep(1000, signal)
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
const ides = await detectIDEs(false)
|
|||
|
|
if (signal.aborted) {
|
|||
|
|
return null
|
|||
|
|
}
|
|||
|
|
// Return the IDE if and only if there is exactly one match, otherwise the user must
|
|||
|
|
// use /ide to select an IDE. When running from a supported built-in terminal, detectIDEs()
|
|||
|
|
// should return at most one IDE.
|
|||
|
|
if (ides.length === 1) {
|
|||
|
|
return ides[0]!
|
|||
|
|
}
|
|||
|
|
await sleep(1000, signal)
|
|||
|
|
}
|
|||
|
|
return null
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Detects IDEs that have a running extension/plugin.
|
|||
|
|
* @param includeInvalid If true, also return IDEs that are invalid (ie. where
|
|||
|
|
* the workspace directory does not match the cwd)
|
|||
|
|
*/
|
|||
|
|
export async function detectIDEs(
|
|||
|
|
includeInvalid: boolean,
|
|||
|
|
): Promise<DetectedIDEInfo[]> {
|
|||
|
|
const detectedIDEs: DetectedIDEInfo[] = []
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
// Get the CLAUDE_CODE_SSE_PORT if set
|
|||
|
|
const ssePort = process.env.CLAUDE_CODE_SSE_PORT
|
|||
|
|
const envPort = ssePort ? parseInt(ssePort) : null
|
|||
|
|
|
|||
|
|
// Get the current working directory, normalized to NFC for consistent
|
|||
|
|
// comparison. macOS returns NFD paths (decomposed Unicode), while IDEs
|
|||
|
|
// like VS Code report NFC paths (composed Unicode). Without normalization,
|
|||
|
|
// paths containing accented/CJK characters fail to match.
|
|||
|
|
const cwd = getOriginalCwd().normalize('NFC')
|
|||
|
|
|
|||
|
|
// Get sorted lockfiles (full paths) and read them all in parallel.
|
|||
|
|
// findAvailableIDE() polls this every 1s for up to 30s; serial I/O here was
|
|||
|
|
// showing up as ~500ms self-time in CPU profiles.
|
|||
|
|
const lockfiles = await getSortedIdeLockfiles()
|
|||
|
|
const lockfileInfos = await Promise.all(lockfiles.map(readIdeLockfile))
|
|||
|
|
|
|||
|
|
// Ancestor PID walk shells out (ps in a loop, up to 10x). Make it lazy and
|
|||
|
|
// single-shot per detectIDEs() call; with the workspace-check-first ordering
|
|||
|
|
// below, this often never fires at all.
|
|||
|
|
const getAncestors = makeAncestorPidLookup()
|
|||
|
|
const needsAncestryCheck = getPlatform() !== 'wsl' && isSupportedTerminal()
|
|||
|
|
|
|||
|
|
// Try to find a lockfile that contains our current working directory
|
|||
|
|
for (const lockfileInfo of lockfileInfos) {
|
|||
|
|
if (!lockfileInfo) continue
|
|||
|
|
|
|||
|
|
let isValid = false
|
|||
|
|
if (isEnvTruthy(process.env.CLAUDE_CODE_IDE_SKIP_VALID_CHECK)) {
|
|||
|
|
isValid = true
|
|||
|
|
} else if (lockfileInfo.port === envPort) {
|
|||
|
|
// If the port matches the environment variable, mark as valid regardless of directory
|
|||
|
|
isValid = true
|
|||
|
|
} else {
|
|||
|
|
// Otherwise, check if the current working directory is within the workspace folders
|
|||
|
|
isValid = lockfileInfo.workspaceFolders.some(idePath => {
|
|||
|
|
if (!idePath) return false
|
|||
|
|
|
|||
|
|
let localPath = idePath
|
|||
|
|
|
|||
|
|
// Handle WSL-specific path conversion and distro matching
|
|||
|
|
if (
|
|||
|
|
getPlatform() === 'wsl' &&
|
|||
|
|
lockfileInfo.runningInWindows &&
|
|||
|
|
process.env.WSL_DISTRO_NAME
|
|||
|
|
) {
|
|||
|
|
// Check for WSL distro mismatch
|
|||
|
|
if (!checkWSLDistroMatch(idePath, process.env.WSL_DISTRO_NAME)) {
|
|||
|
|
return false
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Try both the original path and the converted path
|
|||
|
|
// This handles cases where the IDE might report either format
|
|||
|
|
const resolvedOriginal = resolve(localPath).normalize('NFC')
|
|||
|
|
if (
|
|||
|
|
cwd === resolvedOriginal ||
|
|||
|
|
cwd.startsWith(resolvedOriginal + pathSeparator)
|
|||
|
|
) {
|
|||
|
|
return true
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Convert Windows IDE path to WSL local path and check that too
|
|||
|
|
const converter = new WindowsToWSLConverter(
|
|||
|
|
process.env.WSL_DISTRO_NAME,
|
|||
|
|
)
|
|||
|
|
localPath = converter.toLocalPath(idePath)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const resolvedPath = resolve(localPath).normalize('NFC')
|
|||
|
|
|
|||
|
|
// On Windows, normalize paths for case-insensitive drive letter comparison
|
|||
|
|
if (getPlatform() === 'windows') {
|
|||
|
|
const normalizedCwd = cwd.replace(/^[a-zA-Z]:/, match =>
|
|||
|
|
match.toUpperCase(),
|
|||
|
|
)
|
|||
|
|
const normalizedResolvedPath = resolvedPath.replace(
|
|||
|
|
/^[a-zA-Z]:/,
|
|||
|
|
match => match.toUpperCase(),
|
|||
|
|
)
|
|||
|
|
return (
|
|||
|
|
normalizedCwd === normalizedResolvedPath ||
|
|||
|
|
normalizedCwd.startsWith(normalizedResolvedPath + pathSeparator)
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
cwd === resolvedPath || cwd.startsWith(resolvedPath + pathSeparator)
|
|||
|
|
)
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (!isValid && !includeInvalid) {
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// PID ancestry check: when running in a supported IDE's built-in terminal,
|
|||
|
|
// ensure this lockfile's IDE is actually our parent process. This
|
|||
|
|
// disambiguates when multiple IDE windows have overlapping workspace folders.
|
|||
|
|
// Runs AFTER the workspace check so non-matching lockfiles skip it entirely —
|
|||
|
|
// previously this shelled out once per lockfile and dominated CPU profiles
|
|||
|
|
// during findAvailableIDE() polling.
|
|||
|
|
if (needsAncestryCheck) {
|
|||
|
|
const portMatchesEnv = envPort !== null && lockfileInfo.port === envPort
|
|||
|
|
if (!portMatchesEnv) {
|
|||
|
|
if (!lockfileInfo.pid || !isProcessRunning(lockfileInfo.pid)) {
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
if (process.ppid !== lockfileInfo.pid) {
|
|||
|
|
const ancestors = await getAncestors()
|
|||
|
|
if (!ancestors.has(lockfileInfo.pid)) {
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const ideName =
|
|||
|
|
lockfileInfo.ideName ??
|
|||
|
|
(isSupportedTerminal() ? toIDEDisplayName(envDynamic.terminal) : 'IDE')
|
|||
|
|
|
|||
|
|
const host = await detectHostIP(
|
|||
|
|
lockfileInfo.runningInWindows,
|
|||
|
|
lockfileInfo.port,
|
|||
|
|
)
|
|||
|
|
let url
|
|||
|
|
if (lockfileInfo.useWebSocket) {
|
|||
|
|
url = `ws://${host}:${lockfileInfo.port}`
|
|||
|
|
} else {
|
|||
|
|
url = `http://${host}:${lockfileInfo.port}/sse`
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
detectedIDEs.push({
|
|||
|
|
url: url,
|
|||
|
|
name: ideName,
|
|||
|
|
workspaceFolders: lockfileInfo.workspaceFolders,
|
|||
|
|
port: lockfileInfo.port,
|
|||
|
|
isValid: isValid,
|
|||
|
|
authToken: lockfileInfo.authToken,
|
|||
|
|
ideRunningInWindows: lockfileInfo.runningInWindows,
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// The envPort should be defined for supported IDE terminals. If there is
|
|||
|
|
// an extension with a matching envPort, then we will single that one out
|
|||
|
|
// and return it, otherwise we return all the valid ones.
|
|||
|
|
if (!includeInvalid && envPort) {
|
|||
|
|
const envPortMatch = detectedIDEs.filter(
|
|||
|
|
ide => ide.isValid && ide.port === envPort,
|
|||
|
|
)
|
|||
|
|
if (envPortMatch.length === 1) {
|
|||
|
|
return envPortMatch
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
} catch (error) {
|
|||
|
|
logError(error as Error)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return detectedIDEs
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export async function maybeNotifyIDEConnected(client: Client) {
|
|||
|
|
await client.notification({
|
|||
|
|
method: 'ide_connected',
|
|||
|
|
params: {
|
|||
|
|
pid: process.pid,
|
|||
|
|
},
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export function hasAccessToIDEExtensionDiffFeature(
|
|||
|
|
mcpClients: MCPServerConnection[],
|
|||
|
|
): boolean {
|
|||
|
|
// Check if there's a connected IDE client in the provided MCP clients list
|
|||
|
|
return mcpClients.some(
|
|||
|
|
client => client.type === 'connected' && client.name === 'ide',
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const EXTENSION_ID =
|
|||
|
|
process.env.USER_TYPE === 'ant'
|
|||
|
|
? 'anthropic.claude-code-internal'
|
|||
|
|
: 'anthropic.claude-code'
|
|||
|
|
|
|||
|
|
export async function isIDEExtensionInstalled(
|
|||
|
|
ideType: IdeType,
|
|||
|
|
): Promise<boolean> {
|
|||
|
|
if (isVSCodeIde(ideType)) {
|
|||
|
|
const command = await getVSCodeIDECommand(ideType)
|
|||
|
|
if (command) {
|
|||
|
|
try {
|
|||
|
|
const result = await execFileNoThrowWithCwd(
|
|||
|
|
command,
|
|||
|
|
['--list-extensions'],
|
|||
|
|
{
|
|||
|
|
env: getInstallationEnv(),
|
|||
|
|
},
|
|||
|
|
)
|
|||
|
|
if (result.stdout?.includes(EXTENSION_ID)) {
|
|||
|
|
return true
|
|||
|
|
}
|
|||
|
|
} catch {
|
|||
|
|
// eat the error
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
} else if (isJetBrainsIde(ideType)) {
|
|||
|
|
return await isJetBrainsPluginInstalledCached(ideType)
|
|||
|
|
}
|
|||
|
|
return false
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function installIDEExtension(ideType: IdeType): Promise<string | null> {
|
|||
|
|
if (isVSCodeIde(ideType)) {
|
|||
|
|
const command = await getVSCodeIDECommand(ideType)
|
|||
|
|
|
|||
|
|
if (command) {
|
|||
|
|
if (process.env.USER_TYPE === 'ant') {
|
|||
|
|
return await installFromArtifactory(command)
|
|||
|
|
}
|
|||
|
|
let version = await getInstalledVSCodeExtensionVersion(command)
|
|||
|
|
// If it's not installed or the version is older than the one we have bundled,
|
|||
|
|
if (!version || lt(version, getClaudeCodeVersion())) {
|
|||
|
|
// `code` may crash when invoked too quickly in succession
|
|||
|
|
await sleep(500)
|
|||
|
|
const result = await execFileNoThrowWithCwd(
|
|||
|
|
command,
|
|||
|
|
['--force', '--install-extension', 'anthropic.claude-code'],
|
|||
|
|
{
|
|||
|
|
env: getInstallationEnv(),
|
|||
|
|
},
|
|||
|
|
)
|
|||
|
|
if (result.code !== 0) {
|
|||
|
|
throw new Error(`${result.code}: ${result.error} ${result.stderr}`)
|
|||
|
|
}
|
|||
|
|
version = getClaudeCodeVersion()
|
|||
|
|
}
|
|||
|
|
return version
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
// No automatic installation for JetBrains IDEs as it is not supported in native
|
|||
|
|
// builds. We show a prominent notice for them to download from the marketplace
|
|||
|
|
// instead.
|
|||
|
|
return null
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function getInstallationEnv(): NodeJS.ProcessEnv | undefined {
|
|||
|
|
// Cursor on Linux may incorrectly implement
|
|||
|
|
// the `code` command and actually launch the UI.
|
|||
|
|
// Make this error out if this happens by clearing the DISPLAY
|
|||
|
|
// environment variable.
|
|||
|
|
if (getPlatform() === 'linux') {
|
|||
|
|
return {
|
|||
|
|
...process.env,
|
|||
|
|
DISPLAY: '',
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return undefined
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function getClaudeCodeVersion() {
|
|||
|
|
return MACRO.VERSION
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function getInstalledVSCodeExtensionVersion(
|
|||
|
|
command: string,
|
|||
|
|
): Promise<string | null> {
|
|||
|
|
const { stdout } = await execFileNoThrow(
|
|||
|
|
command,
|
|||
|
|
['--list-extensions', '--show-versions'],
|
|||
|
|
{
|
|||
|
|
env: getInstallationEnv(),
|
|||
|
|
},
|
|||
|
|
)
|
|||
|
|
const lines = stdout?.split('\n') || []
|
|||
|
|
for (const line of lines) {
|
|||
|
|
const [extensionId, version] = line.split('@')
|
|||
|
|
if (extensionId === 'anthropic.claude-code' && version) {
|
|||
|
|
return version
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return null
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function getVSCodeIDECommandByParentProcess(): string | null {
|
|||
|
|
try {
|
|||
|
|
const platform = getPlatform()
|
|||
|
|
|
|||
|
|
// Only supported on OSX, where Cursor has the ability to
|
|||
|
|
// register itself as the 'code' command.
|
|||
|
|
if (platform !== 'macos') {
|
|||
|
|
return null
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
let pid = process.ppid
|
|||
|
|
|
|||
|
|
// Walk up the process tree to find the actual app
|
|||
|
|
for (let i = 0; i < 10; i++) {
|
|||
|
|
if (!pid || pid === 0 || pid === 1) break
|
|||
|
|
|
|||
|
|
// Get the command for this PID
|
|||
|
|
// this function already returned if not running on macos
|
|||
|
|
const command = execSyncWithDefaults_DEPRECATED(
|
|||
|
|
// eslint-disable-next-line custom-rules/no-direct-ps-commands
|
|||
|
|
`ps -o command= -p ${pid}`,
|
|||
|
|
)?.trim()
|
|||
|
|
|
|||
|
|
if (command) {
|
|||
|
|
// Check for known applications and extract the path up to and including .app
|
|||
|
|
const appNames = {
|
|||
|
|
'Visual Studio Code.app': 'code',
|
|||
|
|
'Cursor.app': 'cursor',
|
|||
|
|
'Windsurf.app': 'windsurf',
|
|||
|
|
'Visual Studio Code - Insiders.app': 'code',
|
|||
|
|
'VSCodium.app': 'codium',
|
|||
|
|
}
|
|||
|
|
const pathToExecutable = '/Contents/MacOS/Electron'
|
|||
|
|
|
|||
|
|
for (const [appName, executableName] of Object.entries(appNames)) {
|
|||
|
|
const appIndex = command.indexOf(appName + pathToExecutable)
|
|||
|
|
if (appIndex !== -1) {
|
|||
|
|
// Extract the path from the beginning to the end of the .app name
|
|||
|
|
const folderPathEnd = appIndex + appName.length
|
|||
|
|
// These are all known VSCode variants with the same structure
|
|||
|
|
return (
|
|||
|
|
command.substring(0, folderPathEnd) +
|
|||
|
|
'/Contents/Resources/app/bin/' +
|
|||
|
|
executableName
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Get parent PID
|
|||
|
|
// this function already returned if not running on macos
|
|||
|
|
const ppidStr = execSyncWithDefaults_DEPRECATED(
|
|||
|
|
// eslint-disable-next-line custom-rules/no-direct-ps-commands
|
|||
|
|
`ps -o ppid= -p ${pid}`,
|
|||
|
|
)?.trim()
|
|||
|
|
if (!ppidStr) {
|
|||
|
|
break
|
|||
|
|
}
|
|||
|
|
pid = parseInt(ppidStr.trim())
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return null
|
|||
|
|
} catch {
|
|||
|
|
return null
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
async function getVSCodeIDECommand(ideType: IdeType): Promise<string | null> {
|
|||
|
|
const parentExecutable = getVSCodeIDECommandByParentProcess()
|
|||
|
|
if (parentExecutable) {
|
|||
|
|
// Verify the parent executable actually exists
|
|||
|
|
try {
|
|||
|
|
await getFsImplementation().stat(parentExecutable)
|
|||
|
|
return parentExecutable
|
|||
|
|
} catch {
|
|||
|
|
// Parent executable doesn't exist
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// On Windows, explicitly request the .cmd wrapper. VS Code 1.110.0 began
|
|||
|
|
// prepending the install root (containing Code.exe, the Electron GUI binary)
|
|||
|
|
// to the integrated terminal's PATH ahead of bin\ (containing code.cmd, the
|
|||
|
|
// CLI wrapper) when launched via Start-Menu/Taskbar shortcuts. A bare 'code'
|
|||
|
|
// then resolves to Code.exe via PATHEXT which opens a new editor window
|
|||
|
|
// instead of running the CLI. Asking for 'code.cmd' forces cross-spawn/which
|
|||
|
|
// to skip Code.exe. See microsoft/vscode#299416 (fixed in Insiders) and
|
|||
|
|
// anthropics/claude-code#30975.
|
|||
|
|
const ext = getPlatform() === 'windows' ? '.cmd' : ''
|
|||
|
|
switch (ideType) {
|
|||
|
|
case 'vscode':
|
|||
|
|
return 'code' + ext
|
|||
|
|
case 'cursor':
|
|||
|
|
return 'cursor' + ext
|
|||
|
|
case 'windsurf':
|
|||
|
|
return 'windsurf' + ext
|
|||
|
|
default:
|
|||
|
|
break
|
|||
|
|
}
|
|||
|
|
return null
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export async function isCursorInstalled(): Promise<boolean> {
|
|||
|
|
const result = await execFileNoThrow('cursor', ['--version'])
|
|||
|
|
return result.code === 0
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export async function isWindsurfInstalled(): Promise<boolean> {
|
|||
|
|
const result = await execFileNoThrow('windsurf', ['--version'])
|
|||
|
|
return result.code === 0
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export async function isVSCodeInstalled(): Promise<boolean> {
|
|||
|
|
const result = await execFileNoThrow('code', ['--help'])
|
|||
|
|
// Check if the output indicates this is actually Visual Studio Code
|
|||
|
|
return (
|
|||
|
|
result.code === 0 && Boolean(result.stdout?.includes('Visual Studio Code'))
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Cache for IDE detection results
|
|||
|
|
let cachedRunningIDEs: IdeType[] | null = null
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Internal implementation of IDE detection.
|
|||
|
|
*/
|
|||
|
|
async function detectRunningIDEsImpl(): Promise<IdeType[]> {
|
|||
|
|
const runningIDEs: IdeType[] = []
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const platform = getPlatform()
|
|||
|
|
if (platform === 'macos') {
|
|||
|
|
// On macOS, use ps with process name matching
|
|||
|
|
const result = await execa(
|
|||
|
|
'ps aux | grep -E "Visual Studio Code|Code Helper|Cursor Helper|Windsurf Helper|IntelliJ IDEA|PyCharm|WebStorm|PhpStorm|RubyMine|CLion|GoLand|Rider|DataGrip|AppCode|DataSpell|Aqua|Gateway|Fleet|Android Studio" | grep -v grep',
|
|||
|
|
{ shell: true, reject: false },
|
|||
|
|
)
|
|||
|
|
const stdout = result.stdout ?? ''
|
|||
|
|
for (const [ide, config] of Object.entries(supportedIdeConfigs)) {
|
|||
|
|
for (const keyword of config.processKeywordsMac) {
|
|||
|
|
if (stdout.includes(keyword)) {
|
|||
|
|
runningIDEs.push(ide as IdeType)
|
|||
|
|
break
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
} else if (platform === 'windows') {
|
|||
|
|
// On Windows, use tasklist with findstr for multiple patterns
|
|||
|
|
const result = await execa(
|
|||
|
|
'tasklist | findstr /I "Code.exe Cursor.exe Windsurf.exe idea64.exe pycharm64.exe webstorm64.exe phpstorm64.exe rubymine64.exe clion64.exe goland64.exe rider64.exe datagrip64.exe appcode.exe dataspell64.exe aqua64.exe gateway64.exe fleet.exe studio64.exe"',
|
|||
|
|
{ shell: true, reject: false },
|
|||
|
|
)
|
|||
|
|
const stdout = result.stdout ?? ''
|
|||
|
|
|
|||
|
|
const normalizedStdout = stdout.toLowerCase()
|
|||
|
|
|
|||
|
|
for (const [ide, config] of Object.entries(supportedIdeConfigs)) {
|
|||
|
|
for (const keyword of config.processKeywordsWindows) {
|
|||
|
|
if (normalizedStdout.includes(keyword.toLowerCase())) {
|
|||
|
|
runningIDEs.push(ide as IdeType)
|
|||
|
|
break
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
} else if (platform === 'linux') {
|
|||
|
|
// On Linux, use ps with process name matching
|
|||
|
|
const result = await execa(
|
|||
|
|
'ps aux | grep -E "code|cursor|windsurf|idea|pycharm|webstorm|phpstorm|rubymine|clion|goland|rider|datagrip|dataspell|aqua|gateway|fleet|android-studio" | grep -v grep',
|
|||
|
|
{ shell: true, reject: false },
|
|||
|
|
)
|
|||
|
|
const stdout = result.stdout ?? ''
|
|||
|
|
|
|||
|
|
const normalizedStdout = stdout.toLowerCase()
|
|||
|
|
|
|||
|
|
for (const [ide, config] of Object.entries(supportedIdeConfigs)) {
|
|||
|
|
for (const keyword of config.processKeywordsLinux) {
|
|||
|
|
if (normalizedStdout.includes(keyword)) {
|
|||
|
|
if (ide !== 'vscode') {
|
|||
|
|
runningIDEs.push(ide as IdeType)
|
|||
|
|
break
|
|||
|
|
} else if (
|
|||
|
|
!normalizedStdout.includes('cursor') &&
|
|||
|
|
!normalizedStdout.includes('appcode')
|
|||
|
|
) {
|
|||
|
|
// Special case conflicting keywords from some of the IDEs.
|
|||
|
|
runningIDEs.push(ide as IdeType)
|
|||
|
|
break
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
} catch (error) {
|
|||
|
|
// If process detection fails, return empty array
|
|||
|
|
logError(error as Error)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return runningIDEs
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Detects running IDEs and returns an array of IdeType for those that are running.
|
|||
|
|
* This performs fresh detection (~150ms) and updates the cache for subsequent
|
|||
|
|
* detectRunningIDEsCached() calls.
|
|||
|
|
*/
|
|||
|
|
export async function detectRunningIDEs(): Promise<IdeType[]> {
|
|||
|
|
const result = await detectRunningIDEsImpl()
|
|||
|
|
cachedRunningIDEs = result
|
|||
|
|
return result
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Returns cached IDE detection results, or performs detection if cache is empty.
|
|||
|
|
* Use this for performance-sensitive paths like tips where fresh results aren't needed.
|
|||
|
|
*/
|
|||
|
|
export async function detectRunningIDEsCached(): Promise<IdeType[]> {
|
|||
|
|
if (cachedRunningIDEs === null) {
|
|||
|
|
return detectRunningIDEs()
|
|||
|
|
}
|
|||
|
|
return cachedRunningIDEs
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Resets the cache for detectRunningIDEsCached.
|
|||
|
|
* Exported for testing - allows resetting state between tests.
|
|||
|
|
*/
|
|||
|
|
export function resetDetectRunningIDEs(): void {
|
|||
|
|
cachedRunningIDEs = null
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export function getConnectedIdeName(
|
|||
|
|
mcpClients: MCPServerConnection[],
|
|||
|
|
): string | null {
|
|||
|
|
const ideClient = mcpClients.find(
|
|||
|
|
client => client.type === 'connected' && client.name === 'ide',
|
|||
|
|
)
|
|||
|
|
return getIdeClientName(ideClient)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export function getIdeClientName(
|
|||
|
|
ideClient?: MCPServerConnection,
|
|||
|
|
): string | null {
|
|||
|
|
const config = ideClient?.config
|
|||
|
|
return config?.type === 'sse-ide' || config?.type === 'ws-ide'
|
|||
|
|
? config.ideName
|
|||
|
|
: isSupportedTerminal()
|
|||
|
|
? toIDEDisplayName(envDynamic.terminal)
|
|||
|
|
: null
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const EDITOR_DISPLAY_NAMES: Record<string, string> = {
|
|||
|
|
code: 'VS Code',
|
|||
|
|
cursor: 'Cursor',
|
|||
|
|
windsurf: 'Windsurf',
|
|||
|
|
antigravity: 'Antigravity',
|
|||
|
|
vi: 'Vim',
|
|||
|
|
vim: 'Vim',
|
|||
|
|
nano: 'nano',
|
|||
|
|
notepad: 'Notepad',
|
|||
|
|
'start /wait notepad': 'Notepad',
|
|||
|
|
emacs: 'Emacs',
|
|||
|
|
subl: 'Sublime Text',
|
|||
|
|
atom: 'Atom',
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export function toIDEDisplayName(terminal: string | null): string {
|
|||
|
|
if (!terminal) return 'IDE'
|
|||
|
|
|
|||
|
|
const config = supportedIdeConfigs[terminal as IdeType]
|
|||
|
|
if (config) {
|
|||
|
|
return config.displayName
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Check editor command names (exact match first)
|
|||
|
|
const editorName = EDITOR_DISPLAY_NAMES[terminal.toLowerCase().trim()]
|
|||
|
|
if (editorName) {
|
|||
|
|
return editorName
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Extract command name from path/arguments (e.g., "/usr/bin/code --wait" -> "code")
|
|||
|
|
const command = terminal.split(' ')[0]
|
|||
|
|
const commandName = command ? basename(command).toLowerCase() : null
|
|||
|
|
if (commandName) {
|
|||
|
|
const mappedName = EDITOR_DISPLAY_NAMES[commandName]
|
|||
|
|
if (mappedName) {
|
|||
|
|
return mappedName
|
|||
|
|
}
|
|||
|
|
// Fallback: capitalize the command basename
|
|||
|
|
return capitalize(commandName)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Fallback: capitalize first letter
|
|||
|
|
return capitalize(terminal)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export { callIdeRpc }
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Gets the connected IDE client from a list of MCP clients
|
|||
|
|
* @param mcpClients - Array of wrapped MCP clients
|
|||
|
|
* @returns The connected IDE client, or undefined if not found
|
|||
|
|
*/
|
|||
|
|
export function getConnectedIdeClient(
|
|||
|
|
mcpClients?: MCPServerConnection[],
|
|||
|
|
): ConnectedMCPServer | undefined {
|
|||
|
|
if (!mcpClients) {
|
|||
|
|
return undefined
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const ideClient = mcpClients.find(
|
|||
|
|
client => client.type === 'connected' && client.name === 'ide',
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
// Type guard to ensure we return the correct type
|
|||
|
|
return ideClient?.type === 'connected' ? ideClient : undefined
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Notifies the IDE that a new prompt has been submitted.
|
|||
|
|
* This triggers IDE-specific actions like closing all diff tabs.
|
|||
|
|
*/
|
|||
|
|
export async function closeOpenDiffs(
|
|||
|
|
ideClient: ConnectedMCPServer,
|
|||
|
|
): Promise<void> {
|
|||
|
|
try {
|
|||
|
|
await callIdeRpc('closeAllDiffTabs', {}, ideClient)
|
|||
|
|
} catch (_) {
|
|||
|
|
// Silently ignore errors when closing diff tabs
|
|||
|
|
// This prevents exceptions if the IDE doesn't support this operation
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Initializes IDE detection and extension installation, then calls the provided callback
|
|||
|
|
* with the detected IDE information and installation status.
|
|||
|
|
* @param ideToInstallExtension The ide to install the extension to (if installing from external terminal)
|
|||
|
|
* @param onIdeDetected Callback to be called when an IDE is detected (including null)
|
|||
|
|
* @param onInstallationComplete Callback to be called when extension installation is complete
|
|||
|
|
*/
|
|||
|
|
export async function initializeIdeIntegration(
|
|||
|
|
onIdeDetected: (ide: DetectedIDEInfo | null) => void,
|
|||
|
|
ideToInstallExtension: IdeType | null,
|
|||
|
|
onShowIdeOnboarding: () => void,
|
|||
|
|
onInstallationComplete: (
|
|||
|
|
status: IDEExtensionInstallationStatus | null,
|
|||
|
|
) => void,
|
|||
|
|
): Promise<void> {
|
|||
|
|
// Don't await so we don't block startup, but return a promise that resolves with the status
|
|||
|
|
void findAvailableIDE().then(onIdeDetected)
|
|||
|
|
|
|||
|
|
const shouldAutoInstall = getGlobalConfig().autoInstallIdeExtension ?? true
|
|||
|
|
if (
|
|||
|
|
!isEnvTruthy(process.env.CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL) &&
|
|||
|
|
shouldAutoInstall
|
|||
|
|
) {
|
|||
|
|
const ideType = ideToInstallExtension ?? getTerminalIdeType()
|
|||
|
|
if (ideType) {
|
|||
|
|
if (isVSCodeIde(ideType)) {
|
|||
|
|
void isIDEExtensionInstalled(ideType).then(async isAlreadyInstalled => {
|
|||
|
|
void maybeInstallIDEExtension(ideType)
|
|||
|
|
.catch(error => {
|
|||
|
|
const ideInstallationStatus: IDEExtensionInstallationStatus = {
|
|||
|
|
installed: false,
|
|||
|
|
error: error.message || 'Installation failed',
|
|||
|
|
installedVersion: null,
|
|||
|
|
ideType: ideType,
|
|||
|
|
}
|
|||
|
|
return ideInstallationStatus
|
|||
|
|
})
|
|||
|
|
.then(status => {
|
|||
|
|
onInstallationComplete(status)
|
|||
|
|
|
|||
|
|
if (status?.installed) {
|
|||
|
|
// If we installed and don't yet have an IDE, search again.
|
|||
|
|
void findAvailableIDE().then(onIdeDetected)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (
|
|||
|
|
!isAlreadyInstalled &&
|
|||
|
|
status?.installed === true &&
|
|||
|
|
!ideOnboardingDialog().hasIdeOnboardingDialogBeenShown()
|
|||
|
|
) {
|
|||
|
|
onShowIdeOnboarding()
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
})
|
|||
|
|
} else if (isJetBrainsIde(ideType)) {
|
|||
|
|
// Always check installation to populate the sync cache used by status notices
|
|||
|
|
void isIDEExtensionInstalled(ideType).then(async installed => {
|
|||
|
|
if (
|
|||
|
|
installed &&
|
|||
|
|
!ideOnboardingDialog().hasIdeOnboardingDialogBeenShown()
|
|||
|
|
) {
|
|||
|
|
onShowIdeOnboarding()
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Detects the host IP to use to connect to the extension.
|
|||
|
|
*/
|
|||
|
|
const detectHostIP = memoize(
|
|||
|
|
async (isIdeRunningInWindows: boolean, port: number) => {
|
|||
|
|
if (process.env.CLAUDE_CODE_IDE_HOST_OVERRIDE) {
|
|||
|
|
return process.env.CLAUDE_CODE_IDE_HOST_OVERRIDE
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (getPlatform() !== 'wsl' || !isIdeRunningInWindows) {
|
|||
|
|
return '127.0.0.1'
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// If we are running under the WSL2 VM but the extension/plugin is running in
|
|||
|
|
// Windows, then we must use a different IP address to connect to the extension.
|
|||
|
|
// https://learn.microsoft.com/en-us/windows/wsl/networking
|
|||
|
|
try {
|
|||
|
|
const routeResult = await execa('ip route show | grep -i default', {
|
|||
|
|
shell: true,
|
|||
|
|
reject: false,
|
|||
|
|
})
|
|||
|
|
if (routeResult.exitCode === 0 && routeResult.stdout) {
|
|||
|
|
const gatewayMatch = routeResult.stdout.match(
|
|||
|
|
/default via (\d+\.\d+\.\d+\.\d+)/,
|
|||
|
|
)
|
|||
|
|
if (gatewayMatch) {
|
|||
|
|
const gatewayIP = gatewayMatch[1]!
|
|||
|
|
if (await checkIdeConnection(gatewayIP, port)) {
|
|||
|
|
return gatewayIP
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
} catch (_) {
|
|||
|
|
// Suppress any errors
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Fallback to the default if we cannot find anything
|
|||
|
|
return '127.0.0.1'
|
|||
|
|
},
|
|||
|
|
(isIdeRunningInWindows, port) => `${isIdeRunningInWindows}:${port}`,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
async function installFromArtifactory(command: string): Promise<string> {
|
|||
|
|
// Read auth token from ~/.npmrc
|
|||
|
|
const npmrcPath = join(os.homedir(), '.npmrc')
|
|||
|
|
let authToken: string | null = null
|
|||
|
|
const fs = getFsImplementation()
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const npmrcContent = await fs.readFile(npmrcPath, {
|
|||
|
|
encoding: 'utf8',
|
|||
|
|
})
|
|||
|
|
const lines = npmrcContent.split('\n')
|
|||
|
|
for (const line of lines) {
|
|||
|
|
// Look for the artifactory auth token line
|
|||
|
|
const match = line.match(
|
|||
|
|
/\/\/artifactory\.infra\.ant\.dev\/artifactory\/api\/npm\/npm-all\/:_authToken=(.+)/,
|
|||
|
|
)
|
|||
|
|
if (match && match[1]) {
|
|||
|
|
authToken = match[1].trim()
|
|||
|
|
break
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
} catch (error) {
|
|||
|
|
logError(error as Error)
|
|||
|
|
throw new Error(`Failed to read npm authentication: ${error}`)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (!authToken) {
|
|||
|
|
throw new Error('No artifactory auth token found in ~/.npmrc')
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Fetch the version from artifactory
|
|||
|
|
const versionUrl =
|
|||
|
|
'https://artifactory.infra.ant.dev/artifactory/armorcode-claude-code-internal/claude-vscode-releases/stable'
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const versionResponse = await axios.get(versionUrl, {
|
|||
|
|
headers: {
|
|||
|
|
Authorization: `Bearer ${authToken}`,
|
|||
|
|
},
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
const version = versionResponse.data.trim()
|
|||
|
|
if (!version) {
|
|||
|
|
throw new Error('No version found in artifactory response')
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Download the .vsix file from artifactory
|
|||
|
|
const vsixUrl = `https://artifactory.infra.ant.dev/artifactory/armorcode-claude-code-internal/claude-vscode-releases/${version}/claude-code.vsix`
|
|||
|
|
const tempVsixPath = join(
|
|||
|
|
os.tmpdir(),
|
|||
|
|
`claude-code-${version}-${Date.now()}.vsix`,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const vsixResponse = await axios.get(vsixUrl, {
|
|||
|
|
headers: {
|
|||
|
|
Authorization: `Bearer ${authToken}`,
|
|||
|
|
},
|
|||
|
|
responseType: 'stream',
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
// Write the downloaded file to disk
|
|||
|
|
const writeStream = getFsImplementation().createWriteStream(tempVsixPath)
|
|||
|
|
await new Promise<void>((resolve, reject) => {
|
|||
|
|
vsixResponse.data.pipe(writeStream)
|
|||
|
|
writeStream.on('finish', resolve)
|
|||
|
|
writeStream.on('error', reject)
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
// Install the .vsix file
|
|||
|
|
// Add delay to prevent code command crashes
|
|||
|
|
await sleep(500)
|
|||
|
|
|
|||
|
|
const result = await execFileNoThrowWithCwd(
|
|||
|
|
command,
|
|||
|
|
['--force', '--install-extension', tempVsixPath],
|
|||
|
|
{
|
|||
|
|
env: getInstallationEnv(),
|
|||
|
|
},
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
if (result.code !== 0) {
|
|||
|
|
throw new Error(`${result.code}: ${result.error} ${result.stderr}`)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return version
|
|||
|
|
} finally {
|
|||
|
|
// Clean up the temporary file
|
|||
|
|
try {
|
|||
|
|
await fs.unlink(tempVsixPath)
|
|||
|
|
} catch {
|
|||
|
|
// Ignore cleanup errors
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
} catch (error) {
|
|||
|
|
if (axios.isAxiosError(error)) {
|
|||
|
|
throw new Error(
|
|||
|
|
`Failed to fetch extension version from artifactory: ${error.message}`,
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
throw error
|
|||
|
|
}
|
|||
|
|
}
|