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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,13 @@ printf '%s' "$ELEVENLABS_API_KEY" | codexbar config set-api-key --provider eleve
`set-api-key` trims the piped value, stores it with restrictive config-file permissions, and enables the provider by default. Use `--no-enable` to only save the key, or `--api-key <key>` for one-off local scripts where shell history is not a concern.
See [CLI configuration](docs/cli-configuration.md) for the full flow.

### Hermes Agent local usage

`codexbar hermes-usage` reads local Hermes `state.db` attribution in SQLite read-only mode and reports mapped
provider/model/task tokens. Billed cost, subscription-included usage, Hermes estimates, and API-equivalent estimates
remain separate; the source is not automatically merged with provider-native billing totals. See
[Hermes Agent local usage](docs/hermes-usage.md).

## Providers

- [Codex](docs/codex.md) — OAuth API or local Codex CLI, plus optional OpenAI web dashboard extras.
Expand Down
8 changes: 8 additions & 0 deletions Sources/CodexBarCLI/CLIEntry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ enum CodexBarCLI {
await self.runUsageDisplay(path: invocation.path, values: invocation.parsedValues)
case ["cost"]:
await self.runCost(invocation.parsedValues)
case ["hermes-usage"]:
await self.runHermesUsage(invocation.parsedValues)
case ["sessions", "list"]:
await self.runSessions(invocation.parsedValues)
case ["sessions", "focus"]:
Expand Down Expand Up @@ -163,6 +165,7 @@ enum CodexBarCLI {
let cardsSignature = CommandSignature.describe(CardsOptions())
let usageSignature = CommandSignature.describe(UsageOptions())
let costSignature = CommandSignature.describe(CostOptions())
let hermesUsageSignature = CommandSignature.describe(HermesUsageOptions())
let sessionsSignature = CommandSignature.describe(SessionsOptions())
let sessionsFocusSignature = CommandSignature.describe(SessionsFocusOptions())
let serveSignature = CommandSignature.describe(ServeOptions())
Expand Down Expand Up @@ -195,6 +198,11 @@ enum CodexBarCLI {
abstract: "Print local cost usage as text or JSON",
discussion: nil,
signature: costSignature),
CommandDescriptor(
name: "hermes-usage",
abstract: "Read local Hermes token and cost attribution",
discussion: nil,
signature: hermesUsageSignature),
CommandDescriptor(
name: "sessions",
abstract: "List live Codex, Claude Code, pi, and OMP sessions",
Expand Down
26 changes: 26 additions & 0 deletions Sources/CodexBarCLI/CLIHelp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,30 @@ extension CodexBarCLI {
"""
}

static func hermesUsageHelp(version: String) -> String {
"""
CodexBar \(version)

Usage:
codexbar hermes-usage [--database <path[,path...]>]
[--provider <name|all>]
[--format text|json] [--json] [--json-only] [--pretty]
[--refresh-pricing] [--no-color]

Description:
Read Hermes Agent token attribution from local state.db files in SQLite read-only mode.
Without --database, discovers ~/.hermes/state.db and ~/.hermes/profiles/*/state.db.
Reports actual billed cost, Hermes estimates, subscription-included usage, and
API-equivalent estimates separately. Cumulative rows are not presented as exact daily history.
Routes such as auto/custom remain unmapped unless Hermes persisted an exact billing provider.

Examples:
codexbar hermes-usage
codexbar hermes-usage --provider codex --json --pretty
codexbar hermes-usage --database ~/.hermes/profiles/work/state.db --refresh-pricing
"""
}

static func sessionsHelp(version: String) -> String {
"""
CodexBar \(version)
Expand Down Expand Up @@ -459,6 +483,8 @@ extension CodexBarCLI {
[--provider \(ProviderHelp.list)] [--no-color] [--pretty] [--refresh]
[--provider-native-only]
[--days <days>] [--group-by project]
codexbar hermes-usage [--database <path[,path...]>] [--provider <name|all>]
[--format text|json] [--json] [--pretty] [--refresh-pricing]
codexbar sessions [--json|--json-v2] [--pretty]
codexbar sessions focus <id>
codexbar dashboard [--pretty] [--timeout <seconds>] [--output <path>]
Expand Down
4 changes: 4 additions & 0 deletions Sources/CodexBarCLI/CLIHelpers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -396,6 +396,10 @@ extension CodexBarCLI {
CommandSignature.describe(CostOptions())
}

static func _hermesUsageSignatureForTesting() -> CommandSignature {
CommandSignature.describe(HermesUsageOptions())
}

static func _cacheSignatureForTesting() -> CommandSignature {
CommandSignature.describe(CacheOptions())
}
Expand Down
237 changes: 237 additions & 0 deletions Sources/CodexBarCLI/CLIHermesUsageCommand.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,237 @@
import CodexBarCore
import Commander
import Foundation

extension CodexBarCLI {
static func runHermesUsage(_ values: ParsedValues) async {
let output = CLIOutputPreferences.from(values: values)
let useColor = Self.shouldUseColor(
noColor: values.flags.contains("noColor"),
format: output.format)

do {
let provider = try Self.decodeHermesUsageProvider(from: values)
let explicitDatabases = Self.decodeHermesUsageDatabaseURLs(from: values)
let sources: [HermesUsageDatabaseSource] = if explicitDatabases.isEmpty {
HermesUsageDatabaseDiscovery.discover()
} else {
explicitDatabases.map {
HermesUsageDatabaseSource(
label: HermesUsageDatabaseDiscovery.label(forDatabaseURL: $0),
databaseURL: $0)
}
}
guard !sources.isEmpty else {
throw CLIArgumentError(
"No Hermes state.db databases found. Pass --database <path[,path...]> or set HERMES_HOME.")
}

let scanner = HermesUsageScanner()
if values.flags.contains("refreshPricing") {
await scanner.refreshPricingIfNeeded()
}
var report = try scanner.scan(sources: sources)
try Self.validateHermesUsageSources(report.sources)
if let provider {
report = try Self.filterHermesUsageReport(report, provider: provider)
}

switch output.format {
case .text:
print(Self.renderHermesUsageText(report, useColor: useColor))
case .json:
Self.printJSON(report, pretty: output.pretty)
}
Self.exit(code: .success, output: output, kind: .runtime)
} catch {
Self.exit(
code: Self.mapError(error),
message: "Error: \(error.localizedDescription)",
output: output,
kind: error is CLIArgumentError ? .args : .runtime)
}
}

static func decodeHermesUsageProvider(from values: ParsedValues) throws -> UsageProvider? {
guard let raw = values.options["provider"]?.last?.trimmingCharacters(in: .whitespacesAndNewlines),
!raw.isEmpty,
raw.lowercased() != "all"
else { return nil }
guard let provider = UsageProvider(rawValue: raw.lowercased()) else {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Parse Hermes provider filters through CLI names

When users pass the documented CodexBar provider names for mapped Hermes routes, such as --provider opencode-go, --provider qwen-cloud, --provider vertex-ai, or --provider alibaba-coding-plan, this raw-value lookup rejects them even though the rest of the CLI resolves provider names and aliases through ProviderDescriptorRegistry.cliNameMap. That makes the new --provider filter unusable for several supported Hermes mappings unless users know the internal enum raw values like opencodego/qwencloud instead of the normal CLI names.

Useful? React with 👍 / 👎.

throw CLIArgumentError("Unknown CodexBar provider '\(raw)'.")
}
let supported = Set(HermesUsageProviderMapping.supportedBillingProviders.compactMap {
HermesUsageProviderMapping.route(for: $0)?.provider
})
guard supported.contains(provider) else {
let list = supported.map(\.rawValue).sorted().joined(separator: ", ")
throw CLIArgumentError("Hermes local usage is not mapped to \(raw). Supported providers: \(list).")
}
return provider
}

static func decodeHermesUsageDatabaseURLs(
from values: ParsedValues,
homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser) -> [URL]
{
let paths = values.options["database"]?.flatMap { raw in
raw.split(separator: ",", omittingEmptySubsequences: true).map(String.init)
} ?? []
var seen: Set<String> = []
return paths.compactMap { raw -> URL? in
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
let path: String = if trimmed == "~" {
homeDirectory.path
} else if trimmed.hasPrefix("~/") {
homeDirectory.appendingPathComponent(String(trimmed.dropFirst(2))).path
} else {
NSString(string: trimmed).expandingTildeInPath
}
let url = URL(fileURLWithPath: path, isDirectory: false).standardizedFileURL
return seen.insert(url.path).inserted ? url : nil
}
}

static func filterHermesUsageReport(
_ report: HermesUsageReport,
provider: UsageProvider) throws -> HermesUsageReport
{
guard let providerReport = report.providers.first(where: { $0.provider == provider }) else {
throw CLIArgumentError("No Hermes usage found for CodexBar provider '\(provider.rawValue)'.")
}
return HermesUsageReport(
schemaVersion: report.schemaVersion,
generatedAt: report.generatedAt,
sources: report.sources,
summary: providerReport.summary,
providers: [providerReport],
unmapped: [],
warnings: report.warnings)
}

static func validateHermesUsageSources(_ sources: [HermesUsageSourceReport]) throws {
guard sources.contains(where: { $0.status == .read }) else {
let details = sources.map { "\($0.label): \($0.status.rawValue)" }.joined(separator: ", ")
throw HermesUsageCLIError.noReadableSources(details: details)
}
}

static func renderHermesUsageText(_ report: HermesUsageReport, useColor _: Bool) -> String {
let number = NumberFormatter()
number.numberStyle = .decimal
number.locale = Locale(identifier: "en_US_POSIX")

func integer(_ value: Int) -> String {
number.string(from: NSNumber(value: value)) ?? String(value)
}

func currency(_ value: Double?) -> String {
value.map { UsageFormatter.currencyString($0, currencyCode: "USD") } ?? "unknown"
}

func summaryLines(_ summary: HermesUsageSummary, prefix: String = "") -> [String] {
let tokens = summary.tokens
var lines = [
"\(prefix)Tokens: \(integer(tokens.total)) " +
"(input \(integer(tokens.input)), cache read \(integer(tokens.cacheRead)), " +
"cache write \(integer(tokens.cacheWrite)), output \(integer(tokens.output)), " +
"reasoning \(integer(tokens.reasoning)) subset of output)",
"\(prefix)Requests: \(integer(summary.requests)) · Sessions: \(integer(summary.sessions))",
"\(prefix)Actual billed: \(currency(summary.actualCostUSD))",
"\(prefix)Hermes estimate: \(currency(summary.hermesEstimatedCostUSD))",
"\(prefix)API-equivalent: " + (summary.apiEquivalentCostUSD.map { "~\(currency($0))" } ?? "unknown") +
" · priced \(integer(summary.apiEquivalentPricedTokens)) / " +
"unpriced \(integer(summary.apiEquivalentUnpricedTokens)) tokens",
"\(prefix)Included in subscription: \(integer(summary.subscriptionIncludedTokens)) tokens · " +
"\(integer(summary.subscriptionIncludedRequests)) requests",
]
if summary.actualCostUSD == nil {
lines[2] += " (not reported)"
}
if summary.hermesEstimatedCostUSD == nil {
lines[3] += " (not reported)"
}
return lines
}

var lines = ["Hermes local usage snapshot"]
lines.append(contentsOf: summaryLines(report.summary))
if !report.providers.isEmpty {
lines.append("")
lines.append("Mapped providers:")
for provider in report.providers {
let descriptor = ProviderDescriptorRegistry.descriptor(for: provider.provider)
lines.append(
"- \(descriptor.metadata.displayName): \(integer(provider.summary.tokens.total)) tokens · " +
"actual \(currency(provider.summary.actualCostUSD)) · " +
"Hermes estimate \(currency(provider.summary.hermesEstimatedCostUSD)) · " +
"API-equivalent " +
(provider.summary.apiEquivalentCostUSD.map { "~\(currency($0))" } ?? "unknown"))
}
}
if !report.unmapped.isEmpty {
lines.append("")
lines.append("Unmapped routes: \(report.unmapped.map(\.billingProvider).joined(separator: ", "))")
}
if !report.sources.isEmpty {
lines.append("")
lines.append("Sources:")
for source in report.sources {
lines.append("- \(source.label): \(source.status.rawValue) (\(source.databasePath))")
}
}
if !report.warnings.isEmpty {
lines.append("")
lines.append("Notes:")
lines.append(contentsOf: report.warnings.map { "- \($0)" })
}
return lines.joined(separator: "\n")
}
}

enum HermesUsageCLIError: LocalizedError, Equatable {
case noReadableSources(details: String)

var errorDescription: String? {
switch self {
case let .noReadableSources(details):
"No Hermes usage databases could be read (\(details))."
}
}
}

struct HermesUsageOptions: 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("database"), help: "Hermes state.db path(s), comma-separated; overrides discovery")
var database: [String]?

@Option(name: .long("provider"), help: "Filter by mapped CodexBar provider, or all")
var provider: String?

@Option(name: .long("format"), help: "Output format: text | json")
var format: OutputFormat?

@Flag(name: .long("json"), help: "")
var jsonShortcut: Bool = false

@Flag(name: .long("json-only"), help: "Emit JSON only (suppress non-JSON output)")
var jsonOnly: Bool = false

@Flag(name: .long("pretty"), help: "Pretty-print JSON output")
var pretty: Bool = false

@Flag(name: .long("no-color"), help: "Disable ANSI colors in text output")
var noColor: Bool = false

@Flag(name: .long("refresh-pricing"), help: "Refresh the public models.dev catalog before estimating")
var refreshPricing: Bool = false
}
2 changes: 2 additions & 0 deletions Sources/CodexBarCLI/CLIIO.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ extension CodexBarCLI {
print(Self.usageHelp(version: version))
case "cost":
print(Self.costHelp(version: version))
case "hermes-usage":
print(Self.hermesUsageHelp(version: version))
case "sessions", "focus":
print(Self.sessionsHelp(version: version))
case "dashboard":
Expand Down
Loading