/ utils / jetbrains.ts
jetbrains.ts
  1  import { homedir, platform } from 'os'
  2  import { join } from 'path'
  3  import { getFsImplementation } from '../utils/fsOperations.js'
  4  import type { IdeType } from './ide.js'
  5  
  6  const PLUGIN_PREFIX = 'claude-code-jetbrains-plugin'
  7  
  8  // Map of IDE names to their directory patterns
  9  const ideNameToDirMap: { [key: string]: string[] } = {
 10    pycharm: ['PyCharm'],
 11    intellij: ['IntelliJIdea', 'IdeaIC'],
 12    webstorm: ['WebStorm'],
 13    phpstorm: ['PhpStorm'],
 14    rubymine: ['RubyMine'],
 15    clion: ['CLion'],
 16    goland: ['GoLand'],
 17    rider: ['Rider'],
 18    datagrip: ['DataGrip'],
 19    appcode: ['AppCode'],
 20    dataspell: ['DataSpell'],
 21    aqua: ['Aqua'],
 22    gateway: ['Gateway'],
 23    fleet: ['Fleet'],
 24    androidstudio: ['AndroidStudio'],
 25  }
 26  
 27  // Build plugin directory paths
 28  // https://www.jetbrains.com/help/pycharm/directories-used-by-the-ide-to-store-settings-caches-plugins-and-logs.html#plugins-directory
 29  function buildCommonPluginDirectoryPaths(ideName: string): string[] {
 30    const homeDir = homedir()
 31    const directories: string[] = []
 32    const idePatterns = ideNameToDirMap[ideName.toLowerCase()]
 33    if (!idePatterns) {
 34      return directories
 35    }
 36  
 37    const appData = process.env.APPDATA || join(homeDir, 'AppData', 'Roaming')
 38    const localAppData =
 39      process.env.LOCALAPPDATA || join(homeDir, 'AppData', 'Local')
 40  
 41    switch (platform()) {
 42      case 'darwin':
 43        directories.push(
 44          join(homeDir, 'Library', 'Application Support', 'JetBrains'),
 45          join(homeDir, 'Library', 'Application Support'),
 46        )
 47        if (ideName.toLowerCase() === 'androidstudio') {
 48          directories.push(
 49            join(homeDir, 'Library', 'Application Support', 'Google'),
 50          )
 51        }
 52        break
 53  
 54      case 'win32':
 55        directories.push(
 56          join(appData, 'JetBrains'),
 57          join(localAppData, 'JetBrains'),
 58          join(appData),
 59        )
 60        if (ideName.toLowerCase() === 'androidstudio') {
 61          directories.push(join(localAppData, 'Google'))
 62        }
 63        break
 64  
 65      case 'linux':
 66        directories.push(
 67          join(homeDir, '.config', 'JetBrains'),
 68          join(homeDir, '.local', 'share', 'JetBrains'),
 69        )
 70        for (const pattern of idePatterns) {
 71          directories.push(join(homeDir, '.' + pattern))
 72        }
 73        if (ideName.toLowerCase() === 'androidstudio') {
 74          directories.push(join(homeDir, '.config', 'Google'))
 75        }
 76        break
 77      default:
 78        break
 79    }
 80  
 81    return directories
 82  }
 83  
 84  // Find all actual plugin directories that exist
 85  async function detectPluginDirectories(ideName: string): Promise<string[]> {
 86    const foundDirectories: string[] = []
 87    const fs = getFsImplementation()
 88  
 89    const pluginDirPaths = buildCommonPluginDirectoryPaths(ideName)
 90    const idePatterns = ideNameToDirMap[ideName.toLowerCase()]
 91    if (!idePatterns) {
 92      return foundDirectories
 93    }
 94  
 95    // Precompile once — idePatterns is invariant across baseDirs
 96    const regexes = idePatterns.map(p => new RegExp('^' + p))
 97  
 98    for (const baseDir of pluginDirPaths) {
 99      try {
100        const entries = await fs.readdir(baseDir)
101        for (const regex of regexes) {
102          for (const entry of entries) {
103            if (!regex.test(entry.name)) continue
104            // Accept symlinks too — dirent.isDirectory() is false for symlinks,
105            // but GNU stow users symlink their JetBrains config dirs. Downstream
106            // fs.stat() calls will filter out symlinks that don't point to dirs.
107            if (!entry.isDirectory() && !entry.isSymbolicLink()) continue
108            const dir = join(baseDir, entry.name)
109            // Linux is the only OS to not have a plugins directory
110            if (platform() === 'linux') {
111              foundDirectories.push(dir)
112              continue
113            }
114            const pluginDir = join(dir, 'plugins')
115            try {
116              await fs.stat(pluginDir)
117              foundDirectories.push(pluginDir)
118            } catch {
119              // Plugin directory doesn't exist, skip
120            }
121          }
122        }
123      } catch {
124        // Ignore errors from stale IDE directories (ENOENT, EACCES, etc.)
125        continue
126      }
127    }
128  
129    return foundDirectories.filter(
130      (dir, index) => foundDirectories.indexOf(dir) === index,
131    )
132  }
133  
134  export async function isJetBrainsPluginInstalled(
135    ideType: IdeType,
136  ): Promise<boolean> {
137    const pluginDirs = await detectPluginDirectories(ideType)
138    for (const dir of pluginDirs) {
139      const pluginPath = join(dir, PLUGIN_PREFIX)
140      try {
141        await getFsImplementation().stat(pluginPath)
142        return true
143      } catch {
144        // Plugin not found in this directory, continue
145      }
146    }
147    return false
148  }
149  
150  const pluginInstalledCache = new Map<IdeType, boolean>()
151  const pluginInstalledPromiseCache = new Map<IdeType, Promise<boolean>>()
152  
153  async function isJetBrainsPluginInstalledMemoized(
154    ideType: IdeType,
155    forceRefresh = false,
156  ): Promise<boolean> {
157    if (!forceRefresh) {
158      const existing = pluginInstalledPromiseCache.get(ideType)
159      if (existing) {
160        return existing
161      }
162    }
163    const promise = isJetBrainsPluginInstalled(ideType).then(result => {
164      pluginInstalledCache.set(ideType, result)
165      return result
166    })
167    pluginInstalledPromiseCache.set(ideType, promise)
168    return promise
169  }
170  
171  export async function isJetBrainsPluginInstalledCached(
172    ideType: IdeType,
173    forceRefresh = false,
174  ): Promise<boolean> {
175    if (forceRefresh) {
176      pluginInstalledCache.delete(ideType)
177      pluginInstalledPromiseCache.delete(ideType)
178    }
179    return isJetBrainsPluginInstalledMemoized(ideType, forceRefresh)
180  }
181  
182  /**
183   * Returns the cached result of isJetBrainsPluginInstalled synchronously.
184   * Returns false if the result hasn't been resolved yet.
185   * Use this only in sync contexts (e.g., status notice isActive checks).
186   */
187  export function isJetBrainsPluginInstalledCachedSync(
188    ideType: IdeType,
189  ): boolean {
190    return pluginInstalledCache.get(ideType) ?? false
191  }