@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,272 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import {
3
+ getFullOtlpEndpoint,
4
+ parseEnvContent,
5
+ getConfigPath,
6
+ configExists,
7
+ loadConfig,
8
+ isEnvLoaded,
9
+ } from "../../src/core/config/loader.js";
10
+ import { readFile } from "node:fs/promises";
11
+ import { existsSync } from "node:fs";
12
+ import { homedir } from "node:os";
13
+
14
+ vi.mock("node:fs/promises");
15
+ vi.mock("node:fs");
16
+ vi.mock("node:os");
17
+
18
+ describe("getFullOtlpEndpoint", () => {
19
+ it("should append OTLP path to base URL", () => {
20
+ const result = getFullOtlpEndpoint("https://api.revenium.ai");
21
+ expect(result).toBe("https://api.revenium.ai/meter/v2/otlp");
22
+ });
23
+
24
+ it("should handle base URL with trailing slash", () => {
25
+ const result = getFullOtlpEndpoint("https://api.revenium.ai/");
26
+ expect(result).toBe("https://api.revenium.ai/meter/v2/otlp");
27
+ });
28
+
29
+ it("should handle localhost", () => {
30
+ const result = getFullOtlpEndpoint("http://localhost:8080");
31
+ expect(result).toBe("http://localhost:8080/meter/v2/otlp");
32
+ });
33
+ });
34
+
35
+ describe("parseEnvContent", () => {
36
+ beforeEach(() => {
37
+ vi.clearAllMocks();
38
+ vi.mocked(homedir).mockReturnValue("/home/user");
39
+ });
40
+
41
+ afterEach(() => {
42
+ vi.restoreAllMocks();
43
+ });
44
+
45
+ it("should parse cost multiplier override of 0", () => {
46
+ const content = `
47
+ export CLAUDE_CODE_COST_MULTIPLIER="0"
48
+ export OTEL_EXPORTER_OTLP_ENDPOINT="https://api.revenium.ai/meter/v2/otlp"
49
+ export OTEL_EXPORTER_OTLP_HEADERS="x-api-key=hak_test123"
50
+ `.trim();
51
+
52
+ const result = parseEnvContent(content);
53
+ expect(result.CLAUDE_CODE_COST_MULTIPLIER).toBe("0");
54
+ });
55
+
56
+ it("should parse cost multiplier override of 0.5", () => {
57
+ const content = `
58
+ export CLAUDE_CODE_COST_MULTIPLIER="0.5"
59
+ `.trim();
60
+
61
+ const result = parseEnvContent(content);
62
+ expect(result.CLAUDE_CODE_COST_MULTIPLIER).toBe("0.5");
63
+ });
64
+
65
+ it("should handle unescaped values", () => {
66
+ const content = `
67
+ export TEST_VALUE="hello\\"world"
68
+ `.trim();
69
+
70
+ const result = parseEnvContent(content);
71
+ expect(result.TEST_VALUE).toBe('hello"world');
72
+ });
73
+
74
+ it("should skip comments and empty lines", () => {
75
+ const content = "# Comment\nKEY1=value1\n\n# Another comment\nKEY2=value2";
76
+ const result = parseEnvContent(content);
77
+
78
+ expect(result).toEqual({
79
+ KEY1: "value1",
80
+ KEY2: "value2",
81
+ });
82
+ });
83
+
84
+ it("should handle lines without export prefix", () => {
85
+ const content = "KEY1=value1\nKEY2=value2";
86
+ const result = parseEnvContent(content);
87
+
88
+ expect(result).toEqual({
89
+ KEY1: "value1",
90
+ KEY2: "value2",
91
+ });
92
+ });
93
+ });
94
+
95
+ describe("getConfigPath", () => {
96
+ beforeEach(() => {
97
+ vi.clearAllMocks();
98
+ vi.mocked(homedir).mockReturnValue("/home/user");
99
+ });
100
+
101
+ afterEach(() => {
102
+ vi.restoreAllMocks();
103
+ });
104
+
105
+ it("should return correct config path", () => {
106
+ const path = getConfigPath();
107
+ expect(path).toBe("/home/user/.claude/revenium.env");
108
+ });
109
+ });
110
+
111
+ describe("configExists", () => {
112
+ beforeEach(() => {
113
+ vi.clearAllMocks();
114
+ vi.mocked(homedir).mockReturnValue("/home/user");
115
+ });
116
+
117
+ afterEach(() => {
118
+ vi.restoreAllMocks();
119
+ });
120
+
121
+ it("should return true when config file exists", () => {
122
+ vi.mocked(existsSync).mockReturnValue(true);
123
+
124
+ const exists = configExists();
125
+ expect(exists).toBe(true);
126
+ });
127
+
128
+ it("should return false when config file does not exist", () => {
129
+ vi.mocked(existsSync).mockReturnValue(false);
130
+
131
+ const exists = configExists();
132
+ expect(exists).toBe(false);
133
+ });
134
+ });
135
+
136
+ describe("loadConfig", () => {
137
+ beforeEach(() => {
138
+ vi.clearAllMocks();
139
+ vi.mocked(homedir).mockReturnValue("/home/user");
140
+ });
141
+
142
+ afterEach(() => {
143
+ vi.restoreAllMocks();
144
+ });
145
+
146
+ it("should return null when config file does not exist", async () => {
147
+ vi.mocked(existsSync).mockReturnValue(false);
148
+
149
+ const config = await loadConfig();
150
+ expect(config).toBeNull();
151
+ });
152
+
153
+ it("should parse config from file", async () => {
154
+ vi.mocked(existsSync).mockReturnValue(true);
155
+ vi.mocked(readFile).mockResolvedValue(
156
+ 'export OTEL_EXPORTER_OTLP_ENDPOINT="https://api.revenium.ai/meter/v2/otlp"\n' +
157
+ 'export OTEL_EXPORTER_OTLP_HEADERS="x-api-key=hak_test123"\n' +
158
+ 'export REVENIUM_SUBSCRIBER_EMAIL="test@example.com"\n' +
159
+ 'export CLAUDE_CODE_SUBSCRIPTION="pro"',
160
+ );
161
+
162
+ const config = await loadConfig();
163
+
164
+ expect(config).toEqual({
165
+ apiKey: "hak_test123",
166
+ endpoint: "https://api.revenium.ai",
167
+ email: "test@example.com",
168
+ subscriptionTier: "pro",
169
+ costMultiplierOverride: undefined,
170
+ organizationName: undefined,
171
+ organizationId: undefined,
172
+ productName: undefined,
173
+ productId: undefined,
174
+ });
175
+ });
176
+
177
+ it("should return null when API key is missing", async () => {
178
+ vi.mocked(existsSync).mockReturnValue(true);
179
+ vi.mocked(readFile).mockResolvedValue(
180
+ 'export OTEL_EXPORTER_OTLP_ENDPOINT="https://api.revenium.ai/meter/v2/otlp"\n',
181
+ );
182
+
183
+ const config = await loadConfig();
184
+ expect(config).toBeNull();
185
+ });
186
+
187
+ it("should parse cost multiplier override", async () => {
188
+ vi.mocked(existsSync).mockReturnValue(true);
189
+ vi.mocked(readFile).mockResolvedValue(
190
+ 'export OTEL_EXPORTER_OTLP_ENDPOINT="https://api.revenium.ai/meter/v2/otlp"\n' +
191
+ 'export OTEL_EXPORTER_OTLP_HEADERS="x-api-key=hak_test123"\n' +
192
+ 'export CLAUDE_CODE_COST_MULTIPLIER="0.5"',
193
+ );
194
+
195
+ const config = await loadConfig();
196
+
197
+ expect(config?.costMultiplierOverride).toBe(0.5);
198
+ });
199
+
200
+ it("should parse organizationName and productName from standalone env vars", async () => {
201
+ vi.mocked(existsSync).mockReturnValue(true);
202
+ vi.mocked(readFile).mockResolvedValue(
203
+ 'export OTEL_EXPORTER_OTLP_ENDPOINT="https://api.revenium.ai/meter/v2/otlp"\n' +
204
+ 'export OTEL_EXPORTER_OTLP_HEADERS="x-api-key=hak_test123"\n' +
205
+ 'export REVENIUM_ORGANIZATION_ID="org-123"\n' +
206
+ 'export REVENIUM_PRODUCT_ID="prod-456"',
207
+ );
208
+
209
+ const config = await loadConfig();
210
+
211
+ expect(config?.organizationName).toBe("org-123");
212
+ expect(config?.organizationId).toBe("org-123");
213
+ expect(config?.productName).toBe("prod-456");
214
+ expect(config?.productId).toBe("prod-456");
215
+ });
216
+
217
+ it("should parse organizationName and productName from OTEL_RESOURCE_ATTRIBUTES", async () => {
218
+ vi.mocked(existsSync).mockReturnValue(true);
219
+ vi.mocked(readFile).mockResolvedValue(
220
+ 'export OTEL_EXPORTER_OTLP_ENDPOINT="https://api.revenium.ai/meter/v2/otlp"\n' +
221
+ 'export OTEL_EXPORTER_OTLP_HEADERS="x-api-key=hak_test123"\n' +
222
+ 'export OTEL_RESOURCE_ATTRIBUTES="cost_multiplier=0.08,organization.name=org-456,product.name=prod-789"',
223
+ );
224
+
225
+ const config = await loadConfig();
226
+
227
+ expect(config?.organizationName).toBe("org-456");
228
+ expect(config?.organizationId).toBe("org-456");
229
+ expect(config?.productName).toBe("prod-789");
230
+ expect(config?.productId).toBe("prod-789");
231
+ });
232
+ });
233
+
234
+ describe("isEnvLoaded", () => {
235
+ const originalEnv = process.env;
236
+
237
+ beforeEach(() => {
238
+ vi.clearAllMocks();
239
+ process.env = { ...originalEnv };
240
+ });
241
+
242
+ afterEach(() => {
243
+ process.env = originalEnv;
244
+ vi.restoreAllMocks();
245
+ });
246
+
247
+ it("should return true when env vars are loaded", () => {
248
+ process.env.CLAUDE_CODE_ENABLE_TELEMETRY = "1";
249
+ process.env.OTEL_EXPORTER_OTLP_ENDPOINT =
250
+ "https://api.revenium.ai/meter/v2/otlp";
251
+
252
+ const loaded = isEnvLoaded();
253
+ expect(loaded).toBe(true);
254
+ });
255
+
256
+ it("should return false when telemetry is not enabled", () => {
257
+ process.env.CLAUDE_CODE_ENABLE_TELEMETRY = "0";
258
+ process.env.OTEL_EXPORTER_OTLP_ENDPOINT =
259
+ "https://api.revenium.ai/meter/v2/otlp";
260
+
261
+ const loaded = isEnvLoaded();
262
+ expect(loaded).toBe(false);
263
+ });
264
+
265
+ it("should return false when endpoint is missing", () => {
266
+ process.env.CLAUDE_CODE_ENABLE_TELEMETRY = "1";
267
+ delete process.env.OTEL_EXPORTER_OTLP_ENDPOINT;
268
+
269
+ const loaded = isEnvLoaded();
270
+ expect(loaded).toBe(false);
271
+ });
272
+ });
@@ -0,0 +1,46 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { maskApiKey, maskEmail } from '../../src/utils/masking.js';
3
+
4
+ describe('maskApiKey', () => {
5
+ it('should mask API key showing prefix and last 4 chars', () => {
6
+ const result = maskApiKey('hak_tenant_abc123xyz');
7
+ expect(result).toBe('hak_***3xyz');
8
+ });
9
+
10
+ it('should handle short API keys', () => {
11
+ const result = maskApiKey('short');
12
+ expect(result).toBe('***');
13
+ });
14
+
15
+ it('should handle empty string', () => {
16
+ const result = maskApiKey('');
17
+ expect(result).toBe('***');
18
+ });
19
+
20
+ it('should handle minimum length key', () => {
21
+ const result = maskApiKey('hak_1234');
22
+ expect(result).toBe('hak_***1234');
23
+ });
24
+ });
25
+
26
+ describe('maskEmail', () => {
27
+ it('should mask email showing first char and domain', () => {
28
+ const result = maskEmail('user@example.com');
29
+ expect(result).toBe('u***@example.com');
30
+ });
31
+
32
+ it('should handle email without @', () => {
33
+ const result = maskEmail('invalid-email');
34
+ expect(result).toBe('***');
35
+ });
36
+
37
+ it('should handle email starting with @', () => {
38
+ const result = maskEmail('@example.com');
39
+ expect(result).toBe('***');
40
+ });
41
+
42
+ it('should handle long usernames', () => {
43
+ const result = maskEmail('verylongusername@company.com');
44
+ expect(result).toBe('v***@company.com');
45
+ });
46
+ });
@@ -0,0 +1,146 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import { updateShellProfile, getManualInstructions } from "../../src/core/shell/profile-updater.js";
3
+ import * as detector from "../../src/core/shell/detector.js";
4
+ import * as writer from "../../src/core/config/writer.js";
5
+ import { readFile, writeFile } from "node:fs/promises";
6
+ import { existsSync } from "node:fs";
7
+
8
+ vi.mock("../../src/core/shell/detector.js");
9
+ vi.mock("../../src/core/config/writer.js");
10
+ vi.mock("node:fs/promises");
11
+ vi.mock("node:fs");
12
+
13
+ describe("profile-updater", () => {
14
+ beforeEach(() => {
15
+ vi.clearAllMocks();
16
+ });
17
+
18
+ afterEach(() => {
19
+ vi.restoreAllMocks();
20
+ });
21
+
22
+ describe("updateShellProfile", () => {
23
+ it("should return error for unknown shell", async () => {
24
+ vi.mocked(detector.detectShell).mockReturnValue("unknown");
25
+
26
+ const result = await updateShellProfile();
27
+
28
+ expect(result).toEqual({
29
+ success: false,
30
+ shellType: "unknown",
31
+ message: "Could not detect shell type. Please manually add the source command to your shell profile.",
32
+ });
33
+ });
34
+
35
+ it("should return error when profile path cannot be determined", async () => {
36
+ vi.mocked(detector.detectShell).mockReturnValue("bash");
37
+ vi.mocked(detector.getProfilePath).mockReturnValue(null);
38
+
39
+ const result = await updateShellProfile();
40
+
41
+ expect(result).toEqual({
42
+ success: false,
43
+ shellType: "bash",
44
+ message: "Could not determine profile path for bash.",
45
+ });
46
+ });
47
+
48
+ it("should add new configuration to profile", async () => {
49
+ vi.mocked(detector.detectShell).mockReturnValue("bash");
50
+ vi.mocked(detector.getProfilePath).mockReturnValue("/home/user/.bashrc");
51
+ vi.mocked(writer.getConfigFilePath).mockReturnValue("/home/user/.claude/revenium.env");
52
+ vi.mocked(detector.getSourceCommand).mockReturnValue('source "/home/user/.claude/revenium.env"');
53
+ vi.mocked(existsSync).mockReturnValue(true);
54
+ vi.mocked(readFile).mockResolvedValue("# existing content\n");
55
+
56
+ const result = await updateShellProfile();
57
+
58
+ expect(result).toEqual({
59
+ success: true,
60
+ shellType: "bash",
61
+ profilePath: "/home/user/.bashrc",
62
+ message: "Added configuration to /home/user/.bashrc",
63
+ });
64
+
65
+ expect(writeFile).toHaveBeenCalledWith(
66
+ "/home/user/.bashrc",
67
+ expect.stringContaining("# >>> revenium-claude-code-metering >>>"),
68
+ "utf-8"
69
+ );
70
+ });
71
+
72
+ it("should update existing configuration (idempotent)", async () => {
73
+ vi.mocked(detector.detectShell).mockReturnValue("zsh");
74
+ vi.mocked(detector.getProfilePath).mockReturnValue("/home/user/.zshrc");
75
+ vi.mocked(writer.getConfigFilePath).mockReturnValue("/home/user/.claude/revenium.env");
76
+ vi.mocked(detector.getSourceCommand).mockReturnValue('source "/home/user/.claude/revenium.env"');
77
+ vi.mocked(existsSync).mockReturnValue(true);
78
+ vi.mocked(readFile).mockResolvedValue(
79
+ "# existing content\n# >>> revenium-claude-code-metering >>>\nold config\n# <<< revenium-claude-code-metering <<<\n"
80
+ );
81
+
82
+ const result = await updateShellProfile();
83
+
84
+ expect(result).toEqual({
85
+ success: true,
86
+ shellType: "zsh",
87
+ profilePath: "/home/user/.zshrc",
88
+ message: "Updated existing configuration in /home/user/.zshrc",
89
+ });
90
+
91
+ expect(writeFile).toHaveBeenCalledWith(
92
+ "/home/user/.zshrc",
93
+ expect.stringContaining("# >>> revenium-claude-code-metering >>>"),
94
+ "utf-8"
95
+ );
96
+ });
97
+
98
+ it("should create profile if it does not exist", async () => {
99
+ vi.mocked(detector.detectShell).mockReturnValue("fish");
100
+ vi.mocked(detector.getProfilePath).mockReturnValue("/home/user/.config/fish/config.fish");
101
+ vi.mocked(writer.getConfigFilePath).mockReturnValue("/home/user/.claude/revenium.env");
102
+ vi.mocked(detector.getSourceCommand).mockReturnValue('export (cat "/home/user/.claude/revenium.env" | grep -v "^#" | xargs -L 1)');
103
+ vi.mocked(existsSync).mockReturnValue(false);
104
+
105
+ const result = await updateShellProfile();
106
+
107
+ expect(result).toEqual({
108
+ success: true,
109
+ shellType: "fish",
110
+ profilePath: "/home/user/.config/fish/config.fish",
111
+ message: "Added configuration to /home/user/.config/fish/config.fish",
112
+ });
113
+
114
+ expect(writeFile).toHaveBeenCalledWith(
115
+ "/home/user/.config/fish/config.fish",
116
+ expect.stringContaining("# >>> revenium-claude-code-metering >>>"),
117
+ "utf-8"
118
+ );
119
+ });
120
+ });
121
+
122
+ describe("getManualInstructions", () => {
123
+ it("should return instructions for bash", () => {
124
+ vi.mocked(writer.getConfigFilePath).mockReturnValue("/home/user/.claude/revenium.env");
125
+ vi.mocked(detector.getSourceCommand).mockReturnValue('source "/home/user/.claude/revenium.env"');
126
+ vi.mocked(detector.getProfilePath).mockReturnValue("/home/user/.bashrc");
127
+
128
+ const instructions = getManualInstructions("bash");
129
+
130
+ expect(instructions).toContain("/home/user/.bashrc");
131
+ expect(instructions).toContain('source "/home/user/.claude/revenium.env"');
132
+ });
133
+
134
+ it("should return instructions for zsh", () => {
135
+ vi.mocked(writer.getConfigFilePath).mockReturnValue("/home/user/.claude/revenium.env");
136
+ vi.mocked(detector.getSourceCommand).mockReturnValue('source "/home/user/.claude/revenium.env"');
137
+ vi.mocked(detector.getProfilePath).mockReturnValue("/home/user/.zshrc");
138
+
139
+ const instructions = getManualInstructions("zsh");
140
+
141
+ expect(instructions).toContain("/home/user/.zshrc");
142
+ expect(instructions).toContain('source "/home/user/.claude/revenium.env"');
143
+ });
144
+ });
145
+ });
146
+