quotacap 0.0.36 → 0.0.38

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 (39) hide show
  1. package/README.md +6 -4
  2. package/dist/adapters/index.js +4 -1
  3. package/dist/adapters/muse.d.ts +28 -0
  4. package/dist/adapters/muse.js +124 -33
  5. package/dist/adapters/opencode-go.d.ts +11 -0
  6. package/dist/adapters/opencode-go.js +122 -0
  7. package/dist/advisory/provider-names.js +9 -0
  8. package/dist/advisory/snapshot.d.ts +1 -0
  9. package/dist/advisory/types.d.ts +3 -0
  10. package/dist/cli/providers.js +121 -1
  11. package/dist/cli/status.js +6 -0
  12. package/dist/config.d.ts +11 -1
  13. package/dist/config.js +52 -3
  14. package/dist/diagnostics/failure.js +16 -3
  15. package/dist/http/server.d.ts +2 -0
  16. package/dist/http/server.js +37 -1
  17. package/dist/runtime/service.js +2 -0
  18. package/dist/version.js +1 -1
  19. package/dist/web/src/App.js +53 -4
  20. package/dist/web/src/api.d.ts +2 -0
  21. package/dist/web/src/api.js +20 -0
  22. package/dist/web/src/components/FaultBanner.d.ts +2 -1
  23. package/dist/web/src/components/FaultBanner.js +8 -2
  24. package/dist/web/src/components/OpencodeGoBanner.d.ts +12 -0
  25. package/dist/web/src/components/OpencodeGoBanner.js +36 -0
  26. package/dist/web/src/components/ProviderDrawer.js +3 -0
  27. package/dist/web/src/components/settings/ProvidersPanel.d.ts +2 -1
  28. package/dist/web/src/components/settings/ProvidersPanel.js +12 -10
  29. package/dist/web/src/components/settings/SettingsDrawer.d.ts +2 -1
  30. package/dist/web/src/components/settings/SettingsDrawer.js +2 -2
  31. package/dist/web/src/pages/Onboarding.js +2 -2
  32. package/dist/web/src/state.d.ts +3 -1
  33. package/dist/web/src/state.js +10 -2
  34. package/dist/webAssets.js +1 -1
  35. package/dist/webHtml.js +1 -1
  36. package/package.json +1 -1
  37. package/web/dist/assets/index-CD8jaN87.js +12 -0
  38. package/web/dist/index.html +1 -1
  39. package/web/dist/assets/index-gDYktkri.js +0 -12
package/README.md CHANGED
@@ -23,11 +23,12 @@ QuotaCap helps you get more from the AI coding subscriptions you already pay for
23
23
  | Codex | `pty` — `codex --no-alt-screen` then `/status`, parse `Weekly/5h limit: X% left` | Live |
24
24
  | Kimi Code | `pty` — `kimi` then `/usage`, parse `Weekly/5h limit: Y% used` | Live |
25
25
  | Grok | `pty` — `grok` then `/usage`, parse `Weekly limit (plan)` + `Credits: $X` | Live |
26
- | Muse Code | `pty` — `muse --trust-workspace` then `/usage`, parse `Subscription · Muse Code <plan>` + `Weekly/Current N% used` | Live |
26
+ | Muse Code | `pty` — `muse --trust-workspace` then `/usage`, parse `Subscription · Muse Code <plan>` + `Weekly/Current N% used`; on `Currently unavailable`, up to three headless `muse exec` warm turns and re-reads inside the poll | Live |
27
+ | OpenCode Go | `api` — `GET https://opencode.ai/zen/go/v1/usage` with the OpenCode auth key; opt-in via `quotacap providers enable opencode-go` (consent recorded, disable revokes it) | Live (opt-in) |
27
28
 
28
- Exec adapters run via `execFile` with an argv list. PTY adapters run via `node-pty` (`src/adapters/pty.ts`). They are TUI-fragile: a vendor text change breaks the parser and the row degrades fail-closed until the regex is fixed. Poll latency is 2–10 s per PTY provider (settle plus completion). It dominates `POST /api/refresh` and the first poll, not the steady-state 15 m timer.
29
+ Exec adapters run via `execFile` with an argv list. PTY adapters run via `node-pty` (`src/adapters/pty.ts`). They are TUI-fragile: a vendor text change breaks the parser and the row degrades fail-closed until the regex is fixed. Poll latency is 2–10 s per PTY provider (settle plus completion); `muse` can reach about 75 s when it has to warm an unavailable subscription. It dominates `POST /api/refresh` and the first poll, not the steady-state 15 m timer.
29
30
 
30
- Live adapters invoke the CLIs you already logged into. No API keys. No token files are read.
31
+ Live adapters invoke the CLIs you already logged into — no API keys, no token files read. The one opt-in exception is OpenCode Go: after you run `quotacap providers enable opencode-go`, each poll reads the OpenCode auth key in-memory (`~/.local/share/opencode/auth.json`, `opencode-go` → `opencode` fallback, or `OPENCODE_API_KEY`) and sends it only to `opencode.ai/zen/go/v1/usage`. It is never stored, logged, or returned by the API or MCP. Disable any time with `quotacap providers disable opencode-go`.
31
32
 
