diff --git a/Sources/CodexBarCLI/CLIErrorReporting.swift b/Sources/CodexBarCLI/CLIErrorReporting.swift index b52027c420..cf9a621d32 100644 --- a/Sources/CodexBarCLI/CLIErrorReporting.swift +++ b/Sources/CodexBarCLI/CLIErrorReporting.swift @@ -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, @@ -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) } @@ -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, @@ -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") } @@ -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") } diff --git a/Sources/CodexBarCLI/CLIHelp.swift b/Sources/CodexBarCLI/CLIHelp.swift index 6ff64710c8..5ccd713d0f 100644 --- a/Sources/CodexBarCLI/CLIHelp.swift +++ b/Sources/CodexBarCLI/CLIHelp.swift @@ -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 ] [-v|--verbose] @@ -72,8 +72,10 @@ extension CodexBarCLI { [--web-timeout ] [--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. @@ -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 """ } @@ -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 ] [-v|--verbose] @@ -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 diff --git a/Sources/CodexBarCLI/CLIHelpers.swift b/Sources/CodexBarCLI/CLIHelpers.swift index 89676cc45f..b23a3ef2a8 100644 --- a/Sources/CodexBarCLI/CLIHelpers.swift +++ b/Sources/CodexBarCLI/CLIHelpers.swift @@ -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 { @@ -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") } diff --git a/Sources/CodexBarCLI/CLIOptions.swift b/Sources/CodexBarCLI/CLIOptions.swift index 90e7614e2c..14a94a78a5 100644 --- a/Sources/CodexBarCLI/CLIOptions.swift +++ b/Sources/CodexBarCLI/CLIOptions.swift @@ -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)") var format: OutputFormat? @Flag(name: .long("json"), help: "") diff --git a/Sources/CodexBarCLI/CLIOutputPreferences.swift b/Sources/CodexBarCLI/CLIOutputPreferences.swift index 50f8bdd1d5..ffb6702bc4 100644 --- a/Sources/CodexBarCLI/CLIOutputPreferences.swift +++ b/Sources/CodexBarCLI/CLIOutputPreferences.swift @@ -1,26 +1,43 @@ 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 { @@ -28,23 +45,73 @@ struct CLIOutputPreferences { 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) } } diff --git a/Sources/CodexBarCLI/CLIUsageCommand.swift b/Sources/CodexBarCLI/CLIUsageCommand.swift index eceef323dd..696114d352 100644 --- a/Sources/CodexBarCLI/CLIUsageCommand.swift +++ b/Sources/CodexBarCLI/CLIUsageCommand.swift @@ -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 @@ -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( diff --git a/Sources/CodexBarCLI/ToonFormatter.swift b/Sources/CodexBarCLI/ToonFormatter.swift new file mode 100644 index 0000000000..95ef9530ad --- /dev/null +++ b/Sources/CodexBarCLI/ToonFormatter.swift @@ -0,0 +1,628 @@ +import Foundation + +/// Encodes `Encodable` CLI payloads as TOON (Token-Oriented Object Notation). +/// +/// This is a presentation-only formatter: it mirrors the same data already emitted by +/// `--format json`, just serialized more compactly for uniform arrays of objects. See +/// https://github.com/toon-format/spec for the format definition this targets (v4.1). +enum ToonFormatter { + static func encode(_ value: some Encodable) -> String { + let root = ToonNode() + do { + try Self.encodeValue(value, into: root) + } catch { + return "" + } + return ToonSerializer.render(root) + } + + fileprivate static func encodeValue(_ value: some Encodable, into node: ToonNode) throws { + if let date = value as? Date { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + node.kind = .string(formatter.string(from: date)) + return + } + let encoder = ToonTreeEncoder(node: node, codingPath: []) + try value.encode(to: encoder) + } + + /// TOON has no representation for NaN/Infinity. `JSONEncoder` rejects them by default + /// (`EncodingError.invalidValue`) rather than substituting a placeholder number, so this encoder + /// must fail the same way instead of silently mapping a non-finite provider value to `0` — which + /// would misrepresent invalid usage/cost data as a valid zero reading. + fileprivate static func requireFinite(_ value: Double, codingPath: [CodingKey]) throws -> Double { + guard value.isFinite else { + throw EncodingError.invalidValue(value, EncodingError.Context( + codingPath: codingPath, + debugDescription: "Unable to encode \(value) in TOON: only finite numbers are representable.")) + } + return value + } +} + +// MARK: - Node tree + +/// A reference-typed JSON-model node. Reference semantics let nested containers mutate a +/// node in place after it has already been appended to a parent object/array. +final class ToonNode { + indirect enum Kind { + case null + case bool(Bool) + case int(Int64) + case double(Double) + case string(String) + case array([ToonNode]) + case object([(key: String, node: ToonNode)]) + } + + var kind: Kind = .null +} + +// MARK: - Encoder + +private struct ToonTreeEncoder: Encoder { + let node: ToonNode + var codingPath: [CodingKey] + var userInfo: [CodingUserInfoKey: Any] = [:] + + func container(keyedBy _: Key.Type) -> KeyedEncodingContainer { + self.node.kind = .object([]) + return KeyedEncodingContainer(ToonKeyedContainer(node: self.node, codingPath: self.codingPath)) + } + + func unkeyedContainer() -> UnkeyedEncodingContainer { + self.node.kind = .array([]) + return ToonUnkeyedContainer(node: self.node, codingPath: self.codingPath) + } + + func singleValueContainer() -> SingleValueEncodingContainer { + ToonSingleValueContainer(node: self.node, codingPath: self.codingPath) + } +} + +private struct ToonKeyedContainer: KeyedEncodingContainerProtocol { + let node: ToonNode + var codingPath: [CodingKey] + + private func child(for key: Key) -> ToonNode { + let child = ToonNode() + if case var .object(entries) = self.node.kind { + entries.append((key.stringValue, child)) + self.node.kind = .object(entries) + } else { + self.node.kind = .object([(key.stringValue, child)]) + } + return child + } + + mutating func encodeNil(forKey key: Key) throws { + _ = self.child(for: key) + } + + mutating func encode(_ value: Bool, forKey key: Key) throws { + self.child(for: key).kind = .bool(value) + } + + mutating func encode(_ value: String, forKey key: Key) throws { + self.child(for: key).kind = .string(value) + } + + mutating func encode(_ value: Double, forKey key: Key) throws { + self.child(for: key).kind = try .double(ToonFormatter.requireFinite(value, codingPath: self.codingPath)) + } + + mutating func encode(_ value: Float, forKey key: Key) throws { + self.child(for: key).kind = try .double( + ToonFormatter.requireFinite(Double(value), codingPath: self.codingPath)) + } + + mutating func encode(_ value: Int, forKey key: Key) throws { + self.child(for: key).kind = .int(Int64(value)) + } + + mutating func encode(_ value: Int8, forKey key: Key) throws { + self.child(for: key).kind = .int(Int64(value)) + } + + mutating func encode(_ value: Int16, forKey key: Key) throws { + self.child(for: key).kind = .int(Int64(value)) + } + + mutating func encode(_ value: Int32, forKey key: Key) throws { + self.child(for: key).kind = .int(Int64(value)) + } + + mutating func encode(_ value: Int64, forKey key: Key) throws { + self.child(for: key).kind = .int(value) + } + + mutating func encode(_ value: UInt, forKey key: Key) throws { + self.child(for: key).kind = ToonNode.intKind(value) + } + + mutating func encode(_ value: UInt8, forKey key: Key) throws { + self.child(for: key).kind = .int(Int64(value)) + } + + mutating func encode(_ value: UInt16, forKey key: Key) throws { + self.child(for: key).kind = .int(Int64(value)) + } + + mutating func encode(_ value: UInt32, forKey key: Key) throws { + self.child(for: key).kind = .int(Int64(value)) + } + + mutating func encode(_ value: UInt64, forKey key: Key) throws { + self.child(for: key).kind = ToonNode.intKind(value) + } + + mutating func encode(_ value: some Encodable, forKey key: Key) throws { + try ToonFormatter.encodeValue(value, into: self.child(for: key)) + } + + mutating func nestedContainer( + keyedBy _: NestedKey.Type, + forKey key: Key) -> KeyedEncodingContainer + { + let child = self.child(for: key) + child.kind = .object([]) + return KeyedEncodingContainer(ToonKeyedContainer(node: child, codingPath: self.codingPath)) + } + + mutating func nestedUnkeyedContainer(forKey key: Key) -> UnkeyedEncodingContainer { + let child = self.child(for: key) + child.kind = .array([]) + return ToonUnkeyedContainer(node: child, codingPath: self.codingPath) + } + + mutating func superEncoder() -> Encoder { + ToonTreeEncoder(node: self.child(for: Key(stringValue: "super")!), codingPath: self.codingPath) + } + + mutating func superEncoder(forKey key: Key) -> Encoder { + ToonTreeEncoder(node: self.child(for: key), codingPath: self.codingPath) + } +} + +private struct ToonUnkeyedContainer: UnkeyedEncodingContainer { + let node: ToonNode + var codingPath: [CodingKey] + var count: Int = 0 + + private mutating func appendChild() -> ToonNode { + let child = ToonNode() + if case var .array(items) = self.node.kind { + items.append(child) + self.node.kind = .array(items) + } else { + self.node.kind = .array([child]) + } + self.count += 1 + return child + } + + mutating func encodeNil() throws { + _ = self.appendChild() + } + + mutating func encode(_ value: Bool) throws { + self.appendChild().kind = .bool(value) + } + + mutating func encode(_ value: String) throws { + self.appendChild().kind = .string(value) + } + + mutating func encode(_ value: Double) throws { + self.appendChild().kind = try .double(ToonFormatter.requireFinite(value, codingPath: self.codingPath)) + } + + mutating func encode(_ value: Float) throws { + self.appendChild().kind = try .double( + ToonFormatter.requireFinite(Double(value), codingPath: self.codingPath)) + } + + mutating func encode(_ value: Int) throws { + self.appendChild().kind = .int(Int64(value)) + } + + mutating func encode(_ value: Int8) throws { + self.appendChild().kind = .int(Int64(value)) + } + + mutating func encode(_ value: Int16) throws { + self.appendChild().kind = .int(Int64(value)) + } + + mutating func encode(_ value: Int32) throws { + self.appendChild().kind = .int(Int64(value)) + } + + mutating func encode(_ value: Int64) throws { + self.appendChild().kind = .int(value) + } + + mutating func encode(_ value: UInt) throws { + self.appendChild().kind = ToonNode.intKind(value) + } + + mutating func encode(_ value: UInt8) throws { + self.appendChild().kind = .int(Int64(value)) + } + + mutating func encode(_ value: UInt16) throws { + self.appendChild().kind = .int(Int64(value)) + } + + mutating func encode(_ value: UInt32) throws { + self.appendChild().kind = .int(Int64(value)) + } + + mutating func encode(_ value: UInt64) throws { + self.appendChild().kind = ToonNode.intKind(value) + } + + mutating func encode(_ value: some Encodable) throws { + try ToonFormatter.encodeValue(value, into: self.appendChild()) + } + + mutating func nestedContainer( + keyedBy _: NestedKey.Type) -> KeyedEncodingContainer + { + let child = self.appendChild() + child.kind = .object([]) + return KeyedEncodingContainer(ToonKeyedContainer(node: child, codingPath: self.codingPath)) + } + + mutating func nestedUnkeyedContainer() -> UnkeyedEncodingContainer { + let child = self.appendChild() + child.kind = .array([]) + return ToonUnkeyedContainer(node: child, codingPath: self.codingPath) + } + + mutating func superEncoder() -> Encoder { + ToonTreeEncoder(node: self.appendChild(), codingPath: self.codingPath) + } +} + +private struct ToonSingleValueContainer: SingleValueEncodingContainer { + let node: ToonNode + var codingPath: [CodingKey] + + mutating func encodeNil() throws { + self.node.kind = .null + } + + mutating func encode(_ value: Bool) throws { + self.node.kind = .bool(value) + } + + mutating func encode(_ value: String) throws { + self.node.kind = .string(value) + } + + mutating func encode(_ value: Double) throws { + self.node.kind = try .double(ToonFormatter.requireFinite(value, codingPath: self.codingPath)) + } + + mutating func encode(_ value: Float) throws { + self.node.kind = try .double(ToonFormatter.requireFinite(Double(value), codingPath: self.codingPath)) + } + + mutating func encode(_ value: Int) throws { + self.node.kind = .int(Int64(value)) + } + + mutating func encode(_ value: Int8) throws { + self.node.kind = .int(Int64(value)) + } + + mutating func encode(_ value: Int16) throws { + self.node.kind = .int(Int64(value)) + } + + mutating func encode(_ value: Int32) throws { + self.node.kind = .int(Int64(value)) + } + + mutating func encode(_ value: Int64) throws { + self.node.kind = .int(value) + } + + mutating func encode(_ value: UInt) throws { + self.node.kind = ToonNode.intKind(value) + } + + mutating func encode(_ value: UInt8) throws { + self.node.kind = .int(Int64(value)) + } + + mutating func encode(_ value: UInt16) throws { + self.node.kind = .int(Int64(value)) + } + + mutating func encode(_ value: UInt32) throws { + self.node.kind = .int(Int64(value)) + } + + mutating func encode(_ value: UInt64) throws { + self.node.kind = ToonNode.intKind(value) + } + + mutating func encode(_ value: some Encodable) throws { + try ToonFormatter.encodeValue(value, into: self.node) + } +} + +extension ToonNode { + fileprivate static func intKind(_ value: some BinaryInteger) -> Kind { + if let exact = Int64(exactly: value) { + .int(exact) + } else { + .double(Double(value)) + } + } +} + +// MARK: - Serializer + +enum ToonSerializer { + static func render(_ root: ToonNode) -> String { + self.renderValue(key: nil, node: root, indent: 0).joined(separator: "\n") + } + + private static func pad(_ indent: Int) -> String { + String(repeating: " ", count: indent) + } + + private static func renderValue(key: String?, node: ToonNode, indent: Int) -> [String] { + switch node.kind { + case .null: [self.scalarLine(key, "null", indent)] + case let .bool(value): [self.scalarLine(key, value ? "true" : "false", indent)] + case let .int(value): [self.scalarLine(key, String(value), indent)] + case let .double(value): [self.scalarLine(key, self.formatNumber(value), indent)] + case let .string(value): [self.scalarLine(key, self.quoteIfNeeded(value), indent)] + case let .array(items): self.renderArray(key: key, items: items, indent: indent) + case let .object(entries): self.renderObject(key: key, entries: entries, indent: indent) + } + } + + private static func scalarLine(_ key: String?, _ literal: String, _ indent: Int) -> String { + let prefix = self.pad(indent) + guard let key else { return "\(prefix)\(literal)" } + return "\(prefix)\(self.quoteKeyIfNeeded(key)): \(literal)" + } + + private static func renderObject(key: String?, entries: [(key: String, node: ToonNode)], indent: Int) -> [String] { + guard !entries.isEmpty else { + let prefix = self.pad(indent) + guard let key else { return ["\(prefix){}"] } + return ["\(prefix)\(self.quoteKeyIfNeeded(key)):"] + } + var lines: [String] = [] + var childIndent = indent + if let key { + lines.append("\(self.pad(indent))\(self.quoteKeyIfNeeded(key)):") + childIndent = indent + 1 + } + for entry in entries { + lines.append(contentsOf: self.renderValue(key: entry.key, node: entry.node, indent: childIndent)) + } + return lines + } + + private enum ArrayForm { + case empty + case inlinePrimitives + case tabular(fields: [String]) + case list + } + + /// Tabular form requires every row to carry the *exact* same field set in the same order. A + /// JSON encoder that uses `encodeIfPresent` for optional fields (as CodexBar's payloads do) omits + /// the key entirely when the value is nil — it never emits `null`. Collapsing rows with different + /// field sets into one table would require fabricating `null` for the columns a given row never + /// had, which fails to round-trip back to the source JSON (an absent key is not the same as a + /// present key with a null value). Rows with differing field sets fall back to list form instead, + /// where each row only ever prints the fields it actually has. + private static func classify(_ items: [ToonNode]) -> ArrayForm { + guard !items.isEmpty else { return .empty } + + if items.allSatisfy(self.isScalar) { + return .inlinePrimitives + } + + guard case let .object(firstEntries) = items[0].kind else { return .list } + let fields = firstEntries.map(\.key) + guard !fields.isEmpty else { return .list } + + for item in items { + guard case let .object(entries) = item.kind else { return .list } + guard entries.map(\.key) == fields else { return .list } + guard entries.allSatisfy({ self.isScalar($0.node) }) else { return .list } + } + return .tabular(fields: fields) + } + + private static func isScalar(_ node: ToonNode) -> Bool { + switch node.kind { + case .null, .bool, .int, .double, .string: true + case .array, .object: false + } + } + + private static func scalarLiteral(_ node: ToonNode) -> String { + switch node.kind { + case .null: "null" + case let .bool(value): value ? "true" : "false" + case let .int(value): String(value) + case let .double(value): self.formatNumber(value) + case let .string(value): self.quoteIfNeeded(value) + case .array, .object: "null" + } + } + + private static func renderArray(key: String?, items: [ToonNode], indent: Int) -> [String] { + let prefix = self.pad(indent) + let headerKey = key.map(self.quoteKeyIfNeeded) ?? "" + + guard !items.isEmpty else { + if key != nil { return ["\(prefix)\(headerKey): []"] } + return ["\(prefix)[]"] + } + + switch self.classify(items) { + case .empty: + return ["\(prefix)\(headerKey): []"] + + case .inlinePrimitives: + let values = items.map(self.scalarLiteral).joined(separator: ",") + return ["\(prefix)\(headerKey)[\(items.count)]: \(values)"] + + case let .tabular(fields): + let fieldList = fields.map(self.quoteKeyIfNeeded).joined(separator: ",") + var lines = ["\(prefix)\(headerKey)[\(items.count)]{\(fieldList)}:"] + let rowIndent = self.pad(indent + 1) + for item in items { + guard case let .object(entries) = item.kind else { continue } + let cells = entries.map { self.scalarLiteral($0.node) } + lines.append(rowIndent + cells.joined(separator: ",")) + } + return lines + + case .list: + var lines = ["\(prefix)\(headerKey)[\(items.count)]:"] + for item in items { + lines.append(contentsOf: self.renderListItem(item, indent: indent + 1)) + } + return lines + } + } + + private static func renderListItem(_ node: ToonNode, indent: Int) -> [String] { + switch node.kind { + case let .object(entries): + guard !entries.isEmpty else { return ["\(self.pad(indent))-"] } + let first = entries[0] + let firstLines = self.renderValue(key: first.key, node: first.node, indent: indent + 1) + var lines = self.mergeHyphen(firstLines, indent: indent) + for entry in entries.dropFirst() { + lines.append(contentsOf: self.renderValue(key: entry.key, node: entry.node, indent: indent + 1)) + } + return lines + + case let .array(items): + let arrayLines = self.renderArray(key: nil, items: items, indent: indent + 1) + return self.mergeHyphen(arrayLines, indent: indent) + + default: + return ["\(self.pad(indent))- \(self.scalarLiteral(node))"] + } + } + + /// Collapses a rendered value's first line onto a `- ` marker, matching TOON's convention + /// for list items whose value spans multiple lines (e.g. `- id: 1` then ` name: First`). + private static func mergeHyphen(_ lines: [String], indent: Int) -> [String] { + guard let first = lines.first else { return ["\(self.pad(indent))-"] } + let indentWidth = (indent + 1) * 2 + let content = first.count > indentWidth ? String(first.dropFirst(indentWidth)) : first + var merged = ["\(self.pad(indent))- \(content)"] + merged.append(contentsOf: lines.dropFirst()) + return merged + } + + // MARK: Literal formatting + + private static func formatNumber(_ value: Double) -> String { + // Non-finite doubles never reach here: every encode site rejects them via + // `ToonFormatter.requireFinite` before a `.double` node can be constructed. + if value == 0 { return "0" } + if value == value.rounded(), abs(value) < 1e15 { + return String(Int64(value)) + } + var text = String(value) + if text.hasSuffix(".0") { + text.removeLast(2) + } + return text + } + + /// Matches TOON's numeric-like quoting rule: `^[+-]?[0-9]+(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?$`. + private static func looksNumeric(_ value: String) -> Bool { + var scalars = Substring(value).unicodeScalars[...] + if let first = scalars.first, first == "+" || first == "-" { + scalars.removeFirst() + } + func consumeDigits() -> Bool { + var consumed = false + while let first = scalars.first, ("0"..."9").contains(first) { + scalars.removeFirst() + consumed = true + } + return consumed + } + guard consumeDigits() else { return false } + if scalars.first == "." { + scalars.removeFirst() + guard consumeDigits() else { return false } + } + if let exponentMarker = scalars.first, exponentMarker == "e" || exponentMarker == "E" { + scalars.removeFirst() + if let sign = scalars.first, sign == "+" || sign == "-" { + scalars.removeFirst() + } + guard consumeDigits() else { return false } + } + return scalars.isEmpty + } + + private static func needsQuoting(_ value: String) -> Bool { + if value.isEmpty { return true } + if value.first == " " || value.last == " " { return true } + if value == "true" || value == "false" || value == "null" { return true } + if self.looksNumeric(value) { return true } + if value.hasPrefix("-") || value.hasPrefix("#") { return true } + for scalar in value.unicodeScalars { + switch scalar { + case ":", "\"", "\\", "[", "]", "{", "}", ",": + return true + default: + if scalar.value < 0x20 { return true } + } + } + return false + } + + private static func quoteIfNeeded(_ value: String) -> String { + guard self.needsQuoting(value) else { return value } + return "\"\(self.escape(value))\"" + } + + private static func quoteKeyIfNeeded(_ key: String) -> String { + let isBareKey = key.range(of: "^[A-Za-z_][A-Za-z0-9_.]*$", options: .regularExpression) != nil + guard !isBareKey else { return key } + return "\"\(self.escape(key))\"" + } + + private static func escape(_ value: String) -> String { + var result = "" + result.reserveCapacity(value.count) + for scalar in value.unicodeScalars { + switch scalar { + case "\\": result += "\\\\" + case "\"": result += "\\\"" + case "\n": result += "\\n" + case "\r": result += "\\r" + case "\t": result += "\\t" + default: + if scalar.value < 0x20 { + result += String(format: "\\u%04x", scalar.value) + } else { + result.unicodeScalars.append(scalar) + } + } + } + return result + } +} diff --git a/Tests/CodexBarTests/CLIEntryTests.swift b/Tests/CodexBarTests/CLIEntryTests.swift index c391bc48c9..36f94118ce 100644 --- a/Tests/CodexBarTests/CLIEntryTests.swift +++ b/Tests/CodexBarTests/CLIEntryTests.swift @@ -55,6 +55,58 @@ final class CLIEntryTests: XCTestCase { XCTAssertFalse(result.stderr.isEmpty) } + /// `Program.resolve` throws before `ParsedValues` (and `resolveUsageOutputPreferences`) exist, so + /// a genuine parse failure -- an unrecognized option here -- has to go through the argv-level + /// `CLIOutputPreferences.from(argv:)` bootstrap scanner. Regression test for that scanner not + /// recognizing `--format toon` and silently falling back to plain stderr text. + func test_usageCommanderParseFailureWithToonAndJSONRendersTOON() throws { + let result = try Self.runCLI(arguments: ["usage", "--format", "toon", "--json", "--bogus-flag-xyz"]) + + XCTAssertNotEqual(result.status, 0) + XCTAssertTrue(result.stderr.isEmpty) + let stdout = try XCTUnwrap(String(bytes: result.stdout, encoding: .utf8)) + XCTAssertTrue(stdout.contains("- provider: cli")) + XCTAssertTrue(stdout.contains("Unknown option --bogus-flag-xyz")) + XCTAssertFalse(stdout.hasPrefix("[{"), "TOON error output should not fall back to a JSON array literal") + } + + func test_usageCommanderParseFailureRendersTOONWhenRequested() throws { + let result = try Self.runCLI(arguments: ["usage", "--bogus-flag-xyz", "--format", "toon"]) + + XCTAssertNotEqual(result.status, 0) + XCTAssertTrue(result.stderr.isEmpty) + let stdout = try XCTUnwrap(String(bytes: result.stdout, encoding: .utf8)) + XCTAssertTrue(stdout.contains("- provider: cli")) + XCTAssertTrue(stdout.contains("message: Unknown option --bogus-flag-xyz")) + XCTAssertFalse(stdout.hasPrefix("[{"), "TOON error output should not fall back to a JSON array literal") + XCTAssertFalse(stdout.contains("\"provider\""), "TOON error output should not contain JSON-quoted keys") + } + + func test_usageCommanderParseFailureWithEqualsFormatRendersTOON() throws { + let result = try Self.runCLI(arguments: ["usage", "--bogus-flag-xyz", "--format=toon"]) + + XCTAssertNotEqual(result.status, 0) + XCTAssertTrue(result.stderr.isEmpty) + let stdout = try XCTUnwrap(String(bytes: result.stdout, encoding: .utf8)) + XCTAssertTrue(stdout.contains("Unknown option --bogus-flag-xyz")) + } + + /// TOON is a `usage`-only contract. Commands whose help promises `text | json` must keep treating + /// `--format toon` as an unrecognized value -- reporting on stderr as text -- rather than silently + /// switching to the JSON branch. + func test_nonUsageCommandsDoNotInheritTOONOutput() throws { + for command in ["cost", "diagnose", "cache"] { + let result = try Self.runCLI(arguments: [command, "--format", "toon", "--bogus-flag-xyz"]) + + XCTAssertNotEqual(result.status, 0, "\(command) should still fail on an unknown option") + XCTAssertTrue(result.stdout.isEmpty, "\(command) must not emit a structured payload on stdout") + let stderr = try XCTUnwrap(String(bytes: result.stderr, encoding: .utf8)) + XCTAssertTrue( + stderr.contains("Unknown option --bogus-flag-xyz"), + "\(command) should report the parse failure as text on stderr") + } + } + func test_dashboardCommandPrintsOneSnapshotAndExits() throws { let root = FileManager.default.temporaryDirectory .appendingPathComponent("codexbar-dashboard-command-\(UUID().uuidString)", isDirectory: true) diff --git a/Tests/CodexBarTests/CLIOutputTests.swift b/Tests/CodexBarTests/CLIOutputTests.swift index a7349959fe..b4a139200e 100644 --- a/Tests/CodexBarTests/CLIOutputTests.swift +++ b/Tests/CodexBarTests/CLIOutputTests.swift @@ -1,3 +1,4 @@ +import Commander import Foundation import Testing @testable import CodexBarCLI @@ -11,6 +12,98 @@ struct CLIOutputTests { #expect(output.format == .json) } + @Test + func `argv bootstrap preferences recognize toon before Commander parsing`() { + // This is the scanner CLIEntry uses to render a Program.resolve parse failure (an unknown + // option, before ParsedValues exists), so it has to recognize --format toon independently + // of the post-parse resolveUsageOutputPreferences path. + let spaceForm = CLIOutputPreferences.from(argv: ["usage", "--format", "toon"]) + #expect(spaceForm.toonRequested) + #expect(spaceForm.format == .json) + #expect(spaceForm.usesJSONOutput) + + let equalsForm = CLIOutputPreferences.from(argv: ["usage", "--format=toon"]) + #expect(equalsForm.toonRequested) + #expect(equalsForm.format == .json) + + let notRequested = CLIOutputPreferences.from(argv: ["usage", "--format", "json"]) + #expect(!notRequested.toonRequested) + #expect(notRequested.format == .json) + } + + @Test + func `explicit toon format wins over a json shortcut in argv bootstrap`() { + let toonThenJSON = CLIOutputPreferences.from(argv: ["usage", "--format", "toon", "--json"]) + #expect(toonThenJSON.toonRequested) + #expect(toonThenJSON.format == .json) + + let jsonThenToon = CLIOutputPreferences.from(argv: ["usage", "--json", "--format", "toon"]) + #expect(jsonThenToon.toonRequested) + #expect(jsonThenToon.format == .json) + } + + @Test + func `a later explicit format overrides an earlier toon request`() { + let output = CLIOutputPreferences.from(argv: ["usage", "--format", "toon", "--format", "json"]) + #expect(!output.toonRequested) + #expect(output.format == .json) + } + + @Test + func `parse failure argv bootstrap matches explicit format precedence for toon plus json`() { + let output = CLIOutputPreferences.from(argv: ["usage", "--format", "toon", "--json", "--bogus"]) + #expect(output.toonRequested) + #expect(output.format == .json) + } + + @Test + func `toon is recognized for usage and ignored for every other command`() { + let values = ParsedValues(positional: [], options: ["format": ["toon"]], flags: []) + + let usage = CodexBarCLI.resolveUsageOutputPreferences(from: values) + #expect(usage.toonRequested) + #expect(usage.format == .json) + + // cost, cache, config, hooks, and diagnose share this constructor and advertise only + // `text | json`, so `toon` has to stay an unrecognized value there instead of meaning JSON. + let other = CLIOutputPreferences.from(values: values) + #expect(!other.toonRequested) + #expect(other.format == .text) + #expect(!other.usesJSONOutput) + } + + @Test + func `toon does not change the decoded format of non usage commands`() { + let toonOnly = ParsedValues(positional: [], options: ["format": ["toon"]], flags: []) + #expect(CodexBarCLI._decodeFormatForTesting(from: toonOnly) == .text) + + // An unrecognized --format still loses to nothing, so --json keeps deciding the format. + let toonWithJSONShortcut = ParsedValues(positional: [], options: ["format": ["toon"]], flags: ["json"]) + #expect(CodexBarCLI._decodeFormatForTesting(from: toonWithJSONShortcut) == .json) + + // An unrelated unsupported value behaves identically, which is the pre-TOON contract. + let unsupported = ParsedValues(positional: [], options: ["format": ["xml"]], flags: []) + #expect(CodexBarCLI._decodeFormatForTesting(from: unsupported) == .text) + } + + @Test + func `argv bootstrap only recognizes toon for the usage command`() { + for command in ["cost", "cache", "config", "hooks", "diagnose", "guard", "serve"] { + let output = CLIOutputPreferences.from(argv: [command, "--format", "toon"]) + #expect(!output.toonRequested, "\(command) must not accept --format toon") + #expect(output.format == .text, "\(command) must keep its pre-TOON default format") + + let equalsForm = CLIOutputPreferences.from(argv: [command, "--format=toon", "--json"]) + #expect(!equalsForm.toonRequested, "\(command) must not accept --format=toon") + #expect(equalsForm.format == .json, "\(command) must still honor --json") + } + + // `effectiveArgv` routes a bare `codexbar --format toon` to the implicit usage command. + let implicitUsage = CLIOutputPreferences.from(argv: ["--format", "toon"]) + #expect(implicitUsage.toonRequested) + #expect(implicitUsage.format == .json) + } + @Test func `cli error payload is JSON array`() throws { let payload = CodexBarCLI.makeCLIErrorPayload( diff --git a/Tests/CodexBarTests/CLIProviderSelectionTests.swift b/Tests/CodexBarTests/CLIProviderSelectionTests.swift index 8fa24d0a72..b67c9dbccf 100644 --- a/Tests/CodexBarTests/CLIProviderSelectionTests.swift +++ b/Tests/CodexBarTests/CLIProviderSelectionTests.swift @@ -63,6 +63,17 @@ struct CLIProviderSelectionTests { #expect(!tokens(root).contains("--claude-source")) } + @Test + func `help advertises the toon output format`() { + let usage = CodexBarCLI.usageHelp(version: "0.0.0") + let root = CodexBarCLI.rootHelp(version: "0.0.0") + + #expect(usage.contains("--format text|json|toon")) + #expect(root.contains("--format text|json|toon")) + #expect(usage.contains("codexbar usage --format toon")) + #expect(root.contains("codexbar --format toon")) + } + @Test func `provider selection respects override`() { let selection = CodexBarCLI.providerSelection(rawOverride: "gemini", enabled: [.codex, .claude]) diff --git a/Tests/CodexBarTests/CLIToonSnapshotTests.swift b/Tests/CodexBarTests/CLIToonSnapshotTests.swift new file mode 100644 index 0000000000..a82692a6c3 --- /dev/null +++ b/Tests/CodexBarTests/CLIToonSnapshotTests.swift @@ -0,0 +1,140 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBarCLI + +/// TOON is a presentation-only serializer over the existing `usage --format json` payload, so these +/// pin the exact rendered document for a fixture payload and assert the emitted field names match +/// the JSON schema one-for-one. +struct CLIToonSnapshotTests { + @Test + func `renders a fixture usage payload as an exact TOON document`() throws { + let toon = try ToonFormatter.encode(Self.makeFixturePayload()) + + #expect(toon == """ + [1]: + - provider: claude + account: work + version: 1.2.3 + source: oauth + usage: + primary: + usedPercent: 7 + windowMinutes: 300 + secondary: + usedPercent: 42.5 + windowMinutes: 10080 + tertiary: null + details[2]: + - title: Usage summary + rows[2]{label,value}: + Requests,"120" + Tokens,4.2k + - title: Extra usage + rows[2]: + - label: Spend + value: $5.00 + secondaryValue: of $20.00 + - label: Balance + value: $100.00 + updatedAt: "2026-02-02T02:40:00Z" + identity: + providerID: claude + accountEmail: dev@example.com + loginMethod: OAuth + accountEmail: dev@example.com + loginMethod: OAuth + """) + } + + /// Agents must be able to switch `--format json` to `--format toon` without re-mapping fields, so + /// TOON may not rename, drop, or invent a key relative to the JSON encoding of the same payload. + /// Key *order* is deliberately not compared: `JSONEncoder` emits keyed containers in dictionary + /// order (and sorts them under `--pretty`), while TOON preserves the stable `CodingKeys` order. + @Test + func `TOON emits the same field names as the JSON encoding of the same payload`() throws { + let payload = try Self.makeFixturePayload() + let json = try #require(CodexBarCLI.encodeJSON(payload, pretty: false)) + let toon = ToonFormatter.encode(payload) + + let jsonKeys = try Self.jsonFieldNames(in: JSONSerialization.jsonObject(with: Data(json.utf8))) + let toonKeys = Self.toonFieldNames(in: toon) + + #expect(!jsonKeys.isEmpty) + #expect(toonKeys == jsonKeys) + } + + private static func makeFixturePayload() throws -> [ProviderPayload] { + let uniformRows = try ProviderDetailSection( + title: "Usage summary", + rows: [ + ProviderDetailSection.Row(label: "Requests", value: "120"), + ProviderDetailSection.Row(label: "Tokens", value: "4.2k"), + ]) + // One row omits `secondaryValue`, so this section must stay in list form rather than + // collapsing into a table that would fabricate a value for the missing field. + let mixedRows = try ProviderDetailSection( + title: "Extra usage", + rows: [ + ProviderDetailSection.Row(label: "Spend", value: "$5.00", secondaryValue: "of $20.00"), + ProviderDetailSection.Row(label: "Balance", value: "$100.00"), + ]) + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 7, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 42.5, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), + details: [uniformRows, mixedRows], + updatedAt: Date(timeIntervalSince1970: 1_770_000_000), + identity: ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: "dev@example.com", + accountOrganization: nil, + loginMethod: "OAuth")) + return [ + ProviderPayload( + provider: .claude, + account: "work", + version: "1.2.3", + source: "oauth", + status: nil, + usage: snapshot, + credits: nil, + antigravityPlanInfo: nil, + openaiDashboard: nil, + error: nil), + ] + } + + private static func jsonFieldNames(in value: Any) -> Set { + switch value { + case let object as [String: Any]: + object.reduce(into: Set(object.keys)) { names, entry in + names.formUnion(self.jsonFieldNames(in: entry.value)) + } + case let array as [Any]: + array.reduce(into: Set()) { names, element in + names.formUnion(self.jsonFieldNames(in: element)) + } + default: + [] + } + } + + /// Collects `key:`, `key[N]:`, and tabular `key[N]{field,field}:` names from rendered TOON. + /// Table data lines carry no keys, so they are skipped. + private static func toonFieldNames(in toon: String) -> Set { + var names: Set = [] + for rawLine in toon.split(separator: "\n", omittingEmptySubsequences: false) { + var line = rawLine.trimmingCharacters(in: .whitespaces) + if line.hasPrefix("- ") { + line = String(line.dropFirst(2)) + } + guard let match = line.firstMatch(of: /^([A-Za-z_][A-Za-z0-9_]*)(?:\[\d+\])?(?:\{([^}]*)\})?:/) else { + continue + } + names.insert(String(match.1)) + guard let fields = match.2 else { continue } + names.formUnion(fields.split(separator: ",").map { String($0).trimmingCharacters(in: .whitespaces) }) + } + return names + } +} diff --git a/Tests/CodexBarTests/ToonFormatterTests.swift b/Tests/CodexBarTests/ToonFormatterTests.swift new file mode 100644 index 0000000000..5b1e251ea2 --- /dev/null +++ b/Tests/CodexBarTests/ToonFormatterTests.swift @@ -0,0 +1,280 @@ +import CodexBarCore +import Commander +import Foundation +import Testing +@testable import CodexBarCLI + +struct ToonFormatterTests { + private struct Simple: Encodable { + let name: String + let count: Int + let ratio: Double + let active: Bool + let note: String? + } + + @Test + func `renders a flat object as key colon value lines`() { + let value = Simple(name: "claude", count: 3, ratio: 93.0, active: true, note: nil) + let toon = ToonFormatter.encode(value) + + #expect(toon.contains("name: claude")) + #expect(toon.contains("count: 3")) + #expect(toon.contains("ratio: 93")) + #expect(toon.contains("active: true")) + #expect(!toon.contains("note")) + } + + @Test + func `collapses a uniform scalar array into tabular form`() { + struct Row: Encodable { let id: Int; let label: String } + struct Wrapper: Encodable { let rows: [Row] } + let value = Wrapper(rows: [Row(id: 1, label: "Ada"), Row(id: 2, label: "Bob")]) + + let toon = ToonFormatter.encode(value) + + #expect(toon == """ + rows[2]{id,label}: + 1,Ada + 2,Bob + """) + } + + @Test + func `falls back to list form instead of fabricating null for an omitted optional field`() { + // `encodeIfPresent` omits the key entirely when nil -- row 2 never has a `note` field in the + // source JSON. Collapsing to tabular form would require inventing a `null` cell for it, which + // does not round-trip back to "key absent". List form must be used instead, and row 2 must not + // mention `note` at all. + struct Row: Encodable { + let id: Int + let note: String? + enum CodingKeys: String, CodingKey { case id, note } + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(self.id, forKey: .id) + try container.encodeIfPresent(self.note, forKey: .note) + } + } + struct Wrapper: Encodable { let rows: [Row] } + let value = Wrapper(rows: [Row(id: 1, note: "hi"), Row(id: 2, note: nil)]) + + let toon = ToonFormatter.encode(value) + + #expect(!toon.contains("{"), "differing field sets must not collapse into a tabular header") + #expect(!toon.contains("null"), "an omitted optional field must not be fabricated as null") + #expect(toon.contains("note: hi")) + #expect(toon == """ + rows[2]: + - id: 1 + note: hi + - id: 2 + """) + } + + @Test + func `falls back to list form when array items are not uniform scalars`() { + struct Inner: Encodable { let x: Int } + struct Row: Encodable { let id: Int; let inner: Inner } + struct Wrapper: Encodable { let rows: [Row] } + let value = Wrapper(rows: [Row(id: 1, inner: Inner(x: 9)), Row(id: 2, inner: Inner(x: 8))]) + + let toon = ToonFormatter.encode(value) + + #expect(toon == """ + rows[2]: + - id: 1 + inner: + x: 9 + - id: 2 + inner: + x: 8 + """) + } + + @Test + func `renders a primitive array inline`() { + struct Wrapper: Encodable { let tags: [String] } + let toon = ToonFormatter.encode(Wrapper(tags: ["admin", "ops", "dev"])) + + #expect(toon == "tags[3]: admin,ops,dev") + } + + @Test + func `renders an empty array explicitly`() { + struct Wrapper: Encodable { let tags: [String] } + let toon = ToonFormatter.encode(Wrapper(tags: [])) + + #expect(toon == "tags: []") + } + + @Test + func `quotes strings that collide with delimiters keywords or numeric syntax`() { + struct Wrapper: Encodable { let a: String; let b: String; let c: String; let d: String } + let value = Wrapper(a: "hello, world", b: "true", c: "42", d: "-negative") + let toon = ToonFormatter.encode(value) + + #expect(toon.contains(#"a: "hello, world""#)) + #expect(toon.contains(#"b: "true""#)) + #expect(toon.contains(#"c: "42""#)) + #expect(toon.contains(#"d: "-negative""#)) + } + + @Test + func `fails closed instead of fabricating zero for a NaN or infinite double`() { + struct Wrapper: Encodable { let usedPercent: Double } + + // A non-finite provider value must not silently render as a valid-looking `0`, which would + // misrepresent invalid usage/cost data as "no usage". `JSONEncoder` rejects these the same + // way (EncodingError.invalidValue, swallowed by the CLI's `try?` into "print nothing"); TOON + // must fail the same way rather than diverge from the documented same-payload contract. + #expect(ToonFormatter.encode(Wrapper(usedPercent: .nan)).isEmpty) + #expect(ToonFormatter.encode(Wrapper(usedPercent: .infinity)).isEmpty) + #expect(ToonFormatter.encode(Wrapper(usedPercent: -.infinity)).isEmpty) + + // Sanity: the same shape with a finite value still renders normally. + #expect(!ToonFormatter.encode(Wrapper(usedPercent: 42)).isEmpty) + } + + @Test + func `NaN and infinity are rejected identically by the JSON path this formatter mirrors`() { + struct Wrapper: Encodable { let usedPercent: Double } + + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + + #expect(throws: (any Error).self) { try encoder.encode(Wrapper(usedPercent: .nan)) } + #expect(throws: (any Error).self) { try encoder.encode(Wrapper(usedPercent: .infinity)) } + } + + @Test + func `encodes dates as ISO8601 strings matching the JSON formatter`() { + struct Wrapper: Encodable { let updatedAt: Date } + let date = Date(timeIntervalSince1970: 0) + let toon = ToonFormatter.encode(Wrapper(updatedAt: date)) + + // ISO8601 timestamps contain colons, which TOON's quoting rule always requires quoting for + // (colon is reserved for key:value separators), regardless of the active delimiter. + #expect(toon == #"updatedAt: "1970-01-01T00:00:00Z""#) + } + + @Test + func `renders provider usage payloads with tabular detail rows`() throws { + let section = try ProviderDetailSection( + title: "Usage summary", + rows: [ + ProviderDetailSection.Row(label: "Requests", value: "120", secondaryValue: nil), + ProviderDetailSection.Row(label: "Tokens", value: "4.2k", secondaryValue: nil), + ]) + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 7, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: nil, + details: [section], + updatedAt: Date(timeIntervalSince1970: 0)) + let payload = [ + ProviderPayload( + provider: .claude, + account: nil, + version: nil, + source: "fixture", + status: nil, + usage: snapshot, + credits: nil, + antigravityPlanInfo: nil, + openaiDashboard: nil, + error: nil), + ] + + let toon = ToonFormatter.encode(payload) + + #expect(toon.contains("[1]:")) + #expect(toon.contains("- provider: claude")) + #expect(toon.contains("rows[2]{label,value}:")) + // "120" is quoted because it's a numeric-looking string, distinguishing it from an actual number. + #expect(toon.contains(#"Requests,"120""#)) + #expect(toon.contains("Tokens,4.2k")) + #expect(toon.contains("usedPercent: 7")) + } + + @Test + func `usage --format toon resolves output preferences with toon requested`() { + let output = CodexBarCLI.resolveUsageOutputPreferences(from: ParsedValues( + positional: [], + options: ["format": ["toon"]], + flags: [])) + + #expect(output.format == .json) + #expect(output.toonRequested) + #expect(output.usesJSONOutput) + } + + @Test + func `usage --format json does not request toon`() { + let output = CodexBarCLI.resolveUsageOutputPreferences(from: ParsedValues( + positional: [], + options: ["format": ["json"]], + flags: [])) + + #expect(output.format == .json) + #expect(!output.toonRequested) + } + + @Test + func `explicit toon format wins over a json shortcut in parsed values`() { + let output = CodexBarCLI.resolveUsageOutputPreferences(from: ParsedValues( + positional: [], + options: ["format": ["toon"]], + flags: ["jsonShortcut"])) + + #expect(output.format == .json) + #expect(output.toonRequested) + } + + @Test + func `a later explicit format overrides an earlier toon request in parsed values`() { + let jsonWins = CodexBarCLI.resolveUsageOutputPreferences(from: ParsedValues( + positional: [], + options: ["format": ["toon", "json"]], + flags: [])) + + #expect(jsonWins.format == .json) + #expect(!jsonWins.toonRequested) + + let toonWins = CodexBarCLI.resolveUsageOutputPreferences(from: ParsedValues( + positional: [], + options: ["format": ["json", "toon"]], + flags: [])) + + #expect(toonWins.format == .json) + #expect(toonWins.toonRequested) + } + + @Test + func `early CLI failures render as TOON instead of falling back to JSON`() { + let toonOutput = CLIOutputPreferences(format: .json, jsonOnly: false, pretty: false, toonRequested: true) + let errorPayload = CodexBarCLI.makeCLIErrorProviderPayload( + message: "Error: --source must be auto|web|cli|oauth|api.", + code: .failure, + kind: .args) + + let rendered = CodexBarCLI.renderProviderPayloads([errorPayload], output: toonOutput) + + #expect(rendered.contains("error:")) + #expect(rendered.contains(#"message: "Error: --source must be auto|web|cli|oauth|api.""#)) + #expect(!rendered.contains("{"), "TOON error output should not fall back to a JSON object literal") + } + + @Test + func `early CLI failures still render as JSON when toon was not requested`() { + let jsonOutput = CLIOutputPreferences(format: .json, jsonOnly: false, pretty: false) + let errorPayload = CodexBarCLI.makeCLIErrorProviderPayload( + message: "Nope", + code: .failure, + kind: .args) + + let rendered = CodexBarCLI.renderProviderPayloads([errorPayload], output: jsonOutput) + + #expect(rendered.hasPrefix("[")) + #expect(rendered.contains(#""message":"Nope""#)) + } +} diff --git a/docs/cli.md b/docs/cli.md index 1a4f404c23..e496608edb 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -43,13 +43,20 @@ See `docs/configuration.md` for the schema. ## Command - `codexbar` defaults to the `usage` command. - - `--format text|json` (default: text). + - `--format text|json|toon` (default: text). - JSON uses the generic `usage.details` array for provider-specific information. Each section contains an optional `title`, `rows` (`label`, `value`, and optional `secondaryValue`), and an optional `bars` or `line` chart. The same shape is returned by `GET /usage` from `codexbar serve`. - Legacy provider-specific keys such as `openRouterUsage`, `clawRouterUsage`, and `sub2APIUsage` are not compatibility aliases; clients must read `usage.details`. Unknown legacy keys in cached or iCloud-synced snapshots are ignored when decoding. + - `--format toon` emits the same payload as `--format json` (and implies `--json`'s credits/no-color behavior), + rendered as [TOON](https://github.com/toon-format/spec) instead: uniform arrays of scalar-only objects (for + example `usage.details[].rows`) collapse into a compact `rows[N]{label,value}:` table, everything else falls + back to an indented list form. This is a presentation-only mapping of the existing JSON schema — no new fields, + no denormalization — intended for agents that want a token-cheaper alternative to parsing JSON. `usage --format + toon` is the only command that supports it; every other command still advertises and accepts only + `--format text|json`, and treats `toon` like any other unrecognized value. - `codexbar cost` prints token cost usage for Claude, Codex, and Cursor. - Claude and Codex are scanned from local session logs without web/CLI access. - Cursor is fetched from the cookie-authenticated cursor.com dashboard API (macOS only; see `docs/cursor.md`) and honors the configured cookie source: a non-empty Manual header is required and forwarded, while Off fails explicitly instead of silently omitting Cursor.