From 4efebc23bcdfb109d78b5aa706b0385354dc5e9c Mon Sep 17 00:00:00 2001 From: Ruben Beuker Date: Tue, 19 May 2026 20:16:04 +0200 Subject: [PATCH 1/9] Add Kiro provider --- CopilotMonitor/CLI/CLIProviderManager.swift | 4 +- .../CopilotMonitor.xcodeproj/project.pbxproj | 10 + .../App/StatusBarController.swift | 5 + .../Helpers/ProviderMenuBuilder.swift | 37 ++ .../Models/ProviderProtocol.swift | 7 + .../Models/SubscriptionSettings.swift | 8 + .../Providers/KiroProvider.swift | 336 ++++++++++++++++++ .../Services/ProviderManager.swift | 1 + .../MultiProviderStatusBarIconView.swift | 2 + .../SwiftUI/ModernStatusBarIconView.swift | 1 + .../KiroProviderTests.swift | 82 +++++ 11 files changed, 492 insertions(+), 1 deletion(-) create mode 100644 CopilotMonitor/CopilotMonitor/Providers/KiroProvider.swift create mode 100644 CopilotMonitor/CopilotMonitorTests/KiroProviderTests.swift 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..cbf09c9 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(systemSymbolName: identifier.iconName, accessibilityDescription: identifier.displayName) case .grok: image = NSImage(named: "GrokIcon") case .kimi: diff --git a/CopilotMonitor/CopilotMonitor/Helpers/ProviderMenuBuilder.swift b/CopilotMonitor/CopilotMonitor/Helpers/ProviderMenuBuilder.swift index 2b2adf7..309372f 100644 --- a/CopilotMonitor/CopilotMonitor/Helpers/ProviderMenuBuilder.swift +++ b/CopilotMonitor/CopilotMonitor/Helpers/ProviderMenuBuilder.swift @@ -826,6 +826,43 @@ 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 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..baa9e2e 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 "k.circle" 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..604e1b5 --- /dev/null +++ b/CopilotMonitor/CopilotMonitor/Providers/KiroProvider.swift @@ -0,0 +1,336 @@ +import Foundation +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? + + var remainingCredits: Double { + max(totalCredits - usedCredits, 0) + } + + 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 = 60 + + private let fileManager: FileManager + + init(fileManager: FileManager = .default) { + self.fileManager = fileManager + } + + func fetch() async throws -> ProviderResult { + debugLog("fetch started") + + guard let binaryPath = 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() -> URL? { + if let path = findBinaryViaWhich() { + debugLog("kiro-cli found via PATH at \(path.path)") + return path + } + + if let path = 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() -> URL? { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/which") + process.arguments = ["kiro-cli"] + + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = FileHandle.nullDevice + + do { + try process.run() + process.waitUntilExit() + + guard process.terminationStatus == 0 else { return nil } + let data = pipe.fileHandleForReading.readDataToEndOfFile() + guard let output = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines), + !output.isEmpty, + fileManager.isExecutableFile(atPath: output) else { + return nil + } + return URL(fileURLWithPath: output) + } catch { + debugLog("which kiro-cli failed: \(error.localizedDescription)") + return nil + } + } + + private func findBinaryViaLoginShell() -> URL? { + let shell = ProcessInfo.processInfo.environment["SHELL"] ?? "/bin/zsh" + let process = Process() + process.executableURL = URL(fileURLWithPath: shell) + process.arguments = ["-lc", "which kiro-cli 2>/dev/null"] + + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = FileHandle.nullDevice + + do { + try process.run() + process.waitUntilExit() + + guard process.terminationStatus == 0 else { return nil } + let data = pipe.fileHandleForReading.readDataToEndOfFile() + guard let output = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines), + !output.isEmpty, + fileManager.isExecutableFile(atPath: output) else { + return nil + } + return URL(fileURLWithPath: output) + } catch { + debugLog("login shell kiro-cli lookup failed: \(error.localizedDescription)") + return nil + } + } + + 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", "--classic"] + + group.addTask { + try await withCheckedThrowingContinuation { continuation in + let outputPipe = Pipe() + let inputPipe = Pipe() + process.standardOutput = outputPipe + process.standardError = outputPipe + process.standardInput = inputPipe + + 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() + if let input = "/usage\n/quit\n".data(using: .utf8) { + inputPipe.fileHandleForWriting.write(input) + } + try? inputPipe.fileHandleForWriting.close() + } 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") + } + + group.cancelAll() + if process.isRunning { + process.terminate() + } + return result + } + } + + static func parseUsageOutput(_ output: String) throws -> KiroUsageSnapshot { + let text = stripANSI(from: output) + let normalized = text.replacingOccurrences(of: "\u{00A0}", with: " ") + + guard 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*\)"# + ), + creditsMatch.count >= 3, + let usedCredits = parseNumber(creditsMatch[1]), + let totalCredits = parseNumber(creditsMatch[2]), + totalCredits > 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})"#).flatMap { match in + match.count > 1 ? parseDate(match[1]) : nil + } + let planName = parsePlanName(from: normalized) + let overageStatus = firstMatch(in: normalized, pattern: #"Overages:\s*([A-Za-z]+)"#).flatMap { match in + match.count > 1 ? match[1] : nil + } + + return KiroUsageSnapshot( + usedCredits: usedCredits, + totalCredits: totalCredits, + planName: planName, + resetDate: resetDate, + overageStatus: overageStatus + ) + } + + static func makeResult(from snapshot: KiroUsageSnapshot, binaryPath: URL) -> ProviderResult { + let scale = 100.0 + let entitlement = max(Int((snapshot.totalCredits * scale).rounded()), 1) + let remaining = max(Int((snapshot.remainingCredits * scale).rounded()), 0) + let details = DetailedUsage( + 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}\s*\|\s*([^\n\r]+)"#, + #"Plan:\s*([A-Za-z0-9 +_-]+)"# + ] + + for pattern in patterns { + guard let match = firstMatch(in: text, pattern: pattern), match.count > 1 else { continue } + let plan = match[1] + .replacingOccurrences(of: "(/usage for more detail)", with: "") + .trimmingCharacters(in: .whitespacesAndNewlines) + if !plan.isEmpty { + return plan + } + } + return nil + } + + 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? { + 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..47fb09d 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 = "k.circle" case .grok: iconName = "GrokIcon" case .kimi: diff --git a/CopilotMonitor/CopilotMonitor/Views/SwiftUI/ModernStatusBarIconView.swift b/CopilotMonitor/CopilotMonitor/Views/SwiftUI/ModernStatusBarIconView.swift index e7dcff8..4e308e1 100644 --- a/CopilotMonitor/CopilotMonitor/Views/SwiftUI/ModernStatusBarIconView.swift +++ b/CopilotMonitor/CopilotMonitor/Views/SwiftUI/ModernStatusBarIconView.swift @@ -136,6 +136,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 "k.circle" 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..e462ec5 --- /dev/null +++ b/CopilotMonitor/CopilotMonitorTests/KiroProviderTests.swift @@ -0,0 +1,82 @@ +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, "KIRO 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, "KIRO POWER") + XCTAssertEqual(usage.overageStatus, "Enabled") + } + + 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: "KIRO PRO", + resetDate: resetDate, + overageStatus: "Disabled" + ) + + 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, "KIRO 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(result.details?.authSource, "kiro-cli at /Users/test/.local/bin/kiro-cli") + } +} From a347d84985b62fe2d1887dda27b4cafb31f59aac Mon Sep 17 00:00:00 2001 From: Ruben Beuker Date: Tue, 19 May 2026 20:28:48 +0200 Subject: [PATCH 2/9] Address Kiro plan parsing feedback --- .../CopilotMonitor/Providers/KiroProvider.swift | 6 +++--- .../CopilotMonitorTests/KiroProviderTests.swift | 8 ++++++++ 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/CopilotMonitor/CopilotMonitor/Providers/KiroProvider.swift b/CopilotMonitor/CopilotMonitor/Providers/KiroProvider.swift index 604e1b5..fbc3f27 100644 --- a/CopilotMonitor/CopilotMonitor/Providers/KiroProvider.swift +++ b/CopilotMonitor/CopilotMonitor/Providers/KiroProvider.swift @@ -276,14 +276,14 @@ final class KiroProvider: ProviderProtocol { 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}\s*\|\s*([^\n\r]+)"#, - #"Plan:\s*([A-Za-z0-9 +_-]+)"# + #"Estimated\s+Usage\s*\|\s*resets\s+on\s+\d{4}-\d{2}-\d{2}\s*\|\s*([A-Za-z0-9 +_-]+)(?:\s*\([^\n\r)]*\))?"#, + #"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: "(/usage for more detail)", with: "") + .replacingOccurrences(of: #"\s*\([^)]*\)\s*$"#, with: "", options: .regularExpression) .trimmingCharacters(in: .whitespacesAndNewlines) if !plan.isEmpty { return plan diff --git a/CopilotMonitor/CopilotMonitorTests/KiroProviderTests.swift b/CopilotMonitor/CopilotMonitorTests/KiroProviderTests.swift index e462ec5..5ed1ae9 100644 --- a/CopilotMonitor/CopilotMonitorTests/KiroProviderTests.swift +++ b/CopilotMonitor/CopilotMonitorTests/KiroProviderTests.swift @@ -45,6 +45,14 @@ final class KiroProviderTests: XCTestCase { 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, "KIRO PRO") + } + func testUsageParserThrowsWhenCreditsAreMissing() { XCTAssertThrowsError(try KiroProvider.parseUsageOutput("Estimated Usage | KIRO PRO")) } From cbab362072c673e734e718a12eddc5d7f13da364 Mon Sep 17 00:00:00 2001 From: Ruben Beuker Date: Tue, 19 May 2026 21:12:06 +0200 Subject: [PATCH 3/9] Address Kiro PR review feedback --- .../Helpers/ProviderMenuBuilder.swift | 10 + .../Models/ProviderProtocol.swift | 2 +- .../Providers/KiroProvider.swift | 196 ++++++++++++++--- .../MultiProviderStatusBarIconView.swift | 2 +- .../SwiftUI/ModernStatusBarIconView.swift | 2 +- .../KiroProviderTests.swift | 51 ++++- docs/AI_USAGE_API_REFERENCE.md | 32 ++- scripts/query-all.sh | 2 +- scripts/query-kiro.sh | 203 ++++++++++++++++++ 9 files changed, 461 insertions(+), 39 deletions(-) create mode 100755 scripts/query-kiro.sh diff --git a/CopilotMonitor/CopilotMonitor/Helpers/ProviderMenuBuilder.swift b/CopilotMonitor/CopilotMonitor/Helpers/ProviderMenuBuilder.swift index 309372f..6fef514 100644 --- a/CopilotMonitor/CopilotMonitor/Helpers/ProviderMenuBuilder.swift +++ b/CopilotMonitor/CopilotMonitor/Helpers/ProviderMenuBuilder.swift @@ -850,6 +850,16 @@ extension StatusBarController { 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( diff --git a/CopilotMonitor/CopilotMonitor/Models/ProviderProtocol.swift b/CopilotMonitor/CopilotMonitor/Models/ProviderProtocol.swift index baa9e2e..2dec87a 100644 --- a/CopilotMonitor/CopilotMonitor/Models/ProviderProtocol.swift +++ b/CopilotMonitor/CopilotMonitor/Models/ProviderProtocol.swift @@ -144,7 +144,7 @@ enum ProviderIdentifier: String, CaseIterable { case .openCodeGo: return "chevron.left.forwardslash.chevron.right" case .kiro: - return "k.circle" + return "keyboard.badge.eye" case .grok: return "GrokIcon" case .kimi: diff --git a/CopilotMonitor/CopilotMonitor/Providers/KiroProvider.swift b/CopilotMonitor/CopilotMonitor/Providers/KiroProvider.swift index fbc3f27..e1579e7 100644 --- a/CopilotMonitor/CopilotMonitor/Providers/KiroProvider.swift +++ b/CopilotMonitor/CopilotMonitor/Providers/KiroProvider.swift @@ -1,5 +1,4 @@ import Foundation -import Foundation import os.log private let logger = Logger(subsystem: "com.opencodeproviders", category: "KiroProvider") @@ -10,6 +9,29 @@ struct KiroUsageSnapshot: Equatable { 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 { max(totalCredits - usedCredits, 0) @@ -25,7 +47,7 @@ final class KiroProvider: ProviderProtocol { let identifier: ProviderIdentifier = .kiro let type: ProviderType = .quotaBased let fetchTimeout: TimeInterval = 25 - let minimumFetchInterval: TimeInterval = 60 + let minimumFetchInterval: TimeInterval = 300 private let fileManager: FileManager @@ -139,15 +161,21 @@ final class KiroProvider: ProviderProtocol { return try await withThrowingTaskGroup(of: String.self) { group in let process = Process() process.executableURL = binaryPath - process.arguments = ["chat", "--classic"] + process.arguments = ["chat", "--no-interactive", "/usage"] + + defer { + group.cancelAll() + if process.isRunning { + process.terminate() + } + } group.addTask { try await withCheckedThrowingContinuation { continuation in let outputPipe = Pipe() - let inputPipe = Pipe() process.standardOutput = outputPipe process.standardError = outputPipe - process.standardInput = inputPipe + process.standardInput = FileHandle.nullDevice nonisolated(unsafe) var outputData = Data() @@ -180,10 +208,6 @@ final class KiroProvider: ProviderProtocol { do { try process.run() - if let input = "/usage\n/quit\n".data(using: .utf8) { - inputPipe.fileHandleForWriting.write(input) - } - try? inputPipe.fileHandleForWriting.close() } catch { outputPipe.fileHandleForReading.readabilityHandler = nil continuation.resume(throwing: error) @@ -200,10 +224,6 @@ final class KiroProvider: ProviderProtocol { throw ProviderError.providerError("kiro-cli /usage task failed") } - group.cancelAll() - if process.isRunning { - process.terminate() - } return result } } @@ -211,32 +231,49 @@ final class KiroProvider: ProviderProtocol { 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) - guard let creditsMatch = firstMatch( + 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*\)"# - ), - creditsMatch.count >= 3, - let usedCredits = parseNumber(creditsMatch[1]), - let totalCredits = parseNumber(creditsMatch[2]), - totalCredits > 0 else { + 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})"#).flatMap { match in + 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 planName = parsePlanName(from: normalized) 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: usedCredits, - totalCredits: totalCredits, + usedCredits: resolvedUsedCredits, + totalCredits: resolvedTotalCredits, planName: planName, resetDate: resetDate, - overageStatus: overageStatus + overageStatus: overageStatus, + bonusCreditsUsed: bonusCredits.used, + bonusCreditsTotal: bonusCredits.total, + bonusExpiryDays: bonusCredits.expiryDays ) } @@ -245,6 +282,8 @@ final class KiroProvider: ProviderProtocol { let entitlement = max(Int((snapshot.totalCredits * scale).rounded()), 1) let remaining = max(Int((snapshot.remainingCredits * scale).rounded()), 0) let details = DetailedUsage( + secondaryUsage: bonusUsagePercent(from: snapshot), + secondaryReset: bonusExpiryDate(from: snapshot), primaryReset: snapshot.resetDate, planType: snapshot.planName, monthlyCost: snapshot.usedCredits, @@ -276,7 +315,8 @@ final class KiroProvider: ProviderProtocol { 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}\s*\|\s*([A-Za-z0-9 +_-]+)(?:\s*\([^\n\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 +_-]+)(?:\s*\([^\n\r)]*\))?"#, + #"\|\s*(KIRO\s+[A-Za-z0-9 +_-]+)"#, #"Plan:\s*([A-Za-z0-9 +_-]+)(?:\s*\([^\n\r)]*\))?"# ] @@ -286,12 +326,86 @@ final class KiroProvider: ProviderProtocol { .replacingOccurrences(of: #"\s*\([^)]*\)\s*$"#, with: "", options: .regularExpression) .trimmingCharacters(in: .whitespacesAndNewlines) if !plan.isEmpty { - return plan + 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.. 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" diff --git a/CopilotMonitor/CopilotMonitor/Views/MultiProviderStatusBarIconView.swift b/CopilotMonitor/CopilotMonitor/Views/MultiProviderStatusBarIconView.swift index 47fb09d..01a44e7 100644 --- a/CopilotMonitor/CopilotMonitor/Views/MultiProviderStatusBarIconView.swift +++ b/CopilotMonitor/CopilotMonitor/Views/MultiProviderStatusBarIconView.swift @@ -151,7 +151,7 @@ final class MultiProviderStatusBarIconView: NSView { case .openCodeGo: iconName = "OpencodeIcon" case .kiro: - iconName = "k.circle" + iconName = "keyboard.badge.eye" case .grok: iconName = "GrokIcon" case .kimi: diff --git a/CopilotMonitor/CopilotMonitor/Views/SwiftUI/ModernStatusBarIconView.swift b/CopilotMonitor/CopilotMonitor/Views/SwiftUI/ModernStatusBarIconView.swift index 4e308e1..591f18b 100644 --- a/CopilotMonitor/CopilotMonitor/Views/SwiftUI/ModernStatusBarIconView.swift +++ b/CopilotMonitor/CopilotMonitor/Views/SwiftUI/ModernStatusBarIconView.swift @@ -136,7 +136,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 "k.circle" + case .kiro: return "keyboard.badge.eye" 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 index 5ed1ae9..3ebdff8 100644 --- a/CopilotMonitor/CopilotMonitorTests/KiroProviderTests.swift +++ b/CopilotMonitor/CopilotMonitorTests/KiroProviderTests.swift @@ -29,7 +29,7 @@ final class KiroProviderTests: XCTestCase { 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, "KIRO PRO") + XCTAssertEqual(usage.planName, "Pro") XCTAssertEqual(usage.overageStatus, "Disabled") XCTAssertNotNil(usage.resetDate) } @@ -41,7 +41,7 @@ final class KiroProviderTests: XCTestCase { XCTAssertEqual(usage.usedCredits, 1234.5, accuracy: 0.001) XCTAssertEqual(usage.totalCredits, 10_000, accuracy: 0.001) - XCTAssertEqual(usage.planName, "KIRO POWER") + XCTAssertEqual(usage.planName, "Power") XCTAssertEqual(usage.overageStatus, "Enabled") } @@ -50,7 +50,41 @@ final class KiroProviderTests: XCTestCase { let usage = try KiroProvider.parseUsageOutput(output) - XCTAssertEqual(usage.planName, "KIRO PRO") + 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() { @@ -63,9 +97,12 @@ final class KiroProviderTests: XCTestCase { let snapshot = KiroUsageSnapshot( usedCredits: 3.66, totalCredits: 1000, - planName: "KIRO PRO", + planName: "Pro", resetDate: resetDate, - overageStatus: "Disabled" + overageStatus: "Disabled", + bonusCreditsUsed: 25, + bonusCreditsTotal: 100, + bonusExpiryDays: 14 ) let result = KiroProvider.makeResult( @@ -81,10 +118,12 @@ final class KiroProviderTests: XCTestCase { } else { XCTFail("Expected quota-based usage") } - XCTAssertEqual(result.details?.planType, "KIRO PRO") + 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") } } 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..1d6804d --- /dev/null +++ b/scripts/query-kiro.sh @@ -0,0 +1,203 @@ +# 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 < 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 From 906062c1c8019b9d26bf3b5344f0036f74a1df00 Mon Sep 17 00:00:00 2001 From: Ruben Beuker Date: Tue, 19 May 2026 21:21:48 +0200 Subject: [PATCH 4/9] Preserve Kiro overage usage --- .../Providers/KiroProvider.swift | 4 +-- .../KiroProviderTests.swift | 26 +++++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/CopilotMonitor/CopilotMonitor/Providers/KiroProvider.swift b/CopilotMonitor/CopilotMonitor/Providers/KiroProvider.swift index e1579e7..56cc957 100644 --- a/CopilotMonitor/CopilotMonitor/Providers/KiroProvider.swift +++ b/CopilotMonitor/CopilotMonitor/Providers/KiroProvider.swift @@ -34,7 +34,7 @@ struct KiroUsageSnapshot: Equatable { } var remainingCredits: Double { - max(totalCredits - usedCredits, 0) + totalCredits - usedCredits } var usagePercent: Double { @@ -280,7 +280,7 @@ final class KiroProvider: ProviderProtocol { static func makeResult(from snapshot: KiroUsageSnapshot, binaryPath: URL) -> ProviderResult { let scale = 100.0 let entitlement = max(Int((snapshot.totalCredits * scale).rounded()), 1) - let remaining = max(Int((snapshot.remainingCredits * scale).rounded()), 0) + let remaining = Int((snapshot.remainingCredits * scale).rounded()) let details = DetailedUsage( secondaryUsage: bonusUsagePercent(from: snapshot), secondaryReset: bonusExpiryDate(from: snapshot), diff --git a/CopilotMonitor/CopilotMonitorTests/KiroProviderTests.swift b/CopilotMonitor/CopilotMonitorTests/KiroProviderTests.swift index 3ebdff8..130a161 100644 --- a/CopilotMonitor/CopilotMonitorTests/KiroProviderTests.swift +++ b/CopilotMonitor/CopilotMonitorTests/KiroProviderTests.swift @@ -126,4 +126,30 @@ final class KiroProviderTests: XCTestCase { 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) + } } From 03fb2442b4171e1f34d70be35d4b5098fa043315 Mon Sep 17 00:00:00 2001 From: Ruben Beuker Date: Tue, 19 May 2026 21:22:40 +0200 Subject: [PATCH 5/9] Fix Kiro query script shebang --- scripts/query-kiro.sh | 2 ++ 1 file changed, 2 insertions(+) diff --git a/scripts/query-kiro.sh b/scripts/query-kiro.sh index 1d6804d..9c31462 100755 --- a/scripts/query-kiro.sh +++ b/scripts/query-kiro.sh @@ -1,4 +1,6 @@ # Query Kiro billing usage through the authenticated Kiro CLI. +#!/usr/bin/env bash + # Authentication remains owned by Kiro; this script does not read local token databases. set -euo pipefail From 7b786a188abd7095dcbc16a0ea39e33584839886 Mon Sep 17 00:00:00 2001 From: Ruben Beuker Date: Tue, 19 May 2026 21:29:09 +0200 Subject: [PATCH 6/9] Preserve Kiro script overage usage --- scripts/query-kiro.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/query-kiro.sh b/scripts/query-kiro.sh index 9c31462..904690d 100755 --- a/scripts/query-kiro.sh +++ b/scripts/query-kiro.sh @@ -153,7 +153,7 @@ def parse_usage(output): "plan": plan, "used_credits": used, "total_credits": total, - "remaining_credits": max(total - used, 0), + "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, From 801c3b1342b11a11869eb4bb307c54f4f4cd29e2 Mon Sep 17 00:00:00 2001 From: Ruben Beuker Date: Tue, 19 May 2026 21:41:39 +0200 Subject: [PATCH 7/9] Move Kiro query shebang to first line --- scripts/query-kiro.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/query-kiro.sh b/scripts/query-kiro.sh index 904690d..cc2974d 100755 --- a/scripts/query-kiro.sh +++ b/scripts/query-kiro.sh @@ -1,6 +1,6 @@ -# Query Kiro billing usage through the authenticated Kiro CLI. #!/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 From 56864e376b82fb6a0000023a1564ac1513762514 Mon Sep 17 00:00:00 2001 From: Ruben Beuker Date: Wed, 20 May 2026 20:06:31 +0200 Subject: [PATCH 8/9] Restore Kiro ghost icon --- .../CopilotMonitor/App/StatusBarController.swift | 2 +- .../KiroIcon.imageset/Contents.json | 16 ++++++++++++++++ .../KiroIcon.imageset/kiro-ghost.svg | 3 +++ .../CopilotMonitor/Models/ProviderProtocol.swift | 2 +- .../Views/MultiProviderStatusBarIconView.swift | 2 +- .../Views/SwiftUI/ModernStatusBarIconView.swift | 9 ++++++++- 6 files changed, 30 insertions(+), 4 deletions(-) create mode 100644 CopilotMonitor/CopilotMonitor/Assets.xcassets/KiroIcon.imageset/Contents.json create mode 100644 CopilotMonitor/CopilotMonitor/Assets.xcassets/KiroIcon.imageset/kiro-ghost.svg diff --git a/CopilotMonitor/CopilotMonitor/App/StatusBarController.swift b/CopilotMonitor/CopilotMonitor/App/StatusBarController.swift index cbf09c9..419a22a 100644 --- a/CopilotMonitor/CopilotMonitor/App/StatusBarController.swift +++ b/CopilotMonitor/CopilotMonitor/App/StatusBarController.swift @@ -3049,7 +3049,7 @@ final class StatusBarController: NSObject { case .openCodeGo: image = NSImage(named: "OpencodeIcon") case .kiro: - image = NSImage(systemSymbolName: identifier.iconName, accessibilityDescription: identifier.displayName) + 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/Models/ProviderProtocol.swift b/CopilotMonitor/CopilotMonitor/Models/ProviderProtocol.swift index 2dec87a..972093c 100644 --- a/CopilotMonitor/CopilotMonitor/Models/ProviderProtocol.swift +++ b/CopilotMonitor/CopilotMonitor/Models/ProviderProtocol.swift @@ -144,7 +144,7 @@ enum ProviderIdentifier: String, CaseIterable { case .openCodeGo: return "chevron.left.forwardslash.chevron.right" case .kiro: - return "keyboard.badge.eye" + return "KiroIcon" case .grok: return "GrokIcon" case .kimi: diff --git a/CopilotMonitor/CopilotMonitor/Views/MultiProviderStatusBarIconView.swift b/CopilotMonitor/CopilotMonitor/Views/MultiProviderStatusBarIconView.swift index 01a44e7..eed9fe6 100644 --- a/CopilotMonitor/CopilotMonitor/Views/MultiProviderStatusBarIconView.swift +++ b/CopilotMonitor/CopilotMonitor/Views/MultiProviderStatusBarIconView.swift @@ -151,7 +151,7 @@ final class MultiProviderStatusBarIconView: NSView { case .openCodeGo: iconName = "OpencodeIcon" case .kiro: - iconName = "keyboard.badge.eye" + 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 591f18b..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,7 +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 "keyboard.badge.eye" + case .kiro: return nil case .grok: return nil case .antigravity: return "arrow.up.circle" case .kimi: return "k.circle" From 29cc25e1cad3ded8f87977f50731f026915a7f1b Mon Sep 17 00:00:00 2001 From: Ruben Beuker Date: Wed, 20 May 2026 20:22:42 +0200 Subject: [PATCH 9/9] Tighten Kiro CLI discovery --- .../Providers/KiroProvider.swift | 121 ++++++++++-------- scripts/query-kiro.sh | 14 ++ 2 files changed, 85 insertions(+), 50 deletions(-) diff --git a/CopilotMonitor/CopilotMonitor/Providers/KiroProvider.swift b/CopilotMonitor/CopilotMonitor/Providers/KiroProvider.swift index 56cc957..7d98292 100644 --- a/CopilotMonitor/CopilotMonitor/Providers/KiroProvider.swift +++ b/CopilotMonitor/CopilotMonitor/Providers/KiroProvider.swift @@ -58,7 +58,7 @@ final class KiroProvider: ProviderProtocol { func fetch() async throws -> ProviderResult { debugLog("fetch started") - guard let binaryPath = findKiroCLIBinary() else { + 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.") } @@ -74,13 +74,13 @@ final class KiroProvider: ProviderProtocol { return result } - private func findKiroCLIBinary() -> URL? { - if let path = findBinaryViaWhich() { + private func findKiroCLIBinary() async -> URL? { + if let path = await findBinaryViaWhich() { debugLog("kiro-cli found via PATH at \(path.path)") return path } - if let path = findBinaryViaLoginShell() { + if let path = await findBinaryViaLoginShell() { debugLog("kiro-cli found via login shell at \(path.path)") return path } @@ -101,58 +101,79 @@ final class KiroProvider: ProviderProtocol { return nil } - private func findBinaryViaWhich() -> URL? { - let process = Process() - process.executableURL = URL(fileURLWithPath: "/usr/bin/which") - process.arguments = ["kiro-cli"] + 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) + } - let pipe = Pipe() - process.standardOutput = pipe - process.standardError = FileHandle.nullDevice + 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) + } - do { - try process.run() - process.waitUntilExit() + 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) + } - guard process.terminationStatus == 0 else { return nil } - let data = pipe.fileHandleForReading.readDataToEndOfFile() - guard let output = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines), - !output.isEmpty, - fileManager.isExecutableFile(atPath: output) else { - return nil + 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() + } } - return URL(fileURLWithPath: output) - } catch { - debugLog("which kiro-cli failed: \(error.localizedDescription)") - return nil - } - } - private func findBinaryViaLoginShell() -> URL? { - let shell = ProcessInfo.processInfo.environment["SHELL"] ?? "/bin/zsh" - let process = Process() - process.executableURL = URL(fileURLWithPath: shell) - process.arguments = ["-lc", "which kiro-cli 2>/dev/null"] - - let pipe = Pipe() - process.standardOutput = pipe - process.standardError = FileHandle.nullDevice - - do { - try process.run() - process.waitUntilExit() - - guard process.terminationStatus == 0 else { return nil } - let data = pipe.fileHandleForReading.readDataToEndOfFile() - guard let output = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines), - !output.isEmpty, - fileManager.isExecutableFile(atPath: output) else { - return nil + 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) + } + } } - return URL(fileURLWithPath: output) - } catch { - debugLog("login shell kiro-cli lookup failed: \(error.localizedDescription)") - return nil + + 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 } } diff --git a/scripts/query-kiro.sh b/scripts/query-kiro.sh index cc2974d..4cedacd 100755 --- a/scripts/query-kiro.sh +++ b/scripts/query-kiro.sh @@ -59,6 +59,20 @@ def find_kiro_cli(): if found: return found + shell = os.environ.get("SHELL", "/bin/zsh") + try: + login_shell = subprocess.run( + [shell, "-lc", "command -v kiro-cli 2>/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",