diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2837dbd0..53289336 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,5 @@
+- Fixed authentication for profiles with the "Claude API" connection type: the API key is now correctly recognised by Claude Code on session start
+
# 0.20.4 (2026-04-09)
- Added Maven Central publication support: `release` Maven profile with GPG signing and Sonatype Central Portal deployment, plus CI `deploy` job that publishes artifacts on release builds
diff --git a/pom.xml b/pom.xml
index 5aabe253..083c16b5 100644
--- a/pom.xml
+++ b/pom.xml
@@ -6,7 +6,7 @@
io.github.nbclaudecodegui
netbeans-claude-code-gui
- 0.20.0-SNAPSHOT
+ 0.20.5-SNAPSHOT
nbm
Netbeans Claude Code GUI
NetBeans plugin that provides a GUI for Claude Code CLI — run claude code sessions directly inside the IDE.
diff --git a/src/main/java/io/github/nbclaudecodegui/process/ClaudeProcess.java b/src/main/java/io/github/nbclaudecodegui/process/ClaudeProcess.java
index 575c2c30..f3748a11 100644
--- a/src/main/java/io/github/nbclaudecodegui/process/ClaudeProcess.java
+++ b/src/main/java/io/github/nbclaudecodegui/process/ClaudeProcess.java
@@ -128,8 +128,9 @@ public PtyProcess start(String workingDir, ClaudeProfile profile, String extraCl
Map env = buildEnv(profile, ClaudeCodePreferences.getProfilesDir());
+ boolean apiKeyHelper = profile != null && !profile.getApiKey().isBlank() && profile.getBaseUrl().isBlank();
LOG.info("Starting Claude: profile=" + (profile != null ? profile.getName() + " (" + profile.computeConnectionType() + ")" : "Default")
- + ", ANTHROPIC_API_KEY=" + (!env.getOrDefault("ANTHROPIC_API_KEY", "").isBlank() ? "SET" : "NOT SET")
+ + ", apiKeyHelper=" + (apiKeyHelper ? "SET" : "NOT SET")
+ ", ANTHROPIC_AUTH_TOKEN=" + (!env.getOrDefault("ANTHROPIC_AUTH_TOKEN", "").isBlank() ? "SET" : "NOT SET")
+ ", ANTHROPIC_BASE_URL=" + env.getOrDefault("ANTHROPIC_BASE_URL", "(not set)")
+ ", CLAUDE_CONFIG_DIR=" + env.getOrDefault("CLAUDE_CONFIG_DIR", "(inherited)"));
@@ -148,7 +149,7 @@ public PtyProcess start(String workingDir, ClaudeProfile profile, String extraCl
cmd.add(buildMcpConfigJson(port));
LOG.info("Passing --mcp-config with netbeans SSE server on port " + port);
try {
- writeSettingsLocalJson(workingDir, port);
+ writeSettingsLocalJson(workingDir, port, profile);
} catch (IOException e) {
LOG.warning("Could not write .claude/settings.local.json: " + e.getMessage());
}
@@ -281,7 +282,7 @@ public boolean isRunning() {
* @param port the port the MCP SSE server is listening on
* @throws IOException if the file cannot be written
*/
- static void writeSettingsLocalJson(String workingDir, int port) throws IOException {
+ static void writeSettingsLocalJson(String workingDir, int port, ClaudeProfile profile) throws IOException {
Path claudeDir = Path.of(workingDir, ".claude");
Files.createDirectories(claudeDir);
Path cfg = claudeDir.resolve("settings.local.json");
@@ -290,7 +291,7 @@ static void writeSettingsLocalJson(String workingDir, int port) throws IOExcepti
? Files.readString(cfg, StandardCharsets.UTF_8)
: "{}";
- String merged = mergeSettingsJson(existing, port);
+ String merged = mergeSettingsJson(existing, port, profile);
Files.writeString(cfg, merged, StandardCharsets.UTF_8);
LOG.info("settings.local.json written (merged): " + cfg.toAbsolutePath());
}
@@ -314,7 +315,7 @@ static void writeSettingsLocalJson(String workingDir, int port) throws IOExcepti
* @param port the MCP/hook server port
* @return merged JSON string
*/
- static String mergeSettingsJson(String existingJson, int port) {
+ static String mergeSettingsJson(String existingJson, int port, ClaudeProfile profile) {
try {
ObjectNode root = existingJson == null || existingJson.isBlank()
? MAPPER.createObjectNode()
@@ -387,6 +388,15 @@ static String mergeSettingsJson(String existingJson, int port) {
root.set("hooks", hooks);
+ // apiKeyHelper for CLAUDE_API (only apiKey set, no baseUrl)
+ if (profile != null && !profile.getApiKey().isBlank()
+ && profile.getBaseUrl().isBlank()) {
+ String key = profile.getApiKey().replace("\"", "\\\"");
+ root.put("apiKeyHelper", "echo " + key);
+ } else {
+ root.remove("apiKeyHelper");
+ }
+
return MAPPER.writeValueAsString(root);
} catch (Exception e) {
@@ -485,6 +495,8 @@ static String cleanedSettingsJson(String existingJson) {
}
}
+ root.remove("apiKeyHelper");
+
return root.isEmpty() ? null : MAPPER.writeValueAsString(root);
} catch (Exception e) {
diff --git a/src/main/java/io/github/nbclaudecodegui/settings/ClaudeProfile.java b/src/main/java/io/github/nbclaudecodegui/settings/ClaudeProfile.java
index bc73ac5c..c15a1183 100644
--- a/src/main/java/io/github/nbclaudecodegui/settings/ClaudeProfile.java
+++ b/src/main/java/io/github/nbclaudecodegui/settings/ClaudeProfile.java
@@ -259,7 +259,7 @@ public Map toEnvVars() {
// Auth
switch (computeConnectionType()) {
case SUBSCRIPTION -> env.put("CLAUDE_CODE_OAUTH_TOKEN", blankToEmpty(token));
- case CLAUDE_API -> env.put("ANTHROPIC_API_KEY", blankToEmpty(apiKey));
+ case CLAUDE_API -> { /* API key is written to settings.local.json as apiKeyHelper */ }
case OTHER_API -> {
env.put("ANTHROPIC_AUTH_TOKEN", blankToEmpty(apiKey));
env.put("ANTHROPIC_BASE_URL", blankToEmpty(baseUrl));
diff --git a/src/test/java/io/github/nbclaudecodegui/process/ClaudeProcessProfileTest.java b/src/test/java/io/github/nbclaudecodegui/process/ClaudeProcessProfileTest.java
index a8b1dfd2..1b506b61 100644
--- a/src/test/java/io/github/nbclaudecodegui/process/ClaudeProcessProfileTest.java
+++ b/src/test/java/io/github/nbclaudecodegui/process/ClaudeProcessProfileTest.java
@@ -56,11 +56,13 @@ void buildEnv_namedProfile_setsClaudeConfigDir() {
// -------------------------------------------------------------------------
@Test
- void buildEnv_apiKeyProfile_injectsApiKey() {
+ void buildEnv_apiKeyProfile_doesNotInjectApiKey() {
+ // CLAUDE_API type: key is written to settings.local.json as apiKeyHelper, not env var
ClaudeProfile p = ClaudeProfile.createNamed("API");
p.setApiKey("sk-test-123");
Map env = ClaudeProcess.buildEnv(p, PROFILES_DIR);
- assertEquals("sk-test-123", env.get("ANTHROPIC_API_KEY"));
+ assertFalse(env.containsKey("ANTHROPIC_API_KEY"),
+ "CLAUDE_API key must not be injected as env var (it goes into apiKeyHelper instead)");
}
@Test
diff --git a/src/test/java/io/github/nbclaudecodegui/process/ClaudeProcessTest.java b/src/test/java/io/github/nbclaudecodegui/process/ClaudeProcessTest.java
index 3acac4d3..96cc147f 100644
--- a/src/test/java/io/github/nbclaudecodegui/process/ClaudeProcessTest.java
+++ b/src/test/java/io/github/nbclaudecodegui/process/ClaudeProcessTest.java
@@ -1,6 +1,7 @@
package io.github.nbclaudecodegui.process;
import com.pty4j.PtyProcess;
+import io.github.nbclaudecodegui.settings.ClaudeProfile;
import java.io.File;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
@@ -94,7 +95,7 @@ void testStopKillsProcess() throws Exception {
@Test
void testMergeEmptyFileAddsOurEntries() {
- String result = ClaudeProcess.mergeSettingsJson("{}", 8990);
+ String result = ClaudeProcess.mergeSettingsJson("{}", 8990, null);
assertFalse(result.contains("\"netbeans\""), "mcpServers.netbeans should NOT appear in settings.local.json");
assertFalse(result.contains("/sse"), "SSE URL should NOT appear in settings.local.json");
assertTrue(result.contains("PreToolUse"), "should add PreToolUse hook");
@@ -106,20 +107,69 @@ void testMergeEmptyFileAddsOurEntries() {
void testMergePreservesOtherMcpServers() {
// mcpServers in settings.local.json should be left untouched (not added to by plugin)
String existing = "{\"mcpServers\":{\"other-server\":{\"type\":\"stdio\",\"command\":\"foo\"}}}";
- String result = ClaudeProcess.mergeSettingsJson(existing, 9000);
+ String result = ClaudeProcess.mergeSettingsJson(existing, 9000, null);
assertTrue(result.contains("\"other-server\""), "should preserve user-provided MCP server");
assertFalse(result.contains("\"netbeans\""), "netbeans should NOT be written to settings.local.json");
}
@Test
void testMergeUpdatesPortWhenAlreadyPresent() {
- String existing = ClaudeProcess.mergeSettingsJson("{}", 8888);
- String result = ClaudeProcess.mergeSettingsJson(existing, 9999);
+ String existing = ClaudeProcess.mergeSettingsJson("{}", 8888, null);
+ String result = ClaudeProcess.mergeSettingsJson(existing, 9999, null);
assertTrue(result.contains("localhost:9999/hook"), "hook URL should use new port");
assertFalse(result.contains("localhost:8888"), "should not keep old port");
assertEquals(1, countOccurrences(result, "Edit|Write|MultiEdit"));
}
+ @Test
+ void testMergeWithClaudeApiProfileInjectsApiKeyHelper() {
+ ClaudeProfile p = ClaudeProfile.createNamed("API");
+ p.setApiKey("sk-ant-abc123");
+ String result = ClaudeProcess.mergeSettingsJson("{}", 9000, p);
+ assertTrue(result.contains("\"apiKeyHelper\""), "should inject apiKeyHelper for CLAUDE_API profile");
+ assertTrue(result.contains("echo sk-ant-abc123"), "apiKeyHelper should contain echo ");
+ }
+
+ @Test
+ void testMergeWithNullProfileNoApiKeyHelper() {
+ String result = ClaudeProcess.mergeSettingsJson("{}", 9000, null);
+ assertFalse(result.contains("apiKeyHelper"), "should not inject apiKeyHelper when no profile");
+ }
+
+ @Test
+ void testMergeWithOtherApiProfileNoApiKeyHelper() {
+ ClaudeProfile p = ClaudeProfile.createNamed("Other");
+ p.setApiKey("sk-ant-abc123");
+ p.setBaseUrl("https://proxy.example.com");
+ String result = ClaudeProcess.mergeSettingsJson("{}", 9000, p);
+ assertFalse(result.contains("apiKeyHelper"),
+ "should not inject apiKeyHelper for OTHER_API profile (uses env vars instead)");
+ }
+
+ @Test
+ void testCleanupRemovesApiKeyHelper() {
+ ClaudeProfile p = ClaudeProfile.createNamed("API");
+ p.setApiKey("sk-ant-abc123");
+ String merged = ClaudeProcess.mergeSettingsJson("{}", 9000, p);
+ assertTrue(merged.contains("apiKeyHelper"), "merged should have apiKeyHelper");
+ String cleaned = ClaudeProcess.cleanedSettingsJson(merged);
+ assertNull(cleaned, "file with only plugin content (including apiKeyHelper) should yield null");
+ }
+
+ @Test
+ void testCleanupRemovesApiKeyHelperButKeepsUserContent() {
+ ClaudeProfile p = ClaudeProfile.createNamed("API");
+ p.setApiKey("sk-ant-secret");
+ String existing = "{\"myCustomKey\":\"someValue\"}";
+ String merged = ClaudeProcess.mergeSettingsJson(existing, 9000, p);
+ assertTrue(merged.contains("apiKeyHelper"), "merged should have apiKeyHelper");
+ assertTrue(merged.contains("myCustomKey"), "merged should preserve user content");
+ String cleaned = ClaudeProcess.cleanedSettingsJson(merged);
+ assertNotNull(cleaned, "should keep file — user content remains");
+ assertFalse(cleaned.contains("apiKeyHelper"), "cleaned should remove apiKeyHelper");
+ assertTrue(cleaned.contains("myCustomKey"), "cleaned should preserve user content");
+ }
+
// -------------------------------------------------------------------------
// buildMcpConfigJson
// -------------------------------------------------------------------------
@@ -146,7 +196,7 @@ void testMergePreservesOtherPreToolUseHooks() {
+ "\"hooks\":{\"PreToolUse\":["
+ "{\"matcher\":\"Read\",\"hooks\":[{\"type\":\"http\",\"url\":\"http://other/hook\"}]}"
+ "]}}";
- String result = ClaudeProcess.mergeSettingsJson(existing, 9000);
+ String result = ClaudeProcess.mergeSettingsJson(existing, 9000, null);
assertTrue(result.contains("\"Read\""), "should preserve other PreToolUse hooks");
assertTrue(result.contains("Edit|Write|MultiEdit"), "should add our hook");
// Our matcher appears exactly once
@@ -155,8 +205,8 @@ void testMergePreservesOtherPreToolUseHooks() {
@Test
void testMergeReplacesOurHookWithUpdatedPort() {
- String existing = ClaudeProcess.mergeSettingsJson("{}", 8888);
- String result = ClaudeProcess.mergeSettingsJson(existing, 9999);
+ String existing = ClaudeProcess.mergeSettingsJson("{}", 8888, null);
+ String result = ClaudeProcess.mergeSettingsJson(existing, 9999, null);
assertEquals(1, countOccurrences(result, "Edit|Write|MultiEdit"),
"our hook matcher should appear exactly once");
assertTrue(result.contains("localhost:9999/hook"), "hook URL should be updated");
@@ -166,7 +216,7 @@ void testMergeReplacesOurHookWithUpdatedPort() {
void testMergeRemovesStaleNetbeansEntry() {
// Simulate a file left by old plugin (< 0.19.22) that has mcpServers.netbeans
String stale = "{\"mcpServers\":{\"netbeans\":{\"type\":\"sse\",\"url\":\"http://localhost:8888/sse\"}}}";
- String result = ClaudeProcess.mergeSettingsJson(stale, 9000);
+ String result = ClaudeProcess.mergeSettingsJson(stale, 9000, null);
assertFalse(result.contains("\"netbeans\""), "stale mcpServers.netbeans should be removed on merge");
assertFalse(result.contains("mcpServers"), "mcpServers should be gone if netbeans was the only entry");
}
@@ -176,7 +226,7 @@ void testMergeRemovesStaleNetbeansButKeepsOtherMcpServers() {
String stale = "{\"mcpServers\":{"
+ "\"netbeans\":{\"type\":\"sse\",\"url\":\"http://localhost:8888/sse\"},"
+ "\"other\":{\"type\":\"stdio\",\"command\":\"foo\"}}}";
- String result = ClaudeProcess.mergeSettingsJson(stale, 9000);
+ String result = ClaudeProcess.mergeSettingsJson(stale, 9000, null);
assertFalse(result.contains("\"netbeans\""), "stale netbeans entry should be removed");
assertTrue(result.contains("\"other\""), "other mcpServers entries should be preserved");
}
@@ -207,7 +257,7 @@ void testCleanupRemovesStaleNetbeansButKeepsUserMcpServers() {
@Test
void testCleanupReturnsNullWhenOnlyOurEntries() {
- String merged = ClaudeProcess.mergeSettingsJson("{}", 9000);
+ String merged = ClaudeProcess.mergeSettingsJson("{}", 9000, null);
String cleaned = ClaudeProcess.cleanedSettingsJson(merged);
assertNull(cleaned, "file containing only plugin entries should yield null (→ delete)");
}
@@ -219,7 +269,7 @@ void testCleanupRemovesOurEntriesButKeepsOthers() {
+ "\"hooks\":{\"PreToolUse\":["
+ "{\"matcher\":\"Read\",\"hooks\":[{\"type\":\"http\",\"url\":\"http://other/hook\"}]}"
+ "]}}";
- String merged = ClaudeProcess.mergeSettingsJson(existing, 9000);
+ String merged = ClaudeProcess.mergeSettingsJson(existing, 9000, null);
String cleaned = ClaudeProcess.cleanedSettingsJson(merged);
assertNotNull(cleaned, "should not delete file when user content remains");
@@ -232,7 +282,7 @@ void testCleanupRemovesOurEntriesButKeepsOthers() {
@Test
void testCleanupPreservesNonPreToolUseHooks() {
String existing = "{\"hooks\":{\"PostToolUse\":[{\"matcher\":\"*\",\"hooks\":[]}]}}";
- String merged = ClaudeProcess.mergeSettingsJson(existing, 9000);
+ String merged = ClaudeProcess.mergeSettingsJson(existing, 9000, null);
String cleaned = ClaudeProcess.cleanedSettingsJson(merged);
assertNotNull(cleaned, "should not delete when PostToolUse hook remains");
@@ -248,7 +298,7 @@ void testCleanupDeletesFileWhenOnlyPluginContent(@TempDir Path tmpDir) throws Ex
Path claudeDir = tmpDir.resolve(".claude");
Files.createDirectories(claudeDir);
Path cfg = claudeDir.resolve("settings.local.json");
- Files.writeString(cfg, ClaudeProcess.mergeSettingsJson("{}", 9000), StandardCharsets.UTF_8);
+ Files.writeString(cfg, ClaudeProcess.mergeSettingsJson("{}", 9000, null), StandardCharsets.UTF_8);
ClaudeProcess.cleanupSettingsLocalJson(tmpDir.toString());
@@ -261,7 +311,7 @@ void testCleanupUpdatesFileWhenUserContentPresent(@TempDir Path tmpDir) throws E
Files.createDirectories(claudeDir);
Path cfg = claudeDir.resolve("settings.local.json");
String userContent = "{\"mcpServers\":{\"my-server\":{\"type\":\"stdio\",\"command\":\"bar\"}}}";
- Files.writeString(cfg, ClaudeProcess.mergeSettingsJson(userContent, 9000), StandardCharsets.UTF_8);
+ Files.writeString(cfg, ClaudeProcess.mergeSettingsJson(userContent, 9000, null), StandardCharsets.UTF_8);
ClaudeProcess.cleanupSettingsLocalJson(tmpDir.toString());
diff --git a/src/test/java/io/github/nbclaudecodegui/settings/ClaudeProfileTest.java b/src/test/java/io/github/nbclaudecodegui/settings/ClaudeProfileTest.java
index c57c0315..fcca6a61 100644
--- a/src/test/java/io/github/nbclaudecodegui/settings/ClaudeProfileTest.java
+++ b/src/test/java/io/github/nbclaudecodegui/settings/ClaudeProfileTest.java
@@ -148,11 +148,13 @@ void toEnvVars_subscription_injectsToken() {
}
@Test
- void toEnvVars_claudeApi_injectsApiKey() {
+ void toEnvVars_claudeApi_doesNotInjectApiKey() {
+ // CLAUDE_API key is written to settings.local.json as apiKeyHelper, not env var
ClaudeProfile p = ClaudeProfile.createNamed("P");
p.setApiKey("sk-123");
Map env = p.toEnvVars();
- assertEquals("sk-123", env.get("ANTHROPIC_API_KEY"));
+ assertFalse(env.containsKey("ANTHROPIC_API_KEY"),
+ "CLAUDE_API must not inject ANTHROPIC_API_KEY as env var");
assertFalse(env.containsKey("ANTHROPIC_BASE_URL"));
}