jetbrains.ts 5.7 KB

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