Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 51 additions & 3 deletions Sources/OrreryCore/Commands/ListCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
)
}

Expand All @@ -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)
Expand Down Expand Up @@ -151,14 +160,53 @@ 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)"
}
}

return ([header] + bodyLines).joined(separator: "\n")
}
}

/// 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) {
Expand Down
85 changes: 85 additions & 0 deletions Sources/OrreryCore/Commands/QuotaCommand.swift
Original file line number Diff line number Diff line change
@@ -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 <env> or switch with `orrery use <env>`.")
}
87 changes: 87 additions & 0 deletions Sources/OrreryCore/Quota/ClaudeOAuthRefresh.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
85 changes: 85 additions & 0 deletions Sources/OrreryCore/Quota/ClaudeUsageFetcher.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
43 changes: 43 additions & 0 deletions Sources/OrreryCore/Quota/QuotaCache.swift
Original file line number Diff line number Diff line change
@@ -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")
}
}
Loading
Loading