@revenium/claude-code-metering 0.1.4 → 0.1.5

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 (75) hide show
  1. package/.env.example +15 -0
  2. package/.eslintrc.js +24 -0
  3. package/.github/workflows/branch-bypass-alert.yml +68 -0
  4. package/CODE_OF_CONDUCT.md +57 -0
  5. package/CONTRIBUTING.md +73 -0
  6. package/README.md +57 -3
  7. package/SECURITY.md +46 -0
  8. package/dist/cli/commands/setup.js +3 -1
  9. package/dist/cli/commands/setup.js.map +1 -1
  10. package/dist/core/api/client.d.ts.map +1 -1
  11. package/dist/core/api/client.js +4 -1
  12. package/dist/core/api/client.js.map +1 -1
  13. package/dist/core/tool-context.d.ts +6 -0
  14. package/dist/core/tool-context.d.ts.map +1 -0
  15. package/dist/core/tool-context.js +21 -0
  16. package/dist/core/tool-context.js.map +1 -0
  17. package/dist/core/tool-tracker.d.ts +4 -0
  18. package/dist/core/tool-tracker.d.ts.map +1 -0
  19. package/dist/core/tool-tracker.js +156 -0
  20. package/dist/core/tool-tracker.js.map +1 -0
  21. package/dist/index.d.ts +2 -0
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +2 -0
  24. package/dist/index.js.map +1 -1
  25. package/dist/types/index.d.ts +1 -0
  26. package/dist/types/index.d.ts.map +1 -1
  27. package/dist/types/index.js +15 -0
  28. package/dist/types/index.js.map +1 -1
  29. package/dist/types/tool-metering.d.ts +36 -0
  30. package/dist/types/tool-metering.d.ts.map +1 -0
  31. package/dist/types/tool-metering.js +3 -0
  32. package/dist/types/tool-metering.js.map +1 -0
  33. package/docs/research/settings-json-telemetry-findings.md +171 -0
  34. package/examples/README.md +114 -0
  35. package/examples/validation/validate-installation.sh +212 -0
  36. package/package.json +1 -7
  37. package/public-allowlist-node.txt +7 -0
  38. package/src/cli/commands/backfill.ts +865 -0
  39. package/src/cli/commands/setup.ts +254 -0
  40. package/src/cli/commands/status.ts +108 -0
  41. package/src/cli/commands/test.ts +91 -0
  42. package/src/cli/index.ts +103 -0
  43. package/src/core/api/client.ts +194 -0
  44. package/src/core/config/loader.ts +217 -0
  45. package/src/core/config/validator.ts +142 -0
  46. package/src/core/config/writer.ts +212 -0
  47. package/src/core/shell/detector.ts +92 -0
  48. package/src/core/shell/profile-updater.ts +131 -0
  49. package/src/core/tool-context.ts +23 -0
  50. package/src/core/tool-tracker.ts +204 -0
  51. package/src/index.ts +12 -0
  52. package/src/types/index.ts +110 -0
  53. package/src/types/tool-metering.ts +38 -0
  54. package/src/utils/constants.ts +80 -0
  55. package/src/utils/hashing.ts +35 -0
  56. package/src/utils/masking.ts +32 -0
  57. package/tests/integration/cli-commands.test.ts +158 -0
  58. package/tests/unit/backfill-command.test.ts +366 -0
  59. package/tests/unit/backfill-helpers.test.ts +397 -0
  60. package/tests/unit/backfill-parse.test.ts +276 -0
  61. package/tests/unit/backfill-stream.test.ts +147 -0
  62. package/tests/unit/backfill.test.ts +344 -0
  63. package/tests/unit/cli-index.test.ts +193 -0
  64. package/tests/unit/client.test.ts +195 -0
  65. package/tests/unit/detector.test.ts +247 -0
  66. package/tests/unit/hashing.test.ts +121 -0
  67. package/tests/unit/loader.test.ts +272 -0
  68. package/tests/unit/masking.test.ts +46 -0
  69. package/tests/unit/profile-updater.test.ts +146 -0
  70. package/tests/unit/setup.test.ts +557 -0
  71. package/tests/unit/status.test.ts +149 -0
  72. package/tests/unit/test.test.ts +165 -0
  73. package/tests/unit/validator.test.ts +211 -0
  74. package/tests/unit/writer.test.ts +176 -0
  75. package/tsconfig.json +20 -0
