plugins { id 'java' id 'checkstyle' id 'org.jetbrains.intellij.platform' version '2.10.5' } // Load local properties file (not committed to git) def localPropertiesFile = rootProject.file('local.properties') def localProperties = new Properties() if (localPropertiesFile.exists()) { localPropertiesFile.withInputStream { localProperties.load(it) } println "Loaded local.properties" } // Print Java version info println "Java version: ${System.getProperty('java.version')}" println "Java home: ${System.getProperty('java.home')}" // Helper method: prioritize local.properties, fallback to gradle.properties ext.getLocalProperty = { String key, String defaultValue = null -> return localProperties.getProperty(key) ?: project.findProperty(key) ?: defaultValue } // Get custom JDK path from local properties (support both property names) def localJavaHome = getLocalProperty('java.home') ?: getLocalProperty('org.gradle.java.home') checkstyle { toolVersion = '10.12.5' configFile = file('checkstyle.xml') // Quality gate covers production sources only. Test code legitimately breaks // these rules: fixture child-process mains must write to System.out so the // parent can capture stdout, and single-element-array lambda captures are a // common, harmless test idiom. sourceSets = [project.sourceSets.main] } // sourceSets above unhooks checkstyleTest from the check lifecycle; disabling the // task as well makes an explicit `gradlew checkstyleTest` a SKIPPED no-op instead // of a failure. tasks.named('checkstyleTest') { enabled = false } group = 'com.github.idea-claude-code-gui' version = '0.5.4' java { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 // Use Java Toolchain for consistent JDK across all tasks toolchain { languageVersion = JavaLanguageVersion.of(17) } } // Configure custom JDK path if specified in local.properties if (localJavaHome) { def javaHomeDir = file(localJavaHome) if (javaHomeDir.exists()) { println "Using custom Java home: ${localJavaHome}" // Set the Java launcher for all JavaExec and Test tasks (except runIde) tasks.withType(JavaCompile).configureEach { options.fork = true options.forkOptions.javaHome = javaHomeDir } tasks.withType(JavaExec).configureEach { // Skip runIde task to use JBR for JCEF support if (it.name != 'runIde') { javaLauncher = javaToolchains.launcherFor { languageVersion = JavaLanguageVersion.of(17) } } } } else { println "WARNING: Configured java.home does not exist: ${localJavaHome}" } } repositories { mavenCentral() intellijPlatform { defaultRepositories() } } dependencies { implementation 'com.google.code.gson:gson:2.10.1' implementation 'org.tomlj:tomlj:1.1.1' implementation 'javazoom:jlayer:1.0.1' implementation 'org.snakeyaml:snakeyaml-engine:3.0.1' implementation 'com.twelvemonkeys.imageio:imageio-webp:3.12.0' // OpenCode 1.x stores sessions in ~/.local/share/opencode/opencode.db implementation 'org.xerial:sqlite-jdbc:3.46.1.3' testImplementation 'junit:junit:4.13.2' intellijPlatform { def targetIde = project.findProperty('targetIde') ?: 'IC' if (targetIde == 'PC') { pycharmCommunity('2024.3') bundledPlugin('PythonCore') } else if (targetIde == 'PY') { pycharmProfessional('2024.3') bundledPlugin('Pythonid') } else if (targetIde == 'RD') { // Rider 2025.3.2 rider('2025.3.2') // Enable Terminal Plugin support bundledPlugin('org.jetbrains.plugins.terminal') } else { intellijIdeaCommunity('2024.3.1') bundledPlugin('com.intellij.java') // Add Python plugin to support compiling PythonContextCollector plugin('PythonCore', '243.22562.145') // Enable Terminal Plugin support bundledPlugin('org.jetbrains.plugins.terminal') } // Git4Idea is bundled in all JetBrains IDEs that support Git // (IC/PC/PY/RD). Required so we can run a real `git diff` via // git4idea.commands.* for the Commit AI feature. bundledPlugin('Git4Idea') instrumentationTools() // IDEs to verify the plugin against (used by `verifyPlugin` task). // Mirrors what JetBrains Marketplace runs so we can catch binary // incompatibility (e.g. new abstract methods in JCEF) before upload. pluginVerifier() } } intellijPlatform { pluginVerification { // Keep local verification focused on binary/internal API compatibility // issues that match Marketplace rejection criteria. failureLevel = [ org.jetbrains.intellij.platform.gradle.tasks.VerifyPluginTask.FailureLevel.COMPATIBILITY_PROBLEMS, org.jetbrains.intellij.platform.gradle.tasks.VerifyPluginTask.FailureLevel.INTERNAL_API_USAGES ] // PythonCore is pinned to 243.x for compileOnly use across the // sinceBuild=233 .. untilBuild=263.* range. Verifier on 262.x EAP // cannot resolve com.jetbrains.python classes from a 243.x PythonCore, // so we mark the package as external — references from the optional // python-features.xml content module are guarded by the optional // dependency on com.intellij.modules.python at runtime. externalPrefixes = ['com.jetbrains.python'] ides { // 2026.2.1 EAP build 262.9437.22 – the exact build that surfaced // the CefResourceHandler / PluginManagerCore issues during // marketplace verification. ide('IU', '262.9437.22') } } } sourceSets { main { java { def targetIde = project.findProperty('targetIde') ?: 'IC' // PyCharm: exclude Java context collector if (targetIde == 'PC' || targetIde == 'PY') { exclude '**/JavaContextCollector.java' exclude '**/JavaClassNavigationSupport.java' } // Rider: exclude both Java and Python context collectors (no PSI support) if (targetIde == 'RD') { exclude '**/JavaContextCollector.java' exclude '**/JavaClassNavigationSupport.java' exclude '**/PythonContextCollector.java' } } } } tasks.withType(JavaCompile).configureEach { options.encoding = 'UTF-8' // Ensure consistent source/target compatibility options.release.set(17) } // Ensure plugin.xml is patched with the correct since/until build and change notes patchPluginXml { // plugin compatibility range sinceBuild = '233' untilBuild = '263.*' // Release notes shown in plugin repositories // Dynamically load the latest changelog entry from CHANGELOG.md and convert to simple HTML. def changelogFile = file('CHANGELOG.md') def computedChangeNotes = '' if (changelogFile.exists()) { def lines = changelogFile.readLines('UTF-8') // Find first section header (lines starting with '#####') def start = -1 for (int i = 0; i < lines.size(); i++) { if (lines[i].trim().startsWith('#####')) { start = i break } } if (start >= 0) { def sb = new StringBuilder() boolean inList = false int i = start + 1 while (i < lines.size() && !lines[i].trim().startsWith('#####')) { def line = lines[i] def t = line.trim() if (t.startsWith('- ')) { if (!inList) { sb.append(''); inList = false } // paragraph def para = t.replace('&', '&').replace('<', '<').replace('>', '>') sb.append('

