ocd/DESIGN.md

8.0 KiB

ocd design

ocd is a console application. It reads a config file. The file lists opencode accounts and auth cookies. ocd scrapes the opencode Zen and Go pages. It returns limits and balance data. Limits cover three time frames: 5 hours, week, and month. The program reports usage, totals, reset times, and the next update time. It also reports remaining account balance.

Goals

  1. Speed.
  2. Polite defaults. We do not hurt opencode.
  3. Ease of use.

Stack

The program is written in Rust.

Concern Crate Reason
HTTP and cookies reqwest, rustls, no openssl One client reuses connections. One TLS handshake serves all workspaces.
Async runtime tokio Paced sequential pipeline. Two requests run at once per workspace.
HTML parse scraper on html5ever Fast CSS selectors. This is the only opencode-coupled module.
Config serde and toml Human-editable account list.
CLI clap derive Subcommands and global flags.
Pretty output Custom renderer and comfy-table Unicode blitter bars. The program degrades to ASCII.
Color anstyle Color turns off when output is not a terminal.
Raw output serde_json Stable schema. One schema_version field.
Cache path directories XDG cache dir.
Time chrono now, jiff later Reset times and relative strings.
Errors anyhow and thiserror Typed fetch and parse errors.
Diagnostics tracing RUST_LOG controls verbosity.

Workspaces

An opencode account holds multiple workspaces. The Go subscription is per-workspace. So config carries one default_workspace plus an extra_workspaces list. Reports key by account and workspace.

default_workspace is a required id. The program fetches it by default. extra_workspaces is optional. The program fetches all listed workspaces.

The program cannot auto-discover the default workspace. That path is not reliable. So the user supplies the id. A future ocd workspace list command can add discovery later.

Two pages per workspace

Each workspace needs two HTTP requests.

  1. The Zen page returns balance.
  2. The Go page returns limits.

The program fires both at once per workspace. It uses tokio::join. Then it waits about 500 ms. Then it moves to the next workspace. The workspace walk is sequential. Per-workspace concurrency is two. Cross-workspace concurrency is one. This pacing keeps opencode load low.

Reset times

Each time frame resets on its own rule.

  • 5 hours: window based. The window starts on activity.
  • Week: calendar based. It resets every Monday.
  • Month: calendar based. It resets 31 days from the subscription date.

The model keeps one resets_at field per frame. The scrape step reads this from the page when present. Calendar resets can fall back to a computed value when the scrape lacks the time but knows the rule.

Config format

# ~/.config/ocd/config.toml
default_concurrency = 1
default_ttl_secs    = 180

[[account]]
name              = "work"
cookie            = "${OCD_COOKIE_WORK}"
default_workspace = "ws_123"
extra_workspaces  = ["ws_456"]

[[account]]
name              = "personal"
cookie            = "ocd_session=...."
default_workspace = "ws_789"

Config load order: -c flag, then $OCD_CONFIG, then ./ocd.toml, then $XDG_CONFIG_HOME/ocd/config.toml.

${VAR} expands in string values. This keeps cookies out of the file.

Commands:

  • ocd config init writes a template.
  • ocd config check parses the file and lists accounts. It does no network.

CLI surface

ocd pretty         default; bare `ocd` is an alias
ocd raw json       one stable JSON object to stdout
ocd raw plain      TSV-ish lines for shell and awk widgets
ocd fetch          run network fetch and update cache; print nothing; for cron
ocd cache ls|clear inspect or clear the cache
ocd config init|check

Global flags:

  • -c, --config PATH
  • --no-cache
  • --ttl SECS
  • --force-refresh
  • -a, --account NAME...
  • -w, --workspace ID...
  • -j, --concurrency N (default 1; workspace walk stays sequential)
  • --timeout SECS
  • --retries N
  • --user-agent
  • -v, --verbose (repeat for more)
  • --no-color
  • --ascii
  • -q, --quiet

Data model

