From 8f4f145196fb10a007e8e81e541395b55bd19f1b Mon Sep 17 00:00:00 2001 From: Ruben Beuker Date: Sun, 22 Mar 2026 21:33:26 +0100 Subject: [PATCH 1/3] fix: add search config fallbacks --- .../Providers/BraveSearchProvider.swift | 6 +- .../Providers/TavilySearchProvider.swift | 6 +- .../Services/TokenManager.swift | 288 +++++++++++++++--- scripts/query-brave-search.sh | 123 +++++++- scripts/query-tavily-search.sh | 136 ++++++++- 5 files changed, 485 insertions(+), 74 deletions(-) diff --git a/CopilotMonitor/CopilotMonitor/Providers/BraveSearchProvider.swift b/CopilotMonitor/CopilotMonitor/Providers/BraveSearchProvider.swift index d62c72a..e4d2f4c 100644 --- a/CopilotMonitor/CopilotMonitor/Providers/BraveSearchProvider.swift +++ b/CopilotMonitor/CopilotMonitor/Providers/BraveSearchProvider.swift @@ -101,9 +101,10 @@ final class BraveSearchProvider: ProviderProtocol { } func fetch() async throws -> ProviderResult { - guard let apiKey = tokenManager.getBraveSearchAPIKey() else { + guard let apiKeyInfo = tokenManager.getBraveSearchAPIKeyWithSource() else { throw ProviderError.authenticationFailed("Brave Search API key not available") } + let apiKey = apiKeyInfo.key let mode = currentRefreshMode() var state = stateQueue.sync { loadState() } @@ -142,7 +143,6 @@ final class BraveSearchProvider: ProviderProtocol { let usage = ProviderUsage.quotaBased(remaining: remaining, entitlement: limit, overagePermitted: false) let mcpUsagePercent = normalizedBraveQuotaUsagePercent(used: used, limit: limit) let resetText = formatResetText(seconds: state.lastResetSeconds) - let authSource = tokenManager.lastFoundOpenCodeConfigPath?.path ?? "~/.config/opencode/opencode.json" let sourceSummary = mode == .eventOnly ? "Estimated (event-based)" : "Mode: \(mode.title)" let details = DetailedUsage( @@ -150,7 +150,7 @@ final class BraveSearchProvider: ProviderProtocol { limit: Double(limit), limitRemaining: Double(remaining), resetPeriod: resetText, - authSource: authSource, + authSource: apiKeyInfo.source, authUsageSummary: sourceSummary, mcpUsagePercent: mcpUsagePercent ) diff --git a/CopilotMonitor/CopilotMonitor/Providers/TavilySearchProvider.swift b/CopilotMonitor/CopilotMonitor/Providers/TavilySearchProvider.swift index dd3ff05..5432bcb 100644 --- a/CopilotMonitor/CopilotMonitor/Providers/TavilySearchProvider.swift +++ b/CopilotMonitor/CopilotMonitor/Providers/TavilySearchProvider.swift @@ -48,10 +48,11 @@ final class TavilySearchProvider: ProviderProtocol { } func fetch() async throws -> ProviderResult { - guard let apiKey = tokenManager.getTavilyAPIKey() else { + guard let apiKeyInfo = tokenManager.getTavilyAPIKeyWithSource() else { tavilyLogger.error("Tavily API key not found") throw ProviderError.authenticationFailed("Tavily API key not available") } + let apiKey = apiKeyInfo.key guard let url = URL(string: "https://api.tavily.com/usage") else { throw ProviderError.networkError("Invalid Tavily usage endpoint") @@ -94,14 +95,13 @@ final class TavilySearchProvider: ProviderProtocol { let usage = ProviderUsage.quotaBased(remaining: remaining, entitlement: resolvedLimit, overagePermitted: false) let mcpUsagePercent = normalizedTavilyQuotaUsagePercent(used: resolvedUsed, limit: resolvedLimit) - let authSource = tokenManager.lastFoundOpenCodeConfigPath?.path ?? "~/.config/opencode/opencode.json" let resetText = formatEstimatedMonthlyResetText() let details = DetailedUsage( monthlyUsage: Double(resolvedUsed), limit: Double(resolvedLimit), limitRemaining: Double(remaining), resetPeriod: resetText, - authSource: authSource, + authSource: apiKeyInfo.source, authUsageSummary: decoded.account?.currentPlan ?? "Auto refresh", mcpUsagePercent: mcpUsagePercent ) diff --git a/CopilotMonitor/CopilotMonitor/Services/TokenManager.swift b/CopilotMonitor/CopilotMonitor/Services/TokenManager.swift index 023ff8d..4f42aae 100644 --- a/CopilotMonitor/CopilotMonitor/Services/TokenManager.swift +++ b/CopilotMonitor/CopilotMonitor/Services/TokenManager.swift @@ -593,6 +593,13 @@ final class TokenManager: @unchecked Sendable { /// Path where opencode.json was found private(set) var lastFoundOpenCodeConfigPath: URL? + /// Cached fallback search key JSON (search-keys.json) + private var cachedSearchKeysJSON: [String: Any]? + private var searchKeysCacheTimestamp: Date? + + /// Path where search-keys.json was found + private(set) var lastFoundSearchKeysPath: URL? + private init() { logger.info("TokenManager initialized") } @@ -666,47 +673,167 @@ final class TokenManager: @unchecked Sendable { ) } + /// Possible search-keys.json locations in priority order: + /// 1. $XDG_CONFIG_HOME/opencode/search-keys.json (if XDG_CONFIG_HOME is set) + /// 2. ~/.config/opencode/search-keys.json (XDG default on macOS/Linux) + /// 3. ~/.local/share/opencode/search-keys.json (fallback) + /// 4. ~/Library/Application Support/opencode/search-keys.json (macOS fallback) + func getSearchKeyFilePaths() -> [URL] { + return buildOpenCodeFilePaths( + envVarName: "XDG_CONFIG_HOME", + envRelativePathComponents: ["opencode", "search-keys.json"], + fallbackRelativePathComponents: [ + [".config", "opencode", "search-keys.json"], + [".local", "share", "opencode", "search-keys.json"], + ["Library", "Application Support", "opencode", "search-keys.json"] + ] + ) + } + + private func readJSONDictionaryAllowingComments( + from paths: [URL], + cache: inout [String: Any]?, + timestamp: inout Date?, + foundPath: inout URL?, + warningPrefix: String + ) -> [String: Any]? { + if let cache, + let timestamp, + Date().timeIntervalSince(timestamp) < cacheValiditySeconds { + return cache + } + + let fileManager = FileManager.default + for candidatePath in paths { + guard fileManager.fileExists(atPath: candidatePath.path) else { + continue + } + guard fileManager.isReadableFile(atPath: candidatePath.path) else { + logger.warning("\(warningPrefix) file not readable at \(candidatePath.path)") + continue + } + + do { + let data = try Data(contentsOf: candidatePath) + let normalizedData = stripJSONComments(from: data) + let jsonObject = try JSONSerialization.jsonObject(with: normalizedData) + guard let dict = jsonObject as? [String: Any] else { + logger.warning("\(warningPrefix) is not a JSON object at \(candidatePath.path)") + continue + } + + foundPath = candidatePath + cache = dict + timestamp = Date() + return dict + } catch { + logger.warning("Failed to parse \(warningPrefix) at \(candidatePath.path): \(error.localizedDescription)") + } + } + + foundPath = nil + cache = nil + timestamp = nil + return nil + } + private func readOpenCodeConfigJSON() -> [String: Any]? { return queue.sync { - if let cached = cachedOpenCodeConfigJSON, - let timestamp = openCodeConfigCacheTimestamp, - Date().timeIntervalSince(timestamp) < cacheValiditySeconds { - return cached - } + return readJSONDictionaryAllowingComments( + from: getOpenCodeConfigFilePaths(), + cache: &cachedOpenCodeConfigJSON, + timestamp: &openCodeConfigCacheTimestamp, + foundPath: &lastFoundOpenCodeConfigPath, + warningPrefix: "OpenCode config" + ) + } + } - let fileManager = FileManager.default - let paths = getOpenCodeConfigFilePaths() - for configPath in paths { - guard fileManager.fileExists(atPath: configPath.path) else { + private func readSearchKeysJSON() -> [String: Any]? { + return queue.sync { + return readJSONDictionaryAllowingComments( + from: getSearchKeyFilePaths(), + cache: &cachedSearchKeysJSON, + timestamp: &searchKeysCacheTimestamp, + foundPath: &lastFoundSearchKeysPath, + warningPrefix: "Search keys config" + ) + } + } + + private func stripJSONComments(from data: Data) -> Data { + guard let text = String(data: data, encoding: .utf8) else { + return data + } + + enum State { + case normal + case string + case lineComment + case blockComment + } + + var result = String() + result.reserveCapacity(text.count) + + var state: State = .normal + var isEscaped = false + let characters = Array(text) + var index = 0 + + while index < characters.count { + let current = characters[index] + let next = index + 1 < characters.count ? characters[index + 1] : nil + + switch state { + case .normal: + if current == "/", next == "/" { + state = .lineComment + index += 2 continue } - guard fileManager.isReadableFile(atPath: configPath.path) else { - logger.warning("OpenCode config file not readable at \(configPath.path)") + if current == "/", next == "*" { + state = .blockComment + index += 2 continue } + result.append(current) + if current == "\"" { + state = .string + isEscaped = false + } - do { - let data = try Data(contentsOf: configPath) - let jsonObject = try JSONSerialization.jsonObject(with: data) - guard let dict = jsonObject as? [String: Any] else { - logger.warning("OpenCode config is not a JSON object at \(configPath.path)") - continue - } + case .string: + result.append(current) + if isEscaped { + isEscaped = false + } else if current == "\\" { + isEscaped = true + } else if current == "\"" { + state = .normal + } - lastFoundOpenCodeConfigPath = configPath - cachedOpenCodeConfigJSON = dict - openCodeConfigCacheTimestamp = Date() - return dict - } catch { - logger.warning("Failed to parse OpenCode config at \(configPath.path): \(error.localizedDescription)") + case .lineComment: + if current == "\n" || current == "\r" { + result.append(current) + state = .normal + } + + case .blockComment: + if current == "*", next == "/" { + state = .normal + index += 2 + continue + } + if current == "\n" || current == "\r" { + result.append(current) } } - lastFoundOpenCodeConfigPath = nil - cachedOpenCodeConfigJSON = nil - openCodeConfigCacheTimestamp = nil - return nil + index += 1 } + + return Data(result.utf8) } private func resolveConfigValue(_ rawValue: String?) -> String? { @@ -745,6 +872,39 @@ final class TokenManager: @unchecked Sendable { return current as? String } + private func resolvedSearchAPIKey( + configDictionary: [String: Any]?, + configSourcePath: String?, + configPaths: [[String]], + searchKeysDictionary: [String: Any]?, + searchKeysSourcePath: String?, + searchKeyPaths: [[String]], + directEnvironmentVariable: String + ) -> (key: String, source: String)? { + if let configDictionary { + for path in configPaths { + if let resolved = resolveConfigValue(nestedString(in: configDictionary, path: path)) { + return (resolved, configSourcePath ?? "opencode.json") + } + } + } + + if let searchKeysDictionary { + for path in searchKeyPaths { + if let resolved = resolveConfigValue(nestedString(in: searchKeysDictionary, path: path)) { + return (resolved, searchKeysSourcePath ?? "search-keys.json") + } + } + } + + if let envValue = ProcessInfo.processInfo.environment[directEnvironmentVariable], + let resolved = resolveConfigValue(envValue) { + return (resolved, "Environment variable \(directEnvironmentVariable)") + } + + return nil + } + /// Returns the path where auth.json was found, or nil if not found /// Useful for displaying in UI to help users troubleshoot private(set) var lastFoundAuthPath: URL? @@ -2785,32 +2945,60 @@ final class TokenManager: @unchecked Sendable { } func getTavilyAPIKey() -> String? { - guard let config = readOpenCodeConfigJSON() else { return nil } - - let envKey = nestedString(in: config, path: ["mcp", "tavily", "environment", "TAVILY_API_KEY"]) - if let resolved = resolveConfigValue(envKey) { - return resolved - } - - let authorization = nestedString(in: config, path: ["mcp", "tavily", "headers", "Authorization"]) - if let resolved = resolveConfigValue(authorization) { - return resolved - } - - let headerKey = nestedString(in: config, path: ["mcp", "tavily", "headers", "X-API-Key"]) - return resolveConfigValue(headerKey) + return getTavilyAPIKeyWithSource()?.key } func getBraveSearchAPIKey() -> String? { - guard let config = readOpenCodeConfigJSON() else { return nil } - - let envKey = nestedString(in: config, path: ["mcp", "brave-search", "environment", "BRAVE_API_KEY"]) - if let resolved = resolveConfigValue(envKey) { - return resolved - } + return getBraveSearchAPIKeyWithSource()?.key + } + + func getTavilyAPIKeyWithSource() -> (key: String, source: String)? { + let config = readOpenCodeConfigJSON() + let searchKeys = readSearchKeysJSON() + + return resolvedSearchAPIKey( + configDictionary: config, + configSourcePath: lastFoundOpenCodeConfigPath?.path, + configPaths: [ + ["mcp", "tavily-search", "environment", "TAVILY_API_KEY"], + ["mcp", "tavily-search", "headers", "Authorization"], + ["mcp", "tavily-search", "headers", "X-API-Key"], + ["mcp", "tavily", "environment", "TAVILY_API_KEY"], + ["mcp", "tavily", "headers", "Authorization"], + ["mcp", "tavily", "headers", "X-API-Key"] + ], + searchKeysDictionary: searchKeys, + searchKeysSourcePath: lastFoundSearchKeysPath?.path, + searchKeyPaths: [ + ["tavily", "apiKey"], + ["tavily", "authorization"], + ["tavily", "xApiKey"], + ["TAVILY_API_KEY"] + ], + directEnvironmentVariable: "TAVILY_API_KEY" + ) + } - let headerKey = nestedString(in: config, path: ["mcp", "brave-search", "headers", "X-Subscription-Token"]) - return resolveConfigValue(headerKey) + func getBraveSearchAPIKeyWithSource() -> (key: String, source: String)? { + let config = readOpenCodeConfigJSON() + let searchKeys = readSearchKeysJSON() + + return resolvedSearchAPIKey( + configDictionary: config, + configSourcePath: lastFoundOpenCodeConfigPath?.path, + configPaths: [ + ["mcp", "brave-search", "environment", "BRAVE_API_KEY"], + ["mcp", "brave-search", "headers", "X-Subscription-Token"] + ], + searchKeysDictionary: searchKeys, + searchKeysSourcePath: lastFoundSearchKeysPath?.path, + searchKeyPaths: [ + ["brave-search", "apiKey"], + ["brave-search", "subscriptionToken"], + ["BRAVE_API_KEY"] + ], + directEnvironmentVariable: "BRAVE_API_KEY" + ) } /// Gets Gemini refresh token from discovered Gemini account sources diff --git a/scripts/query-brave-search.sh b/scripts/query-brave-search.sh index e62846d..fcdea7c 100755 --- a/scripts/query-brave-search.sh +++ b/scripts/query-brave-search.sh @@ -9,6 +9,67 @@ CONFIG_PATHS=( "$HOME/Library/Application Support/opencode/opencode.json" ) +SEARCH_KEYS_PATHS=( + "${XDG_CONFIG_HOME:-$HOME/.config}/opencode/search-keys.json" + "$HOME/.config/opencode/search-keys.json" + "$HOME/.local/share/opencode/search-keys.json" + "$HOME/Library/Application Support/opencode/search-keys.json" +) + +strip_json_comments() { + python3 - "$1" <<'PY' +import sys +from pathlib import Path + +text = Path(sys.argv[1]).read_text(encoding='utf-8') +result = [] +state = 'normal' +escaped = False +i = 0 + +while i < len(text): + ch = text[i] + nxt = text[i + 1] if i + 1 < len(text) else '' + + if state == 'normal': + if ch == '/' and nxt == '/': + state = 'line' + i += 2 + continue + if ch == '/' and nxt == '*': + state = 'block' + i += 2 + continue + result.append(ch) + if ch == '"': + state = 'string' + escaped = False + elif state == 'string': + result.append(ch) + if escaped: + escaped = False + elif ch == '\\': + escaped = True + elif ch == '"': + state = 'normal' + elif state == 'line': + if ch in '\r\n': + result.append(ch) + state = 'normal' + elif state == 'block': + if ch == '*' and nxt == '/': + state = 'normal' + i += 2 + continue + if ch in '\r\n': + result.append(ch) + + i += 1 + +sys.stdout.write(''.join(result)) +PY +} + resolve_config_value() { local value="$1" @@ -177,29 +238,77 @@ for path in "${CONFIG_PATHS[@]}"; do fi done -if [[ -z "$CONFIG_FILE" ]]; then - echo "Error: OpenCode config file not found" - exit 1 +SEARCH_KEYS_FILE="" +for path in "${SEARCH_KEYS_PATHS[@]}"; do + if [[ -f "$path" ]]; then + SEARCH_KEYS_FILE="$path" + break + fi +done + +NORMALIZED_CONFIG_FILE="" +NORMALIZED_SEARCH_KEYS_FILE="" + +if [[ -n "$CONFIG_FILE" ]]; then + NORMALIZED_CONFIG_FILE="$(mktemp)" + strip_json_comments "$CONFIG_FILE" > "$NORMALIZED_CONFIG_FILE" +fi + +if [[ -n "$SEARCH_KEYS_FILE" ]]; then + NORMALIZED_SEARCH_KEYS_FILE="$(mktemp)" + strip_json_comments "$SEARCH_KEYS_FILE" > "$NORMALIZED_SEARCH_KEYS_FILE" fi -RAW_ENV_KEY=$(jq -r '.mcp["brave-search"].environment.BRAVE_API_KEY // empty' "$CONFIG_FILE") -RAW_HEADER_KEY=$(jq -r '.mcp["brave-search"].headers["X-Subscription-Token"] // empty' "$CONFIG_FILE") +cleanup() { + rm -f "$NORMALIZED_CONFIG_FILE" "$NORMALIZED_SEARCH_KEYS_FILE" +} +trap cleanup EXIT + +RAW_ENV_KEY="" +RAW_HEADER_KEY="" + +if [[ -n "$NORMALIZED_CONFIG_FILE" ]]; then + RAW_ENV_KEY=$(jq -r '.mcp["brave-search"].environment.BRAVE_API_KEY // empty' "$NORMALIZED_CONFIG_FILE") + RAW_HEADER_KEY=$(jq -r '.mcp["brave-search"].headers["X-Subscription-Token"] // empty' "$NORMALIZED_CONFIG_FILE") +fi API_KEY=$(resolve_config_value "$RAW_ENV_KEY") if [[ -z "$API_KEY" ]]; then API_KEY=$(resolve_config_value "$RAW_HEADER_KEY") fi +if [[ -z "$API_KEY" && -n "$NORMALIZED_SEARCH_KEYS_FILE" ]]; then + RAW_SEARCH_KEY=$(jq -r '."brave-search".apiKey // .BRAVE_API_KEY // empty' "$NORMALIZED_SEARCH_KEYS_FILE") + RAW_SEARCH_TOKEN=$(jq -r '."brave-search".subscriptionToken // empty' "$NORMALIZED_SEARCH_KEYS_FILE") + + API_KEY=$(resolve_config_value "$RAW_SEARCH_KEY") + if [[ -z "$API_KEY" ]]; then + API_KEY=$(resolve_config_value "$RAW_SEARCH_TOKEN") + fi +fi + +if [[ -z "$API_KEY" ]]; then + API_KEY=$(resolve_config_value "$BRAVE_API_KEY") +fi + if [[ -z "$API_KEY" ]]; then - echo "Error: Brave Search API key not found in $CONFIG_FILE" + echo "Error: Brave Search API key not found" echo "Expected one of:" echo " - .mcp[\"brave-search\"].environment.BRAVE_API_KEY" echo " - .mcp[\"brave-search\"].headers[\"X-Subscription-Token\"]" + echo " - .[\"brave-search\"].apiKey in search-keys.json" + echo " - BRAVE_API_KEY environment variable" exit 1 fi echo "=== Brave Search Usage ===" -echo "Config: $CONFIG_FILE" +if [[ -n "$CONFIG_FILE" ]]; then + echo "Config: $CONFIG_FILE" +elif [[ -n "$SEARCH_KEYS_FILE" ]]; then + echo "Config: $SEARCH_KEYS_FILE" +else + echo "Config: env:BRAVE_API_KEY" +fi echo "" headers_file="$(mktemp)" diff --git a/scripts/query-tavily-search.sh b/scripts/query-tavily-search.sh index bcc4b9d..efc821f 100755 --- a/scripts/query-tavily-search.sh +++ b/scripts/query-tavily-search.sh @@ -9,6 +9,67 @@ CONFIG_PATHS=( "$HOME/Library/Application Support/opencode/opencode.json" ) +SEARCH_KEYS_PATHS=( + "${XDG_CONFIG_HOME:-$HOME/.config}/opencode/search-keys.json" + "$HOME/.config/opencode/search-keys.json" + "$HOME/.local/share/opencode/search-keys.json" + "$HOME/Library/Application Support/opencode/search-keys.json" +) + +strip_json_comments() { + python3 - "$1" <<'PY' +import sys +from pathlib import Path + +text = Path(sys.argv[1]).read_text(encoding='utf-8') +result = [] +state = 'normal' +escaped = False +i = 0 + +while i < len(text): + ch = text[i] + nxt = text[i + 1] if i + 1 < len(text) else '' + + if state == 'normal': + if ch == '/' and nxt == '/': + state = 'line' + i += 2 + continue + if ch == '/' and nxt == '*': + state = 'block' + i += 2 + continue + result.append(ch) + if ch == '"': + state = 'string' + escaped = False + elif state == 'string': + result.append(ch) + if escaped: + escaped = False + elif ch == '\\': + escaped = True + elif ch == '"': + state = 'normal' + elif state == 'line': + if ch in '\r\n': + result.append(ch) + state = 'normal' + elif state == 'block': + if ch == '*' and nxt == '/': + state = 'normal' + i += 2 + continue + if ch in '\r\n': + result.append(ch) + + i += 1 + +sys.stdout.write(''.join(result)) +PY +} + resolve_config_value() { local value="$1" @@ -44,14 +105,41 @@ for path in "${CONFIG_PATHS[@]}"; do fi done -if [[ -z "$CONFIG_FILE" ]]; then - echo "Error: OpenCode config file not found" - exit 1 +SEARCH_KEYS_FILE="" +for path in "${SEARCH_KEYS_PATHS[@]}"; do + if [[ -f "$path" ]]; then + SEARCH_KEYS_FILE="$path" + break + fi +done + +NORMALIZED_CONFIG_FILE="" +NORMALIZED_SEARCH_KEYS_FILE="" + +if [[ -n "$CONFIG_FILE" ]]; then + NORMALIZED_CONFIG_FILE="$(mktemp)" + strip_json_comments "$CONFIG_FILE" > "$NORMALIZED_CONFIG_FILE" fi -RAW_ENV_KEY=$(jq -r '.mcp.tavily.environment.TAVILY_API_KEY // empty' "$CONFIG_FILE") -RAW_AUTH_HEADER=$(jq -r '.mcp.tavily.headers.Authorization // empty' "$CONFIG_FILE") -RAW_X_API_KEY=$(jq -r '.mcp.tavily.headers["X-API-Key"] // empty' "$CONFIG_FILE") +if [[ -n "$SEARCH_KEYS_FILE" ]]; then + NORMALIZED_SEARCH_KEYS_FILE="$(mktemp)" + strip_json_comments "$SEARCH_KEYS_FILE" > "$NORMALIZED_SEARCH_KEYS_FILE" +fi + +cleanup() { + rm -f "$NORMALIZED_CONFIG_FILE" "$NORMALIZED_SEARCH_KEYS_FILE" +} +trap cleanup EXIT + +RAW_ENV_KEY="" +RAW_AUTH_HEADER="" +RAW_X_API_KEY="" + +if [[ -n "$NORMALIZED_CONFIG_FILE" ]]; then + RAW_ENV_KEY=$(jq -r '.mcp["tavily-search"].environment.TAVILY_API_KEY // .mcp.tavily.environment.TAVILY_API_KEY // empty' "$NORMALIZED_CONFIG_FILE") + RAW_AUTH_HEADER=$(jq -r '.mcp["tavily-search"].headers.Authorization // .mcp.tavily.headers.Authorization // empty' "$NORMALIZED_CONFIG_FILE") + RAW_X_API_KEY=$(jq -r '.mcp["tavily-search"].headers["X-API-Key"] // .mcp.tavily.headers["X-API-Key"] // empty' "$NORMALIZED_CONFIG_FILE") +fi API_KEY=$(resolve_config_value "$RAW_ENV_KEY") if [[ -z "$API_KEY" ]]; then @@ -61,17 +149,43 @@ if [[ -z "$API_KEY" ]]; then API_KEY=$(resolve_config_value "$RAW_X_API_KEY") fi +if [[ -z "$API_KEY" && -n "$NORMALIZED_SEARCH_KEYS_FILE" ]]; then + RAW_SEARCH_KEY=$(jq -r '.tavily.apiKey // .TAVILY_API_KEY // empty' "$NORMALIZED_SEARCH_KEYS_FILE") + RAW_SEARCH_AUTH=$(jq -r '.tavily.authorization // empty' "$NORMALIZED_SEARCH_KEYS_FILE") + RAW_SEARCH_X_API_KEY=$(jq -r '.tavily.xApiKey // empty' "$NORMALIZED_SEARCH_KEYS_FILE") + + API_KEY=$(resolve_config_value "$RAW_SEARCH_KEY") + if [[ -z "$API_KEY" ]]; then + API_KEY=$(resolve_config_value "$RAW_SEARCH_AUTH") + fi + if [[ -z "$API_KEY" ]]; then + API_KEY=$(resolve_config_value "$RAW_SEARCH_X_API_KEY") + fi +fi + +if [[ -z "$API_KEY" ]]; then + API_KEY=$(resolve_config_value "$TAVILY_API_KEY") +fi + if [[ -z "$API_KEY" ]]; then - echo "Error: Tavily API key not found in $CONFIG_FILE" + echo "Error: Tavily API key not found" echo "Expected one of:" - echo " - .mcp.tavily.environment.TAVILY_API_KEY" - echo " - .mcp.tavily.headers.Authorization" - echo " - .mcp.tavily.headers[\"X-API-Key\"]" + echo " - .mcp[\"tavily-search\"].environment.TAVILY_API_KEY" + echo " - .mcp[\"tavily-search\"].headers.Authorization" + echo " - .mcp[\"tavily-search\"].headers[\"X-API-Key\"]" + echo " - .tavily.apiKey in search-keys.json" + echo " - TAVILY_API_KEY environment variable" exit 1 fi echo "=== Tavily Usage ===" -echo "Config: $CONFIG_FILE" +if [[ -n "$CONFIG_FILE" ]]; then + echo "Config: $CONFIG_FILE" +elif [[ -n "$SEARCH_KEYS_FILE" ]]; then + echo "Config: $SEARCH_KEYS_FILE" +else + echo "Config: env:TAVILY_API_KEY" +fi echo "" HTTP_PAYLOAD=$(curl -sS -w $'\n%{http_code}' "https://api.tavily.com/usage" \ From e1a15995d08260f5139263ffb35ac7cbff65b7fc Mon Sep 17 00:00:00 2001 From: Ruben Beuker Date: Mon, 23 Mar 2026 14:55:40 +0100 Subject: [PATCH 2/3] fix: resolve lint violations in search config fallback --- .../Providers/AntigravityProvider.swift | 2 +- .../Services/TokenManager.swift | 101 ++++++++++-------- 2 files changed, 59 insertions(+), 44 deletions(-) diff --git a/CopilotMonitor/CopilotMonitor/Providers/AntigravityProvider.swift b/CopilotMonitor/CopilotMonitor/Providers/AntigravityProvider.swift index 067c46e..c73f9d1 100644 --- a/CopilotMonitor/CopilotMonitor/Providers/AntigravityProvider.swift +++ b/CopilotMonitor/CopilotMonitor/Providers/AntigravityProvider.swift @@ -463,7 +463,7 @@ final class AntigravityProvider: ProviderProtocol { index += 1 result |= UInt64(byte & 0x7F) << shift - if (byte & 0x80) == 0 { + if byte & 0x80 == 0 { return result } diff --git a/CopilotMonitor/CopilotMonitor/Services/TokenManager.swift b/CopilotMonitor/CopilotMonitor/Services/TokenManager.swift index 4f42aae..0432673 100644 --- a/CopilotMonitor/CopilotMonitor/Services/TokenManager.swift +++ b/CopilotMonitor/CopilotMonitor/Services/TokenManager.swift @@ -872,27 +872,30 @@ final class TokenManager: @unchecked Sendable { return current as? String } + private struct SearchAPIKeyLookupSource { + let dictionary: [String: Any]? + let sourcePath: String? + let paths: [[String]] + let fallbackSourceName: String + } + private func resolvedSearchAPIKey( - configDictionary: [String: Any]?, - configSourcePath: String?, - configPaths: [[String]], - searchKeysDictionary: [String: Any]?, - searchKeysSourcePath: String?, - searchKeyPaths: [[String]], + configSource: SearchAPIKeyLookupSource, + searchKeysSource: SearchAPIKeyLookupSource, directEnvironmentVariable: String ) -> (key: String, source: String)? { - if let configDictionary { - for path in configPaths { + if let configDictionary = configSource.dictionary { + for path in configSource.paths { if let resolved = resolveConfigValue(nestedString(in: configDictionary, path: path)) { - return (resolved, configSourcePath ?? "opencode.json") + return (resolved, configSource.sourcePath ?? configSource.fallbackSourceName) } } } - if let searchKeysDictionary { - for path in searchKeyPaths { + if let searchKeysDictionary = searchKeysSource.dictionary { + for path in searchKeysSource.paths { if let resolved = resolveConfigValue(nestedString(in: searchKeysDictionary, path: path)) { - return (resolved, searchKeysSourcePath ?? "search-keys.json") + return (resolved, searchKeysSource.sourcePath ?? searchKeysSource.fallbackSourceName) } } } @@ -2957,24 +2960,30 @@ final class TokenManager: @unchecked Sendable { let searchKeys = readSearchKeysJSON() return resolvedSearchAPIKey( - configDictionary: config, - configSourcePath: lastFoundOpenCodeConfigPath?.path, - configPaths: [ - ["mcp", "tavily-search", "environment", "TAVILY_API_KEY"], - ["mcp", "tavily-search", "headers", "Authorization"], - ["mcp", "tavily-search", "headers", "X-API-Key"], - ["mcp", "tavily", "environment", "TAVILY_API_KEY"], - ["mcp", "tavily", "headers", "Authorization"], - ["mcp", "tavily", "headers", "X-API-Key"] - ], - searchKeysDictionary: searchKeys, - searchKeysSourcePath: lastFoundSearchKeysPath?.path, - searchKeyPaths: [ - ["tavily", "apiKey"], - ["tavily", "authorization"], - ["tavily", "xApiKey"], - ["TAVILY_API_KEY"] - ], + configSource: SearchAPIKeyLookupSource( + dictionary: config, + sourcePath: lastFoundOpenCodeConfigPath?.path, + paths: [ + ["mcp", "tavily-search", "environment", "TAVILY_API_KEY"], + ["mcp", "tavily-search", "headers", "Authorization"], + ["mcp", "tavily-search", "headers", "X-API-Key"], + ["mcp", "tavily", "environment", "TAVILY_API_KEY"], + ["mcp", "tavily", "headers", "Authorization"], + ["mcp", "tavily", "headers", "X-API-Key"] + ], + fallbackSourceName: "opencode.json" + ), + searchKeysSource: SearchAPIKeyLookupSource( + dictionary: searchKeys, + sourcePath: lastFoundSearchKeysPath?.path, + paths: [ + ["tavily", "apiKey"], + ["tavily", "authorization"], + ["tavily", "xApiKey"], + ["TAVILY_API_KEY"] + ], + fallbackSourceName: "search-keys.json" + ), directEnvironmentVariable: "TAVILY_API_KEY" ) } @@ -2984,19 +2993,25 @@ final class TokenManager: @unchecked Sendable { let searchKeys = readSearchKeysJSON() return resolvedSearchAPIKey( - configDictionary: config, - configSourcePath: lastFoundOpenCodeConfigPath?.path, - configPaths: [ - ["mcp", "brave-search", "environment", "BRAVE_API_KEY"], - ["mcp", "brave-search", "headers", "X-Subscription-Token"] - ], - searchKeysDictionary: searchKeys, - searchKeysSourcePath: lastFoundSearchKeysPath?.path, - searchKeyPaths: [ - ["brave-search", "apiKey"], - ["brave-search", "subscriptionToken"], - ["BRAVE_API_KEY"] - ], + configSource: SearchAPIKeyLookupSource( + dictionary: config, + sourcePath: lastFoundOpenCodeConfigPath?.path, + paths: [ + ["mcp", "brave-search", "environment", "BRAVE_API_KEY"], + ["mcp", "brave-search", "headers", "X-Subscription-Token"] + ], + fallbackSourceName: "opencode.json" + ), + searchKeysSource: SearchAPIKeyLookupSource( + dictionary: searchKeys, + sourcePath: lastFoundSearchKeysPath?.path, + paths: [ + ["brave-search", "apiKey"], + ["brave-search", "subscriptionToken"], + ["BRAVE_API_KEY"] + ], + fallbackSourceName: "search-keys.json" + ), directEnvironmentVariable: "BRAVE_API_KEY" ) } From aa78a7d64b71c963b6845efa03b5a69d5ca9ca46 Mon Sep 17 00:00:00 2001 From: Ruben Beuker Date: Fri, 27 Mar 2026 11:10:02 +0100 Subject: [PATCH 3/3] fix: preserve brave search temp file cleanup --- scripts/query-brave-search.sh | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/scripts/query-brave-search.sh b/scripts/query-brave-search.sh index fcdea7c..fbdef61 100755 --- a/scripts/query-brave-search.sh +++ b/scripts/query-brave-search.sh @@ -248,6 +248,8 @@ done NORMALIZED_CONFIG_FILE="" NORMALIZED_SEARCH_KEYS_FILE="" +headers_file="" +body_file="" if [[ -n "$CONFIG_FILE" ]]; then NORMALIZED_CONFIG_FILE="$(mktemp)" @@ -260,7 +262,7 @@ if [[ -n "$SEARCH_KEYS_FILE" ]]; then fi cleanup() { - rm -f "$NORMALIZED_CONFIG_FILE" "$NORMALIZED_SEARCH_KEYS_FILE" + rm -f "$NORMALIZED_CONFIG_FILE" "$NORMALIZED_SEARCH_KEYS_FILE" "$headers_file" "$body_file" } trap cleanup EXIT @@ -313,7 +315,6 @@ echo "" headers_file="$(mktemp)" body_file="$(mktemp)" -trap 'rm -f "$headers_file" "$body_file"' EXIT HTTP_CODE=$(curl -sS -o "$body_file" -D "$headers_file" -w "%{http_code}" \ "https://api.search.brave.com/res/v1/web/search?q=opencode&count=1" \