@cardinal4/opencode-tavily-quota 1.0.0 → 1.0.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.
package/README.md CHANGED
@@ -26,13 +26,17 @@ polls every 10 minutes and can be refreshed on demand with the command below.
26
26
  ## Requirements
27
27
 
28
28
  - OpenCode 2 (`opencode --version` reports `2.x`).
29
- - A Tavily API key, supplied one of two ways:
30
- - `TAVILY_API_KEY` in the environment OpenCode is started from, or
31
- - the `apiKey` plugin option configured on this plugin in `cli.json`.
32
-
33
- `TAVILY_API_KEY` takes precedence when both are set. The key is read in the CLI
34
- process and sent only to `api.tavily.com` as a `Bearer` token. It is never
35
- displayed.
29
+ - A Tavily API key, supplied by:
30
+ - `TAVILY_API_KEY` in the CLI environment,
31
+ - the `apiKey` plugin option configured in `cli.json`, or
32
+ - OpenCode's active Tavily connection (use `opencode auth login tavily` or
33
+ `/connect` to connect Tavily).
34
+
35
+ The CLI environment takes precedence over `apiKey`; OpenCode's active
36
+ connection is used when neither supplies a key. The server resolves its saved
37
+ credential and fetches `/usage` itself. Only quota data returns to the TUI;
38
+ the saved key is never sent over plugin RPC or displayed. With a remote server,
39
+ install this plugin there too to use its active connection.
36
40
 
37
41
  The `apiKey` option also accepts `{file:...}` and `{env:...}` references, which
38
42
  the plugin expands itself because `cli.json` does not (unlike `opencode.jsonc`).
@@ -40,27 +44,35 @@ the plugin expands itself because `cli.json` does not (unlike `opencode.jsonc`).
40
44
  ## Layout
41
45
 
42
46
  ```
43
- ~/.config/opencode/plugins/opencode-tavily-quota/
44
- ├── index.ts # server entrypoint (no-op; required for discovery)
45
- ├── tui.tsx # TUI entrypoint: sidebar slot + command
46
- ├── usage.ts # key resolution, /usage fetch, formatting
47
- └── usage.test.ts # unit tests for usage.ts
47
+ index.ts # server entrypoint: integration credential + quota RPC
48
+ rpc.ts # shared RPC definition
49
+ tui.tsx # TUI entrypoint: sidebar slot + command
50
+ usage.ts # key resolution, /usage fetch, formatting
51
+ usage.test.ts # unit tests for usage.ts
52
+ scripts/build.mjs
53
+ dist/ # built entrypoints shipped to npm
48
54
  ```
49
55
 
50
- OpenCode discovers local plugins as directories under
51
- `~/.config/opencode/plugins/`, or `<project>/.opencode/plugins/`. The server
52
- entrypoint (`index.ts` / `server.ts`) satisfies discovery; the `tui` entrypoint
53
- (`tui.tsx`) is loaded by the CLI to render the sidebar.
56
+ `npm run build` compiles the TypeScript/JSX sources into `dist/`, which is what
57
+ `package.json` exports and what npm publishes. Published plugins ship built
58
+ `dist/*.js`: OpenCode transpiles `.tsx` entrypoints as it loads them, and the
59
+ Solid JSX runtime (`@opentui/solid`) is only provided at runtime, not from the
60
+ installed plugin.
61
+
62
+ OpenCode installs package plugins under `~/.cache/opencode/npm/`. The server
63
+ entrypoint (`dist/index.js`) registers the quota RPC; the `tui` entrypoint
64
+ (`dist/tui.js`) renders the sidebar in the CLI.
54
65
 
55
66
  ## Configuration
56
67
 
57
- Register the plugin in `cli.json` to pass the API key as a plugin option:
68
+ If you already connected Tavily in OpenCode, no key option is needed. To pass
69
+ the API key as a plugin option instead, register the plugin in `cli.json`:
58
70
 
59
71
  ```json title="cli.json"
60
72
  {
61
73
  "plugins": [
62
74
  {
63
- "package": "file:///home/me/.config/opencode/plugins/opencode-tavily-quota",
75
+ "package": "@cardinal4/opencode-tavily-quota@latest",
64
76
  "options": {
65
77
  "apiKey": "tvly-..."
66
78
  }
@@ -84,7 +96,7 @@ reference as `apiKey` to avoid pasting the raw key into `cli.json`:
84
96
  {
85
97
  "plugins": [
86
98
  {
87
- "package": "file:///home/me/.config/opencode/plugins/opencode-tavily-quota",
99
+ "package": "@cardinal4/opencode-tavily-quota@latest",
88
100
  "options": {
89
101
  "apiKey": "{file:~/.secrets/tavily-api-key}"
90
102
  }
@@ -116,19 +128,18 @@ therefore handled.
116
128
  ## Development
117
129
 
118
130
  `usage.ts` is deliberately free of OpenCode and OpenTUI imports so it can be
119
- tested anywhere. `usage.test.ts` covers the pure quota math (`deriveSnapshot`,
120
- `formatPercent`, `formatRemaining`), key resolution, and every `fetchQuota`
121
- error branch using an injected `fetch`.
131
+ tested anywhere. The tests cover quota math, local-key precedence and fallback,
132
+ the connected-key RPC, and HTTP error handling using a mock `fetch`.
122
133
 
123
134
  Run the tests and type check from the repository root:
124
135
 
125
136
  ```sh
