Greenfield Go project. Target is a single static binary that a developer runs at the terminal and gets back a snapshot of their ZenMux subscription usage across one or more accounts. The only upstream dependency is the ZenMux Management API (GET /api/v1/management/subscription/detail), which returns three rolling quota windows (quota_5_hour, quota_7_day, quota_monthly) along with plan, rate, and account status metadata. The API is authenticated via a Management API key passed as Authorization: Bearer <key>; each account has its own key.
Accounts are configured via a YAML file; the CLI reads it once, fans out one HTTP request per account, and renders each account as a labeled block. There are no existing conventions in this repo yet — the design establishes them.
Goals:
zenmux-usage prints every configured account's three windows and USD value in under a second per account on a healthy network.--json emits machine-readable output (array when multiple accounts, object when one) so it can be piped to jq.Non-Goals:
ZENMUX_KEY_PERSONAL, ZENMUX_KEY_WORK, ...). YAML file is the single source of truth for multi-account. The single env var is only a zero-config fallback.Decision: Go 1.22+; module path github.com/kotoyuuko/zenmux-usage-cli (placeholder — actual owner path can be swapped later). Layout:
cmd/zenmux-usage/main.go // entrypoint, flag parsing, exit codes, fan-out
internal/api/client.go // HTTP client + typed response structs
internal/api/client_test.go // table-driven tests against httptest.Server
internal/config/config.go // YAML load + account resolution
internal/config/config_test.go
internal/render/render.go // progress bars, per-account block, multi-account layout
internal/render/render_test.go
config.example.yaml // committed example, no real keys
Why: internal/ keeps packages unimportable outside this module — we're not publishing a library. cmd/ is idiomatic for multi-binary repos. Config gets its own package so the CLI entrypoint stays thin.
Alternatives considered: Flat package at root (simpler, but awkward to test rendering in isolation); pkg/ (over-promises reusability).
Decision: Single YAML file, default path ~/.config/zenmux-usage/config.yaml (XDG-style). Schema:
accounts:
- name: personal
api_key: sk-zm-...
- name: work
api_key: sk-zm-...
accounts (required, list, min length 1): each entry has name (required, unique, kebab- or snake-case) and api_key (required, non-empty string).default: field in v1 — the default UX renders every account. Users can pin --account in a shell alias if they prefer one.Why: Flat list is the simplest structure that meets the requirement. A map (accounts: {personal: {...}}) would preserve uniqueness via YAML's own semantics but loses user-specified ordering, and ordering matters because the CLI renders accounts in file order.
Alternatives considered: TOML (YAML is more common for this kind of user config and the yaml.v3 dep is tiny); map-of-accounts (loses order); per-account files in ~/.config/zenmux-usage/accounts.d/ (overkill).
Decision: Resolution order for what to fetch:
--api-key <key> set → single ad-hoc account, name cli, no config file loaded.--config <path> set → load that file (error if missing/invalid).~/.config/zenmux-usage/config.yaml exists → load it.ZENMUX_MANAGEMENT_API_KEY set → single ad-hoc account, name env.If a config is loaded and --account <name> is set, filter to just that account (error if the name is not in the file).
Why: Preserves a zero-config "set env var, run" path so users can try the binary before writing a config; the flag beats env beats config-file beats nothing in the usual CLI way. --api-key is explicitly highest precedence because it's a one-off override.
Decision: net/http standard library with a 10-second default timeout, configurable via --timeout. Decode into typed structs using encoding/json. No third-party HTTP client. One request per account, fired sequentially in v1; 1–5 accounts is the realistic upper bound and sequential keeps logs and errors simple.
Why: One endpoint, no retry/backoff complexity. Sequential is fast enough for ≤5 accounts (~1s each) and avoids interleaved error messages.
Alternatives considered: errgroup with bounded parallelism — marginal wall-clock gain, much noisier failure modes; revisit if users report large account lists.
Decision: Use github.com/fatih/color for ANSI color support with automatic no-TTY detection. Render progress bars by hand: a fixed width (default 30 chars) filled with █ and empty with ░, colorized by usage band (green < 60%, yellow 60–85%, red > 85%). For multiple accounts, separate blocks with a blank line and a bold header like ━━━ personal ━━━ so the eye can scan.
Why: fatih/color handles NO_COLOR, Windows, and non-TTY. Hand-rolling the bar keeps us off the lipgloss/bubbletea dependency tree.
Alternatives considered:
charmbracelet/lipgloss — overkill for fixed-width ASCII.Decision: Standard flag package. Flags: --account <name>, --config <path>, --api-key <key>, --json, --no-color, --timeout <duration> (default 10s), --version.
Why: Matches Go conventions, one flag set, no cobra/urfave/cli dependency needed for ~7 flags.
Decision: For a single account, three sections, printed in this order:
━━━ personal ━━━
ZenMux Subscription — Ultra plan ($200/mo) · healthy · $0.03283/flow
5 hour [██░░░░░░░░░░░░░░░░░░░░░░░░░░░░] 7.15% 57.2 / 800 flows $1.88 / $26.26
7 day [██░░░░░░░░░░░░░░░░░░░░░░░░░░░░] 6.73% 416.11 / 6182 flows $13.66 / $202.95
month [░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░] n/a — / 34560 flows — / $1134.33
Tokens consumed (estimated USD value): $13.66
Next reset: 5h → 2026-04-22 14:05 · 7d → 2026-04-28 00:00
For multiple accounts, repeat the block with a blank line between accounts. A failed account still gets a header, but the body is replaced by a single red error line.
Trade-off: The monthly window has max_flows and max_value_usd but no used_* fields per the spec. We render what we have and don't extrapolate. The "Tokens consumed" summary pulls from the 7-day used_value_usd since the monthly window lacks a used value.
--json) shapeDecision: Array of objects, one per account, in config order:
[
{"account": "personal", "success": true, "data": {...}},
{"account": "work", "success": false, "error": "rate limited"}
]
When only a single account is resolved (via --account, --api-key, or env var), emit a single object with no wrapping array — matching the raw API shape so jq .data.plan.tier keeps working.
Why: Preserves the single-account pipe ergonomics while giving multi-account users a clean iterable. Per-account errors go into the JSON (not stderr) so scripts don't have to interleave streams.
Decision:
0 — all fetched accounts succeeded1 — any other unexpected error, OR at least one account failed and the rest are uncertain2 — invalid arguments/flags3 — no config/key available, or config file not found when required4 — auth failure (401/403) — only when it's the sole failure mode across all accounts5 — rate limited (422) — same rule as above6 — network/timeout — same rule as above7 — config file parse error (malformed YAML or missing required fields)In multi-account mode, if accounts fail with mixed causes, the CLI uses exit code 1 and surfaces per-account error lines in the output. If all accounts fail with the same cause, the corresponding specific code is used.
Why: Distinct codes help scripts branch. The "mixed failures → 1" rule keeps the mapping unambiguous.
Decision: Use httptest.NewServer for the client; rendering tested by snapshotting string output with ANSI stripped. Config tested with YAML fixtures under internal/config/testdata/. Keep the example JSON payload from the API docs as a golden fixture in internal/api/testdata/.
Why: No real network or filesystem dependencies in tests. Snapshot-style assertions catch formatting regressions cheaply.
encoding/json behavior), and log a one-line warning to stderr if success: false.used_value_usd absent → Users may expect a monthly USD-burned figure. Mitigation: the "Tokens consumed USD" summary is explicitly scoped to the 7-day window; monthly row shows — rather than a faked computed value.chmod 600, include .gitignore entry, ship only config.example.yaml. On load, if file mode is world-readable on POSIX, print a stderr warning (not fatal).422) → One account tripping rate limit could fail others if we share state. Mitigation: per-request HTTP client, no shared retry backoff, rate-limit is scoped to that account's result.--api-key flag → Visible in shell history and ps. Mitigation: prefer YAML config, document the flag as a one-off override only.N/A — net-new binary, nothing to migrate. First release ships as v0.1.0. A config.example.yaml is committed to the repo to lower onboarding friction.
$XDG_CONFIG_HOME override in addition to the literal ~/.config/zenmux-usage/config.yaml path? Leaning yes, trivial to implement — treat as a spec detail, not a separate decision.