/ utils / ide.ts
ide.ts
   1  import type { Client } from '@modelcontextprotocol/sdk/client/index.js'
   2  import axios from 'axios'
   3  import { execa } from 'execa'
   4  import capitalize from 'lodash-es/capitalize.js'
   5  import memoize from 'lodash-es/memoize.js'
   6  import { createConnection } from 'net'
   7  import * as os from 'os'
   8  import { basename, join, sep as pathSeparator, resolve } from 'path'
   9  import { logEvent } from 'src/services/analytics/index.js'
  10  import { getIsScrollDraining, getOriginalCwd } from '../bootstrap/state.js'
  11  import { callIdeRpc } from '../services/mcp/client.js'
  12  import type {
  13    ConnectedMCPServer,
  14    MCPServerConnection,
  15  } from '../services/mcp/types.js'
  16  import { getGlobalConfig, saveGlobalConfig } from './config.js'
  17  import { env } from './env.js'
  18  import { getClaudeConfigHomeDir, isEnvTruthy } from './envUtils.js'
  19  import {
  20    execFileNoThrow,
  21    execFileNoThrowWithCwd,
  22    execSyncWithDefaults_DEPRECATED,
  23  } from './execFileNoThrow.js'
  24  import { getFsImplementation } from './fsOperations.js'
  25  import { getAncestorPidsAsync } from './genericProcessUtils.js'
  26  import { isJetBrainsPluginInstalledCached } from './jetbrains.js'
  27  import { logError } from './log.js'
  28  import { getPlatform } from './platform.js'
  29  import { 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 */
  33  const ideOnboardingDialog =
  34    (): typeof import('src/components/IdeOnboardingDialog.js') =>
  35      require('src/components/IdeOnboardingDialog.js')
  36  
  37  import { createAbortController } from './abortController.js'
  38  import { logForDebugging } from './debug.js'
  39  import { envDynamic } from './envDynamic.js'
  40  import { errorMessage, isFsInaccessible } from './errors.js'
  41  /* eslint-enable @typescript-eslint/no-require-imports */
  42  import {
  43    checkWSLDistroMatch,
  44    WindowsToWSLConverter,
  45  } from './idePathConversion.js'
  46  import { sleep } from './sleep.js'
  47  import { jsonParse } from './slowOperations.js'
  48  
  49  function 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.
  61  function 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  
  73  type LockfileJsonContent = {
  74    workspaceFolders?: string[]
  75    pid?: number
  76    ideName?: string
  77    transport?: 'ws' | 'sse'
  78    runningInWindows?: boolean
  79    authToken?: string
  80  }
  81  
  82  type IdeLockfileInfo = {
  83    workspaceFolders: string[]
  84    port: number
  85    pid?: number
  86    ideName?: string
  87    useWebSocket: boolean
  88    runningInWindows: boolean
  89    authToken?: string
  90  }
  91  
  92  export 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  
 102  export 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  
 122  type IdeConfig = {
 123    ideKind: 'vscode' | 'jetbrains'
 124    displayName: string
 125    processKeywordsMac: string[]
 126    processKeywordsWindows: string[]
 127    processKeywordsLinux: string[]
 128  }
 129  
 130  const 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  
 259  export function isVSCodeIde(ide: IdeType | null): boolean {
 260    if (!ide) return false
 261    const config = supportedIdeConfigs[ide]
 262    return config && config.ideKind === 'vscode'
 263  }
 264  
 265  export function isJetBrainsIde(ide: IdeType | null): boolean {
 266    if (!ide) return false
 267    const config = supportedIdeConfigs[ide]
 268    return config && config.ideKind === 'jetbrains'
 269  }
 270  
 271  export const isSupportedVSCodeTerminal = memoize(() => {
 272    return isVSCodeIde(env.terminal as IdeType)
 273  })
 274  
 275  export const isSupportedJetBrainsTerminal = memoize(() => {
 276    return isJetBrainsIde(envDynamic.terminal as IdeType)
 277  })
 278  
 279  export const isSupportedTerminal = memoize(() => {
 280    return (
 281      isSupportedVSCodeTerminal() ||
 282      isSupportedJetBrainsTerminal() ||
 283      Boolean(process.env.FORCE_CODE_TERMINAL)
 284    )
 285  })
 286  
 287  export 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   */
 298  export 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  
 346  async 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   */
 402  async 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   */
 440  const 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   */
 462  export 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   */
 522  export 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  
 583  export interface IDEExtensionInstallationStatus {
 584    installed: boolean
 585    error: string | null
 586    installedVersion: string | null
 587    ideType: IdeType | null
 588  }
 589  
 590  export 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  
 624  let currentIDESearch: AbortController | null = null
 625  
 626  export 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   */
 664  export 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  
 829  export async function maybeNotifyIDEConnected(client: Client) {
 830    await client.notification({
 831      method: 'ide_connected',
 832      params: {
 833        pid: process.pid,
 834      },
 835    })
 836  }
 837  
 838  export 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  
 847  const EXTENSION_ID =
 848    process.env.USER_TYPE === 'ant'
 849      ? 'anthropic.claude-code-internal'
 850      : 'anthropic.claude-code'
 851  
 852  export 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  
 879  async 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  
 913  function 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  
 927  function getClaudeCodeVersion() {
 928    return MACRO.VERSION
 929  }
 930  
 931  async 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  
 951  function 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  }
1017  async 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  
1051  export async function isCursorInstalled(): Promise<boolean> {
1052    const result = await execFileNoThrow('cursor', ['--version'])
1053    return result.code === 0
1054  }
1055  
1056  export async function isWindsurfInstalled(): Promise<boolean> {
1057    const result = await execFileNoThrow('windsurf', ['--version'])
1058    return result.code === 0
1059  }
1060  
1061  export 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
1070  let cachedRunningIDEs: IdeType[] | null = null
1071  
1072  /**
1073   * Internal implementation of IDE detection.
1074   */
1075  async 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   */
1154  export 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   */
1164  export 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   */
1175  export function resetDetectRunningIDEs(): void {
1176    cachedRunningIDEs = null
1177  }
1178  
1179  export 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  
1188  export 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  
1199  const 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  
1214  export 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  
1244  export { 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   */
1251  export 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   */
1270  export 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   */
1288  export 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   */
1353  const 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  
1392  async 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  }