32
33
  ## Install
33
34
 
@@ -83,6 +84,7 @@ The local web server listens on `127.0.0.1:8787` (configured via `QUOTACAP_URL`
83
84
  | `GET` | `/api/token` | Same-origin | Shared secret token for the dashboard |
84
85
  | `POST` | `/api/refresh` | `X-QuotaCap-Token` | Trigger an immediate adapter poll (debounced to 60s) |
85
86
  | `PATCH` | `/api/providers/:id` | `X-QuotaCap-Token` | Set or clear custom provider display name override |
87
+ | `POST` | `/api/providers/:id/enabled` | `X-QuotaCap-Token` | Enable or disable a provider (`consent: true` required for `opencode-go`); restart applies it |
86
88
 
87
89
  ### Curl examples
88
90
 
@@ -119,7 +121,7 @@ curl -X PATCH http://localhost:8787/api/providers/claude \
119
121
 
120
122
  QuotaCap is a local daemon. It binds to `127.0.0.1` only (`src/runtime/service.ts` `app.listen`). It does not listen on `0.0.0.0`. There is no LAN surface.
121
123
 
122
- It owns no tokens. It never reads `~/.codex/auth.json`, `~/.kimi-code/credentials/kimi-code.json`, `~/.kimi/credentials/kimi-code.json`, `~/.grok/auth.json`, or `~/.gemini/oauth_creds.json`. It never uses `refresh_token` or `grant_type=refresh_token`. It has no hardcoded client ids. Those OAuth paths and the `.qc-bak` and `.qc-lock` helpers were removed in #14. This is asserted by `tests/adapters/credential-free.test.ts`. Each CLI owns its own session. It never reads `~/.config/muse/auth.json`, `~/.local/share/muse/sessions/`, or `~/.config/muse/tui-history.jsonl`. Each CLI owns its own session. QuotaCap only spawns the CLI and reads its stdout via `exec` (`claude`, `agy`) or PTY (`codex`, `kimi`, `grok`, `muse`).
124
+ It owns no tokens. It never reads `~/.codex/auth.json`, `~/.kimi-code/credentials/kimi-code.json`, `~/.kimi/credentials/kimi-code.json`, `~/.grok/auth.json`, or `~/.gemini/oauth_creds.json`. It never uses `refresh_token` or `grant_type=refresh_token`. It has no hardcoded client ids. Those OAuth paths and the `.qc-bak` and `.qc-lock` helpers were removed in #14. This is asserted by `tests/adapters/credential-free.test.ts`. Each CLI owns its own session. It never reads `~/.config/muse/auth.json`, `~/.local/share/muse/sessions/`, or `~/.config/muse/tui-history.jsonl`. Each CLI owns its own session. The one opt-in exception is OpenCode Go: when you enable it, QuotaCap reads `~/.local/share/opencode/auth.json` in-memory, read-only, once per poll, and sends the key only as `Authorization: Bearer` to `https://opencode.ai/zen/go/v1/usage`. The read is consent-gated (`opencodeGoConsentAt` in config), asserted read-only by `tests/adapters/credential-free.test.ts`, and revoked by `quotacap providers disable opencode-go`. QuotaCap only spawns the CLI and reads its stdout via `exec` (`claude`, `agy`) or PTY (`codex`, `kimi`, `grok`, `muse`).
123
125
 
124
126
  It stores no `raw` provider payload. The `raw` column was dropped and migrated in `src/store/db.ts` `migrate`. `GET /api/quotas` and MCP `get_quotas` never return `raw` (`tests/http/api.test.ts`). History and the token live under `~/.quotacap/` with `0700` on the directory and `0600` on files.
125
127
 
@@ -5,6 +5,7 @@ import { kimiAdapter } from "./kimi.js";
5
5
  import { grokAdapter } from "./grok.js";
6
6
  import { agyAdapter } from "./agy.js";
7
7
  import { museAdapter } from "./muse.js";
8
+ import { opencodeGoAdapter } from "./opencode-go.js";
8
9
  import { installAdapterSignal, clearAdapterSignal } from "../runtime/spawn.js";
9
10
  export const adapters = {
10
11
  claude: claudeAdapter,
@@ -14,6 +15,7 @@ export const adapters = {
14
15
  grok: grokAdapter,
15
16
  agy: agyAdapter,
16
17
  muse: museAdapter,
18
+ "opencode-go": opencodeGoAdapter,
17
19
  };
18
20
  const ADAPTER_TIMEOUTS = {
19
21
  claude: 8000,
@@ -21,7 +23,8 @@ const ADAPTER_TIMEOUTS = {
21
23
  kimi: 8000,
22
24
  grok: 14000,
23
25
  agy: 20000,
24
- muse: 14000,
26
+ muse: 90000,
27
+ "opencode-go": 8000,
25
28
  };
26
29
  export async function pollAll(enabled, opts) {
27
30
  const rawJobs = enabled.map(id => {
@@ -1,4 +1,32 @@
1
1
  import type { ParsedQuota } from "./types.js";
2
+ /** Canonical unavailability error: parseMuseTui throws it, poll() recognises it
3
+ * to start recovery, and failure.ts classifies its text as service_unavailable. */
4
+ export declare const MUSE_UNAVAILABLE_MESSAGE = "muse: subscription currently unavailable in TUI output";
5
+ export declare const WARM_ATTEMPTS = 3;
6
+ export declare const WARM_SETTLE_MS = 3000;
7
+ export declare const WARM_EXEC_TIMEOUT_MS = 20000;
8
+ export declare const FALLBACK_BUDGET_MS = 60000;
9
+ export interface RecoveryDeps {
10
+ unavailable: Error;
11
+ usagePass: (timeoutMs: number) => Promise<ParsedQuota>;
12
+ warmTurn: (timeoutMs: number) => Promise<void>;
13
+ sleep?: (ms: number) => Promise<void>;
14
+ now?: () => number;
15
+ aborted?: () => boolean;
16
+ log?: (message: string) => void;
17
+ attempts?: number;
18
+ settleMs?: number;
19
+ budgetMs?: number;
20
+ }
21
+ /**
22
+ * Recovery loop for an unavailable subscription, entered only on
23
+ * MUSE_UNAVAILABLE_MESSAGE. Attempt = settle, headless warm turn, settle,
24
+ * /usage re-read. The budget is checked before every step, and the warm turn,
25
+ * sleep and readiness/completion waits are clamped to what remains; the
26
+ * adapter's 90 s timeout is the backstop for the fixed per-pass overheads.
27
+ * Exhaustion rethrows the last unavailability error.
28
+ */
29
+ export declare function recoverSubscription(deps: RecoveryDeps): Promise<ParsedQuota>;
2
30
  export declare function museProbeDir(): string;
3
31
  export declare function parseMuseTui(text: string, now?: Date): ParsedQuota;
4
32
  export declare const museAdapter: {
@@ -3,7 +3,71 @@ import os from "node:os";
3
3
  import path from "node:path";
4
4
  import { parseResetText } from "./parse.js";
5
5
  import { runPty, stripAnsi } from "./pty.js";
6
- import { adapterSignal } from "../runtime/spawn.js";
6
+ import { adapterSignal, trackedExecFile } from "../runtime/spawn.js";
7
+ /** Canonical unavailability error: parseMuseTui throws it, poll() recognises it
8
+ * to start recovery, and failure.ts classifies its text as service_unavailable. */
9
+ export const MUSE_UNAVAILABLE_MESSAGE = "muse: subscription currently unavailable in TUI output";
10
+ export const WARM_ATTEMPTS = 3;
11
+ export const WARM_SETTLE_MS = 3000;
12
+ export const WARM_EXEC_TIMEOUT_MS = 20000;
13
+ export const FALLBACK_BUDGET_MS = 60000;
14
+ const USAGE_TIMEOUT_MS = 14000;
15
+ const MIN_STEP_MS = 1000;
16
+ function delay(ms) {
17
+ return new Promise((resolve) => setTimeout(resolve, ms));
18
+ }
19
+ /**
20
+ * Recovery loop for an unavailable subscription, entered only on
21
+ * MUSE_UNAVAILABLE_MESSAGE. Attempt = settle, headless warm turn, settle,
22
+ * /usage re-read. The budget is checked before every step, and the warm turn,
23
+ * sleep and readiness/completion waits are clamped to what remains; the
24
+ * adapter's 90 s timeout is the backstop for the fixed per-pass overheads.
25
+ * Exhaustion rethrows the last unavailability error.
26
+ */
27
+ export async function recoverSubscription(deps) {
28
+ const sleep = deps.sleep ?? delay;
29
+ const now = deps.now ?? Date.now;
30
+ const aborted = deps.aborted ?? (() => adapterSignal("muse")?.aborted === true);
31
+ const log = deps.log ?? ((message) => console.warn(`[quotacap] muse: ${message}`));
32
+ const attempts = deps.attempts ?? WARM_ATTEMPTS;
33
+ const settleMs = deps.settleMs ?? WARM_SETTLE_MS;
34
+ const deadline = now() + (deps.budgetMs ?? FALLBACK_BUDGET_MS);
35
+ const remaining = () => deadline - now();
36
+ let unavailable = deps.unavailable;
37
+ for (let attempt = 1; attempt <= attempts; attempt++) {
38
+ if (remaining() < MIN_STEP_MS)
39
+ break;
40
+ await sleep(Math.min(settleMs, remaining()));
41
+ if (remaining() < MIN_STEP_MS)
42
+ break;
43
+ try {
44
+ await deps.warmTurn(Math.min(WARM_EXEC_TIMEOUT_MS, remaining()));
45
+ }
46
+ catch (warmError) {
47
+ if (aborted())
48
+ throw warmError;
49
+ log(`warm attempt ${attempt} failed: ${warmError?.message}`);
50
+ continue;
51
+ }
52
+ if (remaining() < MIN_STEP_MS)
53
+ break;
54
+ await sleep(Math.min(settleMs, remaining()));
55
+ if (remaining() < MIN_STEP_MS)
56
+ break;
57
+ try {
58
+ const quota = await deps.usagePass(Math.min(USAGE_TIMEOUT_MS, remaining()));
59
+ log(`subscription unavailable; recovered after ${attempt} warm prompt(s)`);
60
+ return quota;
61
+ }
62
+ catch (readError) {
63
+ if (readError?.message !== MUSE_UNAVAILABLE_MESSAGE)
64
+ throw readError;
65
+ unavailable = readError;
66
+ }
67
+ }
68
+ log("subscription still unavailable; warm recovery exhausted");
69
+ throw unavailable;
70
+ }
7
71
  // QuotaCap-owned empty probe dir, beside the config and database. Muse loads
8
72
  // project-local skills, rules, hooks and plugin config from cwd, so the probe
9
73
  // never runs in $HOME or a user project — an empty dir grants nothing.
@@ -15,7 +79,7 @@ export function parseMuseTui(text, now = new Date()) {
15
79
  // newlines), so no pattern here may anchor on ^, $ or \n.
16
80
  const cleaned = stripAnsi(text);
17
81
  if (/currently unavailable|subscriptions aren't currently available|subscription_unavailable/i.test(cleaned)) {
18
- throw new Error("muse: subscription currently unavailable in TUI output");
82
+ throw new Error(MUSE_UNAVAILABLE_MESSAGE);
19
83
  }
20
84
  const weekly = cleaned.match(/Weekly\s+(\d+)%\s+used/i);
21
85
  if (!weekly)
@@ -60,40 +124,67 @@ export function parseMuseTui(text, now = new Date()) {
60
124
  raw: cleaned.slice(0, 4096),
61
125
  };
62
126
  }
127
+ /** One `/usage` read: the TUI scrape, shared by the first attempt and every
128
+ * recovery re-read. */
129
+ async function usagePass(timeoutMs = USAGE_TIMEOUT_MS) {
130
+ const cwd = museProbeDir();
131
+ fs.mkdirSync(cwd, { recursive: true });
132
+ const transcript = await runPty({
133
+ file: "muse",
134
+ args: ["--trust-workspace"],
135
+ cwd,
136
+ // Without this, a missing binary triggers a foreground 248 MB download
137
+ // on every poll; with it the launcher dies fast with a clear message.
138
+ env: { MUSE_NO_AUTO_UPDATE: "1" },
139
+ cols: 140,
140
+ rows: 50,
141
+ readyRegex: /muse-spark|Muse Code \d/,
142
+ readyTimeoutMs: 8000,
143
+ settleDelayMs: 1000,
144
+ // Two-phase submit: "/usage" first, CR 1500ms later. A single combined
145
+ // write is swallowed by the slash-command autocomplete and never runs.
146
+ input: "/usage",
147
+ submitInput: "\r",
148
+ submitAfterMs: 1500,
149
+ completionRegex: /Subscription/i,
150
+ // Trust prompt and the accidental-turn guard stay fail-closed. The
151
+ // unavailable-subscription message is deliberately not here: parseMuseTui
152
+ // owns detection so the poll can recover from it.
153
+ abortOn: /Do you trust this workspace|Working \(\d+s/,
154
+ timeoutMs,
155
+ respondToQueries: true,
156
+ maxBytes: 256 * 1024,
157
+ signal: adapterSignal("muse"),
158
+ label: "muse",
159
+ });
160
+ return parseMuseTui(transcript);
161
+ }
63
162
  export const museAdapter = {
64
163
  id: "muse",
65
164
  requiresAuth: "muse login (CLI owns credentials)",
66
165
  async poll() {
67
- const cwd = museProbeDir();
68
- fs.mkdirSync(cwd, { recursive: true });
69
- const transcript = await runPty({
70
- file: "muse",
71
- args: ["--trust-workspace"],
72
- cwd,
73
- // Without this, a missing binary triggers a foreground 248 MB download
74
- // on every poll; with it the launcher dies fast with a clear message.
75
- env: { MUSE_NO_AUTO_UPDATE: "1" },
76
- cols: 140,
77
- rows: 50,
78
- readyRegex: /muse-spark|Muse Code \d/,
79
- readyTimeoutMs: 8000,
80
- settleDelayMs: 1000,
81
- // Two-phase submit: "/usage" first, CR 1500ms later. A single combined
82
- // write is swallowed by the slash-command autocomplete and never runs.
83
- input: "/usage",
84
- submitInput: "\r",
85
- submitAfterMs: 1500,
86
- completionRegex: /Subscription/i,
87
- // Trust prompt (in case a future Muse ignores the flag), an accidental
88
- // model turn from a mistimed Enter, and the unavailable-subscription
89
- // fast-fail — every one aborts to a degraded row, never a 14s timeout.
90
- abortOn: /Do you trust this workspace|Working \(\d+s|Subscriptions aren't currently available|subscription_unavailable/,
91
- timeoutMs: 14000,
92
- respondToQueries: true,
93
- maxBytes: 256 * 1024,
94
- signal: adapterSignal("muse"),
95
- label: "muse",
96
- });
97
- return parseMuseTui(transcript);
166
+ // Captured before the first pass: pollAll clears the adapter signal map
167
+ // entry in a microtask when its gate rejects, but clearing the map does
168
+ // not reset the signal object, so the loop can still observe the abort.
169
+ const signal = adapterSignal("muse");
170
+ try {
171
+ return await usagePass();
172
+ }
173
+ catch (e) {
174
+ if (e?.message !== MUSE_UNAVAILABLE_MESSAGE)
175
+ throw e;
176
+ return recoverSubscription({
177
+ unavailable: e,
178
+ usagePass: (timeoutMs) => usagePass(timeoutMs),
179
+ warmTurn: async (timeoutMs) => {
180
+ await trackedExecFile("muse", "muse", ["exec", "hi"], {
181
+ cwd: museProbeDir(),
182
+ env: { ...process.env, MUSE_NO_AUTO_UPDATE: "1" },
183
+ timeout: timeoutMs,
184
+ });
185
+ },
186
+ aborted: () => signal?.aborted === true,
187
+ });
188
+ }
98
189
  },
99
190
  };
@@ -0,0 +1,11 @@
1
+ import type { ParsedQuota } from "./types.js";
2
+ export declare const OPENCODE_GO_CONSENT_NOTICE = "QuotaCap will read the API key OpenCode already stored when you ran `opencode auth login -p opencode-go` (`~/.local/share/opencode/auth.json`, `opencode-go` \u2192 `opencode` fallback). It is read in-memory, read-only once per poll (every 15 minutes by default). It is sent only as `Authorization: Bearer` to `https://opencode.ai/zen/go/v1/usage` to fetch `rolling/weekly/monthly` `percent` + `resetsAt`. It is never used to make chat or model requests, never written or rotated, never stored in the `quotacap` DB, never returned by the API or MCP, and never logged (errors show `[redacted]`). Revoke at `https://opencode.ai/auth` or `opencode providers logout opencode-go` and run `quotacap providers disable opencode-go` to stop polling. If you prefer no file access, set `OPENCODE_API_KEY` instead.";
3
+ export declare function openCodeGoAuthPath(): string;
4
+ /** Detection is existence only — no content read before enablement. */
5
+ export declare function opencodeGoDetected(): boolean;
6
+ export declare function parseOpencodeGoUsage(body: unknown, now?: Date): ParsedQuota;
7
+ export declare const opencodeGoAdapter: {
8
+ id: string;
9
+ requiresAuth: string;
10
+ poll(): Promise<ParsedQuota>;
11
+ };
@@ -0,0 +1,122 @@
1
+ // Consented exception to credential-free polling: reads the OpenCode auth
2
+ // key in-memory, only after `providers enable opencode-go` records consent.
3
+ // The static gate pins the auth-file literal to this adapter plus the
4
+ // generated dashboard bundle — see tests/adapters/credential-free.test.ts.
5
+ import fsSync from "node:fs";
6
+ import os from "node:os";
7
+ import path from "node:path";
8
+ import { adapterSignal } from "../runtime/spawn.js";
9
+ const USAGE_URL = "https://opencode.ai/zen/go/v1/usage";
10
+ const POLL_TIMEOUT_MS = 8000;
11
+ export const OPENCODE_GO_CONSENT_NOTICE = "QuotaCap will read the API key OpenCode already stored when you ran `opencode auth login -p opencode-go` (`~/.local/share/opencode/auth.json`, `opencode-go` → `opencode` fallback). It is read in-memory, read-only once per poll (every 15 minutes by default). It is sent only as `Authorization: Bearer` to `https://opencode.ai/zen/go/v1/usage` to fetch `rolling/weekly/monthly` `percent` + `resetsAt`. It is never used to make chat or model requests, never written or rotated, never stored in the `quotacap` DB, never returned by the API or MCP, and never logged (errors show `[redacted]`). Revoke at `https://opencode.ai/auth` or `opencode providers logout opencode-go` and run `quotacap providers disable opencode-go` to stop polling. If you prefer no file access, set `OPENCODE_API_KEY` instead.";
12
+ export function openCodeGoAuthPath() {
13
+ return path.join(os.homedir(), ".local", "share", "opencode", "auth.json");
14
+ }
15
+ /** Detection is existence only — no content read before enablement. */
16
+ export function opencodeGoDetected() {
17
+ try {
18
+ return fsSync.existsSync(openCodeGoAuthPath());
19
+ }
20
+ catch {
21
+ return false;
22
+ }
23
+ }
24
+ /**
25
+ * Consent-gated: poll() runs only when the provider is in enabledProviders,
26
+ * which the enable flow records as explicit consent. One read, in-memory
27
+ * only, never written or logged. Env wins so a host can avoid file access.
28
+ */
29
+ function readBearerToken(env = process.env) {
30
+ const fromEnv = env.OPENCODE_API_KEY;
31
+ if (typeof fromEnv === "string" && fromEnv.length > 0)
32
+ return fromEnv;
33
+ let parsed;
34
+ try {
35
+ parsed = JSON.parse(fsSync.readFileSync(openCodeGoAuthPath(), "utf8"));
36
+ }
37
+ catch {
38
+ throw new Error("opencode-go: missing API key in auth file");
39
+ }
40
+ const entry = (parsed["opencode-go"] ?? parsed["opencode"]);
41
+ if (!entry ||
42
+ entry.type !== "api" ||
43
+ typeof entry.key !== "string" ||
44
+ !entry.key.startsWith("sk-")) {
45
+ throw new Error("opencode-go: missing API key in auth file");
46
+ }
47
+ return entry.key;
48
+ }
49
+ export function parseOpencodeGoUsage(body, now = new Date()) {
50
+ const usage = body?.usage;
51
+ if (typeof usage !== "object" || usage === null) {
52
+ throw new Error("opencode-go: weekly usage not found");
53
+ }
54
+ const weekly = usage.weekly;
55
+ if (typeof weekly !== "object" || weekly === null) {
56
+ throw new Error("opencode-go: weekly usage not found");
57
+ }
58
+ if (weekly.status !== "ok") {
59
+ throw new Error("opencode-go: usage status not ok");
60
+ }
61
+ const usedPct = weekly.percent;
62
+ if (typeof usedPct !== "number" || !Number.isFinite(usedPct) || usedPct < 0 || usedPct > 100) {
63
+ throw new Error("opencode-go: bad weekly pct");
64
+ }
65
+ const resetsMs = Date.parse(String(weekly.resetsAt ?? ""));
66
+ if (!Number.isFinite(resetsMs)) {
67
+ throw new Error("opencode-go: bad weekly reset");
68
+ }
69
+ const rolling = usage.rolling;
70
+ if (typeof rolling !== "object" || rolling === null) {
71
+ throw new Error("opencode-go: rolling usage not found");
72
+ }
73
+ if (rolling.status !== "ok") {
74
+ throw new Error("opencode-go: usage status not ok");
75
+ }
76
+ const sessionPct = rolling.percent;
77
+ if (typeof sessionPct !== "number" || !Number.isFinite(sessionPct) || sessionPct < 0 || sessionPct > 100) {
78
+ throw new Error("opencode-go: bad rolling pct");
79
+ }
80
+ return {
81
+ provider: "opencode-go",
82
+ plan: "unknown",
83
+ usedPct,
84
+ sessionPct,
85
+ resetsAt: new Date(resetsMs).toISOString(),
86
+ periodStart: new Date(resetsMs - 7 * 86400000).toISOString(),
87
+ source: "api",
88
+ fetchedAt: now.toISOString(),
89
+ };
90
+ }
91
+ export const opencodeGoAdapter = {
92
+ id: "opencode-go",
93
+ requiresAuth: "opencode auth login -p opencode-go (read only after you enable opencode-go)",
94
+ async poll() {
95
+ const bearer = readBearerToken();
96
+ const adapter = adapterSignal("opencode-go");
97
+ const signals = [AbortSignal.timeout(POLL_TIMEOUT_MS)];
98
+ if (adapter)
99
+ signals.push(adapter);
100
+ const res = await fetch(USAGE_URL, {
101
+ headers: { Authorization: `Bearer ${bearer}` },
102
+ signal: AbortSignal.any(signals),
103
+ });
104
+ if (res.status === 401 || res.status === 403) {
105
+ throw new Error(`opencode-go: unauthorized (${res.status})`);
106
+ }
107
+ if (res.status === 429) {
108
+ throw new Error("opencode-go: rate limit (429)");
109
+ }
110
+ if (!res.ok) {
111
+ throw new Error(`opencode-go: usage request failed with status ${res.status}`);
112
+ }
113
+ let body;
114
+ try {
115
+ body = JSON.parse(await res.text());
116
+ }
117
+ catch {
118
+ throw new Error("opencode-go: weekly usage not found");
119
+ }
120
+ return parseOpencodeGoUsage(body);
121
+ },
122
+ };
@@ -66,6 +66,15 @@ export const REGISTRY = new Map([
66
66
  description: "Meta's Muse Code subscription, running Muse Spark models, metered as a weekly window with a shorter current window inside it.",
67
67
  },
68
68
  ],
69
+ [
70
+ "opencode-go",
71
+ {
72
+ displayName: "OpenCode Go",
73
+ vendor: "Anomaly",
74
+ harness: "OpenCode",
75
+ description: "OpenCode Go subscription usage via the zen/go usage API, polled only after you explicitly enable it.",
76
+ },
77
+ ],
69
78
  [
70
79
  "manual",
71
80
  {
@@ -16,6 +16,7 @@ export interface SnapshotOptions {
16
16
  upToDate: boolean;
17
17
  checkedAt: string | null;
18
18
  };
19
+ detectedProviders?: string[];
19
20
  };
20
21
  }
21
22
  export declare function buildSnapshot(db: any, opts: SnapshotOptions): StateSnapshot;
@@ -102,6 +102,9 @@ export interface StateSnapshot {
102
102
  lastCompletedPollAt: string | null;
103
103
  version: string;
104
104
  update?: UpdateStatus;
105
+ /** Auth detected for these provider ids (detection-only; consumers intersect with the enabled set).
106
+ * Empty on daemons predating the field. */
107
+ detectedProviders?: string[];
105
108
  };
106
109
  providers: ProviderSnapshot[];
107
110
  recommendation: Recommendation;
@@ -1,4 +1,6 @@
1
- import { readConfig, resetAllProviderNameOverrides, setProviderNameOverride } from "../config.js";
1
+ import { adapters } from "../adapters/index.js";
2
+ import { OPENCODE_GO_CONSENT_NOTICE } from "../adapters/opencode-go.js";
3
+ import { readConfig, resetAllProviderNameOverrides, setProviderEnabled, setProviderNameOverride } from "../config.js";
2
4
  import { REGISTRY, providerIdentity } from "../advisory/provider-names.js";
3
5
  import { validateDisplayName } from "../advisory/validation.js";
4
6
  import { ServiceError, ServiceUnavailable, createServiceClient } from "../runtime/client.js";
@@ -171,4 +173,122 @@ export function registerProvidersCommand(program, deps = {}) {
171
173
  console.log(`reset display name for ${id}`);
172
174
  }
173
175
  });
176
+ providers
177
+ .command("enable <id>")
178
+ .description("enable a provider (OpenCode Go requires explicit consent)")
179
+ .option("--yes", "skip the interactive consent prompt (scripts)")
180
+ .action(async (id, opts) => {
181
+ if (typeof id !== "string" || !id || !Object.hasOwn(adapters, id) || id === "manual") {
182
+ console.error(`error: unknown provider id: ${id}`);
183
+ exit(1);
184
+ return;
185
+ }
186
+ if (id === "opencode-go") {
187
+ console.log("**Enable OpenCode Go?**\n");
188
+ console.log(OPENCODE_GO_CONSENT_NOTICE + "\n");
189
+ if (!opts.yes) {
190
+ if (!process.stdin.isTTY) {
191
+ console.error("refusing to enable opencode-go without interactive consent — read the notice above and re-run with --yes");
192
+ exit(1);
193
+ return;
194
+ }
195
+ const readline = (await import("node:readline/promises")).default;
196
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
197
+ try {
198
+ const answer = (await rl.question("Proceed? [y/N] ")).trim().toLowerCase();
199
+ if (answer !== "y" && answer !== "yes") {
200
+ console.log("not enabled");
201
+ return;
202
+ }
203
+ }
204
+ catch {
205
+ console.log("not enabled");
206
+ return;
207
+ }
208
+ finally {
209
+ rl.close();
210
+ }
211
+ }
212
+ }
213
+ const cfg = await readConfig();
214
+ const client = createClient({ port: cfg.port, token: readToken(), timeoutMs: 2000 });
215
+ try {
216
+ await client.post(`/api/providers/${encodeURIComponent(id)}/enabled`, {
217
+ enabled: true,
218
+ ...(id === "opencode-go" ? { consent: true } : {}),
219
+ });
220
+ }
221
+ catch (e) {
222
+ if (e instanceof ServiceError && e.status === 404 && id === "opencode-go") {
223
+ console.error("the running daemon predates OpenCode Go support (no enable route) — upgrade it with `quotacap update` before enabling; refusing to write config the old daemon cannot start with");
224
+ exit(1);
225
+ return;
226
+ }
227
+ if (e instanceof ServiceUnavailable || (e instanceof ServiceError && e.status === 404)) {
228
+ try {
229
+ await setProviderEnabled(id, true);
230
+ }
231
+ catch (err) {
232
+ console.error(err?.message ?? String(err));
233
+ exit(1);
234
+ return;
235
+ }
236
+ if (e instanceof ServiceError && e.status === 404) {
237
+ console.warn("note: daemon returned 404 (version skew); updated local config, restart quotacap daemon to apply");
238
+ }
239
+ }
240
+ else if (e instanceof ServiceError) {
241
+ console.error(e.message);
242
+ exit(1);
243
+ return;
244
+ }
245
+ else {
246
+ console.error(e instanceof Error ? e.message : String(e));
247
+ exit(1);
248
+ return;
249
+ }
250
+ }
251
+ console.log(`enabled ${id} — restart quotacap daemon to apply`);
252
+ });
253
+ providers
254
+ .command("disable <id>")
255
+ .description("disable a provider and stop polling it")
256
+ .action(async (id) => {
257
+ if (typeof id !== "string" || !id || !Object.hasOwn(adapters, id) || id === "manual") {
258
+ console.error(`error: unknown provider id: ${id}`);
259
+ exit(1);
260
+ return;
261
+ }
262
+ const cfg = await readConfig();
263
+ const client = createClient({ port: cfg.port, token: readToken(), timeoutMs: 2000 });
264
+ try {
265
+ await client.post(`/api/providers/${encodeURIComponent(id)}/enabled`, { enabled: false });
266
+ }
267
+ catch (e) {
268
+ if (e instanceof ServiceUnavailable || (e instanceof ServiceError && e.status === 404)) {
269
+ try {
270
+ await setProviderEnabled(id, false);
271
+ }
272
+ catch (err) {
273
+ console.error(err?.message ?? String(err));
274
+ exit(1);
275
+ return;
276
+ }
277
+ if (e instanceof ServiceError && e.status === 404) {
278
+ console.warn("note: daemon returned 404 (version skew); updated local config, restart quotacap daemon to apply");
279
+ }
280
+ }
281
+ else if (e instanceof ServiceError) {
282
+ console.error(e.message);
283
+ exit(1);
284
+ return;
285
+ }
286
+ else {
287
+ console.error(e instanceof Error ? e.message : String(e));
288
+ exit(1);
289
+ return;
290
+ }
291
+ }
292
+ console.log(`disabled ${id} — restart quotacap daemon to apply`);
293
+ });
174
294
  }
@@ -135,6 +135,12 @@ export function registerStatusCommand(program, deps) {
135
135
  }
136
136
  if (resolved.source === "offline")
137
137
  console.error(OFFLINE_LABEL);
138
+ const detected = resolved.snapshot.runtime?.detectedProviders;
139
+ if (Array.isArray(detected) &&
140
+ detected.includes("opencode-go") &&
141
+ !cfg.enabledProviders.includes("opencode-go")) {
142
+ console.error("opencode-go detected — run `quotacap providers enable opencode-go` to track 5h/weekly usage");
143
+ }
138
144
  const { snapshot } = resolved;
139
145
  if (o.json) {
140
146
  console.log(JSON.stringify(projectQuotasResponse(snapshot), null, 2));
package/dist/config.d.ts CHANGED
@@ -9,6 +9,7 @@ declare const ConfigSchema: z.ZodObject<{
9
9
  enabledProviders: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
10
10
  knownProviders: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
11
11
  providerNames: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodEffects<z.ZodEffects<z.ZodString, string, string>, string, string>>>;
12
+ opencodeGoConsentAt: z.ZodDefault<z.ZodNullable<z.ZodString>>;
12
13
  experimentalIngest: z.ZodOptional<z.ZodBoolean>;
13
14
  }, "strip", z.ZodTypeAny, {
14
15
  port: number;
@@ -17,6 +18,7 @@ declare const ConfigSchema: z.ZodObject<{
17
18
  enabledProviders: string[];
18
19
  knownProviders: string[];
19
20
  providerNames: Record<string, string>;
21
+ opencodeGoConsentAt: string | null;
20
22
  experimentalIngest?: boolean | undefined;
21
23
  }, {
22
24
  port?: number | undefined;
@@ -25,6 +27,7 @@ declare const ConfigSchema: z.ZodObject<{
25
27
  enabledProviders?: string[] | undefined;
26
28
  knownProviders?: string[] | undefined;
27
29
  providerNames?: Record<string, string> | undefined;
30
+ opencodeGoConsentAt?: string | null | undefined;
28
31
  experimentalIngest?: boolean | undefined;
29
32
  }>;
30
33
  export type Config = z.infer<typeof ConfigSchema>;
@@ -50,6 +53,12 @@ export declare function writeConfig(c: Config, p?: string): Promise<void>;
50
53
  * all unknown keys and custom configuration, and fails loudly if the file is corrupt.
51
54
  */
52
55
  export declare function setProviderNameOverride(id: string, displayName: string | null, p?: string): Promise<void>;
56
+ /**
57
+ * Safe mutation of provider enablement in config.json: raw-JSON like
58
+ * setProviderNameOverride (unknown keys preserved). Enabling opencode-go
59
+ * records the consent timestamp; disabling it revokes the consent record.
60
+ */
61
+ export declare function setProviderEnabled(id: string, enabled: boolean, p?: string): Promise<void>;
53
62
  export declare function resetAllProviderNameOverrides(p?: string): Promise<void>;
54
63
  export declare const LEGACY_KNOWN_PROVIDERS: string[];
55
64
  export interface AutoEnableDeps {
@@ -63,7 +72,8 @@ export interface AutoEnableResult {
63
72
  }
64
73
  /**
65
74
  * Auto-enable newly shipped adapters. For each registered adapter (minus
66
- * `manual`, which has no CLI binary) absent from knownProviders, resolve its
75
+ * `manual`, which has no CLI binary, and minus opt-in adapters, which an
76
+ * explicit enable must consent to) absent from knownProviders, resolve its
67
77
  * binary on PATH and append it to both lists when found. An adapter whose
68
78
  * binary is missing stays unknown so it is re-checked on the next start; a
69
79
  * provider already known but disabled is never re-added. Raw-JSON mutation