quotacap 0.0.1

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 (89) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +119 -0
  3. package/dist/adapters/claude.d.ts +7 -0
  4. package/dist/adapters/claude.js +78 -0
  5. package/dist/adapters/index.d.ts +18 -0
  6. package/dist/adapters/index.js +33 -0
  7. package/dist/adapters/manual.d.ts +8 -0
  8. package/dist/adapters/manual.js +25 -0
  9. package/dist/adapters/types.d.ts +17 -0
  10. package/dist/adapters/types.js +1 -0
  11. package/dist/advisory/engine.d.ts +11 -0
  12. package/dist/advisory/engine.js +26 -0
  13. package/dist/advisory/types.d.ts +10 -0
  14. package/dist/advisory/types.js +1 -0
  15. package/dist/cli/index.d.ts +2 -0
  16. package/dist/cli/index.js +86 -0
  17. package/dist/config.d.ts +20 -0
  18. package/dist/config.js +32 -0
  19. package/dist/daemon.d.ts +22 -0
  20. package/dist/daemon.js +30 -0
  21. package/dist/http/server.d.ts +4 -0
  22. package/dist/http/server.js +142 -0
  23. package/dist/mcp/server.d.ts +41 -0
  24. package/dist/mcp/server.js +101 -0
  25. package/dist/src/adapters/claude.d.ts +7 -0
  26. package/dist/src/adapters/claude.js +78 -0
  27. package/dist/src/adapters/index.d.ts +18 -0
  28. package/dist/src/adapters/index.js +33 -0
  29. package/dist/src/adapters/manual.d.ts +8 -0
  30. package/dist/src/adapters/manual.js +25 -0
  31. package/dist/src/adapters/types.d.ts +17 -0
  32. package/dist/src/adapters/types.js +1 -0
  33. package/dist/src/advisory/engine.d.ts +11 -0
  34. package/dist/src/advisory/engine.js +26 -0
  35. package/dist/src/advisory/types.d.ts +10 -0
  36. package/dist/src/advisory/types.js +1 -0
  37. package/dist/src/cli/index.d.ts +2 -0
  38. package/dist/src/cli/index.js +86 -0
  39. package/dist/src/config.d.ts +20 -0
  40. package/dist/src/config.js +32 -0
  41. package/dist/src/daemon.d.ts +22 -0
  42. package/dist/src/daemon.js +30 -0
  43. package/dist/src/http/server.d.ts +4 -0
  44. package/dist/src/http/server.js +142 -0
  45. package/dist/src/mcp/server.d.ts +41 -0
  46. package/dist/src/mcp/server.js +101 -0
  47. package/dist/src/store/db.d.ts +2 -0
  48. package/dist/src/store/db.js +18 -0
  49. package/dist/src/store/quotas.d.ts +5 -0
  50. package/dist/src/store/quotas.js +28 -0
  51. package/dist/src/webAssets.d.ts +1 -0
  52. package/dist/src/webAssets.js +2 -0
  53. package/dist/src/webHtml.d.ts +1 -0
  54. package/dist/src/webHtml.js +2 -0
  55. package/dist/store/db.d.ts +2 -0
  56. package/dist/store/db.js +18 -0
  57. package/dist/store/quotas.d.ts +5 -0
  58. package/dist/store/quotas.js +28 -0
  59. package/dist/tests/adapters/claude.test.d.ts +1 -0
  60. package/dist/tests/adapters/claude.test.js +42 -0
  61. package/dist/tests/adapters/manual.test.d.ts +1 -0
  62. package/dist/tests/adapters/manual.test.js +10 -0
  63. package/dist/tests/advisory/engine.test.d.ts +1 -0
  64. package/dist/tests/advisory/engine.test.js +11 -0
  65. package/dist/tests/bootstrap.test.d.ts +1 -0
  66. package/dist/tests/bootstrap.test.js +14 -0
  67. package/dist/tests/cli/cli.test.d.ts +1 -0
  68. package/dist/tests/cli/cli.test.js +10 -0
  69. package/dist/tests/http/api.test.d.ts +1 -0
  70. package/dist/tests/http/api.test.js +14 -0
  71. package/dist/tests/integration.test.d.ts +1 -0
  72. package/dist/tests/integration.test.js +48 -0
  73. package/dist/tests/mcp/server.test.d.ts +1 -0
  74. package/dist/tests/mcp/server.test.js +7 -0
  75. package/dist/tests/store/db.test.d.ts +1 -0
  76. package/dist/tests/store/db.test.js +13 -0
  77. package/dist/tests/web/build.test.d.ts +1 -0
  78. package/dist/tests/web/build.test.js +8 -0
  79. package/dist/web/src/App.d.ts +3 -0
  80. package/dist/web/src/App.js +78 -0
  81. package/dist/web/src/api.d.ts +2 -0
  82. package/dist/web/src/api.js +8 -0
  83. package/dist/webAssets.d.ts +1 -0
  84. package/dist/webAssets.js +2 -0
  85. package/dist/webHtml.d.ts +1 -0
  86. package/dist/webHtml.js +2 -0
  87. package/package.json +44 -0
  88. package/web/dist/assets/index-G2N84rBQ.js +40 -0
  89. package/web/dist/index.html +2 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Carlos Boeing
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,119 @@
1
+ # QuotaCap
2
+
3
+ **Cross-harness AI quota dashboard + harness-callable advice.**
4
+
5
+ One place to see how much of each AI subscription you've used. Shows when it resets and what to burn next. Goal is 100% use at reset.
6
+
7
+ Polls pluggable adapters (Claude, Codex, Gemini, Kimi, Grok, OpenCode). Stores history in SQLite. Serves:
8
+
9
+ * **Web dashboard** at `http://localhost:8787` — banner + 7-day strip + table with burn vs ideal. Includes stale badges and degraded banner.
10
+ * **CLI** `quotacap` / `npx quotacap` — `status`, `advise --json`, `ingest`, `web`, `init`, `daemon`
11
+ * **MCP server** — `get_quotas`, `get_recommendation`, `forecast` for any harness
12
+
13
+ Runs local-only by default. No prompts or file contents leave your machine.
14
+
15
+ ## Install
16
+
17
+ ```bash
18
+ # Binary — no Node needed (macOS + Linux, arm64/x64)
19
+ curl -fsSL https://raw.githubusercontent.com/carlosboeing/quotacap/main/install.sh | sh
20
+
21
+ # Or via npm (requires Node 22.13+; node:sqlite needs --experimental-sqlite before 22.13)
22
+ npm install -g quotacap # global install
23
+ npx quotacap # run without install
24
+ ```
25
+
26
+ The binary is a self-contained Bun-compiled executable from GitHub Releases.
27
+ The npm package runs the same CLI on Node. Both put `quotacap` on your PATH.
28
+
29
+ ## Quick start
30
+
31
+ ```bash
32
+ quotacap init
33
+ quotacap ingest --provider kimi --text "Current week: 22% used · resets Aug 29 at 11am"
34
+ quotacap status --json
35
+ quotacap web # http://localhost:8787
36
+ quotacap advise --json # tries HTTP, falls back to local DB
37
+ quotacap advise --json --task heavy
38
+ ```
39
+
40
+ ## HTTP API
41
+
42
+ ```bash
43
+ curl http://localhost:8787/health
44
+ curl http://localhost:8787/api/quotas
45
+ curl "http://localhost:8787/api/recommendation?task=any"
46
+ curl -X POST http://localhost:8787/api/refresh
47
+ ```
48
+
49
+ * `GET /health` → `{ok, uptime, lastPollAt}`
50
+ * `GET /api/quotas` → `Quota[]` with `stale`, `ageMs`
51
+ * `GET /api/recommendation?task=any` → `{use, reason, advisories}`
52
+ * `POST /api/refresh` → `{fulfilled, rejected, lastPollAt, degraded}` — debounced 60s, always 200
53
+ * `GET /` → `web/dist/index.html` if built, else `web/index.html`
54
+
55
+ ## CLI
56
+
57
+ ```
58
+ quotacap status [--json]
59
+ quotacap advise [--json] [--task any|heavy|light]
60
+ quotacap ingest --provider <id> --text "..."
61
+ quotacap web [--port 8787]
62
+ quotacap daemon [--foreground]
63
+ quotacap init
64
+ ```
65
+
66
+ `advise` fetches `http://localhost:$port/api/recommendation` with 2s timeout. Falls back to local `recommend()` when daemon is down.
67
+
68
+ ## MCP
69
+
70
+ ```json
71
+ {
72
+ "mcpServers": {
73
+ "quotacap": { "command": "quotacap", "args": ["mcp"] }
74
+ }
75
+ }
76
+ ```
77
+
78
+ Use `"command": "npx", "args": ["quotacap", "mcp"]` when installed via npm only.
79
+ Tools: `get_quotas`, `get_recommendation`, `forecast`. Wrapper over HTTP. Set `QUOTACAP_URL` to override.
80
+
81
+ ## How it works
82
+
83
+ ```
84
+ Adapters (claude-cli ✓, manual ✓, others → manual-paste)
85
+ → daemon (poll 15m + jitter, SQLite ~/.quotacap/quotacap.db)
86
+ → HTTP :8787
87
+ → dashboard + CLI + MCP (same handler)
88
+ ```
89
+
90
+ Adapters are isolated. One failure does not block others. Uses `Promise.allSettled` and per-adapter catch. `POST /api/refresh` always 200.
91
+
92
+ ## Config
93
+
94
+ `~/.quotacap/config.json`:
95
+
96
+ ```json
97
+ { "port": 8787, "pollMinutes": 15, "enabledProviders": ["claude"] }
98
+ ```
99
+
100
+ DB is `~/.quotacap/quotacap.db` (`quotas`, `snapshots`).
101
+
102
+ ## Development
103
+
104
+ ```bash
105
+ npm test # vitest run (10 files) — builds first via pretest
106
+ bun test tests/bun/ # bun-runtime tests (sqlite adapter, MCP translation)
107
+ npm run build # vite build → embed web assets → tsc → flatten dist → chmod bin
108
+ npm run build:bin # bun build --compile (current platform, or pass targets)
109
+ npx tsc --noEmit
110
+ ```
111
+
112
+ The `store/db.ts` adapter picks `node:sqlite` on Node and `bun:sqlite` on Bun, so
113
+ the same code runs as an npm package and as a compiled binary.
114
+
115
+ See `.workbench/2-design/2026-08-28-quotacap-design.md` for spec. See `docs/ROADMAP.md` for next steps.
116
+
117
+ ## License
118
+
119
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,7 @@
1
+ import type { Quota } from "./types.js";
2
+ export declare function parseClaudeUsage(result: string, now?: Date): Quota;
3
+ export declare const claudeAdapter: {
4
+ id: string;
5
+ requiresAuth: string;
6
+ poll(): Promise<Quota>;
7
+ };
@@ -0,0 +1,78 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import { parse, isValid } from "date-fns";
4
+ import { fromZonedTime, formatInTimeZone } from "date-fns-tz";
5
+ const exec = promisify(execFile);
6
+ const BRISBANE_TZ = "Australia/Brisbane";
7
+ const RESET_FORMATS = [
8
+ "MMM d 'at' h:mma yyyy",
9
+ "MMM d 'at' ha yyyy",
10
+ "MMM d 'at' h:mm a yyyy",
11
+ "MMM d 'at' h a yyyy",
12
+ ];
13
+ function tryParseWithYear(raw, year) {
14
+ const withYear = `${raw.trim()} ${year}`;
15
+ const normalized = withYear.replace(/([ap]m)\b/gi, (m) => m.toUpperCase());
16
+ for (const fmt of RESET_FORMATS) {
17
+ const d = parse(normalized, fmt, new Date());
18
+ if (isValid(d)) {
19
+ return fromZonedTime(d, BRISBANE_TZ);
20
+ }
21
+ }
22
+ return null;
23
+ }
24
+ function parseBrisbaneReset(resetsRaw, now) {
25
+ const trimmed = resetsRaw.trim();
26
+ if (!trimmed)
27
+ return null;
28
+ const yearStr = formatInTimeZone(now, BRISBANE_TZ, "yyyy");
29
+ const year = parseInt(yearStr, 10);
30
+ let utc = tryParseWithYear(trimmed, year);
31
+ if (!utc)
32
+ return null;
33
+ if (utc.getTime() < now.getTime()) {
34
+ const utcNext = tryParseWithYear(trimmed, year + 1);
35
+ if (utcNext) {
36
+ const diff = utcNext.getTime() - now.getTime();
37
+ if (diff >= 0 && diff < 8 * 86400000) {
38
+ utc = utcNext;
39
+ }
40
+ else {
41
+ // stale / out-of-range (e.g. monthly-plan string) — fallback to now+7d
42
+ return null;
43
+ }
44
+ }
45
+ else {
46
+ return null;
47
+ }
48
+ // if utc was past and no valid next-year within window, treat as stale
49
+ if (utc.getTime() < now.getTime())
50
+ return null;
51
+ }
52
+ return formatInTimeZone(utc, BRISBANE_TZ, "yyyy-MM-dd'T'HH:mm:ssXXX");
53
+ }
54
+ export function parseClaudeUsage(result, now = new Date()) {
55
+ const sessionMatch = result.match(/Current session:\s+(\d+)% used[^·]*·\s*resets\s+([^\n(]+?)\s*\(/);
56
+ const weeklyMatch = result.match(/Current week \(all models\):\s+(\d+)% used[^·]*·\s*resets\s+([^\n(]+?)\s*\(/);
57
+ const usedPct = weeklyMatch ? parseInt(weeklyMatch[1], 10) : 0;
58
+ const sessionPct = sessionMatch ? parseInt(sessionMatch[1], 10) : undefined;
59
+ const resetsRaw = weeklyMatch?.[2].trim() ?? "";
60
+ let resetsAt = parseBrisbaneReset(resetsRaw, now);
61
+ if (!resetsAt)
62
+ resetsAt = new Date(now.getTime() + 7 * 86400000).toISOString();
63
+ return {
64
+ provider: "claude", plan: "max", usedPct, sessionPct,
65
+ resetsAt, periodStart: new Date(now.getTime() - 7 * 86400000).toISOString(),
66
+ raw: result, source: "cli", fetchedAt: now.toISOString()
67
+ };
68
+ }
69
+ export const claudeAdapter = {
70
+ id: "claude",
71
+ requiresAuth: "keychain:Claude Code-credentials",
72
+ async poll() {
73
+ const { stdout } = await exec("claude", ["-p", "/usage", "--output-format", "json"], { timeout: 8000 });
74
+ const parsed = JSON.parse(stdout);
75
+ const result = parsed.result ?? stdout;
76
+ return parseClaudeUsage(result);
77
+ }
78
+ };
@@ -0,0 +1,18 @@
1
+ import type { Adapter } from "./types.js";
2
+ export declare const adapters: Record<string, Adapter>;
3
+ export declare function pollAll(enabled: string[]): Promise<({
4
+ provider: string;
5
+ status: "fulfilled";
6
+ value: import("./types.js").Quota;
7
+ reason?: undefined;
8
+ } | {
9
+ provider: string;
10
+ status: "skipped";
11
+ reason: any;
12
+ value?: undefined;
13
+ } | {
14
+ provider: string;
15
+ status: "rejected";
16
+ reason: any;
17
+ value?: undefined;
18
+ })[]>;
@@ -0,0 +1,33 @@
1
+ import { claudeAdapter } from "./claude.js";
2
+ import { manualAdapter } from "./manual.js";
3
+ export const adapters = {
4
+ claude: claudeAdapter,
5
+ manual: manualAdapter,
6
+ };
7
+ function withTimeout(p, ms) {
8
+ return new Promise((resolve, reject) => {
9
+ const timer = setTimeout(() => reject(new Error(`timeout after ${ms}ms`)), ms);
10
+ Promise.resolve(p).then((v) => { clearTimeout(timer); resolve(v); }, (e) => { clearTimeout(timer); reject(e); });
11
+ });
12
+ }
13
+ export async function pollAll(enabled) {
14
+ const rawJobs = enabled.map(id => {
15
+ const a = adapters[id];
16
+ if (!a)
17
+ return Promise.reject(new Error(`unknown adapter ${id}`));
18
+ // manual adapter has no poll capability — skip without degraded
19
+ if (id === "manual")
20
+ return Promise.reject(new Error("manual skipped — use ingest"));
21
+ return withTimeout(a.poll(), 8000);
22
+ });
23
+ const settled = await Promise.allSettled(rawJobs);
24
+ return settled.map((s, i) => {
25
+ const id = enabled[i];
26
+ if (s.status === "fulfilled")
27
+ return { provider: id, status: "fulfilled", value: s.value };
28
+ const msg = String(s.reason?.message ?? s.reason);
29
+ if (msg.includes("manual skipped"))
30
+ return { provider: id, status: "skipped", reason: s.reason };
31
+ return { provider: id, status: "rejected", reason: s.reason };
32
+ });
33
+ }
@@ -0,0 +1,8 @@
1
+ import type { Quota } from "./types.js";
2
+ export declare function parseManualUsage(provider: string, text: string, now?: Date): Quota;
3
+ export declare const manualAdapter: {
4
+ id: string;
5
+ requiresAuth: string;
6
+ ingest(provider: string, text: string): Promise<Quota>;
7
+ poll(): Promise<Quota>;
8
+ };
@@ -0,0 +1,25 @@
1
+ export function parseManualUsage(provider, text, now = new Date()) {
2
+ const m = text.match(/(\d+)% used/);
3
+ const usedPct = m ? parseInt(m[1], 10) : 0;
4
+ const resetsAt = new Date(now.getTime() + 3 * 86400000).toISOString();
5
+ return {
6
+ provider,
7
+ plan: "unknown",
8
+ usedPct,
9
+ resetsAt,
10
+ periodStart: new Date(now.getTime() - 7 * 86400000).toISOString(),
11
+ raw: text,
12
+ source: "manual",
13
+ fetchedAt: now.toISOString(),
14
+ };
15
+ }
16
+ export const manualAdapter = {
17
+ id: "manual",
18
+ requiresAuth: "none",
19
+ async ingest(provider, text) {
20
+ return parseManualUsage(provider, text);
21
+ },
22
+ async poll() {
23
+ throw new Error("manual has no poll — use ingest");
24
+ },
25
+ };
@@ -0,0 +1,17 @@
1
+ export interface Quota {
2
+ provider: string;
3
+ plan: string;
4
+ usedPct: number;
5
+ sessionPct?: number;
6
+ resetsAt: string;
7
+ periodStart: string;
8
+ raw: string;
9
+ source: "cli" | "api" | "scrape" | "manual";
10
+ fetchedAt: string;
11
+ }
12
+ export interface Adapter {
13
+ id: string;
14
+ requiresAuth: string;
15
+ poll(): Promise<Quota>;
16
+ }
17
+ export declare const UNIMPLEMENTED: (id: string) => Error;
@@ -0,0 +1 @@
1
+ export const UNIMPLEMENTED = (id) => new Error(`${id} adapter not implemented — use manual-paste`);
@@ -0,0 +1,11 @@
1
+ import type { Quota } from "../adapters/types.js";
2
+ import type { Advisory } from "./types.js";
3
+ export declare function computeAdvisory(q: Quota, burnRate: number, now?: Date): Advisory;
4
+ export declare function recommend(quotas: Quota[], _task: string, burnByProvider?: Map<string, number>, now?: Date): {
5
+ use: string;
6
+ reason: string;
7
+ wastePct: number;
8
+ idealRate: number;
9
+ alternatives: Quota[];
10
+ advisories: Advisory[];
11
+ };
@@ -0,0 +1,26 @@
1
+ export function computeAdvisory(q, burnRate, now = new Date()) {
2
+ const resets = new Date(q.resetsAt);
3
+ const daysLeft = Math.max(0.1, (resets.getTime() - now.getTime()) / 86400000);
4
+ const remaining = 100 - q.usedPct;
5
+ const idealRate = remaining / daysLeft;
6
+ const wastePct = Math.max(0, remaining - burnRate * daysLeft);
7
+ let urgency = "on track";
8
+ if (wastePct > 30 && daysLeft < 3)
9
+ urgency = "burn now";
10
+ else if (wastePct > 20 && daysLeft < 7)
11
+ urgency = "use soon";
12
+ else if (burnRate > idealRate * 1.4)
13
+ urgency = "slow down";
14
+ else if (wastePct > 10)
15
+ urgency = "save";
16
+ return { provider: q.provider, daysLeft, remaining, idealRate, burnRate, wastePct, urgency };
17
+ }
18
+ export function recommend(quotas, _task, burnByProvider = new Map(), now = new Date()) {
19
+ if (!quotas.length)
20
+ return { use: "none", reason: "no quotas yet", wastePct: 0, idealRate: 0, alternatives: [], advisories: [] };
21
+ const advisories = quotas.map(q => computeAdvisory(q, burnByProvider.get(q.provider) ?? 2, now));
22
+ const burnNow = advisories.filter(a => a.urgency === "burn now");
23
+ const pool = burnNow.length ? burnNow : advisories;
24
+ const use = pool.sort((a, b) => b.wastePct - a.wastePct)[0];
25
+ return { use: use.provider, reason: `${Math.round(use.wastePct)}% waste in ${use.daysLeft.toFixed(1)}d`, wastePct: use.wastePct, idealRate: use.idealRate, alternatives: quotas, advisories };
26
+ }
@@ -0,0 +1,10 @@
1
+ export type Urgency = "burn now" | "use soon" | "slow down" | "save" | "on track";
2
+ export interface Advisory {
3
+ provider: string;
4
+ daysLeft: number;
5
+ remaining: number;
6
+ idealRate: number;
7
+ burnRate: number;
8
+ wastePct: number;
9
+ urgency: Urgency;
10
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,86 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from "commander";
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import { buildApp } from "../http/server.js";
6
+ import { openDb, migrate } from "../store/db.js";
7
+ import { getDbPath, readConfig } from "../config.js";
8
+ function ensureDbDir() { try {
9
+ fs.mkdirSync(path.dirname(getDbPath()), { recursive: true });
10
+ }
11
+ catch { } }
12
+ const program = new Command();
13
+ program.name("quotacap").version("0.0.1");
14
+ program.command("status").option("--json", "json").action(async (opts) => {
15
+ ensureDbDir();
16
+ const db = openDb(getDbPath());
17
+ migrate(db);
18
+ const { getAllLatest } = await import("../store/quotas.js");
19
+ const quotas = getAllLatest(db);
20
+ if (opts.json)
21
+ console.log(JSON.stringify(quotas, null, 2));
22
+ else
23
+ console.table(quotas);
24
+ });
25
+ program.command("advise").option("--json", "json").option("--task <t>", "task", "any").action(async (opts) => {
26
+ ensureDbDir();
27
+ const db = openDb(getDbPath());
28
+ migrate(db);
29
+ try {
30
+ const r = await fetch(`http://localhost:${(await readConfig()).port}/api/recommendation?task=${opts.task}`, { signal: AbortSignal.timeout(2000) });
31
+ const j = await r.json();
32
+ console.log(opts.json ? JSON.stringify(j, null, 2) : `${j.use}: ${j.reason}`);
33
+ }
34
+ catch {
35
+ const { recommend } = await import("../advisory/engine.js");
36
+ const { getAllLatest } = await import("../store/quotas.js");
37
+ const quotas = getAllLatest(db);
38
+ if (!quotas.length) {
39
+ console.log(opts.json ? JSON.stringify({ use: "none", reason: "no quotas yet" }, null, 2) : "none: no quotas yet");
40
+ return;
41
+ }
42
+ const rec = recommend(quotas, opts.task);
43
+ console.log(opts.json ? JSON.stringify(rec, null, 2) : `${rec.use}: ${rec.reason}`);
44
+ }
45
+ });
46
+ program.command("ingest").requiredOption("--provider <p>").requiredOption("--text <t>").action(async (o) => {
47
+ ensureDbDir();
48
+ const db = openDb(getDbPath());
49
+ migrate(db);
50
+ const { parseManualUsage } = await import("../adapters/manual.js");
51
+ const { upsertQuota } = await import("../store/quotas.js");
52
+ upsertQuota(db, parseManualUsage(o.provider, o.text));
53
+ console.log("ingested");
54
+ });
55
+ program.command("web").option("--port <n>").action(async (o) => {
56
+ ensureDbDir();
57
+ const db = openDb(getDbPath());
58
+ migrate(db);
59
+ const app = buildApp(db);
60
+ const port = o.port ? parseInt(o.port) : (await readConfig()).port;
61
+ await app.listen({ port, host: "127.0.0.1" });
62
+ console.log(`QuotaCap at http://localhost:${port}`);
63
+ });
64
+ program.command("init").action(async () => {
65
+ const { readConfig, writeConfig } = await import("../config.js");
66
+ const c = await readConfig();
67
+ await writeConfig(c);
68
+ console.log(JSON.stringify(c, null, 2));
69
+ });
70
+ program.command("daemon").option("--foreground", "keep foreground (default: true)").action(async (o) => {
71
+ const { startDaemon } = await import("../daemon.js");
72
+ const { timer } = await startDaemon();
73
+ console.log("QuotaCap daemon started" + (o.foreground !== false ? " (foreground)" : ""));
74
+ // keep alive until SIGINT/SIGTERM — timer is ref'd so event loop stays alive
75
+ process.on("SIGINT", () => { clearInterval(timer); process.exit(0); });
76
+ process.on("SIGTERM", () => { clearInterval(timer); process.exit(0); });
77
+ // explicitly keep process alive if interval was somehow unref'd elsewhere
78
+ if (timer.ref)
79
+ timer.ref();
80
+ });
81
+ program.command("mcp").description("start MCP server (stdio over HTTP)").action(async () => {
82
+ const mod = await import("../mcp/server.js");
83
+ // if run with --help, commander handles it before action; this is the real server
84
+ await mod.runMcpServer();
85
+ });
86
+ program.parseAsync();
@@ -0,0 +1,20 @@
1
+ import { z } from "zod";
2
+ export declare function getConfigPath(p?: string): string;
3
+ export declare function getDbPath(p?: string): string;
4
+ declare const ConfigSchema: z.ZodObject<{
5
+ port: z.ZodDefault<z.ZodNumber>;
6
+ pollMinutes: z.ZodDefault<z.ZodNumber>;
7
+ enabledProviders: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
8
+ }, "strip", z.ZodTypeAny, {
9
+ port: number;
10
+ pollMinutes: number;
11
+ enabledProviders: string[];
12
+ }, {
13
+ port?: number | undefined;
14
+ pollMinutes?: number | undefined;
15
+ enabledProviders?: string[] | undefined;
16
+ }>;
17
+ export type Config = z.infer<typeof ConfigSchema>;
18
+ export declare function readConfig(p?: string): Promise<Config>;
19
+ export declare function writeConfig(c: Config, p?: string): Promise<void>;
20
+ export {};
package/dist/config.js ADDED
@@ -0,0 +1,32 @@
1
+ import fs from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { z } from "zod";
5
+ export function getConfigPath(p) {
6
+ if (p)
7
+ return p;
8
+ return path.join(os.homedir(), ".quotacap", "config.json");
9
+ }
10
+ export function getDbPath(p) {
11
+ if (p)
12
+ return p;
13
+ return path.join(os.homedir(), ".quotacap", "quotacap.db");
14
+ }
15
+ const ConfigSchema = z.object({
16
+ port: z.number().default(8787),
17
+ pollMinutes: z.number().default(15),
18
+ enabledProviders: z.array(z.string()).default(["claude"]),
19
+ });
20
+ export async function readConfig(p) {
21
+ try {
22
+ const raw = await fs.readFile(getConfigPath(p), "utf8");
23
+ return ConfigSchema.parse(JSON.parse(raw));
24
+ }
25
+ catch {
26
+ return ConfigSchema.parse({});
27
+ }
28
+ }
29
+ export async function writeConfig(c, p) {
30
+ await fs.mkdir(path.dirname(getConfigPath(p)), { recursive: true });
31
+ await fs.writeFile(getConfigPath(p), JSON.stringify(c, null, 2));
32
+ }
@@ -0,0 +1,22 @@
1
+ export declare function pollOnce(db: any, enabled: string[]): Promise<({
2
+ provider: string;
3
+ status: "fulfilled";
4
+ value: import("./adapters/types.js").Quota;
5
+ reason?: undefined;
6
+ } | {
7
+ provider: string;
8
+ status: "skipped";
9
+ reason: any;
10
+ value?: undefined;
11
+ } | {
12
+ provider: string;
13
+ status: "rejected";
14
+ reason: any;
15
+ value?: undefined;
16
+ })[]>;
17
+ export declare function startDaemon(): Promise<{
18
+ db: any;
19
+ timer: NodeJS.Timeout;
20
+ stop: () => void;
21
+ }>;
22
+ export declare const start: typeof startDaemon;
package/dist/daemon.js ADDED
@@ -0,0 +1,30 @@
1
+ import { pollAll } from "./adapters/index.js";
2
+ import { upsertQuota } from "./store/quotas.js";
3
+ export async function pollOnce(db, enabled) {
4
+ const results = await pollAll(enabled);
5
+ for (const r of results) {
6
+ if (r.status === "fulfilled" && r.value)
7
+ upsertQuota(db, r.value);
8
+ else if (r.status === "rejected")
9
+ console.warn(`[quotacap] poll ${r.provider} failed: ${String(r.reason?.message ?? r.reason)}`);
10
+ // skipped (e.g. manual) is not a failure — no warning, not degraded
11
+ }
12
+ return results;
13
+ }
14
+ export async function startDaemon() {
15
+ const { getDbPath, readConfig } = await import("./config.js");
16
+ const { openDb, migrate } = await import("./store/db.js");
17
+ const db = openDb(getDbPath());
18
+ migrate(db);
19
+ const cfg = await readConfig();
20
+ const intervalMs = (cfg.pollMinutes ?? 15) * 60 * 1000;
21
+ // initial poll (fire-and-forget, don't block start)
22
+ pollOnce(db, cfg.enabledProviders).catch(e => console.warn("[quotacap] initial poll failed", e?.message ?? String(e)));
23
+ const jitter = Math.floor(Math.random() * 5000);
24
+ const timer = setInterval(() => { pollOnce(db, cfg.enabledProviders).catch(e => console.warn("[quotacap] interval poll failed", e?.message ?? String(e))); }, intervalMs + jitter);
25
+ // keep event loop alive — this is the only long-lived process per design
26
+ // (previous unref() caused immediate exit after the initial poll)
27
+ return { db, timer, stop: () => clearInterval(timer) };
28
+ }
29
+ // alias for plan's daemon.start() naming
30
+ export const start = startDaemon;
@@ -0,0 +1,4 @@
1
+ import type { FastifyInstance } from "fastify";
2
+ export declare function buildApp(db: any): FastifyInstance;
3
+ export declare function getLastPollAt(app?: FastifyInstance): string | null;
4
+ export declare function resetRefreshState(app?: FastifyInstance): void;