quotacap 0.0.37 → 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 (37) hide show
  1. package/README.md +4 -2
  2. package/dist/adapters/index.js +3 -0
  3. package/dist/adapters/opencode-go.d.ts +11 -0
  4. package/dist/adapters/opencode-go.js +122 -0
  5. package/dist/advisory/provider-names.js +9 -0
  6. package/dist/advisory/snapshot.d.ts +1 -0
  7. package/dist/advisory/types.d.ts +3 -0
  8. package/dist/cli/providers.js +121 -1
  9. package/dist/cli/status.js +6 -0
  10. package/dist/config.d.ts +11 -1
  11. package/dist/config.js +52 -3
  12. package/dist/diagnostics/failure.js +11 -0
  13. package/dist/http/server.d.ts +2 -0
  14. package/dist/http/server.js +37 -1
  15. package/dist/runtime/service.js +2 -0
  16. package/dist/version.js +1 -1
  17. package/dist/web/src/App.js +53 -4
  18. package/dist/web/src/api.d.ts +2 -0
  19. package/dist/web/src/api.js +20 -0
  20. package/dist/web/src/components/FaultBanner.d.ts +2 -1
  21. package/dist/web/src/components/FaultBanner.js +8 -2
  22. package/dist/web/src/components/OpencodeGoBanner.d.ts +12 -0
  23. package/dist/web/src/components/OpencodeGoBanner.js +36 -0
  24. package/dist/web/src/components/ProviderDrawer.js +3 -0
  25. package/dist/web/src/components/settings/ProvidersPanel.d.ts +2 -1
  26. package/dist/web/src/components/settings/ProvidersPanel.js +12 -10
  27. package/dist/web/src/components/settings/SettingsDrawer.d.ts +2 -1
  28. package/dist/web/src/components/settings/SettingsDrawer.js +2 -2
  29. package/dist/web/src/pages/Onboarding.js +2 -2
  30. package/dist/web/src/state.d.ts +3 -1
  31. package/dist/web/src/state.js +10 -2
  32. package/dist/webAssets.js +1 -1
  33. package/dist/webHtml.js +1 -1
  34. package/package.json +1 -1
  35. package/web/dist/assets/index-CD8jaN87.js +12 -0
  36. package/web/dist/index.html +1 -1
  37. package/web/dist/assets/index-gDYktkri.js +0 -12
package/README.md CHANGED
@@ -24,10 +24,11 @@ QuotaCap helps you get more from the AI coding subscriptions you already pay for
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
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
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,
@@ -22,6 +24,7 @@ const ADAPTER_TIMEOUTS = {
22
24
  grok: 14000,
23
25
  agy: 20000,
24
26
  muse: 90000,
27
+ "opencode-go": 8000,
25
28
  };