@@ -0,0 +1,366 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import {
3
+ backfillCommand,
4
+ type BackfillDependencies,
5
+ } from "../../src/cli/commands/backfill.js";
6
+
7
+ vi.mock("ora", () => ({
8
+ default: vi.fn(() => ({
9
+ start: vi.fn().mockReturnThis(),
10
+ succeed: vi.fn().mockReturnThis(),
11
+ fail: vi.fn().mockReturnThis(),
12
+ warn: vi.fn().mockReturnThis(),
13
+ text: "",
14
+ })),
15
+ }));
16
+
17
+ describe("backfillCommand flow", () => {
18
+ let mockDeps: BackfillDependencies;
19
+ let mockExit: ReturnType<typeof vi.spyOn>;
20
+ let mockConsoleLog: ReturnType<typeof vi.spyOn>;
21
+
22
+ beforeEach(() => {
23
+ vi.clearAllMocks();
24
+ mockExit = vi.spyOn(process, "exit").mockImplementation((code?: number) => {
25
+ throw new Error(`process.exit(${code})`);
26
+ });
27
+ mockConsoleLog = vi.spyOn(console, "log").mockImplementation(() => {});
28
+
29
+ mockDeps = {
30
+ loadConfig: vi.fn(),
31
+ findJsonlFiles: vi.fn(),
32
+ streamJsonlRecords: vi.fn(),
33
+ sendBatchWithRetry: vi.fn(),
34
+ homedir: vi.fn().mockReturnValue("/mock/home"),
35
+ };
36
+ });
37
+
38
+ afterEach(() => {
39
+ vi.restoreAllMocks();
40
+ });
41
+
42
+ it("should exit if no JSONL files found", async () => {
43
+ mockDeps.loadConfig = vi.fn().mockResolvedValue({
44
+ apiKey: "hak_test",
45
+ endpoint: "https://api.test.com",
46
+ });
47
+ mockDeps.findJsonlFiles = vi
48
+ .fn()
49
+ .mockResolvedValue({ files: [], errors: [] });
50
+
51
+ await expect(backfillCommand({}, mockDeps)).rejects.toThrow(
52
+ "process.exit(1)",
53
+ );
54
+ expect(mockConsoleLog).toHaveBeenCalledWith(
55
+ expect.stringContaining("Searched in:"),
56
+ );
57
+ });
58
+
59
+ it("should show discovery errors with verbose flag", async () => {
60
+ mockDeps.loadConfig = vi.fn().mockResolvedValue({
61
+ apiKey: "hak_test",
62
+ endpoint: "https://api.test.com",
63
+ });
64
+ mockDeps.findJsonlFiles = vi.fn().mockResolvedValue({
65
+ files: ["/mock/file.jsonl"],
66
+ errors: ["Error reading directory"],
67
+ });
68
+ mockDeps.streamJsonlRecords = vi
69
+ .fn()
70
+ .mockImplementation(async function* () {
71
+ yield {
72
+ record: {
73
+ sessionId: "s1",
74
+ timestamp: "2024-01-15T10:00:00Z",
75
+ model: "claude-3",
76
+ inputTokens: 100,
77
+ outputTokens: 50,
78
+ cacheReadTokens: 0,
79
+ cacheCreationTokens: 0,
80
+ },
81
+ };
82
+ });
83
+ mockDeps.sendBatchWithRetry = vi
84
+ .fn()
85
+ .mockResolvedValue({ success: true, attempts: 1 });
86
+
87
+ await backfillCommand({ verbose: true }, mockDeps);
88
+
89
+ expect(mockDeps.findJsonlFiles).toHaveBeenCalled();
90
+ });
91
+
92
+ it("should handle parse errors during file processing", async () => {
93
+ mockDeps.loadConfig = vi.fn().mockResolvedValue({
94
+ apiKey: "hak_test",
95
+ endpoint: "https://api.test.com",
96
+ });
97
+ mockDeps.findJsonlFiles = vi.fn().mockResolvedValue({
98
+ files: ["/mock/file.jsonl"],
99
+ errors: [],
100
+ });
101
+ mockDeps.streamJsonlRecords = vi
102
+ .fn()
103
+ .mockImplementation(async function* () {
104
+ yield { parseError: true };
105
+ yield {
106
+ record: {
107
+ sessionId: "s1",
108
+ timestamp: "2024-01-15T10:00:00Z",
109
+ model: "claude-3",
110
+ inputTokens: 100,
111
+ outputTokens: 50,
112
+ cacheReadTokens: 0,
113
+ cacheCreationTokens: 0,
114
+ },
115
+ };
116
+ });
117
+ mockDeps.sendBatchWithRetry = vi
118
+ .fn()
119
+ .mockResolvedValue({ success: true, attempts: 1 });
120
+
121
+ await backfillCommand({}, mockDeps);
122
+
123
+ expect(mockDeps.sendBatchWithRetry).toHaveBeenCalledTimes(1);
124
+ });
125
+
126
+ it("should handle missing fields during file processing", async () => {
127
+ mockDeps.loadConfig = vi.fn().mockResolvedValue({
128
+ apiKey: "hak_test",
129
+ endpoint: "https://api.test.com",
130
+ });
131
+ mockDeps.findJsonlFiles = vi.fn().mockResolvedValue({
132
+ files: ["/mock/file.jsonl"],
133
+ errors: [],
134
+ });
135
+ mockDeps.streamJsonlRecords = vi
136
+ .fn()
137
+ .mockImplementation(async function* () {
138
+ yield { missingFields: true };
139
+ yield {
140
+ record: {
141
+ sessionId: "s1",
142
+ timestamp: "2024-01-15T10:00:00Z",
143
+ model: "claude-3",
144
+ inputTokens: 100,
145
+ outputTokens: 50,
146
+ cacheReadTokens: 0,
147
+ cacheCreationTokens: 0,
148
+ },
149
+ };
150
+ });
151
+ mockDeps.sendBatchWithRetry = vi
152
+ .fn()
153
+ .mockResolvedValue({ success: true, attempts: 1 });
154
+
155
+ await backfillCommand({}, mockDeps);
156
+
157
+ expect(mockDeps.sendBatchWithRetry).toHaveBeenCalledTimes(1);
158
+ });
159
+
160
+ it("should handle file read error during stream", async () => {
161
+ mockDeps.loadConfig = vi.fn().mockResolvedValue({
162
+ apiKey: "hak_test",
163
+ endpoint: "https://api.test.com",
164
+ });
165
+ mockDeps.findJsonlFiles = vi.fn().mockResolvedValue({
166
+ files: ["/mock/file.jsonl", "/mock/file2.jsonl"],
167
+ errors: [],
168
+ });
169
+ mockDeps.streamJsonlRecords = vi.fn().mockImplementation(async function* (
170
+ file: string,
171
+ ) {
172
+ if (file === "/mock/file.jsonl") {
173
+ throw new Error("ENOENT: no such file or directory");
174
+ }
175
+ yield {
176
+ record: {
177
+ sessionId: "s1",
178
+ timestamp: "2024-01-15T10:00:00Z",
179
+ model: "claude-3",
180
+ inputTokens: 100,
181
+ outputTokens: 50,
182
+ cacheReadTokens: 0,
183
+ cacheCreationTokens: 0,
184
+ },
185
+ };
186
+ });
187
+ mockDeps.sendBatchWithRetry = vi
188
+ .fn()
189
+ .mockResolvedValue({ success: true, attempts: 1 });
190
+
191
+ await backfillCommand({ verbose: true }, mockDeps);
192
+
193
+ expect(mockDeps.sendBatchWithRetry).toHaveBeenCalledTimes(1);
194
+ });
195
+
196
+ it("should return early if no records found after processing", async () => {
197
+ mockDeps.loadConfig = vi.fn().mockResolvedValue({
198
+ apiKey: "hak_test",
199
+ endpoint: "https://api.test.com",
200
+ });
201
+ mockDeps.findJsonlFiles = vi.fn().mockResolvedValue({
202
+ files: ["/mock/file.jsonl"],
203
+ errors: [],
204
+ });
205
+ mockDeps.streamJsonlRecords = vi
206
+ .fn()
207
+ .mockImplementation(async function* () {
208
+ return;
209
+ });
210
+
211
+ await backfillCommand({}, mockDeps);
212
+
213
+ expect(mockConsoleLog).toHaveBeenCalledWith(
214
+ expect.stringContaining("No usage records found"),
215
+ );
216
+ expect(mockDeps.sendBatchWithRetry).not.toHaveBeenCalled();
217
+ });
218
+
219
+ it("should complete dry-run without sending data", async () => {
220
+ mockDeps.loadConfig = vi.fn().mockResolvedValue({
221
+ apiKey: "hak_test",
222
+ endpoint: "https://api.test.com",
223
+ subscriptionTier: "pro",
224
+ });
225
+ mockDeps.findJsonlFiles = vi.fn().mockResolvedValue({
226
+ files: ["/mock/file.jsonl"],
227
+ errors: [],
228
+ });
229
+ mockDeps.streamJsonlRecords = vi
230
+ .fn()
231
+ .mockImplementation(async function* () {
232
+ yield {
233
+ record: {
234
+ sessionId: "s1",
235
+ timestamp: "2024-01-15T10:00:00Z",
236
+ model: "claude-3",
237
+ inputTokens: 100,
238
+ outputTokens: 50,
239
+ cacheReadTokens: 0,
240
+ cacheCreationTokens: 0,
241
+ },
242
+ };
243
+ });
244
+
245
+ await backfillCommand({ dryRun: true }, mockDeps);
246
+
247
+ expect(mockDeps.sendBatchWithRetry).not.toHaveBeenCalled();
248
+ expect(mockConsoleLog).toHaveBeenCalledWith(
249
+ expect.stringContaining("Dry run complete"),
250
+ );
251
+ });
252
+
253
+ it("should send single batch successfully", async () => {
254
+ mockDeps.loadConfig = vi.fn().mockResolvedValue({
255
+ apiKey: "hak_test",
256
+ endpoint: "https://api.test.com",
257
+ subscriptionTier: "pro",
258
+ });
259
+ mockDeps.findJsonlFiles = vi.fn().mockResolvedValue({
260
+ files: ["/mock/file.jsonl"],
261
+ errors: [],
262
+ });
263
+ mockDeps.streamJsonlRecords = vi
264
+ .fn()
265
+ .mockImplementation(async function* () {
266
+ yield {
267
+ record: {
268
+ sessionId: "s1",
269
+ timestamp: "2024-01-15T10:00:00Z",
270
+ model: "claude-3",
271
+ inputTokens: 100,
272
+ outputTokens: 50,
273
+ cacheReadTokens: 0,
274
+ cacheCreationTokens: 0,
275
+ },
276
+ };
277
+ });
278
+ mockDeps.sendBatchWithRetry = vi
279
+ .fn()
280
+ .mockResolvedValue({ success: true, attempts: 1 });
281
+
282
+ await backfillCommand({ batchSize: 100 }, mockDeps);
283
+
284
+ expect(mockDeps.sendBatchWithRetry).toHaveBeenCalledTimes(1);
285
+ expect(mockConsoleLog).toHaveBeenCalledWith(
286
+ expect.stringContaining("Backfill complete"),
287
+ );
288
+ });
289
+
290
+ it("should send multiple batches with delay", async () => {
291
+ mockDeps.loadConfig = vi.fn().mockResolvedValue({
292
+ apiKey: "hak_test",
293
+ endpoint: "https://api.test.com",
294
+ subscriptionTier: "pro",
295
+ });
296
+ mockDeps.findJsonlFiles = vi.fn().mockResolvedValue({
297
+ files: ["/mock/file.jsonl"],
298
+ errors: [],
299
+ });
300
+ mockDeps.streamJsonlRecords = vi
301
+ .fn()
302
+ .mockImplementation(async function* () {
303
+ for (let i = 0; i < 150; i++) {
304
+ yield {
305
+ record: {
306
+ sessionId: `s${i}`,
307
+ timestamp: "2024-01-15T10:00:00Z",
308
+ model: "claude-3",
309
+ inputTokens: 100,
310
+ outputTokens: 50,
311
+ cacheReadTokens: 0,
312
+ cacheCreationTokens: 0,
313
+ },
314
+ };
315
+ }
316
+ });
317
+ mockDeps.sendBatchWithRetry = vi
318
+ .fn()
319
+ .mockResolvedValue({ success: true, attempts: 1 });
320
+
321
+ await backfillCommand({ batchSize: 100, delay: 50 }, mockDeps);
322
+
323
+ expect(mockDeps.sendBatchWithRetry).toHaveBeenCalledTimes(2);
324
+ });
325
+
326
+ it("should handle batch failure and show warning", async () => {
327
+ mockDeps.loadConfig = vi.fn().mockResolvedValue({
328
+ apiKey: "hak_test",
329
+ endpoint: "https://api.test.com",
330
+ subscriptionTier: "pro",
331
+ });
332
+ mockDeps.findJsonlFiles = vi.fn().mockResolvedValue({
333
+ files: ["/mock/file.jsonl"],
334
+ errors: [],
335
+ });
336
+ mockDeps.streamJsonlRecords = vi
337
+ .fn()
338
+ .mockImplementation(async function* () {
339
+ yield {
340
+ record: {
341
+ sessionId: "s1",
342
+ timestamp: "2024-01-15T10:00:00Z",
343
+ model: "claude-3",
344
+ inputTokens: 100,
345
+ outputTokens: 50,
346
+ cacheReadTokens: 0,
347
+ cacheCreationTokens: 0,
348
+ },
349
+ };
350
+ });
351
+ mockDeps.sendBatchWithRetry = vi
352
+ .fn()
353
+ .mockResolvedValue({
354
+ success: false,
355
+ attempts: 3,
356
+ error: "Network error",
357
+ });
358
+
359
+ await backfillCommand({}, mockDeps);
360
+
361
+ expect(mockDeps.sendBatchWithRetry).toHaveBeenCalledTimes(1);
362
+ expect(mockConsoleLog).toHaveBeenCalledWith(
363
+ expect.stringContaining("Permanently Failed Batches"),
364
+ );
365
+ });
366
+ });