diff --git a/Sources/CodexBarCLI/CLIConfigCommand.swift b/Sources/CodexBarCLI/CLIConfigCommand.swift index b79e48cbfe..8442f34524 100644 --- a/Sources/CodexBarCLI/CLIConfigCommand.swift +++ b/Sources/CodexBarCLI/CLIConfigCommand.swift @@ -3,6 +3,29 @@ import Commander import Foundation extension CodexBarCLI { + static func runConfig(path: [String], values: ParsedValues) { + switch path { + case ["config", "validate"]: + self.runConfigValidate(values) + case ["config", "dump"]: + self.runConfigDump(values) + case ["config", "providers"]: + self.runConfigProviders(values) + case ["config", "enable"]: + self.runConfigSetProviderEnabled(values, enabled: true) + case ["config", "disable"]: + self.runConfigSetProviderEnabled(values, enabled: false) + case ["config", "set-api-key"]: + self.runConfigSetAPIKey(values) + default: + self.exit( + code: .failure, + message: "Unknown command", + output: CLIOutputPreferences.from(values: values), + kind: .args) + } + } + static func runConfigValidate(_ values: ParsedValues) { let output = CLIOutputPreferences.from(values: values) let config = Self.loadConfig(output: output) diff --git a/Sources/CodexBarCLI/CLIEntry.swift b/Sources/CodexBarCLI/CLIEntry.swift index 28faa10936..d4facde5c3 100644 --- a/Sources/CodexBarCLI/CLIEntry.swift +++ b/Sources/CodexBarCLI/CLIEntry.swift @@ -59,18 +59,8 @@ enum CodexBarCLI { await self.runSessionsFocus(invocation.parsedValues) case ["serve"]: await self.runServe(invocation.parsedValues) - case ["config", "validate"]: - self.runConfigValidate(invocation.parsedValues) - case ["config", "dump"]: - self.runConfigDump(invocation.parsedValues) - case ["config", "providers"]: - self.runConfigProviders(invocation.parsedValues) - case ["config", "enable"]: - self.runConfigSetProviderEnabled(invocation.parsedValues, enabled: true) - case ["config", "disable"]: - self.runConfigSetProviderEnabled(invocation.parsedValues, enabled: false) - case ["config", "set-api-key"]: - self.runConfigSetAPIKey(invocation.parsedValues) + case let path where path.first == "config": + self.runConfig(path: path, values: invocation.parsedValues) case let path where path.first == "hooks": await self.runHooks(path: path, values: invocation.parsedValues) case ["cache", "clear"]: @@ -81,6 +71,8 @@ enum CodexBarCLI { } defer { signalMonitor.cancel() } await self.runDiagnose(invocation.parsedValues) + case ["guard"]: + await self.runGuard(invocation.parsedValues) default: Self.exit( code: .failure, @@ -89,7 +81,8 @@ enum CodexBarCLI { kind: .args) } } catch let error as CommanderProgramError { - Self.exit(code: .failure, message: error.description, output: outputPreferences, kind: .args) + let exitCode: ExitCode = argv.first == "guard" ? .usage : .failure + Self.exit(code: exitCode, message: error.description, output: outputPreferences, kind: .args) } catch { Self.exit(code: .failure, message: error.localizedDescription, output: outputPreferences, kind: .runtime) } @@ -109,6 +102,7 @@ enum CodexBarCLI { let diagnoseSignature = CommandSignature.describe(DiagnoseOptions()) let hooksSignature = CommandSignature.describe(HooksOptions()) let hooksTestSignature = CommandSignature.describe(HooksTestOptions()) + let guardSignature = CommandSignature.describe(GuardOptions()) return [ CommandDescriptor( @@ -121,6 +115,11 @@ enum CodexBarCLI { abstract: "Print usage as text or JSON", discussion: nil, signature: usageSignature), + CommandDescriptor( + name: "guard", + abstract: "Exit non-zero when a provider lacks quota headroom (for gating scripts)", + discussion: nil, + signature: guardSignature), CommandDescriptor( name: "cost", abstract: "Print local cost usage as text or JSON", diff --git a/Sources/CodexBarCLI/CLIExitCode.swift b/Sources/CodexBarCLI/CLIExitCode.swift index d658552d4f..654df628f7 100644 --- a/Sources/CodexBarCLI/CLIExitCode.swift +++ b/Sources/CodexBarCLI/CLIExitCode.swift @@ -4,6 +4,7 @@ enum ExitCode: Int32 { case binaryNotFound = 2 case parseError = 3 case timeout = 4 + case usage = 64 init(_ rawValue: Int) { self = ExitCode(rawValue: Int32(rawValue)) ?? .failure diff --git a/Sources/CodexBarCLI/CLIGuardCommand.swift b/Sources/CodexBarCLI/CLIGuardCommand.swift new file mode 100644 index 0000000000..87d1a7d51b --- /dev/null +++ b/Sources/CodexBarCLI/CLIGuardCommand.swift @@ -0,0 +1,400 @@ +import CodexBarCore +import Commander +import Foundation + +extension CodexBarCLI { + /// Window selected by the `guard` command: `session` maps to the primary + /// rate window, `weekly` maps to the secondary rate window. + enum GuardWindow: String { + case session + case weekly + + var payloadValue: String { + self.rawValue + } + } + + /// Pure gating outcome. Kept free of I/O so it is unit-testable off-network. + enum GuardDecision: String { + case ok + case blocked + case unknown + } + + enum GuardUnavailableReason: String, Sendable { + case accountResolution = "account-resolution" + case fetchFailed = "fetch-failed" + case timeout + case windowUnavailable = "window-unavailable" + } + + enum GuardFetchOutcome: Sendable { + case available(Double) + case unavailable(GuardUnavailableReason) + } + + struct GuardEvaluation: Sendable { + let decision: GuardDecision + let exitCode: Int32 + let remainingPercent: Double? + let unavailableReason: GuardUnavailableReason? + } + + /// Command-specific stable status codes. `69` is sysexits `EX_UNAVAILABLE`. + private enum GuardExitCode: Int32 { + case safe = 0 + case blocked = 1 + case unavailable = 69 + } + + /// Pure decision core for `codexbar guard`. + /// + /// - unavailable quota → `.unknown` (exit `0` when `failOpen`, else `69`). + /// - remaining quota at or above the threshold → `.ok` (exit `0`). + /// - otherwise → `.blocked` (exit `1`). + static func evaluateGuard( + outcome: GuardFetchOutcome, + minimumRemainingPercent: Double, + failOpen: Bool) -> GuardEvaluation + { + guard case let .available(remainingPercent) = outcome else { + guard case let .unavailable(reason) = outcome else { preconditionFailure("Unhandled guard outcome") } + return GuardEvaluation( + decision: .unknown, + exitCode: failOpen ? GuardExitCode.safe.rawValue : GuardExitCode.unavailable.rawValue, + remainingPercent: nil, + unavailableReason: reason) + } + if remainingPercent >= minimumRemainingPercent { + return GuardEvaluation( + decision: .ok, + exitCode: GuardExitCode.safe.rawValue, + remainingPercent: remainingPercent, + unavailableReason: nil) + } + return GuardEvaluation( + decision: .blocked, + exitCode: GuardExitCode.blocked.rawValue, + remainingPercent: remainingPercent, + unavailableReason: nil) + } + + /// Remaining headroom (`100 - usedPercent`) for a resolved rate window, or `nil` when the window + /// is absent or a synthetic placeholder. A synthetic window is a lane the provider did not + /// actually report (e.g. Claude with no live five-hour session), so it must not read as free + /// headroom and let the gate pass on a phantom metric. + static func guardRemainingHeadroom(for window: RateWindow?) -> Double? { + guard let window, !window.isSyntheticPlaceholder else { return nil } + return 100 - window.usedPercent + } + + static func runGuard(_ values: ParsedValues) async { + let output = CLIOutputPreferences.from(values: values) + let json = values.flags.contains("json") + let failOpen = values.flags.contains("failOpen") + let verbose = values.flags.contains("verbose") + + guard let window = Self.decodeGuardWindow(from: values) else { + Self.exitGuardArgumentError("--window must be session|weekly.", output: output) + } + + let minimumRemainingPercent: Double + switch Self.decodeGuardMinimumRemaining(from: values) { + case let .success(value): + minimumRemainingPercent = value + case .failure: + Self.exitGuardArgumentError( + "--min-remaining must be a finite percent between 0 and 100.", + output: output) + } + + let timeout: TimeInterval + switch Self.decodeGuardTimeout(from: values) { + case let .success(value): + timeout = value + case .failure: + Self.exitGuardArgumentError( + "--timeout must be a finite number of seconds from 0 through 86400.", + output: output) + } + + let provider: UsageProvider + switch Self.decodeGuardProvider(from: values) { + case let .success(value): + provider = value + case let .failure(error): + Self.exitGuardArgumentError(error.localizedDescription, output: output) + } + let config = Self.loadConfig(output: output) + + let outcome = await Self.runGuardFetch(timeout: timeout) { + await ProviderInteractionContext.$current.withValue(.background) { + await Self.guardFetchOutcome( + provider: provider, + window: window, + config: config, + verbose: verbose, + webTimeout: timeout > 0 ? timeout : 60) + } + } + if case .unavailable(.timeout) = outcome { + TTYCommandRunner.terminateActiveProcessesForAppShutdown() + } + + let evaluation = Self.evaluateGuard( + outcome: outcome, + minimumRemainingPercent: minimumRemainingPercent, + failOpen: failOpen) + + Self.emitGuardResult( + provider: provider, + window: window, + minimumRemainingPercent: minimumRemainingPercent, + evaluation: evaluation, + json: json, + pretty: output.pretty) + Self.platformExit(evaluation.exitCode) + } + + // MARK: - Argument decoding + + private static func exitGuardArgumentError(_ message: String, output: CLIOutputPreferences) -> Never { + self.exit(code: .usage, message: "Error: \(message)", output: output, kind: .args) + } + + static func decodeGuardWindow(from values: ParsedValues) -> GuardWindow? { + guard let raw = values.options["window"]?.last else { return .session } + return GuardWindow(rawValue: raw.lowercased()) + } + + static func guardProvider(rawOverride: String?) -> Result { + guard let rawOverride else { + return .failure(CLIArgumentError("guard requires --provider .")) + } + guard let selection = ProviderSelection(argument: rawOverride) else { + return .failure(CLIArgumentError("unknown provider '\(rawOverride)'.")) + } + guard selection.asList.count == 1, let provider = selection.asList.first else { + return .failure(CLIArgumentError("guard requires exactly one --provider.")) + } + return .success(provider) + } + + private static func decodeGuardProvider(from values: ParsedValues) -> Result { + self.guardProvider(rawOverride: values.options["provider"]?.last) + } + + static func decodeGuardMinimumRemaining(from values: ParsedValues) -> Result { + guard let raw = values.options["minRemaining"]?.last else { return .success(10) } + guard let value = Double(raw), value.isFinite, value >= 0, value <= 100 else { + return .failure(CLIArgumentError("--min-remaining must be a finite percent between 0 and 100.")) + } + return .success(value) + } + + static func decodeGuardTimeout(from values: ParsedValues) -> Result { + self.guardTimeout(raw: values.options["timeout"]?.last) + } + + static func guardTimeout(raw: String?) -> Result { + guard let raw else { return .success(60) } + guard let value = TimeInterval(raw), value.isFinite, value >= 0, value <= 86400 else { + return .failure(CLIArgumentError("--timeout must be a finite number of seconds from 0 through 86400.")) + } + return .success(value) + } + + // MARK: - Fetch + + static func runGuardFetch( + timeout: TimeInterval, + operation: @escaping @Sendable () async -> GuardFetchOutcome) async -> GuardFetchOutcome + { + let sourceTask = Task { + await operation() + } + guard timeout > 0 else { + return await (try? sourceTask.value) ?? .unavailable(.fetchFailed) + } + + let join = BoundedTaskJoin(sourceTask: sourceTask) + return switch await join.value(joinGrace: .seconds(timeout)) { + case let .value(outcome): outcome + case .failure: .unavailable(.fetchFailed) + case .timedOut: .unavailable(.timeout) + } + } + + private static func guardFetchOutcome( + provider: UsageProvider, + window: GuardWindow, + config: CodexBarConfig, + verbose: Bool, + webTimeout: TimeInterval) async -> GuardFetchOutcome + { + let tokenContext: TokenAccountCLIContext + do { + tokenContext = try TokenAccountCLIContext( + selection: TokenAccountCLISelection(label: nil, index: nil, allAccounts: false), + config: config, + verbose: verbose) + } catch { + return .unavailable(.accountResolution) + } + + // Resolve the configured token account the same way `usage` does, so token-only + // providers (e.g. Claude, z.ai, OpenAI) fetch their quota instead of returning unknown. + let account: ProviderTokenAccount? + do { + account = try tokenContext.resolvedAccounts(for: provider).first + } catch { + return .unavailable(.accountResolution) + } + + let browserDetection = BrowserDetection() + let fetcher = UsageFetcher() + let claudeFetcher = ClaudeUsageFetcher(browserDetection: browserDetection) + + let env = tokenContext.environment( + base: ProcessInfo.processInfo.environment, + provider: provider, + account: account) + let settings = tokenContext.settingsSnapshot(for: provider, account: account) + let baseSource = tokenContext.preferredSourceMode(for: provider) + let effectiveSourceMode = tokenContext.effectiveSourceMode( + base: baseSource, + provider: provider, + account: account) + + let fetchContext = ProviderFetchContext( + runtime: .cli, + sourceMode: effectiveSourceMode, + includeCredits: false, + webTimeout: webTimeout, + webDebugDumpHTML: false, + verbose: verbose, + env: env, + settings: settings, + fetcher: tokenContext.fetcher(base: fetcher, provider: provider, env: env), + claudeFetcher: claudeFetcher, + browserDetection: browserDetection, + // Guard is read-only: omit updater callbacks so refresh-dependent credentials fail unavailable. + selectedTokenAccountID: account?.id) + + let outcome = await Self.fetchProviderUsage(provider: provider, context: fetchContext) + if verbose { + Self.printFetchAttempts(provider: provider, attempts: outcome.attempts) + } + + switch outcome.result { + case let .success(result): + let usage = result.usage.scoped(to: provider) + let rateWindow = window == .session ? usage.primary : usage.secondary + guard let remaining = Self.guardRemainingHeadroom(for: rateWindow) else { + return .unavailable(.windowUnavailable) + } + return .available(remaining) + case .failure: + return .unavailable(.fetchFailed) + } + } + + // MARK: - Output + + private struct GuardResultPayload: Encodable { + let provider: String + let window: String + let remainingPercent: Double? + let minimumRemainingPercent: Double + let decision: String + let exitCode: Int32 + let unavailableReason: String? + + private enum CodingKeys: String, CodingKey { + case provider + case window + case remainingPercent + case minimumRemainingPercent + case decision + case exitCode + case unavailableReason + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(self.provider, forKey: .provider) + try container.encode(self.window, forKey: .window) + try container.encode(self.minimumRemainingPercent, forKey: .minimumRemainingPercent) + try container.encode(self.decision, forKey: .decision) + try container.encode(self.exitCode, forKey: .exitCode) + if let remainingPercent = self.remainingPercent { + try container.encode(remainingPercent, forKey: .remainingPercent) + } else { + try container.encodeNil(forKey: .remainingPercent) + } + if let unavailableReason = self.unavailableReason { + try container.encode(unavailableReason, forKey: .unavailableReason) + } else { + try container.encodeNil(forKey: .unavailableReason) + } + } + } + + // swiftlint:disable:next function_parameter_count + private static func emitGuardResult( + provider: UsageProvider, + window: GuardWindow, + minimumRemainingPercent: Double, + evaluation: GuardEvaluation, + json: Bool, + pretty: Bool) + { + if json { + let payload = GuardResultPayload( + provider: provider.rawValue, + window: window.payloadValue, + remainingPercent: evaluation.remainingPercent, + minimumRemainingPercent: minimumRemainingPercent, + decision: evaluation.decision.rawValue, + exitCode: evaluation.exitCode, + unavailableReason: evaluation.unavailableReason?.rawValue) + Self.printJSON(payload, pretty: pretty) + return + } + print(self.guardHumanLine( + provider: provider, + window: window, + remainingPercent: evaluation.remainingPercent, + minimumRemainingPercent: minimumRemainingPercent, + decision: evaluation.decision, + unavailableReason: evaluation.unavailableReason)) + } + + static func guardHumanLine( + provider: UsageProvider, + window: GuardWindow, + remainingPercent: Double?, + minimumRemainingPercent: Double, + decision: GuardDecision, + unavailableReason: GuardUnavailableReason? = nil) -> String + { + let remainingText = remainingPercent + .map { "\(Self.guardPercentString($0)) remaining" } ?? "unknown" + let verdict = switch decision { + case .ok: "OK" + case .blocked: "BLOCKED" + case .unknown: "UNKNOWN" + } + let reasonText = unavailableReason.map { "; \($0.rawValue)" } ?? "" + return "\(provider.rawValue) \(window.payloadValue): \(remainingText) — " + + "\(verdict) (minimum \(Self.guardPercentString(minimumRemainingPercent))\(reasonText))" + } + + private static func guardPercentString(_ value: Double) -> String { + let rounded = value.rounded() + if abs(value - rounded) < 0.05 { + return "\(Int(rounded))%" + } + return String(format: "%.1f%%", value) + } +} diff --git a/Sources/CodexBarCLI/CLIHelp.swift b/Sources/CodexBarCLI/CLIHelp.swift index 2f7ed9b25f..aeb371a4b7 100644 --- a/Sources/CodexBarCLI/CLIHelp.swift +++ b/Sources/CodexBarCLI/CLIHelp.swift @@ -302,6 +302,43 @@ extension CodexBarCLI { """ } + static func guardHelp(version: String) -> String { + """ + CodexBar \(version) + + Usage: + codexbar guard --provider \(ProviderHelp.list) + [--min-remaining ] [--window session|weekly] + [--timeout ] [--json] [--pretty] [--fail-open] + [--json-output] [--log-level ] [-v|--verbose] + + Description: + Exit non-zero when a provider lacks quota headroom, for use in gating scripts. + Stable guard exit codes: 0 = safe (relevant window has at least --min-remaining% remaining), + 1 = insufficient quota, 64 = invalid arguments, + 69 = quota unavailable or fetch timed out. + --min-remaining defaults to 10 (percent). --window defaults to session (the primary window); + weekly checks the secondary window. --timeout accepts 0...86400 and defaults to 60 seconds; + 0 disables the guard-level deadline, but provider-specific timeouts still apply. + --fail-open exits 0 instead of 69 when quota is unavailable. + Human output is a single line to stdout; --json emits a machine-readable decision object. + + Global flags: + -h, --help Show help + -V, --version Show version + -v, --verbose Enable verbose logging + --log-level + --json-output Emit machine-readable logs (JSONL) to stderr + + Examples: + codexbar guard --provider claude + codexbar guard --provider codex --min-remaining 20 + codexbar guard --provider claude --window weekly --min-remaining 5 + codexbar guard --provider claude --json + codexbar guard --provider codex --fail-open + """ + } + static func rootHelp(version: String) -> String { """ CodexBar \(version) @@ -343,6 +380,7 @@ extension CodexBarCLI { codexbar hooks test --provider codexbar cache clear <--cookies|--cost|--all> [--provider ] codexbar diagnose --provider --format json [--redact] [--output ] [--pretty] + codexbar guard --provider [--min-remaining ] [--window session|weekly] [--json] Global flags: -h, --help Show help @@ -370,6 +408,7 @@ extension CodexBarCLI { codexbar diagnose --provider minimax --format json --redact --output diagnostic.json codexbar diagnose --provider minimax --format json --pretty codexbar diagnose --provider all --format json + codexbar guard --provider claude --min-remaining 20 """ } } diff --git a/Sources/CodexBarCLI/CLIIO.swift b/Sources/CodexBarCLI/CLIIO.swift index c0a70dffea..fbb407feba 100644 --- a/Sources/CodexBarCLI/CLIIO.swift +++ b/Sources/CodexBarCLI/CLIIO.swift @@ -43,6 +43,8 @@ extension CodexBarCLI { print(Self.cacheHelp(version: version)) case "diagnose": print(Self.diagnoseHelp(version: version)) + case "guard": + print(Self.guardHelp(version: version)) default: print(Self.rootHelp(version: version)) } diff --git a/Sources/CodexBarCLI/CLIOptions.swift b/Sources/CodexBarCLI/CLIOptions.swift index 00eb5f6239..b13622efbc 100644 --- a/Sources/CodexBarCLI/CLIOptions.swift +++ b/Sources/CodexBarCLI/CLIOptions.swift @@ -76,6 +76,40 @@ struct UsageOptions: CommanderParsable { var augmentDebug: Bool = false } +struct GuardOptions: CommanderParsable { + @Flag(names: [.short("v"), .long("verbose")], help: "Enable verbose logging") + var verbose: Bool = false + + @Flag(name: .long("json-output"), help: "Emit machine-readable logs") + var jsonOutput: Bool = false + + @Option(name: .long("log-level"), help: "Set log level (trace|verbose|debug|info|warning|error|critical)") + var logLevel: String? + + @Option(name: .long("provider"), help: ProviderHelp.optionHelp) + var provider: ProviderSelection? + + @Option(name: .long("min-remaining"), help: "Minimum remaining quota required, as a percent (default 10)") + var minRemaining: Double? + + @Option(name: .long("window"), help: "Window to check: session (primary) | weekly (secondary)") + var window: String? + + @Option( + name: .long("timeout"), + help: "Overall fetch timeout in seconds, 0...86400 (default 60; 0 disables)") + var timeout: Double? + + @Flag(name: .long("json"), help: "Emit machine-readable decision JSON") + var json: Bool = false + + @Flag(name: .long("pretty"), help: "Pretty-print decision JSON") + var pretty: Bool = false + + @Flag(name: .long("fail-open"), help: "Exit 0 instead of 69 when quota is unavailable") + var failOpen: Bool = false +} + enum ProviderSelection: ExpressibleFromArgument { case single(UsageProvider) case both diff --git a/TestsLinux/CLIGuardDecisionTests.swift b/TestsLinux/CLIGuardDecisionTests.swift new file mode 100644 index 0000000000..7cf84db139 --- /dev/null +++ b/TestsLinux/CLIGuardDecisionTests.swift @@ -0,0 +1,132 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBarCLI + +struct CLIGuardDecisionTests { + @Test + func `ample headroom is ok and exits zero`() { + let result = CodexBarCLI.evaluateGuard( + outcome: .available(74), + minimumRemainingPercent: 10, + failOpen: false) + #expect(result.decision == .ok) + #expect(result.exitCode == 0) + } + + @Test + func `insufficient headroom is blocked and exits one`() { + let result = CodexBarCLI.evaluateGuard( + outcome: .available(5), + minimumRemainingPercent: 10, + failOpen: false) + #expect(result.decision == .blocked) + #expect(result.exitCode == 1) + } + + @Test + func `fetch failure exits unavailable by default`() { + let result = CodexBarCLI.evaluateGuard( + outcome: .unavailable(.fetchFailed), + minimumRemainingPercent: 10, + failOpen: false) + #expect(result.decision == .unknown) + #expect(result.exitCode == 69) + #expect(result.unavailableReason == .fetchFailed) + } + + @Test + func `unknown remaining with fail-open exits zero`() { + let result = CodexBarCLI.evaluateGuard( + outcome: .unavailable(.fetchFailed), + minimumRemainingPercent: 10, + failOpen: true) + #expect(result.decision == .unknown) + #expect(result.exitCode == 0) + } + + @Test + func `remaining exactly equal to need is ok`() { + let result = CodexBarCLI.evaluateGuard( + outcome: .available(10), + minimumRemainingPercent: 10, + failOpen: false) + #expect(result.decision == .ok) + #expect(result.exitCode == 0) + } + + @Test + func `unknown provider is rejected`() { + let result = CodexBarCLI.guardProvider(rawOverride: "definitely-not-a-provider") + guard case let .failure(error) = result else { + Issue.record("Expected unknown provider to be rejected") + return + } + #expect(error.localizedDescription == "unknown provider 'definitely-not-a-provider'.") + } + + @Test + func `missing provider is rejected`() { + let result = CodexBarCLI.guardProvider(rawOverride: nil) + guard case let .failure(error) = result else { + Issue.record("Expected missing provider to be rejected") + return + } + #expect(error.localizedDescription == "guard requires --provider .") + } + + @Test + func `timeout rejects values that could overflow duration`() { + let result = CodexBarCLI.guardTimeout(raw: "1e100") + guard case .failure = result else { + Issue.record("Expected enormous timeout to be rejected") + return + } + } + + @Test + func `fetch timeout is reported as unavailable`() async { + let result = await CodexBarCLI.runGuardFetch(timeout: 0.01) { + try? await Task.sleep(for: .seconds(30)) + return .available(100) + } + guard case .unavailable(.timeout) = result else { + Issue.record("Expected guard fetch to time out") + return + } + } + + // MARK: - Window headroom (synthetic-placeholder filtering) + + private func window(usedPercent: Double, synthetic: Bool) -> RateWindow { + RateWindow( + usedPercent: usedPercent, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil, + isSyntheticPlaceholder: synthetic) + } + + @Test + func `real window reports remaining headroom`() { + let remaining = CodexBarCLI.guardRemainingHeadroom(for: self.window(usedPercent: 30, synthetic: false)) + #expect(remaining == 70) + } + + @Test + func `synthetic placeholder window is treated as unknown`() { + let remaining = CodexBarCLI.guardRemainingHeadroom(for: self.window(usedPercent: 0, synthetic: true)) + #expect(remaining == nil) + } + + @Test + func `absent window is unknown`() { + #expect(CodexBarCLI.guardRemainingHeadroom(for: nil) == nil) + } + + @Test + func `fully used real window has zero headroom`() { + let remaining = CodexBarCLI.guardRemainingHeadroom(for: self.window(usedPercent: 100, synthetic: false)) + #expect(remaining == 0) + } +} diff --git a/docs/cli.md b/docs/cli.md index c82547f198..df1f708daf 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -88,6 +88,13 @@ See `docs/configuration.md` for the schema. - `--cookies --provider ` removes browser-cookie cache entries for that provider, including managed Codex account scopes. - `--cost` removes local cost-usage scan caches. - `--all` clears both cookies and cost caches. `--provider` is cookie-only and cannot be combined with `--cost` or `--all`. +- `codexbar guard --provider ` gates automation on one provider's remaining quota. + - `--min-remaining ` sets the inclusive threshold (default: `10`; valid range: `0...100`). + - `--window session|weekly` selects the primary/session window or secondary/weekly window (default: `session`). + - `--timeout ` bounds the complete fetch (range: `0...86400`; default: `60`; `0` disables this guard-level deadline while provider-specific timeouts still apply). + - `--json` emits the provider, window, remaining quota, threshold, decision, unavailable reason, and exit code; add `--pretty` for formatted JSON. + - Stable guard exit codes: `0` means safe, `1` means below threshold, `64` (`EX_USAGE`) means invalid arguments, and `69` (`EX_UNAVAILABLE`) means the quota could not be checked or the selected window is unavailable. `--fail-open` changes only unavailable results from `69` to `0`; JSON still reports `decision: "unknown"` and the reason. + - Guard fetches are read-only and use background interaction policy, matching `codexbar usage`; they never request interactive Keychain access. - `--provider ` (default: enabled providers in config; falls back to defaults when missing). - Provider IDs live in the config file (see `docs/configuration.md`). - With three or more providers enabled, the default stays scoped to enabled providers; use `--provider all` to query @@ -163,6 +170,7 @@ codexbar cost # cost usage (default 30-day window + today) codexbar cost --days 90 # choose a 1...365 day cost window codexbar cost --provider codex --group-by project codexbar cost --provider claude --format json --pretty +codexbar guard --provider codex --min-remaining 20 --window weekly --json codexbar cost --provider cursor # Cursor dashboard cost (API-rate + Cursor-metered) codexbar serve --port 8080 # localhost HTTP JSON server codexbar serve --request-timeout 0 # disable serve request deadlines