@hellcoder/companion 0.96.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (242) hide show
  1. package/bin/cli.ts +168 -0
  2. package/bin/ctl.ts +528 -0
  3. package/bin/generate-token.ts +28 -0
  4. package/dist/apple-touch-icon.png +0 -0
  5. package/dist/assets/AgentsPage-DCFhrJ28.js +13 -0
  6. package/dist/assets/CronManager-EGwLJONv.js +1 -0
  7. package/dist/assets/IntegrationsPage-CTMRnbQS.js +1 -0
  8. package/dist/assets/LinearOAuthSettingsPage-CgQFMIgr.js +1 -0
  9. package/dist/assets/LinearSettingsPage-C9nok1qi.js +1 -0
  10. package/dist/assets/Playground-BV3k0RbV.js +109 -0
  11. package/dist/assets/PromptsPage-CFojqNKP.js +4 -0
  12. package/dist/assets/RunsPage-DUJ1QUSa.js +1 -0
  13. package/dist/assets/SandboxManager-CrVQ-VU_.js +8 -0
  14. package/dist/assets/SettingsPage-D1fPCL19.js +1 -0
  15. package/dist/assets/TailscalePage-D06cyvyC.js +1 -0
  16. package/dist/assets/index-BhUa1e6X.css +1 -0
  17. package/dist/assets/index-DkqeP-R9.js +134 -0
  18. package/dist/assets/sw-register-BibwRdvC.js +1 -0
  19. package/dist/assets/workbox-window.prod.es5-BIl4cyR9.js +2 -0
  20. package/dist/favicon.svg +8 -0
  21. package/dist/fonts/MesloLGSNerdFontMono-Bold.woff2 +0 -0
  22. package/dist/fonts/MesloLGSNerdFontMono-Regular.woff2 +0 -0
  23. package/dist/icon-192.png +0 -0
  24. package/dist/icon-512.png +0 -0
  25. package/dist/index.html +20 -0
  26. package/dist/logo-codex.svg +14 -0
  27. package/dist/logo-docker.svg +4 -0
  28. package/dist/logo.svg +14 -0
  29. package/dist/manifest.json +24 -0
  30. package/dist/sw.js +2 -0
  31. package/package.json +104 -0
  32. package/server/agent-cron-migrator.test.ts +610 -0
  33. package/server/agent-cron-migrator.ts +85 -0
  34. package/server/agent-executor.test.ts +1108 -0
  35. package/server/agent-executor.ts +346 -0
  36. package/server/agent-store.test.ts +588 -0
  37. package/server/agent-store.ts +185 -0
  38. package/server/agent-types.ts +138 -0
  39. package/server/ai-validation-settings.test.ts +128 -0
  40. package/server/ai-validation-settings.ts +35 -0
  41. package/server/ai-validator.test.ts +387 -0
  42. package/server/ai-validator.ts +271 -0
  43. package/server/auth-manager.test.ts +83 -0
  44. package/server/auth-manager.ts +150 -0
  45. package/server/auto-namer.test.ts +252 -0
  46. package/server/auto-namer.ts +78 -0
  47. package/server/backend-adapter.test.ts +38 -0
  48. package/server/backend-adapter.ts +54 -0
  49. package/server/cache-headers.test.ts +98 -0
  50. package/server/cache-headers.ts +61 -0
  51. package/server/claude-adapter.test.ts +1363 -0
  52. package/server/claude-adapter.ts +889 -0
  53. package/server/claude-container-auth.test.ts +44 -0
  54. package/server/claude-container-auth.ts +30 -0
  55. package/server/claude-protocol-contract.test.ts +71 -0
  56. package/server/claude-protocol-drift.test.ts +78 -0
  57. package/server/claude-session-discovery.test.ts +132 -0
  58. package/server/claude-session-discovery.ts +157 -0
  59. package/server/claude-session-history.test.ts +158 -0
  60. package/server/claude-session-history.ts +410 -0
  61. package/server/cli-launcher.test.ts +1343 -0
  62. package/server/cli-launcher.ts +1298 -0
  63. package/server/cli.test.ts +16 -0
  64. package/server/codex-adapter.test.ts +5545 -0
  65. package/server/codex-adapter.ts +3062 -0
  66. package/server/codex-container-auth.test.ts +50 -0
  67. package/server/codex-container-auth.ts +24 -0
  68. package/server/codex-home.test.ts +61 -0
  69. package/server/codex-home.ts +26 -0
  70. package/server/codex-protocol-contract.test.ts +96 -0
  71. package/server/codex-protocol-drift.test.ts +123 -0
  72. package/server/codex-ws-proxy.cjs +226 -0
  73. package/server/commands-discovery.test.ts +179 -0
  74. package/server/commands-discovery.ts +81 -0
  75. package/server/constants.ts +7 -0
  76. package/server/container-manager.test.ts +1211 -0
  77. package/server/container-manager.ts +1053 -0
  78. package/server/cron-scheduler.test.ts +957 -0
  79. package/server/cron-scheduler.ts +243 -0
  80. package/server/cron-store.test.ts +422 -0
  81. package/server/cron-store.ts +148 -0
  82. package/server/cron-types.ts +63 -0
  83. package/server/env-manager.test.ts +268 -0
  84. package/server/env-manager.ts +161 -0
  85. package/server/event-bus-types.ts +64 -0
  86. package/server/event-bus.test.ts +244 -0
  87. package/server/event-bus.ts +124 -0
  88. package/server/execution-store.test.ts +307 -0
  89. package/server/execution-store.ts +170 -0
  90. package/server/fs-utils.ts +15 -0
  91. package/server/git-utils.test.ts +938 -0
  92. package/server/git-utils.ts +421 -0
  93. package/server/github-pr.test.ts +498 -0
  94. package/server/github-pr.ts +379 -0
  95. package/server/image-pull-manager.test.ts +303 -0
  96. package/server/image-pull-manager.ts +279 -0
  97. package/server/index.ts +396 -0
  98. package/server/linear-agent-bridge.test.ts +1157 -0
  99. package/server/linear-agent-bridge.ts +629 -0
  100. package/server/linear-agent.test.ts +473 -0
  101. package/server/linear-agent.ts +479 -0
  102. package/server/linear-cache.test.ts +136 -0
  103. package/server/linear-cache.ts +113 -0
  104. package/server/linear-connections.test.ts +350 -0
  105. package/server/linear-connections.ts +231 -0
  106. package/server/linear-credential-migration.test.ts +337 -0
  107. package/server/linear-credential-migration.ts +63 -0
  108. package/server/linear-oauth-connections-migration.test.ts +268 -0
  109. package/server/linear-oauth-connections.test.ts +365 -0
  110. package/server/linear-oauth-connections.ts +294 -0
  111. package/server/linear-project-manager.test.ts +162 -0
  112. package/server/linear-project-manager.ts +111 -0
  113. package/server/linear-prompt-builder.test.ts +74 -0
  114. package/server/linear-prompt-builder.ts +61 -0
  115. package/server/linear-staging.test.ts +276 -0
  116. package/server/linear-staging.ts +142 -0
  117. package/server/logger.test.ts +393 -0
  118. package/server/logger.ts +259 -0
  119. package/server/metrics-collector.test.ts +413 -0
  120. package/server/metrics-collector.ts +350 -0
  121. package/server/metrics-types.ts +108 -0
  122. package/server/middleware/managed-auth.test.ts +264 -0
  123. package/server/middleware/managed-auth.ts +195 -0
  124. package/server/novnc-proxy.test.ts +333 -0
  125. package/server/novnc-proxy.ts +99 -0
  126. package/server/path-resolver.test.ts +552 -0
  127. package/server/path-resolver.ts +186 -0
  128. package/server/paths.test.ts +31 -0
  129. package/server/paths.ts +11 -0
  130. package/server/pr-poller.test.ts +191 -0
  131. package/server/pr-poller.ts +162 -0
  132. package/server/prompt-manager.test.ts +211 -0
  133. package/server/prompt-manager.ts +211 -0
  134. package/server/protocol/claude-upstream/README.md +19 -0
  135. package/server/protocol/claude-upstream/sdk.d.ts.txt +1943 -0
  136. package/server/protocol/codex-upstream/ClientNotification.ts.txt +5 -0
  137. package/server/protocol/codex-upstream/ClientRequest.ts.txt +60 -0
  138. package/server/protocol/codex-upstream/README.md +18 -0
  139. package/server/protocol/codex-upstream/ServerNotification.ts.txt +41 -0
  140. package/server/protocol/codex-upstream/ServerRequest.ts.txt +16 -0
  141. package/server/protocol/codex-upstream/v2/DynamicToolCallParams.ts.txt +6 -0
  142. package/server/protocol/codex-upstream/v2/DynamicToolCallResponse.ts.txt +6 -0
  143. package/server/protocol-monitor.ts +50 -0
  144. package/server/recorder.test.ts +454 -0
  145. package/server/recorder.ts +374 -0
  146. package/server/recording-hub/compat-validator.test.ts +150 -0
  147. package/server/recording-hub/compat-validator.ts +284 -0
  148. package/server/recording-hub/diagnostics.test.ts +140 -0
  149. package/server/recording-hub/diagnostics.ts +299 -0
  150. package/server/recording-hub/hub-config.test.ts +44 -0
  151. package/server/recording-hub/hub-config.ts +19 -0
  152. package/server/recording-hub/hub-routes.test.ts +417 -0
  153. package/server/recording-hub/hub-routes.ts +236 -0
  154. package/server/recording-hub/hub-store.test.ts +262 -0
  155. package/server/recording-hub/hub-store.ts +265 -0
  156. package/server/recording-hub/replay-adapter.test.ts +294 -0
  157. package/server/recording-hub/replay-adapter.ts +207 -0
  158. package/server/relay-client.test.ts +337 -0
  159. package/server/relay-client.ts +320 -0
  160. package/server/replay.test.ts +200 -0
  161. package/server/replay.ts +78 -0
  162. package/server/routes/agent-routes.test.ts +1400 -0
  163. package/server/routes/agent-routes.ts +409 -0
  164. package/server/routes/cron-routes.test.ts +881 -0
  165. package/server/routes/cron-routes.ts +103 -0
  166. package/server/routes/env-routes.test.ts +383 -0
  167. package/server/routes/env-routes.ts +95 -0
  168. package/server/routes/fs-routes.test.ts +1198 -0
  169. package/server/routes/fs-routes.ts +605 -0
  170. package/server/routes/git-routes.test.ts +813 -0
  171. package/server/routes/git-routes.ts +97 -0
  172. package/server/routes/linear-agent-routes.test.ts +721 -0
  173. package/server/routes/linear-agent-routes.ts +304 -0
  174. package/server/routes/linear-connection-routes.test.ts +927 -0
  175. package/server/routes/linear-connection-routes.ts +244 -0
  176. package/server/routes/linear-oauth-connection-routes.test.ts +406 -0
  177. package/server/routes/linear-oauth-connection-routes.ts +129 -0
  178. package/server/routes/linear-routes.test.ts +1510 -0
  179. package/server/routes/linear-routes.ts +953 -0
  180. package/server/routes/metrics-routes.test.ts +103 -0
  181. package/server/routes/metrics-routes.ts +13 -0
  182. package/server/routes/prompt-routes.ts +67 -0
  183. package/server/routes/sandbox-routes.test.ts +513 -0
  184. package/server/routes/sandbox-routes.ts +127 -0
  185. package/server/routes/settings-routes.ts +270 -0
  186. package/server/routes/skills-routes.test.ts +690 -0
  187. package/server/routes/skills-routes.ts +100 -0
  188. package/server/routes/system-routes.test.ts +637 -0
  189. package/server/routes/system-routes.ts +228 -0
  190. package/server/routes/tailscale-routes.test.ts +176 -0
  191. package/server/routes/tailscale-routes.ts +22 -0
  192. package/server/routes.test.ts +4655 -0
  193. package/server/routes.ts +1277 -0
  194. package/server/sandbox-manager.test.ts +378 -0
  195. package/server/sandbox-manager.ts +168 -0
  196. package/server/service.test.ts +1419 -0
  197. package/server/service.ts +718 -0
  198. package/server/session-creation-service.test.ts +661 -0
  199. package/server/session-creation-service.ts +473 -0
  200. package/server/session-git-info.ts +104 -0
  201. package/server/session-linear-issues.test.ts +118 -0
  202. package/server/session-linear-issues.ts +88 -0
  203. package/server/session-names.test.ts +94 -0
  204. package/server/session-names.ts +67 -0
  205. package/server/session-orchestrator.test.ts +1784 -0
  206. package/server/session-orchestrator.ts +973 -0
  207. package/server/session-state-machine.test.ts +606 -0
  208. package/server/session-state-machine.ts +207 -0
  209. package/server/session-store.test.ts +290 -0
  210. package/server/session-store.ts +146 -0
  211. package/server/session-types.ts +509 -0
  212. package/server/settings-manager.test.ts +275 -0
  213. package/server/settings-manager.ts +173 -0
  214. package/server/tailscale-manager.test.ts +553 -0
  215. package/server/tailscale-manager.ts +451 -0
  216. package/server/terminal-manager.ts +240 -0
  217. package/server/update-checker.test.ts +306 -0
  218. package/server/update-checker.ts +197 -0
  219. package/server/usage-limits.test.ts +536 -0
  220. package/server/usage-limits.ts +225 -0
  221. package/server/worktree-tracker.test.ts +243 -0
  222. package/server/worktree-tracker.ts +84 -0
  223. package/server/ws-auth.test.ts +59 -0
  224. package/server/ws-auth.ts +41 -0
  225. package/server/ws-bridge-browser-ingest.test.ts +272 -0
  226. package/server/ws-bridge-browser-ingest.ts +72 -0
  227. package/server/ws-bridge-browser.ts +112 -0
  228. package/server/ws-bridge-cli-ingest.test.ts +302 -0
  229. package/server/ws-bridge-cli-ingest.ts +81 -0
  230. package/server/ws-bridge-codex.test.ts +1837 -0
  231. package/server/ws-bridge-codex.ts +266 -0
  232. package/server/ws-bridge-controls.test.ts +124 -0
  233. package/server/ws-bridge-controls.ts +20 -0
  234. package/server/ws-bridge-persist.test.ts +296 -0
  235. package/server/ws-bridge-persist.ts +66 -0
  236. package/server/ws-bridge-publish.test.ts +234 -0
  237. package/server/ws-bridge-publish.ts +79 -0
  238. package/server/ws-bridge-replay.test.ts +44 -0
  239. package/server/ws-bridge-replay.ts +61 -0
  240. package/server/ws-bridge-types.ts +106 -0
  241. package/server/ws-bridge.test.ts +4777 -0
  242. package/server/ws-bridge.ts +1279 -0
