Skip to content

Latest commit

 

History

History
143 lines (121 loc) · 25.1 KB

AGENTS.md

File metadata and controls

143 lines (121 loc) · 25.1 KB

AGENTS.md

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.

Layout

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

Core rules

  • 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.json are baked by core/build.rs into consts and imported directly by opencode-plugin; zen.json + ark.json are consumed by the CLIs. Edit the JSON, never generated consts or TS.
  • scripts/extract-gating.ts (bun run extract) regenerates gating.json from 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 stamps extractedAt + cliVersion; cliVersion is the signal to watch, not the timestamp. Unrecognised plan ids and snapshots older than GATE_STALE_DAYS warn in both CLI and plugin — otherwise the dashboard silently shows "Free" with no cap.
  • opencode-plugin/src/catalog.ts is generated (bun run extract:catalog): context, efforts, $/1M, cache-write on Anthropic models, and inputModalities from 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 own Plugin interface (both define helpers are identity), so halves export { id, setup } literals with type-only imports. dist/index.js must 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 and let state stay with their code.
  • Picker must never wait on the live list. loadModels' ungated "show everything" fallback swings in size with network luck, so ModelSplit.gated marks 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 + arkuse stay at feature parity with cmduse, 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/--csv always 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 mcp expose 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 shared mcp_tests::tool_params. models takes gated; daily/hourly take tz; session/model take since/days/rows, plus tz on cmduse (its local store needs an offset; ocuse/arkuse rows already carry one); model also takes id (absent = the table). rows is the canonical cap (limit stays 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 --json carries source, the period bounds (periodStart/periodStartAt/periodEndAt as epoch ms, plus the display date periodEnd) and a summary of the period (ocuse's is an alias of totals.month — the same object, so the keys cannot disagree). statusline --json uses fiveHourSpent/fiveHourCap, weeklySpent/weeklyCap, monthSpent/monthCap on all three; cmduse's month is a credit pool, so its spent half is cap − remaining clamped at zero, and its older fiveHourUsed/weeklyUsed/monthlyRemaining remain as aliases. plans --json stays two shapes on purpose — cmduse prints a subscription array (mpc consumes it), ocuse/arkuse print a model catalogue object. Reports sort by spend, biggest first, name as tiebreak (by_spend), never BTreeMap order. Calendar formatting is cmduse_core::dates::civil_from_days everywhere.
  • ocuse measures OpenCode Go/Zen spend from opencode.db (providers opencode-go + opencode; command-code-openai is a different plan and excluded by design), prices each turn itself from the documented peak/off-peak sheet (reasoning at the output rate), and takes limits/rates from core/zen.json. It reads the pi and dsh session logs too (src/stores.rs: plain JSONL for pi, zstd for dsh, PI_SESSIONS_DIR/DSH_SESSIONS_DIR override), keeping only rows on those same providers, because both harnesses spend the same allowance — the header and --json's clients name every source read. Which Go plan is active is not discoverable, so --plan <go|go-plus> is told, default go.
  • arkuse measures a Volcengine ARK Agent Plan in AFP, never dollars. 1 AFP = ¥0.002, AFP = (input + cache_read + output) × coef / 10,000 (cache-read counts; reasoning is already inside output, 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 provider agent-plan — opencode.db plus the pi/dsh logs through the same src/stores.rs reader (per-model attribution + a local estimate). The meter wins and the local AFP prints beside it labelled local; 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 at reset − window and print in the server offset, never now − window. A text-only workload has no daily window. --plan = tier, --profile = arkcli profile, --no-api skips arkcli. Notify fires per window (ARK limits the plan, not a model). arkcli prints its error envelope on stderr — parse the message, not the first {.
  • cmduse's local reports read pi and dsh too — model, session, daily --local and hourly --local append their turns on a command-code* provider (CC_PROVIDERS in reports.rs), carrying the client's own costUsd rather than re-pricing; all three binaries share src/stores.rs and 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) and Deal; 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.

Documentation conventions

  • 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 by website/test/readmes.test.ts; snapshots via bun snapshot:mpc / bun snapshot:releases from website/. Add npm badges only for published packages, whose npm name can differ from the dir/bin (mpc → @jeffreyjyz/mpc; unscoped mpc on npm is a foreign 2014 package).
  • website/ is a standalone Next.js project, not a workspace member — own bun.lock + Biome; root bun install never touches it.

Consumers (in-repo oc-cmd-compare)

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.

Build & test

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 lint

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

Publishing (NEVER without explicit user go)

  • Versions are independent: cmduse-core runs 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-usage verifies against the published core), then the CLI. Clean tree (Cargo.lock committed).
  • Releases ship prebuilt binaries (tag cmduse-vX.Y.Z, workflow release.yml): six targets, cmduse-<ver>-<target>.tar.gz with binaries at the archive root + cmduse.1 + licence, plus SHA256SUMS. The staged binary list is defined once (a job-level BINS env the artifact cp loop and the install -m line both read) — a new bin needs one edit, not two.
  • Homebrew after every release: scripts/bump-tap.sh cmduse-vX.Y.Z matches pins by target triple off the release's own SHA256SUMS and aborts without writing when a pin has no sha. The tag carries a v, 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) and verify-release.ts <version> for npm (--source for a CI-published package, --dir <path> for another package). Running them from the repo root fails — the scripts live in opencode-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's package.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.yml resolves the tag to a directory, runs that package's test/typecheck/build, refuses a tag that disagrees with package.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 filename npm-plugin.yml) — one workflow for all six is what keeps that filename identical everywhere. The manifest's repository must name this repo or npm rejects provenance; stage/approve (npm stage publish → npm stage approve) is the fallback, never bare npm publish: a 202 there can leave a ghost version that packument and npm stage list both deny. Rebuild after every bump — dist/ embeds the version string.
  • Wait for the tag's own run. gh run list --limit 1 returns whatever started last; select by headBranch == <tag> before bumping the tap, or a chained script trusts the previous tag's success.

