design.md 12 KB

Context

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 / Non-Goals

Goals:

  • Single-command usage: zenmux-usage prints every configured account's three windows and USD value in under a second per account on a healthy network.
  • Readable at a glance: usage percentages are rendered as colored progress bars; absolute numbers (used/max flows, used/max USD) sit alongside; multiple accounts are clearly separated with a per-account header.
  • Scriptable: --json emits machine-readable output (array when multiple accounts, object when one) so it can be piped to jq.
  • Portable: pure-Go, cross-compiles to macOS/Linux/Windows with no runtime dependencies.
  • Clear errors: config-missing, auth failure, and rate-limit errors exit with distinct non-zero codes; a single failing account does NOT take down the whole run.
  • One config file, one command — no per-account flag juggling needed in the common case.

Non-Goals:

  • Historical tracking, charting over time, or local caching — this is a point-in-time snapshot tool.
  • Managing API keys or editing the config file from within the CLI — read-only config consumer.
  • Watch/polling mode or TUI dashboard. (Could come later; not in this change.)
  • Supporting env-var-based per-account configuration (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.
  • Automatic encryption / keychain integration for the config file. Filesystem permissions are the user's responsibility in v1.

Decisions

1. Language & module layout

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).

2. Config file schema

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).
  • Unknown top-level keys are ignored with a one-line stderr warning (forward-compat).
  • No 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).

3. Config discovery and precedence

Decision: Resolution order for what to fetch:

  1. --api-key <key> set → single ad-hoc account, name cli, no config file loaded.
  2. --config <path> set → load that file (error if missing/invalid).
  3. Default config at ~/.config/zenmux-usage/config.yaml exists → load it.
  4. No config file, ZENMUX_MANAGEMENT_API_KEY set → single ad-hoc account, name env.
  5. None of the above → exit code 3 with a message pointing to the config path.

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.

4. HTTP client

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.

5. Terminal rendering

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.
  • Pure stdlib ANSI codes — re-implementing NO_COLOR/non-TTY is annoying.

6. Flag parsing

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.

7. Output format (human mode)

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.

8. JSON mode (--json) shape

Decision: 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.

9. Exit codes

Decision:

  • 0 — all fetched accounts succeeded
  • 1 — any other unexpected error, OR at least one account failed and the rest are uncertain
  • 2 — invalid arguments/flags
  • 3 — no config/key available, or config file not found when required
  • 4 — auth failure (401/403) — only when it's the sole failure mode across all accounts
  • 5 — rate limited (422) — same rule as above
  • 6 — network/timeout — same rule as above
  • 7 — 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.

10. Testing

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.

Risks / Trade-offs

  • API schema drift → If ZenMux adds/renames fields we miss them silently. Mitigation: treat unknown JSON fields as non-fatal (default encoding/json behavior), and log a one-line warning to stderr if success: false.
  • Monthly 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.
  • Config contains plaintext keys → Anyone with FS access can exfiltrate them. Mitigation: document 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).
  • Sequential fetch latency → N accounts × per-account latency. Mitigation: fine for the realistic N ≤ 5; parallel fan-out can be added later without a schema change.
  • Terminal width → Long numbers or narrow terminals break alignment. Mitigation: fixed 30-char bar, right-aligned numeric columns; no responsive layout for v1.
  • Rate-limit (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.
  • Secret leakage via --api-key flag → Visible in shell history and ps. Mitigation: prefer YAML config, document the flag as a one-off override only.
  • YAML footguns → Duplicate account names, typos in keys. Mitigation: validate on load: unique names required, required fields checked, unknown fields warned; fail fast with exit code 7.

Migration Plan

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.

Open Questions

  • Should the "Tokens consumed (estimated USD value)" line show the 7-day window (our current pick) or the 5-hour window? Defaulting to 7-day; revisit after first real-world use.
  • Should we support XDG's $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.
  • Future: parallel fan-out for large account counts. Punt.