quotacap 0.0.20 → 0.0.22

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 (315) hide show
  1. package/README.md +59 -18
  2. package/dist/adapters/agy.d.ts +3 -0
  3. package/dist/adapters/agy.js +81 -0
  4. package/dist/adapters/claude.d.ts +7 -5
  5. package/dist/adapters/claude.js +6 -6
  6. package/dist/adapters/codex.d.ts +3 -3
  7. package/dist/adapters/codex.js +128 -49
  8. package/dist/adapters/grok.d.ts +3 -3
  9. package/dist/adapters/grok.js +114 -70
  10. package/dist/adapters/index.d.ts +5 -2
  11. package/dist/adapters/index.js +36 -9
  12. package/dist/adapters/kimi.d.ts +3 -3
  13. package/dist/adapters/kimi.js +64 -86
  14. package/dist/adapters/manual.d.ts +4 -4
  15. package/dist/adapters/manual.js +2 -2
  16. package/dist/adapters/pty.d.ts +23 -0
  17. package/dist/adapters/pty.js +487 -0
  18. package/dist/adapters/types.d.ts +7 -3
  19. package/dist/advisory/engine.d.ts +19 -2
  20. package/dist/advisory/engine.js +130 -10
  21. package/dist/advisory/snapshot.d.ts +16 -0
  22. package/dist/advisory/snapshot.js +213 -0
  23. package/dist/advisory/types.d.ts +46 -4
  24. package/dist/advisory/validate.d.ts +5 -0
  25. package/dist/advisory/validate.js +18 -0
  26. package/dist/cli/advise.d.ts +3 -0
  27. package/dist/cli/advise.js +62 -0
  28. package/dist/cli/clients.d.ts +18 -0
  29. package/dist/cli/clients.js +11 -0
  30. package/dist/cli/index.js +4 -84
  31. package/dist/cli/ingest.d.ts +3 -0
  32. package/dist/cli/ingest.js +31 -0
  33. package/dist/cli/runtime.d.ts +12 -0
  34. package/dist/cli/runtime.js +125 -0
  35. package/dist/cli/snapshot-source.d.ts +25 -0
  36. package/dist/cli/snapshot-source.js +223 -0
  37. package/dist/cli/status.d.ts +3 -0
  38. package/dist/cli/status.js +84 -0
  39. package/dist/config.d.ts +20 -0
  40. package/dist/config.js +126 -1
  41. package/dist/daemon.d.ts +4 -29
  42. package/dist/daemon.js +12 -67
  43. package/dist/format/markdown.d.ts +3 -0
  44. package/dist/format/markdown.js +24 -0
  45. package/dist/format/rows.d.ts +27 -0
  46. package/dist/format/rows.js +241 -0
  47. package/dist/format/terminal.d.ts +12 -0
  48. package/dist/format/terminal.js +144 -0
  49. package/dist/http/server.d.ts +24 -3
  50. package/dist/http/server.js +236 -106
  51. package/dist/mcp/server.d.ts +0 -6
  52. package/dist/mcp/server.js +78 -25
  53. package/dist/runtime/client.d.ts +24 -0
  54. package/dist/runtime/client.js +72 -0
  55. package/dist/runtime/owner.d.ts +35 -0
  56. package/dist/runtime/owner.js +393 -0
  57. package/dist/runtime/poll.d.ts +48 -0
  58. package/dist/runtime/poll.js +222 -0
  59. package/dist/runtime/service.d.ts +24 -0
  60. package/dist/runtime/service.js +251 -0
  61. package/dist/runtime/spawn.d.ts +17 -0
  62. package/dist/runtime/spawn.js +97 -0
  63. package/dist/runtime/token.d.ts +3 -0
  64. package/dist/runtime/token.js +64 -0
  65. package/dist/service/index.d.ts +4 -0
  66. package/dist/service/index.js +47 -0
  67. package/dist/service/macos.d.ts +96 -0
  68. package/dist/service/macos.js +551 -0
  69. package/dist/src/adapters/agy.d.ts +3 -0
  70. package/dist/src/adapters/agy.js +81 -0
  71. package/dist/src/adapters/claude.d.ts +7 -5
  72. package/dist/src/adapters/claude.js +6 -6
  73. package/dist/src/adapters/codex.d.ts +3 -3
  74. package/dist/src/adapters/codex.js +128 -49
  75. package/dist/src/adapters/grok.d.ts +3 -3
  76. package/dist/src/adapters/grok.js +114 -70
  77. package/dist/src/adapters/index.d.ts +5 -2
  78. package/dist/src/adapters/index.js +36 -9
  79. package/dist/src/adapters/kimi.d.ts +3 -3
  80. package/dist/src/adapters/kimi.js +64 -86
  81. package/dist/src/adapters/manual.d.ts +4 -4
  82. package/dist/src/adapters/manual.js +2 -2
  83. package/dist/src/adapters/pty.d.ts +23 -0
  84. package/dist/src/adapters/pty.js +487 -0
  85. package/dist/src/adapters/types.d.ts +7 -3
  86. package/dist/src/advisory/engine.d.ts +19 -2
  87. package/dist/src/advisory/engine.js +130 -10
  88. package/dist/src/advisory/snapshot.d.ts +16 -0
  89. package/dist/src/advisory/snapshot.js +213 -0
  90. package/dist/src/advisory/types.d.ts +46 -4
  91. package/dist/src/advisory/validate.d.ts +5 -0
  92. package/dist/src/advisory/validate.js +18 -0
  93. package/dist/src/cli/advise.d.ts +3 -0
  94. package/dist/src/cli/advise.js +62 -0
  95. package/dist/src/cli/clients.d.ts +18 -0
  96. package/dist/src/cli/clients.js +11 -0
  97. package/dist/src/cli/index.js +4 -84
  98. package/dist/src/cli/ingest.d.ts +3 -0
  99. package/dist/src/cli/ingest.js +31 -0
  100. package/dist/src/cli/runtime.d.ts +12 -0
  101. package/dist/src/cli/runtime.js +125 -0
  102. package/dist/src/cli/snapshot-source.d.ts +25 -0
  103. package/dist/src/cli/snapshot-source.js +223 -0
  104. package/dist/src/cli/status.d.ts +3 -0
  105. package/dist/src/cli/status.js +84 -0
  106. package/dist/src/config.d.ts +20 -0
  107. package/dist/src/config.js +126 -1
  108. package/dist/src/daemon.d.ts +4 -29
  109. package/dist/src/daemon.js +12 -67
  110. package/dist/src/format/markdown.d.ts +3 -0
  111. package/dist/src/format/markdown.js +24 -0
  112. package/dist/src/format/rows.d.ts +27 -0
  113. package/dist/src/format/rows.js +241 -0
  114. package/dist/src/format/terminal.d.ts +12 -0
  115. package/dist/src/format/terminal.js +144 -0
  116. package/dist/src/http/server.d.ts +24 -3
  117. package/dist/src/http/server.js +236 -106
  118. package/dist/src/mcp/server.d.ts +0 -6
  119. package/dist/src/mcp/server.js +78 -25
  120. package/dist/src/runtime/client.d.ts +24 -0
  121. package/dist/src/runtime/client.js +72 -0
  122. package/dist/src/runtime/owner.d.ts +35 -0
  123. package/dist/src/runtime/owner.js +393 -0
  124. package/dist/src/runtime/poll.d.ts +48 -0
  125. package/dist/src/runtime/poll.js +222 -0
  126. package/dist/src/runtime/service.d.ts +24 -0
  127. package/dist/src/runtime/service.js +251 -0
  128. package/dist/src/runtime/spawn.d.ts +17 -0
  129. package/dist/src/runtime/spawn.js +97 -0
  130. package/dist/src/runtime/token.d.ts +3 -0
  131. package/dist/src/runtime/token.js +64 -0
  132. package/dist/src/service/index.d.ts +4 -0
  133. package/dist/src/service/index.js +47 -0
  134. package/dist/src/service/macos.d.ts +96 -0
  135. package/dist/src/service/macos.js +551 -0
  136. package/dist/src/store/attempts.d.ts +12 -0
  137. package/dist/src/store/attempts.js +29 -0
  138. package/dist/src/store/db.d.ts +1 -1
  139. package/dist/src/store/db.js +58 -2
  140. package/dist/src/store/quotas.js +52 -10
  141. package/dist/src/version.js +1 -1
  142. package/dist/src/webAssets.js +1 -1
  143. package/dist/src/webHtml.js +1 -1
  144. package/dist/store/attempts.d.ts +12 -0
  145. package/dist/store/attempts.js +29 -0
  146. package/dist/store/db.d.ts +1 -1
  147. package/dist/store/db.js +58 -2
  148. package/dist/store/quotas.js +52 -10
  149. package/dist/tests/adapters/agy.test.js +337 -0
  150. package/dist/tests/adapters/claude.test.js +41 -1
  151. package/dist/tests/adapters/codex.test.js +231 -91
  152. package/dist/tests/adapters/credential-free.test.js +211 -0
  153. package/dist/tests/adapters/grok.test.js +239 -135
  154. package/dist/tests/adapters/kimi.test.js +219 -104
  155. package/dist/tests/adapters/pty.test.d.ts +1 -0
  156. package/dist/tests/adapters/pty.test.js +240 -0
  157. package/dist/tests/adapters/registry.test.js +5 -5
  158. package/dist/tests/advisory/engine.test.js +194 -6
  159. package/dist/tests/advisory/pace.test.d.ts +1 -0
  160. package/dist/tests/advisory/pace.test.js +60 -0
  161. package/dist/tests/advisory/snapshot.test.d.ts +1 -0
  162. package/dist/tests/advisory/snapshot.test.js +65 -0
  163. package/dist/tests/advisory/validate.test.d.ts +1 -0
  164. package/dist/tests/advisory/validate.test.js +19 -0
  165. package/dist/tests/cli/advise.test.d.ts +1 -0
  166. package/dist/tests/cli/advise.test.js +294 -0
  167. package/dist/tests/cli/cli.test.js +39 -19
  168. package/dist/tests/cli/helpers.d.ts +9 -0
  169. package/dist/tests/cli/helpers.js +93 -0
  170. package/dist/tests/cli/ingest.test.d.ts +1 -0
  171. package/dist/tests/cli/ingest.test.js +188 -0
  172. package/dist/tests/cli/parity.test.d.ts +1 -0
  173. package/dist/tests/cli/parity.test.js +225 -0
  174. package/dist/tests/cli/snapshot-source.test.d.ts +1 -0
  175. package/dist/tests/cli/snapshot-source.test.js +305 -0
  176. package/dist/tests/cli/status.test.d.ts +1 -0
  177. package/dist/tests/cli/status.test.js +423 -0
  178. package/dist/tests/daemon.test.js +140 -38
  179. package/dist/tests/fixtures/stable-state.d.ts +22 -0
  180. package/dist/tests/fixtures/stable-state.js +152 -0
  181. package/dist/tests/fixtures/stable-state.test.d.ts +1 -0
  182. package/dist/tests/fixtures/stable-state.test.js +17 -0
  183. package/dist/tests/format/markdown.test.d.ts +1 -0
  184. package/dist/tests/format/markdown.test.js +49 -0
  185. package/dist/tests/format/terminal.test.d.ts +1 -0
  186. package/dist/tests/format/terminal.test.js +476 -0
  187. package/dist/tests/http/api.test.js +224 -2
  188. package/dist/tests/integration.test.js +20 -7
  189. package/dist/tests/mcp/protocol.test.d.ts +1 -0
  190. package/dist/tests/mcp/protocol.test.js +134 -0
  191. package/dist/tests/mcp/quotas.test.js +38 -6
  192. package/dist/tests/mcp/tools.test.d.ts +1 -0
  193. package/dist/tests/mcp/tools.test.js +291 -0
  194. package/dist/tests/runtime/cancel.test.d.ts +1 -0
  195. package/dist/tests/runtime/cancel.test.js +264 -0
  196. package/dist/tests/runtime/cli.test.d.ts +1 -0
  197. package/dist/tests/runtime/cli.test.js +226 -0
  198. package/dist/tests/runtime/config.test.d.ts +1 -0
  199. package/dist/tests/runtime/config.test.js +144 -0
  200. package/dist/tests/runtime/http.test.d.ts +1 -0
  201. package/dist/tests/runtime/http.test.js +267 -0
  202. package/dist/tests/runtime/lifecycle.test.d.ts +1 -0
  203. package/dist/tests/runtime/lifecycle.test.js +303 -0
  204. package/dist/tests/runtime/ownership.test.d.ts +1 -0
  205. package/dist/tests/runtime/ownership.test.js +355 -0
  206. package/dist/tests/runtime/poll.test.d.ts +1 -0
  207. package/dist/tests/runtime/poll.test.js +410 -0
  208. package/dist/tests/runtime/status.test.d.ts +1 -0
  209. package/dist/tests/runtime/status.test.js +232 -0
  210. package/dist/tests/service/macos.test.d.ts +1 -0
  211. package/dist/tests/service/macos.test.js +349 -0
  212. package/dist/tests/store/attempts.test.d.ts +1 -0
  213. package/dist/tests/store/attempts.test.js +29 -0
  214. package/dist/tests/store/db.test.js +61 -6
  215. package/dist/tests/store/migration.test.d.ts +1 -0
  216. package/dist/tests/store/migration.test.js +41 -0
  217. package/dist/tests/web/advice-drawer.test.d.ts +1 -0
  218. package/dist/tests/web/advice-drawer.test.js +45 -0
  219. package/dist/tests/web/dashboard.e2e.d.ts +1 -0
  220. package/dist/tests/web/dashboard.e2e.js +670 -0
  221. package/dist/tests/web/header.test.d.ts +1 -0
  222. package/dist/tests/web/header.test.js +56 -0
  223. package/dist/tests/web/names.test.d.ts +1 -0
  224. package/dist/tests/web/names.test.js +19 -0
  225. package/dist/tests/web/onboarding.test.d.ts +1 -0
  226. package/dist/tests/web/onboarding.test.js +21 -0
  227. package/dist/tests/web/pacing.test.d.ts +1 -0
  228. package/dist/tests/web/pacing.test.js +62 -0
  229. package/dist/tests/web/playwright.config.d.ts +2 -0
  230. package/dist/tests/web/playwright.config.js +10 -0
  231. package/dist/tests/web/provider-drawer.test.d.ts +1 -0
  232. package/dist/tests/web/provider-drawer.test.js +68 -0
  233. package/dist/tests/web/recommendation.test.d.ts +1 -0
  234. package/dist/tests/web/recommendation.test.js +38 -0
  235. package/dist/tests/web/reset-rail.test.d.ts +1 -0
  236. package/dist/tests/web/reset-rail.test.js +63 -0
  237. package/dist/tests/web/responsive.test.d.ts +1 -0
  238. package/dist/tests/web/responsive.test.js +21 -0
  239. package/dist/tests/web/settings.test.d.ts +1 -0
  240. package/dist/tests/web/settings.test.js +32 -0
  241. package/dist/tests/web/state-catalog.test.d.ts +1 -0
  242. package/dist/tests/web/state-catalog.test.js +206 -0
  243. package/dist/tests/web/state.test.d.ts +1 -0
  244. package/dist/tests/web/state.test.js +27 -0
  245. package/dist/tests/web/stub-server.d.ts +37 -0
  246. package/dist/tests/web/stub-server.js +132 -0
  247. package/dist/tests/web/theme.test.d.ts +1 -0
  248. package/dist/tests/web/theme.test.js +12 -0
  249. package/dist/version.js +1 -1
  250. package/dist/web/src/App.d.ts +1 -0
  251. package/dist/web/src/App.js +106 -78
  252. package/dist/web/src/Theme.d.ts +11 -0
  253. package/dist/web/src/Theme.js +53 -0
  254. package/dist/web/src/api.d.ts +27 -2
  255. package/dist/web/src/api.js +102 -6
  256. package/dist/web/src/components/AdviceDrawer.d.ts +9 -0
  257. package/dist/web/src/components/AdviceDrawer.js +77 -0
  258. package/dist/web/src/components/FaultBanner.d.ts +17 -0
  259. package/dist/web/src/components/FaultBanner.js +60 -0
  260. package/dist/web/src/components/Header.d.ts +22 -0
  261. package/dist/web/src/components/Header.js +46 -0
  262. package/dist/web/src/components/PaceBar.d.ts +51 -0
  263. package/dist/web/src/components/PaceBar.js +251 -0
  264. package/dist/web/src/components/PacingLegend.d.ts +3 -0
  265. package/dist/web/src/components/PacingLegend.js +43 -0
  266. package/dist/web/src/components/ProviderCard.d.ts +8 -0
  267. package/dist/web/src/components/ProviderCard.js +27 -0
  268. package/dist/web/src/components/ProviderDrawer.d.ts +52 -0
  269. package/dist/web/src/components/ProviderDrawer.js +270 -0
  270. package/dist/web/src/components/ProviderIcon.d.ts +7 -0
  271. package/dist/web/src/components/ProviderIcon.js +27 -0
  272. package/dist/web/src/components/ProviderRow.d.ts +8 -0
  273. package/dist/web/src/components/ProviderRow.js +14 -0
  274. package/dist/web/src/components/Recommendation.d.ts +27 -0
  275. package/dist/web/src/components/Recommendation.js +77 -0
  276. package/dist/web/src/components/ResetRail.d.ts +38 -0
  277. package/dist/web/src/components/ResetRail.js +188 -0
  278. package/dist/web/src/components/SubscriptionList.d.ts +19 -0
  279. package/dist/web/src/components/SubscriptionList.js +107 -0
  280. package/dist/web/src/components/settings/CliIntegrationPanel.d.ts +2 -0
  281. package/dist/web/src/components/settings/CliIntegrationPanel.js +49 -0
  282. package/dist/web/src/components/settings/DaemonPanel.d.ts +6 -0
  283. package/dist/web/src/components/settings/DaemonPanel.js +45 -0
  284. package/dist/web/src/components/settings/ProvidersPanel.d.ts +14 -0
  285. package/dist/web/src/components/settings/ProvidersPanel.js +69 -0
  286. package/dist/web/src/components/settings/SettingsDrawer.d.ts +9 -0
  287. package/dist/web/src/components/settings/SettingsDrawer.js +55 -0
  288. package/dist/web/src/names.d.ts +3 -0
  289. package/dist/web/src/names.js +19 -0
  290. package/dist/web/src/pages/Onboarding.d.ts +17 -0
  291. package/dist/web/src/pages/Onboarding.js +96 -0
  292. package/dist/web/src/router.d.ts +11 -0
  293. package/dist/web/src/router.js +31 -0
  294. package/dist/web/src/state.d.ts +97 -0
  295. package/dist/web/src/state.js +80 -0
  296. package/dist/webAssets.js +1 -1
  297. package/dist/webHtml.js +1 -1
  298. package/package.json +28 -9
  299. package/scripts/fix-pty-perms.mjs +21 -0
  300. package/web/dist/assets/index-CyUkWdwJ.css +1 -0
  301. package/web/dist/assets/index-PN4RpMKG.js +12 -0
  302. package/web/dist/index.html +19 -2
  303. package/dist/adapters/core.d.ts +0 -9
  304. package/dist/adapters/core.js +0 -90
  305. package/dist/format/table.d.ts +0 -2
  306. package/dist/format/table.js +0 -56
  307. package/dist/src/adapters/core.d.ts +0 -9
  308. package/dist/src/adapters/core.js +0 -90
  309. package/dist/src/format/table.d.ts +0 -2
  310. package/dist/src/format/table.js +0 -56
  311. package/dist/tests/adapters/core.test.js +0 -110
  312. package/dist/tests/format/table.test.js +0 -98
  313. package/web/dist/assets/index-Dpiqzcc8.js +0 -40
  314. /package/dist/tests/adapters/{core.test.d.ts → agy.test.d.ts} +0 -0
  315. /package/dist/tests/{format/table.test.d.ts → adapters/credential-free.test.d.ts} +0 -0
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # QuotaCap
2
2
 
