Monorepo root (JeffreyJYZ/cmdcode-tools): everything at this root is ONE git repo — the former cmduse repo's history, plus five packages absorbed from sibling repos (each kept its own published identity + GitHub remote; only local checkouts were merged). Root Cargo.toml = Rust workspace (core, cmduse); root package.json = bun workspace (opencode-plugin, oc-cmd-compare, reqshape, opencode-context, opencode-shell-rc, opencode-session-dir); ONE root bun.lock, no per-package lockfiles. Nested .github/workflows are inert — CI lives only in root .github/workflows. Packages share no code; coupling is via installed binaries + published plugins, never relative paths.
Current versions: cmduse-core 2.2.1 · cmd-usage 0.7.30 · @jeffreyjyz/opencode-command-code 0.5.22 · mpc 0.1.6 · reqshape 0.2.2. Per-release history lives in git log and the READMEs — do not grow a changelog in this file; record a fact here only when it still constrains the code.
Cargo.toml workspace (members: core, cmduse)
core/ cmduse-core: pure logic + canonical data, no I/O
plans.json plan table + name rules + monthly caps
gating.json model categories + per-plan gating + hard-blocked
zen.json OpenCode Go/Zen catalogue: shared $/1M rates + per-plan
(Go, Go Plus) per-model monthly limits (generated by
scripts/extract-zen.ts; consumed only by ocuse)
ark.json ARK Agent Plan tiers + AFP coefficients (hand-maintained)
conformance.json shared behaviour vectors: Rust + TS tests both assert these
build.rs reads plans.json + gating.json → NAME_RULES/CAPS/PLANS/GATE_* consts
src/lib.rs plan_name, plan_monthly_cap, money, compact, pct, elapsed_pct,
duration, rel_time, pace_eta (10% gate lives here),
monthly_window, gate/gate_allowed, bare_model + canonical_model
src/dates.rs ISO/UTC helpers (parse_iso_utc, parse_tz_parts,
civil_from_days, iso_instant, now_secs, …)
src/reports.rs usage aggregation: Usage/Totals, day+hour bucketing in a
fixed offset, cumulative-difference math, local bucket fold
src/wire.rs API wire DTOs
cmduse/ cmd-usage, published to crates.io (bins: cmduse, cmdusedev,
ocuse, ocusedev, arkuse, arkusedev)
src/lib.rs `pub fn run()` — the CLI body; main.rs + the *dev bins are
thin entry points (a dev twin avoids shadowing the installed one)
src/… arg parsing, HTTP client + retry, snapshot assembly, report
fetching, ANSI/markdown rendering, config, update check, watch loop
src/ocuse/ OpenCode Go/Zen half (catalogue, api, window, db, cli, render, mcp)
src/arkuse/ ARK Agent Plan half (api via arkcli, window, db filtered to
provider `agent-plan`, cli, render, mcp) — AFP, not dollars
src/reports.rs I/O only: session-JSONL walk + account API pool; math from core
src/stores.rs the other local clients as usage sources (pi JSONL, dsh zstd),
read by cmduse's local reports and by ocuse/arkuse
src/mcp.rs `cmduse mcp` MCP stdio server (hand-rolled JSON-RPC; tool bodies
are the same *_output helpers the subcommands call)
opencode-plugin/ @jeffreyjyz/opencode-command-code (dual opencode v1 (server())
+ v2 (setup()) entrypoints; imports ../../core/{gating,conformance}.json;
usage rendering delegates to the cmduse CLI via src/cli.ts)
src/v2.ts providers + /connect integration + live model list (static seed,
then fetch + gating, re-fetched every 30 min)
src/tui.tsx TUI half (package ./tui export), two hosts one module:
v1 `tui(api)` registers sidebar_content, v2 `setup(ctx)` claims
sidebar.content; v2 also keeps /cmd-usage (spawns cmduse client-side)
src/sidebar/rows.ts pure row builder (usage + model rows)
src/sidebar/zen.ts the Go/Zen sibling builder
src/sidebar/ark.ts the ARK sibling builder
src/sidebar/data.ts spawns: cmduse/ocuse/arkuse for usage (polled), mpc --json
for the per-model catalogue (disk-cached 1h, re-read every poll)
scripts/build-tui.ts builds dist/tui.js with @opentui/solid's transform
src/constants/ module-level DATA constants, one domain per file
- Pure logic + canonical data live in
core/; the CLI is the application. Plan table / window math / pace gate / date / aggregation bug → ONE fix.cmduse/owns I/O only: HTTP, filesystem, terminal, formatting. Presentation (ANSI constants, bars, tables) stays local. - Data lives in JSON, never in code.
core/plans.json+core/gating.jsonare baked bycore/build.rsinto consts and imported directly byopencode-plugin;zen.json+ark.jsonare consumed by the CLIs. Edit the JSON, never generated consts or TS. scripts/extract-gating.ts(bun run extract) regeneratesgating.jsonfrom the published CLI package — registry tarball first, CDNs only as fallback (unpkg 500s; jsdelivr once served a truncated cli.mjs that passed a naive sanity check). Bundled releases rename every minified helper, so the script carries a GENERATIONS table + inlines array vars, picks the first generation that matches, and fails loud when none does. It stampsextractedAt+cliVersion;cliVersionis the signal to watch, not the timestamp. Unrecognised plan ids and snapshots older thanGATE_STALE_DAYSwarn in both CLI and plugin — otherwise the dashboard silently shows "Free" with no cap.opencode-plugin/src/catalog.tsis generated (bun run extract:catalog): context, efforts,$/1M, cache-write on Anthropic models, andinputModalitiesfrom the CLI bundle. Models absent from the table are text-only and unpriced — never assume vision or invent a rate.- Plugin halves are plain objects;
@opencode/*dev-only. opencode decodes the default export against its ownPlugininterface (bothdefinehelpers are identity), so halves export{ id, setup }literals with type-only imports.dist/index.jsmust import nothing but node builtins — that keeps a fresh start from installing the plugins' ~270 MB dependency graph before the provider appears. - Module-level DATA constants live in
src/constants/, one domain per file. A value used twice, or a literal a test asserts, is imported from there. Functions andletstate stay with their code. - Picker must never wait on the live list.
loadModels' ungated "show everything" fallback swings in size with network luck, soModelSplit.gatedmarks it and v2 ignores such a list entirely (no cache write, no transform); a legitimately changed list still only warms the cache on the first pass after start. ocuse+arkusestay at feature parity withcmduse, same pace, ALWAYS — any user-facing thing one gains (subcommand, flag,--json/--csv, config key, update check, notify, statusline, validation) lands in all three in the same commit; a capability only one has is a defect, not a backlog item. Only what the source forces may differ:--local(cmduse's account-vs-logs daily),--db/--no-api/--window/--period-start/--infer-anniversary(a local store has cuts to choose, an account API hands them back),--plan(ocuse's Go plan / arkuse's tier, which their sources cannot report),--profile(arkcli). Everything else is shared, including--rows(0= all; table-only —--json/--csvalways carry every row, and a trimmed table ends… N more) and the 5s default refresh (config default_for; per-binary intervals were drift).- MCP parity is pinned on both axes.
cmduse mcp+ocuse mcp+arkuse mcpexpose the same seven tools —usage,plans,models,daily,hourly,session,model— with the same arguments per tool, asserted by a sorted-set test in each binary through the sharedmcp_tests::tool_params.modelstakesgated;daily/hourlytaketz;session/modeltakesince/days/rows, plustzon cmduse (its local store needs an offset; ocuse/arkuse rows already carry one);modelalso takesid(absent = the table).rowsis the canonical cap (limitstays an alias on ocuse/arkuse), and no cap means every row. - The machine-readable surfaces use one set of names where the concept is the same, so a consumer never branches per binary: every
-1 --jsoncarriessource, the period bounds (periodStart/periodStartAt/periodEndAtas epoch ms, plus the display dateperiodEnd) and asummaryof the period (ocuse's is an alias oftotals.month— the same object, so the keys cannot disagree).statusline --jsonusesfiveHourSpent/fiveHourCap,weeklySpent/weeklyCap,monthSpent/monthCapon all three;cmduse's month is a credit pool, so its spent half iscap − remainingclamped at zero, and its olderfiveHourUsed/weeklyUsed/monthlyRemainingremain as aliases.plans --jsonstays two shapes on purpose —cmduseprints a subscription array (mpc consumes it),ocuse/arkuseprint a model catalogue object. Reports sort by spend, biggest first, name as tiebreak (by_spend), neverBTreeMaporder. Calendar formatting iscmduse_core::dates::civil_from_dayseverywhere. ocusemeasures OpenCode Go/Zen spend fromopencode.db(providersopencode-go+opencode;command-code-openaiis a different plan and excluded by design), prices each turn itself from the documented peak/off-peak sheet (reasoningat the output rate), and takes limits/rates fromcore/zen.json. It reads thepianddshsession logs too (src/stores.rs: plain JSONL for pi, zstd for dsh,PI_SESSIONS_DIR/DSH_SESSIONS_DIRoverride), keeping only rows on those same providers, because both harnesses spend the same allowance — the header and--json'sclientsname every source read. Which Go plan is active is not discoverable, so--plan <go|go-plus>is told, defaultgo.arkusemeasures a Volcengine ARK Agent Plan in AFP, never dollars.1 AFP = ¥0.002,AFP = (input + cache_read + output) × coef / 10,000(cache-read counts;reasoningis already insideoutput, so never add it again). Two sources:arkcli usage plan --profile <name>(the plan's own 5h/daily/weekly/monthly meters — the only view of every machine and harness) and the local stores filtered to provideragent-plan—opencode.dbplus thepi/dshlogs through the samesrc/stores.rsreader (per-model attribution + a local estimate). The meter wins and the local AFP prints beside it labelledlocal; with no answer the rows fall back to the published split (10/50/35/100 % of monthly AFP) labelled(est.)with the reason. Server windows are not reproducible — 5h is a block, the week is Monday 00:00, the month is the subscription anniversary — so cut atreset − windowand print in the server offset, nevernow − window. A text-only workload has no daily window.--plan= tier,--profile= arkcli profile,--no-apiskips arkcli. Notify fires per window (ARK limits the plan, not a model).arkcliprints its error envelope on stderr — parse the message, not the first{.cmduse's local reports readpianddshtoo —model,session,daily --localandhourly --localappend their turns on acommand-code*provider (CC_PROVIDERSinreports.rs), carrying the client's owncostUsdrather than re-pricing; all three binaries sharesrc/stores.rsand apply their own provider filter.- The sidebar sections stay at parity too —
rows.ts::modelRows(Command Code),zen.ts::zenRows(Go/Zen),ark.ts(ARK): a row one shows, the others show unless the source makes it impossible, and then the reason lives in a code comment. Diff the builders whenever one changes, and give a new shared field to all sides that can carry it. Source-forced, therefore CC-only:Tier/Min plan(gating is CommandCode's) andDeal; ARK and Go carry their own plan/window rows instead of a credit pool. - License text is sourced, never copied (OSI canonical MIT). All crates/packages MIT; only the year/holder line is project-specific.
- Docs move with code. A user-visible change updates the READMEs,
cmduse/cmduse.1, and this file in the same commit — never a follow-up "docs" commit. Check for stale version refs and stale flag lists before committing.
- README = the canonical docs page — GitHub and
website/render it; never fork prose into the site. - Skeleton:
# Title→ one-sentence tagline → shields badge line (?style=flat-square) → H2 sections. Required:## What it is,## Install,## Usage|## Commands,## Links; optional## Quick start,## Configuration,## How it works,## Notes. Every fence is language-tagged (an untagged fence fails the structure test). - No hardcoded version in prose — the badge carries it. Restructuring reorganises, never deletes.
- Site derives title/description from the H1 + first paragraph and rewrites relative links to repo URLs, so changing those changes the site.
- Manifest =
website/src/content/projects.ts, pinned bywebsite/test/readmes.test.ts; snapshots viabun snapshot:mpc/bun snapshot:releasesfromwebsite/. Add npm badges only for published packages, whose npm name can differ from the dir/bin (mpc→@jeffreyjyz/mpc; unscopedmpcon npm is a foreign 2014 package). website/is a standalone Next.js project, not a workspace member — ownbun.lock+ Biome; rootbun installnever touches it.
mpc shells out to cmduse and takes its per-model mix from opencode's own store. Stable contracts, not incidental output:
| contract | consumer |
|---|---|
cmduse plans --json — plan name/price/credits/windows |
mpc plan table |
cmduse -1 --json — summary.requests/summary.cost, periodEnd |
mpc coverage line + billing window |
cmduse model --json [--since ISO] — {source, since, models:{id: totals}} |
mpc --usage |
mpc --json --shape off — {plans, rows:[{key, name, oc?, cc?}]}, each side {pricing, allowance, requestsPerMonth/PerFiveHour/PerWeek, ability, tps, deal?, free}; null/absent when unpriced; requestsPerMonth: null = unbounded, never "missing" |
sidebar model rows |
Changing any shape means updating mpc in the same effort; CMDUSE_BIN/OCUSE_BIN/ARKUSE_BIN/MPC_BIN point a consumer at a *dev build. The plugin also exposes mpc_prices to agents (--minimal when no arg, so a default call cannot flood context). Sidebar's mpc spawn keeps --shape off — mpc's default --shape auto shells out to reqshape, which runs mpc --json again, so the flag is the cycle break, not an optimisation.
cargo test && cargo fmt --all -- --check
cargo clippy --all-targets -- -D warnings
cargo package -p cmduse-core --allow-dirty # core ships plans.json+gating.json
printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | cargo run -p cmd-usage -- mcp
cd opencode-plugin && bun test && bun check && bun fmt --check && bunx biome lintTypeScript packages: bun check for types (never tsc --noEmit), bun fmt for formatting, Biome for linting. Formatting is Bun's own formatter, configured once in the repo-root bunfig.toml [fmt] (tabs, tabWidth 4, printWidth 100 — Bun walks up, so every package and website/ inherit it) and .prettierignore skips vendored output — never add Prettier or biome format. Never name a package script fmt or check: Bun prefers the script over its own builtin, so bun fmt --check starts writing and bun check stops type-checking. bun fmt / bun lint write; the gate is bun fmt --check + bunx biome lint. typescript stays a devDependency only where a build shells tsc -p tsconfig.build.json for declarations (the four plugin packages); everything else type-checks through Bun.
bun lint is canary-only as of 1.4.4 (docs pages exist, the binary has no such command). When it ships in a stable Bun, linting moves there: [lint] rules in the root bunfig.toml, and the lint script is renamed or dropped because it would shadow the builtin — the same trap as fmt and check. Biome's biome.json files and the @biomejs/biome devDependency come out with it.
Tests must be hermetic: CI has no cmduse binary and no homebrew prefix, so anything shelling out needs a skip guard or an injected candidate list. Bun module mocks (mock.module) are process-wide and leak into other files — never mock a module another test relies on; drive the real seam instead (e.g. CMD_API_KEY).
Every TS package pins typescript: ^7 (the native port) — never ^5.
- Versions are independent:
cmduse-coreruns its own major line, the CLI is 0.7.x. Never read one from the other. Core's semver stays loose on purpose (an internal breaking change rides a MINOR); core's major always leads the CLI's so the two never look like a pair. crates.io slots are permanent — 0.2–0.4 and 0.6.x are burned; each release takes one patch above the published version, never a reuse. - Publish order: core first when it changed (
cargo package -p cmd-usageverifies against the published core), then the CLI. Clean tree (Cargo.lock committed). - Releases ship prebuilt binaries (tag
cmduse-vX.Y.Z, workflowrelease.yml): six targets,cmduse-<ver>-<target>.tar.gzwith binaries at the archive root +cmduse.1+ licence, plusSHA256SUMS. The staged binary list is defined once (a job-levelBINSenv the artifactcploop and theinstall -mline both read) — a new bin needs one edit, not two. - Homebrew after every release:
scripts/bump-tap.sh cmduse-vX.Y.Zmatches pins by target triple off the release's ownSHA256SUMSand aborts without writing when a pin has no sha. The tag carries av, asset names do not. Formula installs all six binaries (bin.install "cmduse", "ocuse", "arkuse", "cmdusedev", "ocusedev", "arkusedev"). - Verify with the scripts, never by eye:
opencode-plugin/scripts/verify-crate.ts <version>(crate ⇄ release assets ⇄ tap pins) andverify-release.ts <version>for npm (--sourcefor a CI-published package,--dir <path>for another package). Running them from the repo root fails — the scripts live inopencode-plugin/scripts/. - Publish every npm package by tag, not from a terminal — one tag names one package:
plugin-v*opencode-plugin,shell-rc-v*opencode-shell-rc,context-v*opencode-context,session-dir-v*opencode-session-dir,mpc-v*oc-cmd-compare,reqshape-v*reqshape. Bump that package'spackage.json(PATCH for incremental work, MINOR for a line-level change; each is versioned independently of the Rust crates), commit,git tag <prefix>X.Y.Z && git push origin <tag>.npm-plugin.ymlresolves the tag to a directory, runs that package's test/typecheck/build, refuses a tag that disagrees withpackage.json, publishes through npm OIDC trusted publishing (no 2FA prompt, no long-lived token), then polls until the tarball is served. Each package needs its own trusted-publisher entry on npmjs.com (owner, repo, bare workflow filenamenpm-plugin.yml) — one workflow for all six is what keeps that filename identical everywhere. The manifest'srepositorymust name this repo or npm rejects provenance; stage/approve (npm stage publish→npm stage approve) is the fallback, never barenpm publish: a202there can leave a ghost version that packument andnpm stage listboth deny. Rebuild after every bump —dist/embeds the version string. - Wait for the tag's own run.
gh run list --limit 1returns whatever started last; select byheadBranch == <tag>before bumping the tap, or a chained script trusts the previous tag's success.
is_peak's weekend test: the epoch weekday is Sunday=0, so the weekend test isweekday == 0 || weekday == 6(a>= 5test excludes Friday + Saturday and misprices a whole weekday). Pinned by a Friday/Sunday test inocuse/zen.rs.- Percentage rules. One decimal, multiply-rounded (
(x*10).round()/10), never{:.1}alone — that half-evens and drifts from the TS port. A percentage the server supplied prints verbatim (53, never53.0).moneyuses explicit multiply-round too (0.125and2.675disagree between half-even and half-away).pace_etareturns seconds — format withcore::duration, neverrel_time. - Time arithmetic.
--tzoffsets are east-positive seconds; all day/hour bucketing usesnow + tz. Local hour bounds go throughdates::iso_instant, never floored to a UTC hour (minute-bearing offsets break).parse_iso_utcreturns milliseconds — never multiply it by 1000.recent_daysmust stay oldest-first (the cumulative difference needs ascending labels). - Every renderer takes the colour flag, and plain output is byte-identical by construction; report output additionally goes through
render::color_enabled()(NO_COLOR + tty). A renderer that leaks escapes when colour is off is a bug —every_renderer_respects_the_colour_flagasserts it per renderer inocuse/render.rs. --jsonmust leave the watch loop (wants_live_watchexcludes--json/--once/-1), and every format flag must actually thread itsFmt—daily/hourlyonce printed tables for--json/--csv.- Report dispatch: skip flag values when locating the subcommand (
--window all dailymust not readallas the command). - Watch-frame redraw: the frame's last line has no trailing newline, and a shrinking frame clears with the documented byte sequence (
\x1b[1B+\x1b[2K\x1b[1B+\x1b[2K+\x1b[{prev-n}F), tested throughmain_tests.rs. The update notice is drawn inside the frame, nevereprintlnmid-frame. - MCP stdio: stdout is protocol-only, notifications get no response, tool errors are
isError: trueresults, and tool text goes through the result envelope. - opencode v2 stores messages in
session_message, notmessage— amessage-only reader reports zero for every recent session. Reads carrytype = 'assistant'anddata.model.{id,providerID}; an in-flight turn has no tokens yet. - The plugin loads once per session attach, so module-level caches are shared across live sessions — key them (account snapshot, catalog, per-model usage) rather than assuming "this panel". Polling is skipped while one is in flight, and a spawn is killed at
CHILD_TIMEOUT_MS. - An outage must not blank the panel.
cmduse -1 --jsonexits 0 with defaults and anerrorstring when the API is unreachable, so the JSON consumer must lifterrorand keep the last good snapshot. Keep a seeded last-good for the account block, the per-model figure (keyed by model and session) and the mpc catalogue — a blank model block with only local rows means mpc, not the network. A transport fault is not a reason to paint anything:rows.ts::actionableAccountErrorgates theusage API: …row to reasons the user can fix (missing/expired key, missing CLI/profile, HTTP 401/403), anduseRows.ts::keepAccountcarries the previous meters through a non-actionable failure — otherwise one dropped request blinks the whole meter block (and prints its URL) for one 5s poll, which reads as the sidebar flickering. - Sidebar styling: theme tokens are
text.base/text.muted(nottext.default/text.subdued); spans need<span style={{ fg }}>(<span fg={…}>is silently ignored); colour is opt-in ({"colors": true},CMD_COLORSoverride) andmono()must stay achromatic. Read the mpc catalogue'sfreeflag, notallowance— an unlimited model reads∞, never$0/mo. - A stale
target/after a repo move carries a build script with the oldCARGO_MANIFEST_DIR, which fails "read plans.json" pointing at the old path —cargo clean -p cmduse-core. - A report is a claim, not evidence, and so is a brief: verify load-bearing claims against the diff or a re-run.