26
29
  export async function pollAll(enabled, opts) {
27
30
  const rawJobs = enabled.map(id => {
@@ -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
package/dist/config.js CHANGED
@@ -37,6 +37,7 @@ const ConfigSchema = z.object({
37
37
  enabledProviders: z.array(z.string()).default(["claude", "codex", "kimi", "grok", "agy", "muse"]),
38
38
  knownProviders: z.array(z.string()).default(["claude", "codex", "kimi", "grok", "agy", "muse"]),
39
39
  providerNames: z.record(z.string(), ProviderDisplayNameSchema).default({}),
40
+ opencodeGoConsentAt: z.string().nullable().default(null),
40
41
  // Optional, omitted from defaults and `init` output. Manual ingest stays
41
42
  // in-tree but is not a public surface until the product design lands.
42
43
  experimentalIngest: z.boolean().optional(),
@@ -90,7 +91,7 @@ const ServiceConfigSchema = z.object({
90
91
  enabledProviders: z
91
92
  .array(z.string())
92
93
  .superRefine((ids, ctx) => {
93
- const bad = ids.filter((id) => !(id in adapters));
94
+ const bad = ids.filter((id) => !Object.hasOwn(adapters, id));
94
95
  if (bad.length > 0) {
95
96
  ctx.addIssue({
96
97
  code: z.ZodIssueCode.custom,
@@ -103,6 +104,7 @@ const ServiceConfigSchema = z.object({
103
104
  // start. A non-array still fails naming the field.
104
105
  knownProviders: z.array(z.string()).default(["claude", "codex", "kimi", "grok", "agy", "muse"]),
105
106
  providerNames: z.record(z.string(), ProviderDisplayNameSchema).default({}),
107
+ opencodeGoConsentAt: z.string().nullable().default(null),
106
108
  experimentalIngest: z.boolean().optional(),
107
109
  });
108
110
  export function defaultConfig() {
@@ -205,6 +207,49 @@ export async function setProviderNameOverride(id, displayName, p) {
205
207
  await fs.mkdir(path.dirname(file), { recursive: true });
206
208
  await fs.writeFile(file, JSON.stringify(rawObj, null, 2) + "\n");
207
209
  }
210
+ /**
211
+ * Safe mutation of provider enablement in config.json: raw-JSON like
212
+ * setProviderNameOverride (unknown keys preserved). Enabling opencode-go
213
+ * records the consent timestamp; disabling it revokes the consent record.
214
+ */
215
+ export async function setProviderEnabled(id, enabled, p) {
216
+ const file = getConfigPath(p);
217
+ let rawObj = {};
218
+ try {
219
+ const raw = await fs.readFile(file, "utf8");
220
+ const parsed = JSON.parse(raw);
221
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
222
+ throw new Error(`invalid config: ${file} must contain a JSON object`);
223
+ }
224
+ rawObj = parsed;
225
+ }
226
+ catch (err) {
227
+ if (err?.code === "ENOENT") {
228
+ rawObj = {};
229
+ }
230
+ else {
231
+ throw new Error(`cannot update provider enablement: failed to read ${file}: ${err?.message ?? err}`);
232
+ }
233
+ }
234
+ const list = Array.isArray(rawObj.enabledProviders)
235
+ ? [...rawObj.enabledProviders]
236
+ : [...defaultConfig().enabledProviders];
237
+ if (enabled) {
238
+ if (!list.includes(id))
239
+ list.push(id);
240
+ }
241
+ else {
242
+ const at = list.indexOf(id);
243
+ if (at >= 0)
244
+ list.splice(at, 1);
245
+ }
246
+ rawObj.enabledProviders = list;
247
+ if (id === "opencode-go") {
248
+ rawObj.opencodeGoConsentAt = enabled ? new Date().toISOString() : null;
249
+ }
250
+ await fs.mkdir(path.dirname(file), { recursive: true });
251
+ await fs.writeFile(file, JSON.stringify(rawObj, null, 2) + "\n");
252
+ }
208
253
  export async function resetAllProviderNameOverrides(p) {
209
254
  const file = getConfigPath(p);
210
255
  let rawObj = {};
@@ -233,6 +278,9 @@ export async function resetAllProviderNameOverrides(p) {
233
278
  // reads as new. Never extend this list: seeding from the live registry would
234
279
  // mark every adapter known and silently disable auto-enable.
235
280
  export const LEGACY_KNOWN_PROVIDERS = ["claude", "codex", "kimi", "grok", "agy"];
281
+ // Opt-in adapters are never auto-enabled: a binary on PATH is not consent.
282
+ // opencode-go polls a stored credential, so only an explicit enable adds it.
283
+ const OPT_IN_ADAPTERS = new Set(["opencode-go"]);
236
284
  // PATH lookup kept local: importing the sibling in src/service/macos.ts
237
285
  // would create a config<->service import cycle.
238
286
  function defaultWhich(bin) {
@@ -249,7 +297,8 @@ function defaultWhich(bin) {
249
297
  }
250
298
  /**
251
299
  * Auto-enable newly shipped adapters. For each registered adapter (minus
252
- * `manual`, which has no CLI binary) absent from knownProviders, resolve its
300
+ * `manual`, which has no CLI binary, and minus opt-in adapters, which an
301
+ * explicit enable must consent to) absent from knownProviders, resolve its
253
302
  * binary on PATH and append it to both lists when found. An adapter whose
254
303
  * binary is missing stays unknown so it is re-checked on the next start; a
255
304
  * provider already known but disabled is never re-added. Raw-JSON mutation
@@ -285,7 +334,7 @@ export async function autoEnableNewProviders(deps) {
285
334
  const enabled = [...prevEnabled];
286
335
  const known = [...prevKnown];
287
336
  for (const id of Object.keys(adapters)) {
288
- if (id === "manual" || known.includes(id))
337
+ if (id === "manual" || OPT_IN_ADAPTERS.has(id) || known.includes(id))
289
338
  continue;
290
339
  let resolved = null;
291
340
  try {
@@ -22,6 +22,8 @@ export function formatProviderName(provider) {
22
22
  return "Antigravity (3rd-party)";
23
23
  case "muse":
24
24
  return "Muse";
25
+ case "opencode-go":
26
+ return "OpenCode Go";
25
27
  case "all":
26
28
  return "QuotaCap service";
27
29
  default:
@@ -224,6 +226,15 @@ function matchPrecedence(evidence, errorName, errorCode, text) {
224
226
  if (/\bno weekly bucket\b/i.test(text)) {
225
227
  return { code: "parse_error", phrase: "no weekly bucket" };
226
228
  }
229
+ if (/\brolling usage not found\b/i.test(text)) {
230
+ return { code: "parse_error", phrase: "rolling usage not found" };
231
+ }
232
+ if (/\bbad rolling pct\b/i.test(text)) {
233
+ return { code: "parse_error", phrase: "bad rolling pct" };
234
+ }
235
+ if (/\busage status not ok\b/i.test(text)) {
236
+ return { code: "parse_error", phrase: "usage status not ok" };
237
+ }
227
238
  // Order 8b: service_unavailable — subscription or service currently unavailable
228
239
  if (/\b(?:subscription|service|usage)\s+currently\s+unavailable\b/i.test(text) ||
229
240
  /\bsubscriptions aren't currently available\b/i.test(text)) {
@@ -10,6 +10,8 @@ export interface RuntimeContext {
10
10
  coordinator: Coordinator;
11
11
  enabledProviders: string[];
12
12
  providerNames?: Record<string, string>;
13
+ /** Detected-but-not-enabled opt-in providers, computed once at start. */
14
+ detectedProviders?: string[];
13
15
  version: string;
14
16
  exec: string;
15
17
  now?: () => Date;
@@ -10,12 +10,13 @@ import { validateTask } from "../advisory/validate.js";
10
10
  import { readUpdateCache } from "../runtime/updates.js";
11
11
  import { compareVersions } from "../runtime/versions.js";
12
12
  import { parseManualUsage } from "../adapters/manual.js";
13
+ import { adapters } from "../adapters/index.js";
13
14
  import { upsertQuota } from "../store/quotas.js";
14
15
  import { createCoordinator, ServiceClosing, } from "../runtime/poll.js";
15
16
  import { classifyFailure } from "../diagnostics/failure.js";
16
17
  import { validateDisplayName } from "../advisory/validation.js";
17
18
  import { providerIdentity } from "../advisory/provider-names.js";
18
- import { setProviderNameOverride } from "../config.js";
19
+ import { setProviderNameOverride, setProviderEnabled } from "../config.js";
19
20
  import { ensureCatalogs, catalogProviders, CATALOG_FAIL_COOLDOWN_MS } from "../catalog/index.js";
20
21
  import { getAllLatest } from "../store/quotas.js";
21
22
  const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "[::1]", "::1"]);
@@ -91,6 +92,7 @@ export function testCtx(db, overrides) {
91
92
  coordinator: createCoordinator({ db, enabledProviders: [] }),
92
93
  enabledProviders: [],
93
94
  providerNames: {},
95
+ detectedProviders: [],
94
96
  version: "test",
95
97
  exec: "test",
96
98
  ingestEnabled: false,
@@ -119,6 +121,7 @@ function snapshotOf(ctx) {
119
121
  upToDate: !cache || compareVersions(cache.latest, ctx.version) <= 0,
120
122
  checkedAt: cache?.checkedAt ?? null,
121
123
  },
124
+ detectedProviders: ctx.detectedProviders ?? [],
122
125
  },
123
126
  });
124
127
  }
@@ -363,6 +366,39 @@ export function buildApp(ctx) {
363
366
  override: validatedName,
364
367
  };
365
368
  });
369
+ // Enable/disable a provider. Token-gated like refresh. Consent is recorded
370
+ // protocol-level for the one provider whose key QuotaCap reads. The
371
+ // coordinator's enabled set is a start-time snapshot, so the response
372
+ // tells every caller a restart applies it — no live mutation.
373
+ app.post("/api/providers/:id/enabled", async (req, reply) => {
374
+ const headerToken = req.headers["x-quotacap-token"];
375
+ if (!isValidToken(headerToken, ctx.token)) {
376
+ return reply.status(401).send({ error: "unauthorized: missing or invalid X-QuotaCap-Token header" });
377
+ }
378
+ const { id } = req.params;
379
+ if (typeof id !== "string" || !id || !Object.hasOwn(adapters, id) || id === "manual") {
380
+ return reply.status(400).send({ error: `unknown provider id: ${id}` });
381
+ }
382
+ const body = (req.body ?? {});
383
+ if (typeof body !== "object" || body === null || typeof body.enabled !== "boolean") {
384
+ return reply.status(400).send({ error: "invalid-argument: body must contain enabled: boolean" });
385
+ }
386
+ const enabling = body.enabled === true;
387
+ if (id === "opencode-go" && enabling && body.consent !== true) {
388
+ return reply.status(400).send({ error: "consent required: enabling opencode-go requires consent: true" });
389
+ }
390
+ if (ctx.configPath) {
391
+ try {
392
+ await setProviderEnabled(id, enabling, ctx.configPath);
393
+ }
394
+ catch (err) {
395
+ return reply.status(500).send({
396
+ error: `failed to persist configuration: ${err?.message ?? String(err)}`,
397
+ });
398
+ }
399
+ }
400
+ return { ok: true, id, enabled: enabling, restartRequired: true };
401
+ });
366
402
  // Graceful-stop request for CLI-driven takeovers. The endpoint restarts
367
403
  // nothing by itself: it responds 202, then the service follows its normal
368
404
  // SIGTERM path while the CLI spawns the successor after the port releases.
@@ -16,6 +16,7 @@ import { buildApp } from "../http/server.js";
16
16
  import { detectChannel, refreshUpdateCache } from "./updates.js";
17
17
  import { VERSION } from "../version.js";
18
18
  import { claudeAdapter } from "../adapters/claude.js";
19
+ import { opencodeGoDetected } from "../adapters/opencode-go.js";
19
20
  import { classifyFailure } from "../diagnostics/failure.js";
20
21
  import { ensureCatalogs, catalogProviders } from "../catalog/index.js";
21
22
  export function resolveClaudeExecPath() {
@@ -198,6 +199,7 @@ export async function startService(opts) {
198
199
  token,
199
200
  coordinator,
200
201
  enabledProviders: config.enabledProviders,
202
+ detectedProviders: opencodeGoDetected() ? ["opencode-go"] : [],
201
203
  providerNames: config.providerNames,
202
204
  catalogTtlHours: config.catalogTtlHours,
203
205
  version: VERSION,
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  // generated by scripts/build-embed.mjs — do not edit
2
- export const VERSION = "0.0.37";
2
+ export const VERSION = "0.0.38";