3
- QuotaCap helps you get more from the AI coding subscriptions you already pay for: Claude Code, Codex, Kimi Code, and Grok. It tracks one current usage window and reset time for each plan, then estimates which plan to use next from remaining usage and recent pace when available.
3
+ QuotaCap helps you get more from the AI coding subscriptions you already pay for: Claude Code, Codex, Kimi Code, Grok, and Antigravity. It tracks one current usage window and reset time for each plan, then estimates which plan to use next from remaining usage and recent pace when available.
4
4
 
5
5
  [![npm](https://img.shields.io/npm/v/quotacap)](https://www.npmjs.com/package/quotacap)
6
6
  [![CI](https://github.com/carlosboeing/quotacap/actions/workflows/test.yml/badge.svg)](https://github.com/carlosboeing/quotacap/actions)
@@ -16,15 +16,17 @@ QuotaCap helps you get more from the AI coding subscriptions you already pay for
16
16
 
17
17
  ## Providers
18
18
 
19
- | Provider | Source |
20
- |---|---|
21
- | Claude Code | Live |
22
- | Codex | Live |
23
- | Kimi Code | Live |
24
- | Grok | Live |
25
- | Antigravity / Gemini | Manual (`quotacap ingest`) |
19
+ | Provider | Mechanism | Source |
20
+ |---|---|---|
21
+ | Claude Code | `exec` — `claude -p /usage --output-format json` | Live |
22
+ | Antigravity | `exec` — `agy -p /usage --output-format json` (two rows: `agy` Gemini, `agy:3p` 3p) | Live |
23
+ | Codex | `pty` — `codex --no-alt-screen` then `/status`, parse `Weekly/5h limit: X% left` | Live |
24
+ | Kimi Code | `pty` — `kimi` then `/usage`, parse `Weekly/5h limit: Y% used` | Live |
25
+ | Grok | `pty` `grok` then `/usage`, parse `Weekly limit (plan)` + `Credits: $X` | Live |
26
26
 
27
- Live adapters reuse the matching CLI login.
27
+ 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.
28
+
29
+ Live adapters invoke the CLIs you already logged into. No API keys. No token files are read.
28
30
 
29
31
  ## Install
30
32
 
@@ -36,7 +38,7 @@ curl -fsSL https://raw.githubusercontent.com/carlosboeing/quotacap/main/install.
36
38
  npm install -g quotacap
37
39
  ```
38
40
 
39
- The binary is a self-contained executable from GitHub Releases.
41
+ The binary is a self-contained executable from GitHub Releases (shipped as `quotacap-<os>-<arch>.tar.gz` with a `pty` sidecar for Kimi/Codex/Grok); `install.sh` handles the tarball and sidecar transparently.
40
42
  The npm package runs the same CLI on Node. To run without a global install, replace `quotacap` with `npx quotacap` in any command, for example `npx quotacap web`.
41
43
 
42
44
  ## Quick start
@@ -53,12 +55,6 @@ quotacap advise
53
55
 
54
56
  `web` stays in the foreground and starts the daemon.
55
57
 
56
- For a provider without a live adapter:
57
-
58
- ```bash
59
- quotacap ingest --provider agy --text "65% used · resets Sep 1"
60
- ```
61
-
62
58
  ## MCP
63
59
 
64
60
  ```json
@@ -73,15 +69,51 @@ Use `"command": "npx", "args": ["quotacap", "mcp"]` when installed via npm only.
73
69
 
74
70
  Tools: `get_quotas`, `get_recommendation`, `forecast`.
75
71
 
76
- ## Privacy
72
+ ## HTTP API
73
+
74
+ The local web server listens on `127.0.0.1:8787` (configured via `QUOTACAP_URL` or `~/.quotacap/config.json`). All endpoints enforce loopback `Host` (`127.0.0.1`, `localhost`, `[::1]`) and reject foreign `Origin` headers (403) to prevent DNS rebinding and cross-origin requests.
75
+
76
+ | Method | Path | Auth | Description |
77
+ |---|---|---|---|
78
+ | `GET` | `/health` | None | Health check and uptime |
79
+ | `GET` | `/api/quotas` | None | Current quotas for all providers |
80
+ | `GET` | `/api/recommendation` | None | Current advisory on which provider to use next |
81
+ | `GET` | `/api/token` | Same-origin | Shared secret token for the dashboard |
82
+ | `POST` | `/api/refresh` | `X-QuotaCap-Token` | Trigger an immediate adapter poll (debounced to 60s) |
83
+
84
+ ### Curl examples
85
+
86
+ Read current quotas:
87
+
88
+ ```bash
89
+ curl http://localhost:8787/api/quotas
90
+ ```
91
+
92
+ Trigger an immediate refresh (requires the shared secret in `~/.quotacap/token` with mode `0600`):
93
+
94
+ ```bash
95
+ curl -X POST http://localhost:8787/api/refresh \
96
+ -H "X-QuotaCap-Token: $(cat ~/.quotacap/token)"
97
+ ```
98
+
99
+ ## Security
100
+
101
+ QuotaCap is a local daemon. It binds to `127.0.0.1` only (`src/cli/index.ts:56`). It does not listen on `0.0.0.0`. There is no LAN surface.
77
102
 
78
- Bound to `127.0.0.1`, with usage history stored under `~/.quotacap/`. Live adapters contact provider usage endpoints or invoke the provider CLI using your existing login. Codex, Kimi, and Grok adapters may refresh expired OAuth tokens and update the CLI-owned credential file. QuotaCap stores no API keys.
103
+ 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. QuotaCap only spawns the CLI and reads its stdout via `exec` (`claude`, `agy`) or PTY (`codex`, `kimi`, `grok`).
104
+
105
+ It stores no `raw` provider payload. The `raw` column was dropped and migrated in `src/store/db.ts:37-48`. `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.
106
+
107
+ Every request checks `Host` and `Origin`. `Host` must be loopback (`127.0.0.1`, `localhost`, `[::1]`), otherwise `403`. `Origin` when present must be loopback, otherwise `403`. Absent `Origin` passes for `curl` and MCP. `POST /api/refresh` requires `X-QuotaCap-Token` matching `~/.quotacap/token` with `crypto.timingSafeEqual` (`src/http/server.ts:isValidToken`), otherwise `401`. `GET /assets/*` is rooted with `path.resolve` and a prefix check (`tests/http/api.test.ts`).
108
+
109
+ Adapters fail closed. A timeout or unmatched TUI text becomes a stale row, never `0% used`. See `SECURITY.md` for the full "does and does not touch" list and how to report a vulnerability. See `docs/architecture.md` for the detailed security model.
79
110
 
80
111
  ## Docs
81
112
 
82
113
  - [Architecture](docs/architecture.md)
83
114
  - [Changelog](docs/CHANGELOG.md)
84
115
  - [Roadmap](docs/ROADMAP.md)
116
+ - [Security](SECURITY.md)
85
117
 
86
118
  ## License
87
119
 
@@ -100,4 +132,13 @@ npm run build:bin
100
132
  The store uses `node:sqlite` on Node and `bun:sqlite` on Bun.
101
133
  The same code is an npm package and a compiled binary.
102
134
 
135
+ `node-pty` is a native addon for the PTY-based adapters (Kimi, Codex, Grok) and is an `optionalDependency`. Prebuilt binaries are provided where available (macOS and Linux). If no prebuild matches your Node version or platform, `npm install` compiles it from source — this requires Xcode (macOS) or `build-essential` + `python3` (Linux). If the compile fails the install still succeeds and exec-based adapters (Claude, Agy) continue to work; PTY adapters will report `node-pty not available` until you install the toolchain and run `npm rebuild node-pty`.
136
+
137
+ The Kimi adapter spawns `kimi` in your home directory so the quota modal does not depend on the project path. If Kimi shows `Trust this folder?`, QuotaCap fails closed with `untrusted workspace — run \`kimi\` there and select Trust this folder` and does not auto-trust; trust remains an explicit interactive decision.
138
+
139
+ The compiled binaries are built with `bun build --compile`.
140
+ Each `dist-bin/quotacap-<os>-<arch>.tar.gz` bundles a `pty` sidecar.
141
+ The sidecar is `pty/node-pty` with the matching prebuild.
142
+ `install.sh` verifies the tarball and installs the sidecar to `~/.local/bin/pty` and `~/.local/share/quotacap/pty`.
143
+
103
144
  </details>
@@ -0,0 +1,3 @@
1
+ import type { Adapter, ParsedQuota } from "./types.js";
2
+ export declare function parseAgyUsage(parsedJson: any, now?: Date): ParsedQuota[];
3
+ export declare const agyAdapter: Adapter;
@@ -0,0 +1,81 @@
1
+ import { trackedExecFile } from "../runtime/spawn.js";
2
+ function usedPctFromRemaining(remaining) {
3
+ if (typeof remaining !== "number" || !Number.isFinite(remaining))
4
+ return null;
5
+ const pct = Math.round((1 - remaining) * 100);
6
+ if (!Number.isFinite(pct) || pct < 0 || pct > 100)
7
+ return null;
8
+ return pct;
9
+ }
10
+ function parseGroup(g, provider, rawJsonString, now) {
11
+ if (!Array.isArray(g.buckets))
12
+ return null;
13
+ const weekly = g.buckets.find((b) => b.window === "weekly");
14
+ if (!weekly)
15
+ return null;
16
+ const usedPct = usedPctFromRemaining(weekly.remaining_fraction);
17
+ if (usedPct === null)
18
+ return null;
19
+ const rawReset = weekly.reset_time;
20
+ const resetDate = rawReset ? new Date(rawReset) : null;
21
+ if (!resetDate || Number.isNaN(resetDate.getTime()))
22
+ throw new Error("agy: bad weekly reset_time");
23
+ const resetsAt = resetDate.toISOString();
24
+ const periodStart = new Date(resetDate.getTime() - 7 * 86400000).toISOString();
25
+ const fiveHour = g.buckets.find((b) => b.window === "5h");
26
+ const sessionPct = fiveHour
27
+ ? usedPctFromRemaining(fiveHour.remaining_fraction) ?? undefined
28
+ : undefined;
29
+ return {
30
+ provider,
31
+ plan: "unknown",
32
+ usedPct,
33
+ sessionPct,
34
+ resetsAt,
35
+ periodStart,
36
+ source: "cli",
37
+ fetchedAt: now.toISOString(),
38
+ raw: rawJsonString,
39
+ };
40
+ }
41
+ export function parseAgyUsage(parsedJson, now = new Date()) {
42
+ if (parsedJson?.status !== "SUCCESS")
43
+ throw new Error("agy: status is not SUCCESS");
44
+ const groups = parsedJson?.command?.data?.groups;
45
+ if (!Array.isArray(groups))
46
+ throw new Error("agy: no usage groups");
47
+ const raw = JSON.stringify(parsedJson);
48
+ const rows = [];
49
+ const geminiGroup = groups.find((g) => /gemini/i.test(g.name ?? "") || g.buckets?.some((b) => b.id?.startsWith("gemini")));
50
+ if (geminiGroup) {
51
+ const q = parseGroup(geminiGroup, "agy", raw, now);
52
+ if (q)
53
+ rows.push(q);
54
+ }
55
+ const threePGroup = groups.find((g) => /claude|gpt|3p/i.test(g.name ?? "") || g.buckets?.some((b) => b.id?.startsWith("3p")));
56
+ if (threePGroup) {
57
+ const q = parseGroup(threePGroup, "agy:3p", raw, now);
58
+ if (q)
59
+ rows.push(q);
60
+ }
61
+ if (rows.length === 0) {
62
+ for (const g of groups) {
63
+ const q = parseGroup(g, "agy", raw, now);
64
+ if (q) {
65
+ rows.push(q);
66
+ break;
67
+ }
68
+ }
69
+ }
70
+ if (rows.length === 0)
71
+ throw new Error("agy: no weekly bucket");
72
+ return rows;
73
+ }
74
+ export const agyAdapter = {
75
+ id: "agy",
76
+ requiresAuth: "agy login (CLI owns credentials)",
77
+ async poll() {
78
+ const { stdout } = await trackedExecFile("agy", "agy", ["-p", "/usage", "--output-format", "json"], { timeout: 20000 });
79
+ return parseAgyUsage(JSON.parse(stdout));
80
+ },
81
+ };
@@ -1,7 +1,9 @@
1
- import type { Quota } from "./types.js";
2
- export declare function parseClaudeUsage(result: string, now?: Date): Quota;
3
- export declare const claudeAdapter: {
1
+ import type { ParsedQuota } from "./types.js";
2
+ export declare function parseClaudeUsage(result: string, now?: Date): ParsedQuota;
3
+ export interface ClaudeAdapter {
4
4
  id: string;
5
5
  requiresAuth: string;
6
- poll(): Promise<Quota>;
7
- };
6
+ execPath: string;
7
+ poll(execPath?: string): Promise<ParsedQuota>;
8
+ }
9
+ export declare const claudeAdapter: ClaudeAdapter;
@@ -1,7 +1,5 @@
1
- import { execFile } from "node:child_process";
2
- import { promisify } from "node:util";
1
+ import { trackedExecFile } from "../runtime/spawn.js";
3
2
  import { parseResetText } from "./parse.js";
4
- const exec = promisify(execFile);
5
3
  export function parseClaudeUsage(result, now = new Date()) {
6
4
  const sessionMatch = result.match(/Current session:\s+(\d+)% used[^·]*·\s*resets\s+([^\n(]+?)\s*\(/);
7
5
  const weeklyMatch = result.match(/Current week \(all models\):\s+(\d+)% used[^·]*·\s*resets\s+([^\n(]+?)\s*\(/);
@@ -15,14 +13,16 @@ export function parseClaudeUsage(result, now = new Date()) {
15
13
  const periodStart = (parsedReset ? new Date(resets - 7 * 86400000) : new Date(now.getTime() - 7 * 86400000)).toISOString();
16
14
  return {
17
15
  provider: "claude", plan: "max", usedPct, sessionPct,
18
- resetsAt, periodStart, raw: result, source: "cli", fetchedAt: now.toISOString()
16
+ resetsAt, periodStart, source: "cli", fetchedAt: now.toISOString(), raw: result,
19
17
  };
20
18
  }
21
19
  export const claudeAdapter = {
22
20
  id: "claude",
23
21
  requiresAuth: "keychain:Claude Code-credentials",
24
- async poll() {
25
- const { stdout } = await exec("claude", ["-p", "/usage", "--output-format", "json"], { timeout: 8000 });
22
+ execPath: "claude",
23
+ async poll(execPath) {
24
+ const bin = execPath ?? claudeAdapter.execPath ?? "claude";
25
+ const { stdout } = await trackedExecFile("claude", bin, ["-p", "/usage", "--output-format", "json"], { timeout: 8000 });
26
26
  const parsed = JSON.parse(stdout);
27
27
  const result = parsed.result ?? stdout;
28
28
  return parseClaudeUsage(result);
@@ -1,7 +1,7 @@
1
- import type { Quota } from "./types.js";
2
- export declare function parseCodexUsage(body: any, now?: Date): Quota;
1
+ import type { ParsedQuota } from "./types.js";
2
+ export declare function parseCodexTui(text: string, now?: Date): ParsedQuota;
3
3
  export declare const codexAdapter: {
4
4
  id: string;
5
5
  requiresAuth: string;
6
- poll(): Promise<Quota>;
6
+ poll(): Promise<ParsedQuota>;
7
7
  };
@@ -1,61 +1,140 @@
1
1
  import os from "node:os";
2
- import path from "node:path";
3
- import { readJsonFile, getJson, postForm, persistCreds } from "./core.js";
4
- export function parseCodexUsage(body, now = new Date()) {
5
- const rl = body?.rate_limit ?? {};
6
- const win = rl.secondary_window ?? rl.primary_window;
7
- if (!win)
8
- throw new Error("codex: no rate-limit window in response");
9
- const resetsAt = new Date(win.reset_at * 1000).toISOString();
10
- const periodStart = new Date((win.reset_at - win.limit_window_seconds) * 1000).toISOString();
2
+ import { runPty, stripAnsi } from "./pty.js";
3
+ import { adapterSignal } from "../runtime/spawn.js";
4
+ const MONTHS = {
5
+ jan: 0, feb: 1, mar: 2, apr: 3, may: 4, jun: 5,
6
+ jul: 6, aug: 7, sep: 8, oct: 9, nov: 10, dec: 11,
7
+ };
8
+ function parseWeeklyReset(raw, now) {
9
+ const m = raw.trim().match(/^(\d{1,2}):(\d{2})\s+on\s+(\d{1,2})\s+([A-Za-z]{3,9})$/);
10
+ if (!m)
11
+ return null;
12
+ const hours = parseInt(m[1], 10);
13
+ const mins = parseInt(m[2], 10);
14
+ const day = parseInt(m[3], 10);
15
+ const monStr = m[4].slice(0, 3).toLowerCase();
16
+ const month = MONTHS[monStr];
17
+ if (month === undefined || !Number.isFinite(hours) || !Number.isFinite(mins) || !Number.isFinite(day))
18
+ return null;
19
+ if (hours < 0 || hours > 23 || mins < 0 || mins > 59 || day < 1 || day > 31)
20
+ return null;
21
+ let year = now.getFullYear();
22
+ let dt = new Date(year, month, day, hours, mins, 0, 0);
23
+ if (Number.isNaN(dt.getTime()))
24
+ return null;
25
+ if (dt.getTime() < now.getTime()) {
26
+ dt = new Date(year + 1, month, day, hours, mins, 0, 0);
27
+ if (Number.isNaN(dt.getTime()))
28
+ return null;
29
+ }
30
+ if (dt.getTime() < now.getTime())
31
+ return null;
32
+ return dt.toISOString();
33
+ }
34
+ function parseFiveReset(raw, now) {
35
+ const m = raw.trim().match(/^(\d{1,2}):(\d{2})$/);
36
+ if (!m)
37
+ return null;
38
+ const hours = parseInt(m[1], 10);
39
+ const mins = parseInt(m[2], 10);
40
+ if (hours < 0 || hours > 23 || mins < 0 || mins > 59)
41
+ return null;
42
+ let dt = new Date(now.getFullYear(), now.getMonth(), now.getDate(), hours, mins, 0, 0);
43
+ if (dt.getTime() < now.getTime()) {
44
+ dt = new Date(dt.getTime() + 86400000);
45
+ }
46
+ return dt.toISOString();
47
+ }
48
+ export function parseCodexTui(text, now = new Date()) {
49
+ const cleaned = stripAnsi(text);
50
+ let weeklyLeft = null;
51
+ let fiveLeft = null;
52
+ let weeklyRaw = null;
53
+ let fiveRaw = null;
54
+ const weeklyRe = /Weekly limit:\s*(\d+)%\s*left\s*\(resets\s+([^)]+)\)/i;
55
+ const fiveRe = /5h limit:\s*(\d+)%\s*left\s*\(resets\s+([^)]+)\)/i;
56
+ const w = cleaned.match(weeklyRe);
57
+ const f = cleaned.match(fiveRe);
58
+ if (w) {
59
+ weeklyLeft = parseInt(w[1], 10);
60
+ weeklyRaw = w[2].trim();
61
+ }
62
+ if (f) {
63
+ fiveLeft = parseInt(f[1], 10);
64
+ fiveRaw = f[2].trim();
65
+ }
66
+ if (weeklyLeft === null) {
67
+ const m = cleaned.match(/weekly[^\d%]*(\d+)%\s*left/i);
68
+ if (m)
69
+ weeklyLeft = parseInt(m[1], 10);
70
+ }
71
+ if (fiveLeft === null) {
72
+ const m = cleaned.match(/5h[^\d%]*(\d+)%\s*left/i);
73
+ if (m)
74
+ fiveLeft = parseInt(m[1], 10);
75
+ }
76
+ if (weeklyLeft === null)
77
+ throw new Error("codex: weekly limit not found in TUI output");
78
+ if (fiveLeft === null)
79
+ throw new Error("codex: 5h limit not found in TUI output");
80
+ if (!Number.isFinite(weeklyLeft) || weeklyLeft < 0 || weeklyLeft > 100)
81
+ throw new Error("codex: bad weekly pct");
82
+ if (!Number.isFinite(fiveLeft) || fiveLeft < 0 || fiveLeft > 100)
83
+ throw new Error("codex: bad 5h pct");
84
+ let weeklyIso = null;
85
+ let fiveIso = null;
86
+ let estimated = false;
87
+ if (weeklyRaw) {
88
+ weeklyIso = parseWeeklyReset(weeklyRaw, now);
89
+ if (!weeklyIso)
90
+ throw new Error(`codex: bad weekly reset "${weeklyRaw}"`);
91
+ }
92
+ if (fiveRaw) {
93
+ fiveIso = parseFiveReset(fiveRaw, now);
94
+ if (!fiveIso)
95
+ throw new Error(`codex: bad 5h reset "${fiveRaw}"`);
96
+ }
97
+ if (!weeklyIso) {
98
+ weeklyIso = new Date(now.getTime() + 7 * 86400000).toISOString();
99
+ estimated = true;
100
+ }
101
+ if (!fiveIso)
102
+ fiveIso = new Date(now.getTime() + 5 * 3600000).toISOString();
103
+ const usedPct = 100 - weeklyLeft;
104
+ const sessionPct = 100 - fiveLeft;
105
+ const periodStart = new Date(new Date(weeklyIso).getTime() - 7 * 86400000).toISOString();
11
106
  return {
12
107
  provider: "codex",
13
- plan: body.plan_type ?? "unknown",
14
- usedPct: win.used_percent ?? 0,
15
- resetsAt,
108
+ plan: "unknown",
109
+ usedPct,
110
+ sessionPct,
111
+ resetsAt: weeklyIso,
16
112
  periodStart,
17
- raw: JSON.stringify(body),
18
- source: "api",
113
+ source: "tui",
19
114
  fetchedAt: now.toISOString(),
115
+ resetsAtEstimated: estimated || undefined,
116
+ raw: cleaned.slice(0, 4096),
20
117
  };
21
118
  }
22
- const USAGE_URL = "https://chatgpt.com/backend-api/wham/usage";
23
- const REFRESH_URL = "https://auth.openai.com/oauth/token";
24
- const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
25
- function codexHome() {
26
- return process.env.CODEX_HOME ?? path.join(os.homedir(), ".codex");
27
- }
28
119
  export const codexAdapter = {
29
120
  id: "codex",
30
- requiresAuth: "~/.codex/auth.json (codex login)",
121
+ requiresAuth: "codex login (CLI owns credentials)",
31
122
  async poll() {
32
- const authFile = path.join(codexHome(), "auth.json");
33
- const auth = await readJsonFile(authFile);
34
- const at = auth.tokens?.access_token;
35
- if (!at)
36
- throw new Error("codex: no access_token in auth.json — run codex login");
37
- const headers = { Authorization: `Bearer ${at}`, "User-Agent": "quotacap" };
38
- if (auth.tokens?.account_id)
39
- headers["ChatGPT-Account-Id"] = auth.tokens.account_id;
40
- try {
41
- return parseCodexUsage(await getJson(USAGE_URL, headers));
42
- }
43
- catch (e) {
44
- if (e.status !== 401)
45
- throw e;
46
- const rf = auth.tokens?.refresh_token;
47
- if (!rf)
48
- throw e;
49
- const tok = await postForm(REFRESH_URL, { grant_type: "refresh_token", client_id: CLIENT_ID, refresh_token: rf });
50
- await persistCreds(authFile, (cur) => {
51
- const tokens = { ...(cur.tokens ?? {}), };
52
- if (tok.access_token)
53
- tokens.access_token = tok.access_token;
54
- if (tok.refresh_token)
55
- tokens.refresh_token = tok.refresh_token;
56
- return { ...cur, tokens, last_refresh: new Date().toISOString() };
57
- });
58
- return parseCodexUsage(await getJson(USAGE_URL, { ...headers, Authorization: `Bearer ${tok.access_token}` }));
59
- }
123
+ const transcript = await runPty({
124
+ file: "codex",
125
+ args: ["--no-alt-screen"],
126
+ cwd: os.homedir(),
127
+ cols: 140,
128
+ rows: 50,
129
+ settleDelayMs: 2000,
130
+ input: "/status\r",
131
+ completionRegex: /\d+%\s*left/i,
132
+ abortOn: /Do you trust|Trust.*folder|trust the files in this folder/i,
133
+ timeoutMs: 12000,
134
+ maxBytes: 256 * 1024,
135
+ signal: adapterSignal("codex"),
136
+ label: "codex",
137
+ });
138
+ return parseCodexTui(transcript);
60
139
  },
61
140
  };
@@ -1,7 +1,7 @@
1
- import type { Quota } from "./types.js";
2
- export declare function parseGrokUsage(body: any, now?: Date): Quota;
1
+ import type { ParsedQuota } from "./types.js";
2
+ export declare function parseGrokTui(text: string, now?: Date): ParsedQuota;
3
3
  export declare const grokAdapter: {
4
4
  id: string;
5
5
  requiresAuth: string;
6
- poll(): Promise<Quota>;
6
+ poll(): Promise<ParsedQuota>;
7
7
  };