Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

<groupId>io.github.nbclaudecodegui</groupId>
<artifactId>netbeans-claude-code-gui</artifactId>
<version>0.20.0-SNAPSHOT</version>
<version>0.20.5-SNAPSHOT</version>
<packaging>nbm</packaging>
<name>Netbeans Claude Code GUI</name>
<description>NetBeans plugin that provides a GUI for Claude Code CLI — run claude code sessions directly inside the IDE.</description>
Expand Down
22 changes: 17 additions & 5 deletions src/main/java/io/github/nbclaudecodegui/process/ClaudeProcess.java
Original file line number Diff line number Diff line change
Expand Up @@ -128,8 +128,9 @@ public PtyProcess start(String workingDir, ClaudeProfile profile, String extraCl

Map<String, String> 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)"));
Expand All @@ -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());
}
Expand Down Expand Up @@ -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");
Expand All @@ -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());
}
Expand All @@ -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()
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -485,6 +495,8 @@ static String cleanedSettingsJson(String existingJson) {
}
}

root.remove("apiKeyHelper");

return root.isEmpty() ? null : MAPPER.writeValueAsString(root);

} catch (Exception e) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -259,7 +259,7 @@ public Map<String, String> 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));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, String> 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
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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");
Expand All @@ -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 <key>");
}

@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
// -------------------------------------------------------------------------
Expand All @@ -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
Expand All @@ -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");
Expand All @@ -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");
}
Expand All @@ -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");
}
Expand Down Expand Up @@ -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)");
}
Expand All @@ -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");
Expand All @@ -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");
Expand All @@ -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());

Expand All @@ -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());

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, String> 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"));
}

Expand Down
Loading