Known traps

  • is_peak's weekend test: the epoch weekday is Sunday=0, so the weekend test is weekday == 0 || weekday == 6 (a >= 5 test excludes Friday + Saturday and misprices a whole weekday). Pinned by a Friday/Sunday test in ocuse/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, never 53.0). money uses explicit multiply-round too (0.125 and 2.675 disagree between half-even and half-away). pace_eta returns seconds — format with core::duration, never rel_time.
  • Time arithmetic. --tz offsets are east-positive seconds; all day/hour bucketing uses now + tz. Local hour bounds go through dates::iso_instant, never floored to a UTC hour (minute-bearing offsets break). parse_iso_utc returns milliseconds — never multiply it by 1000. recent_days must 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_flag asserts it per renderer in ocuse/render.rs.
  • --json must leave the watch loop (wants_live_watch excludes --json/--once/-1), and every format flag must actually thread its Fmt — daily/hourly once printed tables for --json/--csv.
  • Report dispatch: skip flag values when locating the subcommand (--window all daily must not read all as 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 through main_tests.rs. The update notice is drawn inside the frame, never eprintln mid-frame.
  • MCP stdio: stdout is protocol-only, notifications get no response, tool errors are isError: true results, and tool text goes through the result envelope.
  • opencode v2 stores messages in session_message, not message — a message-only reader reports zero for every recent session. Reads carry type = 'assistant' and data.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 --json exits 0 with defaults and an error string when the API is unreachable, so the JSON consumer must lift error and 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::actionableAccountError gates the usage API: … row to reasons the user can fix (missing/expired key, missing CLI/profile, HTTP 401/403), and useRows.ts::keepAccount carries 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 (not text.default/text.subdued); spans need <span style={{ fg }}> (<span fg={…}> is silently ignored); colour is opt-in ({"colors": true}, CMD_COLORS override) and mono() must stay achromatic. Read the mpc catalogue's free flag, not allowance — an unlimited model reads ∞, never $0/mo.
  • A stale target/ after a repo move carries a build script with the old CARGO_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.