' + para + '

') } i++ } if (inList) { sb.append('') } computedChangeNotes = sb.toString() } } changeNotes = computedChangeNotes ?: '

No changelog found.

' } // macOS: Finder may recreate .DS_Store inside build/ while Gradle's Delete task is // removing it, which fails with "New files were found". Bypass Gradle's strict // directory-delete check and remove build/ with a tolerant strategy instead. tasks.named('clean', Delete) { // Clear targets registered by the Java plugin so the strict Delete action is a no-op. delete = [] doLast { def buildDir = layout.buildDirectory.get().asFile if (!buildDir.exists()) { return } // Prefer shell rm on macOS — more resilient to concurrent .DS_Store writes. def isMac = System.getProperty('os.name', '').toLowerCase().contains('mac') if (isMac) { providers.exec { commandLine 'rm', '-rf', buildDir.absolutePath }.result.get() } else { ant.delete(dir: buildDir, failonerror: false) } if (buildDir.exists()) { // One more attempt after stripping junk metadata files buildDir.eachFileRecurse(groovy.io.FileType.FILES) { f -> if (f.name == '.DS_Store' || f.name == 'Thumbs.db') { f.delete() } } ant.delete(dir: buildDir, failonerror: false) } if (buildDir.exists()) { throw new GradleException("Unable to delete build directory: ${buildDir}") } } } // Disable product-specific searchable options/instrument tasks if present // Use matching to cover task name variants like buildSearchableOptionsIC / buildSearchableOptionsIU etc. tasks.matching { it.name.startsWith('buildSearchableOptions') }.configureEach { enabled = false } tasks.named('buildSearchableOptions') { enabled = false } tasks.named('instrumentCode') { enabled = false } tasks.named('jarSearchableOptions') { enabled = false } tasks.named('runIde') { jvmArgumentProviders.add({ [ '-Djcef.sandbox.enable=false', // Disable automatic plugin unloading (prevents unloading when file changes are detected) '-Didea.auto.reload.plugins=false', // Disable dynamic plugin unloading '-Didea.dynamic.plugins.allowed=false', // Enable internal mode '-Didea.is.internal=true', // Disable file system event monitoring (optional) // '-Didea.filewatcher.disabled=true', // Increase plugin loading timeout '-Didea.plugins.load.timeout=60000' ] } as CommandLineArgumentProvider) // Set system properties systemProperty 'idea.auto.reload.plugins', 'false' systemProperty 'idea.dynamic.plugins.allowed', 'false' systemProperty 'idea.is.internal', 'true' systemProperty 'idea.plugin.in.sandbox.mode', 'true' // Configure log output to console systemProperty 'idea.log.debug.categories', '#com.github.claudecodegui' // Point bridge resolver to pre-extracted ai-bridge with node_modules (dev only) def stableBridge = layout.buildDirectory.dir("stable-ai-bridge").get().asFile if (stableBridge.exists()) { systemProperty 'claude.bridge.path', stableBridge.absolutePath } // Redirect standard output standardOutput = System.out errorOutput = System.err // Copy custom configuration to sandbox doFirst { def sandboxConfig = file('sandbox-idea.properties') if (sandboxConfig.exists()) { def sandboxDir = layout.buildDirectory.dir("idea-sandbox").get().asFile def configDir = new File(sandboxDir, 'config') configDir.mkdirs() copy { from sandboxConfig into configDir rename { 'idea.properties' } } println "Copied sandbox config to: ${configDir}/idea.properties" } } } // Unified AI Bridge (merging Claude and Codex) def aiBridgeDir = file("ai-bridge") def aiBridgePackDir = layout.buildDirectory.dir('ai-bridge-pack').get().asFile def aiBridgeArchive = new File(aiBridgePackDir, 'ai-bridge.zip') def aiBridgeHashFile = new File(aiBridgePackDir, 'ai-bridge.hash') // Build webview automatically def webviewDir = file('webview') def requestedTasks = gradle.startParameter.taskNames.collect { it.toLowerCase() } def enableVConsoleInWebview = requestedTasks.any { it.contains('runide') } def skipWebviewBuild = (project.findProperty('skipWebview') ?: 'false').toString().toBoolean() // Get custom Node.js path from local properties def localNodePath = getLocalProperty('node.path') tasks.register('buildWebview', Exec) { workingDir webviewDir environment 'VITE_ENABLE_VCONSOLE', enableVConsoleInWebview ? 'true' : 'false' // Determine npm command path with robust detection def npmCommand = 'npm' if (localNodePath) { def nodeFile = file(localNodePath) def npmExecutable = null def pathToAdd = null if (nodeFile.isFile()) { // localNodePath points to node executable (e.g., /usr/local/bin/node) // npm should be in the same directory def binDir = nodeFile.parentFile def npmInSameDir = new File(binDir, 'npm') if (npmInSameDir.exists()) { npmExecutable = npmInSameDir pathToAdd = binDir.absolutePath } } else if (nodeFile.isDirectory()) { // localNodePath points to a directory, check multiple possible locations // Case 1: bin/npm (standard installation like /usr/local/node/bin/npm) def npmInBin = new File(nodeFile, 'bin/npm') // Case 2: npm directly in directory (e.g., /usr/local/bin/npm) def npmInDir = new File(nodeFile, 'npm') if (npmInBin.exists()) { npmExecutable = npmInBin pathToAdd = new File(nodeFile, 'bin').absolutePath } else if (npmInDir.exists()) { npmExecutable = npmInDir pathToAdd = nodeFile.absolutePath } } if (npmExecutable != null) { npmCommand = npmExecutable.absolutePath println "Using npm executable: ${npmCommand}" } if (pathToAdd != null) { def currentPath = System.getenv('PATH') ?: '' environment 'PATH', "${pathToAdd}${File.pathSeparator}${currentPath}" } println "Using custom Node.js path: ${localNodePath}" } if (System.getProperty('os.name').toLowerCase().contains('windows')) { commandLine 'cmd', '/c', npmCommand, 'run', 'build' } else { commandLine npmCommand, 'run', 'build' } onlyIf { webviewDir.exists() && !skipWebviewBuild } if (skipWebviewBuild) { println "Skipping webview build because -PskipWebview=true" } doFirst { println "Building webview..." } doLast { println "Webview build completed." } } // Ensure processResources runs after buildWebview // Use mustRunAfter for execution order, trigger buildWebview before compileJava tasks.named('compileJava') { dependsOn('buildWebview') } tasks.named('processResources') { mustRunAfter('buildWebview') } // Use system zip command to package ai-bridge, preserving file permissions tasks.register('packageAiBridge', Exec) { onlyIf { aiBridgeDir.exists() } // Switch to ai-bridge directory for packaging, avoiding inclusion of top-level directory workingDir aiBridgeDir // Ensure output directory exists and dependencies are installed doFirst { aiBridgePackDir.mkdirs() // Delete old zip file if (aiBridgeArchive.exists()) { aiBridgeArchive.delete() } // Install npm dependencies if node_modules doesn't exist if (!new File(aiBridgeDir, 'node_modules').exists()) { println 'Installing ai-bridge npm dependencies...' String npmCmd = System.getProperty('os.name').toLowerCase().contains('windows') ? 'npm.cmd' : 'npm' exec { commandLine npmCmd, 'install', '--silent', '--loglevel=error'; workingDir = aiBridgeDir } println 'ai-bridge npm dependencies installed.' } } // Use system zip command, -r for recursive, -y to preserve symlinks // Exclude SDK packages (they will be installed on-demand by users to ~/.codemoss/dependencies/) if (System.getProperty('os.name').toLowerCase().contains('windows')) { // Windows: Use PowerShell to exclude SDK packages // Create temporary PowerShell script file to avoid escaping issues def tempScript = File.createTempFile('ai-bridge-pack', '.ps1') tempScript.deleteOnExit() def scriptContent = ''' $excludePatterns = @( '*node_modules\\.pnpm*', '*node_modules\\@anthropic-ai*', '*node_modules\\@openai*' ) $tempDir = Join-Path $env:TEMP "ai-bridge-temp-$(Get-Random)" New-Item -ItemType Directory -Path $tempDir -Force | Out-Null try { Get-ChildItem -Path . -Recurse -Force | Where-Object { $itemPath = $_.FullName $shouldExclude = $false foreach ($pattern in $excludePatterns) { if ($itemPath -like $pattern) { $shouldExclude = $true break } } -not $shouldExclude } | ForEach-Object { $relativePath = $_.FullName.Substring((Get-Location).Path.Length + 1) $dest = Join-Path $tempDir $relativePath if ($_.PSIsContainer) { if (-not (Test-Path $dest)) { New-Item -ItemType Directory -Path $dest -Force | Out-Null } } else { $destDir = Split-Path $dest -Parent if (-not (Test-Path $destDir)) { New-Item -ItemType Directory -Path $destDir -Force | Out-Null } Copy-Item $_.FullName -Destination $dest -Force } } Compress-Archive -Path "$tempDir\\*" -DestinationPath "''' + aiBridgeArchive.absolutePath.replace('\\', '\\\\') + '''" -Force } finally { if (Test-Path $tempDir) { Remove-Item -Path $tempDir -Recurse -Force } } ''' tempScript.text = scriptContent commandLine 'powershell', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', tempScript.absolutePath } else { // Unix/Linux/macOS: Use zip command to preserve permissions // Exclude SDK package directories commandLine 'zip', '-r', '-y', aiBridgeArchive.absolutePath, '.', '-x', '*/node_modules/.pnpm/*', '-x', '*/node_modules/@anthropic-ai/*', '-x', '*/node_modules/@openai/*' } doLast { if (aiBridgeArchive.exists()) { println "✓ ai-bridge.zip created: ${aiBridgeArchive.absolutePath}" println " Size: ${aiBridgeArchive.length() / 1024 / 1024} MB" // Generate ai-bridge.hash file (SHA-256) def digest = java.security.MessageDigest.getInstance('SHA-256') aiBridgeArchive.withInputStream { is -> byte[] buffer = new byte[8192] int read while ((read = is.read(buffer)) != -1) { digest.update(buffer, 0, read) } } def hashHex = digest.digest().collect { String.format('%02x', it) }.join('') aiBridgeHashFile.text = hashHex println "✓ ai-bridge.hash created: ${hashHex}" } else { throw new GradleException("Packaging failed: ai-bridge.zip was not generated") } } } // Extract plugin name def pluginName = project.name tasks.matching { it.name.startsWith('prepareSandbox') }.configureEach { dependsOn('buildWebview', 'packageAiBridge') // Declare ai-bridge files as task inputs so changes to JS source // invalidate the UP-TO-DATE check and ensure doLast (copy/cleanup) runs. inputs.files(aiBridgeArchive, aiBridgeHashFile) def sandboxRootProvider = layout.buildDirectory.dir('idea-sandbox') // Capture needed file references during configuration phase def archiveSource = aiBridgeArchive def hashSource = aiBridgeHashFile def bridgeDir = aiBridgeDir doLast { if (!bridgeDir.exists() && !archiveSource.exists()) { throw new GradleException('ai-bridge directory does not exist. Please ensure it has been pulled and dependencies installed.') } def sandboxRoot = sandboxRootProvider.get().asFile // Find all plugin directories (including versioned ones like IC-2024.3.1/plugins/) def pluginDirs = [] // Direct plugins directory def directPluginDir = new File(sandboxRoot, "plugins/${pluginName}") if (directPluginDir.exists() || directPluginDir.parentFile.exists()) { pluginDirs.add(directPluginDir) } // Versioned plugin directories (IC-*/plugins/, PC-*/plugins/, etc.) sandboxRoot.listFiles()?.each { file -> if (file.isDirectory() && file.name.matches(/[A-Z]+-\d+\.\d+\.\d+/)) { def versionedPluginDir = new File(file, "plugins/${pluginName}") if (versionedPluginDir.exists() || versionedPluginDir.parentFile.exists()) { pluginDirs.add(versionedPluginDir) } } } if (pluginDirs.isEmpty()) { println "Warning: No plugin directories found in sandbox" } // Copy ai-bridge.zip to all found plugin directories pluginDirs.each { pluginDir -> println "Copying ai-bridge to: ${pluginDir.absolutePath}" def extractedDir = new File(pluginDir, 'ai-bridge') def archiveTarget = new File(pluginDir, 'ai-bridge.zip') def hashTarget = new File(pluginDir, 'ai-bridge.hash') // Ensure plugin directory exists pluginDir.mkdirs() // Use ant tasks for delete and copy, avoiding project.* methods ant.delete(dir: extractedDir, failonerror: false) ant.delete(file: archiveTarget, failonerror: false) ant.delete(file: hashTarget, failonerror: false) ant.copy(file: archiveSource, todir: pluginDir) if (hashSource.exists()) { ant.copy(file: hashSource, todir: pluginDir) } println "✓ ai-bridge.zip copied to: ${archiveTarget.absolutePath}" } } } // Configure tasks after prepareSandbox to ensure ai-bridge.zip is included tasks.matching { it.name == 'preparePluginForDistribution' || it.name == 'composedJar' }.configureEach { dependsOn('packageAiBridge') } tasks.named('buildPlugin') { dependsOn('packageAiBridge', 'checkstyleMain') // Invalidate UP-TO-DATE when ai-bridge.zip content changes. inputs.files(aiBridgeArchive, aiBridgeHashFile) doLast { // Unzip the plugin zip, add ai-bridge.zip and ai-bridge.hash, then repackage def distributionFile = archiveFile.get().asFile if (distributionFile.exists() && aiBridgeArchive.exists()) { def tempDir = file("${layout.buildDirectory.get()}/tmp/plugin-repack") delete(tempDir) // Unzip copy { from zipTree(distributionFile) into tempDir } // Find the plugin directory and add ai-bridge.zip and ai-bridge.hash def pluginDir = new File(tempDir, pluginName) if (pluginDir.exists()) { copy { from aiBridgeArchive from aiBridgeHashFile into pluginDir } // Delete searchableOptions jar if present fileTree(new File(pluginDir, 'lib')).matching { include '**/*searchableOptions*.jar' }.each { jarFile -> delete(jarFile) println "Deleted searchableOptions jar: ${jarFile.name}" } // Repackage delete(distributionFile) ant.zip(destfile: distributionFile) { fileset(dir: tempDir) } println "Added ai-bridge.zip and ai-bridge.hash to plugin package and removed searchableOptions jar" } delete(tempDir) } } }