diff --git a/CopilotMonitor/CLI/CLIProviderManager.swift b/CopilotMonitor/CLI/CLIProviderManager.swift index a398acb..dc8e9cd 100644 --- a/CopilotMonitor/CLI/CLIProviderManager.swift +++ b/CopilotMonitor/CLI/CLIProviderManager.swift @@ -14,7 +14,7 @@ actor CLIProviderManager { static let registeredProviders: [ProviderIdentifier] = [ .claude, .codex, .cursor, .geminiCLI, .openRouter, - .antigravity, .openCodeZen, .openCodeGo, .grok, .kimi, .minimaxCodingPlan, .zaiCodingPlan, + .antigravity, .openCodeZen, .openCodeGo, .kiro, .grok, .kimi, .minimaxCodingPlan, .zaiCodingPlan, .nanoGpt, .chutes, .copilot, .synthetic @@ -33,6 +33,7 @@ actor CLIProviderManager { let antigravityProvider = AntigravityProvider() let openCodeZenProvider = OpenCodeZenProvider() let openCodeGoProvider = OpenCodeGoProvider() + let kiroProvider = KiroProvider() let grokProvider = GrokProvider() let kimiProvider = KimiProvider() let minimaxProvider = MiniMaxProvider() @@ -53,6 +54,7 @@ actor CLIProviderManager { antigravityProvider, openCodeZenProvider, openCodeGoProvider, + kiroProvider, grokProvider, kimiProvider, minimaxProvider, diff --git a/CopilotMonitor/CopilotMonitor.xcodeproj/project.pbxproj b/CopilotMonitor/CopilotMonitor.xcodeproj/project.pbxproj index 23ba5aa..ede7ab5 100644 --- a/CopilotMonitor/CopilotMonitor.xcodeproj/project.pbxproj +++ b/CopilotMonitor/CopilotMonitor.xcodeproj/project.pbxproj @@ -36,6 +36,7 @@ MINIMAXAPP11111111111111 /* MiniMaxProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = MINIMAXFILE2222222222222 /* MiniMaxProvider.swift */; }; OCGOAPP11111111111111 /* OpenCodeGoProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = OCGOFILE2222222222222 /* OpenCodeGoProvider.swift */; }; GROKAPP11111111111111 /* GrokProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = GROKFILE2222222222222 /* GrokProvider.swift */; }; + KIROAPP11111111111111 /* KiroProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = KIROFILE2222222222222 /* KiroProvider.swift */; }; NANOGPTAPP11111111111111 /* NanoGptProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = NANOGPTFILE222222222222 /* NanoGptProvider.swift */; }; A55555555555555555555555 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A66666666666666666666666 /* Assets.xcassets */; }; AD95EBD6AE3134DF4C797577 /* ClaudeProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DDC1B4DE6B5118CE4AE8F82 /* ClaudeProvider.swift */; }; @@ -59,6 +60,7 @@ CLIMINIMAX11111111111111 /* MiniMaxProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = MINIMAXFILE2222222222222 /* MiniMaxProvider.swift */; }; OCGOCLI11111111111111 /* OpenCodeGoProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = OCGOFILE2222222222222 /* OpenCodeGoProvider.swift */; }; GROKCLI11111111111111 /* GrokProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = GROKFILE2222222222222 /* GrokProvider.swift */; }; + KIROCLI11111111111111 /* KiroProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = KIROFILE2222222222222 /* KiroProvider.swift */; }; CLIZAI11111111111111111 /* ZaiCodingPlanProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = A454D8C32F30548900E355E3 /* ZaiCodingPlanProvider.swift */; }; NANOGPTCLI11111111111111 /* NanoGptProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = NANOGPTFILE222222222222 /* NanoGptProvider.swift */; }; CLIOPENCZEN1111111111111 /* OpenCodeZenProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = OZ2222222222222222222222 /* OpenCodeZenProvider.swift */; }; @@ -77,6 +79,7 @@ MINIMAXTESTBF11111111111 /* MiniMaxProviderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = MINIMAXTESTFR11111111111 /* MiniMaxProviderTests.swift */; }; OCGOTESTBF11111111111 /* OpenCodeGoProviderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = OCGOTESTFR11111111111 /* OpenCodeGoProviderTests.swift */; }; GROKTESTBF11111111111 /* GrokProviderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = GROKTESTFR11111111111 /* GrokProviderTests.swift */; }; + KIROTESTBF11111111111 /* KiroProviderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = KIROTESTFR11111111111 /* KiroProviderTests.swift */; }; MINIMAXJSONBF11111111111 /* minimax_response.json in Resources */ = {isa = PBXBuildFile; fileRef = MINIMAXJSONFR11111111111 /* minimax_response.json */; }; BRAVEAPP1111111111111111 /* BraveSearchProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = BRAVEFILE22222222222222 /* BraveSearchProvider.swift */; }; TAVILYAPP111111111111111 /* TavilySearchProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = TAVILYFILE22222222222222 /* TavilySearchProvider.swift */; }; @@ -170,6 +173,7 @@ MINIMAXFILE2222222222222 /* MiniMaxProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MiniMaxProvider.swift; sourceTree = ""; }; OCGOFILE2222222222222 /* OpenCodeGoProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenCodeGoProvider.swift; sourceTree = ""; }; GROKFILE2222222222222 /* GrokProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GrokProvider.swift; sourceTree = ""; }; + KIROFILE2222222222222 /* KiroProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KiroProvider.swift; sourceTree = ""; }; NANOGPTFILE222222222222 /* NanoGptProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NanoGptProvider.swift; sourceTree = ""; }; A66666666666666666666666 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; A77777777777777777777777 /* OpenCode Bar.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "OpenCode Bar.app"; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -199,6 +203,7 @@ MINIMAXTESTFR11111111111 /* MiniMaxProviderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MiniMaxProviderTests.swift; sourceTree = ""; }; OCGOTESTFR11111111111 /* OpenCodeGoProviderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenCodeGoProviderTests.swift; sourceTree = ""; }; GROKTESTFR11111111111 /* GrokProviderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GrokProviderTests.swift; sourceTree = ""; }; + KIROTESTFR11111111111 /* KiroProviderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KiroProviderTests.swift; sourceTree = ""; }; MINIMAXJSONFR11111111111 /* minimax_response.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = minimax_response.json; sourceTree = ""; }; BRAVEFILE22222222222222 /* BraveSearchProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BraveSearchProvider.swift; sourceTree = ""; }; TAVILYFILE22222222222222 /* TavilySearchProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TavilySearchProvider.swift; sourceTree = ""; }; @@ -287,6 +292,7 @@ OZ2222222222222222222222 /* OpenCodeZenProvider.swift */, OCGOFILE2222222222222 /* OpenCodeGoProvider.swift */, GROKFILE2222222222222 /* GrokProvider.swift */, + KIROFILE2222222222222 /* KiroProvider.swift */, KI2222222222222222222222 /* KimiProvider.swift */, MINIMAXFILE2222222222222 /* MiniMaxProvider.swift */, 283349002F313176004DADE1 /* ChutesProvider.swift */, @@ -441,6 +447,7 @@ MINIMAXTESTFR11111111111 /* MiniMaxProviderTests.swift */, OCGOTESTFR11111111111 /* OpenCodeGoProviderTests.swift */, GROKTESTFR11111111111 /* GrokProviderTests.swift */, + KIROTESTFR11111111111 /* KiroProviderTests.swift */, CURSORTSTFR11111111111 /* CursorProviderTests.swift */, TOKENTESTFR1111111111111 /* TokenManagerTests.swift */, CODEXTESTFR111111111111 /* CodexProviderTests.swift */, @@ -615,6 +622,7 @@ CLIMINIMAX11111111111111 /* MiniMaxProvider.swift in Sources */, OCGOCLI11111111111111 /* OpenCodeGoProvider.swift in Sources */, GROKCLI11111111111111 /* GrokProvider.swift in Sources */, + KIROCLI11111111111111 /* KiroProvider.swift in Sources */, CLIZAI11111111111111111 /* ZaiCodingPlanProvider.swift in Sources */, NANOGPTCLI11111111111111 /* NanoGptProvider.swift in Sources */, 283349022F313176004DADE1 /* ChutesProvider.swift in Sources */, @@ -661,6 +669,7 @@ MINIMAXAPP11111111111111 /* MiniMaxProvider.swift in Sources */, OCGOAPP11111111111111 /* OpenCodeGoProvider.swift in Sources */, GROKAPP11111111111111 /* GrokProvider.swift in Sources */, + KIROAPP11111111111111 /* KiroProvider.swift in Sources */, 283349012F313176004DADE1 /* ChutesProvider.swift in Sources */, TAVILYAPP111111111111111 /* TavilySearchProvider.swift in Sources */, BRAVEAPP1111111111111111 /* BraveSearchProvider.swift in Sources */, @@ -693,6 +702,7 @@ MINIMAXTESTBF11111111111 /* MiniMaxProviderTests.swift in Sources */, OCGOTESTBF11111111111 /* OpenCodeGoProviderTests.swift in Sources */, GROKTESTBF11111111111 /* GrokProviderTests.swift in Sources */, + KIROTESTBF11111111111 /* KiroProviderTests.swift in Sources */, CURSORTSTBF11111111111 /* CursorProviderTests.swift in Sources */, TOKENTESTBF1111111111111 /* TokenManagerTests.swift in Sources */, CODEXTESTBF111111111111 /* CodexProviderTests.swift in Sources */, diff --git a/CopilotMonitor/CopilotMonitor/App/StatusBarController.swift b/CopilotMonitor/CopilotMonitor/App/StatusBarController.swift index 30c9ba2..419a22a 100644 --- a/CopilotMonitor/CopilotMonitor/App/StatusBarController.swift +++ b/CopilotMonitor/CopilotMonitor/App/StatusBarController.swift @@ -987,6 +987,8 @@ final class StatusBarController: NSObject { add(details?.openCodeGoMonthlyUsage, priority: .monthly) add(details?.sevenDayUsage, priority: .weekly) add(details?.fiveHourUsage, priority: .hourly) + case .kiro: + add(usage.usagePercentage, priority: .monthly) case .grok: add(details?.monthlyUsage, priority: .monthly) case .codex: @@ -1948,6 +1950,7 @@ final class StatusBarController: NSObject { .kimi, .minimaxCodingPlan, .openCodeGo, + .kiro, .grok, .codex, .cursor, @@ -3045,6 +3048,8 @@ final class StatusBarController: NSObject { image = NSImage(named: "OpencodeIcon") case .openCodeGo: image = NSImage(named: "OpencodeIcon") + case .kiro: + image = NSImage(named: "KiroIcon") case .grok: image = NSImage(named: "GrokIcon") case .kimi: diff --git a/CopilotMonitor/CopilotMonitor/Assets.xcassets/KiroIcon.imageset/Contents.json b/CopilotMonitor/CopilotMonitor/Assets.xcassets/KiroIcon.imageset/Contents.json new file mode 100644 index 0000000..d96d840 --- /dev/null +++ b/CopilotMonitor/CopilotMonitor/Assets.xcassets/KiroIcon.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "kiro-ghost.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/CopilotMonitor/CopilotMonitor/Assets.xcassets/KiroIcon.imageset/kiro-ghost.svg b/CopilotMonitor/CopilotMonitor/Assets.xcassets/KiroIcon.imageset/kiro-ghost.svg new file mode 100644 index 0000000..a7f39a2 --- /dev/null +++ b/CopilotMonitor/CopilotMonitor/Assets.xcassets/KiroIcon.imageset/kiro-ghost.svg @@ -0,0 +1,3 @@ + + + diff --git a/CopilotMonitor/CopilotMonitor/Helpers/ProviderMenuBuilder.swift b/CopilotMonitor/CopilotMonitor/Helpers/ProviderMenuBuilder.swift index 2b2adf7..6fef514 100644 --- a/CopilotMonitor/CopilotMonitor/Helpers/ProviderMenuBuilder.swift +++ b/CopilotMonitor/CopilotMonitor/Helpers/ProviderMenuBuilder.swift @@ -826,6 +826,53 @@ extension StatusBarController { addSubscriptionItems(to: submenu, provider: .synthetic, accountId: subscriptionAccountId) debugLog("createDetailSubmenu: added subscription items for Synthetic") + case .kiro: + if let total = details.creditsTotal, total > 0, let remaining = details.creditsRemaining { + let used = max(total - remaining, 0) + let usagePercent = min(max((used / total) * 100.0, 0), 999) + let rows = createUsageWindowRow( + label: "Monthly Credits", + usagePercent: usagePercent, + resetDate: details.primaryReset, + isMonthly: true + ) + rows.forEach { submenu.addItem($0) } + + let usedItem = NSMenuItem() + usedItem.view = createDisabledLabelView( + text: String(format: "Credits Used: %.2f / %.2f", used, total), + icon: NSImage(systemSymbolName: "creditcard", accessibilityDescription: "Credits") + ) + submenu.addItem(usedItem) + + let remainingItem = NSMenuItem() + remainingItem.view = createDisabledLabelView(text: String(format: "Credits Left: %.2f", remaining)) + submenu.addItem(remainingItem) + } + + if let bonusUsage = details.secondaryUsage { + let rows = createUsageWindowRow( + label: "Bonus Credits", + usagePercent: bonusUsage, + resetDate: details.secondaryReset, + isMonthly: false + ) + rows.forEach { submenu.addItem($0) } + } + + if let plan = details.planType { + let planItem = NSMenuItem() + planItem.view = createDisabledLabelView( + text: "Plan: \(plan)", + icon: NSImage(systemSymbolName: "crown", accessibilityDescription: "Plan") + ) + submenu.addItem(planItem) + } + + submenu.addItem(NSMenuItem.separator()) + addSubscriptionItems(to: submenu, provider: .kiro, accountId: subscriptionAccountId) + debugLog("createDetailSubmenu: added subscription items for Kiro") + default: break } diff --git a/CopilotMonitor/CopilotMonitor/Models/ProviderProtocol.swift b/CopilotMonitor/CopilotMonitor/Models/ProviderProtocol.swift index c42f3c3..972093c 100644 --- a/CopilotMonitor/CopilotMonitor/Models/ProviderProtocol.swift +++ b/CopilotMonitor/CopilotMonitor/Models/ProviderProtocol.swift @@ -20,6 +20,7 @@ enum ProviderIdentifier: String, CaseIterable { case antigravity case openCodeZen = "opencode_zen" case openCodeGo = "opencode_go" + case kiro case grok case kimi case minimaxCodingPlan = "minimax_coding_plan" @@ -52,6 +53,8 @@ enum ProviderIdentifier: String, CaseIterable { return "OpenCode Zen" case .openCodeGo: return "OpenCode Go" + case .kiro: + return "Kiro" case .grok: return "Grok" case .kimi: @@ -95,6 +98,8 @@ enum ProviderIdentifier: String, CaseIterable { return "Zen" case .openCodeGo: return "Go" + case .kiro: + return "Kiro" case .grok: return "Grok" case .kimi: @@ -138,6 +143,8 @@ enum ProviderIdentifier: String, CaseIterable { return "moon.stars" case .openCodeGo: return "chevron.left.forwardslash.chevron.right" + case .kiro: + return "KiroIcon" case .grok: return "GrokIcon" case .kimi: diff --git a/CopilotMonitor/CopilotMonitor/Models/SubscriptionSettings.swift b/CopilotMonitor/CopilotMonitor/Models/SubscriptionSettings.swift index 121fd96..402056b 100644 --- a/CopilotMonitor/CopilotMonitor/Models/SubscriptionSettings.swift +++ b/CopilotMonitor/CopilotMonitor/Models/SubscriptionSettings.swift @@ -175,6 +175,12 @@ struct ProviderSubscriptionPresets { static let openCodeGo: [SubscriptionPreset] = [ SubscriptionPreset(name: "Go", cost: 10) ] + static let kiro: [SubscriptionPreset] = [ + SubscriptionPreset(name: "Free", cost: 0), + SubscriptionPreset(name: "Pro", cost: 20), + SubscriptionPreset(name: "Pro+", cost: 40), + SubscriptionPreset(name: "Power", cost: 200) + ] static let grok: [SubscriptionPreset] = [ SubscriptionPreset(name: "SuperGrok Lite", cost: 10), SubscriptionPreset(name: "SuperGrok", cost: 30), @@ -210,6 +216,8 @@ struct ProviderSubscriptionPresets { return openCodeZen case .openCodeGo: return openCodeGo + case .kiro: + return kiro case .grok: return grok case .tavilySearch: diff --git a/CopilotMonitor/CopilotMonitor/Providers/KiroProvider.swift b/CopilotMonitor/CopilotMonitor/Providers/KiroProvider.swift new file mode 100644 index 0000000..7d98292 --- /dev/null +++ b/CopilotMonitor/CopilotMonitor/Providers/KiroProvider.swift @@ -0,0 +1,499 @@ +import Foundation +import os.log + +private let logger = Logger(subsystem: "com.opencodeproviders", category: "KiroProvider") + +struct KiroUsageSnapshot: Equatable { + let usedCredits: Double + let totalCredits: Double + let planName: String? + let resetDate: Date? + let overageStatus: String? + let bonusCreditsUsed: Double? + let bonusCreditsTotal: Double? + let bonusExpiryDays: Int? + + init( + usedCredits: Double, + totalCredits: Double, + planName: String?, + resetDate: Date?, + overageStatus: String?, + bonusCreditsUsed: Double? = nil, + bonusCreditsTotal: Double? = nil, + bonusExpiryDays: Int? = nil + ) { + self.usedCredits = usedCredits + self.totalCredits = totalCredits + self.planName = planName + self.resetDate = resetDate + self.overageStatus = overageStatus + self.bonusCreditsUsed = bonusCreditsUsed + self.bonusCreditsTotal = bonusCreditsTotal + self.bonusExpiryDays = bonusExpiryDays + } + + var remainingCredits: Double { + totalCredits - usedCredits + } + + var usagePercent: Double { + guard totalCredits > 0 else { return 0 } + return min(max((usedCredits / totalCredits) * 100.0, 0), 999) + } +} + +final class KiroProvider: ProviderProtocol { + let identifier: ProviderIdentifier = .kiro + let type: ProviderType = .quotaBased + let fetchTimeout: TimeInterval = 25 + let minimumFetchInterval: TimeInterval = 300 + + private let fileManager: FileManager + + init(fileManager: FileManager = .default) { + self.fileManager = fileManager + } + + func fetch() async throws -> ProviderResult { + debugLog("fetch started") + + guard let binaryPath = await findKiroCLIBinary() else { + debugLog("kiro-cli binary not found") + throw ProviderError.authenticationFailed("Kiro CLI not found. Install and sign in to Kiro CLI first.") + } + + let output = try await runKiroUsage(binaryPath: binaryPath) + let snapshot = try Self.parseUsageOutput(output) + let result = Self.makeResult(from: snapshot, binaryPath: binaryPath) + + logger.info( + "Kiro usage fetched: used=\(String(format: "%.2f", snapshot.usedCredits), privacy: .public), total=\(String(format: "%.2f", snapshot.totalCredits), privacy: .public), plan=\(snapshot.planName ?? "unknown", privacy: .public)" + ) + debugLog("fetch completed through kiro-cli /usage") + return result + } + + private func findKiroCLIBinary() async -> URL? { + if let path = await findBinaryViaWhich() { + debugLog("kiro-cli found via PATH at \(path.path)") + return path + } + + if let path = await findBinaryViaLoginShell() { + debugLog("kiro-cli found via login shell at \(path.path)") + return path + } + + let home = fileManager.homeDirectoryForCurrentUser.path + let fallbackPaths = [ + "\(home)/.local/bin/kiro-cli", + "/opt/homebrew/bin/kiro-cli", + "/usr/local/bin/kiro-cli", + "/Applications/Kiro CLI.app/Contents/MacOS/kiro-cli" + ] + + for path in fallbackPaths where fileManager.isExecutableFile(atPath: path) { + debugLog("kiro-cli found via fallback at \(path)") + return URL(fileURLWithPath: path) + } + + return nil + } + + private func findBinaryViaWhich() async -> URL? { + guard let output = try? await runLookupProcess( + executableURL: URL(fileURLWithPath: "/usr/bin/which"), + arguments: ["kiro-cli"] + ) else { return nil } + return validatedBinaryPath(from: output) + } + + private func findBinaryViaLoginShell() async -> URL? { + let shell = ProcessInfo.processInfo.environment["SHELL"] ?? "/bin/zsh" + guard let output = try? await runLookupProcess( + executableURL: URL(fileURLWithPath: shell), + arguments: ["-lc", "command -v kiro-cli 2>/dev/null"] + ) else { return nil } + return validatedBinaryPath(from: output) + } + + private func validatedBinaryPath(from output: String) -> URL? { + let path = output.trimmingCharacters(in: .whitespacesAndNewlines) + guard !path.isEmpty, fileManager.isExecutableFile(atPath: path) else { return nil } + return URL(fileURLWithPath: path) + } + + private func runLookupProcess( + executableURL: URL, + arguments: [String], + timeout: TimeInterval = 5 + ) async throws -> String { + try await withThrowingTaskGroup(of: String.self) { group in + let process = Process() + process.executableURL = executableURL + process.arguments = arguments + + defer { + group.cancelAll() + if process.isRunning { + process.terminate() + } + } + + group.addTask { + try await withCheckedThrowingContinuation { continuation in + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = FileHandle.nullDevice + + process.terminationHandler = { _ in + let data = pipe.fileHandleForReading.readDataToEndOfFile() + guard process.terminationStatus == 0, + let output = String(data: data, encoding: .utf8) else { + continuation.resume(throwing: ProviderError.providerError("Kiro CLI lookup failed")) + return + } + continuation.resume(returning: output) + } + + do { + try process.run() + } catch { + continuation.resume(throwing: error) + } + } + } + + group.addTask { + try await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000)) + throw ProviderError.networkError("Kiro CLI lookup timeout") + } + + guard let result = try await group.next() else { + throw ProviderError.providerError("Kiro CLI lookup failed") + } + return result + } + } + + private func runKiroUsage(binaryPath: URL) async throws -> String { + let timeout = fetchTimeout + return try await withThrowingTaskGroup(of: String.self) { group in + let process = Process() + process.executableURL = binaryPath + process.arguments = ["chat", "--no-interactive", "/usage"] + + defer { + group.cancelAll() + if process.isRunning { + process.terminate() + } + } + + group.addTask { + try await withCheckedThrowingContinuation { continuation in + let outputPipe = Pipe() + process.standardOutput = outputPipe + process.standardError = outputPipe + process.standardInput = FileHandle.nullDevice + + nonisolated(unsafe) var outputData = Data() + + outputPipe.fileHandleForReading.readabilityHandler = { handle in + let data = handle.availableData + if !data.isEmpty { + outputData.append(data) + } + } + + process.terminationHandler = { _ in + outputPipe.fileHandleForReading.readabilityHandler = nil + + let remainingData = outputPipe.fileHandleForReading.readDataToEndOfFile() + if !remainingData.isEmpty { + outputData.append(remainingData) + } + + guard let output = String(data: outputData, encoding: .utf8) else { + continuation.resume(throwing: ProviderError.decodingError("Cannot decode kiro-cli output")) + return + } + + if process.terminationStatus == 0 { + continuation.resume(returning: output) + } else { + continuation.resume(throwing: ProviderError.providerError("kiro-cli exited with status \(process.terminationStatus)")) + } + } + + do { + try process.run() + } catch { + outputPipe.fileHandleForReading.readabilityHandler = nil + continuation.resume(throwing: error) + } + } + } + + group.addTask { + try await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000)) + throw ProviderError.networkError("kiro-cli /usage timed out after \(Int(timeout))s") + } + + guard let result = try await group.next() else { + throw ProviderError.providerError("kiro-cli /usage task failed") + } + + return result + } + } + + static func parseUsageOutput(_ output: String) throws -> KiroUsageSnapshot { + let text = stripANSI(from: output) + let normalized = text.replacingOccurrences(of: "\u{00A0}", with: " ") + let planName = parsePlanName(from: normalized) + + let creditsMatch = firstMatch( + in: normalized, + pattern: #"Credits\s*\(\s*([0-9][0-9,]*(?:\.[0-9]+)?)\s+of\s+([0-9][0-9,]*(?:\.[0-9]+)?)(?:\s+covered\s+in\s+plan)?\s*\)"# + ) + let percent = parseProgressPercent(from: normalized) + + let usedCredits = creditsMatch.flatMap { match in + match.count > 1 ? parseNumber(match[1]) : nil + } + let totalCredits = creditsMatch.flatMap { match in + match.count > 2 ? parseNumber(match[2]) : nil + } + + let resolvedTotalCredits = totalCredits ?? planName.flatMap(planCreditTotal) + let resolvedUsedCredits = usedCredits ?? percent.flatMap { parsedPercent in + resolvedTotalCredits.map { ($0 * parsedPercent) / 100.0 } + } + + guard let resolvedUsedCredits, + let resolvedTotalCredits, + resolvedTotalCredits > 0 else { + throw ProviderError.decodingError("Kiro usage output did not include monthly credit usage") + } + + let resetDate = firstMatch(in: normalized, pattern: #"resets\s+on\s+(\d{4}-\d{2}-\d{2}|\d{2}/\d{2})"#).flatMap { match in + match.count > 1 ? parseDate(match[1]) : nil + } + let overageStatus = firstMatch(in: normalized, pattern: #"Overages:\s*([A-Za-z]+)"#).flatMap { match in + match.count > 1 ? match[1] : nil + } + let bonusCredits = parseBonusCredits(from: normalized) + + return KiroUsageSnapshot( + usedCredits: resolvedUsedCredits, + totalCredits: resolvedTotalCredits, + planName: planName, + resetDate: resetDate, + overageStatus: overageStatus, + bonusCreditsUsed: bonusCredits.used, + bonusCreditsTotal: bonusCredits.total, + bonusExpiryDays: bonusCredits.expiryDays + ) + } + + static func makeResult(from snapshot: KiroUsageSnapshot, binaryPath: URL) -> ProviderResult { + let scale = 100.0 + let entitlement = max(Int((snapshot.totalCredits * scale).rounded()), 1) + let remaining = Int((snapshot.remainingCredits * scale).rounded()) + let details = DetailedUsage( + secondaryUsage: bonusUsagePercent(from: snapshot), + secondaryReset: bonusExpiryDate(from: snapshot), + primaryReset: snapshot.resetDate, + planType: snapshot.planName, + monthlyCost: snapshot.usedCredits, + creditsRemaining: snapshot.remainingCredits, + creditsTotal: snapshot.totalCredits, + authSource: "kiro-cli at \(binaryPath.path)" + ) + + return ProviderResult( + usage: .quotaBased( + remaining: remaining, + entitlement: entitlement, + overagePermitted: snapshot.overageStatus?.localizedCaseInsensitiveContains("enabled") == true + ), + details: details + ) + } + + private static func stripANSI(from text: String) -> String { + let escape = "\u{001B}" + let patterns = [ + "\(escape)\\[[0-?]*[ -/]*[@-~]", + "\(escape)\\][^\u{0007}]*(?:\u{0007}|\(escape)\\\\)" + ] + return patterns.reduce(text) { current, pattern in + current.replacingOccurrences(of: pattern, with: "", options: .regularExpression) + } + } + + private static func parsePlanName(from text: String) -> String? { + let patterns = [ + #"Estimated\s+Usage\s*\|\s*resets\s+on\s+(?:\d{4}-\d{2}-\d{2}|\d{2}/\d{2})\s*\|\s*([A-Za-z0-9 +_-]+)(?:\s*\([^\n\r)]*\))?"#, + #"\|\s*(KIRO\s+[A-Za-z0-9 +_-]+)"#, + #"Plan:\s*([A-Za-z0-9 +_-]+)(?:\s*\([^\n\r)]*\))?"# + ] + + for pattern in patterns { + guard let match = firstMatch(in: text, pattern: pattern), match.count > 1 else { continue } + let plan = match[1] + .replacingOccurrences(of: #"\s*\([^)]*\)\s*$"#, with: "", options: .regularExpression) + .trimmingCharacters(in: .whitespacesAndNewlines) + if !plan.isEmpty { + return normalizePlanName(plan) + } + } + return nil + } + + private static func normalizePlanName(_ planName: String) -> String { + let cleaned = planName + .replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression) + .trimmingCharacters(in: .whitespacesAndNewlines) + let uppercased = cleaned.uppercased() + + if uppercased.contains("POWER") { + return "Power" + } + if uppercased.contains("PRO+") || uppercased.contains("PRO PLUS") { + return "Pro+" + } + if uppercased.contains("PRO") { + return "Pro" + } + if uppercased.contains("FREE") { + return "Free" + } + return cleaned + } + + private static func planCreditTotal(for planName: String) -> Double? { + switch normalizePlanName(planName).lowercased() { + case "free": + return 50 + case "pro": + return 1_000 + case "pro+": + return 2_000 + case "power": + return 10_000 + default: + return nil + } + } + + private static func parseProgressPercent(from text: String) -> Double? { + firstMatch(in: text, pattern: #"(?:█|▓|▒|━|─|■)+\s*([0-9]+(?:\.[0-9]+)?)%"#).flatMap { match in + match.count > 1 ? parseNumber(match[1]) : nil + } + } + + private static func parseBonusCredits(from text: String) -> (used: Double?, total: Double?, expiryDays: Int?) { + let bonusMatch = firstMatch( + in: text, + pattern: #"Bonus\s+credits:[\s\S]{0,160}?([0-9][0-9,]*(?:\.[0-9]+)?)/([0-9][0-9,]*(?:\.[0-9]+)?)\s+credits\s+used"# + ) + let expiryMatch = firstMatch(in: text, pattern: #"expires\s+in\s+(\d+)\s+days?"#) + + return ( + used: bonusMatch.flatMap { $0.count > 1 ? parseNumber($0[1]) : nil }, + total: bonusMatch.flatMap { $0.count > 2 ? parseNumber($0[2]) : nil }, + expiryDays: expiryMatch.flatMap { $0.count > 1 ? Int($0[1]) : nil } + ) + } + + private static func bonusUsagePercent(from snapshot: KiroUsageSnapshot) -> Double? { + guard let used = snapshot.bonusCreditsUsed, + let total = snapshot.bonusCreditsTotal, + total > 0 else { + return nil + } + return min(max((used / total) * 100.0, 0), 999) + } + + private static func bonusExpiryDate(from snapshot: KiroUsageSnapshot) -> Date? { + guard let days = snapshot.bonusExpiryDays else { return nil } + var calendar = Calendar(identifier: .gregorian) + if let utc = TimeZone(identifier: "UTC") { + calendar.timeZone = utc + } + return calendar.date(byAdding: .day, value: days, to: Date()) + } + + private static func firstMatch(in text: String, pattern: String) -> [String]? { + guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else { return nil } + let range = NSRange(text.startIndex.. Double? { + Double(value.replacingOccurrences(of: ",", with: "")) + } + + private static func parseDate(_ value: String) -> Date? { + if value.contains("/") { + let parts = value.split(separator: "/") + guard parts.count == 2, + let month = Int(parts[0]), + let day = Int(parts[1]) else { + return nil + } + + var calendar = Calendar(identifier: .gregorian) + if let utc = TimeZone(identifier: "UTC") { + calendar.timeZone = utc + } + + let now = Date() + let currentYear = calendar.component(.year, from: now) + var components = DateComponents() + components.year = currentYear + components.month = month + components.day = day + + if let date = calendar.date(from: components), date >= calendar.startOfDay(for: now) { + return date + } + + components.year = currentYear + 1 + return calendar.date(from: components) + } + + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "yyyy-MM-dd" + if let utc = TimeZone(identifier: "UTC") { + formatter.timeZone = utc + } + return formatter.date(from: value) + } + + private func debugLog(_ message: String) { + #if DEBUG + let msg = "[\(Date())] KiroProvider: \(message)\n" + if let data = msg.data(using: .utf8) { + let path = "/tmp/provider_debug.log" + if fileManager.fileExists(atPath: path), let handle = FileHandle(forWritingAtPath: path) { + handle.seekToEndOfFile() + handle.write(data) + handle.closeFile() + } else { + try? data.write(to: URL(fileURLWithPath: path)) + } + } + #endif + } +} diff --git a/CopilotMonitor/CopilotMonitor/Services/ProviderManager.swift b/CopilotMonitor/CopilotMonitor/Services/ProviderManager.swift index 88252f8..0caf904 100644 --- a/CopilotMonitor/CopilotMonitor/Services/ProviderManager.swift +++ b/CopilotMonitor/CopilotMonitor/Services/ProviderManager.swift @@ -39,6 +39,7 @@ actor ProviderManager { AntigravityProvider(), OpenCodeZenProvider(), OpenCodeGoProvider(), + KiroProvider(), GrokProvider(), KimiProvider(), ChutesProvider(), diff --git a/CopilotMonitor/CopilotMonitor/Views/MultiProviderStatusBarIconView.swift b/CopilotMonitor/CopilotMonitor/Views/MultiProviderStatusBarIconView.swift index 9e08654..eed9fe6 100644 --- a/CopilotMonitor/CopilotMonitor/Views/MultiProviderStatusBarIconView.swift +++ b/CopilotMonitor/CopilotMonitor/Views/MultiProviderStatusBarIconView.swift @@ -150,6 +150,8 @@ final class MultiProviderStatusBarIconView: NSView { iconName = "OpencodeIcon" case .openCodeGo: iconName = "OpencodeIcon" + case .kiro: + iconName = "KiroIcon" case .grok: iconName = "GrokIcon" case .kimi: diff --git a/CopilotMonitor/CopilotMonitor/Views/SwiftUI/ModernStatusBarIconView.swift b/CopilotMonitor/CopilotMonitor/Views/SwiftUI/ModernStatusBarIconView.swift index e7dcff8..51a98c6 100644 --- a/CopilotMonitor/CopilotMonitor/Views/SwiftUI/ModernStatusBarIconView.swift +++ b/CopilotMonitor/CopilotMonitor/Views/SwiftUI/ModernStatusBarIconView.swift @@ -120,6 +120,13 @@ struct SwiftUIProviderAlertView: View { .scaledToFit() .frame(width: 12, height: 12) .foregroundColor(.red) + } else if identifier == .kiro { + Image("KiroIcon") + .renderingMode(.template) + .resizable() + .scaledToFit() + .frame(width: 12, height: 12) + .foregroundColor(.red) } else if let systemIconName = systemIconName(for: identifier) { Image(systemName: systemIconName) .font(.system(size: 12)) @@ -136,6 +143,7 @@ struct SwiftUIProviderAlertView: View { case .copilot: return "airplane" case .openRouter: return "dollarsign.circle" case .openCode, .openCodeZen, .openCodeGo: return "chevron.left.forwardslash.chevron.right" + case .kiro: return nil case .grok: return nil case .antigravity: return "arrow.up.circle" case .kimi: return "k.circle" diff --git a/CopilotMonitor/CopilotMonitorTests/KiroProviderTests.swift b/CopilotMonitor/CopilotMonitorTests/KiroProviderTests.swift new file mode 100644 index 0000000..130a161 --- /dev/null +++ b/CopilotMonitor/CopilotMonitorTests/KiroProviderTests.swift @@ -0,0 +1,155 @@ +import XCTest +@testable import OpenCode_Bar + +final class KiroProviderTests: XCTestCase { + func testProviderIdentifier() { + let provider = KiroProvider() + XCTAssertEqual(provider.identifier, .kiro) + } + + func testProviderType() { + let provider = KiroProvider() + XCTAssertEqual(provider.type, .quotaBased) + } + + func testUsageParserReadsClassicOutput() throws { + let output = #""" + Model: auto | Plan: KIRO PRO (/usage for more detail) + + Estimated Usage | resets on 2026-06-01 | KIRO PRO + + Credits (3.66 of 1000 covered in plan) + 0% + Overages: Disabled + """# + + let usage = try KiroProvider.parseUsageOutput(output) + + XCTAssertEqual(usage.usedCredits, 3.66, accuracy: 0.001) + XCTAssertEqual(usage.totalCredits, 1000, accuracy: 0.001) + XCTAssertEqual(usage.remainingCredits, 996.34, accuracy: 0.001) + XCTAssertEqual(usage.usagePercent, 0.366, accuracy: 0.001) + XCTAssertEqual(usage.planName, "Pro") + XCTAssertEqual(usage.overageStatus, "Disabled") + XCTAssertNotNil(usage.resetDate) + } + + func testUsageParserStripsANSIAndParsesCommaNumbers() throws { + let output = "\u{001B}[32mEstimated Usage | resets on 2026-06-01 | KIRO POWER\u{001B}[0m\nCredits (1,234.5 of 10,000 covered in plan)\nOverages: Enabled" + + let usage = try KiroProvider.parseUsageOutput(output) + + XCTAssertEqual(usage.usedCredits, 1234.5, accuracy: 0.001) + XCTAssertEqual(usage.totalCredits, 10_000, accuracy: 0.001) + XCTAssertEqual(usage.planName, "Power") + XCTAssertEqual(usage.overageStatus, "Enabled") + } + + func testUsageParserTrimsPlanHintText() throws { + let output = "Model: auto | Plan: KIRO PRO (/usage for more detail)\nCredits (3.66 of 1000 covered in plan)" + + let usage = try KiroProvider.parseUsageOutput(output) + + XCTAssertEqual(usage.planName, "Pro") + } + + func testUsageParserReadsTableOutputWithSlashReset() throws { + let output = #""" + ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ + ┃ | KIRO FREE ┃ + ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ + ┃ Monthly credits: ┃ + ┃ ████████████████████████████████████████████████████████ 100% (resets on 01/01) ┃ + ┃ (50.00 of 50 covered in plan) ┃ + ┃ Bonus credits: ┃ + ┃ 0.00/100 credits used, expires in 88 days ┃ + ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + """# + + let usage = try KiroProvider.parseUsageOutput(output) + + XCTAssertEqual(usage.usedCredits, 50, accuracy: 0.001) + XCTAssertEqual(usage.totalCredits, 50, accuracy: 0.001) + XCTAssertEqual(usage.planName, "Free") + XCTAssertEqual(try XCTUnwrap(usage.bonusCreditsUsed), 0, accuracy: 0.001) + XCTAssertEqual(try XCTUnwrap(usage.bonusCreditsTotal), 100, accuracy: 0.001) + XCTAssertEqual(usage.bonusExpiryDays, 88) + XCTAssertNotNil(usage.resetDate) + } + + func testUsageParserFallsBackToPercentAndPlanAllowance() throws { + let output = "Estimated Usage | resets on 01/01 | KIRO POWER\n████████ 12%" + + let usage = try KiroProvider.parseUsageOutput(output) + + XCTAssertEqual(usage.usedCredits, 1_200, accuracy: 0.001) + XCTAssertEqual(usage.totalCredits, 10_000, accuracy: 0.001) + XCTAssertEqual(usage.planName, "Power") + } + + func testUsageParserThrowsWhenCreditsAreMissing() { + XCTAssertThrowsError(try KiroProvider.parseUsageOutput("Estimated Usage | KIRO PRO")) + } + + func testMakeResultKeepsCenticreditPrecision() throws { + let resetDate = try KiroProvider.parseUsageOutput("Credits (3.66 of 1000 covered in plan)\nresets on 2026-06-01") + .resetDate + let snapshot = KiroUsageSnapshot( + usedCredits: 3.66, + totalCredits: 1000, + planName: "Pro", + resetDate: resetDate, + overageStatus: "Disabled", + bonusCreditsUsed: 25, + bonusCreditsTotal: 100, + bonusExpiryDays: 14 + ) + + let result = KiroProvider.makeResult( + from: snapshot, + binaryPath: URL(fileURLWithPath: "/Users/test/.local/bin/kiro-cli") + ) + + XCTAssertEqual(result.usage.totalEntitlement, 100_000) + XCTAssertEqual(result.usage.remainingQuota, 99_634) + XCTAssertEqual(result.usage.usagePercentage, 0.366, accuracy: 0.001) + if case .quotaBased(_, _, let overagePermitted) = result.usage { + XCTAssertFalse(overagePermitted) + } else { + XCTFail("Expected quota-based usage") + } + XCTAssertEqual(result.details?.planType, "Pro") + XCTAssertEqual(try XCTUnwrap(result.details?.creditsRemaining), 996.34, accuracy: 0.001) + XCTAssertEqual(try XCTUnwrap(result.details?.creditsTotal), 1000, accuracy: 0.001) + XCTAssertEqual(try XCTUnwrap(result.details?.monthlyCost), 3.66, accuracy: 0.001) + XCTAssertEqual(try XCTUnwrap(result.details?.secondaryUsage), 25, accuracy: 0.001) + XCTAssertNotNil(result.details?.secondaryReset) + XCTAssertEqual(result.details?.authSource, "kiro-cli at /Users/test/.local/bin/kiro-cli") + } + + func testMakeResultPreservesOverageAmount() throws { + let snapshot = KiroUsageSnapshot( + usedCredits: 1_050, + totalCredits: 1_000, + planName: "Pro", + resetDate: nil, + overageStatus: "Enabled" + ) + + let result = KiroProvider.makeResult( + from: snapshot, + binaryPath: URL(fileURLWithPath: "/Users/test/.local/bin/kiro-cli") + ) + + XCTAssertEqual(snapshot.remainingCredits, -50, accuracy: 0.001) + XCTAssertEqual(result.usage.totalEntitlement, 100_000) + XCTAssertEqual(result.usage.remainingQuota, -5_000) + XCTAssertEqual(result.usage.usagePercentage, 105, accuracy: 0.001) + if case .quotaBased(_, _, let overagePermitted) = result.usage { + XCTAssertTrue(overagePermitted) + } else { + XCTFail("Expected quota-based usage") + } + XCTAssertEqual(try XCTUnwrap(result.details?.creditsRemaining), -50, accuracy: 0.001) + } +} diff --git a/docs/AI_USAGE_API_REFERENCE.md b/docs/AI_USAGE_API_REFERENCE.md index 1ecefec..f8a38d4 100644 --- a/docs/AI_USAGE_API_REFERENCE.md +++ b/docs/AI_USAGE_API_REFERENCE.md @@ -11,6 +11,7 @@ | Copilot, Nano-GPT, MiniMax, OpenCode Go | `~/.local/share/opencode/auth.json` | | Antigravity (Gemini) | `~/.config/opencode/antigravity-accounts.json` | | Antigravity (Local cache) | `~/Library/Application Support/Antigravity/User/globalStorage/state.vscdb` | +| Kiro | Authenticated `kiro-cli` session; OpenCode Bar does not read local Kiro token databases | | Grok | `~/.grok/auth.json`, `~/.grok/sessions/**/signals.json`, optional grok.com browser cookies | --- @@ -312,7 +313,33 @@ The bundled [`scripts/query-opencode-go.sh`](/Users/kargnas/projects/opencode-ba --- -## 7. Grok +## 7. Kiro + +**Usage source:** `kiro-cli chat --no-interactive "/usage"` + +Kiro currently exposes billing usage through the authenticated Kiro CLI experience. OpenCode Bar uses `kiro-cli` as the integration boundary and does not read Kiro's local auth database or token storage directly. + +```bash +kiro-cli chat --no-interactive "/usage" +./scripts/query-kiro.sh +./scripts/query-kiro.sh --json +``` + +The CLI output is ANSI-decorated and may appear in either a compact line format or a table-style format. OpenCode Bar strips ANSI escape sequences and reads: + +| Field | Description | +|-------|-------------| +| `Credits (X of Y covered in plan)` | Monthly credits used and total plan allowance | +| Progress bar percent | Fallback used percentage when the credit tuple is omitted | +| `resets on YYYY-MM-DD` or `resets on MM/DD` | Monthly reset date | +| `Plan: ...` / `Estimated Usage ... | PLAN` | Kiro plan name, normalized to Free, Pro, Pro+, or Power when possible | +| `Overages: Enabled/Disabled` | Whether paid overages are enabled | + +When only a progress percentage is available, OpenCode Bar derives the credit allowance from Kiro's current public plan limits: Free 50, Pro 1,000, Pro+ 2,000, Power 10,000. The provider throttles fetches to avoid spawning the Kiro CLI too frequently. + +--- + +## 8. Grok **Identity source:** `~/.grok/auth.json` @@ -347,7 +374,7 @@ OpenCode Bar's native Grok provider uses the same identity selection rule. It cu --- -## 8. Antigravity (Dual Quota System) +## 9. Antigravity (Dual Quota System) Antigravity has **two independent quota systems**: @@ -576,6 +603,7 @@ Test scripts are located in the `scripts/` folder: | `query-copilot.sh` | GitHub Copilot | | `query-minimax.sh` | MiniMax Coding Plan | | `query-opencode-go.sh` | OpenCode Go | +| `query-kiro.sh` | Kiro | | `query-grok.sh` | Grok | | `query-gemini-cli.sh` | Antigravity - Gemini CLI quota | | `query-gemini-oauth-creds.sh` | Gemini CLI oauth_creds identity/token inspection | diff --git a/scripts/query-all.sh b/scripts/query-all.sh index e4b2a77..feb3438 100755 --- a/scripts/query-all.sh +++ b/scripts/query-all.sh @@ -9,7 +9,7 @@ echo " $(date '+%Y-%m-%d %H:%M:%S')" echo "========================================" echo "" -for script in query-claude.sh query-codex.sh query-copilot.sh query-gemini-cli.sh query-antigravity-local.sh query-openrouter.sh query-synthetic.sh query-nano-gpt.sh query-brave-search.sh query-tavily-search.sh query-minimax.sh query-opencode-go.sh query-grok.sh; do +for script in query-claude.sh query-codex.sh query-copilot.sh query-gemini-cli.sh query-antigravity-local.sh query-openrouter.sh query-synthetic.sh query-nano-gpt.sh query-brave-search.sh query-tavily-search.sh query-minimax.sh query-opencode-go.sh query-kiro.sh query-grok.sh; do if [[ -x "$SCRIPT_DIR/$script" ]]; then "$SCRIPT_DIR/$script" 2>/dev/null || echo "$script: Failed" echo "" diff --git a/scripts/query-kiro.sh b/scripts/query-kiro.sh new file mode 100755 index 0000000..4cedacd --- /dev/null +++ b/scripts/query-kiro.sh @@ -0,0 +1,219 @@ +#!/usr/bin/env bash + +# Query Kiro billing usage through the authenticated Kiro CLI. +# Authentication remains owned by Kiro; this script does not read local token databases. + +set -euo pipefail + +SCRIPT_NAME="$(basename "$0")" + +usage() { + cat </dev/null"], + check=False, + capture_output=True, + text=True, + timeout=5, + ).stdout.strip() + if login_shell and os.access(login_shell, os.X_OK): + return login_shell + except Exception: + pass + + home = str(Path.home()) + for path in [ + f"{home}/.local/bin/kiro-cli", + "/opt/homebrew/bin/kiro-cli", + "/usr/local/bin/kiro-cli", + "/Applications/Kiro CLI.app/Contents/MacOS/kiro-cli", + ]: + if os.access(path, os.X_OK): + return path + raise SystemExit("kiro-cli not found. Install and sign in to Kiro CLI first.") + + +def clean(text): + return ANSI_RE.sub("", text).replace("\u00a0", " ") + + +def parse_number(value): + return float(value.replace(",", "")) + + +def normalize_plan(value): + cleaned = re.sub(r"\s+", " ", value).strip() + upper = cleaned.upper() + if "POWER" in upper: + return "Power" + if "PRO+" in upper or "PRO PLUS" in upper: + return "Pro+" + if "PRO" in upper: + return "Pro" + if "FREE" in upper: + return "Free" + return cleaned or None + + +def parse_date(value): + if not value: + return None + if "-" in value: + return value + parts = value.split("/") + if len(parts) != 2: + return None + month, day = int(parts[0]), int(parts[1]) + today = dt.date.today() + candidate = dt.date(today.year, month, day) + if candidate < today: + candidate = dt.date(today.year + 1, month, day) + return candidate.isoformat() + + +def parse_usage(output): + text = clean(output) + + plan = None + for pattern in [ + r"Estimated\s+Usage\s*\|\s*resets\s+on\s+(?:\d{4}-\d{2}-\d{2}|\d{2}/\d{2})\s*\|\s*([A-Za-z0-9 +_-]+)", + r"\|\s*(KIRO\s+[A-Za-z0-9 +_-]+)", + r"Plan:\s*([A-Za-z0-9 +_-]+)(?:\s*\([^\n\r)]*\))?", + ]: + match = re.search(pattern, text, re.I) + if match: + plan = normalize_plan(re.sub(r"\s*\([^)]*\)\s*$", "", match.group(1))) + break + + credit_match = re.search( + r"Credits\s*\(\s*([0-9][0-9,]*(?:\.[0-9]+)?)\s+of\s+([0-9][0-9,]*(?:\.[0-9]+)?)(?:\s+covered\s+in\s+plan)?\s*\)", + text, + re.I, + ) + percent_match = re.search(r"(?:█|▓|▒|━|─|■)+\s*([0-9]+(?:\.[0-9]+)?)%", text) + + used = parse_number(credit_match.group(1)) if credit_match else None + total = parse_number(credit_match.group(2)) if credit_match else None + if total is None and plan: + total = PLAN_TOTALS.get(plan.lower()) + if used is None and total is not None and percent_match: + used = total * parse_number(percent_match.group(1)) / 100.0 + if used is None or total is None or total <= 0: + raise SystemExit("Kiro usage output did not include monthly credit usage") + + reset_match = re.search(r"resets\s+on\s+(\d{4}-\d{2}-\d{2}|\d{2}/\d{2})", text, re.I) + overage_match = re.search(r"Overages:\s*([A-Za-z]+)", text, re.I) + bonus_match = re.search( + r"Bonus\s+credits:[\s\S]{0,160}?([0-9][0-9,]*(?:\.[0-9]+)?)/([0-9][0-9,]*(?:\.[0-9]+)?)\s+credits\s+used", + text, + re.I, + ) + bonus_expiry_match = re.search(r"expires\s+in\s+(\d+)\s+days?", text, re.I) + + result = { + "provider": "kiro", + "plan": plan, + "used_credits": used, + "total_credits": total, + "remaining_credits": total - used, + "used_percent": min(max((used / total) * 100.0, 0), 999), + "reset_date": parse_date(reset_match.group(1)) if reset_match else None, + "overages": overage_match.group(1) if overage_match else None, + } + if bonus_match: + bonus_used = parse_number(bonus_match.group(1)) + bonus_total = parse_number(bonus_match.group(2)) + result["bonus_used_credits"] = bonus_used + result["bonus_total_credits"] = bonus_total + result["bonus_used_percent"] = (bonus_used / bonus_total) * 100.0 if bonus_total > 0 else None + if bonus_expiry_match: + result["bonus_expiry_days"] = int(bonus_expiry_match.group(1)) + return result + + +parser = argparse.ArgumentParser(add_help=False) +parser.add_argument("--json", action="store_true") +args = parser.parse_args(sys.argv[1:]) + +binary = find_kiro_cli() +proc = subprocess.run( + [binary, "chat", "--no-interactive", "/usage"], + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + timeout=25, + check=False, +) +if proc.returncode != 0: + raise SystemExit(proc.stdout.strip() or f"kiro-cli exited with status {proc.returncode}") + +usage = parse_usage(proc.stdout) +usage["cli"] = binary + +if args.json: + print(json.dumps(usage, indent=2, sort_keys=True)) +else: + print(f"Kiro plan: {usage['plan'] or 'unknown'}") + print(f"Credits used: {usage['used_credits']:.2f} / {usage['total_credits']:.2f} ({usage['used_percent']:.2f}% used)") + print(f"Credits left: {usage['remaining_credits']:.2f}") + if usage.get("reset_date"): + print(f"Resets: {usage['reset_date']}") + if usage.get("overages"): + print(f"Overages: {usage['overages']}") + if "bonus_used_credits" in usage: + print(f"Bonus credits used: {usage['bonus_used_credits']:.2f} / {usage['bonus_total_credits']:.2f}") + if "bonus_expiry_days" in usage: + print(f"Bonus expires in: {usage['bonus_expiry_days']} days") +PY