126
137
  npm install
127
- npm test # tsx --test usage.test.ts
138
+ npm test
128
139
  npm run typecheck
129
140
  ```
130
141
 
131
142
  ## Notes
132
143
 
133
- - The plugin is a no-op on the server. All work happens in the CLI runtime,
134
- which is where TUI slots live.
144
+ - The server handles OpenCode-connected credentials and their quota requests;
145
+ the CLI handles local keys and renders TUI slots.
package/dist/index.js ADDED
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Server entrypoint. Resolve the active Tavily integration credential and
3
+ * fetch quota here; only the quota result crosses the RPC boundary to the TUI.
4
+ */
5
+
6
+ import { Plugin } from "@opencode/plugin";
7
+ import { TavilyQuotaRpc } from "./rpc.js";
8
+ import { fetchQuota } from "./usage.js";
9
+ export const PLUGIN_ID = "opencode-tavily-quota";
10
+ export default Plugin.define({
11
+ id: PLUGIN_ID,
12
+ async setup(ctx) {
13
+ await ctx.rpc.register(TavilyQuotaRpc, {
14
+ usage: async () => {
15
+ const connection = await ctx.integration.connection.active("tavily");
16
+ const credential = connection && (await ctx.integration.connection.resolve(connection));
17
+ const key = credential?.type === "key" ? credential.key : connection?.type === "env" ? process.env[connection.name] : undefined;
18
+ if (!key) {
19
+ return {
20
+ ok: false,
21
+ message: "no active Tavily connection (run opencode auth login tavily)"
22
+ };
23
+ }
24
+ return fetchQuota({
25
+ apiKey: key,
26
+ env: {}
27
+ });
28
+ }
29
+ });
30
+ }
31
+ });
package/dist/rpc.js ADDED
@@ -0,0 +1,54 @@
1
+ import { Rpc } from "@opencode/plugin/rpc";
2
+
3
+ /** The server fetches quota so the saved OpenCode credential never enters the TUI. */
4
+ export const TavilyQuotaRpc = Rpc.define({
5
+ id: "opencode-tavily-quota",
6
+ methods: {
7
+ usage: {
8
+ input: {
9
+ type: "object",
10
+ properties: {},
11
+ additionalProperties: false
12
+ },
13
+ output: {
14
+ type: "object",
15
+ properties: {
16
+ ok: {
17
+ type: "boolean"
18
+ },
19
+ message: {
20
+ type: "string"
21
+ },
22
+ snapshot: {
23
+ type: "object",
24
+ properties: {
25
+ planName: {
26
+ type: "string"
27
+ },
28
+ used: {
29
+ type: "number"
30
+ },
31
+ limit: {
32
+ type: "number"
33
+ },
34
+ remaining: {
35
+ type: "number"
36
+ },
37
+ percentRemaining: {
38
+ type: "number"
39
+ },
40
+ updatedAt: {
41
+ type: "number"
42
+ }
43
+ },
44
+ required: ["planName", "used", "limit", "remaining", "percentRemaining", "updatedAt"],
45
+ additionalProperties: false
46
+ }
47
+ },
48
+ required: ["ok"],
49
+ additionalProperties: false
50
+ }
51
+ }
52
+ },
53
+ events: {}
54
+ });
package/dist/tui.js ADDED
@@ -0,0 +1,185 @@
1
+ import { createComponent as _$createComponent } from "@opentui/solid";
2
+ import { effect as _$effect } from "@opentui/solid";
3
+ import { insert as _$insert } from "@opentui/solid";
4
+ import { createTextNode as _$createTextNode } from "@opentui/solid";
5
+ import { insertNode as _$insertNode } from "@opentui/solid";
6
+ import { setProp as _$setProp } from "@opentui/solid";
7
+ import { createElement as _$createElement } from "@opentui/solid";
8
+ /** @jsxImportSource @opentui/solid */
9
+
10
+ /**
11
+ * Tavily quota sidebar — an OpenCode 2 TUI plugin.
12
+ *
13
+ * Renders the remaining Tavily API credits in the session sidebar
14
+ * (`sidebar.content`) and exposes a `/tavily-quota` slash command /
15
+ * "Refresh Tavily quota" palette command that re-fetches on demand.
16
+ *
17
+ * The plugin owns no session state: Tavily credits are account-wide, so a
18
+ * single snapshot is shared by every sidebar render and refreshed on a timer.
19
+ * Colors follow the built-in quota sections: the heading uses the default text
20
+ * color and the body (bar included) uses the muted/subdued text color.
21
+ *
22
+ * Authenticate with `TAVILY_API_KEY`, the `apiKey` plugin option in `cli.json`,
23
+ * or OpenCode's active Tavily integration. A saved key is used server-side.
24
+ */
25
+
26
+ import { Plugin } from "@opencode/plugin/tui";
27
+ import { createSignal } from "solid-js";
28
+ import { fetchQuotaWithFallback, formatPercent, formatQuotaLine, formatRemaining } from "./usage.js";
29
+ import { TavilyQuotaRpc } from "./rpc.js";
30
+ export const PLUGIN_ID = "opencode-tavily-quota";
31
+ // Tavily's `/usage` endpoint allows at most 10 requests per 10 minutes, so poll
32
+ // once per window and rely on the `/tavily-quota` command for on-demand reads.
33
+ const REFRESH_INTERVAL_MS = 600_000;
34
+ /**
35
+ * OpenCode 2.0.7 exposes `text.default` / `text.subdued`; later builds renamed
36
+ * the leaves to `text.base` / `text.muted`. Read whichever the running build
37
+ * provides so the plugin matches the built-in quota sections on both.
38
+ */
39
+ function readThemeColors(context) {
40
+ const text = context.theme.text;
41
+ return {
42
+ text: text.default ?? text.base,
43
+ textMuted: text.subdued ?? text.muted
44
+ };
45
+ }
46
+ export function buildLines(state) {
47
+ if (state.status === "loading") {
48
+ return ["loading…"];
49
+ }
50
+ if (state.status === "error") {
51
+ return [`⚠ ${state.message}`];
52
+ }
53
+ const snapshot = state.snapshot;
54
+ return [formatQuotaLine(snapshot)];
55
+ }
56
+ function TavilyQuotaSidebar(props) {
57
+ const colors = () => readThemeColors(props.context);
58
+ // Keep the quota line on one line; let an error message wrap instead of
59
+ // truncating it, since it can be longer than the sidebar.
60
+ const wrapMode = () => props.state().status === "error" ? "word" : "none";
61
+ return (() => {
62
+ var _el$ = _$createElement("box"),
63
+ _el$2 = _$createElement("text"),
64
+ _el$3 = _$createElement("b");
65
+ _$insertNode(_el$, _el$2);
66
+ _$setProp(_el$, "gap", 0);
67
+ _$setProp(_el$, "flexDirection", "column");
68
+ _$insertNode(_el$2, _el$3);
69
+ _$insertNode(_el$3, _$createTextNode(`Tavily`));
70
+ _$insert(_el$, () => buildLines(props.state()).map(line => (() => {
71
+ var _el$5 = _$createElement("text");
72
+ _$insert(_el$5, line);
73
+ _$effect(_p$ => {
74
+ var _v$ = colors().textMuted,
75
+ _v$2 = wrapMode();
76
+ _v$ !== _p$.e && (_p$.e = _$setProp(_el$5, "fg", _v$, _p$.e));
77
+ _v$2 !== _p$.t && (_p$.t = _$setProp(_el$5, "wrapMode", _v$2, _p$.t));
78
+ return _p$;
79
+ }, {
80
+ e: undefined,
81
+ t: undefined
82
+ });
83
+ return _el$5;
84
+ })()), null);
85
+ _$effect(_$p => _$setProp(_el$2, "fg", colors().text, _$p));
86
+ return _el$;
87
+ })();
88
+ }
89
+ function TavilyQuotaCommands(props) {
90
+ props.context.keymap.layer(() => ({
91
+ mode: "global",
92
+ commands: [{
93
+ id: `${PLUGIN_ID}.refresh`,
94
+ title: "Refresh Tavily quota",
95
+ description: "Re-fetch remaining Tavily API credits and show a summary",
96
+ group: "Tavily",
97
+ palette: true,
98
+ slash: {
99
+ name: "tavily-quota"
100
+ },
101
+ run: async () => {
102
+ const next = await props.refresh();
103
+ const message = next.status === "ready" ? `${formatRemaining(next.snapshot)} · ${formatPercent(next.snapshot.percentRemaining)}` : next.status === "error" ? next.message : "still loading…";
104
+ props.context.ui.toast.show({
105
+ title: "Tavily quota",
106
+ message,
107
+ variant: next.status === "error" ? "error" : "info"
108
+ });
109
+ }
110
+ }]
111
+ }));
112
+ return null;
113
+ }
114
+ export const TavilyQuotaTuiPlugin = Plugin.define({
115
+ id: PLUGIN_ID,
116
+ async setup(context) {
117
+ const [state, setState] = createSignal({
118
+ status: "loading"
119
+ });
120
+ const apiKey = typeof context.options.apiKey === "string" ? context.options.apiKey : undefined;
121
+ const remote = context.client.rpc(TavilyQuotaRpc);
122
+ const refreshMs = typeof context.options.refreshMs === "number" && context.options.refreshMs > 0 ? context.options.refreshMs : REFRESH_INTERVAL_MS;
123
+ let disposed = false;
124
+ let inFlight = false;
125
+ const refresh = async () => {
126
+ if (inFlight) return state();
127
+ inFlight = true;
128
+ try {
129
+ const result = await fetchQuotaWithFallback({
130
+ apiKey,
131
+ remote: () => remote.usage({}, {
132
+ location: context.location ?? context.data.location.default()
133
+ })
134
+ });
135
+ if (!disposed) {
136
+ setState(result.ok ? {
137
+ status: "ready",
138
+ snapshot: result.snapshot
139
+ } : {
140
+ status: "error",
141
+ message: result.message
142
+ });
143
+ }
144
+ } catch (error) {
145
+ // `fetchQuota` is written never to throw, but a rejected promise here
146
+ // must not take down the sidebar: surface it inline instead.
147
+ if (!disposed) {
148
+ setState({
149
+ status: "error",
150
+ message: error instanceof Error ? error.message : String(error)
151
+ });
152
+ }
153
+ } finally {
154
+ inFlight = false;
155
+ }
156
+ return state();
157
+ };
158
+ const stopSidebar = context.ui.slot({
159
+ append: "sidebar.content",
160
+ render: () => _$createComponent(TavilyQuotaSidebar, {
161
+ context: context,
162
+ state: state
163
+ })
164
+ });
165
+
166
+ // `keymap.layer` reads Solid context, so it must be mounted from a slot
167
+ // component rather than called directly during plugin setup.
168
+ const stopCommands = context.ui.slot({
169
+ append: "app",
170
+ render: () => _$createComponent(TavilyQuotaCommands, {
171
+ context: context,
172
+ refresh: refresh
173
+ })
174
+ });
175
+ void refresh();
176
+ const interval = setInterval(() => void refresh(), refreshMs);
177
+ return () => {
178
+ disposed = true;
179
+ clearInterval(interval);
180
+ stopSidebar();
181
+ stopCommands();
182
+ };
183
+ }
184
+ });
185
+ export default TavilyQuotaTuiPlugin;
@@ -13,7 +13,6 @@
13
13
  import { readFileSync } from "node:fs";
14
14
  import { homedir } from "node:os";
15
15
  import { isAbsolute, join, resolve } from "node:path";
16
-
17
16
  export const USAGE_URL = "https://api.tavily.com/usage";
18
17
  export const DEFAULT_TIMEOUT_MS = 15_000;
19
18
 
@@ -36,59 +35,31 @@ export const BAR_SEPARATOR = " ";
36
35
  export const DEFAULT_BAR_WIDTH = SIDEBAR_WIDTH - BAR_SEPARATOR.length - PERCENT_COLUMN_WIDTH;
37
36
 
38
37
  /** Shape of `GET https://api.tavily.com/usage` (all fields optional). */