struct Envelope {
  schema_version: u32,         // 1
  generated_at: String,        // ISO 8601 UTC
  accounts: Vec<AccountReport>,
}

struct AccountReport {
  account: String,             // config name
  workspace: String,           // workspace id
  fetched_at: Option<String>,  // ISO 8601 UTC
  status: ReportStatus,        // Ok | Cached | RateLimited | Timeout | Error(msg)
  limits: Option<Limits>,
  balance: Option<Balance>,
}

struct Limits {
  usage_5h: UsageFrame,
  usage_week: UsageFrame,
  usage_month: UsageFrame,
  next_update: Option<String>,
}

struct UsageFrame {
  used: f64,
  total: f64,
  unit: String,                // "credits" | "requests" | "$"
  resets_at: Option<String>,
}

struct Balance {
  remaining: f64,
  unit: String,
}

Run flow

  1. Parse flags and subcommand.
  2. Load config. Expand ${VAR}. Filter by --account.
  3. Flatten targets. Each target is one account and one workspace.
  4. Filter targets by --workspace.
  5. Open the cache store.
  6. Walk targets in order.
    • Cache fresh and present. Emit Cached. No network.
    • Else fire Zen and Go at once. Use conditional GET headers when cached. On 304, reuse cache. On 200, parse both pages in scrape.rs, build the report, and write cache. On 429, back off or honor Retry-After. On 5xx or timeout, retry with backoff and jitter. On hard fail, emit Error and continue other workspaces.
    • Sleep about 500 ms before the next workspace.
  7. Collect reports in config order.
  8. Detect terminal width. Render.
  9. Exit 0 when any account is Ok or Cached. Exit 1 only when all fail.

Politeness defaults

Knob Default
per-workspace requests 2, run at once
cross-workspace concurrency 1
workspace pacing 500 ms between workspaces
request timeout 15 s
retries 3, transient only
backoff 500 ms, double per try, plus jitter
cache TTL 180 s
fetches per workspace per run 1, no loop
429 honor Retry-After, else back off; mark RateLimited

Conditional GET sends If-Modified-Since and If-None-Match when cached. A 304 reuses cache at zero bandwidth. This is the cheapest hit for us and opencode.

bleak project layout

src/
  main.rs        clap entry and dispatch
  cli.rs         clap definitions
  config.rs      TOML load, ${VAR} expansion, Account
  model.rs       Envelope, AccountReport, Limits, UsageFrame, Balance
  client.rs      reqwest client, get with retry and backoff
  fetch.rs       paced workspace walk, join Zen and Go, cache read and write
  cache.rs       XDG file store, TTL, etag and last-modified
  scrape.rs      HTML to model; the only opencode-coupled module
  render/pretty.rs  Unicode blitter-bar renderer and table
  render/raw.rs     json and plain serializers
  timefmt.rs     relative and absolute time strings
  error.rs       typed fetch and parse errors

scrape.rs holds the only opencode HTML selectors. When the page changes, swap this file. Transport, cache, and render stay untouched.

Pretty renderer

Blitter blocks fill a bar.

  • Full: .
  • Eighths: ▏▎▍▌▋▊▉.
  • Shaded: , .
  • Empty: .

Bar width comes from terminal width. Filled cells are floor(pct * W). The trailing cell uses the matching eighths block. The remainder is .

Color zones. Color is off when output is not a terminal.

  • Under 70 per cent: green.
  • 70 to 90 per cent: amber.
  • Over 90 per cent: red.

Each row shows name, used / total unit, percentage, and resets in 3h12m.

The next-update row shows 2025-08-30 17:00 UTC (in 47m).

--ascii or a non-UTF-8 locale prints # repeats.

Open items

  • Crate name: ocd.
  • Live watch mode for htop-like refresh. A later task.
  • Confirm the Zen and Go page URLs and selectors. Fill scrape.rs.
  • Confirm conditional GET support. OpenCode may not send ETag. Cache still works from TTL alone.