Skip to content

Latest commit

 

History

History
151 lines (129 loc) · 28.6 KB

AGENTS.md

File metadata and controls

151 lines (129 loc) · 28.6 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.32 · @jeffreyjyz/opencode-command-code 0.5.30 · mpc 0.1.10 · reqshape 0.2.3. 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.
  • The static seed carries the catalog context window, never a fallback. staticSeedModels passes modelContext(id) into toV2Model, because contextLength: 0 becomes DEFAULT_CONTEXT_LENGTH (128k) and the host compacts a 1M model at a fifth of its window until the live list lands; a catalogued 0 still means unknown, so the fallback stays.
  • Prices, context and efforts are fetched at runtime; only vision stays baked — src/catalogLive.ts pulls Command Code own models.md from the npm CDN (jsdelivr, unpkg fallback), caches it a day at ~/.cache/command-code/catalog-live.json, and overlays it before each model refresh; the generated table is the offline seed, never the only source.
  • 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.
  • The four report envelopes carry the same keys on all three binaries — daily/hourly emit {scope, source, total, days|hours}, model emits {scope, source, since, sinceMs, models}, session carries both sessions and projects with identical rows (cmduse spelled it projects first); source names where the rows came from (command-code, opencode, or local), so a consumer never branches on binary. REPORT_*_KEYS in report_render.rs are the single source, and each binary's report_json_envelopes_are_the_shared_contract test asserts its own emitter against them — dropping a key from either side fails all three tests (verified by mutation on 2026-11-13).
  • 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).
  • The cmduse-v* tag does NOT publish the crate — release.yml builds the six assets and stops, so cargo publish -p cmd-usage is a separate step and verify-crate.ts reports absent from the index until it runs; measured 2026-11-05, where a green release run read as a finished release.
  • 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/.
  • A plugin tarball that hashes differently from the local pack is usually bun's bundle comments, not a bad publish — bun build writes // <module path> per inlined module, and the path differs between a workspace install and CI's npm one; the build now strips them (scripts/strip-bundle-comments.ts), so diff the bundles before treating it as a fault (2026-11-06).
  • 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. Same for any consumer pin: dist-tags answering the new version is not the verdict — flipping the plugin pin while the run was in_progress left an empty 64 B ~/.cache/opencode/npm/<pkg>@<ver>/ the loader treats as installed, so delete that dir after the publish lands rather than pinning early (2026-11-08).

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.
  • A 401 on all three billing endpoints is a stale key, not a dead endpoint — /connect writes the live key to opencode.db's credential table and leaves ~/.commandcode/auth.json on the superseded one, so credentials::candidates offers store rows first (active, then newest) and api::get skips a 401 for the next candidate; measured 2026-11-05 with three keys on disk and one live.
  • A command-code-* lane answering 401 while provider.settings.apiKey is live means the integration credential ROW is stale, not the provider — opencode resolves the command-code credential row (one account can leave three: a dead command-code, a dead inactive commandcode, a live active commandcode), and the raw Invalid 'Authorization' header or token in the transcript proves our own transport never saw the request, so no failover in the plugin can save it; repair the row, then cmd login the CLI (2026-11-06).
  • 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.
  • bun run extract:catalog rewrites all of src/catalog.ts, so a hand-added helper dies there — accessors such as modelContext() live in the generator template, and a modality the bundle grows that the host cannot declare (file, 1.88.5) is filtered by KNOWN_MODALITIES rather than added to the union.