From b2913f1427274b121cc28ae4ad28e8ab74d94d4f Mon Sep 17 00:00:00 2001 From: Gregory Kramida Date: Sat, 29 Nov 2025 19:09:16 -0500 Subject: [PATCH 1/4] feat(clients): add Claude Code CLI provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add new LLM provider that invokes the Claude Code CLI (`claude`) for commit message generation. This allows users with Claude Code/Max subscriptions to leverage their existing authentication without configuring separate API keys. Implementation: - ClaudeCodeClientConfiguration: Settings for CLI path, timeout, model - ClaudeCodeClientService: CLI invocation via ProcessBuilder with JSON parsing - ClaudeCodeClientPanel: Settings UI with path selector and detect button - ClaudeCodeClientSharedState: Cached model aliases (sonnet, opus, haiku) Known issues (to be fixed): - CLI path auto-detection may fail in IDE environment (different PATH) - Verification may hang if CLI waits for input 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../blarc/ai/commits/intellij/plugin/Icons.kt | 1 + .../intellij/plugin/settings/AppSettings2.kt | 4 +- .../plugin/settings/clients/LLMClientTable.kt | 4 +- .../ClaudeCodeClientConfiguration.kt | 60 +++++ .../claudeCode/ClaudeCodeClientPanel.kt | 90 +++++++ .../claudeCode/ClaudeCodeClientService.kt | 249 ++++++++++++++++++ .../claudeCode/ClaudeCodeClientSharedState.kt | 35 +++ src/main/resources/icons/claudeCode15.svg | 4 + .../messages/AiCommitsBundle.properties | 10 + 9 files changed, 455 insertions(+), 2 deletions(-) create mode 100644 src/main/kotlin/com/github/blarc/ai/commits/intellij/plugin/settings/clients/claudeCode/ClaudeCodeClientConfiguration.kt create mode 100644 src/main/kotlin/com/github/blarc/ai/commits/intellij/plugin/settings/clients/claudeCode/ClaudeCodeClientPanel.kt create mode 100644 src/main/kotlin/com/github/blarc/ai/commits/intellij/plugin/settings/clients/claudeCode/ClaudeCodeClientService.kt create mode 100644 src/main/kotlin/com/github/blarc/ai/commits/intellij/plugin/settings/clients/claudeCode/ClaudeCodeClientSharedState.kt create mode 100644 src/main/resources/icons/claudeCode15.svg diff --git a/src/main/kotlin/com/github/blarc/ai/commits/intellij/plugin/Icons.kt b/src/main/kotlin/com/github/blarc/ai/commits/intellij/plugin/Icons.kt index 12e2313..6be1df0 100644 --- a/src/main/kotlin/com/github/blarc/ai/commits/intellij/plugin/Icons.kt +++ b/src/main/kotlin/com/github/blarc/ai/commits/intellij/plugin/Icons.kt @@ -24,6 +24,7 @@ object Icons { val GEMINI_VERTEX = AICommitsIcon("/icons/geminiVertex.svg", null) val GEMINI_GOOGLE = AICommitsIcon("/icons/geminiGoogle.svg", null) val ANTHROPIC = AICommitsIcon("/icons/anthropic15bright.svg", "/icons/anthropic15dark.svg") + val CLAUDE_CODE = AICommitsIcon("/icons/claudeCode15.svg", null) val AZURE_OPEN_AI = AICommitsIcon("/icons/azureOpenAi.svg", null) val HUGGING_FACE = AICommitsIcon("/icons/huggingface.svg", null) val GITHUB = AICommitsIcon("/icons/github15bright.svg", "/icons/github15dark.svg") diff --git a/src/main/kotlin/com/github/blarc/ai/commits/intellij/plugin/settings/AppSettings2.kt b/src/main/kotlin/com/github/blarc/ai/commits/intellij/plugin/settings/AppSettings2.kt index dfedd37..9e5a027 100644 --- a/src/main/kotlin/com/github/blarc/ai/commits/intellij/plugin/settings/AppSettings2.kt +++ b/src/main/kotlin/com/github/blarc/ai/commits/intellij/plugin/settings/AppSettings2.kt @@ -8,6 +8,7 @@ import com.github.blarc.ai.commits.intellij.plugin.settings.clients.LLMClientCon import com.github.blarc.ai.commits.intellij.plugin.settings.clients.amazonBedrock.AmazonBedrockClientConfiguration import com.github.blarc.ai.commits.intellij.plugin.settings.clients.anthropic.AnthropicClientConfiguration import com.github.blarc.ai.commits.intellij.plugin.settings.clients.azureOpenAi.AzureOpenAiClientConfiguration +import com.github.blarc.ai.commits.intellij.plugin.settings.clients.claudeCode.ClaudeCodeClientConfiguration import com.github.blarc.ai.commits.intellij.plugin.settings.clients.geminiGoogle.GeminiGoogleClientConfiguration import com.github.blarc.ai.commits.intellij.plugin.settings.clients.geminiVertex.GeminiClientConfiguration import com.github.blarc.ai.commits.intellij.plugin.settings.clients.githubModels.GitHubModelsClientConfiguration @@ -68,7 +69,8 @@ class AppSettings2 : PersistentStateComponent { HuggingFaceClientConfiguration::class, GitHubModelsClientConfiguration::class, MistralAIClientConfiguration::class, - AmazonBedrockClientConfiguration::class + AmazonBedrockClientConfiguration::class, + ClaudeCodeClientConfiguration::class ], style = XCollection.Style.v2 ) diff --git a/src/main/kotlin/com/github/blarc/ai/commits/intellij/plugin/settings/clients/LLMClientTable.kt b/src/main/kotlin/com/github/blarc/ai/commits/intellij/plugin/settings/clients/LLMClientTable.kt index a1e09ee..c88347c 100644 --- a/src/main/kotlin/com/github/blarc/ai/commits/intellij/plugin/settings/clients/LLMClientTable.kt +++ b/src/main/kotlin/com/github/blarc/ai/commits/intellij/plugin/settings/clients/LLMClientTable.kt @@ -5,6 +5,7 @@ import com.github.blarc.ai.commits.intellij.plugin.createColumn import com.github.blarc.ai.commits.intellij.plugin.settings.AppSettings2 import com.github.blarc.ai.commits.intellij.plugin.settings.clients.amazonBedrock.AmazonBedrockClientConfiguration import com.github.blarc.ai.commits.intellij.plugin.settings.clients.anthropic.AnthropicClientConfiguration +import com.github.blarc.ai.commits.intellij.plugin.settings.clients.claudeCode.ClaudeCodeClientConfiguration import com.github.blarc.ai.commits.intellij.plugin.settings.clients.azureOpenAi.AzureOpenAiClientConfiguration import com.github.blarc.ai.commits.intellij.plugin.settings.clients.geminiGoogle.GeminiGoogleClientConfiguration import com.github.blarc.ai.commits.intellij.plugin.settings.clients.geminiVertex.GeminiClientConfiguration @@ -159,7 +160,8 @@ class LLMClientTable { HuggingFaceClientConfiguration(), GitHubModelsClientConfiguration(), MistralAIClientConfiguration(), - AmazonBedrockClientConfiguration() + AmazonBedrockClientConfiguration(), + ClaudeCodeClientConfiguration() ).sortedBy { it.getClientName() } } else { listOf(newLLMClientConfiguration) diff --git a/src/main/kotlin/com/github/blarc/ai/commits/intellij/plugin/settings/clients/claudeCode/ClaudeCodeClientConfiguration.kt b/src/main/kotlin/com/github/blarc/ai/commits/intellij/plugin/settings/clients/claudeCode/ClaudeCodeClientConfiguration.kt new file mode 100644 index 0000000..7b86ac4 --- /dev/null +++ b/src/main/kotlin/com/github/blarc/ai/commits/intellij/plugin/settings/clients/claudeCode/ClaudeCodeClientConfiguration.kt @@ -0,0 +1,60 @@ +package com.github.blarc.ai.commits.intellij.plugin.settings.clients.claudeCode + +import com.github.blarc.ai.commits.intellij.plugin.Icons +import com.github.blarc.ai.commits.intellij.plugin.settings.clients.LLMClientConfiguration +import com.github.blarc.ai.commits.intellij.plugin.settings.clients.LLMClientSharedState +import com.intellij.openapi.project.Project +import com.intellij.util.xmlb.annotations.Attribute +import com.intellij.vcs.commit.AbstractCommitWorkflowHandler +import kotlinx.coroutines.Job +import javax.swing.Icon + +class ClaudeCodeClientConfiguration : LLMClientConfiguration( + "Claude Code", + "", // No default model - uses CLI's configured model + "" // No temperature - CLI doesn't support it +) { + + @Attribute + var cliPath: String = "" // Empty means auto-detect + + @Attribute + var timeout: Int = 120 // Longer default for CLI execution + + companion object { + const val CLIENT_NAME = "Claude Code" + } + + override fun getClientName(): String { + return CLIENT_NAME + } + + override fun getClientIcon(): Icon { + return Icons.CLAUDE_CODE.getThemeBasedIcon() + } + + override fun getSharedState(): LLMClientSharedState { + return ClaudeCodeClientSharedState.getInstance() + } + + override fun generateCommitMessage(commitWorkflowHandler: AbstractCommitWorkflowHandler<*, *>, project: Project) { + return ClaudeCodeClientService.getInstance().generateCommitMessageCli(this, commitWorkflowHandler, project) + } + + override fun getGenerateCommitMessageJob(): Job? { + return ClaudeCodeClientService.getInstance().generateCommitMessageJob + } + + override fun clone(): LLMClientConfiguration { + val copy = ClaudeCodeClientConfiguration() + copy.id = id + copy.name = name + copy.cliPath = cliPath + copy.timeout = timeout + copy.modelId = modelId + copy.temperature = temperature + return copy + } + + override fun panel() = ClaudeCodeClientPanel(this) +} \ No newline at end of file diff --git a/src/main/kotlin/com/github/blarc/ai/commits/intellij/plugin/settings/clients/claudeCode/ClaudeCodeClientPanel.kt b/src/main/kotlin/com/github/blarc/ai/commits/intellij/plugin/settings/clients/claudeCode/ClaudeCodeClientPanel.kt new file mode 100644 index 0000000..632ec58 --- /dev/null +++ b/src/main/kotlin/com/github/blarc/ai/commits/intellij/plugin/settings/clients/claudeCode/ClaudeCodeClientPanel.kt @@ -0,0 +1,90 @@ +package com.github.blarc.ai.commits.intellij.plugin.settings.clients.claudeCode + +import com.github.blarc.ai.commits.intellij.plugin.AICommitsBundle.message +import com.github.blarc.ai.commits.intellij.plugin.settings.clients.LLMClientPanel +import com.intellij.openapi.fileChooser.FileChooserDescriptorFactory +import com.intellij.ui.components.JBTextField +import com.intellij.ui.dsl.builder.* + +class ClaudeCodeClientPanel private constructor( + private val clientConfiguration: ClaudeCodeClientConfiguration, + val service: ClaudeCodeClientService +) : LLMClientPanel(clientConfiguration) { + + private val cliPathTextField = JBTextField() + private val timeoutTextField = JBTextField() + + constructor(configuration: ClaudeCodeClientConfiguration) : this(configuration, ClaudeCodeClientService.getInstance()) + + override fun create() = panel { + nameRow() + cliPathRow() + timeoutRow() + modelRow() + verifyRow() + } + + private fun Panel.cliPathRow() { + row { + label(message("settings.claudeCode.cliPath")) + .widthGroup("label") + cell(cliPathTextField) + .bindText(clientConfiguration::cliPath) + .align(Align.FILL) + .resizableColumn() + .comment(message("settings.claudeCode.cliPath.comment")) + button(message("settings.claudeCode.browse")) { + val descriptor = FileChooserDescriptorFactory.createSingleFileDescriptor() + val chooser = com.intellij.openapi.fileChooser.FileChooser.chooseFile(descriptor, null, null) + chooser?.let { + cliPathTextField.text = it.path + } + }.widthGroup("button") + button(message("settings.claudeCode.detectPath")) { + val detectedPath = service.findClaudePath("") + if (detectedPath != null) { + cliPathTextField.text = detectedPath + } + }.widthGroup("button") + } + } + + private fun Panel.timeoutRow() { + row { + label(message("settings.llmClient.timeout")) + .widthGroup("label") + cell(timeoutTextField) + .bindIntText(clientConfiguration::timeout) + .resizableColumn() + .align(Align.FILL) + .comment(message("settings.claudeCode.timeout.comment")) + } + } + + private fun Panel.modelRow() { + row { + label(message("settings.claudeCode.model")) + .widthGroup("label") + cell(modelComboBox) + .applyToComponent { + isEditable = true + } + .bindItem({ clientConfiguration.modelId }, { + if (it != null) { + clientConfiguration.modelId = it + } + }) + .align(Align.FILL) + .resizableColumn() + .comment(message("settings.claudeCode.model.comment")) + } + } + + override fun verifyConfiguration() { + clientConfiguration.cliPath = cliPathTextField.text + clientConfiguration.timeout = timeoutTextField.text.toIntOrNull() ?: 120 + clientConfiguration.modelId = modelComboBox.item ?: "" + + service.verifyConfigurationCli(clientConfiguration, verifyLabel) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/github/blarc/ai/commits/intellij/plugin/settings/clients/claudeCode/ClaudeCodeClientService.kt b/src/main/kotlin/com/github/blarc/ai/commits/intellij/plugin/settings/clients/claudeCode/ClaudeCodeClientService.kt new file mode 100644 index 0000000..346b61e --- /dev/null +++ b/src/main/kotlin/com/github/blarc/ai/commits/intellij/plugin/settings/clients/claudeCode/ClaudeCodeClientService.kt @@ -0,0 +1,249 @@ +package com.github.blarc.ai.commits.intellij.plugin.settings.clients.claudeCode + +import com.github.blarc.ai.commits.intellij.plugin.AICommitsBundle.message +import com.github.blarc.ai.commits.intellij.plugin.AICommitsUtils.computeDiff +import com.github.blarc.ai.commits.intellij.plugin.AICommitsUtils.constructPrompt +import com.github.blarc.ai.commits.intellij.plugin.AICommitsUtils.getCommonBranch +import com.github.blarc.ai.commits.intellij.plugin.notifications.Notification +import com.github.blarc.ai.commits.intellij.plugin.notifications.sendNotification +import com.github.blarc.ai.commits.intellij.plugin.settings.AppSettings2 +import com.github.blarc.ai.commits.intellij.plugin.settings.ProjectSettings +import com.github.blarc.ai.commits.intellij.plugin.settings.clients.LLMClientService +import com.github.blarc.ai.commits.intellij.plugin.wrap +import com.intellij.icons.AllIcons +import com.intellij.openapi.application.EDT +import com.intellij.openapi.application.ModalityState +import com.intellij.openapi.application.asContextElement +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.service +import com.intellij.openapi.project.Project +import com.intellij.platform.ide.progress.withBackgroundProgress +import com.intellij.ui.components.JBLabel +import com.intellij.vcs.commit.AbstractCommitWorkflowHandler +import com.intellij.vcs.commit.isAmendCommitMode +import dev.langchain4j.model.chat.ChatModel +import dev.langchain4j.model.chat.StreamingChatModel +import git4idea.GitCommit +import git4idea.history.GitHistoryUtils +import git4idea.repo.GitRepositoryManager +import kotlinx.coroutines.* +import kotlinx.serialization.json.* +import java.io.File +import java.util.concurrent.TimeUnit + +@Service(Service.Level.APP) +class ClaudeCodeClientService(private val cs: CoroutineScope) : LLMClientService(cs) { + + companion object { + @JvmStatic + fun getInstance(): ClaudeCodeClientService = service() + + private val CLAUDE_PATHS = listOf( + "claude", // In PATH + System.getProperty("user.home") + "/.claude/local/claude", + System.getProperty("user.home") + "/.local/bin/claude", + System.getenv("LOCALAPPDATA")?.let { "$it\\Claude\\claude.exe" } + ).filterNotNull() + } + + override suspend fun buildChatModel(client: ClaudeCodeClientConfiguration): ChatModel { + throw UnsupportedOperationException("Claude Code uses CLI invocation, not langchain4j ChatModel") + } + + override suspend fun buildStreamingChatModel(client: ClaudeCodeClientConfiguration): StreamingChatModel? { + return null // CLI-based, no streaming model + } + + fun findClaudePath(configuredPath: String): String? { + // Use configured path if provided + if (configuredPath.isNotBlank()) { + val file = File(configuredPath) + if (file.exists() && file.canExecute()) { + return configuredPath + } + return null + } + + // Try to find claude in common locations + for (path in CLAUDE_PATHS) { + try { + // Check if it's in PATH using 'which' (Unix) or 'where' (Windows) + if (path == "claude") { + val whichProcess = ProcessBuilder( + if (System.getProperty("os.name").lowercase().contains("win")) "where" else "which", + "claude" + ).start() + if (whichProcess.waitFor(5, TimeUnit.SECONDS) && whichProcess.exitValue() == 0) { + return whichProcess.inputStream.bufferedReader().readLine()?.trim() + } + } else { + val file = File(path) + if (file.exists() && file.canExecute()) { + return path + } + } + } catch (e: Exception) { + // Continue to next path + } + } + return null + } + + private suspend fun executeClaudeCli( + client: ClaudeCodeClientConfiguration, + prompt: String + ): Result = withContext(Dispatchers.IO) { + val claudePath = findClaudePath(client.cliPath) + ?: return@withContext Result.failure( + IllegalStateException(message("claudeCode.cliNotFound")) + ) + + val command = mutableListOf(claudePath, "-p", "--output-format", "json") + + // Add model if specified + if (client.modelId.isNotBlank()) { + command.add("--model") + command.add(client.modelId) + } + + // Add the prompt as the last argument + command.add(prompt) + + try { + val process = ProcessBuilder(command) + .redirectErrorStream(true) + .start() + + val completed = process.waitFor(client.timeout.toLong(), TimeUnit.SECONDS) + if (!completed) { + process.destroyForcibly() + return@withContext Result.failure( + IllegalStateException(message("claudeCode.timeout")) + ) + } + + val output = process.inputStream.bufferedReader().readText() + + if (process.exitValue() != 0) { + return@withContext Result.failure( + IllegalStateException("CLI exited with code ${process.exitValue()}: $output") + ) + } + + // Parse JSON response + parseClaudeResponse(output) + } catch (e: Exception) { + Result.failure(e) + } + } + + private fun parseClaudeResponse(jsonOutput: String): Result { + return try { + val json = Json { ignoreUnknownKeys = true } + val response = json.parseToJsonElement(jsonOutput).jsonObject + + val isError = response["is_error"]?.jsonPrimitive?.booleanOrNull ?: false + val result = response["result"]?.jsonPrimitive?.contentOrNull + + if (result == null) { + Result.failure(IllegalStateException("No result in Claude response")) + } else if (isError) { + Result.failure(IllegalStateException(result)) + } else { + Result.success(result) + } + } catch (e: Exception) { + Result.failure(IllegalStateException("Failed to parse Claude response: ${e.message}")) + } + } + + fun generateCommitMessageCli( + clientConfiguration: ClaudeCodeClientConfiguration, + commitWorkflowHandler: AbstractCommitWorkflowHandler<*, *>, + project: Project + ) { + val commitContext = commitWorkflowHandler.workflow.commitContext + val includedChanges = commitWorkflowHandler.ui.getIncludedChanges().toMutableList() + + generateCommitMessageJob = cs.launch(ModalityState.current().asContextElement()) { + withBackgroundProgress(project, message("action.background")) { + if (commitContext.isAmendCommitMode) { + includedChanges += getLastCommitChanges(project) + } + + val diff = computeDiff(includedChanges, false, project) + if (diff.isBlank()) { + withContext(Dispatchers.EDT) { + sendNotification(Notification.emptyDiff()) + } + return@withBackgroundProgress + } + + val branch = getCommonBranch(includedChanges, project) + val prompt = constructPrompt( + project.service().activePrompt.content, + diff, + branch, + commitWorkflowHandler.getCommitMessage(), + project + ) + + val result = executeClaudeCli(clientConfiguration, prompt) + + result.fold( + onSuccess = { commitMessage -> + withContext(Dispatchers.EDT) { + commitWorkflowHandler.setCommitMessage(commitMessage) + } + AppSettings2.instance.recordHit() + }, + onFailure = { error -> + withContext(Dispatchers.EDT) { + commitWorkflowHandler.setCommitMessage(error.message ?: message("unknown-error")) + } + } + ) + } + } + } + + fun verifyConfigurationCli(client: ClaudeCodeClientConfiguration, label: JBLabel) { + label.text = message("settings.verify.running") + label.icon = AllIcons.General.InlineRefresh + cs.launch(ModalityState.current().asContextElement()) { + val claudePath = findClaudePath(client.cliPath) + if (claudePath == null) { + withContext(Dispatchers.EDT) { + label.text = message("claudeCode.cliNotFound").wrap(60) + label.icon = AllIcons.General.InspectionsError + } + return@launch + } + + // Test with a simple prompt + val result = executeClaudeCli(client, "Say 'OK' in exactly one word") + withContext(Dispatchers.EDT) { + result.fold( + onSuccess = { + label.text = message("settings.verify.valid") + label.icon = AllIcons.General.InspectionsOK + }, + onFailure = { error -> + label.text = (error.message ?: message("unknown-error")).wrap(60) + label.icon = AllIcons.General.InspectionsError + } + ) + } + } + } + + private suspend fun getLastCommitChanges(project: Project) = withContext(Dispatchers.IO) { + GitRepositoryManager.getInstance(project).repositories.map { repo -> + GitHistoryUtils.history(project, repo.root, "--max-count=1") + }.filter { commits -> + commits.isNotEmpty() + }.map { commits -> + (commits.first() as GitCommit).changes + }.flatten() + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/github/blarc/ai/commits/intellij/plugin/settings/clients/claudeCode/ClaudeCodeClientSharedState.kt b/src/main/kotlin/com/github/blarc/ai/commits/intellij/plugin/settings/clients/claudeCode/ClaudeCodeClientSharedState.kt new file mode 100644 index 0000000..f2929ec --- /dev/null +++ b/src/main/kotlin/com/github/blarc/ai/commits/intellij/plugin/settings/clients/claudeCode/ClaudeCodeClientSharedState.kt @@ -0,0 +1,35 @@ +package com.github.blarc.ai.commits.intellij.plugin.settings.clients.claudeCode + +import com.github.blarc.ai.commits.intellij.plugin.settings.clients.LLMClientSharedState +import com.intellij.openapi.components.* +import com.intellij.util.xmlb.annotations.XCollection + +@Service(Service.Level.APP) +@State(name = "ClaudeCodeClientSharedState", storages = [Storage("AICommitsClaudeCode.xml")]) +class ClaudeCodeClientSharedState : PersistentStateComponent, LLMClientSharedState { + + companion object { + @JvmStatic + fun getInstance(): ClaudeCodeClientSharedState = service() + } + + // Claude Code CLI doesn't have host configuration + @XCollection(style = XCollection.Style.v2) + override val hosts: MutableSet = mutableSetOf() + + // Common model aliases for Claude Code CLI + @XCollection(style = XCollection.Style.v2) + override val modelIds: MutableSet = mutableSetOf( + "", // Empty = use CLI default + "sonnet", + "opus", + "haiku" + ) + + override fun getState(): ClaudeCodeClientSharedState = this + + override fun loadState(state: ClaudeCodeClientSharedState) { + modelIds += state.modelIds + hosts += state.hosts + } +} \ No newline at end of file diff --git a/src/main/resources/icons/claudeCode15.svg b/src/main/resources/icons/claudeCode15.svg new file mode 100644 index 0000000..c65dd62 --- /dev/null +++ b/src/main/resources/icons/claudeCode15.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/src/main/resources/messages/AiCommitsBundle.properties b/src/main/resources/messages/AiCommitsBundle.properties index aa58b9e..091d6c3 100644 --- a/src/main/resources/messages/AiCommitsBundle.properties +++ b/src/main/resources/messages/AiCommitsBundle.properties @@ -161,3 +161,13 @@ settings.amazonBedrock.defaultCredentialsProvider=Default settings.amazonBedrock.staticCredentialsProvider=Static settings.amazonBedrock.profileName=Profile name settings.amazonBedrock.defaultCredentialsProvider.comment=Use Default for discovering credentials from the host's environment and Static for a fixed set of credentials. + +settings.claudeCode.cliPath=CLI Path +settings.claudeCode.cliPath.comment=Path to the Claude Code CLI executable. Leave empty to auto-detect from PATH. +settings.claudeCode.browse=Browse... +settings.claudeCode.detectPath=Detect +settings.claudeCode.timeout.comment=Timeout in seconds for CLI execution. +settings.claudeCode.model=Model +settings.claudeCode.model.comment=Model alias (sonnet, opus, haiku) or full name. Leave empty to use CLI default. +claudeCode.cliNotFound=Claude Code CLI not found. Please install it from https://claude.ai/download or specify the path manually. +claudeCode.timeout=Claude Code CLI execution timed out. From fba8d287b78a34af576196c06f58c8f84cadc48d Mon Sep 17 00:00:00 2001 From: Gregory Kramida Date: Sat, 29 Nov 2025 21:44:33 -0500 Subject: [PATCH 2/4] fix(claude-code): resolve CLI path detection issues MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use interactive bash (-i flag) to inherit user's .bashrc PATH - Read only stdout to avoid bash terminal warnings mixing with output - Filter output lines to find actual path (first line starting with /) - Resolve symlinks to get the real executable path - Close stdin immediately to prevent CLI hanging Fixes detection when claude is installed via npm in non-standard locations (e.g., ~/.npm-global/bin or custom npm prefix). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../claudeCode/ClaudeCodeClientService.kt | 103 ++++++++++++++---- 1 file changed, 83 insertions(+), 20 deletions(-) diff --git a/src/main/kotlin/com/github/blarc/ai/commits/intellij/plugin/settings/clients/claudeCode/ClaudeCodeClientService.kt b/src/main/kotlin/com/github/blarc/ai/commits/intellij/plugin/settings/clients/claudeCode/ClaudeCodeClientService.kt index 346b61e..e03824e 100644 --- a/src/main/kotlin/com/github/blarc/ai/commits/intellij/plugin/settings/clients/claudeCode/ClaudeCodeClientService.kt +++ b/src/main/kotlin/com/github/blarc/ai/commits/intellij/plugin/settings/clients/claudeCode/ClaudeCodeClientService.kt @@ -38,12 +38,23 @@ class ClaudeCodeClientService(private val cs: CoroutineScope) : LLMClientService @JvmStatic fun getInstance(): ClaudeCodeClientService = service() - private val CLAUDE_PATHS = listOf( - "claude", // In PATH - System.getProperty("user.home") + "/.claude/local/claude", - System.getProperty("user.home") + "/.local/bin/claude", - System.getenv("LOCALAPPDATA")?.let { "$it\\Claude\\claude.exe" } - ).filterNotNull() + private val HOME = System.getProperty("user.home") + + private val CLAUDE_PATHS = listOfNotNull( + // Common npm global locations + "$HOME/.npm-global/bin/claude", + "$HOME/.npm/bin/claude", + "/usr/local/bin/claude", + "/usr/bin/claude", + // nvm installations + "$HOME/.nvm/versions/node/*/bin/claude", + // Claude-specific locations + "$HOME/.claude/local/claude", + "$HOME/.local/bin/claude", + // Windows + System.getenv("LOCALAPPDATA")?.let { "$it\\Claude\\claude.exe" }, + System.getenv("APPDATA")?.let { "$it\\npm\\claude.cmd" } + ) } override suspend fun buildChatModel(client: ClaudeCodeClientConfiguration): ChatModel { @@ -64,22 +75,61 @@ class ClaudeCodeClientService(private val cs: CoroutineScope) : LLMClientService return null } - // Try to find claude in common locations - for (path in CLAUDE_PATHS) { + // Try to find claude using shell (inherits user's PATH) + try { + val isWindows = System.getProperty("os.name").lowercase().contains("win") + val shellCommand = if (isWindows) { + arrayOf("cmd", "/c", "where claude") + } else { + // Use interactive shell to get user's PATH from .bashrc + arrayOf("/bin/bash", "-i", "-c", "which claude 2>/dev/null") + } + val process = ProcessBuilder(*shellCommand) + .start() // Don't redirect stderr - we only want stdout + process.outputStream.close() // Close stdin + val completed = process.waitFor(5, TimeUnit.SECONDS) + val exitValue = if (completed) process.exitValue() else -1 + if (completed && exitValue == 0) { + // Read all lines and find one that looks like a path (interactive bash may print warnings) + val lines = process.inputStream.bufferedReader().readLines() + val result = lines.firstOrNull { it.trim().startsWith("/") }?.trim() + if (!result.isNullOrBlank()) { + val file = File(result) + if (file.exists()) { + // Resolve symlinks to get the real path + return try { + file.toPath().toRealPath().toString() + } catch (e: Exception) { + result + } + } + } + } + } catch (e: Exception) { + // Fall through to hardcoded paths + } + + // Try hardcoded common locations + for (pathPattern in CLAUDE_PATHS) { try { - // Check if it's in PATH using 'which' (Unix) or 'where' (Windows) - if (path == "claude") { - val whichProcess = ProcessBuilder( - if (System.getProperty("os.name").lowercase().contains("win")) "where" else "which", - "claude" - ).start() - if (whichProcess.waitFor(5, TimeUnit.SECONDS) && whichProcess.exitValue() == 0) { - return whichProcess.inputStream.bufferedReader().readLine()?.trim() + if (pathPattern.contains("*")) { + // Handle glob patterns (e.g., nvm paths) + val parts = pathPattern.split("*") + if (parts.size == 2) { + val parentDir = File(parts[0]).parentFile + if (parentDir?.exists() == true) { + parentDir.listFiles()?.forEach { dir -> + val candidate = File(dir, parts[1].removePrefix("/")) + if (candidate.exists() && candidate.canExecute()) { + return candidate.absolutePath + } + } + } } } else { - val file = File(path) + val file = File(pathPattern) if (file.exists() && file.canExecute()) { - return path + return pathPattern } } } catch (e: Exception) { @@ -114,15 +164,28 @@ class ClaudeCodeClientService(private val cs: CoroutineScope) : LLMClientService .redirectErrorStream(true) .start() + // Close stdin immediately to prevent CLI from waiting for input + process.outputStream.close() + + // Read output in a separate thread to prevent buffer deadlock + val outputFuture = java.util.concurrent.CompletableFuture.supplyAsync { + process.inputStream.bufferedReader().readText() + } + val completed = process.waitFor(client.timeout.toLong(), TimeUnit.SECONDS) if (!completed) { process.destroyForcibly() + outputFuture.cancel(true) return@withContext Result.failure( IllegalStateException(message("claudeCode.timeout")) ) } - val output = process.inputStream.bufferedReader().readText() + val output = try { + outputFuture.get(5, TimeUnit.SECONDS) + } catch (e: Exception) { + "" + } if (process.exitValue() != 0) { return@withContext Result.failure( @@ -246,4 +309,4 @@ class ClaudeCodeClientService(private val cs: CoroutineScope) : LLMClientService (commits.first() as GitCommit).changes }.flatten() } -} \ No newline at end of file +} From 151cc3cefa6e1cdbc2d1f3a2d2d66163a14c26a8 Mon Sep 17 00:00:00 2001 From: Gregory Kramida Date: Sat, 29 Nov 2025 23:53:50 -0500 Subject: [PATCH 3/4] fix(settings): resolve stale AnAction config values after editing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Look up current config by ID in generateCommitMessageAction instead of using potentially stale `this` (IntelliJ caches AnAction instances) - Call editPanel.apply() when editing existing LLM client configs (was only called when adding new configs) - Add onApply callback to ClaudeCodeClientPanel model combobox to explicitly capture typed values from editable combobox This fixes the bug where editing an LLM client configuration (e.g., changing the model) would not take effect until IDE restart. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../plugin/settings/clients/LLMClientConfiguration.kt | 6 +++++- .../intellij/plugin/settings/clients/LLMClientTable.kt | 7 +++++-- .../settings/clients/claudeCode/ClaudeCodeClientPanel.kt | 5 +++++ 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/src/main/kotlin/com/github/blarc/ai/commits/intellij/plugin/settings/clients/LLMClientConfiguration.kt b/src/main/kotlin/com/github/blarc/ai/commits/intellij/plugin/settings/clients/LLMClientConfiguration.kt index e1de155..651b40a 100644 --- a/src/main/kotlin/com/github/blarc/ai/commits/intellij/plugin/settings/clients/LLMClientConfiguration.kt +++ b/src/main/kotlin/com/github/blarc/ai/commits/intellij/plugin/settings/clients/LLMClientConfiguration.kt @@ -3,6 +3,7 @@ package com.github.blarc.ai.commits.intellij.plugin.settings.clients import com.github.blarc.ai.commits.intellij.plugin.Icons import com.github.blarc.ai.commits.intellij.plugin.notifications.Notification import com.github.blarc.ai.commits.intellij.plugin.notifications.sendNotification +import com.github.blarc.ai.commits.intellij.plugin.settings.AppSettings2 import com.github.blarc.ai.commits.intellij.plugin.settings.ProjectSettings import com.intellij.openapi.actionSystem.ActionUpdateThread import com.intellij.openapi.actionSystem.AnAction @@ -66,7 +67,10 @@ abstract class LLMClientConfiguration( val projectSettings = project.service() projectSettings.splitButtonActionSelectedLLMClientId = this.id - generateCommitMessage(commitWorkflowHandler, project) + // Look up the current configuration by ID to ensure we use the latest settings + // (AnAction instances may be cached by IntelliJ and hold stale values) + val currentConfig = AppSettings2.instance.llmClientConfigurations.find { it.id == this.id } ?: this + currentConfig.generateCommitMessage(commitWorkflowHandler, project) } open fun setCommitMessage(commitWorkflowHandler: AbstractCommitWorkflowHandler<*, *>, prompt: String, result: String) { diff --git a/src/main/kotlin/com/github/blarc/ai/commits/intellij/plugin/settings/clients/LLMClientTable.kt b/src/main/kotlin/com/github/blarc/ai/commits/intellij/plugin/settings/clients/LLMClientTable.kt index c88347c..4bab0ef 100644 --- a/src/main/kotlin/com/github/blarc/ai/commits/intellij/plugin/settings/clients/LLMClientTable.kt +++ b/src/main/kotlin/com/github/blarc/ai/commits/intellij/plugin/settings/clients/LLMClientTable.kt @@ -121,6 +121,7 @@ class LLMClientTable { var llmClient = newLlmClientConfiguration ?: llmClientConfigurations[0] private val cardLayout = JBCardLayout() + private var editPanel: DialogPanel? = null init { title = newLlmClientConfiguration?.let { "Edit LLM Client" } ?: "Add LLM Client" @@ -131,15 +132,17 @@ class LLMClientTable { override fun doOKAction() { if (newLlmClientConfiguration == null) { (cardLayout.findComponentById(llmClient.getClientName()) as DialogPanel).apply() + } else { + // Apply the edit panel to save typed values from editable comboboxes + editPanel?.apply() } - // TODO: Figure out how to call apply of the currently active panel super.doOKAction() } override fun createCenterPanel() = if (newLlmClientConfiguration == null) { createCardSplitter() } else { - llmClient.panel().create() + llmClient.panel().create().also { editPanel = it } }.apply { isResizable = false // Add 200 so there is space for verification message. diff --git a/src/main/kotlin/com/github/blarc/ai/commits/intellij/plugin/settings/clients/claudeCode/ClaudeCodeClientPanel.kt b/src/main/kotlin/com/github/blarc/ai/commits/intellij/plugin/settings/clients/claudeCode/ClaudeCodeClientPanel.kt index 632ec58..5a7e618 100644 --- a/src/main/kotlin/com/github/blarc/ai/commits/intellij/plugin/settings/clients/claudeCode/ClaudeCodeClientPanel.kt +++ b/src/main/kotlin/com/github/blarc/ai/commits/intellij/plugin/settings/clients/claudeCode/ClaudeCodeClientPanel.kt @@ -74,6 +74,11 @@ class ClaudeCodeClientPanel private constructor( clientConfiguration.modelId = it } }) + .onApply { + // Explicitly capture typed value from editable combobox + // bindItem doesn't reliably capture typed values not in the dropdown + modelComboBox.item?.let { clientConfiguration.modelId = it } + } .align(Align.FILL) .resizableColumn() .comment(message("settings.claudeCode.model.comment")) From 8ad2815251036531dc818b11887fdfd12cd6404a Mon Sep 17 00:00:00 2001 From: Gregory Kramida Date: Mon, 1 Dec 2025 19:05:47 -0500 Subject: [PATCH 4/4] docs: add Claude Code to supported models in README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 9dd8100..88182aa 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ plugin and configure a LLM API client in plugin's settings: Settings - Amazon Bedrock - Anthropic - Azure Open AI +- Claude Code (via CLI) - Gemini Google AI - Gemini Vertex AI - GitHub Models