@@ -0,0 +1,536 @@
1
+ import { mkdtempSync, rmSync, mkdirSync, writeFileSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { tmpdir } from "node:os";
4
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // Mocks
8
+ // ---------------------------------------------------------------------------
9
+
10
+ const mockExecSync = vi.hoisted(() => vi.fn());
11
+ const mockExecFileSync = vi.hoisted(() => vi.fn());
12
+
13
+ vi.mock("node:child_process", () => ({
14
+ execSync: mockExecSync,
15
+ execFileSync: mockExecFileSync,
16
+ }));
17
+
18
+ // Mock global fetch at the module level (persists across tests)
19
+ const mockFetch = vi.fn();
20
+ globalThis.fetch = mockFetch as unknown as typeof fetch;
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // Module under test - re-imported each time to reset module-level cache
24
+ // ---------------------------------------------------------------------------
25
+ let mod: typeof import("./usage-limits.js");
26
+ const originalPlatform = process.platform;
27
+
28
+ beforeEach(async () => {
29
+ vi.resetModules();
30
+ mockExecSync.mockReset();
31
+ mockExecFileSync.mockReset();
32
+ mockFetch.mockReset();
33
+ mod = await import("./usage-limits.js");
34
+ });
35
+
36
+ afterEach(() => {
37
+ Object.defineProperty(process, "platform", { value: originalPlatform });
38
+ });
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // Helpers
42
+ // ---------------------------------------------------------------------------
43
+
44
+ const SAMPLE_TOKEN = "sk-ant-fake-token-123";
45
+
46
+ function makeCredentialsJson(token: string, opts?: { expired?: boolean }): string {
47
+ return JSON.stringify({
48
+ claudeAiOauth: {
49
+ accessToken: token,
50
+ refreshToken: "sk-ant-ort01-fake-refresh-token",
51
+ expiresAt: opts?.expired ? Date.now() - 1000 : Date.now() + 3_600_000,
52
+ },
53
+ });
54
+ }
55
+
56
+ function makeCredentialsHex(token: string): string {
57
+ return Buffer.from(makeCredentialsJson(token), "utf-8").toString("hex");
58
+ }
59
+
60
+ function makeFetchResponse(body: object, ok = true) {
61
+ return Promise.resolve({
62
+ ok,
63
+ json: () => Promise.resolve(body),
64
+ });
65
+ }
66
+
67
+ const SAMPLE_LIMITS = {
68
+ five_hour: { utilization: 42, resets_at: "2025-01-01T12:00:00Z" },
69
+ seven_day: { utilization: 15, resets_at: null },
70
+ extra_usage: null,
71
+ };
72
+
73
+ // ===========================================================================
74
+ // getCredentials (macOS - Keychain)
75
+ // ===========================================================================
76
+ describe("getCredentials (macOS)", () => {
77
+ beforeEach(() => {
78
+ // Force macOS platform so the Keychain (execSync) path is used
79
+ Object.defineProperty(process, "platform", { value: "darwin" });
80
+ });
81
+
82
+ it("extracts token from plain JSON output", async () => {
83
+ vi.resetModules();
84
+ mockFetch.mockReset();
85
+ const darwinMod = await import("./usage-limits.js");
86
+ mockExecSync.mockReturnValue(makeCredentialsJson(SAMPLE_TOKEN));
87
+ expect(darwinMod.getCredentials()).toBe(SAMPLE_TOKEN);
88
+ });
89
+
90
+ it("extracts token from hex-encoded output", async () => {
91
+ vi.resetModules();
92
+ mockFetch.mockReset();
93
+ const darwinMod = await import("./usage-limits.js");
94
+ mockExecSync.mockReturnValue(makeCredentialsHex(SAMPLE_TOKEN));
95
+ expect(darwinMod.getCredentials()).toBe(SAMPLE_TOKEN);
96
+ });
97
+
98
+ it("returns null when execSync throws (e.g. no keychain entry)", async () => {
99
+ vi.resetModules();
100
+ mockFetch.mockReset();
101
+ const darwinMod = await import("./usage-limits.js");
102
+ mockExecSync.mockImplementation(() => {
103
+ throw new Error("security: command not found");
104
+ });
105
+ expect(darwinMod.getCredentials()).toBeNull();
106
+ });
107
+
108
+ it("returns null when JSON has no claudeAiOauth field", async () => {
109
+ vi.resetModules();
110
+ mockFetch.mockReset();
111
+ const darwinMod = await import("./usage-limits.js");
112
+ mockExecSync.mockReturnValue(JSON.stringify({ other: "data" }));
113
+ expect(darwinMod.getCredentials()).toBeNull();
114
+ });
115
+
116
+ it("returns token even when format does not match sk-ant-*", async () => {
117
+ vi.resetModules();
118
+ mockFetch.mockReset();
119
+ const darwinMod = await import("./usage-limits.js");
120
+ mockExecSync.mockReturnValue(
121
+ JSON.stringify({ claudeAiOauth: { accessToken: "not-a-valid-token" } }),
122
+ );
123
+ expect(darwinMod.getCredentials()).toBe("not-a-valid-token");
124
+ });
125
+ });
126
+
127
+ // ===========================================================================
128
+ // getCredentials - Windows path
129
+ // ===========================================================================
130
+ describe("getCredentials (Windows)", () => {
131
+ let tempDir: string;
132
+
133
+ beforeEach(() => {
134
+ Object.defineProperty(process, "platform", { value: "win32" });
135
+ tempDir = mkdtempSync(join(tmpdir(), "usage-limits-test-"));
136
+ });
137
+
138
+ afterEach(() => {
139
+ rmSync(tempDir, { recursive: true, force: true });
140
+ });
141
+
142
+ it("reads token from credentials file on Windows", async () => {
143
+ const claudeDir = join(tempDir, ".claude");
144
+ mkdirSync(claudeDir, { recursive: true });
145
+ writeFileSync(
146
+ join(claudeDir, ".credentials.json"),
147
+ makeCredentialsJson(SAMPLE_TOKEN),
148
+ );
149
+ process.env.USERPROFILE = tempDir;
150
+
151
+ // Re-import to pick up the mocked platform
152
+ vi.resetModules();
153
+ mockFetch.mockReset();
154
+ const winMod = await import("./usage-limits.js");
155
+ expect(winMod.getCredentials()).toBe(SAMPLE_TOKEN);
156
+
157
+ delete process.env.USERPROFILE;
158
+ });
159
+
160
+ it("returns null when credentials file does not exist on Windows", async () => {
161
+ process.env.USERPROFILE = tempDir;
162
+
163
+ vi.resetModules();
164
+ const winMod = await import("./usage-limits.js");
165
+ expect(winMod.getCredentials()).toBeNull();
166
+
167
+ delete process.env.USERPROFILE;
168
+ });
169
+
170
+ it("returns null when credentials file has invalid JSON on Windows", async () => {
171
+ const claudeDir = join(tempDir, ".claude");
172
+ mkdirSync(claudeDir, { recursive: true });
173
+ writeFileSync(join(claudeDir, ".credentials.json"), "NOT VALID JSON{{{");
174
+ process.env.USERPROFILE = tempDir;
175
+
176
+ vi.resetModules();
177
+ const winMod = await import("./usage-limits.js");
178
+ expect(winMod.getCredentials()).toBeNull();
179
+
180
+ delete process.env.USERPROFILE;
181
+ });
182
+ });
183
+
184
+ // ===========================================================================
185
+ // getCredentials - Linux / Docker path
186
+ // ===========================================================================
187
+ describe("getCredentials (Linux / Docker)", () => {
188
+ let tempDir: string;
189
+ const originalHome = process.env.HOME;
190
+
191
+ beforeEach(() => {
192
+ // Force Linux platform so the file-based credential path is used
193
+ Object.defineProperty(process, "platform", { value: "linux" });
194
+ tempDir = mkdtempSync(join(tmpdir(), "usage-limits-linux-test-"));
195
+ });
196
+
197
+ afterEach(() => {
198
+ // Restore original HOME to avoid cross-test environment pollution
199
+ if (originalHome !== undefined) {
200
+ process.env.HOME = originalHome;
201
+ } else {
202
+ delete process.env.HOME;
203
+ }
204
+ rmSync(tempDir, { recursive: true, force: true });
205
+ });
206
+
207
+ it("reads token from .credentials.json on Linux", async () => {
208
+ const claudeDir = join(tempDir, ".claude");
209
+ mkdirSync(claudeDir, { recursive: true });
210
+ writeFileSync(
211
+ join(claudeDir, ".credentials.json"),
212
+ makeCredentialsJson(SAMPLE_TOKEN),
213
+ );
214
+ process.env.HOME = tempDir;
215
+
216
+ vi.resetModules();
217
+ mockFetch.mockReset();
218
+ const linuxMod = await import("./usage-limits.js");
219
+ expect(linuxMod.getCredentials()).toBe(SAMPLE_TOKEN);
220
+ });
221
+
222
+ it("falls back to alternative credential file names", async () => {
223
+ // Only auth.json exists (not .credentials.json)
224
+ const claudeDir = join(tempDir, ".claude");
225
+ mkdirSync(claudeDir, { recursive: true });
226
+ writeFileSync(
227
+ join(claudeDir, "auth.json"),
228
+ makeCredentialsJson(SAMPLE_TOKEN),
229
+ );
230
+ process.env.HOME = tempDir;
231
+
232
+ vi.resetModules();
233
+ mockFetch.mockReset();
234
+ const linuxMod = await import("./usage-limits.js");
235
+ expect(linuxMod.getCredentials()).toBe(SAMPLE_TOKEN);
236
+ });
237
+
238
+ it("returns null when no credential files exist on Linux", async () => {
239
+ process.env.HOME = tempDir;
240
+
241
+ vi.resetModules();
242
+ mockFetch.mockReset();
243
+ const linuxMod = await import("./usage-limits.js");
244
+ expect(linuxMod.getCredentials()).toBeNull();
245
+ });
246
+
247
+ it("returns null when credentials file has invalid JSON on Linux", async () => {
248
+ const claudeDir = join(tempDir, ".claude");
249
+ mkdirSync(claudeDir, { recursive: true });
250
+ writeFileSync(join(claudeDir, ".credentials.json"), "NOT VALID JSON{{{");
251
+ process.env.HOME = tempDir;
252
+
253
+ vi.resetModules();
254
+ mockFetch.mockReset();
255
+ const linuxMod = await import("./usage-limits.js");
256
+ expect(linuxMod.getCredentials()).toBeNull();
257
+ });
258
+
259
+ it("does not call macOS security command on Linux", async () => {
260
+ const claudeDir = join(tempDir, ".claude");
261
+ mkdirSync(claudeDir, { recursive: true });
262
+ writeFileSync(
263
+ join(claudeDir, ".credentials.json"),
264
+ makeCredentialsJson(SAMPLE_TOKEN),
265
+ );
266
+ process.env.HOME = tempDir;
267
+
268
+ vi.resetModules();
269
+ mockFetch.mockReset();
270
+ const linuxMod = await import("./usage-limits.js");
271
+ linuxMod.getCredentials();
272
+ // security command should never be invoked on Linux
273
+ expect(mockExecSync).not.toHaveBeenCalled();
274
+ });
275
+
276
+ it("writes refreshed credentials back to the same source file", async () => {
277
+ // Credentials are stored in auth.json (not the default .credentials.json)
278
+ const claudeDir = join(tempDir, ".claude");
279
+ mkdirSync(claudeDir, { recursive: true });
280
+ writeFileSync(
281
+ join(claudeDir, "auth.json"),
282
+ makeCredentialsJson(SAMPLE_TOKEN, { expired: true }),
283
+ );
284
+ process.env.HOME = tempDir;
285
+
286
+ vi.resetModules();
287
+ mockFetch.mockReset();
288
+ const linuxMod = await import("./usage-limits.js");
289
+
290
+ // First call = token refresh, second call = usage API
291
+ mockFetch
292
+ .mockReturnValueOnce(
293
+ makeFetchResponse({
294
+ access_token: "sk-ant-new-token",
295
+ refresh_token: "sk-ant-new-refresh",
296
+ expires_in: 3600,
297
+ }),
298
+ )
299
+ .mockReturnValueOnce(makeFetchResponse(SAMPLE_LIMITS));
300
+
301
+ const result = await linuxMod.getUsageLimits();
302
+ expect(result).toEqual(SAMPLE_LIMITS);
303
+
304
+ // Credentials should have been written to file (not via execFileSync/security)
305
+ expect(mockExecFileSync).not.toHaveBeenCalled();
306
+
307
+ // Verify the refreshed token was written back to auth.json (the source file)
308
+ const updatedCreds = JSON.parse(
309
+ readFileSync(join(claudeDir, "auth.json"), "utf-8"),
310
+ );
311
+ expect(updatedCreds.claudeAiOauth.accessToken).toBe("sk-ant-new-token");
312
+ expect(updatedCreds.claudeAiOauth.refreshToken).toBe("sk-ant-new-refresh");
313
+ });
314
+ });
315
+
316
+ // ===========================================================================
317
+ // fetchUsageLimits
318
+ // ===========================================================================
319
+ describe("fetchUsageLimits", () => {
320
+ it("returns parsed limits on success", async () => {
321
+ mockFetch.mockReturnValue(makeFetchResponse(SAMPLE_LIMITS));
322
+
323
+ const result = await mod.fetchUsageLimits(SAMPLE_TOKEN);
324
+ expect(result).toEqual(SAMPLE_LIMITS);
325
+
326
+ // Verify correct headers
327
+ expect(mockFetch).toHaveBeenCalledWith(
328
+ "https://api.anthropic.com/api/oauth/usage",
329
+ expect.objectContaining({
330
+ method: "GET",
331
+ headers: expect.objectContaining({
332
+ Authorization: `Bearer ${SAMPLE_TOKEN}`,
333
+ }),
334
+ }),
335
+ );
336
+ });
337
+
338
+ it("returns null on non-ok response", async () => {
339
+ mockFetch.mockReturnValue(makeFetchResponse({}, false));
340
+ const result = await mod.fetchUsageLimits(SAMPLE_TOKEN);
341
+ expect(result).toBeNull();
342
+ });
343
+
344
+ it("returns null on network error", async () => {
345
+ mockFetch.mockRejectedValue(new Error("network error"));
346
+ const result = await mod.fetchUsageLimits(SAMPLE_TOKEN);
347
+ expect(result).toBeNull();
348
+ });
349
+
350
+ it("normalizes missing fields to null", async () => {
351
+ mockFetch.mockReturnValue(
352
+ makeFetchResponse({ five_hour: { utilization: 10, resets_at: null } }),
353
+ );
354
+ const result = await mod.fetchUsageLimits(SAMPLE_TOKEN);
355
+ expect(result).toEqual({
356
+ five_hour: { utilization: 10, resets_at: null },
357
+ seven_day: null,
358
+ extra_usage: null,
359
+ });
360
+ });
361
+ });
362
+
363
+ // ===========================================================================
364
+ // getUsageLimits (orchestrator with cache)
365
+ // These tests use macOS platform to exercise the execSync/keychain path
366
+ // ===========================================================================
367
+ describe("getUsageLimits", () => {
368
+ const EMPTY = { five_hour: null, seven_day: null, extra_usage: null };
369
+
370
+ beforeEach(() => {
371
+ Object.defineProperty(process, "platform", { value: "darwin" });
372
+ });
373
+
374
+ it("returns empty when no credentials are available", async () => {
375
+ vi.resetModules();
376
+ mockFetch.mockReset();
377
+ const darwinMod = await import("./usage-limits.js");
378
+ mockExecSync.mockImplementation(() => {
379
+ throw new Error("no keychain");
380
+ });
381
+ const result = await darwinMod.getUsageLimits();
382
+ expect(result).toEqual(EMPTY);
383
+ });
384
+
385
+ it("returns limits and caches the result", async () => {
386
+ vi.resetModules();
387
+ mockFetch.mockReset();
388
+ const darwinMod = await import("./usage-limits.js");
389
+ mockExecSync.mockReturnValue(makeCredentialsJson(SAMPLE_TOKEN));
390
+ mockFetch.mockReturnValue(makeFetchResponse(SAMPLE_LIMITS));
391
+
392
+ const first = await darwinMod.getUsageLimits();
393
+ expect(first).toEqual(SAMPLE_LIMITS);
394
+
395
+ // Second call should use cache - fetch should not be called again
396
+ const second = await darwinMod.getUsageLimits();
397
+ expect(second).toEqual(SAMPLE_LIMITS);
398
+ expect(mockFetch).toHaveBeenCalledTimes(1);
399
+ });
400
+
401
+ it("refreshes cache after TTL expires", async () => {
402
+ vi.resetModules();
403
+ mockFetch.mockReset();
404
+ const darwinMod = await import("./usage-limits.js");
405
+ mockExecSync.mockReturnValue(makeCredentialsJson(SAMPLE_TOKEN));
406
+ mockFetch.mockReturnValue(makeFetchResponse(SAMPLE_LIMITS));
407
+
408
+ await darwinMod.getUsageLimits();
409
+ expect(mockFetch).toHaveBeenCalledTimes(1);
410
+
411
+ // Manually expire the cache by advancing Date.now via spy
412
+ const realNow = Date.now();
413
+ vi.spyOn(Date, "now").mockReturnValue(realNow + 61_000);
414
+
415
+ const updated = {
416
+ ...SAMPLE_LIMITS,
417
+ five_hour: { utilization: 99, resets_at: null },
418
+ };
419
+ mockFetch.mockReturnValue(makeFetchResponse(updated));
420
+
421
+ const result = await darwinMod.getUsageLimits();
422
+ expect(result).toEqual(updated);
423
+ expect(mockFetch).toHaveBeenCalledTimes(2);
424
+
425
+ vi.spyOn(Date, "now").mockRestore();
426
+ });
427
+
428
+ it("returns empty when fetch fails", async () => {
429
+ vi.resetModules();
430
+ mockFetch.mockReset();
431
+ const darwinMod = await import("./usage-limits.js");
432
+ mockExecSync.mockReturnValue(makeCredentialsJson(SAMPLE_TOKEN));
433
+ mockFetch.mockReturnValue(makeFetchResponse({}, false));
434
+
435
+ const result = await darwinMod.getUsageLimits();
436
+ expect(result).toEqual(EMPTY);
437
+ });
438
+ });
439
+
440
+ // ===========================================================================
441
+ // Token refresh flow (via getUsageLimits -> getValidAccessToken)
442
+ // These tests use macOS platform to exercise the execSync/keychain path
443
+ // ===========================================================================
444
+ describe("token refresh", () => {
445
+ const EMPTY = { five_hour: null, seven_day: null, extra_usage: null };
446
+
447
+ beforeEach(() => {
448
+ Object.defineProperty(process, "platform", { value: "darwin" });
449
+ });
450
+
451
+ it("refreshes an expired token and uses the new one", async () => {
452
+ vi.resetModules();
453
+ mockFetch.mockReset();
454
+ mockExecSync.mockReset();
455
+ mockExecFileSync.mockReset();
456
+ const darwinMod = await import("./usage-limits.js");
457
+
458
+ // Provide an expired token
459
+ mockExecSync.mockReturnValue(makeCredentialsJson(SAMPLE_TOKEN, { expired: true }));
460
+
461
+ // First call = token refresh, second call = usage API
462
+ mockFetch
463
+ .mockReturnValueOnce(
464
+ makeFetchResponse({
465
+ access_token: "sk-ant-new-token",
466
+ refresh_token: "sk-ant-new-refresh",
467
+ expires_in: 3600,
468
+ }),
469
+ )
470
+ .mockReturnValueOnce(makeFetchResponse(SAMPLE_LIMITS));
471
+
472
+ const result = await darwinMod.getUsageLimits();
473
+ expect(result).toEqual(SAMPLE_LIMITS);
474
+
475
+ // First fetch = refresh call to platform.claude.com
476
+ expect(mockFetch).toHaveBeenCalledTimes(2);
477
+ const [refreshUrl, refreshOpts] = mockFetch.mock.calls[0];
478
+ expect(refreshUrl).toContain("oauth/token");
479
+ expect(refreshOpts.method).toBe("POST");
480
+
481
+ // Second fetch = usage API with refreshed token
482
+ const [usageUrl, usageOpts] = mockFetch.mock.calls[1];
483
+ expect(usageUrl).toContain("oauth/usage");
484
+ expect(usageOpts.headers.Authorization).toBe("Bearer sk-ant-new-token");
485
+
486
+ // Credentials should have been written back via execFileSync (macOS keychain)
487
+ expect(mockExecFileSync).toHaveBeenCalled();
488
+ });
489
+
490
+ it("returns empty when token is expired and refresh fails", async () => {
491
+ vi.resetModules();
492
+ mockFetch.mockReset();
493
+ mockExecSync.mockReset();
494
+ const darwinMod = await import("./usage-limits.js");
495
+
496
+ mockExecSync.mockReturnValue(makeCredentialsJson(SAMPLE_TOKEN, { expired: true }));
497
+ // Refresh call fails
498
+ mockFetch.mockReturnValueOnce(makeFetchResponse({}, false));
499
+
500
+ const result = await darwinMod.getUsageLimits();
501
+ expect(result).toEqual(EMPTY);
502
+ // Only the refresh call, no usage call
503
+ expect(mockFetch).toHaveBeenCalledTimes(1);
504
+ });
505
+
506
+ it("returns empty when token is expired and refresh throws", async () => {
507
+ vi.resetModules();
508
+ mockFetch.mockReset();
509
+ mockExecSync.mockReset();
510
+ const darwinMod = await import("./usage-limits.js");
511
+
512
+ mockExecSync.mockReturnValue(makeCredentialsJson(SAMPLE_TOKEN, { expired: true }));
513
+ mockFetch.mockRejectedValueOnce(new Error("network error"));
514
+
515
+ const result = await darwinMod.getUsageLimits();
516
+ expect(result).toEqual(EMPTY);
517
+ });
518
+
519
+ it("uses valid (non-expired) token without refreshing", async () => {
520
+ vi.resetModules();
521
+ mockFetch.mockReset();
522
+ mockExecSync.mockReset();
523
+ const darwinMod = await import("./usage-limits.js");
524
+
525
+ // Token has a future expiry (default in makeCredentialsJson)
526
+ mockExecSync.mockReturnValue(makeCredentialsJson(SAMPLE_TOKEN));
527
+ mockFetch.mockReturnValueOnce(makeFetchResponse(SAMPLE_LIMITS));
528
+
529
+ const result = await darwinMod.getUsageLimits();
530
+ expect(result).toEqual(SAMPLE_LIMITS);
531
+ // Only one fetch call (usage API), no refresh call
532
+ expect(mockFetch).toHaveBeenCalledTimes(1);
533
+ const [url] = mockFetch.mock.calls[0];
534
+ expect(url).toContain("oauth/usage");
535
+ });
536
+ });