@cardinal4/opencode-tavily-quota 1.0.0

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 (6) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +134 -0
  3. package/index.ts +23 -0
  4. package/package.json +69 -0
  5. package/tui.tsx +197 -0
  6. package/usage.ts +299 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 opencode-tavily-quota contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,134 @@
1
+ # opencode-tavily-quota
2
+
3
+ An OpenCode 2 TUI plugin that shows the remaining Tavily API credits in the
4
+ session sidebar.
5
+
6
+ ```
7
+ Tavily
8
+ █████████████████████████ 84% left
9
+ ```
10
+
11
+ ## Install
12
+
13
+ ```sh
14
+ opencode plugin add @cardinal4/opencode-tavily-quota
15
+ ```
16
+
17
+ Or declare it in `cli.json` to pass the API key option (see
18
+ [Configuration](#configuration)). If a local copy exists under
19
+ `~/.config/opencode/plugins/opencode-tavily-quota/`, remove it after installing
20
+ the package so the plugin is not loaded twice.
21
+
22
+ The sidebar follows the built-in quota styling — the heading uses the default
23
+ text color and the body (bar included) uses the muted/subdued text color. It
24
+ polls every 10 minutes and can be refreshed on demand with the command below.
25
+
26
+ ## Requirements
27
+
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.
36
+
37
+ The `apiKey` option also accepts `{file:...}` and `{env:...}` references, which
38
+ the plugin expands itself because `cli.json` does not (unlike `opencode.jsonc`).
39
+
40
+ ## Layout
41
+
42
+ ```
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
48
+ ```
49
+
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.
54
+
55
+ ## Configuration
56
+
57
+ Register the plugin in `cli.json` to pass the API key as a plugin option:
58
+
59
+ ```json title="cli.json"
60
+ {
61
+ "plugins": [
62
+ {
63
+ "package": "file:///home/me/.config/opencode/plugins/opencode-tavily-quota",
64
+ "options": {
65
+ "apiKey": "tvly-..."
66
+ }
67
+ }
68
+ ]
69
+ }
70
+ ```
71
+
72
+ The CLI-only `cli.json` entry also keeps the plugin active when the TUI is
73
+ connected to a remote server. The same entry accepts a `refreshMs` option to
74
+ override the default 10 minute poll interval (Tavily's `/usage` endpoint allows
75
+ at most 10 requests per 10 minutes, so keep it at or above that).
76
+
77
+ ### Keeping the key out of the file
78
+
79
+ `cli.json` is not processed like `opencode.jsonc`: OpenCode does not expand
80
+ `{file:...}` or `{env:...}` in plugin options, so the plugin does it. Use either
81
+ reference as `apiKey` to avoid pasting the raw key into `cli.json`:
82
+
83
+ ```json title="cli.json"
84
+ {
85
+ "plugins": [
86
+ {
87
+ "package": "file:///home/me/.config/opencode/plugins/opencode-tavily-quota",
88
+ "options": {
89
+ "apiKey": "{file:~/.secrets/tavily-api-key}"
90
+ }
91
+ }
92
+ ]
93
+ }
94
+ ```
95
+
96
+ `{env:NAME}` reads an environment variable (empty when unset). `{file:PATH}`
97
+ reads a file's trimmed contents, with `~/` expanding to the home directory and
98
+ relative paths resolving against the CLI's working directory. `TAVILY_API_KEY`
99
+ still wins over `apiKey` when both are set. A missing or unreadable file is
100
+ reported in the sidebar instead of being used as the key.
101
+
102
+ ## Commands
103
+
104
+ - `/tavily-quota` — slash command that re-fetches and reports the current quota
105
+ in a toast.
106
+ - "Refresh Tavily quota" — the same action from the command palette (`Ctrl+P`).
107
+
108
+ ## Data source
109
+
110
+ `GET https://api.tavily.com/usage` with `Authorization: Bearer <key>`. The
111
+ plugin prefers the account plan (`account.plan_limit` / `account.plan_usage`,
112
+ e.g. `Researcher` with 1000 monthly credits) and falls back to the per-key limit
113
+ when the plan limit is absent. A `null` per-key limit (unlimited key) is
114
+ therefore handled.
115
+
116
+ ## Development
117
+
118
+ `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`.
122
+
123
+ Run the tests and type check from the repository root:
124
+
125
+ ```sh
126
+ npm install
127
+ npm test # tsx --test usage.test.ts
128
+ npm run typecheck
129
+ ```
130
+
131
+ ## Notes
132
+
133
+ - The plugin is a no-op on the server. All work happens in the CLI runtime,
134
+ which is where TUI slots live.
package/index.ts ADDED
@@ -0,0 +1,23 @@
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/package.json ADDED
@@ -0,0 +1,69 @@
1
+ {
2
+ "$schema": "https://json.schemastore.org/package.json",
3
+ "name": "@cardinal4/opencode-tavily-quota",
4
+ "version": "1.0.0",
5
+ "description": "OpenCode 2 TUI plugin that shows remaining Tavily API credits in the sidebar.",
6
+ "type": "module",
7
+ "license": "MIT",
8
+ "exports": {
9
+ ".": "./index.ts",
10
+ "./server": "./index.ts",
11
+ "./tui": "./tui.tsx"
12
+ },
13
+ "files": [
14
+ "index.ts",
15
+ "tui.tsx",
16
+ "usage.ts",
17
+ "README.md",
18
+ "LICENSE"
19
+ ],
20
+ "scripts": {
21
+ "test": "tsx --test usage.test.ts",
22
+ "typecheck": "tsc --noEmit"
23
+ },
24
+ "keywords": [
25
+ "opencode",
26
+ "opencode-plugin",
27
+ "opencode-tui",
28
+ "tavily",
29
+ "quota",
30
+ "credits"
31
+ ],
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "git+https://github.com/cardin/opencode-tavily-quota.git"
35
+ },
36
+ "bugs": {
37
+ "url": "https://github.com/cardin/opencode-tavily-quota/issues"
38
+ },
39
+ "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": {
55
+ "@opencode/plugin": "^2.0.7",
56
+ "@opentui/core": "^0.5.11",
57
+ "@opentui/solid": "^0.5.11",
58
+ "@types/node": "^22.0.0",
59
+ "solid-js": "^1.9.0",
60
+ "tsx": "^4.20.0",
61
+ "typescript": "^5.6.0"
62
+ },
63
+ "engines": {
64
+ "node": ">=22.0.0"
65
+ },
66
+ "publishConfig": {
67
+ "access": "public"
68
+ }
69
+ }
package/tui.tsx ADDED
@@ -0,0 +1,197 @@
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;
package/usage.ts ADDED
@@ -0,0 +1,299 @@
1
+ /**
2
+ * Tavily quota client for the sidebar plugin.
3
+ *
4
+ * Pure formatting/derivation helpers are split from the single network call so
5
+ * they can be unit tested without touching the network, real credentials, or
6
+ * the OpenCode runtime. Node builtins only; no OpenCode or OpenTUI imports.
7
+ *
8
+ * The `apiKey` plugin option may be a literal key or an `{env:NAME}` /
9
+ * `{file:PATH}` reference. OpenCode expands those references in
10
+ * `opencode.json(c)` but not in `cli.json`, so this module expands them itself.
11
+ */
12
+
13
+ import { readFileSync } from "node:fs";
14
+ import { homedir } from "node:os";
15
+ import { isAbsolute, join, resolve } from "node:path";
16
+
17
+ export const USAGE_URL = "https://api.tavily.com/usage";
18
+ export const DEFAULT_TIMEOUT_MS = 15_000;
19
+
20
+ /**
21
+ * Sidebar content width shared with the built-in quota section, which renders
22
+ * in a 36-column sidebar (`TUI_SIDEBAR_MAX_WIDTH` in opencode-quota).
23
+ */
24
+ export const SIDEBAR_WIDTH = 36;
25
+ /**
26
+ * Width the quota section reserves for its `100% left` / `100% used` label, so
27
+ * labels line up vertically across sections.
28
+ */
29
+ export const PERCENT_COLUMN_WIDTH = "100% left".length;
30
+ /** Gap between the bar and its label, matching the quota section's separator. */
31
+ export const BAR_SEPARATOR = " ";
32
+ /**
33
+ * Bar width that lines the Tavily bar up with the quota section's bars:
34
+ * `36 (sidebar) − 2 (separator) − 9 (percent column) = 25`.
35
+ */
36
+ export const DEFAULT_BAR_WIDTH = SIDEBAR_WIDTH - BAR_SEPARATOR.length - PERCENT_COLUMN_WIDTH;
37
+
38
+ /** 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
+
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;
89
+ }
90
+
91
+ function finite(value: unknown): number | null {
92
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
93
+ }
94
+
95
+ /**
96
+ * `{env:NAME}` and `{file:PATH}` references accepted in the `apiKey` option.
97
+ * This mirrors the references OpenCode expands in `opencode.json(c)`, but the
98
+ * expansion lives here because `cli.json` passes plugin options through
99
+ * verbatim.
100
+ */
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
+
112
+ const OPTION_REFERENCE = /\{(env|file):([^}]+)\}/g;
113
+
114
+ /**
115
+ * Expand every `{env:NAME}` and `{file:PATH}` reference in a plugin option.
116
+ *
117
+ * `{env:NAME}` reads an environment variable (empty string when unset).
118
+ * `{file:PATH}` reads a file's trimmed contents; `~/` expands to the home
119
+ * directory and relative paths resolve against `cwd`. Unreadable files throw
120
+ * with the offending reference and resolved path.
121
+ */
122
+ export function expandOptionValue(value: string, options: ExpandOptions = {}): string {
123
+ const env = options.env ?? process.env;
124
+ const readFile = options.readFile ?? ((path: string) => readFileSync(path, "utf8"));
125
+ const home = options.home ?? homedir();
126
+ const cwd = options.cwd ?? process.cwd();
127
+
128
+ return value.replace(OPTION_REFERENCE, (match, kind: string, body: string) => {
129
+ if (kind === "env") return env[body] ?? "";
130
+
131
+ 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);
140
+ try {
141
+ return readFile(path).trim();
142
+ } catch (cause) {
143
+ throw new Error(`bad file reference: "${match}" (${path}) could not be read`, { cause });
144
+ }
145
+ });
146
+ }
147
+
148
+ /**
149
+ * Resolve the Tavily API key: `TAVILY_API_KEY` first, then the `apiKey` plugin
150
+ * option configured in `cli.json`. Reference values such as
151
+ * `{file:~/.secrets/tavily-api-key}` are expanded here (see
152
+ * `expandOptionValue`), since `cli.json` does not expand them itself. Never
153
+ * logs or returns the key beyond the caller.
154
+ */
155
+ export function resolveApiKey(
156
+ env: Record<string, string | undefined> = process.env,
157
+ apiKey?: string,
158
+ options: Omit<ExpandOptions, "env"> = {},
159
+ ): string {
160
+ const fromEnv = (env.TAVILY_API_KEY ?? "").trim();
161
+ if (fromEnv) return fromEnv;
162
+ const raw = (apiKey ?? "").trim();
163
+ if (!raw) return "";
164
+ return expandOptionValue(raw, { ...options, env }).trim();
165
+ }
166
+
167
+ /**
168
+ * Derive the headline credit quota from a `/usage` payload.
169
+ *
170
+ * Prefers the account plan (`plan_limit`/`plan_usage`) because a per-key limit
171
+ * is often `null` while the account still has a monthly allowance. Falls back
172
+ * to the key limit when no positive plan limit is reported.
173
+ */
174
+ export function deriveSnapshot(
175
+ payload: TavilyUsagePayload,
176
+ now: number = Date.now(),
177
+ ): QuotaResult {
178
+ const account = payload.account ?? {};
179
+ const key = payload.key ?? {};
180
+
181
+ const planLimit = finite(account.plan_limit);
182
+ const planUsage = finite(account.plan_usage);
183
+ const keyLimit = finite(key.limit);
184
+ const keyUsage = finite(key.usage);
185
+
186
+ const usePlan = planLimit !== null && planLimit > 0;
187
+ const limit = usePlan ? planLimit : keyLimit;
188
+ const used = usePlan ? planUsage ?? 0 : keyUsage ?? 0;
189
+
190
+ if (limit === null || limit <= 0) {
191
+ return { ok: false, message: "Tavily returned no credit limit for this key" };
192
+ }
193
+
194
+ 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
+
200
+ return {
201
+ ok: true,
202
+ snapshot: {
203
+ planName,
204
+ used,
205
+ limit,
206
+ remaining,
207
+ percentRemaining: (remaining / limit) * 100,
208
+ updatedAt: now,
209
+ },
210
+ };
211
+ }
212
+
213
+ /** Format a remaining percentage for the sidebar. */
214
+ export function formatPercent(percentRemaining: number): string {
215
+ const clamped = Math.max(0, Math.min(100, percentRemaining));
216
+ return `${clamped.toFixed(clamped >= 10 ? 0 : 1)}%`;
217
+ }
218
+
219
+ /** Render a fixed-width progress bar for a remaining percentage. */
220
+ export function progressBar(percentRemaining: number, width = DEFAULT_BAR_WIDTH): string {
221
+ const clamped = Math.max(0, Math.min(100, percentRemaining));
222
+ const safeWidth = Math.max(1, Math.floor(width));
223
+ const filled = Math.round((clamped / 100) * safeWidth);
224
+ return "█".repeat(filled) + "░".repeat(Math.max(0, safeWidth - filled));
225
+ }
226
+
227
+ /**
228
+ * Compose the single sidebar line: the bar plus a `84% left` label.
229
+ *
230
+ * The bar matches the built-in quota section's bars and the label sits in the
231
+ * same right-aligned percent column, so the two sections line up. The exact
232
+ * credit count is still available from `formatRemaining` (used by the toast).
233
+ */
234
+ export function formatQuotaLine(
235
+ snapshot: QuotaSnapshot,
236
+ width = DEFAULT_BAR_WIDTH,
237
+ ): string {
238
+ const label = `${formatPercent(snapshot.percentRemaining)} left`;
239
+ return `${progressBar(snapshot.percentRemaining, width)}${BAR_SEPARATOR}${label.padStart(PERCENT_COLUMN_WIDTH)}`;
240
+ }
241
+
242
+ /** Format the headline line, e.g. `839 credits left`. */
243
+ export function formatRemaining(snapshot: QuotaSnapshot): string {
244
+ return `${snapshot.remaining} credits left`;
245
+ }
246
+
247
+ /**
248
+ * Fetch and derive the current Tavily quota.
249
+ *
250
+ * Never throws: every failure is returned as `{ ok: false, message }` so the
251
+ * sidebar can render it inline.
252
+ */
253
+ export async function fetchQuota(options: FetchQuotaOptions = {}): Promise<QuotaResult> {
254
+ let key: string;
255
+ try {
256
+ key = resolveApiKey(options.env, options.apiKey, options);
257
+ } catch (error) {
258
+ // A `{file:...}` option can point at a missing or unreadable file; surface
259
+ // that inline instead of rejecting the sidebar render.
260
+ return { ok: false, message: error instanceof Error ? error.message : String(error) };
261
+ }
262
+ if (!key) {
263
+ return {
264
+ ok: false,
265
+ message: "no Tavily API key (set TAVILY_API_KEY or the apiKey plugin option)",
266
+ };
267
+ }
268
+
269
+ const fetchImpl = options.fetchImpl ?? fetch;
270
+ let response: Response;
271
+ try {
272
+ response = await fetchImpl(USAGE_URL, {
273
+ headers: { Authorization: `Bearer ${key}` },
274
+ signal: AbortSignal.timeout(options.timeoutMs ?? DEFAULT_TIMEOUT_MS),
275
+ });
276
+ } catch (error) {
277
+ const reason = error instanceof Error ? error.message : String(error);
278
+ return { ok: false, message: `request failed: ${reason}` };
279
+ }
280
+
281
+ if (response.status === 401) {
282
+ return { ok: false, message: "API key rejected (401)" };
283
+ }
284
+ if (response.status === 429) {
285
+ return { ok: false, message: "rate limited on /usage (10 req / 10 min)" };
286
+ }
287
+ if (!response.ok) {
288
+ return { ok: false, message: `HTTP ${response.status} from /usage` };
289
+ }
290
+
291
+ let payload: TavilyUsagePayload;
292
+ try {
293
+ payload = (await response.json()) as TavilyUsagePayload;
294
+ } catch {
295
+ return { ok: false, message: "invalid JSON from /usage" };
296
+ }
297
+
298
+ return deriveSnapshot(payload, options.now);
299
+ }