39
- export interface TavilyUsagePayload {
40
- key?: {
41
- usage?: number | null;
42
- limit?: number | null;
43
- } | null;
44
- account?: {
45
- current_plan?: string | null;
46
- plan_usage?: number | null;
47
- plan_limit?: number | null;
48
- paygo_usage?: number | null;
49
- paygo_limit?: number | null;
50
- } | null;
51
- }
52
-
53
- export interface QuotaSnapshot {
54
- /** Plan name reported by Tavily, e.g. `Researcher`. */
55
- planName: string;
56
- /** Credits consumed this billing cycle. */
57
- used: number;
58
- /** Credits included this billing cycle. */
59
- limit: number;
60
- /** Credits still available (`limit - used`, never negative). */
61
- remaining: number;
62
- /** `remaining / limit` as a percentage in `[0, 100]`. */
63
- percentRemaining: number;
64
- /** Milliseconds since epoch when the snapshot was produced. */
65
- updatedAt: number;
66
- }
67
38
 
68
- export type QuotaResult =
69
- | { ok: true; snapshot: QuotaSnapshot }
70
- | { ok: false; message: string };
71
-
72
- export interface FetchQuotaOptions {
73
- /** Environment to read `TAVILY_API_KEY` from. Defaults to `process.env`. */
74
- env?: Record<string, string | undefined>;
75
- /** Key supplied as a plugin option (e.g. from `cli.json`). */
76
- apiKey?: string;
77
- /** File reader for `{file:...}` references in `apiKey`. Injected by tests. */
78
- readFile?: (path: string) => string;
79
- /** Home directory for `~/` in `{file:...}`. Injected by tests. */
80
- home?: string;
81
- /** Base directory for relative `{file:...}` paths. Injected by tests. */
82
- cwd?: string;
83
- /** Request timeout in milliseconds. */
84
- timeoutMs?: number;
85
- /** Injectable fetch, used by tests. Defaults to the global `fetch`. */
86
- fetchImpl?: typeof fetch;
87
- /** Injectable clock, used by tests. Defaults to `Date.now`. */
88
- now?: number;
39
+ /** Prefer CLI credentials; ask the server only when the CLI has no key. */
40
+ export async function fetchQuotaWithFallback(options) {
41
+ try {
42
+ const key = resolveApiKey(options.env, options.apiKey, options);
43
+ if (key) return fetchQuota({
44
+ ...options,
45
+ apiKey: key,
46
+ env: {}
47
+ });
48
+ } catch {
49
+ // Preserve the existing inline error for an unreadable {file:...} option.
50
+ return fetchQuota(options);
51
+ }
52
+ try {
53
+ return await options.remote();
54
+ } catch (error) {
55
+ const reason = error instanceof Error ? error.message : String(error);
56
+ return {
57
+ ok: false,
58
+ message: `OpenCode Tavily credential unavailable: ${reason}`
59
+ };
60
+ }
89
61
  }
90
-
91
- function finite(value: unknown): number | null {
62
+ function finite(value) {
92
63
  return typeof value === "number" && Number.isFinite(value) ? value : null;
93
64
  }
94
65
 
@@ -98,16 +69,6 @@ function finite(value: unknown): number | null {
98
69
  * expansion lives here because `cli.json` passes plugin options through
99
70
  * verbatim.
100
71
  */
101
- export interface ExpandOptions {
102
- /** Environment used by `{env:NAME}`. Defaults to `process.env`. */
103
- env?: Record<string, string | undefined>;
104
- /** Reads a file as UTF-8. Defaults to `readFileSync`. Injected by tests. */
105
- readFile?: (path: string) => string;
106
- /** Home directory for `~/`. Defaults to `os.homedir()`. */
107
- home?: string;
108
- /** Base directory for relative paths. Defaults to `process.cwd()`. */
109
- cwd?: string;
110
- }
111
72
 
112
73
  const OPTION_REFERENCE = /\{(env|file):([^}]+)\}/g;
113
74
 
@@ -119,28 +80,21 @@ const OPTION_REFERENCE = /\{(env|file):([^}]+)\}/g;
119
80
  * directory and relative paths resolve against `cwd`. Unreadable files throw
120
81
  * with the offending reference and resolved path.
121
82
  */
122
- export function expandOptionValue(value: string, options: ExpandOptions = {}): string {
83
+ export function expandOptionValue(value, options = {}) {
123
84
  const env = options.env ?? process.env;
124
- const readFile = options.readFile ?? ((path: string) => readFileSync(path, "utf8"));
85
+ const readFile = options.readFile ?? (path => readFileSync(path, "utf8"));
125
86
  const home = options.home ?? homedir();
126
87
  const cwd = options.cwd ?? process.cwd();
127
-
128
- return value.replace(OPTION_REFERENCE, (match, kind: string, body: string) => {
88
+ return value.replace(OPTION_REFERENCE, (match, kind, body) => {
129
89
  if (kind === "env") return env[body] ?? "";
130
-
131
90
  const raw = body.trim();
132
- const path =
133
- raw === "~"
134
- ? home
135
- : raw.startsWith("~/")
136
- ? join(home, raw.slice(2))
137
- : isAbsolute(raw)
138
- ? raw
139
- : resolve(cwd, raw);
91
+ const path = raw === "~" ? home : raw.startsWith("~/") ? join(home, raw.slice(2)) : isAbsolute(raw) ? raw : resolve(cwd, raw);
140
92
  try {
141
93
  return readFile(path).trim();
142
94
  } catch (cause) {
143
- throw new Error(`bad file reference: "${match}" (${path}) could not be read`, { cause });
95
+ throw new Error(`bad file reference: "${match}" (${path}) could not be read`, {
96
+ cause
97
+ });
144
98
  }
145
99
  });
146
100
  }
@@ -152,16 +106,15 @@ export function expandOptionValue(value: string, options: ExpandOptions = {}): s
152
106
  * `expandOptionValue`), since `cli.json` does not expand them itself. Never
153
107
  * logs or returns the key beyond the caller.
154
108
  */
155
- export function resolveApiKey(
156
- env: Record<string, string | undefined> = process.env,
157
- apiKey?: string,
158
- options: Omit<ExpandOptions, "env"> = {},
159
- ): string {
109
+ export function resolveApiKey(env = process.env, apiKey, options = {}) {
160
110
  const fromEnv = (env.TAVILY_API_KEY ?? "").trim();
161
111
  if (fromEnv) return fromEnv;
162
112
  const raw = (apiKey ?? "").trim();
163
113
  if (!raw) return "";
164
- return expandOptionValue(raw, { ...options, env }).trim();
114
+ return expandOptionValue(raw, {
115
+ ...options,
116
+ env
117
+ }).trim();
165
118
  }
166
119
 
167
120
  /**
@@ -171,32 +124,24 @@ export function resolveApiKey(
171
124
  * is often `null` while the account still has a monthly allowance. Falls back
172
125
  * to the key limit when no positive plan limit is reported.
173
126
  */
174
- export function deriveSnapshot(
175
- payload: TavilyUsagePayload,
176
- now: number = Date.now(),
177
- ): QuotaResult {
127
+ export function deriveSnapshot(payload, now = Date.now()) {
178
128
  const account = payload.account ?? {};
179
129
  const key = payload.key ?? {};
180
-
181
130
  const planLimit = finite(account.plan_limit);
182
131
  const planUsage = finite(account.plan_usage);
183
132
  const keyLimit = finite(key.limit);
184
133
  const keyUsage = finite(key.usage);
185
-
186
134
  const usePlan = planLimit !== null && planLimit > 0;
187
135
  const limit = usePlan ? planLimit : keyLimit;
188
136
  const used = usePlan ? planUsage ?? 0 : keyUsage ?? 0;
189
-
190
137
  if (limit === null || limit <= 0) {
191
- return { ok: false, message: "Tavily returned no credit limit for this key" };
138
+ return {
139
+ ok: false,
140
+ message: "Tavily returned no credit limit for this key"
141
+ };
192
142
  }
193
-
194
143
  const remaining = Math.max(0, limit - used);
195
- const planName =
196
- typeof account.current_plan === "string" && account.current_plan.trim()
197
- ? account.current_plan.trim()
198
- : "Tavily";
199
-
144
+ const planName = typeof account.current_plan === "string" && account.current_plan.trim() ? account.current_plan.trim() : "Tavily";
200
145
  return {
201
146
  ok: true,
202
147
  snapshot: {
@@ -204,23 +149,23 @@ export function deriveSnapshot(
204
149
  used,
205
150
  limit,
206
151
  remaining,
207
- percentRemaining: (remaining / limit) * 100,
208
- updatedAt: now,
209
- },
152
+ percentRemaining: remaining / limit * 100,
153
+ updatedAt: now
154
+ }
210
155
  };
211
156
  }
212
157
 
213
158
  /** Format a remaining percentage for the sidebar. */
214
- export function formatPercent(percentRemaining: number): string {
159
+ export function formatPercent(percentRemaining) {
215
160
  const clamped = Math.max(0, Math.min(100, percentRemaining));
216
161
  return `${clamped.toFixed(clamped >= 10 ? 0 : 1)}%`;
217
162
  }
218
163
 
219
164
  /** Render a fixed-width progress bar for a remaining percentage. */
220
- export function progressBar(percentRemaining: number, width = DEFAULT_BAR_WIDTH): string {
165
+ export function progressBar(percentRemaining, width = DEFAULT_BAR_WIDTH) {
221
166
  const clamped = Math.max(0, Math.min(100, percentRemaining));
222
167
  const safeWidth = Math.max(1, Math.floor(width));
223
- const filled = Math.round((clamped / 100) * safeWidth);
168
+ const filled = Math.round(clamped / 100 * safeWidth);
224
169
  return "█".repeat(filled) + "░".repeat(Math.max(0, safeWidth - filled));
225
170
  }
226
171
 
@@ -231,16 +176,13 @@ export function progressBar(percentRemaining: number, width = DEFAULT_BAR_WIDTH)
231
176
  * same right-aligned percent column, so the two sections line up. The exact
232
177
  * credit count is still available from `formatRemaining` (used by the toast).
233
178
  */
234
- export function formatQuotaLine(
235
- snapshot: QuotaSnapshot,
236
- width = DEFAULT_BAR_WIDTH,
237
- ): string {
179
+ export function formatQuotaLine(snapshot, width = DEFAULT_BAR_WIDTH) {
238
180
  const label = `${formatPercent(snapshot.percentRemaining)} left`;
239
181
  return `${progressBar(snapshot.percentRemaining, width)}${BAR_SEPARATOR}${label.padStart(PERCENT_COLUMN_WIDTH)}`;
240
182
  }
241
183
 
242
184
  /** Format the headline line, e.g. `839 credits left`. */
243
- export function formatRemaining(snapshot: QuotaSnapshot): string {
185
+ export function formatRemaining(snapshot) {
244
186
  return `${snapshot.remaining} credits left`;
245
187
  }
246
188
 
@@ -250,50 +192,66 @@ export function formatRemaining(snapshot: QuotaSnapshot): string {
250
192
  * Never throws: every failure is returned as `{ ok: false, message }` so the
251
193
  * sidebar can render it inline.
252
194
  */
253
- export async function fetchQuota(options: FetchQuotaOptions = {}): Promise<QuotaResult> {
254
- let key: string;
195
+ export async function fetchQuota(options = {}) {
196
+ let key;
255
197
  try {
256
198
  key = resolveApiKey(options.env, options.apiKey, options);
257
199
  } catch (error) {
258
200
  // A `{file:...}` option can point at a missing or unreadable file; surface
259
201
  // that inline instead of rejecting the sidebar render.
260
- return { ok: false, message: error instanceof Error ? error.message : String(error) };
202
+ return {
203
+ ok: false,
204
+ message: error instanceof Error ? error.message : String(error)
205
+ };
261
206
  }
262
207
  if (!key) {
263
208
  return {
264
209
  ok: false,
265
- message: "no Tavily API key (set TAVILY_API_KEY or the apiKey plugin option)",
210
+ message: "no Tavily API key (set TAVILY_API_KEY or the apiKey plugin option)"
266
211
  };
267
212
  }
268
-
269
213
  const fetchImpl = options.fetchImpl ?? fetch;
270
- let response: Response;
214
+ let response;
271
215
  try {
272
216
  response = await fetchImpl(USAGE_URL, {
273
- headers: { Authorization: `Bearer ${key}` },
274
- signal: AbortSignal.timeout(options.timeoutMs ?? DEFAULT_TIMEOUT_MS),
217
+ headers: {
218
+ Authorization: `Bearer ${key}`
219
+ },
220
+ signal: AbortSignal.timeout(options.timeoutMs ?? DEFAULT_TIMEOUT_MS)
275
221
  });
276
222
  } catch (error) {
277
223
  const reason = error instanceof Error ? error.message : String(error);
278
- return { ok: false, message: `request failed: ${reason}` };
224
+ return {
225
+ ok: false,
226
+ message: `request failed: ${reason}`
227
+ };
279
228
  }
280
-
281
229
  if (response.status === 401) {
282
- return { ok: false, message: "API key rejected (401)" };
230
+ return {
231
+ ok: false,
232
+ message: "API key rejected (401)"
233
+ };
283
234
  }
284
235
  if (response.status === 429) {
285
- return { ok: false, message: "rate limited on /usage (10 req / 10 min)" };
236
+ return {
237
+ ok: false,
238
+ message: "rate limited on /usage (10 req / 10 min)"
239
+ };
286
240
  }
287
241
  if (!response.ok) {
288
- return { ok: false, message: `HTTP ${response.status} from /usage` };
242
+ return {
243
+ ok: false,
244
+ message: `HTTP ${response.status} from /usage`
245
+ };
289
246
  }
290
-
291
- let payload: TavilyUsagePayload;
247
+ let payload;
292
248
  try {
293
- payload = (await response.json()) as TavilyUsagePayload;
249
+ payload = await response.json();
294
250
  } catch {
295
- return { ok: false, message: "invalid JSON from /usage" };
251
+ return {
252
+ ok: false,
253
+ message: "invalid JSON from /usage"
254
+ };
296
255
  }
297
-
298
256
  return deriveSnapshot(payload, options.now);
299
257
  }
package/package.json CHANGED
@@ -1,24 +1,25 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
3
  "name": "@cardinal4/opencode-tavily-quota",
4
- "version": "1.0.0",
4
+ "version": "1.0.2",
5
5
  "description": "OpenCode 2 TUI plugin that shows remaining Tavily API credits in the sidebar.",
6
6
  "type": "module",
7
7
  "license": "MIT",
8
+ "main": "./dist/index.js",
8
9
  "exports": {
9
- ".": "./index.ts",
10
- "./server": "./index.ts",
11
- "./tui": "./tui.tsx"
10
+ ".": "./dist/index.js",
11
+ "./server": "./dist/index.js",
12
+ "./tui": "./dist/tui.js"
12
13
  },
13
14
  "files": [
14
- "index.ts",
15
- "tui.tsx",
16
- "usage.ts",
15
+ "dist",
17
16
  "README.md",
18
17
  "LICENSE"
19
18
  ],
20
19
  "scripts": {
21
- "test": "tsx --test usage.test.ts",
20
+ "build": "node scripts/build.mjs",
21
+ "prepublishOnly": "npm run build",
22
+ "test": "tsx --test usage.test.ts index.test.ts",
22
23
  "typecheck": "tsc --noEmit"
23
24
  },
24
25
  "keywords": [
@@ -37,26 +38,17 @@
37
38
  "url": "https://github.com/cardin/opencode-tavily-quota/issues"
38
39
  },
39
40
  "homepage": "https://github.com/cardin/opencode-tavily-quota#readme",
40
- "peerDependencies": {
41
- "@opencode/plugin": "^2.0.7",
42
- "@opentui/core": ">=0.5.10",
43
- "@opentui/solid": ">=0.5.10",
44
- "solid-js": ">=1.9.0"
45
- },
46
- "peerDependenciesMeta": {
47
- "@opentui/core": {
48
- "optional": true
49
- },
50
- "@opentui/solid": {
51
- "optional": true
52
- }
53
- },
54
- "devDependencies": {
41
+ "dependencies": {
55
42
  "@opencode/plugin": "^2.0.7",
56
43
  "@opentui/core": "^0.5.11",
57
44
  "@opentui/solid": "^0.5.11",
45
+ "solid-js": "^1.9.0"
46
+ },
47
+ "devDependencies": {
48
+ "@babel/core": "^7.28.0",
49
+ "@babel/preset-typescript": "^7.27.1",
58
50
  "@types/node": "^22.0.0",
59
- "solid-js": "^1.9.0",
51
+ "babel-preset-solid": "^1.9.12",
60
52
  "tsx": "^4.20.0",
61
53
  "typescript": "^5.6.0"
62
54
  },
package/index.ts DELETED
@@ -1,23 +0,0 @@
1
- /**
2
- * Server entrypoint for the Tavily quota sidebar plugin.
3
- *
4
- * OpenCode discovers a local plugin directory by its `index` (server) entry and
5
- * loads the `tui` entry beside it for the terminal UI. All rendering happens in
6
- * the CLI runtime (`tui.tsx`); this file only exists so the directory is a
7
- * complete plugin and shows up by id instead of as an anonymous entry.
8
- *
9
- * It deliberately imports nothing beyond `@opencode/plugin` so the background
10
- * service can load it in any runtime.
11
- */
12
-
13
- import { Plugin } from "@opencode/plugin";
14
-
15
- export const PLUGIN_ID = "opencode-tavily-quota";
16
-
17
- export default Plugin.define({
18
- id: PLUGIN_ID,
19
- setup() {
20
- // Intentional no-op: the sidebar and command are registered by the TUI
21
- // entrypoint (tui.tsx), which runs in the CLI process.
22
- },
23
- });
package/tui.tsx DELETED
@@ -1,197 +0,0 @@
1
- /** @jsxImportSource @opentui/solid */
2
-
3
- /**
4
- * Tavily quota sidebar — an OpenCode 2 TUI plugin.
5
- *
6
- * Renders the remaining Tavily API credits in the session sidebar
7
- * (`sidebar.content`) and exposes a `/tavily-quota` slash command /
8
- * "Refresh Tavily quota" palette command that re-fetches on demand.
9
- *
10
- * The plugin owns no session state: Tavily credits are account-wide, so a
11
- * single snapshot is shared by every sidebar render and refreshed on a timer.
12
- * Colors follow the built-in quota sections: the heading uses the default text
13
- * color and the body (bar included) uses the muted/subdued text color.
14
- *
15
- * Authenticate with `TAVILY_API_KEY`, or with the `apiKey` plugin option
16
- * configured on this plugin in `cli.json`. Resolution and the network call
17
- * live in `./usage.ts`.
18
- */
19
-
20
- import type { RGBA } from "@opentui/core";
21
- import { Plugin } from "@opencode/plugin/tui";
22
- import { createSignal } from "solid-js";
23
- import {
24
- fetchQuota,
25
- formatPercent,
26
- formatQuotaLine,
27
- formatRemaining,
28
- type QuotaSnapshot,
29
- } from "./usage";
30
-
31
- type Context = Plugin.Context;
32
-
33
- export const PLUGIN_ID = "opencode-tavily-quota";
34
- // Tavily's `/usage` endpoint allows at most 10 requests per 10 minutes, so poll
35
- // once per window and rely on the `/tavily-quota` command for on-demand reads.
36
- const REFRESH_INTERVAL_MS = 600_000;
37
-
38
- type ViewState =
39
- | { status: "loading" }
40
- | { status: "ready"; snapshot: QuotaSnapshot }
41
- | { status: "error"; message: string };
42
-
43
- type ThemeColors = {
44
- text?: RGBA;
45
- textMuted?: RGBA;
46
- };
47
-
48
- /**
49
- * OpenCode 2.0.7 exposes `text.default` / `text.subdued`; later builds renamed
50
- * the leaves to `text.base` / `text.muted`. Read whichever the running build
51
- * provides so the plugin matches the built-in quota sections on both.
52
- */
53
- function readThemeColors(context: Context): ThemeColors {
54
- const text = context.theme.text as unknown as {
55
- default?: RGBA;
56
- base?: RGBA;
57
- subdued?: RGBA;
58
- muted?: RGBA;
59
- };
60
- return {
61
- text: text.default ?? text.base,
62
- textMuted: text.subdued ?? text.muted,
63
- };
64
- }
65
-
66
- export function buildLines(state: ViewState): string[] {
67
- if (state.status === "loading") {
68
- return ["loading…"];
69
- }
70
- if (state.status === "error") {
71
- return [`⚠ ${state.message}`];
72
- }
73
-
74
- const snapshot = state.snapshot;
75
- return [formatQuotaLine(snapshot)];
76
- }
77
-
78
- function TavilyQuotaSidebar(props: { context: Context; state: () => ViewState }) {
79
- const colors = () => readThemeColors(props.context);
80
- // Keep the quota line on one line; let an error message wrap instead of
81
- // truncating it, since it can be longer than the sidebar.
82
- const wrapMode = () => (props.state().status === "error" ? "word" : "none");
83
-
84
- return (
85
- <box gap={0} flexDirection="column">
86
- <text fg={colors().text}>
87
- <b>Tavily</b>
88
- </text>
89
- {buildLines(props.state()).map((line) => (
90
- <text fg={colors().textMuted} wrapMode={wrapMode()}>
91
- {line}
92
- </text>
93
- ))}
94
- </box>
95
- );
96
- }
97
-
98
- function TavilyQuotaCommands(props: {
99
- context: Context;
100
- refresh: () => Promise<ViewState>;
101
- }) {
102
- props.context.keymap.layer(() => ({
103
- mode: "global",
104
- commands: [
105
- {
106
- id: `${PLUGIN_ID}.refresh`,
107
- title: "Refresh Tavily quota",
108
- description: "Re-fetch remaining Tavily API credits and show a summary",
109
- group: "Tavily",
110
- palette: true,
111
- slash: { name: "tavily-quota" },
112
- run: async () => {
113
- const next = await props.refresh();
114
- const message =
115
- next.status === "ready"
116
- ? `${formatRemaining(next.snapshot)} · ${formatPercent(next.snapshot.percentRemaining)}`
117
- : next.status === "error"
118
- ? next.message
119
- : "still loading…";
120
- props.context.ui.toast.show({
121
- title: "Tavily quota",
122
- message,
123
- variant: next.status === "error" ? "error" : "info",
124
- });
125
- },
126
- },
127
- ],
128
- }));
129
-
130
- return null;
131
- }
132
-
133
- export const TavilyQuotaTuiPlugin = Plugin.define({
134
- id: PLUGIN_ID,
135
- async setup(context) {
136
- const [state, setState] = createSignal<ViewState>({ status: "loading" });
137
- const apiKey = typeof context.options.apiKey === "string" ? context.options.apiKey : undefined;
138
- const refreshMs =
139
- typeof context.options.refreshMs === "number" && context.options.refreshMs > 0
140
- ? context.options.refreshMs
141
- : REFRESH_INTERVAL_MS;
142
-
143
- let disposed = false;
144
- let inFlight = false;
145
-
146
- const refresh = async (): Promise<ViewState> => {
147
- if (inFlight) return state();
148
- inFlight = true;
149
- try {
150
- const result = await fetchQuota({ apiKey });
151
- if (!disposed) {
152
- setState(
153
- result.ok
154
- ? { status: "ready", snapshot: result.snapshot }
155
- : { status: "error", message: result.message },
156
- );
157
- }
158
- } catch (error) {
159
- // `fetchQuota` is written never to throw, but a rejected promise here
160
- // must not take down the sidebar: surface it inline instead.
161
- if (!disposed) {
162
- setState({
163
- status: "error",
164
- message: error instanceof Error ? error.message : String(error),
165
- });
166
- }
167
- } finally {
168
- inFlight = false;
169
- }
170
- return state();
171
- };
172
-
173
- const stopSidebar = context.ui.slot({
174
- append: "sidebar.content",
175
- render: () => <TavilyQuotaSidebar context={context} state={state} />,
176
- });
177
-
178
- // `keymap.layer` reads Solid context, so it must be mounted from a slot
179
- // component rather than called directly during plugin setup.
180
- const stopCommands = context.ui.slot({
181
- append: "app",
182
- render: () => <TavilyQuotaCommands context={context} refresh={refresh} />,
183
- });
184
-
185
- void refresh();
186
- const interval = setInterval(() => void refresh(), refreshMs);
187
-
188
- return () => {
189
- disposed = true;
190
- clearInterval(interval);
191
- stopSidebar();
192
- stopCommands();
193
- };
194
- },
195
- });
196
-
197
- export default TavilyQuotaTuiPlugin;