opencode-providers-balances 0.1.1 → 0.1.2

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.
Files changed (4) hide show
  1. package/README.md +2 -2
  2. package/package.json +1 -1
  3. package/providers.ts +157 -34
  4. package/tui.tsx +34 -20
package/README.md CHANGED
@@ -107,7 +107,7 @@ balance (AITUNNEL).
107
107
 
108
108
  | Field | Required | Description |
109
109
  | --- | --- | --- |
110
- | `id` | yes | Stable id; used for `disable` and as the default env prefix. |
110
+ | `id` | yes | Stable id; used for `disable` and as the base of the default env var (sanitized — see `env`). |
111
111
  | `label` | yes | Sidebar label. |
112
112
  | `url` | yes | Balance endpoint. |
113
113
  | `jsonPath` | no | Dot path into the JSON body. Numeric segments index arrays, e.g. `balance_infos.0.total_balance`. The value must be a number (or numeric string) and is formatted with two decimals. |
@@ -115,7 +115,7 @@ balance (AITUNNEL).
115
115
  | `prefixFrom` | no | Derive the prefix from a value: `{ path, map, fallback? }`, e.g. `{ "path": "currency", "map": { "USD": "$" } }`. Takes precedence over `prefix`. |
116
116
  | `require` | no | Gate the row: `{ path, equals }`; hidden unless the value at `path` strictly equals `equals`. |
117
117
  | `authScheme` | no | Authorization scheme, default `Bearer`. |
118
- | `env` | no | Env var holding the key, default `<ID>_API_KEY`. |
118
+ | `env` | no | Env var holding the key, default `<SANITIZED_ID>_API_KEY` — the id uppercased with runs of non-alphanumerics turned into `_` (e.g. `router-ai` → `ROUTER_AI_API_KEY`). |
119
119
  | `key` | no | Literal key (discouraged — prefer env/integration). |
120
120
  | `integration` | no | Id in the V2 SQLite `credential` table. |
121
121
  | `configProvider` | no | Provider id in `opencode.jsonc` whose `settings.apiKey` to use. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-providers-balances",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "OpenCode TUI plugin that shows provider account balances in the session sidebar. Providers are configured entirely in opencode.jsonc.",
