@jeffreyjyz/opencode-command-code 0.5.18 → 0.5.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,84 @@
1
+ # @jeffreyjyz/opencode-command-code
2
+
3
+ Command Code providers, a live gated model list and a usage sidebar for opencode, with all usage rendering delegated to the `cmduse` CLI.
4
+
5
+ [![npm](https://img.shields.io/npm/v/@jeffreyjyz/opencode-command-code?style=flat-square)](https://www.npmjs.com/package/@jeffreyjyz/opencode-command-code) [![CI](https://img.shields.io/github/actions/workflow/status/JeffreyJYZ/cmdcode-tools/ci.yml?branch=main&style=flat-square&label=CI)](https://github.com/JeffreyJYZ/cmdcode-tools/actions/workflows/ci.yml) [![license](https://img.shields.io/badge/license-MIT-blue?style=flat-square)](LICENSE)
6
+
7
+ ## What it is
8
+
9
+ One package, both opencode plugin APIs: v1 hosts call the default export's `server()` hook map, v2 hosts decode `{ id, setup }` and call `setup(context)`. The two halves are independent implementations sharing one CLI layer, and neither reimplements usage math — every window figure comes from the `cmduse` binaries.
10
+
11
+ It registers two provider lanes against `api.commandcode.ai`:
12
+
13
+ - `command-code-anthropic` — Claude models on the Anthropic wire.
14
+ - `command-code-openai` — everything else on the OpenAI-compatible wire, where the reasoning text is round-tripped through `reasoning_content` so a compaction or model switch doesn't get rejected.
15
+
16
+ The model list is fetched live from Command Code's listing API, split by wire, and gated to your plan (`core/gating.json` in the repo is the source of truth). The picker never waits on the network: a static seed from the known-model table, then the last live list cached on disk, paint instantly and the live fetch merges over them — union by model id, so a gated or partial response can never make a model vanish. On v2 hosts the fetch then repeats on an interval, and a tick only touches the provider registry when the model ids actually changed.
17
+
18
+ Each model is registered with Command Code's published $/1M rates, so opencode's own cost display works for a subscription provider instead of showing $0. Vision support and reasoning-effort variants come from a generated catalog, because the listing API publishes no capabilities.
19
+
20
+ Credentials resolve in one order everywhere: `CMD_API_KEY`, then the key `/connect` stored in opencode's credential store, then `~/.commandcode/auth.json` from `cmd login`.
21
+
22
+ ## Install
23
+
24
+ Add the plugin to `opencode.json` (v2 hosts use `plugins`; v1's singular `plugin` is auto-normalized):
25
+
26
+ ```json
27
+ {
28
+ "plugins": ["@jeffreyjyz/opencode-command-code"]
29
+ }
30
+ ```
31
+
32
+ Usage rendering needs the `cmduse` CLI on `PATH` — it owns the plan table, the window math and the formatting:
33
+
34
+ ```sh
35
+ brew install JeffreyJYZ/tap/cmduse
36
+ ```
37
+
38
+ The sidebar's per-model catalog (tier, rates, allowance, deals) comes from `mpc`, which is optional: without it the section keeps the model name and the store-backed rows and simply omits the catalog rows.
39
+
40
+ ```sh
41
+ bun add -g @jeffreyjyz/mpc
42
+ ```
43
+
44
+ ## Usage
45
+
46
+ Run `/connect`, choose **Command Code** and paste an API key (create one at commandcode.ai/settings/keys), or use the `CMD_API_KEY` env method, or log in once with `cmd login`. With no credential at all the providers register as `auto`, so a later `/connect` lights them up without a restart.
47
+
48
+ One caveat on the v1 half: its `/connect` entry is bound to the Anthropic lane (opencode's plugin API allows one auth hook per provider id), so the OpenAI lane shares the key through the resolved `options.apiKey` — a `/connect`-only v1 setup needs `cmd login` or `CMD_API_KEY` for that lane.
49
+
50
+ While a session uses a Command Code, OpenCode Go/Zen or Volcengine ARK model, the sidebar grows one section for that family — plain text unless you ask for colour. Write `~/.config/opencode/command-code.json`:
51
+
52
+ ```json
53
+ { "colors": true, "ocPlan": "go-plus" }
54
+ ```
55
+
56
+ `CMD_COLORS=1` (or `0`) and `CMD_OC_PLAN=go|go-plus` override it for a single run. `ocPlan` only matters on the Go side: the two Go plans share token prices but grant different per-model limits and nothing local records which one you have. The file is re-read every few seconds, so a flip lands on the next poll.
57
+
58
+ What the section shows:
59
+
60
+ - plan, price, monthly credits used, and the 5-hour / weekly windows with used / cap, percent used, elapsed share, reset countdown and a `cap in …` pace estimate; an over-cap window is flagged `LIMIT EXCEEDED`
61
+ - this period's requests and spend, the active model's tier, allowance and $/M rates, its `Usage (this model)` share, any running `Deal`, and this session's own totals
62
+ - on OpenCode Go/Zen and ARK sessions, the same shape from `ocuse` and `arkuse` instead — account meters where the provider publishes them, per-model spend from opencode's own store
63
+
64
+ Usage comes from `cmduse` polled every 5 seconds, read from opencode's message store for the per-model and per-session rows, and cached from `mpc` for the catalog. Every block remembers its last reading, so a remount, a session switch or a network outage repaints instead of blanking; the model figure is keyed by model and session, so switching models never shows the previous model's numbers.
65
+
66
+ ## Commands
67
+
68
+ Each command passes its arguments through to the CLI that owns it, and each has an agent-facing tool of the same name, so the model can fetch the same text without you typing a slash command.
69
+
70
+ | Command | Tool | Spawns |
71
+ |---|---|---|
72
+ | `/cmd-usage` | `cmd_usage` | `cmduse` — plan, credits, windows, billing period; `plans` for the plan table |
73
+ | `/ocuse-usage` | `ocuse_usage` | `ocuse` — OpenCode Go/Zen meters and per-model spend; empty args mean `-1` |
74
+ | `/arkuse-usage` | `arkuse_usage` | `arkuse` — Volcengine ARK Agent Plan meters in AFP; empty args mean `-1` |
75
+ | — | `mpc_prices` | `mpc` — per-model price comparison across Command Code and OpenCode Go plans |
76
+
77
+ A bare `ocuse` or `arkuse` is a live watch loop that would never return to the dialog, so an empty argument means the one-shot dashboard.
78
+
79
+ ## Links
80
+
81
+ - [Source (GitHub)](https://github.com/JeffreyJYZ/cmdcode-tools/tree/main/opencode-plugin)
82
+ - [npm](https://www.npmjs.com/package/@jeffreyjyz/opencode-command-code)
83
+ - [`cmduse` CLI](https://github.com/JeffreyJYZ/cmdcode-tools/tree/main/cmduse) — the usage renderer this plugin spawns
84
+ - [MIT license](LICENSE)
package/dist/index.js CHANGED
@@ -2414,7 +2414,7 @@ function superRefine(fn) {
2414
2414
  // package.json
2415
2415
  var package_default = {
2416
2416
  name: "@jeffreyjyz/opencode-command-code",
2417
- version: "0.5.18",
2417
+ version: "0.5.20",
2418
2418
  description: "Command Code provider + usage dashboard for opencode: two providers (Anthropic wire for Claude, OpenAI wire for everything else) against api.commandcode.ai, live model list, plan gating, /cmd-usage. Requires the cmduse CLI for usage rendering.",
2419
2419
  type: "module",
2420
2420
  main: "dist/index.js",
@@ -22,6 +22,15 @@ export interface ArkModel {
22
22
  weekly: ArkTotals;
23
23
  month: ArkTotals;
24
24
  }
25
+ /** This store's own view of one window (`arkuse`'s `windows` block): AFP against
26
+ * the plan's published quota, the percentage that yields, and the boundary the
27
+ * row counts down to. Present even when no meter answered, which is what keeps
28
+ * a panel row from degrading to AFP alone. */
29
+ export interface ArkWindow {
30
+ quota?: number;
31
+ percent?: number;
32
+ resetAt?: number;
33
+ }
25
34
  export interface ArkUsage {
26
35
  plan: string;
27
36
  profile?: string;
@@ -34,6 +43,11 @@ export interface ArkUsage {
34
43
  weekly: ArkTotals;
35
44
  month: ArkTotals;
36
45
  daily?: ArkTotals;
46
+ windows?: {
47
+ fiveHour?: ArkWindow;
48
+ weekly?: ArkWindow;
49
+ monthly?: ArkWindow;
50
+ };
37
51
  account?: {
38
52
  fiveHour: ArkMeter;
39
53
  daily?: ArkMeter;
@@ -30,6 +30,17 @@ export declare function pctTone(pct: number): Tone;
30
30
  export declare function separator(): SidebarRow;
31
31
  /** Truncate to `width`, ellipsis included. */
32
32
  export declare function clip(text: string, width?: number): string;
33
+ /**
34
+ * Whether a usage-API reason is worth a row in the panel.
35
+ *
36
+ * Only a reason that names something the user can fix is: a missing or expired
37
+ * key, a missing CLI/profile, a rejected request. Everything else — a dropped
38
+ * connection, a DNS blip, a timeout — is transport, heals on the next poll, and
39
+ * used to make an `usage API: …` line pop in and out of the sidebar every few
40
+ * seconds. The pattern list is a whitelist on purpose: a new transport wording
41
+ * must stay quiet by default.
42
+ */
43
+ export declare function actionableAccountError(reason: string | undefined): boolean;
33
44
  /** Value budget for a labelled row: the panel draws `label: value`. */
34
45
  export declare function valueWidth(label: string): number;
35
46
  /** How the panel splits a row: a muted label (or banner) and a toned value. */
@@ -45,4 +45,19 @@ export declare function seededZen(): ZenUsage | undefined;
45
45
  export declare function rememberArk(usage: ArkUsage): void;
46
46
  /** The remembered ARK snapshot, or undefined on the first panel. */
47
47
  export declare function seededArk(): ArkUsage | undefined;
48
+ /** The account half both panels share. */
49
+ export type Accounted = {
50
+ account?: unknown;
51
+ accountError?: string;
52
+ };
53
+ /**
54
+ * Keep the previous account meters when this poll only failed on the wire.
55
+ *
56
+ * ocuse/arkuse answer with `accountError` and no `account` for *any* failure, so
57
+ * one dropped request blinked the whole meter block to the local estimate and
58
+ * back 5s later. Only a reason the user can act on may drop the old numbers;
59
+ * a transport fault keeps them, unlabelled, because the next poll restores them
60
+ * anyway.
61
+ */
62
+ export declare function keepAccount<T extends Accounted>(next: T, last: Accounted | undefined): T;
48
63
  export declare function useRows(activeModelId: () => string | undefined, providerID: () => string | undefined, sessionID: () => string | undefined): import("solid-js").Accessor<SidebarRow[]>;
package/dist/tui.js CHANGED
@@ -675,6 +675,11 @@ function separator() {
675
675
  function clip(text, width = ROW_WIDTH) {
676
676
  return text.length <= width ? text : `${text.slice(0, width - 1)}\u2026`;
677
677
  }
678
+ function actionableAccountError(reason) {
679
+ if (!reason)
680
+ return false;
681
+ return /api_key_expired|no Go key|no agent-plan|not found on PATH|HTTP 40[13]|invalid.*key|unauthori[sz]ed/i.test(reason);
682
+ }
678
683
  function valueWidth(label) {
679
684
  return ROW_WIDTH - label.length - 2;
680
685
  }
@@ -2146,6 +2151,16 @@ function meter(value) {
2146
2151
  resetAt: typeof v.resetAt === "number" ? v.resetAt : undefined
2147
2152
  };
2148
2153
  }
2154
+ function storeWindow(value) {
2155
+ if (!value || typeof value !== "object")
2156
+ return;
2157
+ const v = value;
2158
+ return {
2159
+ quota: typeof v.quota === "number" ? v.quota : undefined,
2160
+ percent: typeof v.percent === "number" ? v.percent : undefined,
2161
+ resetAt: typeof v.resetAt === "number" ? v.resetAt : undefined
2162
+ };
2163
+ }
2149
2164
  function parseArkJson(text) {
2150
2165
  let body;
2151
2166
  try {
@@ -2167,6 +2182,7 @@ function parseArkJson(text) {
2167
2182
  });
2168
2183
  }
2169
2184
  const summary = body.summary ?? {};
2185
+ const windows = body.windows ?? {};
2170
2186
  return {
2171
2187
  plan: typeof body.plan === "string" ? body.plan : "medium",
2172
2188
  profile: typeof body.profile === "string" ? body.profile : undefined,
@@ -2180,6 +2196,11 @@ function parseArkJson(text) {
2180
2196
  weekly: totals(body.weekly),
2181
2197
  month: totals(body.monthly),
2182
2198
  daily: body.daily ? totals(body.daily) : undefined,
2199
+ windows: body.windows ? {
2200
+ fiveHour: storeWindow(windows.fiveHour),
2201
+ weekly: storeWindow(windows.weekly),
2202
+ monthly: storeWindow(windows.monthly)
2203
+ } : undefined,
2183
2204
  account: body.account ? {
2184
2205
  fiveHour: meter(account.fiveHour),
2185
2206
  daily: account.daily ? meter(account.daily) : undefined,
@@ -2233,16 +2254,28 @@ function meterRows(label, meter, local, now, durSecs) {
2233
2254
  exceeded: pct >= 100 || meter.status !== "ok"
2234
2255
  }, now, durSecs, `${afp(local.afp)} local${local.requests > 0 ? ` \xB7 ${count(local.requests)} req` : ""}`, (used, cap) => `${brief(used)}/${brief(cap)} (${pctText}%)`);
2235
2256
  }
2236
- function localRows(label, totals) {
2237
- const detail = totals.requests > 0 ? ` \xB7 ${count(totals.requests)} req` : "";
2238
- return [[label, `${afp(totals.afp)}${detail}`, "base"]];
2257
+ function localRows(label, totals, window, now, durSecs) {
2258
+ const quota = window?.quota;
2259
+ const percent = window?.percent;
2260
+ if (typeof quota !== "number" || quota <= 0 || typeof percent !== "number") {
2261
+ const detail = totals.requests > 0 ? ` \xB7 ${count(totals.requests)} req` : "";
2262
+ return [[label, `${afp(totals.afp)}${detail}`, "base"]];
2263
+ }
2264
+ const pctText = percent.toFixed(1);
2265
+ return windowRows(label, {
2266
+ cap: quota,
2267
+ used: totals.afp,
2268
+ resetAt: window?.resetAt,
2269
+ sharePct: percent,
2270
+ exceeded: percent >= 100
2271
+ }, now, durSecs, `${count(totals.requests)} req`, (used, cap) => `${brief(used)}/${brief(cap)} (${pctText}% est.)`);
2239
2272
  }
2240
2273
  function arkRows(usage, fallbackName) {
2241
2274
  if (!usage)
2242
2275
  return [];
2243
2276
  const now = Date.now();
2244
2277
  const rows = [["Plan", clip(`${usage.plan} \xB7 AFP plan${usage.profile ? ` \xB7 ${usage.profile}` : ""}`, valueWidth("Plan")), "base", true]];
2245
- if (usage.accountError) {
2278
+ if (usage.accountError && actionableAccountError(usage.accountError)) {
2246
2279
  rows.push(["", clip(`usage API: ${usage.accountError}`, valueWidth("")), "warn"]);
2247
2280
  }
2248
2281
  const period = monthPeriod(usage);
@@ -2255,9 +2288,10 @@ function arkRows(usage, fallbackName) {
2255
2288
  rows.push(...meterRows("Weekly", account.weekly, usage.weekly, now, WEEKLY_SECS));
2256
2289
  rows.push(...meterRows("Monthly", account.monthly, usage.month, now, period?.durSecs ?? 0));
2257
2290
  } else {
2258
- rows.push(...localRows("5-hour", usage.fiveHour));
2259
- rows.push(...localRows("Weekly", usage.weekly));
2260
- rows.push(...localRows("Monthly", usage.month));
2291
+ const windows = usage.windows;
2292
+ rows.push(...localRows("5-hour", usage.fiveHour, windows?.fiveHour, now, FIVE_HOUR_SECS));
2293
+ rows.push(...localRows("Weekly", usage.weekly, windows?.weekly, now, WEEKLY_SECS));
2294
+ rows.push(...localRows("Monthly", usage.month, windows?.monthly, now, period?.durSecs ?? 0));
2261
2295
  }
2262
2296
  const yuan = typeof usage.summary.yuan === "number" ? ` (${money(usage.summary.yuan)})` : "";
2263
2297
  rows.push(["Period", clip(`${count(usage.summary.requests)} requests \xB7 ${afp(usage.summary.afp)}${yuan}`, valueWidth("Period")), "base"]);
@@ -2390,7 +2424,7 @@ function zenRows(usage, meta, modelUsage, session, fallbackName, windows) {
2390
2424
  const now = Date.now();
2391
2425
  const oc = meta?.oc;
2392
2426
  const rows = [["Plan", `${oc?.plan ?? "Go"} \xB7 limits per model`, "base"]];
2393
- if (usage.accountError) {
2427
+ if (usage.accountError && actionableAccountError(usage.accountError)) {
2394
2428
  rows.push(["", clip(`usage API: ${usage.accountError}`, valueWidth("")), "warn"]);
2395
2429
  }
2396
2430
  const mine = usage.models.get(modelKey(fallbackName ?? "")) ?? (meta ? usage.models.get(modelKey(meta.name)) : undefined);
@@ -2820,6 +2854,17 @@ function rememberArk(usage) {
2820
2854
  function seededArk() {
2821
2855
  return lastArk;
2822
2856
  }
2857
+ function keepAccount(next, last) {
2858
+ if (next.account || !last?.account)
2859
+ return next;
2860
+ if (actionableAccountError(next.accountError))
2861
+ return next;
2862
+ return {
2863
+ ...next,
2864
+ account: last.account,
2865
+ accountError: undefined
2866
+ };
2867
+ }
2823
2868
  function useRows(activeModelId, providerID, sessionID) {
2824
2869
  const [usage, setUsage] = createSignal(seededRows());
2825
2870
  const [zen, setZen] = createSignal(seededZen());
@@ -2874,13 +2919,13 @@ function useRows(activeModelId, providerID, sessionID) {
2874
2919
  setUsage(usageRows(snapshot));
2875
2920
  refreshModelUsage(activeModelId());
2876
2921
  } else if (kind === "go" || kind === "zen") {
2877
- const snapshot = await loadZen(loadPrefs().ocPlan);
2922
+ const snapshot = keepAccount(await loadZen(loadPrefs().ocPlan), lastZen);
2878
2923
  rememberZen(snapshot);
2879
2924
  setZen(snapshot);
2880
2925
  refreshModelUsage(activeModelId());
2881
2926
  refreshModelWindows(activeModelId());
2882
2927
  } else if (kind === "ark") {
2883
- const snapshot = await loadArk();
2928
+ const snapshot = keepAccount(await loadArk(), lastArk);
2884
2929
  rememberArk(snapshot);
2885
2930
  setArk(snapshot);
2886
2931
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jeffreyjyz/opencode-command-code",
3
- "version": "0.5.18",
3
+ "version": "0.5.20",
4
4
  "description": "Command Code provider + usage dashboard for opencode: two providers (Anthropic wire for Claude, OpenAI wire for everything else) against api.commandcode.ai, live model list, plan gating, /cmd-usage. Requires the cmduse CLI for usage rendering.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",