# 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 opencode app is React Router v7 with streaming SSR. The route loader data is inlined in the HTML as JS resource objects. There is no separate JSON HTTP endpoint. `ocd` parses these embedded records first — they give an exact integer `resetInSec` and a `status` field — and falls back to the rendered `data-slot` DOM when the embedded path finds nothing. `ocd` stores the percentage and computes an absolute reset time from `resetInSec`. The Zen page reports remaining balance the same way. `ocd` prints all of it. ## 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 does not auto-discover the default workspace. That path is not reliable. The user supplies the id. Workspace ids look like `wrk_01KYNTSGXF7GFXMAMT36N3GH21`. A future `ocd workspace list` command can add discovery later. ## Two pages per workspace Each workspace needs two HTTP requests. Each request puts the workspace id in the path. 1. The Zen page is `https://opencode.ai/workspace/{id}`. It returns balance. 2. The Go page is `https://opencode.ai/workspace/{id}/go`. It 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 Go page gives a relative reset string such as "Resets in 30 days 7 hours". The scrape step parses that into seconds and adds it to the fetch time. The result is an absolute `resets_at` datetime per frame. This is how a widget gets a sortable time even though the page only states a duration. ## Config format ```toml # ~/.config/ocd/config.toml default_concurrency = 1 default_ttl_secs = 180 [[account]] name = "work" cookie = "${OCD_COOKIE_WORK}" default_workspace = "wrk_01KYNTSGXF7GFXMAMT36N3GH21" extra_workspaces = ["wrk_02KYNTSGXF7GFXMAMT36N3GH21"] [[account]] name = "personal" cookie = "ocd_session=...." default_workspace = "wrk_03KYNTSGXF7GFXMAMT36N3GH21" ``` 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 health probe each auth cookie; report dead cookies and a count 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 ```rust struct Envelope { schema_version: u32, // 1 generated_at: String, // ISO 8601 UTC accounts: Vec, } struct AccountReport { account: String, // config name workspace: String, // workspace id fetched_at: Option, // ISO 8601 UTC status: ReportStatus, // Ok | Cached | RateLimited | Timeout | Error(msg) limits: Option, balance: Option, } struct Limits { usage_5h: UsageFrame, usage_week: UsageFrame, usage_month: UsageFrame, next_update: Option, } struct UsageFrame { used: f64, total: f64, unit: String, // "credits" | "requests" | "$" resets_at: Option, } 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. - A dead auth cookie sends 302 to `/auth/authorize` (or 401/403). The HTTP client does not follow redirects, so this surfaces as the `CookieDead` status, not a parse error. The run continues with the valid accounts and reports the dead ones in the output. - 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 percentage and `resets in 3h12m`. The opencode Go page exposes only a percentage per frame, not an absolute count, so the bar is the percentage. The next-update row shows `2025-08-30 17:00 UTC (in 47m)` when the page ever reports one. Today the opencode page has no such banner, so the field stays empty in the model. `--ascii` or a non-UTF-8 locale prints `#` repeats. ## Cookie health `ocd health` probes each account's auth cookie against that account's default workspace Zen page. A live cookie gets HTTP 200. A dead or logged-out cookie gets HTTP 302 to `/auth/authorize` (or 401/403). The probe sends one request per account, paced by `--pace-ms`. The summary counts `alive`, `dead`, `errored`, and `misconfigured` (empty cookie or workspace still a placeholder). Exit is 0 only when every account is alive. The main walk (`pretty`, `raw`, `fetch`) also detects a dead cookie and labels the report `CookieDead`. It does not abort the run; it continues with the valid accounts and only reports the dead ones in the output. ## Open items - Crate name: `ocd`. - Live watch mode for `htop`-like refresh. A later task. - Conditional GET: `get_text` sends `If-None-Match` and `If-Modified-Since` from cached validators and handles a 304 by reusing the cached parse. OpenCode (via Cloudflare) currently emits no `ETag` or `Last-Modified` on either page, so the slots stay empty and no headers go out. The machinery is idle but correct; it works the day OpenCode adds validators. - Extra billing fields: the Zen page embedded record also streams `reloadAmount`, `reloadTrigger`, `subscriptionPlan`, `monthlyLimit`, and `monthlyUsage`. These do not appear in the rendered DOM. A future `ocd billing` command could expose auto-reload config. Store them by extending the model; keep the scrape module the only place that touches this.