@jeffreyjyz/opencode-command-code 0.5.19 → 0.5.21

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.19",
2417
+ version: "0.5.21",
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",
@@ -2426,7 +2426,8 @@ var package_default = {
2426
2426
  scripts: {
2427
2427
  build: "rm -rf dist && bun build src/index.ts --outdir dist --target bun --external @opencode-ai/plugin --external @opencode/plugin --external @opencode/plugin/tui && bun scripts/build-tui.ts && tsc -p tsconfig.build.json",
2428
2428
  test: "bun test",
2429
- typecheck: "tsc --noEmit",
2429
+ lint: "biome lint --write .",
2430
+ typecheck: "bun check",
2430
2431
  "build:tui": "bun scripts/build-tui.ts",
2431
2432
  "extract:catalog": "bun scripts/extract-catalog.ts"
2432
2433
  },
@@ -4046,12 +4047,7 @@ function sseDecoder(onEvent) {
4046
4047
  var text = (v) => typeof v === "string" ? v : "";
4047
4048
  var num = (v) => typeof v === "number" && Number.isFinite(v) ? v : 0;
4048
4049
  var rec = (v) => v && typeof v === "object" ? v : {};
4049
- var TOOL_FINISH = new Set([
4050
- "tool_use",
4051
- "tool_calls",
4052
- "tool-call",
4053
- "function_call"
4054
- ]);
4050
+ var TOOL_FINISH = new Set(["tool_use", "tool_calls", "tool-call", "function_call"]);
4055
4051
  var LENGTH_FINISH = new Set([
4056
4052
  "length",
4057
4053
  "max_tokens",
@@ -4100,9 +4096,7 @@ function anthropicReducer() {
4100
4096
  const open = (index, kind) => {
4101
4097
  const id = `${kind === "text" ? "text" : "reasoning"}-${index}`;
4102
4098
  blocks.set(index, { type: kind, id });
4103
- return [
4104
- { type: kind === "text" ? "text-start" : "reasoning-start", id }
4105
- ];
4099
+ return [{ type: kind === "text" ? "text-start" : "reasoning-start", id }];
4106
4100
  };
4107
4101
  return {
4108
4102
  feed(event) {
@@ -4143,19 +4137,13 @@ function anthropicReducer() {
4143
4137
  if (textDelta) {
4144
4138
  const parts = block?.type === "text" ? [] : open(index, "text");
4145
4139
  const id = blocks.get(index)?.id ?? `text-${index}`;
4146
- return [
4147
- ...parts,
4148
- { type: "text-delta", id, delta: textDelta }
4149
- ];
4140
+ return [...parts, { type: "text-delta", id, delta: textDelta }];
4150
4141
  }
4151
4142
  const thinking = text(delta.thinking);
4152
4143
  if (thinking) {
4153
4144
  const parts = block?.type === "thinking" ? [] : open(index, "thinking");
4154
4145
  const id = blocks.get(index)?.id ?? `reasoning-${index}`;
4155
- return [
4156
- ...parts,
4157
- { type: "reasoning-delta", id, delta: thinking }
4158
- ];
4146
+ return [...parts, { type: "reasoning-delta", id, delta: thinking }];
4159
4147
  }
4160
4148
  const partial = text(delta.partial_json);
4161
4149
  if (partial && block?.type === "tool_use") {
@@ -4382,11 +4370,7 @@ function openaiReducer() {
4382
4370
  return parts;
4383
4371
  },
4384
4372
  close() {
4385
- const parts = [
4386
- ...closeReasoning(),
4387
- ...closeText(),
4388
- ...flushTools()
4389
- ];
4373
+ const parts = [...closeReasoning(), ...closeText(), ...flushTools()];
4390
4374
  if (pending && !finished) {
4391
4375
  finished = true;
4392
4376
  parts.push({
@@ -11,12 +11,7 @@ export type Tone = "base" | "muted" | "strong" | "ok" | "warn" | "crit";
11
11
  * A row. `headline` marks the two rows whose label names the thing the panel is
12
12
  * about (the plan, the active model); the panel colours their labels apart.
13
13
  */
14
- export type SidebarRow = [
15
- label: string,
16
- value: string,
17
- tone?: Tone,
18
- headline?: true
19
- ];
14
+ export type SidebarRow = [label: string, value: string, tone?: Tone, headline?: true];
20
15
  /** Headroom severity: comfortable to 70%, tight to 90%, over after that. */
21
16
  export declare function pctTone(pct: number): Tone;
22
17
  /**
@@ -30,6 +25,17 @@ export declare function pctTone(pct: number): Tone;
30
25
  export declare function separator(): SidebarRow;
31
26
  /** Truncate to `width`, ellipsis included. */
32
27
  export declare function clip(text: string, width?: number): string;
28
+ /**
29
+ * Whether a usage-API reason is worth a row in the panel.
30
+ *
31
+ * Only a reason that names something the user can fix is: a missing or expired
32
+ * key, a missing CLI/profile, a rejected request. Everything else — a dropped
33
+ * connection, a DNS blip, a timeout — is transport, heals on the next poll, and
34
+ * used to make an `usage API: …` line pop in and out of the sidebar every few
35
+ * seconds. The pattern list is a whitelist on purpose: a new transport wording
36
+ * must stay quiet by default.
37
+ */
38
+ export declare function actionableAccountError(reason: string | undefined): boolean;
33
39
  /** Value budget for a labelled row: the panel draws `label: value`. */
34
40
  export declare function valueWidth(label: string): number;
35
41
  /** 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
  }
@@ -2270,7 +2275,7 @@ function arkRows(usage, fallbackName) {
2270
2275
  return [];
2271
2276
  const now = Date.now();
2272
2277
  const rows = [["Plan", clip(`${usage.plan} \xB7 AFP plan${usage.profile ? ` \xB7 ${usage.profile}` : ""}`, valueWidth("Plan")), "base", true]];
2273
- if (usage.accountError) {
2278
+ if (usage.accountError && actionableAccountError(usage.accountError)) {
2274
2279
  rows.push(["", clip(`usage API: ${usage.accountError}`, valueWidth("")), "warn"]);
2275
2280
  }
2276
2281
  const period = monthPeriod(usage);
@@ -2419,7 +2424,7 @@ function zenRows(usage, meta, modelUsage, session, fallbackName, windows) {
2419
2424
  const now = Date.now();
2420
2425
  const oc = meta?.oc;
2421
2426
  const rows = [["Plan", `${oc?.plan ?? "Go"} \xB7 limits per model`, "base"]];
2422
- if (usage.accountError) {
2427
+ if (usage.accountError && actionableAccountError(usage.accountError)) {
2423
2428
  rows.push(["", clip(`usage API: ${usage.accountError}`, valueWidth("")), "warn"]);
2424
2429
  }
2425
2430
  const mine = usage.models.get(modelKey(fallbackName ?? "")) ?? (meta ? usage.models.get(modelKey(meta.name)) : undefined);
@@ -2849,6 +2854,17 @@ function rememberArk(usage) {
2849
2854
  function seededArk() {
2850
2855
  return lastArk;
2851
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
+ }
2852
2868
  function useRows(activeModelId, providerID, sessionID) {
2853
2869
  const [usage, setUsage] = createSignal(seededRows());
2854
2870
  const [zen, setZen] = createSignal(seededZen());
@@ -2903,13 +2919,13 @@ function useRows(activeModelId, providerID, sessionID) {
2903
2919
  setUsage(usageRows(snapshot));
2904
2920
  refreshModelUsage(activeModelId());
2905
2921
  } else if (kind === "go" || kind === "zen") {
2906
- const snapshot = await loadZen(loadPrefs().ocPlan);
2922
+ const snapshot = keepAccount(await loadZen(loadPrefs().ocPlan), lastZen);
2907
2923
  rememberZen(snapshot);
2908
2924
  setZen(snapshot);
2909
2925
  refreshModelUsage(activeModelId());
2910
2926
  refreshModelWindows(activeModelId());
2911
2927
  } else if (kind === "ark") {
2912
- const snapshot = await loadArk();
2928
+ const snapshot = keepAccount(await loadArk(), lastArk);
2913
2929
  rememberArk(snapshot);
2914
2930
  setArk(snapshot);
2915
2931
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jeffreyjyz/opencode-command-code",
3
- "version": "0.5.19",
3
+ "version": "0.5.21",
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",
@@ -12,7 +12,8 @@
12
12
  "scripts": {
13
13
  "build": "rm -rf dist && bun build src/index.ts --outdir dist --target bun --external @opencode-ai/plugin --external @opencode/plugin --external @opencode/plugin/tui && bun scripts/build-tui.ts && tsc -p tsconfig.build.json",
14
14
  "test": "bun test",
15
- "typecheck": "tsc --noEmit",
15
+ "lint": "biome lint --write .",
16
+ "typecheck": "bun check",
16
17
  "build:tui": "bun scripts/build-tui.ts",
17
18
  "extract:catalog": "bun scripts/extract-catalog.ts"
18
19
  },