diff --git a/CopilotMonitor/CopilotMonitor/App/StatusBarController.swift b/CopilotMonitor/CopilotMonitor/App/StatusBarController.swift index d1709f16..a62cdc8c 100644 --- a/CopilotMonitor/CopilotMonitor/App/StatusBarController.swift +++ b/CopilotMonitor/CopilotMonitor/App/StatusBarController.swift @@ -2518,7 +2518,8 @@ final class StatusBarController: NSObject { func parseSingleSource(_ rawSource: String) -> String? { let lowercased = rawSource.lowercased() - if lowercased.contains("opencode") { + if lowercased.contains("opencode") + || rawSource == TokenManager.shared.getOpenCodeDatabasePath().path { return "OpenCode" } @@ -4024,7 +4025,7 @@ final class StatusBarController: NSObject { submenu.addItem(NSMenuItem.separator()) let authItem = NSMenuItem() authItem.view = createDisabledLabelView( - text: "Token From: ~/.local/share/opencode/auth.json", + text: "Token From: \(TokenManager.shared.openCodeAuthSourceLabel)", icon: NSImage(systemSymbolName: "key", accessibilityDescription: "Auth Source"), multiline: true ) diff --git a/CopilotMonitor/CopilotMonitor/Providers/ChutesProvider.swift b/CopilotMonitor/CopilotMonitor/Providers/ChutesProvider.swift index d9b086a0..a688abaf 100644 --- a/CopilotMonitor/CopilotMonitor/Providers/ChutesProvider.swift +++ b/CopilotMonitor/CopilotMonitor/Providers/ChutesProvider.swift @@ -142,7 +142,7 @@ final class ChutesProvider: ProviderProtocol { chutesMonthlyValueCapUSD: monthlyValueCapUSD, chutesMonthlyValueUsedUSD: monthlyValueUsedUSD, chutesMonthlyValueUsedPercent: monthlyValueUsedPercent, - authSource: tokenManager.lastFoundAuthPath?.path ?? "~/.local/share/opencode/auth.json" + authSource: tokenManager.openCodeAuthSourceLabel ) return ProviderResult(usage: providerUsage, details: details) diff --git a/CopilotMonitor/CopilotMonitor/Providers/DeepSeekProvider.swift b/CopilotMonitor/CopilotMonitor/Providers/DeepSeekProvider.swift index 18d8dd03..1dd05b5d 100644 --- a/CopilotMonitor/CopilotMonitor/Providers/DeepSeekProvider.swift +++ b/CopilotMonitor/CopilotMonitor/Providers/DeepSeekProvider.swift @@ -106,7 +106,7 @@ final class DeepSeekProvider: ProviderProtocol { balanceCurrency: currency, balanceGranted: grantedBalance, balanceToppedUp: toppedUpBalance, - authSource: tokenManager.lastFoundAuthPath?.path ?? "~/.local/share/opencode/auth.json" + authSource: tokenManager.openCodeAuthSourceLabel ) // `cost` stays nil: it represents money already spent, while DeepSeek diff --git a/CopilotMonitor/CopilotMonitor/Providers/KimiProvider.swift b/CopilotMonitor/CopilotMonitor/Providers/KimiProvider.swift index fde8f7f4..0d16860d 100644 --- a/CopilotMonitor/CopilotMonitor/Providers/KimiProvider.swift +++ b/CopilotMonitor/CopilotMonitor/Providers/KimiProvider.swift @@ -151,7 +151,7 @@ final class KimiProvider: ProviderProtocol { sevenDayReset: weeklyReset, planType: planType, email: kimiResponse.user?.userId, - authSource: "~/.local/share/opencode/auth.json" + authSource: tokenManager.openCodeAuthSourceLabel ) return ProviderResult(usage: providerUsage, details: details) diff --git a/CopilotMonitor/CopilotMonitor/Providers/MiniMaxProvider.swift b/CopilotMonitor/CopilotMonitor/Providers/MiniMaxProvider.swift index be1a584a..09d1e877 100644 --- a/CopilotMonitor/CopilotMonitor/Providers/MiniMaxProvider.swift +++ b/CopilotMonitor/CopilotMonitor/Providers/MiniMaxProvider.swift @@ -185,7 +185,7 @@ final class MiniMaxProvider: ProviderProtocol { fiveHourReset: dateFromMilliseconds(primaryRow.endTime), sevenDayUsage: weeklyUsage, sevenDayReset: dateFromMilliseconds(primaryRow.weeklyEndTime), - authSource: "~/.local/share/opencode/auth.json" + authSource: tokenManager.openCodeAuthSourceLabel ) logger.info( diff --git a/CopilotMonitor/CopilotMonitor/Providers/NanoGptProvider.swift b/CopilotMonitor/CopilotMonitor/Providers/NanoGptProvider.swift index 24af0732..67c0dbac 100644 --- a/CopilotMonitor/CopilotMonitor/Providers/NanoGptProvider.swift +++ b/CopilotMonitor/CopilotMonitor/Providers/NanoGptProvider.swift @@ -365,7 +365,7 @@ final class NanoGptProvider: ProviderProtocol { sevenDayUsage: weeklyInputTokenPercentUsed, sevenDayReset: dateFromMilliseconds(usageResponse.weeklyInputTokens?.resetAt), creditsBalance: parseDouble(balanceResponse?.usdBalance), - authSource: tokenManager.lastFoundAuthPath?.path ?? "~/.local/share/opencode/auth.json" + authSource: tokenManager.openCodeAuthSourceLabel ) logger.info( diff --git a/CopilotMonitor/CopilotMonitor/Providers/OpenCodeGoProvider.swift b/CopilotMonitor/CopilotMonitor/Providers/OpenCodeGoProvider.swift index dafa953f..27847038 100644 --- a/CopilotMonitor/CopilotMonitor/Providers/OpenCodeGoProvider.swift +++ b/CopilotMonitor/CopilotMonitor/Providers/OpenCodeGoProvider.swift @@ -89,7 +89,7 @@ final class OpenCodeGoProvider: ProviderProtocol { overagePermitted: false ) - let authPath = tokenManager.lastFoundAuthPath?.path ?? "~/.local/share/opencode/auth.json" + let authPath = tokenManager.openCodeAuthSourceLabel let details = DetailedUsage( fiveHourUsage: dashboardUsage.rolling?.usagePercent, fiveHourReset: dashboardUsage.rolling?.resetDate, diff --git a/CopilotMonitor/CopilotMonitor/Providers/OpenRouterProvider.swift b/CopilotMonitor/CopilotMonitor/Providers/OpenRouterProvider.swift index 4f948b35..a0badb9d 100644 --- a/CopilotMonitor/CopilotMonitor/Providers/OpenRouterProvider.swift +++ b/CopilotMonitor/CopilotMonitor/Providers/OpenRouterProvider.swift @@ -78,7 +78,7 @@ final class OpenRouterProvider: ProviderProtocol { monthlyCost: monthlyCost, creditsRemaining: remainingCredits, creditsTotal: creditsResponse.data.total_credits, - authSource: "~/.local/share/opencode/auth.json" + authSource: TokenManager.shared.openCodeAuthSourceLabel ) return ProviderResult( diff --git a/CopilotMonitor/CopilotMonitor/Providers/SyntheticProvider.swift b/CopilotMonitor/CopilotMonitor/Providers/SyntheticProvider.swift index fffe7cb6..d4cebdb0 100644 --- a/CopilotMonitor/CopilotMonitor/Providers/SyntheticProvider.swift +++ b/CopilotMonitor/CopilotMonitor/Providers/SyntheticProvider.swift @@ -92,7 +92,7 @@ final class SyntheticProvider: ProviderProtocol { overagePermitted: false ) - let authSource = tokenManager.lastFoundAuthPath?.path ?? "~/.local/share/opencode/auth.json" + let authSource = tokenManager.openCodeAuthSourceLabel let details = DetailedUsage( limit: Double(limit), limitRemaining: Double(remaining), diff --git a/CopilotMonitor/CopilotMonitor/Providers/ZaiCodingPlanProvider.swift b/CopilotMonitor/CopilotMonitor/Providers/ZaiCodingPlanProvider.swift index 9be127bd..ffc1ce30 100644 --- a/CopilotMonitor/CopilotMonitor/Providers/ZaiCodingPlanProvider.swift +++ b/CopilotMonitor/CopilotMonitor/Providers/ZaiCodingPlanProvider.swift @@ -275,7 +275,7 @@ final class ZaiCodingPlanProvider: ProviderProtocol { } let details = DetailedUsage( - authSource: "~/.local/share/opencode/auth.json", + authSource: tokenManager.openCodeAuthSourceLabel, tokenUsagePercent: tokenUsagePercent, tokenUsageReset: dateFromMilliseconds((tokenLimit ?? creditSessionLimit)?.nextResetTime), tokenUsageUsed: (tokenLimit ?? creditSessionLimit)?.currentValue, diff --git a/CopilotMonitor/CopilotMonitor/Services/TokenManager.swift b/CopilotMonitor/CopilotMonitor/Services/TokenManager.swift index f3d2ef3e..bf9bc700 100644 --- a/CopilotMonitor/CopilotMonitor/Services/TokenManager.swift +++ b/CopilotMonitor/CopilotMonitor/Services/TokenManager.swift @@ -8,7 +8,7 @@ private let logger = Logger(subsystem: "com.opencodeproviders", category: "Token // MARK: - Data Structures for JSON Parsing -/// OpenCode Auth structure for ~/.local/share/opencode/auth.json +/// Provider credentials decoded from OpenCode's database or legacy auth.json. struct OpenCodeAuth: Codable { struct OAuth: Codable { let type: String @@ -306,7 +306,7 @@ struct OpenCodeAuth: Codable { throw DecodingError.dataCorrupted( DecodingError.Context( codingPath: container.codingPath, - debugDescription: "No valid auth entries found in auth.json" + debugDescription: "No valid OpenCode auth entries found" ) ) } @@ -600,7 +600,7 @@ struct AntigravityAccounts: Codable { enum GeminiAuthSource: Equatable { /// NoeFabris/opencode-antigravity-auth (~/.config/opencode/antigravity-accounts.json) case antigravity - /// jenslys/opencode-gemini-auth (OpenCode auth.json google.oauth) + /// jenslys/opencode-gemini-auth (OpenCode google OAuth credentials) case opencodeAuth /// Gemini CLI OAuth credentials (~/.gemini/oauth_creds.json) case oauthCreds @@ -696,7 +696,7 @@ struct OpenCodeGeminiAuthContainer: Decodable { let google: GeminiOAuthAuth? } -/// Gemini OAuth payload as stored in OpenCode auth.json +/// Gemini OAuth payload from the OpenCode credential store. struct GeminiOAuthAuth: Decodable { let type: String? let refresh: String? @@ -864,11 +864,11 @@ final class TokenManager: @unchecked Sendable { private var cachedAntigravityAccounts: AntigravityAccounts? private var antigravityCacheTimestamp: Date? - /// Cached Gemini OAuth auth payload (OpenCode auth.json) + /// Cached Gemini OAuth auth payload from OpenCode. private var cachedGeminiOAuthAuth: GeminiOAuthAuth? private var geminiOAuthCacheTimestamp: Date? - /// Path where Gemini OAuth auth was found (OpenCode auth.json) + /// Path of the OpenCode store where Gemini OAuth auth was found. private(set) var lastFoundGeminiOAuthPath: URL? /// Cached Gemini OAuth creds payload (~/.gemini/oauth_creds.json) @@ -970,6 +970,146 @@ final class TokenManager: @unchecked Sendable { ) } + /// Resolves OPENCODE_DB relative to the OpenCode data directory unless it is absolute. + func getOpenCodeDatabasePath() -> URL { + let environment = ProcessInfo.processInfo.environment + let dataHome = normalizedNonEmpty(environment["XDG_DATA_HOME"]) + .map { URL(fileURLWithPath: $0) } + ?? FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".local/share") + let dataDirectory = dataHome.appendingPathComponent("opencode") + let configured = normalizedNonEmpty(environment["OPENCODE_DB"]) ?? "opencode.db" + if configured.hasPrefix("/") { + return URL(fileURLWithPath: configured) + } + return dataDirectory.appendingPathComponent(configured) + } + + private func getOpenCodeAuthStores() -> [(url: URL, isDatabase: Bool)] { + let databasePath = getOpenCodeDatabasePath() + if FileManager.default.fileExists(atPath: databasePath.path) { + // V1 also has opencode.db, but keeps credentials in auth.json. + // Only a successful schema probe may enable the legacy fallback. + var database: OpaquePointer? + defer { sqlite3_close(database) } + var statement: OpaquePointer? + defer { sqlite3_finalize(statement) } + if FileManager.default.isReadableFile(atPath: databasePath.path), + sqlite3_open_v2(databasePath.path, &database, SQLITE_OPEN_READONLY, nil) == SQLITE_OK { + sqlite3_busy_timeout(database, 1000) + let query = "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'credential'" + if sqlite3_prepare_v2(database, query, -1, &statement, nil) == SQLITE_OK, + sqlite3_step(statement) == SQLITE_DONE { + logger.info("OpenCode database has no credential table; using legacy auth discovery") + return getAuthFilePaths().map { ($0, false) } + } + } + return [(databasePath, true)] + } + return getAuthFilePaths().map { ($0, false) } + } + + private func readOpenCodeAuthData(at path: URL, isDatabase: Bool) throws -> Data { + guard isDatabase else { + return try Data(contentsOf: path) + } + var database: OpaquePointer? + let status = sqlite3_open_v2(path.path, &database, SQLITE_OPEN_READONLY, nil) + defer { sqlite3_close(database) } + guard status == SQLITE_OK else { + throw NSError(domain: "OpenCodeCredentials", code: Int(status), userInfo: [ + NSLocalizedDescriptionKey: String(cString: sqlite3_errmsg(database)) + ]) + } + sqlite3_busy_timeout(database, 1000) + var statement: OpaquePointer? + defer { sqlite3_finalize(statement) } + let query = """ + SELECT integration_id, value FROM credential + WHERE integration_id IS NOT NULL + ORDER BY integration_id, active DESC, time_updated DESC + """ + let prepared = sqlite3_prepare_v2(database, query, -1, &statement, nil) + guard prepared == SQLITE_OK else { + throw NSError(domain: "OpenCodeCredentials", code: Int(prepared), userInfo: [ + NSLocalizedDescriptionKey: String(cString: sqlite3_errmsg(database)) + ]) + } + var entries: [String: Any] = [:] + var selected = Set() + var step = sqlite3_step(statement) + while step == SQLITE_ROW { + if let idText = sqlite3_column_text(statement, 0) { + let integrationID = String(cString: idText) + if selected.insert(integrationID).inserted { + guard let valueText = sqlite3_column_text(statement, 1), + var value = (try? JSONSerialization.jsonObject( + with: Data(String(cString: valueText).utf8) + )) as? [String: Any] else { + logger.warning("Ignoring OpenCode credential for \(integrationID): invalid JSON object") + step = sqlite3_step(statement) + continue + } + switch value["type"] as? String { + case "oauth": + if let metadata = value["metadata"] as? [String: Any] { + if let accountID = metadata["accountID"] as? String, !accountID.isEmpty { + value["accountId"] = accountID + } + } + entries[integrationID] = value + case "key": + value["type"] = "api" + entries[integrationID] = value + default: + logger.warning("Ignoring OpenCode credential for \(integrationID): unsupported credential type") + } + } + } + step = sqlite3_step(statement) + } + guard step == SQLITE_DONE else { + throw NSError(domain: "OpenCodeCredentials", code: Int(step), userInfo: [ + NSLocalizedDescriptionKey: String(cString: sqlite3_errmsg(database)) + ]) + } + logger.info("Loaded OpenCode credential database: \(entries.count) integrations") + return try JSONSerialization.data(withJSONObject: entries) + } + + func openCodeAuthStoreDiagnostics() -> [String] { + let hasAuth = readOpenCodeAuth() != nil + let hasGoogle = readGeminiOAuthAuth() != nil + let (authPath, googlePath) = queue.sync { + (hasAuth ? lastFoundAuthPath : nil, hasGoogle ? lastFoundGeminiOAuthPath : nil) + } + let environment = ProcessInfo.processInfo.environment + var lines = [ + "[XDG_DATA_HOME] \(environment["XDG_DATA_HOME"] ?? "NOT SET")", + "[OPENCODE_DB] \(environment["OPENCODE_DB"] ?? "NOT SET")", + "[Database path] \(getOpenCodeDatabasePath().path)" + ] + for store in getOpenCodeAuthStores() { + let path = store.url + guard FileManager.default.fileExists(atPath: path.path) else { + lines.append("[NOT FOUND] \(path.path)") + continue + } + guard FileManager.default.isReadableFile(atPath: path.path), + (try? readOpenCodeAuthData(at: path, isDatabase: store.isDatabase)) != nil else { + lines.append("[UNREADABLE] \(path.path)") + continue + } + let isActive = path == authPath || path == googlePath + lines.append("[\(isActive ? "ACTIVE" : "READABLE")] \(path.path)") + } + lines.append("[OpenCode providers] \(authPath.map { "Using: " + $0.path } ?? "NO USABLE CREDENTIALS")") + lines.append("[Gemini OAuth] \(googlePath.map { "Using: " + $0.path } ?? "NO USABLE CREDENTIALS")") + if authPath == nil && googlePath == nil { + lines.append("[Result] NO USABLE OPENCODE CREDENTIALS FOUND") + } + return lines + } + /// Possible opencode.json/opencode.jsonc locations in priority order. /// For each directory, opencode.jsonc is preferred over opencode.json /// (matching copilothydra behavior): @@ -1349,10 +1489,14 @@ final class TokenManager: @unchecked Sendable { return nil } - /// Returns the path where auth.json was found, or nil if not found + /// Returns the path of the loaded OpenCode credential store, or nil if not found. /// Useful for displaying in UI to help users troubleshoot private(set) var lastFoundAuthPath: URL? + var openCodeAuthSourceLabel: String { + queue.sync { lastFoundAuthPath?.path ?? "OpenCode" } + } + /// Thread-safe read of OpenCode auth tokens with caching func readOpenCodeAuth() -> OpenCodeAuth? { return queue.sync { @@ -1364,9 +1508,10 @@ final class TokenManager: @unchecked Sendable { } let fileManager = FileManager.default - let paths = getAuthFilePaths() + let stores = getOpenCodeAuthStores() - for authPath in paths { + for store in stores { + let authPath = store.url guard fileManager.fileExists(atPath: authPath.path) else { continue } @@ -1376,7 +1521,7 @@ final class TokenManager: @unchecked Sendable { } do { - let data = try Data(contentsOf: authPath) + let data = try readOpenCodeAuthData(at: authPath, isDatabase: store.isDatabase) let auth = try JSONDecoder().decode(OpenCodeAuth.self, from: data) lastFoundAuthPath = authPath cachedAuth = auth @@ -1392,7 +1537,7 @@ final class TokenManager: @unchecked Sendable { lastFoundAuthPath = nil cachedAuth = nil cacheTimestamp = nil - logger.error("No valid auth.json found in any location") + logger.error("No valid OpenCode credentials found") return nil } } @@ -1402,6 +1547,9 @@ final class TokenManager: @unchecked Sendable { cachedAuth = nil cacheTimestamp = nil lastFoundAuthPath = nil + cachedGeminiOAuthAuth = nil + geminiOAuthCacheTimestamp = nil + lastFoundGeminiOAuthPath = nil cachedOpenCodeAnthropicCodexAccounts = nil openCodeAnthropicCodexAccountsCacheTimestamp = nil lastFoundOpenCodeAnthropicCodexAccountPaths = [] @@ -3429,7 +3577,7 @@ final class TokenManager: @unchecked Sendable { if let auth = readOpenCodeAuth(), let access = auth.anthropic?.access, !access.isEmpty { - let authSource = lastFoundAuthPath?.path ?? "~/.local/share/opencode/auth.json" + let authSource = openCodeAuthSourceLabel accounts.append( ClaudeAuthAccount( accessToken: access, @@ -3694,7 +3842,7 @@ final class TokenManager: @unchecked Sendable { if let auth = readOpenCodeAuth(), let access = auth.githubCopilot?.access, !access.isEmpty { - let authSource = lastFoundAuthPath?.path ?? "~/.local/share/opencode/auth.json" + let authSource = openCodeAuthSourceLabel accounts.append( CopilotAuthAccount( accessToken: access, @@ -3765,7 +3913,7 @@ final class TokenManager: @unchecked Sendable { if let auth = readOpenCodeAuth(), let access = auth.openai?.access, !access.isEmpty { - let authSource = lastFoundAuthPath?.path ?? "~/.local/share/opencode/auth.json" + let authSource = openCodeAuthSourceLabel let metadata = resolvedOpenAIAuthMetadata(from: auth.openai) accounts.append( OpenAIAuthAccount( @@ -3784,7 +3932,7 @@ final class TokenManager: @unchecked Sendable { if let auth = readOpenCodeAuth(), let apiKey = auth.openaiAPIKey, !apiKey.key.isEmpty { - let authSource = lastFoundAuthPath?.path ?? "~/.local/share/opencode/auth.json" + let authSource = openCodeAuthSourceLabel accounts.append( OpenAIAuthAccount( accessToken: apiKey.key, @@ -3898,7 +4046,7 @@ final class TokenManager: @unchecked Sendable { return (TokenManager.geminiClientId, TokenManager.geminiClientSecret) } - /// Thread-safe read of Gemini OAuth auth stored under "google" in OpenCode auth.json (jenslys/opencode-gemini-auth) + /// Thread-safe read of Gemini OAuth auth stored under "google" in OpenCode. func readGeminiOAuthAuth() -> GeminiOAuthAuth? { return queue.sync { if let cached = cachedGeminiOAuthAuth, @@ -3908,15 +4056,20 @@ final class TokenManager: @unchecked Sendable { } let fileManager = FileManager.default - let paths = getAuthFilePaths() + let stores = getOpenCodeAuthStores() - for authPath in paths { + for store in stores { + let authPath = store.url guard fileManager.fileExists(atPath: authPath.path) else { continue } + guard fileManager.isReadableFile(atPath: authPath.path) else { + logger.warning("Auth store not readable at \(authPath.path)") + continue + } do { - let data = try Data(contentsOf: authPath) + let data = try readOpenCodeAuthData(at: authPath, isDatabase: store.isDatabase) let container = try JSONDecoder().decode(OpenCodeGeminiAuthContainer.self, from: data) guard let geminiAuth = container.google else { continue @@ -4713,7 +4866,7 @@ final class TokenManager: @unchecked Sendable { if let auth = readOpenCodeAuth(), let token = auth.githubCopilot?.access.trimmingCharacters(in: .whitespacesAndNewlines), !token.isEmpty { - return " [GitHub Copilot] CONFIGURED (auth.json)" + return " [GitHub Copilot] CONFIGURED (OpenCode)" } return " [GitHub Copilot] NOT CONFIGURED" } @@ -4735,7 +4888,7 @@ final class TokenManager: @unchecked Sendable { let fileManager = FileManager.default let homeDir = fileManager.homeDirectoryForCurrentUser let openCodeAuth = readOpenCodeAuth() - let openCodePath = lastFoundAuthPath?.path ?? "~/.local/share/opencode/auth.json" + let openCodePath = openCodeAuthSourceLabel var lines: [String] = [] lines.append("Auth Discovery Summary:") @@ -4805,7 +4958,7 @@ final class TokenManager: @unchecked Sendable { token: openCodeAuth?.openai?.access ?? openCodeAuth?.openaiAPIKey?.key, accountId: openCodeOpenAIMetadata.accountId ?? openCodeOpenAIMetadata.overrideAccountId ) - lines.append(" OpenCode auth.json (\(shortPath(openCodePath))): \(openAIStatus)") + lines.append(" OpenCode (\(shortPath(openCodePath))): \(openAIStatus)") let openCodeMultiAuthPaths = openCodeMultiAuthPaths() let existingOpenCodeMultiAuthPaths = openCodeMultiAuthPaths.filter { fileManager.fileExists(atPath: $0.path) } @@ -4884,7 +5037,7 @@ final class TokenManager: @unchecked Sendable { lines.append("") lines.append("[Claude]") - lines.append(" OpenCode auth.json (\(shortPath(openCodePath))): \(tokenStatus(hasAuth: openCodeAuth != nil, token: openCodeAuth?.anthropic?.access, accountId: openCodeAuth?.anthropic?.accountId))") + lines.append(" OpenCode (\(shortPath(openCodePath))): \(tokenStatus(hasAuth: openCodeAuth != nil, token: openCodeAuth?.anthropic?.access, accountId: openCodeAuth?.anthropic?.accountId))") let claudeTokenKeys: Set = ["accesstoken", "access", "oauthtoken", "token"] let claudeKeychainPrimary = "Claude Code-credentials" let claudeKeychainSecondary = "Claude Code" @@ -4907,7 +5060,7 @@ final class TokenManager: @unchecked Sendable { lines.append("") lines.append("[GitHub Copilot]") - lines.append(" OpenCode auth.json (\(shortPath(openCodePath))): \(tokenStatus(hasAuth: openCodeAuth != nil, token: openCodeAuth?.githubCopilot?.access, accountId: openCodeAuth?.githubCopilot?.accountId))") + lines.append(" OpenCode (\(shortPath(openCodePath))): \(tokenStatus(hasAuth: openCodeAuth != nil, token: openCodeAuth?.githubCopilot?.access, accountId: openCodeAuth?.githubCopilot?.accountId))") // Copilot CLI Keychain status let copilotCliKeychainAccounts = readCopilotCliKeychainAccounts() @@ -4930,9 +5083,9 @@ final class TokenManager: @unchecked Sendable { let geminiAuth = readGeminiOAuthAuth() let geminiAuthPath = lastFoundGeminiOAuthPath?.path ?? "auth.json" if let geminiAuth = geminiAuth, let refresh = geminiAuth.refresh, !refresh.isEmpty { - lines.append(" OpenCode auth.json (google.oauth, jenslys/opencode-gemini-auth, \(shortPath(geminiAuthPath))): FOUND") + lines.append(" OpenCode (google.oauth, jenslys/opencode-gemini-auth, \(shortPath(geminiAuthPath))): FOUND") } else { - lines.append(" OpenCode auth.json (google.oauth, jenslys/opencode-gemini-auth, \(shortPath(geminiAuthPath))): NOT FOUND") + lines.append(" OpenCode (google.oauth, jenslys/opencode-gemini-auth, \(shortPath(geminiAuthPath))): NOT FOUND") } let geminiOAuthCreds = readGeminiOAuthCreds() let geminiOAuthCredsPath = lastFoundGeminiOAuthCredsPath?.path ?? geminiOAuthCredsPath().path @@ -4964,67 +5117,10 @@ final class TokenManager: @unchecked Sendable { debugLines.append("Environment Info:") debugLines.append(String(repeating: "─", count: 40)) - // 0. XDG_DATA_HOME environment variable - let hasXdgDataHome = ProcessInfo.processInfo.environment["XDG_DATA_HOME"]?.isEmpty == false - if let xdgDataHome = ProcessInfo.processInfo.environment["XDG_DATA_HOME"], !xdgDataHome.isEmpty { - debugLines.append("[XDG_DATA_HOME] SET: \(xdgDataHome)") - } else { - debugLines.append("[XDG_DATA_HOME] NOT SET (using default ~/.local/share)") - } - - // 1. Check all possible auth.json paths (fallback order) + // 1. Report the selected OpenCode credential stores. debugLines.append("") debugLines.append("Auth File Search:") - let authPaths = getAuthFilePaths() - var foundAuthPath: URL? - - for (index, authPath) in authPaths.enumerated() { - let priority = index + 1 - let pathLabel: String - if hasXdgDataHome { - switch index { - case 0: - pathLabel = "$XDG_DATA_HOME/opencode" - case 1: - pathLabel = "~/.local/share/opencode" - default: - pathLabel = "~/Library/Application Support/opencode" - } - } else { - switch index { - case 0: - pathLabel = "~/.local/share/opencode" - default: - pathLabel = "~/Library/Application Support/opencode" - } - } - - if fileManager.fileExists(atPath: authPath.path) { - if let content = try? String(contentsOf: authPath, encoding: .utf8) { - let lineCount = content.components(separatedBy: .newlines).count - let byteCount = content.utf8.count - let marker = foundAuthPath == nil ? "ACTIVE" : "SHADOWED" - debugLines.append(" [\(priority)] [\(marker)] \(pathLabel)/auth.json") - debugLines.append(" Path: \(authPath.path)") - debugLines.append(" Lines: \(lineCount), Bytes: \(byteCount)") - if foundAuthPath == nil { - foundAuthPath = authPath - } - } else { - debugLines.append(" [\(priority)] [UNREADABLE] \(pathLabel)/auth.json") - debugLines.append(" Path: \(authPath.path)") - } - } else { - debugLines.append(" [\(priority)] [NOT FOUND] \(pathLabel)/auth.json") - debugLines.append(" Path: \(authPath.path)") - } - } - - if let activePath = foundAuthPath { - debugLines.append(" [Result] Using: \(activePath.path)") - } else { - debugLines.append(" [Result] NO VALID auth.json FOUND") - } + debugLines.append(contentsOf: openCodeAuthStoreDiagnostics().map { " " + $0 }) // 2. Check ~/.config/opencode directory (antigravity-accounts.json) debugLines.append("") @@ -5065,7 +5161,7 @@ final class TokenManager: @unchecked Sendable { debugLines.append(" [Z.AI Coding Plan] \(auth.zaiCodingPlan != nil ? "CONFIGURED" : "NOT CONFIGURED")") debugLines.append(" [Nano-GPT] \(auth.nanoGpt != nil ? "CONFIGURED" : "NOT CONFIGURED")") } else { - debugLines.append(" [auth.json] PARSE FAILED or NOT FOUND") + debugLines.append(" [OpenCode credentials] PARSE FAILED or NOT FOUND") } // 4. Antigravity accounts @@ -5149,66 +5245,9 @@ final class TokenManager: @unchecked Sendable { var debugLines: [String] = [] debugLines.append("========== Environment Debug Info ==========") - // 0. XDG_DATA_HOME environment variable - let hasXdgDataHome = ProcessInfo.processInfo.environment["XDG_DATA_HOME"]?.isEmpty == false - if let xdgDataHome = ProcessInfo.processInfo.environment["XDG_DATA_HOME"], !xdgDataHome.isEmpty { - debugLines.append("[XDG_DATA_HOME] SET: \(xdgDataHome)") - } else { - debugLines.append("[XDG_DATA_HOME] NOT SET (using default ~/.local/share)") - } - - // 1. Check all possible auth.json paths (fallback order) + // 1. Report the selected OpenCode credential stores. debugLines.append("---------- Auth File Search ----------") - let authPaths = getAuthFilePaths() - var foundAuthPath: URL? - - for (index, authPath) in authPaths.enumerated() { - let priority = index + 1 - let pathLabel: String - if hasXdgDataHome { - switch index { - case 0: - pathLabel = "$XDG_DATA_HOME/opencode" - case 1: - pathLabel = "~/.local/share/opencode" - default: - pathLabel = "~/Library/Application Support/opencode" - } - } else { - switch index { - case 0: - pathLabel = "~/.local/share/opencode" - default: - pathLabel = "~/Library/Application Support/opencode" - } - } - - if fileManager.fileExists(atPath: authPath.path) { - if let content = try? String(contentsOf: authPath, encoding: .utf8) { - let lineCount = content.components(separatedBy: .newlines).count - let byteCount = content.utf8.count - let marker = foundAuthPath == nil ? "ACTIVE" : "SHADOWED" - debugLines.append("[\(priority)] [\(marker)] \(pathLabel)/auth.json") - debugLines.append(" Path: \(authPath.path)") - debugLines.append(" Lines: \(lineCount), Bytes: \(byteCount)") - if foundAuthPath == nil { - foundAuthPath = authPath - } - } else { - debugLines.append("[\(priority)] [UNREADABLE] \(pathLabel)/auth.json") - debugLines.append(" Path: \(authPath.path)") - } - } else { - debugLines.append("[\(priority)] [NOT FOUND] \(pathLabel)/auth.json") - debugLines.append(" Path: \(authPath.path)") - } - } - - if let activePath = foundAuthPath { - debugLines.append("[Result] Using auth from: \(activePath.path)") - } else { - debugLines.append("[Result] NO VALID auth.json FOUND IN ANY LOCATION") - } + debugLines.append(contentsOf: openCodeAuthStoreDiagnostics()) // 2. ~/.local/share/opencode directory contents debugLines.append("---------- Directory Contents ----------") @@ -5403,7 +5442,7 @@ final class TokenManager: @unchecked Sendable { debugLines.append("[Nano-GPT] NOT CONFIGURED") } } else { - debugLines.append("[auth.json] PARSE FAILED or NOT FOUND") + debugLines.append("[OpenCode credentials] PARSE FAILED or NOT FOUND") } // 6. Antigravity accounts diff --git a/CopilotMonitor/CopilotMonitorTests/MiniMaxProviderTests.swift b/CopilotMonitor/CopilotMonitorTests/MiniMaxProviderTests.swift index 8459b270..7a163eb0 100644 --- a/CopilotMonitor/CopilotMonitorTests/MiniMaxProviderTests.swift +++ b/CopilotMonitor/CopilotMonitorTests/MiniMaxProviderTests.swift @@ -54,10 +54,30 @@ final class MiniMaxProviderTests: XCTestCase { } func testFetchSuccessCreatesProviderResult() async throws { - guard TokenManager.shared.getMiniMaxCodingPlanAPIKey() != nil else { - throw XCTSkip("MiniMax Coding Plan API key not available; skipping fetch test.") + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let authDirectory = directory.appendingPathComponent("opencode") + try FileManager.default.createDirectory(at: authDirectory, withIntermediateDirectories: true) + let oldEnvironment = ProcessInfo.processInfo.environment + defer { + for name in ["XDG_DATA_HOME", "OPENCODE_DB"] { + if let value = oldEnvironment[name] { + setenv(name, value, 1) + } else { + unsetenv(name) + } + } + TokenManager.shared.clearOpenCodeAuthCacheForTesting() + try? FileManager.default.removeItem(at: directory) } - + setenv("XDG_DATA_HOME", directory.path, 1) + unsetenv("OPENCODE_DB") + let authPath = authDirectory.appendingPathComponent("auth.json") + try Data(#"{"minimax-coding-plan":{"type":"api","key":"fixture-minimax-key"}}"#.utf8).write(to: authPath) + TokenManager.shared.clearOpenCodeAuthCacheForTesting() + XCTAssertEqual(TokenManager.shared.getMiniMaxCodingPlanAPIKey(), "fixture-minimax-key") + + let expectedAuthSource = TokenManager.shared.openCodeAuthSourceLabel + XCTAssertEqual(expectedAuthSource, authPath.path) let session = makeSession() let provider = MiniMaxProvider(tokenManager: .shared, session: session) let responseJSON = """ @@ -120,7 +140,7 @@ final class MiniMaxProviderTests: XCTestCase { XCTAssertEqual(result.details?.sevenDayUsage ?? -1, 60.0, accuracy: 0.001) XCTAssertNotNil(result.details?.fiveHourReset) XCTAssertNotNil(result.details?.sevenDayReset) - XCTAssertEqual(result.details?.authSource, "~/.local/share/opencode/auth.json") + XCTAssertEqual(result.details?.authSource, expectedAuthSource) } func testFetchReturnsAuthenticationErrorOn401() async throws { diff --git a/CopilotMonitor/CopilotMonitorTests/TokenManagerTests.swift b/CopilotMonitor/CopilotMonitorTests/TokenManagerTests.swift index 72ea9ea9..e163f49f 100644 --- a/CopilotMonitor/CopilotMonitorTests/TokenManagerTests.swift +++ b/CopilotMonitor/CopilotMonitorTests/TokenManagerTests.swift @@ -1,7 +1,126 @@ import XCTest +import SQLite3 @testable import OpenCode_Bar final class TokenManagerTests: XCTestCase { + func testOpenCodeDatabasePrecedenceAndCredentialSelection() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let dataDirectory = directory.appendingPathComponent("opencode") + try FileManager.default.createDirectory(at: dataDirectory, withIntermediateDirectories: true) + let oldEnvironment = ProcessInfo.processInfo.environment + defer { + for name in ["XDG_DATA_HOME", "OPENCODE_DB"] { + if let value = oldEnvironment[name] { + setenv(name, value, 1) + } else { + unsetenv(name) + } + } + TokenManager.shared.clearOpenCodeAuthCacheForTesting() + try? FileManager.default.removeItem(at: directory) + } + setenv("XDG_DATA_HOME", directory.path, 1) + unsetenv("OPENCODE_DB") + let manager = TokenManager.shared + let legacyPath = dataDirectory.appendingPathComponent("auth.json") + try Data(#"{"openai":{"type":"oauth","access":"legacy"}}"#.utf8).write(to: legacyPath) + manager.clearOpenCodeAuthCacheForTesting() + XCTAssertEqual(manager.readOpenCodeAuth()?.openai?.access, "legacy") + XCTAssertEqual(manager.lastFoundAuthPath, legacyPath) + XCTAssertEqual(manager.openCodeAuthSourceLabel, legacyPath.path) + + let databasePath = dataDirectory.appendingPathComponent("opencode.db") + XCTAssertEqual(manager.getOpenCodeDatabasePath(), databasePath) + var database: OpaquePointer? + XCTAssertEqual(sqlite3_open(databasePath.path, &database), SQLITE_OK) + defer { sqlite3_close(database) } + // OpenCode V1 stores sessions in SQLite but credentials in auth.json. + XCTAssertEqual(sqlite3_exec(database, "CREATE TABLE session (id TEXT)", nil, nil, nil), SQLITE_OK) + manager.clearOpenCodeAuthCacheForTesting() + XCTAssertEqual(manager.readOpenCodeAuth()?.openai?.access, "legacy") + XCTAssertEqual(manager.lastFoundAuthPath, legacyPath) + let sql = """ + PRAGMA journal_mode=WAL; + CREATE TABLE credential (integration_id TEXT, value TEXT, active INTEGER, time_updated INTEGER); + INSERT INTO credential VALUES + ('openai', '{"type":"oauth","access":"newer","metadata":{"accountID":"other"}}', 0, 20), + ('openai', '{"type":"oauth","access":"selected","metadata":{"accountID":"account"}}', 1, 10), + ('openrouter', '{"type":"key","key":"router-key"}', 1, 10), + ('anthropic', 'invalid-json', 1, 20), + ('anthropic', '{"type":"oauth","access":"stale"}', 0, 10), + ('google', '{"type":"oauth","access":"google-token","refresh":"google-refresh"}', 1, 10); + """ + XCTAssertEqual(sqlite3_exec(database, sql, nil, nil, nil), SQLITE_OK) + manager.clearOpenCodeAuthCacheForTesting() + let auth = try XCTUnwrap(manager.readOpenCodeAuth()) + XCTAssertEqual(auth.openai?.access, "selected") + XCTAssertEqual(auth.openai?.accountId, "account") + XCTAssertEqual(auth.openrouter?.key, "router-key") + XCTAssertNil(auth.anthropic) + XCTAssertEqual(manager.lastFoundAuthPath, databasePath) + XCTAssertEqual(manager.readGeminiOAuthAuth()?.access, "google-token") + XCTAssertEqual(manager.lastFoundGeminiOAuthPath, databasePath) + XCTAssertTrue(manager.openCodeAuthStoreDiagnostics().contains("[ACTIVE] \(databasePath.path)")) + XCTAssertTrue(manager.openCodeAuthStoreDiagnostics().contains("[OpenCode providers] Using: \(databasePath.path)")) + XCTAssertTrue(manager.openCodeAuthStoreDiagnostics().contains("[Gemini OAuth] Using: \(databasePath.path)")) + + let metadataSQL = """ + UPDATE credential SET value = '{"type":"oauth","access":"selected","accountId":"top-level","metadata":{}}' + WHERE integration_id = 'openai' AND active = 1; + """ + XCTAssertEqual(sqlite3_exec(database, metadataSQL, nil, nil, nil), SQLITE_OK) + manager.clearOpenCodeAuthCacheForTesting() + XCTAssertEqual(manager.readOpenCodeAuth()?.openai?.accountId, "top-level") + + XCTAssertEqual(sqlite3_exec(database, "UPDATE credential SET active = 0", nil, nil, nil), SQLITE_OK) + manager.clearOpenCodeAuthCacheForTesting() + XCTAssertEqual(manager.readOpenCodeAuth()?.openai?.access, "newer") + + // Simulate a stopped writer with no shared-memory sidecar. SQLite may + // retain that file on close, so remove it after all connections close. + XCTAssertEqual(sqlite3_wal_checkpoint_v2(database, nil, SQLITE_CHECKPOINT_TRUNCATE, nil, nil), SQLITE_OK) + XCTAssertEqual(sqlite3_close(database), SQLITE_OK) + database = nil + let sharedMemoryPath = databasePath.path + "-shm" + if FileManager.default.fileExists(atPath: sharedMemoryPath) { + try FileManager.default.removeItem(atPath: sharedMemoryPath) + } + XCTAssertFalse(FileManager.default.fileExists(atPath: databasePath.path + "-shm")) + manager.clearOpenCodeAuthCacheForTesting() + XCTAssertEqual(manager.readOpenCodeAuth()?.openai?.access, "newer") + XCTAssertEqual(sqlite3_open(databasePath.path, &database), SQLITE_OK) + + XCTAssertEqual(sqlite3_exec(database, "DELETE FROM credential", nil, nil, nil), SQLITE_OK) + manager.clearOpenCodeAuthCacheForTesting() + XCTAssertNil(manager.readOpenCodeAuth()) + XCTAssertNil(manager.readGeminiOAuthAuth()) + XCTAssertEqual(manager.openCodeAuthSourceLabel, "OpenCode") + XCTAssertTrue(manager.openCodeAuthStoreDiagnostics().contains("[Result] NO USABLE OPENCODE CREDENTIALS FOUND")) + XCTAssertFalse(manager.openCodeAuthStoreDiagnostics().contains { $0.contains("[ACTIVE]") }) + XCTAssertEqual(sqlite3_exec(database, "DROP TABLE credential", nil, nil, nil), SQLITE_OK) + manager.clearOpenCodeAuthCacheForTesting() + XCTAssertEqual(manager.readOpenCodeAuth()?.openai?.access, "legacy") + XCTAssertEqual(manager.lastFoundAuthPath, legacyPath) + + // A credential table with an invalid schema is not a V1 database. + XCTAssertEqual(sqlite3_exec(database, "CREATE TABLE credential (invalid TEXT)", nil, nil, nil), SQLITE_OK) + manager.clearOpenCodeAuthCacheForTesting() + XCTAssertNil(manager.readOpenCodeAuth()) + XCTAssertTrue(manager.openCodeAuthStoreDiagnostics().contains("[UNREADABLE] \(databasePath.path)")) + + setenv("OPENCODE_DB", "custom.db", 1) + XCTAssertEqual(manager.getOpenCodeDatabasePath(), dataDirectory.appendingPathComponent("custom.db")) + setenv("OPENCODE_DB", databasePath.path, 1) + XCTAssertEqual(manager.getOpenCodeDatabasePath(), databasePath) + + let corruptPath = directory.appendingPathComponent("credentials-without-extension") + try Data("not a SQLite database".utf8).write(to: corruptPath) + setenv("OPENCODE_DB", corruptPath.path, 1) + manager.clearOpenCodeAuthCacheForTesting() + XCTAssertNil(manager.readOpenCodeAuth()) + XCTAssertTrue(manager.openCodeAuthStoreDiagnostics().contains("[UNREADABLE] \(corruptPath.path)")) + } + func testGeminiProjectPolicyPrefersConfiguredProject() { XCTAssertEqual( GeminiProjectPolicy.resolve(primary: "configured-project", fallback: "managed-project"), @@ -215,12 +334,19 @@ final class TokenManagerTests: XCTestCase { defer { try? fileManager.removeItem(at: tempDirectory) } let originalXDGDataHome = ProcessInfo.processInfo.environment["XDG_DATA_HOME"] + let originalDatabase = ProcessInfo.processInfo.environment["OPENCODE_DB"] + unsetenv("OPENCODE_DB") if let originalXDGDataHome { setenv("XDG_DATA_HOME", originalXDGDataHome, 1) } else { unsetenv("XDG_DATA_HOME") } defer { + if let originalDatabase { + setenv("OPENCODE_DB", originalDatabase, 1) + } else { + unsetenv("OPENCODE_DB") + } if let originalXDGDataHome { setenv("XDG_DATA_HOME", originalXDGDataHome, 1) } else { diff --git a/README.md b/README.md index c5fb260c..a27036d4 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,7 @@ Download the latest `.dmg` file from the [**Releases**](https://github.com/opggi - Also understands plugin-managed OpenCode `auth.json` fields such as `idToken`, `accountIdOverride`, and `organizationIdOverride` - **Antigravity/Gemini** - `NoeFabris/opencode-antigravity-auth` (writes `~/.config/opencode/antigravity-accounts.json`) - - `jenslys/opencode-gemini-auth` (writes `google.oauth` in OpenCode `auth.json`) + - `jenslys/opencode-gemini-auth` (`google` OAuth credentials in the OpenCode credential store) - Gemini CLI OAuth creds (writes `~/.gemini/oauth_creds.json` for email/account ID metadata; overlaps are merged with Antigravity accounts) - **Claude**: `anomalyco/opencode-anthropic-auth` @@ -75,13 +75,13 @@ Download the latest `.dmg` file from the [**Releases**](https://github.com/opggi ### Other AI agents beyond OpenCode that supports auto-detection - **GitHub Copilot** (multi-source discovery) - - **OpenCode auth** - Auto-detected from OpenCode `auth.json` (`copilot` provider entry) + - **OpenCode auth** - Auto-detected from the OpenCode credential store (`github-copilot` integration) - **Copilot CLI** - Auto-detected through macOS Keychain (`github.com` entries) - **VS Code / Cursor** - Auto-detected from `~/.config/github-copilot/hosts.json` and `~/.config/github-copilot/apps.json` - **Browser Cookies** - Chrome, Brave, Arc, Edge session cookies - Multiple accounts from different sources are automatically deduplicated and merged - **Codex** - - **OpenCode + oc-chatgpt-multi-auth** - Auto-detected from OpenCode `auth.json` plus `~/.opencode/.../openai-codex-accounts.json` + - **OpenCode + oc-chatgpt-multi-auth** - Auto-detected from the OpenCode credential store plus `~/.opencode/.../openai-codex-accounts.json` - **Codex for Mac** - Auto-detected through `~/.codex/auth.json` - **Codex CLI** - Auto-detected through `~/.codex/auth.json` - **codex-lb** - Auto-detected through `~/.codex-lb/` @@ -90,11 +90,29 @@ Download the latest `.dmg` file from the [**Releases**](https://github.com/opggi ## Features ### Automatic Provider Detection -- **Zero Configuration**: Reads your OpenCode `auth.json` automatically -- **Multi-path Support**: Searches `$XDG_DATA_HOME/opencode`, `~/.local/share/opencode`, and `~/Library/Application Support/opencode` +- **Zero Configuration**: Reads OpenCode 2.x credentials from `opencode.db`, or OpenCode 1.x credentials from `auth.json` +- **Custom Locations**: Supports `XDG_DATA_HOME` and `OPENCODE_DB`; legacy JSON discovery also checks the macOS Application Support directory - **Dynamic Updates**: New providers appear as you add them to OpenCode - **Smart Categorization**: Pay-as-you-go vs Quota-based providers displayed separately +### OpenCode Authentication + +OpenCode Bar reads the OpenCode 2.x `credential` table in read-only mode. It uses +the active credential for each integration, or the most recently updated one +when none is active. OAuth account metadata and API keys are supported. + +The database defaults to `$XDG_DATA_HOME/opencode/opencode.db`, or +`~/.local/share/opencode/opencode.db` when `XDG_DATA_HOME` is unset. `OPENCODE_DB` +can specify an absolute path or a path relative to the OpenCode data directory. +Environment overrides must be available to the app or CLI process. + +When the database is absent, or a successful schema query confirms it has no +`credential` table, OpenCode Bar uses OpenCode 1.x `auth.json` discovery. V1 also +uses SQLite for sessions, so a database file alone does not identify a V2 +credential store. An empty `credential` table, invalid credential schema, or +unreadable database does not fall back to old credentials. OpenCode remains responsible for refreshing +its credentials. Token source labels show the store that was actually loaded. + ### Kiro Authentication Kiro usage is fetched from its regional usage API with credentials read from @@ -222,13 +240,13 @@ OpenRouter Pay-as-you-go - $37.42 spent #### MiniMax Notes -- MiniMax Coding Plan is resolved from the OpenCode auth entry `minimax-coding-plan` in `auth.json`. +- MiniMax Coding Plan is resolved from the OpenCode credential entry `minimax-coding-plan`. - OpenCode Bar uses the Coding Plan remains endpoint and converts it into used percentages for the menu bar app and CLI. - MiniMax response fields `current_interval_usage_count` and `current_weekly_usage_count` behave as remaining counts despite their names, so OpenCode Bar calculates used percent as `total - remaining`. #### OpenCode Go Notes -- OpenCode Go is resolved from the OpenCode auth entry `opencode-go` in `auth.json`. +- OpenCode Go is resolved from the OpenCode credential entry `opencode-go`. - OpenCode Bar validates the API key against `https://opencode.ai/zen/go/v1/models`. - Usage windows come from the OpenCode dashboard and require `OPENCODE_GO_WORKSPACE_ID` plus `OPENCODE_GO_AUTH_COOKIE`, or `~/.config/opencode-bar/opencode-go.json`. - The monthly dashboard window is a usage cap signal; the app's subscription preset for the Go plan remains `$10.00`. @@ -391,7 +409,7 @@ Quit (⌘Q) ## How It Works -1. **Token Discovery**: Reads authentication tokens from OpenCode's `auth.json` (with multi-path fallback), including plugin-managed OpenAI metadata +1. **Token Discovery**: Reads the OpenCode credential store using the V1/V2 rules in [OpenCode Authentication](#opencode-authentication), including OpenAI account metadata 2. **Multi-Source Account Discovery**: For providers like ChatGPT and GitHub Copilot, discovers accounts from multiple sources (OpenCode auth, OpenCode plugin files, CLI/Keychain/config stores, browser cookies) and deduplicates them by stable account metadata 3. **Parallel Fetching**: Queries all provider APIs simultaneously using TaskGroup 4. **Smart Caching**: Falls back to cached data on network errors @@ -412,11 +430,15 @@ Grok reads identity from `~/.grok/auth.json`, uses the email address as the subs ## Troubleshooting -### "No providers found" or auth.json not detected -The app searches for `auth.json` in these locations (in order): +### "No providers found" or credentials not detected +For OpenCode 2.x, check the database location described in [OpenCode Authentication](#opencode-authentication). +`OPENCODE_DB` overrides the default `opencode.db` path. The app must inherit any environment overrides used by OpenCode. +The diagnostic dialog distinguishes unreadable stores from stores without usable credentials. Reconnect the provider in OpenCode if its credentials are missing or expired. + +When the database is absent or has no `credential` table, the app searches for legacy `auth.json` (in order): 1. `$XDG_DATA_HOME/opencode/auth.json` (if XDG_DATA_HOME is set) 2. `~/.local/share/opencode/auth.json` (default) -3. `~/Library/Application Support/opencode/auth.json` (macOS fallback) +3. `~/Library/Application Support/opencode/auth.json` (legacy JSON-only macOS fallback) For ChatGPT/Codex multi-account setups, the app also searches: 1. `~/.opencode/auth/openai.json` @@ -429,7 +451,7 @@ OpenCode Bar also reads `provider.openai.options.apiKey` from the OpenCode confi ### GitHub Copilot not showing GitHub Copilot accounts are discovered from multiple sources (in priority order): -1. **OpenCode auth** — `copilot` entry in OpenCode `auth.json` +1. **OpenCode auth** — `github-copilot` integration in the OpenCode credential store 2. **Copilot CLI Keychain** — macOS Keychain entries for `github.com` 3. **VS Code / Cursor** — `~/.config/github-copilot/hosts.json` and `apps.json` 4. **Browser Cookies** — Chrome, Brave, Arc, Edge session cookies