Skip to content
Merged
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
45 changes: 29 additions & 16 deletions Sources/CodexBarCLI/CLIErrorReporting.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,8 @@ extension CodexBarCLI {
ProviderErrorPayload(code: code.rawValue, message: message, kind: kind)
}

static func makeCLIErrorPayload(
message: String,
code: ExitCode,
kind: CLIErrorKind,
pretty: Bool) -> String?
{
let payload = ProviderPayload(
static func makeCLIErrorProviderPayload(message: String, code: ExitCode, kind: CLIErrorKind) -> ProviderPayload {
ProviderPayload(
providerID: "cli",
account: nil,
version: nil,
Expand All @@ -43,6 +38,15 @@ extension CodexBarCLI {
antigravityPlanInfo: nil,
openaiDashboard: nil,
error: ProviderErrorPayload(code: code.rawValue, message: message, kind: kind))
}

static func makeCLIErrorPayload(
message: String,
code: ExitCode,
kind: CLIErrorKind,
pretty: Bool) -> String?
{
let payload = self.makeCLIErrorProviderPayload(message: message, code: code, kind: kind)
return self.encodeJSON([payload], pretty: pretty)
}

Expand Down Expand Up @@ -83,6 +87,20 @@ extension CodexBarCLI {
}
}

/// Renders as TOON when the caller requested `usage --format toon`, JSON otherwise. Error/exit
/// paths must honor this too, or `--format toon` silently falls back to JSON on any early failure
/// (invalid arguments, config load errors, provider errors).
static func renderProviderPayloads(_ payloads: [ProviderPayload], output: CLIOutputPreferences) -> String {
if output.toonRequested {
return ToonFormatter.encode(payloads)
}
return self.encodeJSON(payloads, pretty: output.pretty) ?? ""
}

static func printProviderPayloads(_ payloads: [ProviderPayload], output: CLIOutputPreferences) {
print(self.renderProviderPayloads(payloads, output: output))
}

static func exit(
code: ExitCode,
message: String? = nil,
Expand All @@ -91,14 +109,9 @@ extension CodexBarCLI {
{
if self.shouldPrintExitError(code: code, message: message) {
if let output, output.usesJSONOutput {
let payload = self.makeCLIErrorPayload(
message: message ?? "",
code: code,
kind: kind,
pretty: output.pretty)
if let payload {
print(payload)
}
self.printProviderPayloads(
[self.makeCLIErrorProviderPayload(message: message ?? "", code: code, kind: kind)],
output: output)
} else if let message {
self.writeStderr("\(message)\n")
}
Expand All @@ -123,7 +136,7 @@ extension CodexBarCLI {
antigravityPlanInfo: nil,
openaiDashboard: nil,
error: self.makeErrorPayload(error, kind: kind))
self.printJSON([payload], pretty: output.pretty)
self.printProviderPayloads([payload], output: output)
} else {
self.writeStderr("Error: \(error.localizedDescription)\n")
}
Expand Down
10 changes: 7 additions & 3 deletions Sources/CodexBarCLI/CLIHelp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ extension CodexBarCLI {
CodexBar \(version)

Usage:
codexbar usage [--format text|json]
codexbar usage [--format text|json|toon]
[--json]
[--json-only]
[--json-output] [--log-level <trace|verbose|debug|info|warning|error|critical>] [-v|--verbose]
Expand All @@ -72,8 +72,10 @@ extension CodexBarCLI {
[--web-timeout <seconds>] [--web-debug-dump-html] [--antigravity-plan-debug] [--augment-debug]

Description:
Print usage from enabled providers as text (default) or JSON. Honors your in-app toggles.
Print usage from enabled providers as text (default), JSON, or TOON. Honors your in-app toggles.
Output format: use --json (or --format json) for JSON on stdout; use --json-output for JSON logs on stderr.
--format toon emits the same payload as --format json, rendered as TOON (github.com/toon-format/spec)
for token-cheaper agent consumption.
Source behavior is provider-specific:
- Codex: OpenAI web dashboard (usage limits, credits remaining, code review remaining, usage breakdown).
Auto falls back to Codex CLI only when cookies are missing.
Expand Down Expand Up @@ -102,6 +104,7 @@ extension CodexBarCLI {
codexbar usage --provider all --json
codexbar usage --status
codexbar usage --provider codex --source web --format json --pretty
codexbar usage --format toon --provider claude
"""
}

Expand Down Expand Up @@ -444,7 +447,7 @@ extension CodexBarCLI {
CodexBar \(version)

Usage:
codexbar [--format text|json]
codexbar [--format text|json|toon]
[--json]
[--json-only]
[--json-output] [--log-level <trace|verbose|debug|info|warning|error|critical>] [-v|--verbose]
Expand Down Expand Up @@ -497,6 +500,7 @@ extension CodexBarCLI {
Examples:
codexbar
codexbar --format json --provider all --pretty
codexbar --format toon --provider claude
codexbar --provider all --json
codexbar --provider gemini
codexbar cards --provider all --status
Expand Down
10 changes: 2 additions & 8 deletions Sources/CodexBarCLI/CLIHelpers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,13 +39,7 @@ extension CodexBarCLI {
}

static func decodeFormat(from values: ParsedValues) -> OutputFormat {
if let raw = values.options["format"]?.last, let parsed = OutputFormat(argument: raw) {
return parsed
}
if values.flags.contains("jsonShortcut") || values.flags.contains("json") || values.flags.contains("jsonOnly") {
return .json
}
return .text
CLIOutputPreferences.resolveOutputFormat(from: values).format
}

static func decodeTokenAccountSelection(from values: ParsedValues) throws -> TokenAccountCLISelection {
Expand Down Expand Up @@ -387,7 +381,7 @@ extension CodexBarCLI {
antigravityPlanInfo: nil,
openaiDashboard: nil,
error: self.makeErrorPayload(code: .failure, message: error.localizedDescription, kind: .config))
self.printJSON([payload], pretty: output.pretty)
self.printProviderPayloads([payload], output: output)
} else {
self.writeStderr("Error: \(error.localizedDescription)\n")
}
Expand Down
2 changes: 1 addition & 1 deletion Sources/CodexBarCLI/CLIOptions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ struct UsageOptions: CommanderParsable {
@Flag(name: .long("all-accounts"), help: "Fetch all token accounts, or all visible Codex accounts")
var allAccounts: Bool = false

@Option(name: .long("format"), help: "Output format: text | json")
@Option(name: .long("format"), help: "Output format: text | json | toon (toon: structured, agent-friendly)")
Comment thread
elijahfriedman marked this conversation as resolved.
var format: OutputFormat?

@Flag(name: .long("json"), help: "")
Expand Down
87 changes: 77 additions & 10 deletions Sources/CodexBarCLI/CLIOutputPreferences.swift
Original file line number Diff line number Diff line change
@@ -1,50 +1,117 @@
import Commander
import Foundation

struct ResolvedOutputFormat {
let format: OutputFormat
let toonRequested: Bool
}

struct CLIOutputPreferences {
let format: OutputFormat
let jsonOnly: Bool
let pretty: Bool
/// Set only by `usage --format toon`. TOON piggybacks on the JSON fetch/render pipeline (`format`
/// stays `.json` so credits/color/account behavior matches), but error/exit payloads must still
/// render as TOON rather than JSON so callers parsing `--format toon` see a consistent format.
var toonRequested: Bool = false

var usesJSONOutput: Bool {
self.jsonOnly || self.format == .json
}

static func from(values: ParsedValues) -> CLIOutputPreferences {
/// TOON is a `usage`-only contract. Every other command's `--format` help promises `text | json`,
/// so they must keep the legacy decoder, where an unrecognized value falls through to the
/// text/JSON default instead of silently selecting JSON.
static func from(values: ParsedValues, allowsToon: Bool = false) -> CLIOutputPreferences {
let jsonOnly = values.flags.contains("jsonOnly")
let format = CodexBarCLI.decodeFormat(from: values)
let pretty = values.flags.contains("pretty")
return CLIOutputPreferences(format: format, jsonOnly: jsonOnly, pretty: pretty)
let resolved = Self.resolveOutputFormat(from: values, allowsToon: allowsToon)
return CLIOutputPreferences(
format: resolved.format,
jsonOnly: jsonOnly,
pretty: pretty,
toonRequested: resolved.toonRequested)
}

static func from(argv: [String]) -> CLIOutputPreferences {
var jsonOnly = false
var pretty = false
var format: OutputFormat = .text
var lastExplicitFormat: String?
var jsonShortcut = false

var index = 0
while index < argv.count {
let arg = argv[index]
switch arg {
case "--json-only":
jsonOnly = true
format = .json
jsonShortcut = true
case "--json":
format = .json
jsonShortcut = true
case "--pretty":
pretty = true
case "--format":
let next = index + 1
if next < argv.count, let parsed = OutputFormat(argument: argv[next]) {
format = parsed
if next < argv.count {
lastExplicitFormat = argv[next]
index += 1
}
default:
break
if arg.hasPrefix("--format="), arg != "--format=" {
lastExplicitFormat = String(arg.dropFirst("--format=".count))
}
}
index += 1
}

return CLIOutputPreferences(format: format, jsonOnly: jsonOnly, pretty: pretty)
let resolved = Self.resolveOutputFormat(
lastExplicitFormat: lastExplicitFormat,
jsonShortcut: jsonShortcut || jsonOnly,
allowsToon: Self.commandSupportsToon(argv: argv))
return CLIOutputPreferences(
format: resolved.format,
jsonOnly: jsonOnly,
pretty: pretty,
toonRequested: resolved.toonRequested)
}

/// Mirrors `CodexBarCLI.effectiveArgv`: a bare `codexbar --format toon` runs the implicit `usage`
/// command, so the argv bootstrap scanner has to reach the same verdict as the post-parse path.
static func commandSupportsToon(argv: [String]) -> Bool {
guard let first = argv.first else { return true }
if first.hasPrefix("-") { return true }
return first == "usage"
}

/// Explicit `--format` wins over `--json` / `--json-only`, matching `decodeFormat(from:)`.
/// TOON is recognized only via `usage --format toon` and piggybacks on the JSON pipeline; for
/// every other command `toon` stays an unrecognized value, exactly as before TOON existed.
static func resolveOutputFormat(from values: ParsedValues, allowsToon: Bool = false) -> ResolvedOutputFormat {
let jsonShortcut = values.flags.contains("jsonShortcut")
|| values.flags.contains("json")
|| values.flags.contains("jsonOnly")
return Self.resolveOutputFormat(
lastExplicitFormat: values.options["format"]?.last,
jsonShortcut: jsonShortcut,
allowsToon: allowsToon)
}

static func resolveOutputFormat(
lastExplicitFormat: String?,
jsonShortcut: Bool,
allowsToon: Bool = false) -> ResolvedOutputFormat
{
if let raw = lastExplicitFormat {
if allowsToon, raw.lowercased() == "toon" {
return ResolvedOutputFormat(format: .json, toonRequested: true)
}
if let parsed = OutputFormat(argument: raw) {
return ResolvedOutputFormat(format: parsed, toonRequested: false)
}
}
if jsonShortcut {
return ResolvedOutputFormat(format: .json, toonRequested: false)
}
return ResolvedOutputFormat(format: .text, toonRequested: false)
}
}
38 changes: 34 additions & 4 deletions Sources/CodexBarCLI/CLIUsageCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ extension UsageCommandOutput {

extension CodexBarCLI {
static func runUsage(_ values: ParsedValues) async {
let output = CLIOutputPreferences.from(values: values)
let output = Self.resolveUsageOutputPreferences(from: values)
let config = Self.loadConfig(output: output)
let provider = Self.decodeProvider(from: values, config: config)
let format = output.format
Expand Down Expand Up @@ -222,16 +222,46 @@ extension CodexBarCLI {
payload.append(contentsOf: output.payload)
}

Self.printUsageOutput(
format: format,
toonRequested: output.toonRequested,
sections: sections,
payload: payload,
pretty: output.pretty)

Self.exit(code: exitCode, output: output, kind: exitCode == .success ? .runtime : .provider)
}

/// TOON piggybacks on the JSON fetch/render pipeline (same data, denser rendering at print time)
/// rather than being a first-class `OutputFormat` case, so it doesn't ripple into every other
/// command's exhaustive `switch format` sites. `toonRequested` also travels on the returned
/// preferences so early-exit error paths (`Self.exit`, `Self.loadConfig`) render TOON instead of
/// silently falling back to JSON. `allowsToon` is opt-in here and nowhere else: `cost`, `cache`,
/// `config`, `hooks`, and `diagnose` advertise only `text | json`, so they keep the legacy
/// decoder that ignores unrecognized `--format` values.
static func resolveUsageOutputPreferences(from values: ParsedValues) -> CLIOutputPreferences {
CLIOutputPreferences.from(values: values, allowsToon: true)
}

private static func printUsageOutput(
format: OutputFormat,
toonRequested: Bool,
sections: [String],
payload: [ProviderPayload],
pretty: Bool)
{
if toonRequested {
print(ToonFormatter.encode(payload))
return
}
switch format {
case .text:
if !sections.isEmpty {
print(sections.joined(separator: "\n\n"))
}
case .json:
Self.printJSON(payload, pretty: output.pretty)
printJSON(payload, pretty: pretty)
}

Self.exit(code: exitCode, output: output, kind: exitCode == .success ? .runtime : .provider)
}

static func appAutoVerifierArgumentError(
Expand Down
Loading