source dump of claude code
at main 1494 lines 47 kB view raw
1import type { Client } from '@modelcontextprotocol/sdk/client/index.js' 2import axios from 'axios' 3import { execa } from 'execa' 4import capitalize from 'lodash-es/capitalize.js' 5import memoize from 'lodash-es/memoize.js' 6import { createConnection } from 'net' 7import * as os from 'os' 8import { basename, join, sep as pathSeparator, resolve } from 'path' 9import { logEvent } from 'src/services/analytics/index.js' 10import { getIsScrollDraining, getOriginalCwd } from '../bootstrap/state.js' 11import { callIdeRpc } from '../services/mcp/client.js' 12import type { 13 ConnectedMCPServer, 14 MCPServerConnection, 15} from '../services/mcp/types.js' 16import { getGlobalConfig, saveGlobalConfig } from './config.js' 17import { env } from './env.js' 18import { getClaudeConfigHomeDir, isEnvTruthy } from './envUtils.js' 19import { 20 execFileNoThrow, 21 execFileNoThrowWithCwd, 22 execSyncWithDefaults_DEPRECATED, 23} from './execFileNoThrow.js' 24import { getFsImplementation } from './fsOperations.js' 25import { getAncestorPidsAsync } from './genericProcessUtils.js' 26import { isJetBrainsPluginInstalledCached } from './jetbrains.js' 27import { logError } from './log.js' 28import { getPlatform } from './platform.js' 29import { lt } from './semver.js' 30 31// Lazy: IdeOnboardingDialog.tsx pulls React/ink; only needed in interactive onboarding path 32/* eslint-disable @typescript-eslint/no-require-imports */ 33const ideOnboardingDialog = 34 (): typeof import('src/components/IdeOnboardingDialog.js') => 35 require('src/components/IdeOnboardingDialog.js') 36 37import { createAbortController } from './abortController.js' 38import { logForDebugging } from './debug.js' 39import { envDynamic } from './envDynamic.js' 40import { errorMessage, isFsInaccessible } from './errors.js' 41/* eslint-enable @typescript-eslint/no-require-imports */ 42import { 43 checkWSLDistroMatch, 44 WindowsToWSLConverter, 45} from './idePathConversion.js' 46import { sleep } from './sleep.js' 47import { jsonParse } from './slowOperations.js' 48 49function isProcessRunning(pid: number): boolean { 50 try { 51 process.kill(pid, 0) 52 return true 53 } catch { 54 return false 55 } 56} 57 58// Returns a function that lazily fetches our process's ancestor PID chain, 59// caching within the closure's lifetime. Callers should scope this to a 60// single detection pass — PIDs recycle and process trees change over time. 61function makeAncestorPidLookup(): () => Promise<Set<number>> { 62 let promise: Promise<Set<number>> | null = null 63 return () => { 64 if (!promise) { 65 promise = getAncestorPidsAsync(process.ppid, 10).then( 66 pids => new Set(pids), 67 ) 68 } 69 return promise 70 } 71} 72 73type LockfileJsonContent = { 74 workspaceFolders?: string[] 75 pid?: number 76 ideName?: string 77 transport?: 'ws' | 'sse' 78 runningInWindows?: boolean 79 authToken?: string 80} 81 82type IdeLockfileInfo = { 83 workspaceFolders: string[] 84 port: number 85 pid?: number 86 ideName?: string 87 useWebSocket: boolean 88 runningInWindows: boolean 89 authToken?: string 90} 91 92export type DetectedIDEInfo = { 93 name: string 94 port: number 95 workspaceFolders: string[] 96 url: string 97 isValid: boolean 98 authToken?: string 99 ideRunningInWindows?: boolean 100} 101 102export type IdeType = 103 | 'cursor' 104 | 'windsurf' 105 | 'vscode' 106 | 'pycharm' 107 | 'intellij' 108 | 'webstorm' 109 | 'phpstorm' 110 | 'rubymine' 111 | 'clion' 112 | 'goland' 113 | 'rider' 114 | 'datagrip' 115 | 'appcode' 116 | 'dataspell' 117 | 'aqua' 118 | 'gateway' 119 | 'fleet' 120 | 'androidstudio' 121 122type IdeConfig = { 123 ideKind: 'vscode' | 'jetbrains' 124 displayName: string 125 processKeywordsMac: string[] 126 processKeywordsWindows: string[] 127 processKeywordsLinux: string[] 128} 129 130const supportedIdeConfigs: Record<IdeType, IdeConfig> = { 131 cursor: { 132 ideKind: 'vscode', 133 displayName: 'Cursor', 134 processKeywordsMac: ['Cursor Helper', 'Cursor.app'], 135 processKeywordsWindows: ['cursor.exe'], 136 processKeywordsLinux: ['cursor'], 137 }, 138 windsurf: { 139 ideKind: 'vscode', 140 displayName: 'Windsurf', 141 processKeywordsMac: ['Windsurf Helper', 'Windsurf.app'], 142 processKeywordsWindows: ['windsurf.exe'], 143 processKeywordsLinux: ['windsurf'], 144 }, 145 vscode: { 146 ideKind: 'vscode', 147 displayName: 'VS Code', 148 processKeywordsMac: ['Visual Studio Code', 'Code Helper'], 149 processKeywordsWindows: ['code.exe'], 150 processKeywordsLinux: ['code'], 151 }, 152 intellij: { 153 ideKind: 'jetbrains', 154 displayName: 'IntelliJ IDEA', 155 processKeywordsMac: ['IntelliJ IDEA'], 156 processKeywordsWindows: ['idea64.exe'], 157 processKeywordsLinux: ['idea', 'intellij'], 158 }, 159 pycharm: { 160 ideKind: 'jetbrains', 161 displayName: 'PyCharm', 162 processKeywordsMac: ['PyCharm'], 163 processKeywordsWindows: ['pycharm64.exe'], 164 processKeywordsLinux: ['pycharm'], 165 }, 166 webstorm: { 167 ideKind: 'jetbrains', 168 displayName: 'WebStorm', 169 processKeywordsMac: ['WebStorm'], 170 processKeywordsWindows: ['webstorm64.exe'], 171 processKeywordsLinux: ['webstorm'], 172 }, 173 phpstorm: { 174 ideKind: 'jetbrains', 175 displayName: 'PhpStorm', 176 processKeywordsMac: ['PhpStorm'], 177 processKeywordsWindows: ['phpstorm64.exe'], 178 processKeywordsLinux: ['phpstorm'], 179 }, 180 rubymine: { 181 ideKind: 'jetbrains', 182 displayName: 'RubyMine', 183 processKeywordsMac: ['RubyMine'], 184 processKeywordsWindows: ['rubymine64.exe'], 185 processKeywordsLinux: ['rubymine'], 186 }, 187 clion: { 188 ideKind: 'jetbrains', 189 displayName: 'CLion', 190 processKeywordsMac: ['CLion'], 191 processKeywordsWindows: ['clion64.exe'], 192 processKeywordsLinux: ['clion'], 193 }, 194 goland: { 195 ideKind: 'jetbrains', 196 displayName: 'GoLand', 197 processKeywordsMac: ['GoLand'], 198 processKeywordsWindows: ['goland64.exe'], 199 processKeywordsLinux: ['goland'], 200 }, 201 rider: { 202 ideKind: 'jetbrains', 203 displayName: 'Rider', 204 processKeywordsMac: ['Rider'], 205 processKeywordsWindows: ['rider64.exe'], 206 processKeywordsLinux: ['rider'], 207 }, 208 datagrip: { 209 ideKind: 'jetbrains', 210 displayName: 'DataGrip', 211 processKeywordsMac: ['DataGrip'], 212 processKeywordsWindows: ['datagrip64.exe'], 213 processKeywordsLinux: ['datagrip'], 214 }, 215 appcode: { 216 ideKind: 'jetbrains', 217 displayName: 'AppCode', 218 processKeywordsMac: ['AppCode'], 219 processKeywordsWindows: ['appcode.exe'], 220 processKeywordsLinux: ['appcode'], 221 }, 222 dataspell: { 223 ideKind: 'jetbrains', 224 displayName: 'DataSpell', 225 processKeywordsMac: ['DataSpell'], 226 processKeywordsWindows: ['dataspell64.exe'], 227 processKeywordsLinux: ['dataspell'], 228 }, 229 aqua: { 230 ideKind: 'jetbrains', 231 displayName: 'Aqua', 232 processKeywordsMac: [], // Do not auto-detect since aqua is too common 233 processKeywordsWindows: ['aqua64.exe'], 234 processKeywordsLinux: [], 235 }, 236 gateway: { 237 ideKind: 'jetbrains', 238 displayName: 'Gateway', 239 processKeywordsMac: [], // Do not auto-detect since gateway is too common 240 processKeywordsWindows: ['gateway64.exe'], 241 processKeywordsLinux: [], 242 }, 243 fleet: { 244 ideKind: 'jetbrains', 245 displayName: 'Fleet', 246 processKeywordsMac: [], // Do not auto-detect since fleet is too common 247 processKeywordsWindows: ['fleet.exe'], 248 processKeywordsLinux: [], 249 }, 250 androidstudio: { 251 ideKind: 'jetbrains', 252 displayName: 'Android Studio', 253 processKeywordsMac: ['Android Studio'], 254 processKeywordsWindows: ['studio64.exe'], 255 processKeywordsLinux: ['android-studio'], 256 }, 257} 258 259export function isVSCodeIde(ide: IdeType | null): boolean { 260 if (!ide) return false 261 const config = supportedIdeConfigs[ide] 262 return config && config.ideKind === 'vscode' 263} 264 265export function isJetBrainsIde(ide: IdeType | null): boolean { 266 if (!ide) return false 267 const config = supportedIdeConfigs[ide] 268 return config && config.ideKind === 'jetbrains' 269} 270 271export const isSupportedVSCodeTerminal = memoize(() => { 272 return isVSCodeIde(env.terminal as IdeType) 273}) 274 275export const isSupportedJetBrainsTerminal = memoize(() => { 276 return isJetBrainsIde(envDynamic.terminal as IdeType) 277}) 278 279export const isSupportedTerminal = memoize(() => { 280 return ( 281 isSupportedVSCodeTerminal() || 282 isSupportedJetBrainsTerminal() || 283 Boolean(process.env.FORCE_CODE_TERMINAL) 284 ) 285}) 286 287export function getTerminalIdeType(): IdeType | null { 288 if (!isSupportedTerminal()) { 289 return null 290 } 291 return env.terminal as IdeType 292} 293 294/** 295 * Gets sorted IDE lockfiles from ~/.claude/ide directory 296 * @returns Array of full lockfile paths sorted by modification time (newest first) 297 */ 298export async function getSortedIdeLockfiles(): Promise<string[]> { 299 try { 300 const ideLockFilePaths = await getIdeLockfilesPaths() 301 302 // Collect all lockfiles from all directories 303 const allLockfiles: Array<{ path: string; mtime: Date }>[] = 304 await Promise.all( 305 ideLockFilePaths.map(async ideLockFilePath => { 306 try { 307 const entries = await getFsImplementation().readdir(ideLockFilePath) 308 const lockEntries = entries.filter(file => 309 file.name.endsWith('.lock'), 310 ) 311 // Stat all lockfiles in parallel; skip ones that fail 312 const stats = await Promise.all( 313 lockEntries.map(async file => { 314 const fullPath = join(ideLockFilePath, file.name) 315 try { 316 const fileStat = await getFsImplementation().stat(fullPath) 317 return { path: fullPath, mtime: fileStat.mtime } 318 } catch { 319 return null 320 } 321 }), 322 ) 323 return stats.filter(s => s !== null) 324 } catch (error) { 325 // Candidate paths are pushed without pre-checking existence, so 326 // missing/inaccessible dirs are expected here — skip silently. 327 if (!isFsInaccessible(error)) { 328 logError(error) 329 } 330 return [] 331 } 332 }), 333 ) 334 335 // Flatten and sort all lockfiles by last modified date (newest first) 336 return allLockfiles 337 .flat() 338 .sort((a, b) => b.mtime.getTime() - a.mtime.getTime()) 339 .map(file => file.path) 340 } catch (error) { 341 logError(error as Error) 342 return [] 343 } 344} 345 346async function readIdeLockfile(path: string): Promise<IdeLockfileInfo | null> { 347 try { 348 const content = await getFsImplementation().readFile(path, { 349 encoding: 'utf-8', 350 }) 351 352 let workspaceFolders: string[] = [] 353 let pid: number | undefined 354 let ideName: string | undefined 355 let useWebSocket = false 356 let runningInWindows = false 357 let authToken: string | undefined 358 359 try { 360 const parsedContent = jsonParse(content) as LockfileJsonContent 361 if (parsedContent.workspaceFolders) { 362 workspaceFolders = parsedContent.workspaceFolders 363 } 364 pid = parsedContent.pid 365 ideName = parsedContent.ideName 366 useWebSocket = parsedContent.transport === 'ws' 367 runningInWindows = parsedContent.runningInWindows === true 368 authToken = parsedContent.authToken 369 } catch (_) { 370 // Older format- just a list of paths. 371 workspaceFolders = content.split('\n').map(line => line.trim()) 372 } 373 374 // Extract the port from the filename (e.g., 12345.lock -> 12345) 375 const filename = path.split(pathSeparator).pop() 376 if (!filename) return null 377 378 const port = filename.replace('.lock', '') 379 380 return { 381 workspaceFolders, 382 port: parseInt(port), 383 pid, 384 ideName, 385 useWebSocket, 386 runningInWindows, 387 authToken, 388 } 389 } catch (error) { 390 logError(error as Error) 391 return null 392 } 393} 394 395/** 396 * Checks if the IDE connection is responding by testing if the port is open 397 * @param host Host to connect to 398 * @param port Port to connect to 399 * @param timeout Optional timeout in milliseconds (defaults to 500ms) 400 * @returns true if the port is open, false otherwise 401 */ 402async function checkIdeConnection( 403 host: string, 404 port: number, 405 timeout = 500, 406): Promise<boolean> { 407 try { 408 return new Promise(resolve => { 409 const socket = createConnection({ 410 host: host, 411 port: port, 412 timeout: timeout, 413 }) 414 415 socket.on('connect', () => { 416 socket.destroy() 417 void resolve(true) 418 }) 419 420 socket.on('error', () => { 421 void resolve(false) 422 }) 423 424 socket.on('timeout', () => { 425 socket.destroy() 426 void resolve(false) 427 }) 428 }) 429 } catch (_) { 430 // Invalid URL or other errors 431 return false 432 } 433} 434 435/** 436 * Resolve the Windows USERPROFILE path. WSL often doesn't pass USERPROFILE 437 * through, so fall back to shelling out to powershell.exe. That spawn is 438 * ~500ms–2s cold; the value is static per session. 439 */ 440const getWindowsUserProfile = memoize(async (): Promise<string | undefined> => { 441 if (process.env.USERPROFILE) return process.env.USERPROFILE 442 const { stdout, code } = await execFileNoThrow('powershell.exe', [ 443 '-NoProfile', 444 '-NonInteractive', 445 '-Command', 446 '$env:USERPROFILE', 447 ]) 448 if (code === 0 && stdout.trim()) return stdout.trim() 449 logForDebugging( 450 'Unable to get Windows USERPROFILE via PowerShell - IDE detection may be incomplete', 451 ) 452 return undefined 453}) 454 455/** 456 * Gets the potential IDE lockfiles directories path based on platform. 457 * Paths are not pre-checked for existence — the consumer readdirs each 458 * and handles ENOENT. Pre-checking with stat() would double syscalls, 459 * and on WSL (where /mnt/c access is 2-10x slower) the per-user-dir 460 * stat loop compounded startup latency. 461 */ 462export async function getIdeLockfilesPaths(): Promise<string[]> { 463 const paths: string[] = [join(getClaudeConfigHomeDir(), 'ide')] 464 465 if (getPlatform() !== 'wsl') { 466 return paths 467 } 468 469 // For Windows, use heuristics to find the potential paths. 470 // See https://learn.microsoft.com/en-us/windows/wsl/filesystems 471 472 const windowsHome = await getWindowsUserProfile() 473 474 if (windowsHome) { 475 const converter = new WindowsToWSLConverter(process.env.WSL_DISTRO_NAME) 476 const wslPath = converter.toLocalPath(windowsHome) 477 paths.push(resolve(wslPath, '.claude', 'ide')) 478 } 479 480 // Construct the path based on the standard Windows WSL locations 481 // This can fail if the current user does not have "List folder contents" permission on C:\Users 482 try { 483 const usersDir = '/mnt/c/Users' 484 const userDirs = await getFsImplementation().readdir(usersDir) 485 486 for (const user of userDirs) { 487 // Skip files (e.g. desktop.ini) — readdir on a file path throws ENOTDIR. 488 // isFsInaccessible covers ENOTDIR, but pre-filtering here avoids the 489 // cost of attempting to readdir non-directories. Symlinks are kept since 490 // Windows creates junction points for user profiles. 491 if (!user.isDirectory() && !user.isSymbolicLink()) { 492 continue 493 } 494 if ( 495 user.name === 'Public' || 496 user.name === 'Default' || 497 user.name === 'Default User' || 498 user.name === 'All Users' 499 ) { 500 continue // Skip system directories 501 } 502 paths.push(join(usersDir, user.name, '.claude', 'ide')) 503 } 504 } catch (error: unknown) { 505 if (isFsInaccessible(error)) { 506 // Expected on WSL when C: drive is not mounted or user lacks permissions 507 logForDebugging( 508 `WSL IDE lockfile path detection failed (${error.code}): ${errorMessage(error)}`, 509 ) 510 } else { 511 logError(error) 512 } 513 } 514 return paths 515} 516 517/** 518 * Cleans up stale IDE lockfiles 519 * - Removes lockfiles for processes that are no longer running 520 * - Removes lockfiles for ports that are not responding 521 */ 522export async function cleanupStaleIdeLockfiles(): Promise<void> { 523 try { 524 const lockfiles = await getSortedIdeLockfiles() 525 526 for (const lockfilePath of lockfiles) { 527 const lockfileInfo = await readIdeLockfile(lockfilePath) 528 529 if (!lockfileInfo) { 530 // If we can't read the lockfile, delete it 531 try { 532 await getFsImplementation().unlink(lockfilePath) 533 } catch (error) { 534 logError(error as Error) 535 } 536 continue 537 } 538 539 const host = await detectHostIP( 540 lockfileInfo.runningInWindows, 541 lockfileInfo.port, 542 ) 543 544 let shouldDelete = false 545 546 if (lockfileInfo.pid) { 547 // Check if the process is still running 548 if (!isProcessRunning(lockfileInfo.pid)) { 549 if (getPlatform() !== 'wsl') { 550 shouldDelete = true 551 } else { 552 // The process id may not be reliable in wsl, so also check the connection 553 const isResponding = await checkIdeConnection( 554 host, 555 lockfileInfo.port, 556 ) 557 if (!isResponding) { 558 shouldDelete = true 559 } 560 } 561 } 562 } else { 563 // No PID, check if the URL is responding 564 const isResponding = await checkIdeConnection(host, lockfileInfo.port) 565 if (!isResponding) { 566 shouldDelete = true 567 } 568 } 569 570 if (shouldDelete) { 571 try { 572 await getFsImplementation().unlink(lockfilePath) 573 } catch (error) { 574 logError(error as Error) 575 } 576 } 577 } 578 } catch (error) { 579 logError(error as Error) 580 } 581} 582 583export interface IDEExtensionInstallationStatus { 584 installed: boolean 585 error: string | null 586 installedVersion: string | null 587 ideType: IdeType | null 588} 589 590export async function maybeInstallIDEExtension( 591 ideType: IdeType, 592): Promise<IDEExtensionInstallationStatus | null> { 593 try { 594 // Install/update the extension 595 const installedVersion = await installIDEExtension(ideType) 596 // Only track successful installations 597 logEvent('tengu_ext_installed', {}) 598 599 // Set diff tool config to auto if it has not been set already 600 const globalConfig = getGlobalConfig() 601 if (!globalConfig.diffTool) { 602 saveGlobalConfig(current => ({ ...current, diffTool: 'auto' })) 603 } 604 return { 605 installed: true, 606 error: null, 607 installedVersion, 608 ideType: ideType, 609 } 610 } catch (error) { 611 logEvent('tengu_ext_install_error', {}) 612 // Handle installation errors 613 const errorMessage = error instanceof Error ? error.message : String(error) 614 logError(error as Error) 615 return { 616 installed: false, 617 error: errorMessage, 618 installedVersion: null, 619 ideType: ideType, 620 } 621 } 622} 623 624let currentIDESearch: AbortController | null = null 625 626export async function findAvailableIDE(): Promise<DetectedIDEInfo | null> { 627 if (currentIDESearch) { 628 currentIDESearch.abort() 629 } 630 currentIDESearch = createAbortController() 631 const signal = currentIDESearch.signal 632 633 // Clean up stale IDE lockfiles first so we don't check them at all. 634 await cleanupStaleIdeLockfiles() 635 const startTime = Date.now() 636 while (Date.now() - startTime < 30_000 && !signal.aborted) { 637 // Skip iteration during scroll drain — detectIDEs reads lockfiles + 638 // shells out to ps, competing for the event loop with scroll frames. 639 // Next tick after scroll settles resumes the search. 640 if (getIsScrollDraining()) { 641 await sleep(1000, signal) 642 continue 643 } 644 const ides = await detectIDEs(false) 645 if (signal.aborted) { 646 return null 647 } 648 // Return the IDE if and only if there is exactly one match, otherwise the user must 649 // use /ide to select an IDE. When running from a supported built-in terminal, detectIDEs() 650 // should return at most one IDE. 651 if (ides.length === 1) { 652 return ides[0]! 653 } 654 await sleep(1000, signal) 655 } 656 return null 657} 658 659/** 660 * Detects IDEs that have a running extension/plugin. 661 * @param includeInvalid If true, also return IDEs that are invalid (ie. where 662 * the workspace directory does not match the cwd) 663 */ 664export async function detectIDEs( 665 includeInvalid: boolean, 666): Promise<DetectedIDEInfo[]> { 667 const detectedIDEs: DetectedIDEInfo[] = [] 668 669 try { 670 // Get the CLAUDE_CODE_SSE_PORT if set 671 const ssePort = process.env.CLAUDE_CODE_SSE_PORT 672 const envPort = ssePort ? parseInt(ssePort) : null 673 674 // Get the current working directory, normalized to NFC for consistent 675 // comparison. macOS returns NFD paths (decomposed Unicode), while IDEs 676 // like VS Code report NFC paths (composed Unicode). Without normalization, 677 // paths containing accented/CJK characters fail to match. 678 const cwd = getOriginalCwd().normalize('NFC') 679 680 // Get sorted lockfiles (full paths) and read them all in parallel. 681 // findAvailableIDE() polls this every 1s for up to 30s; serial I/O here was 682 // showing up as ~500ms self-time in CPU profiles. 683 const lockfiles = await getSortedIdeLockfiles() 684 const lockfileInfos = await Promise.all(lockfiles.map(readIdeLockfile)) 685 686 // Ancestor PID walk shells out (ps in a loop, up to 10x). Make it lazy and 687 // single-shot per detectIDEs() call; with the workspace-check-first ordering 688 // below, this often never fires at all. 689 const getAncestors = makeAncestorPidLookup() 690 const needsAncestryCheck = getPlatform() !== 'wsl' && isSupportedTerminal() 691 692 // Try to find a lockfile that contains our current working directory 693 for (const lockfileInfo of lockfileInfos) { 694 if (!lockfileInfo) continue 695 696 let isValid = false 697 if (isEnvTruthy(process.env.CLAUDE_CODE_IDE_SKIP_VALID_CHECK)) { 698 isValid = true 699 } else if (lockfileInfo.port === envPort) { 700 // If the port matches the environment variable, mark as valid regardless of directory 701 isValid = true 702 } else { 703 // Otherwise, check if the current working directory is within the workspace folders 704 isValid = lockfileInfo.workspaceFolders.some(idePath => { 705 if (!idePath) return false 706 707 let localPath = idePath 708 709 // Handle WSL-specific path conversion and distro matching 710 if ( 711 getPlatform() === 'wsl' && 712 lockfileInfo.runningInWindows && 713 process.env.WSL_DISTRO_NAME 714 ) { 715 // Check for WSL distro mismatch 716 if (!checkWSLDistroMatch(idePath, process.env.WSL_DISTRO_NAME)) { 717 return false 718 } 719 720 // Try both the original path and the converted path 721 // This handles cases where the IDE might report either format 722 const resolvedOriginal = resolve(localPath).normalize('NFC') 723 if ( 724 cwd === resolvedOriginal || 725 cwd.startsWith(resolvedOriginal + pathSeparator) 726 ) { 727 return true 728 } 729 730 // Convert Windows IDE path to WSL local path and check that too 731 const converter = new WindowsToWSLConverter( 732 process.env.WSL_DISTRO_NAME, 733 ) 734 localPath = converter.toLocalPath(idePath) 735 } 736 737 const resolvedPath = resolve(localPath).normalize('NFC') 738 739 // On Windows, normalize paths for case-insensitive drive letter comparison 740 if (getPlatform() === 'windows') { 741 const normalizedCwd = cwd.replace(/^[a-zA-Z]:/, match => 742 match.toUpperCase(), 743 ) 744 const normalizedResolvedPath = resolvedPath.replace( 745 /^[a-zA-Z]:/, 746 match => match.toUpperCase(), 747 ) 748 return ( 749 normalizedCwd === normalizedResolvedPath || 750 normalizedCwd.startsWith(normalizedResolvedPath + pathSeparator) 751 ) 752 } 753 754 return ( 755 cwd === resolvedPath || cwd.startsWith(resolvedPath + pathSeparator) 756 ) 757 }) 758 } 759 760 if (!isValid && !includeInvalid) { 761 continue 762 } 763 764 // PID ancestry check: when running in a supported IDE's built-in terminal, 765 // ensure this lockfile's IDE is actually our parent process. This 766 // disambiguates when multiple IDE windows have overlapping workspace folders. 767 // Runs AFTER the workspace check so non-matching lockfiles skip it entirely — 768 // previously this shelled out once per lockfile and dominated CPU profiles 769 // during findAvailableIDE() polling. 770 if (needsAncestryCheck) { 771 const portMatchesEnv = envPort !== null && lockfileInfo.port === envPort 772 if (!portMatchesEnv) { 773 if (!lockfileInfo.pid || !isProcessRunning(lockfileInfo.pid)) { 774 continue 775 } 776 if (process.ppid !== lockfileInfo.pid) { 777 const ancestors = await getAncestors() 778 if (!ancestors.has(lockfileInfo.pid)) { 779 continue 780 } 781 } 782 } 783 } 784 785 const ideName = 786 lockfileInfo.ideName ?? 787 (isSupportedTerminal() ? toIDEDisplayName(envDynamic.terminal) : 'IDE') 788 789 const host = await detectHostIP( 790 lockfileInfo.runningInWindows, 791 lockfileInfo.port, 792 ) 793 let url 794 if (lockfileInfo.useWebSocket) { 795 url = `ws://${host}:${lockfileInfo.port}` 796 } else { 797 url = `http://${host}:${lockfileInfo.port}/sse` 798 } 799 800 detectedIDEs.push({ 801 url: url, 802 name: ideName, 803 workspaceFolders: lockfileInfo.workspaceFolders, 804 port: lockfileInfo.port, 805 isValid: isValid, 806 authToken: lockfileInfo.authToken, 807 ideRunningInWindows: lockfileInfo.runningInWindows, 808 }) 809 } 810 811 // The envPort should be defined for supported IDE terminals. If there is 812 // an extension with a matching envPort, then we will single that one out 813 // and return it, otherwise we return all the valid ones. 814 if (!includeInvalid && envPort) { 815 const envPortMatch = detectedIDEs.filter( 816 ide => ide.isValid && ide.port === envPort, 817 ) 818 if (envPortMatch.length === 1) { 819 return envPortMatch 820 } 821 } 822 } catch (error) { 823 logError(error as Error) 824 } 825 826 return detectedIDEs 827} 828 829export async function maybeNotifyIDEConnected(client: Client) { 830 await client.notification({ 831 method: 'ide_connected', 832 params: { 833 pid: process.pid, 834 }, 835 }) 836} 837 838export function hasAccessToIDEExtensionDiffFeature( 839 mcpClients: MCPServerConnection[], 840): boolean { 841 // Check if there's a connected IDE client in the provided MCP clients list 842 return mcpClients.some( 843 client => client.type === 'connected' && client.name === 'ide', 844 ) 845} 846 847const EXTENSION_ID = 848 process.env.USER_TYPE === 'ant' 849 ? 'anthropic.claude-code-internal' 850 : 'anthropic.claude-code' 851 852export async function isIDEExtensionInstalled( 853 ideType: IdeType, 854): Promise<boolean> { 855 if (isVSCodeIde(ideType)) { 856 const command = await getVSCodeIDECommand(ideType) 857 if (command) { 858 try { 859 const result = await execFileNoThrowWithCwd( 860 command, 861 ['--list-extensions'], 862 { 863 env: getInstallationEnv(), 864 }, 865 ) 866 if (result.stdout?.includes(EXTENSION_ID)) { 867 return true 868 } 869 } catch { 870 // eat the error 871 } 872 } 873 } else if (isJetBrainsIde(ideType)) { 874 return await isJetBrainsPluginInstalledCached(ideType) 875 } 876 return false 877} 878 879async function installIDEExtension(ideType: IdeType): Promise<string | null> { 880 if (isVSCodeIde(ideType)) { 881 const command = await getVSCodeIDECommand(ideType) 882 883 if (command) { 884 if (process.env.USER_TYPE === 'ant') { 885 return await installFromArtifactory(command) 886 } 887 let version = await getInstalledVSCodeExtensionVersion(command) 888 // If it's not installed or the version is older than the one we have bundled, 889 if (!version || lt(version, getClaudeCodeVersion())) { 890 // `code` may crash when invoked too quickly in succession 891 await sleep(500) 892 const result = await execFileNoThrowWithCwd( 893 command, 894 ['--force', '--install-extension', 'anthropic.claude-code'], 895 { 896 env: getInstallationEnv(), 897 }, 898 ) 899 if (result.code !== 0) { 900 throw new Error(`${result.code}: ${result.error} ${result.stderr}`) 901 } 902 version = getClaudeCodeVersion() 903 } 904 return version 905 } 906 } 907 // No automatic installation for JetBrains IDEs as it is not supported in native 908 // builds. We show a prominent notice for them to download from the marketplace 909 // instead. 910 return null 911} 912 913function getInstallationEnv(): NodeJS.ProcessEnv | undefined { 914 // Cursor on Linux may incorrectly implement 915 // the `code` command and actually launch the UI. 916 // Make this error out if this happens by clearing the DISPLAY 917 // environment variable. 918 if (getPlatform() === 'linux') { 919 return { 920 ...process.env, 921 DISPLAY: '', 922 } 923 } 924 return undefined 925} 926 927function getClaudeCodeVersion() { 928 return MACRO.VERSION 929} 930 931async function getInstalledVSCodeExtensionVersion( 932 command: string, 933): Promise<string | null> { 934 const { stdout } = await execFileNoThrow( 935 command, 936 ['--list-extensions', '--show-versions'], 937 { 938 env: getInstallationEnv(), 939 }, 940 ) 941 const lines = stdout?.split('\n') || [] 942 for (const line of lines) { 943 const [extensionId, version] = line.split('@') 944 if (extensionId === 'anthropic.claude-code' && version) { 945 return version 946 } 947 } 948 return null 949} 950 951function getVSCodeIDECommandByParentProcess(): string | null { 952 try { 953 const platform = getPlatform() 954 955 // Only supported on OSX, where Cursor has the ability to 956 // register itself as the 'code' command. 957 if (platform !== 'macos') { 958 return null 959 } 960 961 let pid = process.ppid 962 963 // Walk up the process tree to find the actual app 964 for (let i = 0; i < 10; i++) { 965 if (!pid || pid === 0 || pid === 1) break 966 967 // Get the command for this PID 968 // this function already returned if not running on macos 969 const command = execSyncWithDefaults_DEPRECATED( 970 // eslint-disable-next-line custom-rules/no-direct-ps-commands 971 `ps -o command= -p ${pid}`, 972 )?.trim() 973 974 if (command) { 975 // Check for known applications and extract the path up to and including .app 976 const appNames = { 977 'Visual Studio Code.app': 'code', 978 'Cursor.app': 'cursor', 979 'Windsurf.app': 'windsurf', 980 'Visual Studio Code - Insiders.app': 'code', 981 'VSCodium.app': 'codium', 982 } 983 const pathToExecutable = '/Contents/MacOS/Electron' 984 985 for (const [appName, executableName] of Object.entries(appNames)) { 986 const appIndex = command.indexOf(appName + pathToExecutable) 987 if (appIndex !== -1) { 988 // Extract the path from the beginning to the end of the .app name 989 const folderPathEnd = appIndex + appName.length 990 // These are all known VSCode variants with the same structure 991 return ( 992 command.substring(0, folderPathEnd) + 993 '/Contents/Resources/app/bin/' + 994 executableName 995 ) 996 } 997 } 998 } 999 1000 // Get parent PID 1001 // this function already returned if not running on macos 1002 const ppidStr = execSyncWithDefaults_DEPRECATED( 1003 // eslint-disable-next-line custom-rules/no-direct-ps-commands 1004 `ps -o ppid= -p ${pid}`, 1005 )?.trim() 1006 if (!ppidStr) { 1007 break 1008 } 1009 pid = parseInt(ppidStr.trim()) 1010 } 1011 1012 return null 1013 } catch { 1014 return null 1015 } 1016} 1017async function getVSCodeIDECommand(ideType: IdeType): Promise<string | null> { 1018 const parentExecutable = getVSCodeIDECommandByParentProcess() 1019 if (parentExecutable) { 1020 // Verify the parent executable actually exists 1021 try { 1022 await getFsImplementation().stat(parentExecutable) 1023 return parentExecutable 1024 } catch { 1025 // Parent executable doesn't exist 1026 } 1027 } 1028 1029 // On Windows, explicitly request the .cmd wrapper. VS Code 1.110.0 began 1030 // prepending the install root (containing Code.exe, the Electron GUI binary) 1031 // to the integrated terminal's PATH ahead of bin\ (containing code.cmd, the 1032 // CLI wrapper) when launched via Start-Menu/Taskbar shortcuts. A bare 'code' 1033 // then resolves to Code.exe via PATHEXT which opens a new editor window 1034 // instead of running the CLI. Asking for 'code.cmd' forces cross-spawn/which 1035 // to skip Code.exe. See microsoft/vscode#299416 (fixed in Insiders) and 1036 // anthropics/claude-code#30975. 1037 const ext = getPlatform() === 'windows' ? '.cmd' : '' 1038 switch (ideType) { 1039 case 'vscode': 1040 return 'code' + ext 1041 case 'cursor': 1042 return 'cursor' + ext 1043 case 'windsurf': 1044 return 'windsurf' + ext 1045 default: 1046 break 1047 } 1048 return null 1049} 1050 1051export async function isCursorInstalled(): Promise<boolean> { 1052 const result = await execFileNoThrow('cursor', ['--version']) 1053 return result.code === 0 1054} 1055 1056export async function isWindsurfInstalled(): Promise<boolean> { 1057 const result = await execFileNoThrow('windsurf', ['--version']) 1058 return result.code === 0 1059} 1060 1061export async function isVSCodeInstalled(): Promise<boolean> { 1062 const result = await execFileNoThrow('code', ['--help']) 1063 // Check if the output indicates this is actually Visual Studio Code 1064 return ( 1065 result.code === 0 && Boolean(result.stdout?.includes('Visual Studio Code')) 1066 ) 1067} 1068 1069// Cache for IDE detection results 1070let cachedRunningIDEs: IdeType[] | null = null 1071 1072/** 1073 * Internal implementation of IDE detection. 1074 */ 1075async function detectRunningIDEsImpl(): Promise<IdeType[]> { 1076 const runningIDEs: IdeType[] = [] 1077 1078 try { 1079 const platform = getPlatform() 1080 if (platform === 'macos') { 1081 // On macOS, use ps with process name matching 1082 const result = await execa( 1083 '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', 1084 { shell: true, reject: false }, 1085 ) 1086 const stdout = result.stdout ?? '' 1087 for (const [ide, config] of Object.entries(supportedIdeConfigs)) { 1088 for (const keyword of config.processKeywordsMac) { 1089 if (stdout.includes(keyword)) { 1090 runningIDEs.push(ide as IdeType) 1091 break 1092 } 1093 } 1094 } 1095 } else if (platform === 'windows') { 1096 // On Windows, use tasklist with findstr for multiple patterns 1097 const result = await execa( 1098 '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"', 1099 { shell: true, reject: false }, 1100 ) 1101 const stdout = result.stdout ?? '' 1102 1103 const normalizedStdout = stdout.toLowerCase() 1104 1105 for (const [ide, config] of Object.entries(supportedIdeConfigs)) { 1106 for (const keyword of config.processKeywordsWindows) { 1107 if (normalizedStdout.includes(keyword.toLowerCase())) { 1108 runningIDEs.push(ide as IdeType) 1109 break 1110 } 1111 } 1112 } 1113 } else if (platform === 'linux') { 1114 // On Linux, use ps with process name matching 1115 const result = await execa( 1116 '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', 1117 { shell: true, reject: false }, 1118 ) 1119 const stdout = result.stdout ?? '' 1120 1121 const normalizedStdout = stdout.toLowerCase() 1122 1123 for (const [ide, config] of Object.entries(supportedIdeConfigs)) { 1124 for (const keyword of config.processKeywordsLinux) { 1125 if (normalizedStdout.includes(keyword)) { 1126 if (ide !== 'vscode') { 1127 runningIDEs.push(ide as IdeType) 1128 break 1129 } else if ( 1130 !normalizedStdout.includes('cursor') && 1131 !normalizedStdout.includes('appcode') 1132 ) { 1133 // Special case conflicting keywords from some of the IDEs. 1134 runningIDEs.push(ide as IdeType) 1135 break 1136 } 1137 } 1138 } 1139 } 1140 } 1141 } catch (error) { 1142 // If process detection fails, return empty array 1143 logError(error as Error) 1144 } 1145 1146 return runningIDEs 1147} 1148 1149/** 1150 * Detects running IDEs and returns an array of IdeType for those that are running. 1151 * This performs fresh detection (~150ms) and updates the cache for subsequent 1152 * detectRunningIDEsCached() calls. 1153 */ 1154export async function detectRunningIDEs(): Promise<IdeType[]> { 1155 const result = await detectRunningIDEsImpl() 1156 cachedRunningIDEs = result 1157 return result 1158} 1159 1160/** 1161 * Returns cached IDE detection results, or performs detection if cache is empty. 1162 * Use this for performance-sensitive paths like tips where fresh results aren't needed. 1163 */ 1164export async function detectRunningIDEsCached(): Promise<IdeType[]> { 1165 if (cachedRunningIDEs === null) { 1166 return detectRunningIDEs() 1167 } 1168 return cachedRunningIDEs 1169} 1170 1171/** 1172 * Resets the cache for detectRunningIDEsCached. 1173 * Exported for testing - allows resetting state between tests. 1174 */ 1175export function resetDetectRunningIDEs(): void { 1176 cachedRunningIDEs = null 1177} 1178 1179export function getConnectedIdeName( 1180 mcpClients: MCPServerConnection[], 1181): string | null { 1182 const ideClient = mcpClients.find( 1183 client => client.type === 'connected' && client.name === 'ide', 1184 ) 1185 return getIdeClientName(ideClient) 1186} 1187 1188export function getIdeClientName( 1189 ideClient?: MCPServerConnection, 1190): string | null { 1191 const config = ideClient?.config 1192 return config?.type === 'sse-ide' || config?.type === 'ws-ide' 1193 ? config.ideName 1194 : isSupportedTerminal() 1195 ? toIDEDisplayName(envDynamic.terminal) 1196 : null 1197} 1198 1199const EDITOR_DISPLAY_NAMES: Record<string, string> = { 1200 code: 'VS Code', 1201 cursor: 'Cursor', 1202 windsurf: 'Windsurf', 1203 antigravity: 'Antigravity', 1204 vi: 'Vim', 1205 vim: 'Vim', 1206 nano: 'nano', 1207 notepad: 'Notepad', 1208 'start /wait notepad': 'Notepad', 1209 emacs: 'Emacs', 1210 subl: 'Sublime Text', 1211 atom: 'Atom', 1212} 1213 1214export function toIDEDisplayName(terminal: string | null): string { 1215 if (!terminal) return 'IDE' 1216 1217 const config = supportedIdeConfigs[terminal as IdeType] 1218 if (config) { 1219 return config.displayName 1220 } 1221 1222 // Check editor command names (exact match first) 1223 const editorName = EDITOR_DISPLAY_NAMES[terminal.toLowerCase().trim()] 1224 if (editorName) { 1225 return editorName 1226 } 1227 1228 // Extract command name from path/arguments (e.g., "/usr/bin/code --wait" -> "code") 1229 const command = terminal.split(' ')[0] 1230 const commandName = command ? basename(command).toLowerCase() : null 1231 if (commandName) { 1232 const mappedName = EDITOR_DISPLAY_NAMES[commandName] 1233 if (mappedName) { 1234 return mappedName 1235 } 1236 // Fallback: capitalize the command basename 1237 return capitalize(commandName) 1238 } 1239 1240 // Fallback: capitalize first letter 1241 return capitalize(terminal) 1242} 1243 1244export { callIdeRpc } 1245 1246/** 1247 * Gets the connected IDE client from a list of MCP clients 1248 * @param mcpClients - Array of wrapped MCP clients 1249 * @returns The connected IDE client, or undefined if not found 1250 */ 1251export function getConnectedIdeClient( 1252 mcpClients?: MCPServerConnection[], 1253): ConnectedMCPServer | undefined { 1254 if (!mcpClients) { 1255 return undefined 1256 } 1257 1258 const ideClient = mcpClients.find( 1259 client => client.type === 'connected' && client.name === 'ide', 1260 ) 1261 1262 // Type guard to ensure we return the correct type 1263 return ideClient?.type === 'connected' ? ideClient : undefined 1264} 1265 1266/** 1267 * Notifies the IDE that a new prompt has been submitted. 1268 * This triggers IDE-specific actions like closing all diff tabs. 1269 */ 1270export async function closeOpenDiffs( 1271 ideClient: ConnectedMCPServer, 1272): Promise<void> { 1273 try { 1274 await callIdeRpc('closeAllDiffTabs', {}, ideClient) 1275 } catch (_) { 1276 // Silently ignore errors when closing diff tabs 1277 // This prevents exceptions if the IDE doesn't support this operation 1278 } 1279} 1280 1281/** 1282 * Initializes IDE detection and extension installation, then calls the provided callback 1283 * with the detected IDE information and installation status. 1284 * @param ideToInstallExtension The ide to install the extension to (if installing from external terminal) 1285 * @param onIdeDetected Callback to be called when an IDE is detected (including null) 1286 * @param onInstallationComplete Callback to be called when extension installation is complete 1287 */ 1288export async function initializeIdeIntegration( 1289 onIdeDetected: (ide: DetectedIDEInfo | null) => void, 1290 ideToInstallExtension: IdeType | null, 1291 onShowIdeOnboarding: () => void, 1292 onInstallationComplete: ( 1293 status: IDEExtensionInstallationStatus | null, 1294 ) => void, 1295): Promise<void> { 1296 // Don't await so we don't block startup, but return a promise that resolves with the status 1297 void findAvailableIDE().then(onIdeDetected) 1298 1299 const shouldAutoInstall = getGlobalConfig().autoInstallIdeExtension ?? true 1300 if ( 1301 !isEnvTruthy(process.env.CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL) && 1302 shouldAutoInstall 1303 ) { 1304 const ideType = ideToInstallExtension ?? getTerminalIdeType() 1305 if (ideType) { 1306 if (isVSCodeIde(ideType)) { 1307 void isIDEExtensionInstalled(ideType).then(async isAlreadyInstalled => { 1308 void maybeInstallIDEExtension(ideType) 1309 .catch(error => { 1310 const ideInstallationStatus: IDEExtensionInstallationStatus = { 1311 installed: false, 1312 error: error.message || 'Installation failed', 1313 installedVersion: null, 1314 ideType: ideType, 1315 } 1316 return ideInstallationStatus 1317 }) 1318 .then(status => { 1319 onInstallationComplete(status) 1320 1321 if (status?.installed) { 1322 // If we installed and don't yet have an IDE, search again. 1323 void findAvailableIDE().then(onIdeDetected) 1324 } 1325 1326 if ( 1327 !isAlreadyInstalled && 1328 status?.installed === true && 1329 !ideOnboardingDialog().hasIdeOnboardingDialogBeenShown() 1330 ) { 1331 onShowIdeOnboarding() 1332 } 1333 }) 1334 }) 1335 } else if (isJetBrainsIde(ideType)) { 1336 // Always check installation to populate the sync cache used by status notices 1337 void isIDEExtensionInstalled(ideType).then(async installed => { 1338 if ( 1339 installed && 1340 !ideOnboardingDialog().hasIdeOnboardingDialogBeenShown() 1341 ) { 1342 onShowIdeOnboarding() 1343 } 1344 }) 1345 } 1346 } 1347 } 1348} 1349 1350/** 1351 * Detects the host IP to use to connect to the extension. 1352 */ 1353const detectHostIP = memoize( 1354 async (isIdeRunningInWindows: boolean, port: number) => { 1355 if (process.env.CLAUDE_CODE_IDE_HOST_OVERRIDE) { 1356 return process.env.CLAUDE_CODE_IDE_HOST_OVERRIDE 1357 } 1358 1359 if (getPlatform() !== 'wsl' || !isIdeRunningInWindows) { 1360 return '127.0.0.1' 1361 } 1362 1363 // If we are running under the WSL2 VM but the extension/plugin is running in 1364 // Windows, then we must use a different IP address to connect to the extension. 1365 // https://learn.microsoft.com/en-us/windows/wsl/networking 1366 try { 1367 const routeResult = await execa('ip route show | grep -i default', { 1368 shell: true, 1369 reject: false, 1370 }) 1371 if (routeResult.exitCode === 0 && routeResult.stdout) { 1372 const gatewayMatch = routeResult.stdout.match( 1373 /default via (\d+\.\d+\.\d+\.\d+)/, 1374 ) 1375 if (gatewayMatch) { 1376 const gatewayIP = gatewayMatch[1]! 1377 if (await checkIdeConnection(gatewayIP, port)) { 1378 return gatewayIP 1379 } 1380 } 1381 } 1382 } catch (_) { 1383 // Suppress any errors 1384 } 1385 1386 // Fallback to the default if we cannot find anything 1387 return '127.0.0.1' 1388 }, 1389 (isIdeRunningInWindows, port) => `${isIdeRunningInWindows}:${port}`, 1390) 1391 1392async function installFromArtifactory(command: string): Promise<string> { 1393 // Read auth token from ~/.npmrc 1394 const npmrcPath = join(os.homedir(), '.npmrc') 1395 let authToken: string | null = null 1396 const fs = getFsImplementation() 1397 1398 try { 1399 const npmrcContent = await fs.readFile(npmrcPath, { 1400 encoding: 'utf8', 1401 }) 1402 const lines = npmrcContent.split('\n') 1403 for (const line of lines) { 1404 // Look for the artifactory auth token line 1405 const match = line.match( 1406 /\/\/artifactory\.infra\.ant\.dev\/artifactory\/api\/npm\/npm-all\/:_authToken=(.+)/, 1407 ) 1408 if (match && match[1]) { 1409 authToken = match[1].trim() 1410 break 1411 } 1412 } 1413 } catch (error) { 1414 logError(error as Error) 1415 throw new Error(`Failed to read npm authentication: ${error}`) 1416 } 1417 1418 if (!authToken) { 1419 throw new Error('No artifactory auth token found in ~/.npmrc') 1420 } 1421 1422 // Fetch the version from artifactory 1423 const versionUrl = 1424 'https://artifactory.infra.ant.dev/artifactory/armorcode-claude-code-internal/claude-vscode-releases/stable' 1425 1426 try { 1427 const versionResponse = await axios.get(versionUrl, { 1428 headers: { 1429 Authorization: `Bearer ${authToken}`, 1430 }, 1431 }) 1432 1433 const version = versionResponse.data.trim() 1434 if (!version) { 1435 throw new Error('No version found in artifactory response') 1436 } 1437 1438 // Download the .vsix file from artifactory 1439 const vsixUrl = `https://artifactory.infra.ant.dev/artifactory/armorcode-claude-code-internal/claude-vscode-releases/${version}/claude-code.vsix` 1440 const tempVsixPath = join( 1441 os.tmpdir(), 1442 `claude-code-${version}-${Date.now()}.vsix`, 1443 ) 1444 1445 try { 1446 const vsixResponse = await axios.get(vsixUrl, { 1447 headers: { 1448 Authorization: `Bearer ${authToken}`, 1449 }, 1450 responseType: 'stream', 1451 }) 1452 1453 // Write the downloaded file to disk 1454 const writeStream = getFsImplementation().createWriteStream(tempVsixPath) 1455 await new Promise<void>((resolve, reject) => { 1456 vsixResponse.data.pipe(writeStream) 1457 writeStream.on('finish', resolve) 1458 writeStream.on('error', reject) 1459 }) 1460 1461 // Install the .vsix file 1462 // Add delay to prevent code command crashes 1463 await sleep(500) 1464 1465 const result = await execFileNoThrowWithCwd( 1466 command, 1467 ['--force', '--install-extension', tempVsixPath], 1468 { 1469 env: getInstallationEnv(), 1470 }, 1471 ) 1472 1473 if (result.code !== 0) { 1474 throw new Error(`${result.code}: ${result.error} ${result.stderr}`) 1475 } 1476 1477 return version 1478 } finally { 1479 // Clean up the temporary file 1480 try { 1481 await fs.unlink(tempVsixPath) 1482 } catch { 1483 // Ignore cleanup errors 1484 } 1485 } 1486 } catch (error) { 1487 if (axios.isAxiosError(error)) { 1488 throw new Error( 1489 `Failed to fetch extension version from artifactory: ${error.message}`, 1490 ) 1491 } 1492 throw error 1493 } 1494}