From 7df5dd3366ce3e521b5e3df19d249125b2f9fb27 Mon Sep 17 00:00:00 2001 From: Grady Zhuo Date: Tue, 19 May 2026 13:39:16 +0800 Subject: [PATCH] [FEAT] quota tracking + last-used display in orrery list Introduces a Quota subsystem that fetches and caches AI tool usage: - New `orrery quota` subcommand with `refresh` action. - `Quota/` module: ClaudeUsageFetcher, QuotaCache, ClaudeOAuthRefresh, UsageQuota model. - `orrery list` now renders last-used relative time + cached quota per tool (5h/7d windows for Claude, stale flag after 8h). - `EnvironmentStore+LastUsed` records per-tool/env activation timestamps. - `RelativeTime` helper for human-readable durations. - `ClaudeKeychain.accessToken` / `validAccessToken` for authenticated endpoint access. - L10n: quota.* keys. Co-Authored-By: Claude Opus 4.7 (1M context) --- Sources/OrreryCore/Commands/ListCommand.swift | 54 ++++++++- .../OrreryCore/Commands/QuotaCommand.swift | 85 +++++++++++++ .../OrreryCore/Quota/ClaudeOAuthRefresh.swift | 87 ++++++++++++++ .../OrreryCore/Quota/ClaudeUsageFetcher.swift | 85 +++++++++++++ Sources/OrreryCore/Quota/QuotaCache.swift | 43 +++++++ Sources/OrreryCore/Quota/UsageQuota.swift | 100 ++++++++++++++++ .../OrreryCore/Resources/Localization/en.json | 6 + .../OrreryCore/Resources/Localization/ja.json | 6 + .../Localization/l10n-signatures.json | 18 +++ .../Resources/Localization/zh-Hant.json | 6 + Sources/OrreryCore/Setup/ClaudeKeychain.swift | 112 ++++++++++++++++++ .../Storage/EnvironmentStore+LastUsed.swift | 36 ++++++ Sources/OrreryCore/Storage/RelativeTime.swift | 19 +++ Sources/orrery/OrreryCommand.swift | 1 + Tests/OrreryTests/LastUsedTests.swift | 62 ++++++++++ Tests/OrreryTests/QuotaCacheTests.swift | 48 ++++++++ Tests/OrreryTests/RelativeTimeTests.swift | 38 ++++++ Tests/OrreryTests/UsageQuotaDecodeTests.swift | 43 +++++++ 18 files changed, 846 insertions(+), 3 deletions(-) create mode 100644 Sources/OrreryCore/Commands/QuotaCommand.swift create mode 100644 Sources/OrreryCore/Quota/ClaudeOAuthRefresh.swift create mode 100644 Sources/OrreryCore/Quota/ClaudeUsageFetcher.swift create mode 100644 Sources/OrreryCore/Quota/QuotaCache.swift create mode 100644 Sources/OrreryCore/Quota/UsageQuota.swift create mode 100644 Sources/OrreryCore/Storage/EnvironmentStore+LastUsed.swift create mode 100644 Sources/OrreryCore/Storage/RelativeTime.swift create mode 100644 Tests/OrreryTests/LastUsedTests.swift create mode 100644 Tests/OrreryTests/QuotaCacheTests.swift create mode 100644 Tests/OrreryTests/RelativeTimeTests.swift create mode 100644 Tests/OrreryTests/UsageQuotaDecodeTests.swift diff --git a/Sources/OrreryCore/Commands/ListCommand.swift b/Sources/OrreryCore/Commands/ListCommand.swift index 903e499..7b1f4ab 100644 --- a/Sources/OrreryCore/Commands/ListCommand.swift +++ b/Sources/OrreryCore/Commands/ListCommand.swift @@ -27,6 +27,9 @@ public struct ListCommand: ParsableCommand { private struct ToolRow { let name: String let suffix: String + /// Trailing info: last-used relative time (and quota in P2). Already + /// colorized; rendered after `suffix` with a `·` separator. + let usage: String } private struct EnvRow { @@ -95,9 +98,13 @@ public struct ListCommand: ParsableCommand { let info = results[i] let suffix = [info.email, info.plan, info.model].compactMap { $0 }.joined(separator: ", ") guard !suffix.isEmpty else { return nil } + let usage = Self.usageString( + store: store, tool: item.tool, envName: ReservedEnvironment.defaultName + ) return ToolRow( name: item.tool.rawValue, - suffix: Self.colorizeSuffix(suffix, email: info.email, plan: info.plan, model: info.model) + suffix: Self.colorizeSuffix(suffix, email: info.email, plan: info.plan, model: info.model), + usage: usage ) } @@ -117,9 +124,11 @@ public struct ListCommand: ParsableCommand { let item = workItems[i] let info = results[i] let suffix = [info.email, info.plan, info.model].compactMap { $0 }.joined(separator: ", ") + let usage = Self.usageString(store: store, tool: item.tool, envName: pair.name) return ToolRow( name: item.tool.rawValue, - suffix: Self.colorizeSuffix(suffix, email: info.email, plan: info.plan, model: info.model) + suffix: Self.colorizeSuffix(suffix, email: info.email, plan: info.plan, model: info.model), + usage: usage ) } let lastUsed = df.string(from: pair.env.lastUsed) @@ -151,7 +160,8 @@ public struct ListCommand: ParsableCommand { bodyLines = row.tools.map { tool in let paddedName = tool.name + String(repeating: " ", count: max(0, toolWidth - tool.name.count)) let prefix = Self.colorize(" · \(paddedName)", code: "90") - return tool.suffix.isEmpty ? prefix : "\(prefix)\(tool.suffix)" + let body = tool.suffix.isEmpty ? prefix : "\(prefix)\(tool.suffix)" + return tool.usage.isEmpty ? body : "\(body) \(Self.colorize("·", code: "90")) \(tool.usage)" } } @@ -159,6 +169,44 @@ public struct ListCommand: ParsableCommand { } } + /// Trailing per-tool info: last-used relative time + cached quota when + /// available. Returns "" when there is no signal worth rendering, so the + /// caller can skip the trailing `·`. + private static func usageString(store: EnvironmentStore, tool: Tool, envName: String) -> String { + var parts: [String] = [] + if let last = store.lastUsed(tool: tool, environment: envName) { + parts.append(colorize(RelativeTime.ago(from: last), code: "38;5;245")) + } + if let q = quotaSummary(store: store, tool: tool, envName: envName) { + parts.append(q) + } + return parts.joined(separator: " \(colorize("·", code: "90")) ") + } + + /// Compact "5h X% / 7d Y%" string from cached quota. Only Claude has data + /// in P2 — other tools return nil. Tagged "(stale)" when fetchedAt is + /// older than 8h, matching statusline.js's TTL. + private static func quotaSummary(store: EnvironmentStore, tool: Tool, envName: String) -> String? { + guard tool == .claude else { return nil } + let cache = QuotaCache(homeURL: store.homeURL) + guard let snap = cache.load(envName: envName), let q = snap.claude else { return nil } + var bits: [String] = [] + if let w = q.fiveHour { bits.append("5h \(formatPct(w.utilization))") } + if let w = q.sevenDay { bits.append("7d \(formatPct(w.utilization))") } + guard !bits.isEmpty else { return nil } + let stale = Date().timeIntervalSince(snap.fetchedAt) > 8 * 3600 + let body = bits.joined(separator: " / ") + let colored = colorize(body, code: stale ? "90" : "38;5;108") + return stale ? "\(colored) \(colorize("(stale)", code: "90"))" : colored + } + + private static func formatPct(_ percentage: Double) -> String { + let rounded = (percentage * 10).rounded() / 10 + return rounded == rounded.rounded() + ? "\(Int(rounded))%" + : String(format: "%.1f%%", rounded) + } + private static func colorizeSuffix(_ suffix: String, email: String?, plan: String?, model: String?) -> String { var result = suffix if let model, !model.isEmpty, let range = result.range(of: model, options: .backwards) { diff --git a/Sources/OrreryCore/Commands/QuotaCommand.swift b/Sources/OrreryCore/Commands/QuotaCommand.swift new file mode 100644 index 0000000..0320727 --- /dev/null +++ b/Sources/OrreryCore/Commands/QuotaCommand.swift @@ -0,0 +1,85 @@ +import ArgumentParser +import Foundation + +public struct QuotaCommand: ParsableCommand { + public static let configuration = CommandConfiguration( + commandName: "quota", + abstract: L10n.Quota.abstract, + subcommands: [Refresh.self] + ) + public init() {} + + public struct Refresh: ParsableCommand { + public static let configuration = CommandConfiguration( + commandName: "refresh", + abstract: L10n.Quota.refreshAbstract + ) + + @Option(name: .shortAndLong, help: ArgumentHelp(L10n.Quota.envHelp)) + public var environment: String? + + public init() {} + + public func run() throws { + let store = EnvironmentStore.default + let envName = try environment ?? quotaCurrentEnvOrThrow() + let cache = QuotaCache(homeURL: store.homeURL) + + // Resolve the Claude config dir for this env. `nil` = origin. + let configDir: String? + if envName == ReservedEnvironment.defaultName { + configDir = nil + } else { + configDir = store.toolConfigDir(tool: .claude, environment: envName).path + } + + let quota: UsageQuota + do { + quota = try ClaudeUsageFetcher.fetch(configDir: configDir) + } catch ClaudeUsageError.noAccessToken { + print(L10n.Quota.notLoggedIn(envName)) + throw ExitCode(1) + } catch { + print(L10n.Quota.fetchFailed(error.localizedDescription)) + throw ExitCode(1) + } + + try cache.update(envName: envName, claude: quota) + printQuota(envName: envName, quota: quota) + } + + private func printQuota(envName: String, quota: UsageQuota) { + print(L10n.Quota.refreshedHeader(envName)) + if let w = quota.fiveHour { + print(" 5h: \(formatPct(w.utilization))%\(formatReset(w.resetsAt))") + } + if let w = quota.sevenDay { + print(" 7d: \(formatPct(w.utilization))%\(formatReset(w.resetsAt))") + } + if let w = quota.sevenDayOpus { + print(" 7d (opus): \(formatPct(w.utilization))%\(formatReset(w.resetsAt))") + } + if let w = quota.sevenDaySonnet { + print(" 7d (sonnet): \(formatPct(w.utilization))%\(formatReset(w.resetsAt))") + } + } + + private func formatPct(_ utilization: Double) -> String { + // API returns percentage already (e.g. 13.0 for 13%). + String(format: "%.1f", utilization) + } + + private func formatReset(_ resetsAt: Date?) -> String { + guard let resetsAt else { return "" } + let df = DateFormatter() + df.dateStyle = .short + df.timeStyle = .short + return " · resets \(df.string(from: resetsAt))" + } + } +} + +private func quotaCurrentEnvOrThrow() throws -> String { + if let env = ProcessInfo.processInfo.environment["ORRERY_ACTIVE_ENV"] { return env } + throw ValidationError("No active environment. Use --environment or switch with `orrery use `.") +} diff --git a/Sources/OrreryCore/Quota/ClaudeOAuthRefresh.swift b/Sources/OrreryCore/Quota/ClaudeOAuthRefresh.swift new file mode 100644 index 0000000..9a064ee --- /dev/null +++ b/Sources/OrreryCore/Quota/ClaudeOAuthRefresh.swift @@ -0,0 +1,87 @@ +import Foundation + +/// OAuth token refresh for Claude Code credentials. +/// +/// Discovered by reverse-engineering `claude-code`'s bundled binary: +/// POST https://platform.claude.com/v1/oauth/token +/// body: { refresh_token, client_id, scope } +/// → { access_token, refresh_token, expires_in, scope, account, organization } +/// +/// We only call this when the stored `expiresAt` is in the past. claude-code +/// itself refreshes the same way when running, so writing the new tokens back +/// to the keychain stays compatible with concurrent claude usage. +public enum ClaudeOAuthRefresh { + /// Public OAuth client ID for the Claude Code CLI. Hard-coded by the + /// upstream binary; we use the same value because the refresh_token was + /// issued against it. + public static let clientID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e" + + /// POST to platform.claude.com to swap a refresh_token for a new + /// access_token. Returns the parsed response shape. + public static func refresh( + refreshToken: String, + scopes: [String], + clientID: String = ClaudeOAuthRefresh.clientID + ) throws -> RefreshedToken { + let url = URL(string: "https://platform.claude.com/v1/oauth/token")! + var request = URLRequest(url: url, timeoutInterval: 15.0) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("orrery/\(OrreryVersion.current)", forHTTPHeaderField: "User-Agent") + + let payload: [String: Any] = [ + "grant_type": "refresh_token", + "refresh_token": refreshToken, + "client_id": clientID, + "scope": scopes.joined(separator: " "), + ] + request.httpBody = try JSONSerialization.data(withJSONObject: payload) + + let (data, response) = try syncDataTask(with: request) + if let http = response as? HTTPURLResponse, http.statusCode != 200 { + let body = String(data: data, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines) + throw ClaudeUsageError.httpError(status: http.statusCode, body: body) + } + do { + return try JSONDecoder().decode(RefreshedToken.self, from: data) + } catch { + throw ClaudeUsageError.decode(error) + } + } + + public struct RefreshedToken: Codable, Sendable { + public let accessToken: String + public let refreshToken: String + public let expiresIn: Int + public let scope: String + + enum CodingKeys: String, CodingKey { + case accessToken = "access_token" + case refreshToken = "refresh_token" + case expiresIn = "expires_in" + case scope + } + } + + /// Same dispatch-group dance as ClaudeUsageFetcher — keeps the call sync + /// for CLI-style usage without forcing async up the stack. + private static func syncDataTask(with request: URLRequest) throws -> (Data, URLResponse) { + let group = DispatchGroup() + var result: (Data, URLResponse)? + var failure: (any Error)? + group.enter() + let task = URLSession.shared.dataTask(with: request) { data, response, error in + defer { group.leave() } + if let error { failure = error; return } + if let data, let response { result = (data, response) } + } + task.resume() + group.wait() + if let failure { throw ClaudeUsageError.transport(failure) } + guard let result else { + throw ClaudeUsageError.transport(URLError(.badServerResponse)) + } + return result + } +} diff --git a/Sources/OrreryCore/Quota/ClaudeUsageFetcher.swift b/Sources/OrreryCore/Quota/ClaudeUsageFetcher.swift new file mode 100644 index 0000000..99bb168 --- /dev/null +++ b/Sources/OrreryCore/Quota/ClaudeUsageFetcher.swift @@ -0,0 +1,85 @@ +import Foundation + +public enum ClaudeUsageError: Error, LocalizedError { + case noAccessToken + case httpError(status: Int, body: String?) + case transport(any Error) + case decode(any Error) + + public var errorDescription: String? { + switch self { + case .noAccessToken: + return "Claude is not logged in for this environment (no OAuth access token in keychain)." + case .httpError(let status, let body): + let suffix = body.map { " — \($0)" } ?? "" + return "Claude usage endpoint returned HTTP \(status)\(suffix)" + case .transport(let err): + return "Could not reach api.anthropic.com: \(err.localizedDescription)" + case .decode(let err): + return "Could not parse usage response: \(err.localizedDescription)" + } + } +} + +/// Fetches usage / quota info from Anthropic's `/api/oauth/usage` endpoint. +/// +/// This is the same endpoint `claude-code` calls (we located it by reverse +/// engineering its bundled binary). It requires an OAuth bearer token from +/// the Claude Keychain entry for the target config dir. +public enum ClaudeUsageFetcher { + /// `nil` configDir = origin (system `~/.claude`). + public static func fetch(configDir: String?) throws -> UsageQuota { + guard let token = try ClaudeKeychain.validAccessToken(for: configDir) else { + throw ClaudeUsageError.noAccessToken + } + let url = URL(string: "https://api.anthropic.com/api/oauth/usage")! + var request = URLRequest(url: url, timeoutInterval: 5.0) + request.httpMethod = "GET" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + request.setValue("orrery/\(OrreryVersion.current)", forHTTPHeaderField: "User-Agent") + // OAuth-authenticated endpoints reject requests without this beta header + // ("OAuth authentication is currently not supported"). Discovered while + // reverse-engineering claude-code's bootstrap call. + request.setValue("oauth-2025-04-20", forHTTPHeaderField: "anthropic-beta") + + let (data, response) = try syncDataTask(with: request) + + if let http = response as? HTTPURLResponse, http.statusCode != 200 { + let body = String(data: data, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines) + throw ClaudeUsageError.httpError(status: http.statusCode, body: body) + } + + do { + return try JSONDecoder().decode(UsageQuota.self, from: data) + } catch { + throw ClaudeUsageError.decode(error) + } + } + + /// `URLSession.dataTask` doesn't have a sync wrapper on Linux/older macOS, + /// and `async let` would force every caller into async. Drive it via a + /// dispatch group instead — used only here, where the whole CLI run is + /// single-shot anyway. + private static func syncDataTask(with request: URLRequest) throws -> (Data, URLResponse) { + let group = DispatchGroup() + var result: (Data, URLResponse)? + var failure: (any Error)? + + group.enter() + let task = URLSession.shared.dataTask(with: request) { data, response, error in + defer { group.leave() } + if let error { failure = error; return } + if let data, let response { result = (data, response) } + } + task.resume() + group.wait() + + if let failure { throw ClaudeUsageError.transport(failure) } + guard let result else { + throw ClaudeUsageError.transport(URLError(.badServerResponse)) + } + return result + } +} diff --git a/Sources/OrreryCore/Quota/QuotaCache.swift b/Sources/OrreryCore/Quota/QuotaCache.swift new file mode 100644 index 0000000..adecc22 --- /dev/null +++ b/Sources/OrreryCore/Quota/QuotaCache.swift @@ -0,0 +1,43 @@ +import Foundation + +/// Persists `QuotaSnapshot` per environment under `~/.orrery/quota-cache/`. +/// Each env gets its own JSON file so reads stay independent and refresh of +/// one env never invalidates another. +public struct QuotaCache: Sendable { + private let cacheDir: URL + + public init(homeURL: URL) { + self.cacheDir = homeURL.appendingPathComponent("quota-cache") + } + + public func load(envName: String) -> QuotaSnapshot? { + guard let data = try? Data(contentsOf: fileURL(envName: envName)) else { return nil } + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return try? decoder.decode(QuotaSnapshot.self, from: data) + } + + public func save(envName: String, snapshot: QuotaSnapshot) throws { + try FileManager.default.createDirectory(at: cacheDir, withIntermediateDirectories: true) + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys, .prettyPrinted] + encoder.dateEncodingStrategy = .iso8601 + let data = try encoder.encode(snapshot) + try data.write(to: fileURL(envName: envName), options: .atomic) + } + + /// Merge a fresh per-tool quota into the env's existing snapshot. Other + /// tool fields are preserved so refreshing claude alone doesn't clobber + /// codex/gemini data once we add them in P3. + public func update(envName: String, claude: UsageQuota, fetchedAt: Date = Date()) throws { + let _ = load(envName: envName) // future: merge non-claude fields + let merged = QuotaSnapshot(fetchedAt: fetchedAt, claude: claude) + try save(envName: envName, snapshot: merged) + } + + private func fileURL(envName: String) -> URL { + // Env names come from the user; replace path separators just in case. + let safe = envName.replacingOccurrences(of: "/", with: "_") + return cacheDir.appendingPathComponent("\(safe).json") + } +} diff --git a/Sources/OrreryCore/Quota/UsageQuota.swift b/Sources/OrreryCore/Quota/UsageQuota.swift new file mode 100644 index 0000000..ccd90d5 --- /dev/null +++ b/Sources/OrreryCore/Quota/UsageQuota.swift @@ -0,0 +1,100 @@ +import Foundation + +/// One usage window from Anthropic's `/api/oauth/usage` (e.g. five-hour, seven-day). +public struct WindowedUsage: Codable, Equatable, Sendable { + /// Percentage in [0, 100]. The API already returns it pre-multiplied. + public let utilization: Double + /// When the window resets. `null` in the API when no usage has accrued yet. + public let resetsAt: Date? + + enum CodingKeys: String, CodingKey { + case utilization + case resetsAt = "resets_at" + } + + public init(utilization: Double, resetsAt: Date?) { + self.utilization = utilization + self.resetsAt = resetsAt + } + + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + self.utilization = try c.decode(Double.self, forKey: .utilization) + // Server sends ISO 8601 with fractional seconds. JSONDecoder's + // built-in iso8601 strategy is per-decoder, so parse manually here + // to keep decoding the rest of the response with default settings. + // ISO8601DateFormatter is not Sendable — instantiate per-call. + if let s = try c.decodeIfPresent(String.self, forKey: .resetsAt) { + self.resetsAt = WindowedUsage.parseISO8601(s) + } else { + self.resetsAt = nil + } + } + + public func encode(to encoder: Encoder) throws { + var c = encoder.container(keyedBy: CodingKeys.self) + try c.encode(utilization, forKey: .utilization) + if let resetsAt { + try c.encode(WindowedUsage.formatISO8601(resetsAt), forKey: .resetsAt) + } else { + try c.encodeNil(forKey: .resetsAt) + } + } + + private static func parseISO8601(_ s: String) -> Date? { + let withFractional = ISO8601DateFormatter() + withFractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let d = withFractional.date(from: s) { return d } + let plain = ISO8601DateFormatter() + plain.formatOptions = [.withInternetDateTime] + return plain.date(from: s) + } + + private static func formatISO8601(_ date: Date) -> String { + let f = ISO8601DateFormatter() + f.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return f.string(from: date) + } +} + +/// Snapshot of one tool's quota — currently only Claude exposes a usable +/// endpoint, so this struct mirrors `/api/oauth/usage` directly. Codex and +/// Gemini will get their own variants in P3. +public struct UsageQuota: Codable, Equatable, Sendable { + public let fiveHour: WindowedUsage? + public let sevenDay: WindowedUsage? + /// `seven_day_opus` / `seven_day_sonnet` only present on max plans. + public let sevenDayOpus: WindowedUsage? + public let sevenDaySonnet: WindowedUsage? + + enum CodingKeys: String, CodingKey { + case fiveHour = "five_hour" + case sevenDay = "seven_day" + case sevenDayOpus = "seven_day_opus" + case sevenDaySonnet = "seven_day_sonnet" + } + + public init( + fiveHour: WindowedUsage?, + sevenDay: WindowedUsage?, + sevenDayOpus: WindowedUsage? = nil, + sevenDaySonnet: WindowedUsage? = nil + ) { + self.fiveHour = fiveHour + self.sevenDay = sevenDay + self.sevenDayOpus = sevenDayOpus + self.sevenDaySonnet = sevenDaySonnet + } +} + +/// One cache entry: a quota snapshot plus when we fetched it. Persisted as +/// `~/.orrery/quota-cache/.json` keyed by tool. +public struct QuotaSnapshot: Codable, Equatable, Sendable { + public let fetchedAt: Date + public let claude: UsageQuota? + + public init(fetchedAt: Date, claude: UsageQuota? = nil) { + self.fetchedAt = fetchedAt + self.claude = claude + } +} diff --git a/Sources/OrreryCore/Resources/Localization/en.json b/Sources/OrreryCore/Resources/Localization/en.json index bca0040..56e1193 100644 --- a/Sources/OrreryCore/Resources/Localization/en.json +++ b/Sources/OrreryCore/Resources/Localization/en.json @@ -292,6 +292,12 @@ "which.noActive": "No active environment. Run 'orrery use ' first.", "which.toolHelp": "Tool name: claude, codex, or gemini", "which.unknownTool": "Unknown tool '{tool}'. Valid tools: claude, codex, gemini", + "quota.abstract": "Inspect or refresh AI tool usage quotas for an environment", + "quota.refreshAbstract": "Fetch fresh quota data from each tool and update the cache", + "quota.envHelp": "Environment name (defaults to active environment)", + "quota.notLoggedIn": "Cannot fetch quota: '{env}' is not logged in for Claude.", + "quota.fetchFailed": "Could not fetch quota: {error}", + "quota.refreshedHeader": "Quota for environment '{env}':", "install.abstract": "Install a third-party add-on into an environment", "install.idHelp": "Package id (e.g. statusline)", "install.envHelp": "Target environment name", diff --git a/Sources/OrreryCore/Resources/Localization/ja.json b/Sources/OrreryCore/Resources/Localization/ja.json index b1c265c..7e32a66 100644 --- a/Sources/OrreryCore/Resources/Localization/ja.json +++ b/Sources/OrreryCore/Resources/Localization/ja.json @@ -292,6 +292,12 @@ "which.noActive": "No active environment. Run 'orrery use ' first.", "which.toolHelp": "Tool name: claude, codex, or gemini", "which.unknownTool": "Unknown tool '{tool}'. Valid tools: claude, codex, gemini", + "quota.abstract": "Inspect or refresh AI tool usage quotas for an environment", + "quota.refreshAbstract": "Fetch fresh quota data from each tool and update the cache", + "quota.envHelp": "Environment name (defaults to active environment)", + "quota.notLoggedIn": "Cannot fetch quota: '{env}' is not logged in for Claude.", + "quota.fetchFailed": "Could not fetch quota: {error}", + "quota.refreshedHeader": "Quota for environment '{env}':", "install.abstract": "Install a third-party add-on into an environment", "install.idHelp": "Package id (e.g. statusline)", "install.envHelp": "Target environment name", diff --git a/Sources/OrreryCore/Resources/Localization/l10n-signatures.json b/Sources/OrreryCore/Resources/Localization/l10n-signatures.json index f85ab3e..a9b6881 100644 --- a/Sources/OrreryCore/Resources/Localization/l10n-signatures.json +++ b/Sources/OrreryCore/Resources/Localization/l10n-signatures.json @@ -2131,6 +2131,24 @@ "kind": "func", "parameters": [{ "label": "_", "name": "path", "type": "String" }] }, + { "path": ["Quota", "abstract"], "kind": "var", "parameters": [] }, + { "path": ["Quota", "refreshAbstract"], "kind": "var", "parameters": [] }, + { "path": ["Quota", "envHelp"], "kind": "var", "parameters": [] }, + { + "path": ["Quota", "notLoggedIn"], + "kind": "func", + "parameters": [{ "label": "_", "name": "env", "type": "String" }] + }, + { + "path": ["Quota", "fetchFailed"], + "kind": "func", + "parameters": [{ "label": "_", "name": "error", "type": "String" }] + }, + { + "path": ["Quota", "refreshedHeader"], + "kind": "func", + "parameters": [{ "label": "_", "name": "env", "type": "String" }] + }, { "path": ["Install", "abstract"], "kind": "var", "parameters": [] }, { "path": ["Install", "idHelp"], "kind": "var", "parameters": [] }, { "path": ["Install", "envHelp"], "kind": "var", "parameters": [] }, diff --git a/Sources/OrreryCore/Resources/Localization/zh-Hant.json b/Sources/OrreryCore/Resources/Localization/zh-Hant.json index 9e623c0..4cdf9fb 100644 --- a/Sources/OrreryCore/Resources/Localization/zh-Hant.json +++ b/Sources/OrreryCore/Resources/Localization/zh-Hant.json @@ -292,6 +292,12 @@ "which.noActive": "沒有啟用的環境。請先執行 'orrery use '。", "which.toolHelp": "工具名稱:claude、codex 或 gemini", "which.unknownTool": "未知工具 '{tool}'。可用工具:claude, codex, gemini", + "quota.abstract": "查看或刷新環境內各 AI tool 的使用量配額", + "quota.refreshAbstract": "從每個 tool 重新抓取最新的 quota 並更新快取", + "quota.envHelp": "環境名稱(預設為當前啟用環境)", + "quota.notLoggedIn": "無法抓取 quota:'{env}' 的 Claude 尚未登入。", + "quota.fetchFailed": "抓取 quota 失敗:{error}", + "quota.refreshedHeader": "環境 '{env}' 的 quota:", "install.abstract": "把第三方外掛安裝到指定環境", "install.idHelp": "套件 id(例如 statusline)", "install.envHelp": "目標環境名稱", diff --git a/Sources/OrreryCore/Setup/ClaudeKeychain.swift b/Sources/OrreryCore/Setup/ClaudeKeychain.swift index 12ee301..a4493fd 100644 --- a/Sources/OrreryCore/Setup/ClaudeKeychain.swift +++ b/Sources/OrreryCore/Setup/ClaudeKeychain.swift @@ -38,6 +38,100 @@ public enum ClaudeKeychain { return dir.appendingPathComponent(".credentials.json") } + /// OAuth access token from the Claude credential store, used by anything + /// that hits an authenticated endpoint (`/api/oauth/usage` etc.). Returns + /// nil when the credential isn't present or doesn't expose the token. + /// Does NOT refresh expired tokens — see `validAccessToken(for:)`. + public static func accessToken(for configDir: String?) -> String? { + loadCredential(for: configDir)?.accessToken + } + + /// Returns a non-expired access token, refreshing via the platform OAuth + /// endpoint when needed. The refreshed token is written back to the + /// credential store so subsequent calls (and concurrent claude-code + /// processes) see the new value. Returns nil only when the credential is + /// missing entirely; throws on refresh failure. + public static func validAccessToken(for configDir: String?) throws -> String? { + guard let credential = loadCredential(for: configDir) else { return nil } + // 60s skew — refresh slightly early so a request started just before + // expiry doesn't race the clock. + let nowMS = Int64(Date().timeIntervalSince1970 * 1000) + if credential.expiresAt > nowMS + 60_000 { + return credential.accessToken + } + let refreshed = try ClaudeOAuthRefresh.refresh( + refreshToken: credential.refreshToken, + scopes: credential.scopes + ) + let updated = ClaudeCredential( + accessToken: refreshed.accessToken, + refreshToken: refreshed.refreshToken, + expiresAt: nowMS + Int64(refreshed.expiresIn) * 1000, + scopes: refreshed.scope.split(separator: " ").map(String.init), + extras: credential.extras + ) + try saveCredential(updated, for: configDir) + return refreshed.accessToken + } + + /// Parsed view of `claudeAiOauth` JSON — the subset orrery needs plus an + /// opaque bag of fields claude-code maintains (subscriptionType, + /// rateLimitTier, clientId, etc.) so we can write the JSON back without + /// dropping anything. + public struct ClaudeCredential { + public let accessToken: String + public let refreshToken: String + public let expiresAt: Int64 + public let scopes: [String] + /// Other keys present in `claudeAiOauth` that we round-trip verbatim. + public let extras: [String: Any] + + public init(accessToken: String, refreshToken: String, expiresAt: Int64, + scopes: [String], extras: [String: Any]) { + self.accessToken = accessToken + self.refreshToken = refreshToken + self.expiresAt = expiresAt + self.scopes = scopes + self.extras = extras + } + } + + public static func loadCredential(for configDir: String?) -> ClaudeCredential? { + guard let json = loadCredentialJSON(for: configDir), + let data = json.data(using: .utf8), + let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + var oauth = obj["claudeAiOauth"] as? [String: Any], + let access = oauth.removeValue(forKey: "accessToken") as? String, + !access.isEmpty, + let refresh = oauth.removeValue(forKey: "refreshToken") as? String, + let expiresAt = oauth.removeValue(forKey: "expiresAt") as? NSNumber + else { return nil } + let scopes = (oauth.removeValue(forKey: "scopes") as? [String]) ?? [] + return ClaudeCredential( + accessToken: access, + refreshToken: refresh, + expiresAt: expiresAt.int64Value, + scopes: scopes, + extras: oauth + ) + } + + /// Write the credential back. Preserves the surrounding JSON shape + /// (`{"claudeAiOauth": {...}}`) and round-trips `extras`. + public static func saveCredential(_ credential: ClaudeCredential, for configDir: String?) throws { + var oauth: [String: Any] = credential.extras + oauth["accessToken"] = credential.accessToken + oauth["refreshToken"] = credential.refreshToken + oauth["expiresAt"] = credential.expiresAt + oauth["scopes"] = credential.scopes + let envelope: [String: Any] = ["claudeAiOauth": oauth] + let data = try JSONSerialization.data(withJSONObject: envelope, options: [.sortedKeys]) + guard let json = String(data: data, encoding: .utf8) else { + throw ClaudeUsageError.transport(URLError(.cannotDecodeContentData)) + } + try writeCredentialJSON(json, for: configDir) + } + /// Look up the Claude account email (from `.claude.json`) and plan (from the credential /// store) for a given config dir. Pass `nil` for origin (unset CLAUDE_CONFIG_DIR). public static func accountInfo(for configDir: String?) -> ToolAuth.AccountInfo { @@ -96,6 +190,24 @@ public enum ClaudeKeychain { #endif } + /// Counterpart to `loadCredentialJSON`. macOS: writes via `security + /// add-generic-password -U` (-U upserts). Linux: writes the same JSON to + /// `.credentials.json` with mode 0600. + private static func writeCredentialJSON(_ json: String, for configDir: String?) throws { + #if os(macOS) + let account = ProcessInfo.processInfo.environment["USER"] ?? NSUserName() + guard addPassword(service: service(for: configDir), account: account, password: json) else { + throw ClaudeUsageError.transport(URLError(.cannotWriteToFile)) + } + #else + let url = credentialsFile(for: configDir) + let fm = FileManager.default + try fm.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try Data(json.utf8).write(to: url, options: .atomic) + try? fm.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path) + #endif + } + private static func parsePlan(fromCredential json: String) -> String? { guard let data = json.data(using: .utf8), let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any], diff --git a/Sources/OrreryCore/Storage/EnvironmentStore+LastUsed.swift b/Sources/OrreryCore/Storage/EnvironmentStore+LastUsed.swift new file mode 100644 index 0000000..68b382b --- /dev/null +++ b/Sources/OrreryCore/Storage/EnvironmentStore+LastUsed.swift @@ -0,0 +1,36 @@ +import Foundation + +extension EnvironmentStore { + /// Newest mtime among the tool's session subdirectories for an environment. + /// `origin` resolves to the system tool config dir. Returns nil when no + /// session files have been written yet. + public func lastUsed(tool: Tool, environment envName: String) -> Date? { + let configDir = envName == ReservedEnvironment.defaultName + ? originConfigDir(tool: tool) + : toolConfigDir(tool: tool, environment: envName) + let fm = FileManager.default + let keys: [URLResourceKey] = [.contentModificationDateKey, .isRegularFileKey] + var newest: Date? + + for sub in tool.sessionSubdirectories { + // Sessions subdirs are usually symlinks (shared sessions mode). + // FileManager.enumerator(at:) refuses to enumerate when the root + // itself is a symlink — resolve to the real path first. + let dir = configDir.appendingPathComponent(sub).resolvingSymlinksInPath() + guard let enumerator = fm.enumerator( + at: dir, + includingPropertiesForKeys: keys, + options: [.skipsHiddenFiles, .skipsPackageDescendants] + ) else { continue } + + for case let url as URL in enumerator { + let values = try? url.resourceValues(forKeys: Set(keys)) + guard values?.isRegularFile == true, + let mtime = values?.contentModificationDate + else { continue } + if newest == nil || mtime > newest! { newest = mtime } + } + } + return newest + } +} diff --git a/Sources/OrreryCore/Storage/RelativeTime.swift b/Sources/OrreryCore/Storage/RelativeTime.swift new file mode 100644 index 0000000..c7324b3 --- /dev/null +++ b/Sources/OrreryCore/Storage/RelativeTime.swift @@ -0,0 +1,19 @@ +import Foundation + +/// Compact relative-time formatter for `orrery list`. Output is human-friendly +/// English (e.g. "5m ago", "2d ago") to keep alignment predictable across rows. +public enum RelativeTime { + public static func ago(from date: Date, now: Date = Date()) -> String { + let seconds = max(0, Int(now.timeIntervalSince(date))) + if seconds < 60 { return "\(seconds)s ago" } + let minutes = seconds / 60 + if minutes < 60 { return "\(minutes)m ago" } + let hours = minutes / 60 + if hours < 24 { return "\(hours)h ago" } + let days = hours / 24 + if days < 30 { return "\(days)d ago" } + let months = days / 30 + if months < 12 { return "\(months)mo ago" } + return "\(months / 12)y ago" + } +} diff --git a/Sources/orrery/OrreryCommand.swift b/Sources/orrery/OrreryCommand.swift index cfdb46f..57ce7c5 100644 --- a/Sources/orrery/OrreryCommand.swift +++ b/Sources/orrery/OrreryCommand.swift @@ -46,6 +46,7 @@ public struct OrreryCommand: AsyncParsableCommand { OriginCommand.self, UninstallCommand.self, AuthCommand.self, + QuotaCommand.self, InstallCommand.self, ThirdPartyCommand.self, PhantomTriggerCommand.self, diff --git a/Tests/OrreryTests/LastUsedTests.swift b/Tests/OrreryTests/LastUsedTests.swift new file mode 100644 index 0000000..76e270b --- /dev/null +++ b/Tests/OrreryTests/LastUsedTests.swift @@ -0,0 +1,62 @@ +import Testing +import Foundation +@testable import OrreryCore + +@Suite("EnvironmentStore.lastUsed") +struct LastUsedTests { + private func tempStore() throws -> (EnvironmentStore, URL) { + let home = FileManager.default.temporaryDirectory + .appendingPathComponent("orrery-lastused-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: home, withIntermediateDirectories: true) + return (EnvironmentStore(homeURL: home), home) + } + + @Test("returns nil when no session files exist") + func empty() throws { + let (store, _) = try tempStore() + try store.save(OrreryEnvironment(name: "work", tools: [.claude])) + #expect(store.lastUsed(tool: .claude, environment: "work") == nil) + } + + @Test("returns the newest mtime across session subdirs") + func newestMtime() throws { + let (store, _) = try tempStore() + try store.save(OrreryEnvironment(name: "work", tools: [.claude])) + let claudeDir = store.toolConfigDir(tool: .claude, environment: "work") + let projects = claudeDir.appendingPathComponent("projects") + try FileManager.default.createDirectory(at: projects, withIntermediateDirectories: true) + + let oldFile = projects.appendingPathComponent("old.jsonl") + let newFile = projects.appendingPathComponent("new.jsonl") + try Data("old".utf8).write(to: oldFile) + try Data("new".utf8).write(to: newFile) + let oldDate = Date(timeIntervalSince1970: 1_700_000_000) + let newDate = Date(timeIntervalSince1970: 1_777_000_000) + try FileManager.default.setAttributes([.modificationDate: oldDate], ofItemAtPath: oldFile.path) + try FileManager.default.setAttributes([.modificationDate: newDate], ofItemAtPath: newFile.path) + + let result = store.lastUsed(tool: .claude, environment: "work") + #expect(result == newDate) + } + + @Test("follows symlinked session subdirs (shared sessions mode)") + func symlinked() throws { + let (store, home) = try tempStore() + try store.save(OrreryEnvironment(name: "work", tools: [.codex])) + let codexDir = store.toolConfigDir(tool: .codex, environment: "work") + try FileManager.default.createDirectory(at: codexDir, withIntermediateDirectories: true) + + // Real sessions dir lives elsewhere; codex/sessions is just a symlink. + let real = home.appendingPathComponent("shared-sessions") + try FileManager.default.createDirectory(at: real, withIntermediateDirectories: true) + let file = real.appendingPathComponent("session.json") + try Data("x".utf8).write(to: file) + let mtime = Date(timeIntervalSince1970: 1_750_000_000) + try FileManager.default.setAttributes([.modificationDate: mtime], ofItemAtPath: file.path) + + let link = codexDir.appendingPathComponent("sessions") + try FileManager.default.createSymbolicLink(at: link, withDestinationURL: real) + + #expect(store.lastUsed(tool: .codex, environment: "work") == mtime) + } +} diff --git a/Tests/OrreryTests/QuotaCacheTests.swift b/Tests/OrreryTests/QuotaCacheTests.swift new file mode 100644 index 0000000..56a7331 --- /dev/null +++ b/Tests/OrreryTests/QuotaCacheTests.swift @@ -0,0 +1,48 @@ +import Testing +import Foundation +@testable import OrreryCore + +@Suite("QuotaCache") +struct QuotaCacheTests { + private func tempCache() -> (QuotaCache, URL) { + let home = FileManager.default.temporaryDirectory + .appendingPathComponent("orrery-quota-\(UUID().uuidString)") + try? FileManager.default.createDirectory(at: home, withIntermediateDirectories: true) + return (QuotaCache(homeURL: home), home) + } + + @Test("load returns nil when file does not exist") + func loadMissing() { + let (cache, _) = tempCache() + #expect(cache.load(envName: "work") == nil) + } + + @Test("save then load round-trips a quota snapshot") + func roundTrip() throws { + let (cache, _) = tempCache() + let resetAt = Date(timeIntervalSince1970: 1_777_500_000) + let quota = UsageQuota( + fiveHour: WindowedUsage(utilization: 12.5, resetsAt: resetAt), + sevenDay: WindowedUsage(utilization: 33.0, resetsAt: nil) + ) + try cache.update(envName: "work", claude: quota, + fetchedAt: Date(timeIntervalSince1970: 1_777_400_000)) + + let loaded = cache.load(envName: "work") + #expect(loaded != nil) + #expect(loaded?.claude?.fiveHour?.utilization == 12.5) + #expect(loaded?.claude?.fiveHour?.resetsAt == resetAt) + #expect(loaded?.claude?.sevenDay?.utilization == 33.0) + #expect(loaded?.claude?.sevenDay?.resetsAt == nil) + #expect(loaded?.fetchedAt == Date(timeIntervalSince1970: 1_777_400_000)) + } + + @Test("envs are isolated — saving 'work' does not change 'personal'") + func isolation() throws { + let (cache, _) = tempCache() + let q = UsageQuota(fiveHour: WindowedUsage(utilization: 1, resetsAt: nil), sevenDay: nil) + try cache.update(envName: "work", claude: q) + #expect(cache.load(envName: "work")?.claude?.fiveHour?.utilization == 1) + #expect(cache.load(envName: "personal") == nil) + } +} diff --git a/Tests/OrreryTests/RelativeTimeTests.swift b/Tests/OrreryTests/RelativeTimeTests.swift new file mode 100644 index 0000000..a444208 --- /dev/null +++ b/Tests/OrreryTests/RelativeTimeTests.swift @@ -0,0 +1,38 @@ +import Testing +import Foundation +@testable import OrreryCore + +@Suite("RelativeTime") +struct RelativeTimeTests { + private let now = Date(timeIntervalSince1970: 1_700_000_000) + + @Test("seconds bucket") + func seconds() { + #expect(RelativeTime.ago(from: now.addingTimeInterval(-5), now: now) == "5s ago") + #expect(RelativeTime.ago(from: now.addingTimeInterval(-59), now: now) == "59s ago") + } + + @Test("minutes bucket") + func minutes() { + #expect(RelativeTime.ago(from: now.addingTimeInterval(-60), now: now) == "1m ago") + #expect(RelativeTime.ago(from: now.addingTimeInterval(-59*60), now: now) == "59m ago") + } + + @Test("hours bucket") + func hours() { + #expect(RelativeTime.ago(from: now.addingTimeInterval(-60*60), now: now) == "1h ago") + #expect(RelativeTime.ago(from: now.addingTimeInterval(-23*60*60), now: now) == "23h ago") + } + + @Test("days, months, years") + func longer() { + #expect(RelativeTime.ago(from: now.addingTimeInterval(-2*86400), now: now) == "2d ago") + #expect(RelativeTime.ago(from: now.addingTimeInterval(-45*86400), now: now) == "1mo ago") + #expect(RelativeTime.ago(from: now.addingTimeInterval(-400*86400), now: now) == "1y ago") + } + + @Test("future dates clamp to 0s") + func future() { + #expect(RelativeTime.ago(from: now.addingTimeInterval(60), now: now) == "0s ago") + } +} diff --git a/Tests/OrreryTests/UsageQuotaDecodeTests.swift b/Tests/OrreryTests/UsageQuotaDecodeTests.swift new file mode 100644 index 0000000..0919767 --- /dev/null +++ b/Tests/OrreryTests/UsageQuotaDecodeTests.swift @@ -0,0 +1,43 @@ +import Testing +import Foundation +@testable import OrreryCore + +@Suite("UsageQuota — /api/oauth/usage decode") +struct UsageQuotaDecodeTests { + /// Real response shape captured from api.anthropic.com (with values trimmed). + private let realPayload = #""" + { + "five_hour": {"utilization": 0.0, "resets_at": null}, + "seven_day": {"utilization": 13.0, "resets_at": "2026-05-02T18:00:00.808561+00:00"}, + "seven_day_oauth_apps": null, + "seven_day_opus": null, + "seven_day_sonnet": {"utilization": 10.0, "resets_at": "2026-05-02T18:00:00.808573+00:00"}, + "seven_day_cowork": null, + "extra_usage": {"is_enabled": true, "monthly_limit": null, "used_credits": 1108.0} + } + """# + + @Test("decodes the live shape including null windows and ISO-8601 fractional seconds") + func realShape() throws { + let q = try JSONDecoder().decode(UsageQuota.self, from: Data(realPayload.utf8)) + #expect(q.fiveHour?.utilization == 0.0) + #expect(q.fiveHour?.resetsAt == nil) + #expect(q.sevenDay?.utilization == 13.0) + #expect(q.sevenDay?.resetsAt != nil) + #expect(q.sevenDayOpus == nil) + #expect(q.sevenDaySonnet?.utilization == 10.0) + } + + @Test("round-trips through JSONEncoder") + func encodeRoundTrip() throws { + let q = UsageQuota( + fiveHour: WindowedUsage(utilization: 5.0, resetsAt: Date(timeIntervalSince1970: 1_777_500_000)), + sevenDay: WindowedUsage(utilization: 25.5, resetsAt: nil) + ) + let data = try JSONEncoder().encode(q) + let again = try JSONDecoder().decode(UsageQuota.self, from: data) + #expect(again.fiveHour?.utilization == 5.0) + #expect(again.fiveHour?.resetsAt == Date(timeIntervalSince1970: 1_777_500_000)) + #expect(again.sevenDay?.resetsAt == nil) + } +}