diff --git a/CHANGELOG.md b/CHANGELOG.md index 1cb8f932..315d0846 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,4 @@ +- Fixed authentication for profiles with the "Claude API" connection type: the API key is now correctly recognised by Claude Code on session start - Added capability of changing the profile storage directory and sharing a single directory with several profiles - Fixed drag-and-drop into text fields: files are now inserted at the text caret position instead of where the mouse was dropped - Fixed Accept/Decline and Yes/No button colors to respect the IDE Look and Feel theme (dark themes no longer show clashing hardcoded colors) diff --git a/pom.xml b/pom.xml index add1ea3f..8e0ab695 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ io.github.nbclaudecodegui netbeans-claude-code-gui - 0.21.26-SNAPSHOT + 0.21.27-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 8ffabfa9..53b62f40 100644 --- a/src/main/java/io/github/nbclaudecodegui/process/ClaudeProcess.java +++ b/src/main/java/io/github/nbclaudecodegui/process/ClaudeProcess.java @@ -146,8 +146,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)") @@ -168,7 +169,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()); } @@ -336,7 +337,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"); @@ -345,7 +346,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()); } @@ -369,7 +370,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() @@ -442,6 +443,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) { @@ -540,6 +550,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 7d8effa9..96f46ac4 100644 --- a/src/main/java/io/github/nbclaudecodegui/settings/ClaudeProfile.java +++ b/src/main/java/io/github/nbclaudecodegui/settings/ClaudeProfile.java @@ -267,7 +267,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 3fc4c631..4485d67f 100644 --- a/src/test/java/io/github/nbclaudecodegui/process/ClaudeProcessProfileTest.java +++ b/src/test/java/io/github/nbclaudecodegui/process/ClaudeProcessProfileTest.java @@ -68,11 +68,13 @@ void buildEnv_namedProfile_storageDirMatchesDefault_noClaudeConfigDir() { // ------------------------------------------------------------------------- @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 092a59a0..ee679571 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 86574f31..e8c27ab1 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")); }