5
5
  "keywords": [
6
6
  "opencode",
package/providers.ts CHANGED
@@ -11,7 +11,7 @@
11
11
 
12
12
  import { readFileSync, existsSync } from "node:fs"
13
13
  import { homedir } from "node:os"
14
- import { join } from "node:path"
14
+ import { join, resolve } from "node:path"
15
15
  import { createRequire } from "node:module"
16
16
 
17
17
  // ---------------------------------------------------------------------------
@@ -58,7 +58,7 @@ export interface Provider {
58
58
  * - `prefix` / `prefixFrom` fixed or value-derived display prefix.
59
59
  */
60
60
  export interface ProviderConfig {
61
- /** Stable id; also the default env prefix and `disable` key. */
61
+ /** Stable id; also the base of the default env var and the `disable` key. */
62
62
  id: string
63
63
  /** Sidebar label. */
64
64
  label: string
@@ -66,7 +66,7 @@ export interface ProviderConfig {
66
66
  url: string
67
67
  /** Authorization scheme; defaults to `Bearer`. */
68
68
  authScheme?: string
69
- /** Env var holding the key; defaults to `<ID>_API_KEY`. */
69
+ /** Env var holding the key; defaults to `<SANITIZED_ID>_API_KEY`. */
70
70
  env?: string
71
71
  /** Literal key (discouraged — prefer env/integration). */
72
72
  key?: string
@@ -99,8 +99,13 @@ export interface FetchResult {
99
99
  value: string
100
100
  /** True when the provider is configured but the last refresh failed. */
101
101
  stale: boolean
102
- /** True when the provider has no usable key — hidden, not rendered. */
103
- skipped: boolean
102
+ /**
103
+ * True when the provider must not be rendered: no usable key, a failed
104
+ * `require` gate, or a response with no numeric value. Distinguishing this
105
+ * from `stale` keeps an intentionally unavailable provider from showing a
106
+ * previous balance marked stale.
107
+ */
108
+ hidden: boolean
104
109
  }
105
110
 
106
111
  export interface BalanceState {
@@ -122,10 +127,21 @@ export function jsonPathGet(body: unknown, path: string): unknown {
122
127
  return cur
123
128
  }
124
129
 
125
- /** Coerce a value to a finite number, or null. */
130
+ /**
131
+ * Coerce a value to a finite number, or null. Only a real number or a non-blank
132
+ * numeric string counts; `null`, booleans, arrays, objects and blank strings are
133
+ * rejected rather than coerced (so `Number(null) === 0` can't masquerade as a
134
+ * zero balance).
135
+ */
126
136
  function num(v: unknown): number | null {
127
- const n = typeof v === "number" ? v : Number(v)
128
- return Number.isFinite(n) ? n : null
137
+ if (typeof v === "number") return Number.isFinite(v) ? v : null
138
+ if (typeof v === "string") {
139
+ const t = v.trim()
140
+ if (!t) return null
141
+ const n = Number(t)
142
+ return Number.isFinite(n) ? n : null
143
+ }
144
+ return null
129
145
  }
130
146
 
131
147
  /** Build a formatter from the declarative rules in a spec. */
@@ -150,27 +166,67 @@ function buildFormatter(spec: ProviderConfig): (body: unknown) => string | null
150
166
 
151
167
  let configCache: { at: number; data: Record<string, unknown> | null } | null = null
152
168
 
169
+ /**
170
+ * Root directories OpenCode uses for global state, honoring the XDG base
171
+ * directories exactly like OpenCode's own `roots()`: `$XDG_CONFIG_HOME` and
172
+ * `$XDG_DATA_HOME`, falling back to `~/.config` and `~/.local/share` when unset
173
+ * or empty.
174
+ */
175
+ export function opencodeRoots(app = "opencode"): { config: string; data: string } {
176
+ const home = homedir()
177
+ const configHome = process.env.XDG_CONFIG_HOME || join(home, ".config")
178
+ const dataHome = process.env.XDG_DATA_HOME || join(home, ".local", "share")
179
+ return { config: join(configHome, app), data: join(dataHome, app) }
180
+ }
181
+
182
+ /**
183
+ * Candidate global config files in precedence order, honoring OpenCode's
184
+ * `OPENCODE_CONFIG` (explicit file) and `OPENCODE_CONFIG_DIR` (config directory
185
+ * override) before the XDG-derived default.
186
+ */
187
+ export function configFilePaths(): string[] {
188
+ const dir = process.env.OPENCODE_CONFIG_DIR || opencodeRoots().config
189
+ const files = ["opencode.jsonc", "opencode.json"].map((name) => join(dir, name))
190
+ const explicit = process.env.OPENCODE_CONFIG
191
+ return explicit ? [explicit, ...files] : files
192
+ }
193
+
153
194
  function readConfig(): Record<string, unknown> | null {
154
195
  const now = Date.now()
155
196
  if (configCache && now - configCache.at < CONFIG_CACHE_MS) return configCache.data
156
197
  let data: Record<string, unknown> | null = null
157
- for (const name of ["opencode.jsonc", "opencode.json"]) {
158
- const p = join(homedir(), ".config/opencode", name)
159
- if (!existsSync(p)) continue
198
+ const inline = process.env.OPENCODE_CONFIG_CONTENT
199
+ if (inline) {
160
200
  try {
161
- data = parseJsonc(readFileSync(p, "utf8"))
162
- break
201
+ data = parseJsonc(inline)
163
202
  } catch {}
164
203
  }
204
+ if (!data) {
205
+ for (const p of configFilePaths()) {
206
+ if (!existsSync(p)) continue
207
+ try {
208
+ data = parseJsonc(readFileSync(p, "utf8"))
209
+ break
210
+ } catch {}
211
+ }
212
+ }
165
213
  configCache = { at: now, data }
166
214
  return data
167
215
  }
168
216
 
169
- /** Strip JSONC comments without corrupting string values. */
170
- function parseJsonc(raw: string): Record<string, unknown> {
217
+ /**
218
+ * Parse JSONC: strip line/block comments and trailing commas, then hand the
219
+ * result to `JSON.parse`. Comments and commas inside string values are left
220
+ * intact. Only `"` delimits strings, matching JSONC.
221
+ */
222
+ export function parseJsonc(raw: string): Record<string, unknown> {
223
+ return JSON.parse(stripTrailingCommas(stripComments(raw))) as Record<string, unknown>
224
+ }
225
+
226
+ /** Remove line and block comments, ignoring any that appear inside strings. */
227
+ function stripComments(raw: string): string {
171
228
  let out = ""
172
229
  let inString = false
173
- let quote = ""
174
230
  for (let i = 0; i < raw.length; i++) {
175
231
  const ch = raw[i]
176
232
  const next = raw[i + 1]
@@ -179,14 +235,13 @@ function parseJsonc(raw: string): Record<string, unknown> {
179
235
  if (ch === "\\") {
180
236
  out += next ?? ""
181
237
  i++
182
- } else if (ch === quote) {
238
+ } else if (ch === '"') {
183
239
  inString = false
184
240
  }
185
241
  continue
186
242
  }
187
- if (ch === '"' || ch === "'") {
243
+ if (ch === '"') {
188
244
  inString = true
189
- quote = ch
190
245
  out += ch
191
246
  continue
192
247
  }
@@ -203,8 +258,38 @@ function parseJsonc(raw: string): Record<string, unknown> {
203
258
  }
204
259
  out += ch
205
260
  }
206
- out = out.replace(/,(\s*[}\]])/g, "$1")
207
- return JSON.parse(out) as Record<string, unknown>
261
+ return out
262
+ }
263
+
264
+ /** Drop commas that precede a closing brace/bracket, ignoring string values. */
265
+ function stripTrailingCommas(s: string): string {
266
+ let out = ""
267
+ let inString = false
268
+ for (let i = 0; i < s.length; i++) {
269
+ const ch = s[i]
270
+ if (inString) {
271
+ out += ch
272
+ if (ch === "\\") {
273
+ out += s[i + 1] ?? ""
274
+ i++
275
+ } else if (ch === '"') {
276
+ inString = false
277
+ }
278
+ continue
279
+ }
280
+ if (ch === '"') {
281
+ inString = true
282
+ out += ch
283
+ continue
284
+ }
285
+ if (ch === ",") {
286
+ let j = i + 1
287
+ while (j < s.length && /\s/.test(s[j])) j++
288
+ if (s[j] === "}" || s[j] === "]") continue
289
+ }
290
+ out += ch
291
+ }
292
+ return out
208
293
  }
209
294
 
210
295
  // ---------------------------------------------------------------------------
@@ -279,9 +364,17 @@ function allIntegrationKeys(): Record<string, string> {
279
364
  return keyCache.keys
280
365
  }
281
366
 
367
+ /** Path to the SQLite credential store, or null when no persistent DB is used. */
368
+ export function databasePath(): string | null {
369
+ const override = process.env.OPENCODE_DB
370
+ const dataDir = opencodeRoots().data
371
+ if (override) return override === ":memory:" ? null : resolve(dataDir, override)
372
+ return join(dataDir, "opencode.db")
373
+ }
374
+
282
375
  function readAllCredentials(): Record<string, string> {
283
- const path = join(homedir(), ".local/share/opencode/opencode.db")
284
- if (!existsSync(path)) return {}
376
+ const path = databasePath()
377
+ if (!path || !existsSync(path)) return {}
285
378
  const driver = loadSqlite()
286
379
  if (!driver) return {}
287
380
  let db: any
@@ -330,7 +423,7 @@ function extractKey(v: unknown): string | null {
330
423
  }
331
424
 
332
425
  /** Resolve a provider's key from the first source that has one. */
333
- export function keyForProvider(p: Provider): string | null {
426
+ function keyForProvider(p: Provider): string | null {
334
427
  if (p.literalKey?.trim()) return p.literalKey.trim()
335
428
  if (p.integration) {
336
429
  const k = allIntegrationKeys()[p.integration]
@@ -349,6 +442,21 @@ export function keyForProvider(p: Provider): string | null {
349
442
  // Provider construction
350
443
  // ---------------------------------------------------------------------------
351
444
 
445
+ /**
446
+ * Derive a conventional, settable env var name from a provider id:
447
+ * uppercase, runs of characters that are not `A-Z0-9` collapsed to `_`, and a
448
+ * leading `_` added when the result would otherwise start with a digit. So
449
+ * `router-ai` -> `ROUTER_AI_API_KEY` rather than `ROUTER-AI_API_KEY`.
450
+ */
451
+ function defaultEnvVar(id: string): string {
452
+ const base = id
453
+ .toUpperCase()
454
+ .replace(/[^A-Z0-9]+/g, "_")
455
+ .replace(/^_+|_+$/g, "")
456
+ const safe = base === "" ? "PROVIDER" : /^[0-9]/.test(base) ? `_${base}` : base
457
+ return `${safe}_API_KEY`
458
+ }
459
+
352
460
  /** Convert a declarative spec into an executable provider. */
353
461
  function toProvider(spec: ProviderConfig): Provider {
354
462
  return {
@@ -356,7 +464,7 @@ function toProvider(spec: ProviderConfig): Provider {
356
464
  label: spec.label,
357
465
  url: spec.url,
358
466
  authScheme: spec.authScheme,
359
- env: spec.env ?? `${spec.id.toUpperCase()}_API_KEY`,
467
+ env: spec.env ?? defaultEnvVar(spec.id),
360
468
  integration: spec.integration,
361
469
  configProvider: spec.configProvider,
362
470
  literalKey: spec.key,
@@ -394,7 +502,7 @@ export function buildProviders(options?: BalancesOptions): Provider[] {
394
502
  export async function fetchProvider(p: Provider): Promise<FetchResult> {
395
503
  const key = keyForProvider(p)
396
504
  // Hidden rather than stale when unconfigured.
397
- if (!key) return { value: "", stale: false, skipped: true }
505
+ if (!key) return { value: "", stale: false, hidden: true }
398
506
 
399
507
  const ctrl = new AbortController()
400
508
  const timer = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT_MS)
@@ -404,11 +512,13 @@ export async function fetchProvider(p: Provider): Promise<FetchResult> {
404
512
  headers: { Authorization: `${scheme} ${key}` },
405
513
  signal: ctrl.signal,
406
514
  })
407
- if (!res.ok) return { value: "", stale: true, skipped: false }
515
+ if (!res.ok) return { value: "", stale: true, hidden: false }
408
516
  const value = p.format(await res.json())
409
- return value ? { value, stale: false, skipped: false } : { value: "", stale: true, skipped: false }
517
+ // A response that parses but yields no value hides the row; it is not a
518
+ // failed fetch, so it must not resurface as a stale balance.
519
+ return value ? { value, stale: false, hidden: false } : { value: "", stale: false, hidden: true }
410
520
  } catch {
411
- return { value: "", stale: true, skipped: false }
521
+ return { value: "", stale: true, hidden: false }
412
522
  } finally {
413
523
  clearTimeout(timer)
414
524
  }
@@ -416,7 +526,7 @@ export async function fetchProvider(p: Provider): Promise<FetchResult> {
416
526
 
417
527
  /**
418
528
  * Merge fresh results into state. Successful values replace; a failed refresh
419
- * keeps the previous value marked stale; a skipped provider is removed.
529
+ * keeps the previous value marked stale; a hidden provider is removed.
420
530
  */
421
531
  export function collect(
422
532
  state: Map<ProviderKey, BalanceState>,
@@ -425,7 +535,7 @@ export function collect(
425
535
  ): void {
426
536
  providers.forEach((p, i) => {
427
537
  const fresh = results[i]
428
- if (!fresh || fresh.skipped) {
538
+ if (!fresh || fresh.hidden) {
429
539
  state.delete(p.id)
430
540
  return
431
541
  }
@@ -439,12 +549,25 @@ export function collect(
439
549
  })
440
550
  }
441
551
 
442
- export function renderRows(state: Map<ProviderKey, BalanceState>, providers: Provider[]): string[] {
443
- const rows: string[] = []
552
+ /** One renderable balance line: a provider that currently has a value. */
553
+ export interface BalanceRow {
554
+ id: ProviderKey
555
+ label: string
556
+ value: string
557
+ stale: boolean
558
+ }
559
+
560
+ /** Providers with a current value, in provider order. */
561
+ export function balanceRows(state: Map<ProviderKey, BalanceState>, providers: Provider[]): BalanceRow[] {
562
+ const rows: BalanceRow[] = []
444
563
  for (const p of providers) {
445
564
  const s = state.get(p.id)
446
565
  if (!s?.value) continue
447
- rows.push(`${s.stale ? "!" : "•"} ${p.label} ${s.value}`)
566
+ rows.push({ id: p.id, label: p.label, value: s.value, stale: s.stale })
448
567
  }
449
568
  return rows
450
569
  }
570
+
571
+ export function renderRows(state: Map<ProviderKey, BalanceState>, providers: Provider[]): string[] {
572
+ return balanceRows(state, providers).map((r) => `${r.stale ? "!" : "•"} ${r.label} ${r.value}`)
573
+ }
package/tui.tsx CHANGED
@@ -1,24 +1,21 @@
1
1
  /** @jsxImportSource @opentui/solid */
2
2
  import { Plugin } from "@opencode/plugin/tui"
3
- import { createSignal } from "solid-js"
3
+ import { createSignal, For } from "solid-js"
4
4
  import {
5
+ balanceRows,
5
6
  buildProviders,
6
7
  collect,
7
8
  DEFAULT_REFRESH_MINUTES,
8
9
  fetchProvider,
9
10
  loadOptionsFromConfig,
10
- renderRows,
11
+ type BalanceRow,
11
12
  type BalanceState,
12
13
  type BalancesOptions,
13
14
  type Provider,
14
15
  type ProviderKey,
15
16
  } from "./providers"
16
17
 
17
- /**
18
- * Resolve options. OpenCode V2 beta passes plugin options to the server
19
- * entrypoint but not to the TUI entrypoint, so fall back to reading our own
20
- * options from opencode.jsonc when the context gives us nothing.
21
- */
18
+ /** Resolve options, falling back to opencode.jsonc when the context gives none. */
22
19
  function resolveOptions(contextOptions: unknown): BalancesOptions {
23
20
  const fromContext = (contextOptions ?? {}) as BalancesOptions
24
21
  if (Array.isArray(fromContext.providers) && fromContext.providers.length > 0) return fromContext
@@ -34,37 +31,54 @@ function resolveOptions(contextOptions: unknown): BalancesOptions {
34
31
 
35
32
  export default Plugin.define({
36
33
  id: "providers-balances.tui",
37
- async setup(context) {
34
+ setup(context) {
38
35
  const options = resolveOptions(context.options)
39
36
  const providers: Provider[] = buildProviders(options)
40
37
  const refreshMs = Math.max(1, options.refreshMinutes ?? DEFAULT_REFRESH_MINUTES) * 60_000
41
38
 
42
39
  const state = new Map<ProviderKey, BalanceState>()
43
- const [rows, setRows] = createSignal("")
40
+ const [rows, setRows] = createSignal<BalanceRow[]>([])
41
+ const [loading, setLoading] = createSignal(providers.length > 0)
42
+ let stopped = false
44
43
 
45
44
  async function refresh() {
46
45
  const results = await Promise.all(providers.map((p) => fetchProvider(p)))
46
+ if (stopped) return
47
47
  collect(state, providers, results)
48
- const list = renderRows(state, providers)
49
- setRows(list.length ? `\n${list.join("\n")}` : "")
48
+ setRows(balanceRows(state, providers))
49
+ setLoading(false)
50
50
  }
51
51
 
52
- // Fetch before registering so the first render already has values.
53
- await refresh()
54
-
55
52
  const unregister = context.ui.slot({
56
53
  append: "sidebar.content",
57
- render: () => (
58
- <text>
59
- <b>Balances</b>
60
- {rows()}
61
- </text>
62
- ),
54
+ // Show a placeholder during the first fetch, then either the rows or
55
+ // nothing at all (never a bare heading).
56
+ render: () => {
57
+ const list = rows()
58
+ if (!list.length && !loading()) return null
59
+ return (
60
+ <text>
61
+ <b>Balances</b>
62
+ <For each={list}>
63
+ {(r) => (
64
+ <span style={{ fg: r.stale ? context.theme.text.feedback.warning.base : context.theme.text.base }}>
65
+ {`\n${r.stale ? "!" : "•"} ${r.label} ${r.value}`}
66
+ </span>
67
+ )}
68
+ </For>
69
+ {loading() && !list.length ? "\n…" : ""}
70
+ </text>
71
+ )
72
+ },
63
73
  })
64
74
 
75
+ // Register first, then fetch in the background: a slow or unreachable
76
+ // endpoint must not delay setup or the first render.
77
+ void refresh()
65
78
  const timer = setInterval(() => void refresh(), refreshMs)
66
79
 
67
80
  return () => {
81
+ stopped = true
68
82
  clearInterval(timer)
69
83
  unregister()
70
84
  }