@marginfront/code-cost-clarity 0.5.4

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.
package/dist/cli.js ADDED
@@ -0,0 +1,2496 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli.ts
4
+ import { existsSync as existsSync3, rmSync as rmSync3, readFileSync as readFileSync3, realpathSync } from "fs";
5
+ import { fileURLToPath as fileURLToPath2 } from "url";
6
+ import readline from "readline";
7
+ import process3 from "process";
8
+
9
+ // src/connector.ts
10
+ import {
11
+ existsSync,
12
+ mkdirSync,
13
+ writeFileSync,
14
+ readFileSync,
15
+ openSync,
16
+ rmSync,
17
+ chmodSync,
18
+ copyFileSync
19
+ } from "fs";
20
+ import { spawn, spawnSync, execFileSync } from "child_process";
21
+ import { createHash } from "crypto";
22
+ import { homedir, tmpdir, platform, arch } from "os";
23
+ import { join, dirname } from "path";
24
+ import { fileURLToPath } from "url";
25
+ import process from "process";
26
+ var CONFIG_DIR = join(homedir(), ".marginfront-ccc");
27
+ var ENV_PATH = join(CONFIG_DIR, ".env");
28
+ var COLLECTOR_CONFIG_PATH = join(CONFIG_DIR, "otel-collector.yaml");
29
+ var COLLECTOR_BIN_PATH = join(CONFIG_DIR, "otelcol-contrib");
30
+ var LIVE_JSONL_PATH = join(CONFIG_DIR, "live-otlp.jsonl");
31
+ var COLLECTOR_PID_PATH = join(CONFIG_DIR, "collector.pid");
32
+ var COLLECTOR_LOG_PATH = join(CONFIG_DIR, "collector.log");
33
+ var FORWARDER_CURSOR_PATH = join(CONFIG_DIR, "forwarder.cursor");
34
+ var FORWARDER_QUEUE_PATH = join(CONFIG_DIR, "forwarder.queue.jsonl");
35
+ var FORWARDER_PID_PATH = join(CONFIG_DIR, "forwarder.pid");
36
+ var VERSION_PATTERN = /^v\d+\.\d+\.\d+$/;
37
+ function resolveOtelcolVersion(override = process.env.CCC_OTELCOL_VERSION) {
38
+ const DEFAULT_VERSION = "v0.154.0";
39
+ if (!override) return DEFAULT_VERSION;
40
+ if (!VERSION_PATTERN.test(override)) {
41
+ throw new Error(
42
+ `Invalid CCC_OTELCOL_VERSION "${override}".
43
+ What happened: you set the collector version override, but it isn't a valid version number.
44
+ Why this is strict: that value goes into a download URL and a filename, so a malformed one could point the installer somewhere unsafe. We refuse rather than guess.
45
+ Fix: use the form vMAJOR.MINOR.PATCH (for example CCC_OTELCOL_VERSION=v0.154.0), or unset it to use the built-in pinned version.`
46
+ );
47
+ }
48
+ return override;
49
+ }
50
+ var OTELCOL_VERSION = resolveOtelcolVersion();
51
+ var COLLECTOR_CHECKSUMS = {
52
+ "otelcol-contrib_0.154.0_darwin_amd64.tar.gz": "14f7f825a7ad7ee0799947ff70a42b9a5528a9c1411ab45f8fcc4caeb9346f7b",
53
+ "otelcol-contrib_0.154.0_darwin_arm64.tar.gz": "de3af70ef0b80af213911cc9ba8daf553348dd22ed1fb15db561d207fc2cb3d9",
54
+ "otelcol-contrib_0.154.0_linux_amd64.tar.gz": "f0fe6e7b1d81936d4e5a3aad7a678f3fc2f8ada2a9f8f37f37542813c12ed322",
55
+ "otelcol-contrib_0.154.0_linux_arm64.tar.gz": "52310bfcb5a952c072e8abff7f0f2940ee38cea36752a4a4e1a4e21c2088c3f9"
56
+ };
57
+ function verifyTarballChecksum(tarballPath, expectedSha256) {
58
+ const actual = createHash("sha256").update(readFileSync(tarballPath)).digest("hex").toLowerCase();
59
+ return actual === expectedSha256.toLowerCase();
60
+ }
61
+ function renderEnvFile() {
62
+ return `# ============================================================
63
+ # Claude Code -> MarginFront connector: environment settings
64
+ # ============================================================
65
+ # MAIN JOB of this file: hold your MARGINFRONT_API_KEY (below) so the background
66
+ # meter can read it - fill that in (init usually does it for you).
67
+ #
68
+ # As of v0.5.0 you do NOT need to 'source' this before running Claude Code:
69
+ # 'init' wires the telemetry settings into ~/.claude/settings.json for you, so
70
+ # every 'claude'/'codex' session (and the desktop apps) broadcast on their own.
71
+ # Sourcing this is only useful for the manual, foreground 'run' path.
72
+ #
73
+ # This file stays on your machine; it is never published or committed.
74
+ # ============================================================
75
+
76
+ # Turns on Claude Code's usage broadcasting. Leave as 1.
77
+ CLAUDE_CODE_ENABLE_TELEMETRY=1
78
+
79
+ # Send usage as OpenTelemetry metrics. Leave as otlp.
80
+ OTEL_METRICS_EXPORTER=otlp
81
+
82
+ # Send Claude Code's EVENTS (the per-tool-call records) too. Leave as otlp.
83
+ # Tokens come in as metrics (above); a paid tool call (Google Maps, Browserbase,
84
+ # a paid MCP server) only shows up on this EVENT stream - without this line Claude
85
+ # Code broadcasts no tool activity and tool spend can't be tracked (LOWAI-478).
86
+ OTEL_LOGS_EXPORTER=otlp
87
+
88
+ # Use HTTP/protobuf. Verified working; gRPC on 4317 silently failed in testing.
89
+ OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
90
+
91
+ # Where the local collector listens. Must match the collector YAML.
92
+ OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:4318
93
+
94
+ # How often Claude Code flushes usage, in milliseconds.
95
+ # 300000 = 5 minutes - batches usage so you get roughly one event per turn
96
+ # without a long wait. Raise to 600000 (10 min) to batch harder; lower to
97
+ # 60000 (1 min) or 5000 (5 s) for a more live drip.
98
+ OTEL_METRIC_EXPORT_INTERVAL=300000
99
+
100
+ # Tool-call costs (LOWAI-478) need NOTHING configured here. Every tool your agents
101
+ # fire (other than free built-ins like Read/Bash) is forwarded automatically;
102
+ # MarginFront's pricing catalog decides what's billable and at what rate. Add price
103
+ # rows for your paid tools in MarginFront - there's no client-side list to maintain.
104
+
105
+ # YOUR MarginFront SECRET key (mf_sk_*). Create or copy one at:
106
+ # app.marginfront.com -> Build -> API Keys -> "Create Key Pair"
107
+ # (https://app.marginfront.com/developer-zone/api-keys)
108
+ # Use the SECRET key (mf_sk_*) - a publishable key (mf_pk_*) is rejected on send.
109
+ # The forwarder reads this from the environment only; it is never hardcoded.
110
+ # Leave blank to run in no-identity preview mode (see the README).
111
+ MARGINFRONT_API_KEY=
112
+ `;
113
+ }
114
+ function renderCollectorYaml(jsonlPath = LIVE_JSONL_PATH) {
115
+ return `# OpenTelemetry Collector config - Claude Code -> MarginFront
116
+ # ---------------------------------------------------------------------------
117
+ # WHAT THIS DOES, plainly: it's the "catcher." Claude Code broadcasts its token
118
+ # usage here; this collector batches what it hears and writes it to a file,
119
+ # which the forwarder tails and turns into MarginFront usage rows.
120
+ # ---------------------------------------------------------------------------
121
+
122
+ receivers:
123
+ # The door Claude Code knocks on. It listens for OpenTelemetry (OTLP) on the
124
+ # two standard local ports. We point Claude Code at HTTP/protobuf on 4318 -
125
+ # gRPC on 4317 silently failed to connect from Claude Code 2.1.185 in testing,
126
+ # so 4318 is the one that actually works. Bound to localhost only.
127
+ otlp:
128
+ protocols:
129
+ grpc:
130
+ endpoint: 127.0.0.1:4317
131
+ http:
132
+ endpoint: 127.0.0.1:4318
133
+
134
+ processors:
135
+ # THE NO-DOUBLE-COUNT PROCESSOR. Claude Code emits CUMULATIVE counters (each
136
+ # export is the running total since the session started), and it ignores the
137
+ # "give me deltas" env var. Without this, the forwarder would record the
138
+ # running total every export - re-counting everything before it. This turns
139
+ # each cumulative total into the increment since the last export.
140
+ cumulativetodelta: {}
141
+
142
+ # Group what arrives into small batches and flush quickly, so the live view
143
+ # feels near-real-time rather than trickling one datapoint at a time.
144
+ batch:
145
+ timeout: 1s
146
+
147
+ exporters:
148
+ # Write each batch as one line of OTLP/JSON (JSON Lines). The forwarder
149
+ # watches this file and records each new line. Both pipelines below share this
150
+ # one exporter, so Claude Code's metrics and Codex's logs land in the SAME file
151
+ # (each as its own line) and the one forwarder reads both.
152
+ file:
153
+ path: ${jsonlPath}
154
+
155
+ service:
156
+ telemetry:
157
+ logs:
158
+ # The collector's OWN log verbosity (how chatty otelcol itself is). This is
159
+ # NOT a data pipeline - it does not catch Codex's usage logs. The pipeline
160
+ # that does is service.pipelines.logs below. Keep this quiet.
161
+ level: warn
162
+ pipelines:
163
+ # Claude Code source: token + cost COUNTERS arrive as metrics.
164
+ # Receive -> de-dupe (cumulative->delta) -> batch -> file.
165
+ metrics:
166
+ receivers: [otlp]
167
+ processors: [cumulativetodelta, batch]
168
+ exporters: [file]
169
+ # Codex source (LOWAI-463): Codex reports usage on the LOG stream, not as
170
+ # metrics, so it needs its own pipeline. No cumulativetodelta here - that's a
171
+ # METRICS processor and doesn't apply to logs; each Codex log record is one
172
+ # turn's usage on its own. Receive -> batch -> file (the same file).
173
+ logs:
174
+ receivers: [otlp]
175
+ processors: [batch]
176
+ exporters: [file]
177
+ `;
178
+ }
179
+ var CODEX_CONFIG_PATH = join(homedir(), ".codex", "config.toml");
180
+ var CODEX_CCC_BEGIN_MARKER = "# >>> MarginFront code-cost-clarity - auto-added; `npx @marginfront/code-cost-clarity uninstall` removes this block >>>";
181
+ var CODEX_CCC_END_MARKER = "# <<< MarginFront code-cost-clarity - end of auto-added block <<<";
182
+ var CODEX_OTEL_TOML_BLOCK = [
183
+ "[otel]",
184
+ 'exporter = { otlp-http = { endpoint = "http://127.0.0.1:4318/v1/logs", protocol = "binary" } }'
185
+ ].join("\n");
186
+ function renderCodexCccBlock() {
187
+ return [
188
+ CODEX_CCC_BEGIN_MARKER,
189
+ CODEX_OTEL_TOML_BLOCK,
190
+ CODEX_CCC_END_MARKER
191
+ ].join("\n");
192
+ }
193
+ function renderCodexConfigInstructions() {
194
+ return [
195
+ "Optional - also capture Codex (in addition to, or instead of, Claude Code):",
196
+ "",
197
+ " Codex reports its usage to the SAME local collector, so there's nothing",
198
+ " extra to install. Add this block to your ~/.codex/config.toml:",
199
+ "",
200
+ // Single-sourced from CODEX_OTEL_TOML_BLOCK above, indented for the printout,
201
+ // so the pasted block always matches what the auto-writer would write.
202
+ ...CODEX_OTEL_TOML_BLOCK.split("\n").map((line) => " " + line),
203
+ "",
204
+ " Then run Codex normally. `run` already watches both sources - Claude Code,",
205
+ " Codex, or both - from the one collector file; there's no extra flag.",
206
+ "",
207
+ " Note: exact [otel] key names can vary by Codex version, and whether your",
208
+ " engineer email shows up depends on how you sign in (org API key vs ChatGPT",
209
+ " login). If the email doesn't surface, usage still lands under the no-identity",
210
+ " placeholder. See the README's Codex section for the full details."
211
+ ].join("\n");
212
+ }
213
+ function lineDeclaresOtelTable(rawLine) {
214
+ const beforeComment = rawLine.split("#")[0].trim();
215
+ if (!beforeComment) return false;
216
+ let firstToken;
217
+ if (beforeComment.startsWith("[")) {
218
+ const inner = beforeComment.replace(/^\[+/, "").trim();
219
+ const path = inner.split("]")[0].trim();
220
+ firstToken = path.split(".")[0].trim();
221
+ } else {
222
+ firstToken = beforeComment.split(/[.=\s]/)[0].trim();
223
+ }
224
+ firstToken = firstToken.replace(/^["']|["']$/g, "");
225
+ return firstToken === "otel";
226
+ }
227
+ function codexConfigHasOtelTable(content) {
228
+ return content.split(/\r?\n/).some(lineDeclaresOtelTable);
229
+ }
230
+ function codexConfigHasCccBlock(configPath = CODEX_CONFIG_PATH) {
231
+ if (!existsSync(configPath)) return false;
232
+ try {
233
+ return readFileSync(configPath, "utf8").includes(CODEX_CCC_BEGIN_MARKER);
234
+ } catch {
235
+ return false;
236
+ }
237
+ }
238
+ function writeCodexOtelConfig({
239
+ configPath = CODEX_CONFIG_PATH,
240
+ log = console.log
241
+ } = {}) {
242
+ if (!existsSync(configPath)) {
243
+ mkdirSync(dirname(configPath), { recursive: true });
244
+ writeFileSync(configPath, renderCodexCccBlock() + "\n");
245
+ log(
246
+ `Created ${configPath} and wired Codex to the local collector for you.`
247
+ );
248
+ return { action: "created", backupPath: null };
249
+ }
250
+ const content = readFileSync(configPath, "utf8");
251
+ if (content.includes(CODEX_CCC_BEGIN_MARKER)) {
252
+ log(`Codex is already wired up in ${configPath} - nothing to do.`);
253
+ return { action: "skipped-existing", backupPath: null };
254
+ }
255
+ if (codexConfigHasOtelTable(content)) {
256
+ log(
257
+ `Left your existing [otel] settings in ${configPath} untouched - add the MarginFront block by hand if you want Codex captured too.`
258
+ );
259
+ return { action: "skipped-existing", backupPath: null };
260
+ }
261
+ const backupPath = `${configPath}.ccc-bak`;
262
+ copyFileSync(configPath, backupPath);
263
+ const separator = content.length > 0 ? "\n" : "";
264
+ writeFileSync(configPath, content + separator + renderCodexCccBlock() + "\n");
265
+ log(
266
+ `Added the MarginFront telemetry block to ${configPath} (backup at ${backupPath}).`
267
+ );
268
+ return { action: "appended", backupPath };
269
+ }
270
+ function removeCodexOtelConfig({
271
+ configPath = CODEX_CONFIG_PATH,
272
+ log = console.log
273
+ } = {}) {
274
+ if (!existsSync(configPath)) {
275
+ log(
276
+ `Nothing to undo: ${configPath} doesn't exist (Codex was never wired up here).`
277
+ );
278
+ return { action: "missing", backupPath: null };
279
+ }
280
+ const content = readFileSync(configPath, "utf8");
281
+ const beginIdx = content.indexOf(CODEX_CCC_BEGIN_MARKER);
282
+ const endIdx = content.indexOf(CODEX_CCC_END_MARKER);
283
+ if (beginIdx === -1 || endIdx === -1 || endIdx < beginIdx) {
284
+ log(
285
+ `Nothing to undo: ${configPath} has no MarginFront block (left every other setting untouched).`
286
+ );
287
+ return { action: "no-marker", backupPath: null };
288
+ }
289
+ let cutStart = beginIdx;
290
+ let cutEnd = endIdx + CODEX_CCC_END_MARKER.length;
291
+ if (content[cutEnd] === "\n") cutEnd += 1;
292
+ if (content[cutStart - 1] === "\n") {
293
+ cutStart -= 1;
294
+ }
295
+ const backupPath = existsSync(`${configPath}.ccc-bak`) ? `${configPath}.ccc-bak` : null;
296
+ writeFileSync(configPath, content.slice(0, cutStart) + content.slice(cutEnd));
297
+ log(
298
+ `Removed the MarginFront block from ${configPath}` + (backupPath ? ` (your original is preserved at ${backupPath}).` : ".")
299
+ );
300
+ return { action: "removed", backupPath };
301
+ }
302
+ function resolveCollectorAsset(nodePlatform = platform(), nodeArch = arch(), version = OTELCOL_VERSION) {
303
+ const osMap = { darwin: "darwin", linux: "linux" };
304
+ const archMap = { arm64: "arm64", x64: "amd64" };
305
+ const os = osMap[nodePlatform];
306
+ const cpu = archMap[nodeArch];
307
+ if (!os) {
308
+ throw new Error(
309
+ `Unsupported operating system "${nodePlatform}". The bundled collector supports macOS (darwin) and Linux. On Windows, run this under WSL.`
310
+ );
311
+ }
312
+ if (!cpu) {
313
+ throw new Error(
314
+ `Unsupported CPU architecture "${nodeArch}". The bundled collector supports arm64 and x64 (amd64).`
315
+ );
316
+ }
317
+ const versionNoV = version.replace(/^v/, "");
318
+ const asset = `otelcol-contrib_${versionNoV}_${os}_${cpu}.tar.gz`;
319
+ const url = `https://github.com/open-telemetry/opentelemetry-collector-releases/releases/download/${version}/${asset}`;
320
+ return { os, cpu, asset, url, version };
321
+ }
322
+ function ensureCollectorBinary({
323
+ force = false,
324
+ log = console.log
325
+ } = {}) {
326
+ if (existsSync(COLLECTOR_BIN_PATH) && !force) {
327
+ log(
328
+ `Collector already present at ${COLLECTOR_BIN_PATH} - skipping download.`
329
+ );
330
+ return COLLECTOR_BIN_PATH;
331
+ }
332
+ const { asset, url, version } = resolveCollectorAsset();
333
+ const tgzPath = join(tmpdir(), asset);
334
+ log(
335
+ `Downloading the collector (${asset}, ~360 MB) - this can take a minute\u2026`
336
+ );
337
+ try {
338
+ execFileSync("curl", ["-fSL", "-o", tgzPath, url], { stdio: "inherit" });
339
+ } catch {
340
+ throw new Error(
341
+ `Could not download the collector from ${url}
342
+ What happened: the download failed (network issue, or version ${version} has no asset for your platform).
343
+ Fix: check your internet connection, or pin a different version with CCC_OTELCOL_VERSION=v0.155.0 (see the OpenTelemetry collector releases page).`
344
+ );
345
+ }
346
+ const expectedSha256 = COLLECTOR_CHECKSUMS[asset];
347
+ if (!expectedSha256) {
348
+ throw new Error(
349
+ `Refusing to run an unverified collector.
350
+ What happened: there is no pinned, known-good fingerprint on file for "${asset}" (version ${version}).
351
+ Why: this tool refuses to make ANY downloaded program executable unless we can first confirm it's exactly the release we trust.
352
+ Fix: install the built-in pinned version (unset CCC_OTELCOL_VERSION), or add this release's official signed SHA-256 to the package's pinned checksums before installing it. The download is left at ${tgzPath} so you can verify it yourself.`
353
+ );
354
+ }
355
+ log("Verifying the downloaded collector's fingerprint\u2026");
356
+ if (!verifyTarballChecksum(tgzPath, expectedSha256)) {
357
+ try {
358
+ rmSync(tgzPath, { force: true });
359
+ } catch {
360
+ }
361
+ throw new Error(
362
+ `Collector integrity check FAILED - refusing to install.
363
+ What happened: the collector we downloaded for "${asset}" does NOT match its known-good fingerprint, so we deleted it and stopped.
364
+ Why this probably happened: the file was altered between GitHub and your machine - most often a corporate TLS-inspecting proxy rewriting the download, a corrupted transfer, or (worst case) a tampered release.
365
+ Fix: re-run the install on a network without a man-in-the-middle proxy. If it keeps failing on a clean network, do NOT bypass this - report it, because a persistent mismatch can mean the download is being tampered with.`
366
+ );
367
+ }
368
+ log("Fingerprint verified - the collector is exactly the trusted release.");
369
+ log(`Unpacking the collector into ${CONFIG_DIR}\u2026`);
370
+ try {
371
+ execFileSync("tar", ["xzf", tgzPath, "-C", CONFIG_DIR, "otelcol-contrib"], {
372
+ stdio: "inherit"
373
+ });
374
+ } catch {
375
+ throw new Error(
376
+ `Could not unpack ${tgzPath}.
377
+ What happened: the downloaded archive didn't contain the expected otelcol-contrib binary, or tar isn't available.
378
+ Fix: delete the file above and re-run \`npx @marginfront/code-cost-clarity init\`.`
379
+ );
380
+ } finally {
381
+ try {
382
+ rmSync(tgzPath, { force: true });
383
+ } catch {
384
+ }
385
+ }
386
+ try {
387
+ execFileSync("chmod", ["755", COLLECTOR_BIN_PATH]);
388
+ } catch {
389
+ }
390
+ log(`Collector installed at ${COLLECTOR_BIN_PATH}.`);
391
+ return COLLECTOR_BIN_PATH;
392
+ }
393
+ function ensureConfigFiles({
394
+ log = console.log
395
+ } = {}) {
396
+ if (!existsSync(CONFIG_DIR)) {
397
+ mkdirSync(CONFIG_DIR, { recursive: true });
398
+ log(`Created ${CONFIG_DIR}.`);
399
+ }
400
+ if (existsSync(ENV_PATH)) {
401
+ log(`Kept your existing settings file at ${ENV_PATH} (API key preserved).`);
402
+ } else {
403
+ writeFileSync(ENV_PATH, renderEnvFile(), { mode: 384 });
404
+ log(`Wrote settings template to ${ENV_PATH} - add your API key there.`);
405
+ }
406
+ writeFileSync(COLLECTOR_CONFIG_PATH, renderCollectorYaml());
407
+ log(`Wrote collector config to ${COLLECTOR_CONFIG_PATH}.`);
408
+ }
409
+ function loadEnvFile(path = ENV_PATH) {
410
+ if (!existsSync(path)) return {};
411
+ const loaded = {};
412
+ let text;
413
+ try {
414
+ text = readFileSync(path, "utf8");
415
+ } catch {
416
+ return {};
417
+ }
418
+ for (const rawLine of text.split("\n")) {
419
+ const line = rawLine.trim();
420
+ if (!line || line.startsWith("#")) continue;
421
+ const eq = line.indexOf("=");
422
+ if (eq === -1) continue;
423
+ const key = line.slice(0, eq).trim();
424
+ let value = line.slice(eq + 1).trim();
425
+ if (value.length >= 2 && (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'"))) {
426
+ value = value.slice(1, -1);
427
+ }
428
+ if (!key) continue;
429
+ loaded[key] = value;
430
+ if (process.env[key] === void 0) {
431
+ process.env[key] = value;
432
+ }
433
+ }
434
+ return loaded;
435
+ }
436
+ function writeApiKeyToEnv(value, path = ENV_PATH) {
437
+ const current = existsSync(path) ? readFileSync(path, "utf8") : renderEnvFile();
438
+ const keyLine = /^MARGINFRONT_API_KEY=.*$/m;
439
+ let next;
440
+ if (keyLine.test(current)) {
441
+ next = current.replace(keyLine, () => `MARGINFRONT_API_KEY=${value}`);
442
+ } else {
443
+ const sep = current.endsWith("\n") || current === "" ? "" : "\n";
444
+ next = `${current}${sep}MARGINFRONT_API_KEY=${value}
445
+ `;
446
+ }
447
+ writeFileSync(path, next, { mode: 384 });
448
+ chmodSync(path, 384);
449
+ }
450
+ var CLAUDE_SETTINGS_PATH = join(homedir(), ".claude", "settings.json");
451
+ var CLAUDE_SETTINGS_OWNERSHIP_PATH = join(
452
+ CONFIG_DIR,
453
+ "claude-settings-owned.json"
454
+ );
455
+ var CCC_OWNED_TELEMETRY_KEYS = [
456
+ "CLAUDE_CODE_ENABLE_TELEMETRY",
457
+ "OTEL_METRICS_EXPORTER",
458
+ "OTEL_LOGS_EXPORTER",
459
+ "OTEL_EXPORTER_OTLP_PROTOCOL",
460
+ "OTEL_EXPORTER_OTLP_ENDPOINT",
461
+ "OTEL_METRIC_EXPORT_INTERVAL"
462
+ ];
463
+ var CCC_TELEMETRY_VALUES = {
464
+ // Turns on Claude Code's usage broadcasting.
465
+ CLAUDE_CODE_ENABLE_TELEMETRY: "1",
466
+ // Send token usage as OpenTelemetry metrics.
467
+ OTEL_METRICS_EXPORTER: "otlp",
468
+ // Send the per-tool-call EVENT stream too (needed for tool-call line items).
469
+ OTEL_LOGS_EXPORTER: "otlp",
470
+ // HTTP/protobuf - verified working; gRPC on 4317 silently failed in testing.
471
+ OTEL_EXPORTER_OTLP_PROTOCOL: "http/protobuf",
472
+ // Point Claude Code at the local collector (must match the collector YAML).
473
+ OTEL_EXPORTER_OTLP_ENDPOINT: "http://127.0.0.1:4318",
474
+ // Flush usage every 5 minutes (batched ~one event per turn).
475
+ OTEL_METRIC_EXPORT_INTERVAL: "300000"
476
+ };
477
+ function isPlainObject(value) {
478
+ return typeof value === "object" && value !== null && !Array.isArray(value);
479
+ }
480
+ function backupClaudeSettings(settingsPath) {
481
+ const backupPath = `${settingsPath}.ccc-bak`;
482
+ copyFileSync(settingsPath, backupPath);
483
+ return backupPath;
484
+ }
485
+ function readClaudeSettingsOwnership(ownershipPath) {
486
+ if (!existsSync(ownershipPath)) return null;
487
+ try {
488
+ const parsed = JSON.parse(readFileSync(ownershipPath, "utf8"));
489
+ if (!isPlainObject(parsed) || !isPlainObject(parsed.ownedKeys)) {
490
+ return null;
491
+ }
492
+ const out = {};
493
+ for (const [key, value] of Object.entries(parsed.ownedKeys)) {
494
+ out[key] = String(value);
495
+ }
496
+ return out;
497
+ } catch {
498
+ return null;
499
+ }
500
+ }
501
+ function writeClaudeSettingsOwnership(ownershipPath, ownedKeys) {
502
+ mkdirSync(dirname(ownershipPath), { recursive: true });
503
+ const payload = {
504
+ _comment: "MarginFront code-cost-clarity wrote these telemetry keys into your Claude Code settings.json. Uninstall removes ONLY the keys below, and only if their value still matches. Safe to delete by hand if you've already cleaned up settings.json yourself.",
505
+ ownedKeys
506
+ };
507
+ writeFileSync(ownershipPath, JSON.stringify(payload, null, 2) + "\n");
508
+ }
509
+ function writeClaudeTelemetrySettings(vars, {
510
+ settingsPath = CLAUDE_SETTINGS_PATH,
511
+ ownershipPath = CLAUDE_SETTINGS_OWNERSHIP_PATH,
512
+ log = console.log
513
+ } = {}) {
514
+ let settings = {};
515
+ const fileExists = existsSync(settingsPath);
516
+ if (fileExists) {
517
+ let parsed;
518
+ try {
519
+ parsed = JSON.parse(readFileSync(settingsPath, "utf8"));
520
+ } catch {
521
+ const backupPath2 = backupClaudeSettings(settingsPath);
522
+ throw new Error(
523
+ `Could not update ${settingsPath}.
524
+ What happened: your Claude Code settings file isn't valid JSON, so we stopped instead of risking your settings.
525
+ What we did: saved an untouched copy at ${backupPath2}.
526
+ Fix: open the file and repair the JSON (a common cause is a trailing comma or a missing quote), then run init again. We will not modify a file we can't read.`
527
+ );
528
+ }
529
+ if (!isPlainObject(parsed)) {
530
+ const backupPath2 = backupClaudeSettings(settingsPath);
531
+ throw new Error(
532
+ `Could not update ${settingsPath}.
533
+ What happened: the top level of your Claude Code settings file isn't a JSON object (it looks like an array or a single value), so there's nowhere safe to add the telemetry settings.
534
+ What we did: saved an untouched copy at ${backupPath2}.
535
+ Fix: make the file a normal JSON object like { "env": { ... } }, then run init again.`
536
+ );
537
+ }
538
+ settings = parsed;
539
+ }
540
+ if ("env" in settings && !isPlainObject(settings.env)) {
541
+ const backupPath2 = backupClaudeSettings(settingsPath);
542
+ throw new Error(
543
+ `Could not update ${settingsPath}.
544
+ What happened: your settings file has an "env" entry that isn't a block of key/value settings (it's null, a list, or a single value), so we stopped instead of overwriting it.
545
+ What we did: saved an untouched copy at ${backupPath2}.
546
+ Fix: change "env" to a normal object like { "KEY": "value" } (or remove it), then run init again.`
547
+ );
548
+ }
549
+ const hadEnv = isPlainObject(settings.env);
550
+ const env = hadEnv ? settings.env : {};
551
+ const priorOwned = readClaudeSettingsOwnership(ownershipPath) ?? {};
552
+ const ownedKeys = {};
553
+ const written = [];
554
+ const skipped = [];
555
+ let mutated = false;
556
+ for (const key of CCC_OWNED_TELEMETRY_KEYS) {
557
+ if (!(key in vars)) continue;
558
+ const canonical = String(vars[key]);
559
+ const current = env[key];
560
+ if (current === void 0) {
561
+ env[key] = canonical;
562
+ ownedKeys[key] = canonical;
563
+ written.push(key);
564
+ mutated = true;
565
+ } else if (current === canonical) {
566
+ if (priorOwned[key] === canonical) {
567
+ ownedKeys[key] = canonical;
568
+ written.push(key);
569
+ } else {
570
+ skipped.push(key);
571
+ }
572
+ } else {
573
+ skipped.push(key);
574
+ }
575
+ }
576
+ let backupPath = null;
577
+ if (mutated) {
578
+ if (!hadEnv) settings.env = env;
579
+ if (fileExists) backupPath = backupClaudeSettings(settingsPath);
580
+ mkdirSync(dirname(settingsPath), { recursive: true });
581
+ writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n");
582
+ log(
583
+ `Wrote ${written.length} telemetry setting(s) into ${settingsPath}` + (backupPath ? ` (backup at ${backupPath}).` : ".")
584
+ );
585
+ } else {
586
+ log(`No changes needed in ${settingsPath} - telemetry already in place.`);
587
+ }
588
+ writeClaudeSettingsOwnership(ownershipPath, ownedKeys);
589
+ if (skipped.length) {
590
+ log(
591
+ `Left ${skipped.length} setting(s) you already had in place untouched: ` + skipped.join(", ")
592
+ );
593
+ }
594
+ return { written, skipped, backupPath };
595
+ }
596
+ function removeClaudeTelemetrySettings({
597
+ settingsPath = CLAUDE_SETTINGS_PATH,
598
+ ownershipPath = CLAUDE_SETTINGS_OWNERSHIP_PATH,
599
+ log = console.log
600
+ } = {}) {
601
+ const removed = [];
602
+ const kept = [];
603
+ const owned = readClaudeSettingsOwnership(ownershipPath);
604
+ if (owned === null) {
605
+ log(
606
+ `Nothing to undo: no MarginFront ownership record found at ${ownershipPath} (either it was never written, or it's already been removed).`
607
+ );
608
+ return { removed, kept, backupPath: null };
609
+ }
610
+ if (!existsSync(settingsPath)) {
611
+ log(
612
+ `Nothing to undo: ${settingsPath} doesn't exist. Removing the stale ownership record.`
613
+ );
614
+ rmSync(ownershipPath, { force: true });
615
+ return { removed, kept, backupPath: null };
616
+ }
617
+ let parsed;
618
+ try {
619
+ parsed = JSON.parse(readFileSync(settingsPath, "utf8"));
620
+ } catch {
621
+ log(
622
+ `Could not undo cleanly: ${settingsPath} isn't valid JSON right now, so we left it untouched. Fix the JSON and re-run uninstall, or remove the telemetry keys by hand.`
623
+ );
624
+ return { removed, kept, backupPath: null };
625
+ }
626
+ if (!isPlainObject(parsed)) {
627
+ log(
628
+ `Could not undo cleanly: ${settingsPath} isn't a JSON object, so we left it untouched.`
629
+ );
630
+ return { removed, kept, backupPath: null };
631
+ }
632
+ const settings = parsed;
633
+ const env = isPlainObject(settings.env) ? settings.env : null;
634
+ let mutated = false;
635
+ for (const [key, canonical] of Object.entries(owned)) {
636
+ if (env && env[key] === canonical) {
637
+ delete env[key];
638
+ removed.push(key);
639
+ mutated = true;
640
+ } else {
641
+ kept.push(key);
642
+ }
643
+ }
644
+ if (env && Object.keys(env).length === 0) {
645
+ delete settings.env;
646
+ mutated = true;
647
+ }
648
+ let backupPath = null;
649
+ if (mutated) {
650
+ backupPath = backupClaudeSettings(settingsPath);
651
+ writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n");
652
+ log(
653
+ `Removed ${removed.length} MarginFront telemetry setting(s) from ${settingsPath} (backup at ${backupPath}).`
654
+ );
655
+ } else {
656
+ log(
657
+ `Nothing to remove from ${settingsPath} - the telemetry keys were already gone or you'd changed them (left untouched).`
658
+ );
659
+ }
660
+ rmSync(ownershipPath, { force: true });
661
+ return { removed, kept, backupPath };
662
+ }
663
+ function isPidAlive(pid) {
664
+ if (!pid || !Number.isInteger(pid)) return false;
665
+ try {
666
+ process.kill(pid, 0);
667
+ return true;
668
+ } catch (err) {
669
+ return err?.code === "EPERM";
670
+ }
671
+ }
672
+ function readCollectorPid() {
673
+ if (!existsSync(COLLECTOR_PID_PATH)) return null;
674
+ try {
675
+ const pid = parseInt(readFileSync(COLLECTOR_PID_PATH, "utf8").trim(), 10);
676
+ return Number.isInteger(pid) ? pid : null;
677
+ } catch {
678
+ return null;
679
+ }
680
+ }
681
+ function startCollector({ log = console.log } = {}) {
682
+ const existing = readCollectorPid();
683
+ if (isPidAlive(existing)) {
684
+ log(`Collector already running (pid ${existing}).`);
685
+ return { pid: existing, startedByUs: false };
686
+ }
687
+ if (!existsSync(COLLECTOR_BIN_PATH)) {
688
+ throw new Error(
689
+ "Collector binary is missing. Run `npx @marginfront/code-cost-clarity init` first."
690
+ );
691
+ }
692
+ const logFd = openSync(COLLECTOR_LOG_PATH, "a");
693
+ const child = spawn(COLLECTOR_BIN_PATH, ["--config", COLLECTOR_CONFIG_PATH], {
694
+ cwd: CONFIG_DIR,
695
+ detached: true,
696
+ stdio: ["ignore", logFd, logFd]
697
+ });
698
+ child.unref();
699
+ writeFileSync(COLLECTOR_PID_PATH, String(child.pid));
700
+ log(`Started collector (pid ${child.pid}); logs at ${COLLECTOR_LOG_PATH}.`);
701
+ return { pid: child.pid, startedByUs: true };
702
+ }
703
+ function stopCollector({
704
+ log = console.log
705
+ } = {}) {
706
+ const pid = readCollectorPid();
707
+ if (!isPidAlive(pid)) {
708
+ if (existsSync(COLLECTOR_PID_PATH))
709
+ rmSync(COLLECTOR_PID_PATH, { force: true });
710
+ log("No running collector found.");
711
+ return false;
712
+ }
713
+ try {
714
+ process.kill(pid);
715
+ log(`Stopped collector (pid ${pid}).`);
716
+ } catch (err) {
717
+ log(`Could not stop collector (pid ${pid}): ${err.message}`);
718
+ }
719
+ rmSync(COLLECTOR_PID_PATH, { force: true });
720
+ return true;
721
+ }
722
+ var PLIST_LABEL = "ai.marginfront.ccc";
723
+ var PLIST_PATH = join(
724
+ homedir(),
725
+ "Library",
726
+ "LaunchAgents",
727
+ PLIST_LABEL + ".plist"
728
+ );
729
+ var DAEMON_CLI_PATH = join(CONFIG_DIR, "cli.js");
730
+ var DAEMON_STDOUT_LOG_PATH = join(CONFIG_DIR, "forwarder.out.log");
731
+ var DAEMON_STDERR_LOG_PATH = join(CONFIG_DIR, "forwarder.err.log");
732
+ function defaultLaunchctlRunner(args) {
733
+ const result = spawnSync("launchctl", args, { encoding: "utf8" });
734
+ return {
735
+ // A spawn error (e.g. launchctl missing on a non-mac) leaves status null; we
736
+ // treat that as a failure so callers don't read it as "succeeded."
737
+ status: typeof result.status === "number" ? result.status : 1,
738
+ stdout: result.stdout ?? "",
739
+ stderr: result.stderr ?? ""
740
+ };
741
+ }
742
+ function setDesktopGuiEnv(runLaunchctl = defaultLaunchctlRunner) {
743
+ for (const key of CCC_OWNED_TELEMETRY_KEYS) {
744
+ runLaunchctl(["setenv", key, CCC_TELEMETRY_VALUES[key]]);
745
+ }
746
+ }
747
+ function unsetDesktopGuiEnv(runLaunchctl = defaultLaunchctlRunner) {
748
+ for (const key of CCC_OWNED_TELEMETRY_KEYS) {
749
+ runLaunchctl(["unsetenv", key]);
750
+ }
751
+ }
752
+ function currentUid() {
753
+ return typeof process.getuid === "function" ? process.getuid() : 0;
754
+ }
755
+ function escapeXml(value) {
756
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
757
+ }
758
+ function renderDaemonPlist({
759
+ nodePath,
760
+ cliPath
761
+ }) {
762
+ return `<?xml version="1.0" encoding="UTF-8"?>
763
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
764
+ <plist version="1.0">
765
+ <dict>
766
+ <key>Label</key>
767
+ <string>${escapeXml(PLIST_LABEL)}</string>
768
+ <key>ProgramArguments</key>
769
+ <array>
770
+ <string>${escapeXml(nodePath)}</string>
771
+ <string>${escapeXml(cliPath)}</string>
772
+ <string>run</string>
773
+ </array>
774
+ <key>WorkingDirectory</key>
775
+ <string>${escapeXml(CONFIG_DIR)}</string>
776
+ <key>RunAtLoad</key>
777
+ <true/>
778
+ <key>KeepAlive</key>
779
+ <true/>
780
+ <key>StandardOutPath</key>
781
+ <string>${escapeXml(DAEMON_STDOUT_LOG_PATH)}</string>
782
+ <key>StandardErrorPath</key>
783
+ <string>${escapeXml(DAEMON_STDERR_LOG_PATH)}</string>
784
+ </dict>
785
+ </plist>
786
+ `;
787
+ }
788
+ function installDaemon(options = {}) {
789
+ const runLaunchctl = options.runLaunchctl ?? defaultLaunchctlRunner;
790
+ const log = options.log ?? console.log;
791
+ const nodePath = options.nodePath ?? process.execPath;
792
+ const sourceCliPath = options.sourceCliPath ?? fileURLToPath(import.meta.url);
793
+ const daemonCliPath = options.daemonCliPath ?? DAEMON_CLI_PATH;
794
+ const plistPath = options.plistPath ?? PLIST_PATH;
795
+ const uid = options.uid ?? currentUid();
796
+ if (nodePath === "npx" || nodePath.endsWith("/npx")) {
797
+ throw new Error(
798
+ "Refusing to install the background daemon with 'npx' as its program.\nWhat happened: the daemon must be pinned to the real node binary (process.execPath), because npx runs from a temporary cache that gets cleaned up - a daemon pointed at npx would crash-loop once that cache is gone.\nFix: this is an internal error; the daemon should always be installed with process.execPath. Re-run `start` from the npx-installed CLI."
799
+ );
800
+ }
801
+ mkdirSync(dirname(daemonCliPath), { recursive: true });
802
+ copyFileSync(sourceCliPath, daemonCliPath);
803
+ log(
804
+ `Copied the meter program to ${daemonCliPath} (a stable copy that survives npx cleanup).`
805
+ );
806
+ mkdirSync(dirname(plistPath), { recursive: true });
807
+ writeFileSync(
808
+ plistPath,
809
+ renderDaemonPlist({ nodePath, cliPath: daemonCliPath })
810
+ );
811
+ log(`Wrote the background-job definition to ${plistPath}.`);
812
+ const domainTarget = `gui/${uid}`;
813
+ const serviceTarget = `gui/${uid}/${PLIST_LABEL}`;
814
+ const alreadyLoaded = runLaunchctl(["print", serviceTarget]).status === 0;
815
+ let reloaded = false;
816
+ if (alreadyLoaded) {
817
+ log(
818
+ "A background meter is already loaded - reloading it with the new settings."
819
+ );
820
+ runLaunchctl(["bootout", serviceTarget]);
821
+ reloaded = true;
822
+ }
823
+ const bootstrap = runLaunchctl(["bootstrap", domainTarget, plistPath]);
824
+ if (bootstrap.status !== 0) {
825
+ throw new Error(
826
+ `Could not start the background meter (launchctl bootstrap failed).
827
+ What happened: macOS refused to load the job at ${plistPath}.
828
+ ` + (bootstrap.stderr ? `launchctl said: ${bootstrap.stderr.trim()}
829
+ ` : "") + `Fix: run \`npx @marginfront/code-cost-clarity status\` to see the current state, or \`stop\` then \`start\` again. If it keeps failing, confirm you're on macOS and that ${plistPath} looks like valid XML.`
830
+ );
831
+ }
832
+ log("The background meter is loaded and will start at login from now on.");
833
+ return { reloaded, plistPath, daemonCliPath };
834
+ }
835
+ function uninstallDaemon(options = {}) {
836
+ const runLaunchctl = options.runLaunchctl ?? defaultLaunchctlRunner;
837
+ const log = options.log ?? console.log;
838
+ const daemonCliPath = options.daemonCliPath ?? DAEMON_CLI_PATH;
839
+ const plistPath = options.plistPath ?? PLIST_PATH;
840
+ const uid = options.uid ?? currentUid();
841
+ runLaunchctl(["bootout", `gui/${uid}/${PLIST_LABEL}`]);
842
+ if (existsSync(plistPath)) {
843
+ rmSync(plistPath, { force: true });
844
+ log(`Removed the background-job definition at ${plistPath}.`);
845
+ }
846
+ if (existsSync(daemonCliPath)) {
847
+ rmSync(daemonCliPath, { force: true });
848
+ log(`Removed the meter program copy at ${daemonCliPath}.`);
849
+ }
850
+ }
851
+ function bootoutDaemon(options = {}) {
852
+ const runLaunchctl = options.runLaunchctl ?? defaultLaunchctlRunner;
853
+ const uid = options.uid ?? currentUid();
854
+ const result = runLaunchctl(["bootout", `gui/${uid}/${PLIST_LABEL}`]);
855
+ return result.status === 0;
856
+ }
857
+ function parseDaemonPrint(result) {
858
+ if (result.status !== 0) {
859
+ return { loaded: false, running: false, pid: null };
860
+ }
861
+ const out = result.stdout;
862
+ const running = /\bstate\s*=\s*running\b/.test(out);
863
+ const pidMatch = out.match(/\bpid\s*=\s*(\d+)/);
864
+ const pid = pidMatch ? parseInt(pidMatch[1], 10) : null;
865
+ return { loaded: true, running, pid };
866
+ }
867
+ function queryDaemonStatus(options = {}) {
868
+ const runLaunchctl = options.runLaunchctl ?? defaultLaunchctlRunner;
869
+ const uid = options.uid ?? currentUid();
870
+ return parseDaemonPrint(runLaunchctl(["print", `gui/${uid}/${PLIST_LABEL}`]));
871
+ }
872
+ function readLastLines(path, maxLines) {
873
+ if (!existsSync(path)) return [];
874
+ let text;
875
+ try {
876
+ text = readFileSync(path, "utf8");
877
+ } catch {
878
+ return [];
879
+ }
880
+ const lines = text.split("\n");
881
+ if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
882
+ return lines.slice(-maxLines);
883
+ }
884
+ var ZSHRC_PATH = join(homedir(), ".zshrc");
885
+ function lineSourcesCccEnv(rawLine) {
886
+ const trimmed = rawLine.trim();
887
+ if (!trimmed || trimmed.startsWith("#")) return false;
888
+ if (!/^(source|\.)\s/.test(trimmed)) return false;
889
+ return trimmed.includes(".marginfront-ccc/.env");
890
+ }
891
+ function findCccZshrcSourceLines(zshrcPath = ZSHRC_PATH) {
892
+ if (!existsSync(zshrcPath)) return { found: false, matchedLines: [] };
893
+ let text;
894
+ try {
895
+ text = readFileSync(zshrcPath, "utf8");
896
+ } catch {
897
+ return { found: false, matchedLines: [] };
898
+ }
899
+ const matchedLines = text.split("\n").filter(lineSourcesCccEnv).map((line) => line.trim());
900
+ return { found: matchedLines.length > 0, matchedLines };
901
+ }
902
+ function removeCccZshrcSourceLine({
903
+ zshrcPath = ZSHRC_PATH,
904
+ log = console.log
905
+ } = {}) {
906
+ if (!existsSync(zshrcPath)) {
907
+ log(`Nothing to remove: ${zshrcPath} doesn't exist.`);
908
+ return { removed: [], backupPath: null };
909
+ }
910
+ let text;
911
+ try {
912
+ text = readFileSync(zshrcPath, "utf8");
913
+ } catch {
914
+ log(
915
+ `Could not read ${zshrcPath}, so I left it untouched - remove the \`source ~/.marginfront-ccc/.env\` line by hand if you like.`
916
+ );
917
+ return { removed: [], backupPath: null };
918
+ }
919
+ const removed = [];
920
+ const kept = [];
921
+ for (const line of text.split("\n")) {
922
+ if (lineSourcesCccEnv(line)) removed.push(line.trim());
923
+ else kept.push(line);
924
+ }
925
+ if (removed.length === 0) {
926
+ log(`Nothing to remove in ${zshrcPath}: no MarginFront source line found.`);
927
+ return { removed: [], backupPath: null };
928
+ }
929
+ const backupPath = `${zshrcPath}.ccc-bak`;
930
+ copyFileSync(zshrcPath, backupPath);
931
+ writeFileSync(zshrcPath, kept.join("\n"));
932
+ log(
933
+ `Removed the redundant 'source ~/.marginfront-ccc/.env' line from ${zshrcPath} (backup at ${backupPath}).`
934
+ );
935
+ return { removed, backupPath };
936
+ }
937
+
938
+ // src/forwarder.ts
939
+ import {
940
+ readFileSync as readFileSync2,
941
+ existsSync as existsSync2,
942
+ statSync,
943
+ openSync as openSync2,
944
+ readSync,
945
+ closeSync,
946
+ writeFileSync as writeFileSync2,
947
+ appendFileSync,
948
+ rmSync as rmSync2
949
+ } from "fs";
950
+ import { createHash as createHash2 } from "crypto";
951
+ import { createRequire } from "module";
952
+ import process2 from "process";
953
+ var _cccRequire = createRequire(import.meta.url);
954
+ function resolveCccPackageVersion() {
955
+ if ("0.5.4".length > 0) {
956
+ return "0.5.4";
957
+ }
958
+ try {
959
+ const pkg = _cccRequire("../package.json");
960
+ if (typeof pkg.version === "string" && pkg.version.length > 0) {
961
+ return pkg.version;
962
+ }
963
+ } catch {
964
+ }
965
+ return "0.0.0-unknown";
966
+ }
967
+ var CCC_PACKAGE_VERSION = resolveCccPackageVersion();
968
+ var DEFAULT_INGEST_URL = "https://api.marginfront.com/v1/sdk/usage/record";
969
+ function resolveIngestUrl(override = process2.env.MARGINFRONT_INGEST_URL) {
970
+ if (!override) return DEFAULT_INGEST_URL;
971
+ try {
972
+ const url = new URL(override);
973
+ const host = url.hostname;
974
+ const onMarginFront = host === "marginfront.com" || host.endsWith(".marginfront.com");
975
+ if (url.protocol === "https:" && onMarginFront) return override;
976
+ } catch {
977
+ }
978
+ console.error(
979
+ "Ignoring MARGINFRONT_INGEST_URL - only an https://*.marginfront.com URL is allowed; sending to production instead."
980
+ );
981
+ return DEFAULT_INGEST_URL;
982
+ }
983
+ var MARGINFRONT_INGEST_URL = resolveIngestUrl();
984
+ var AGENT_CODE = "claude-code";
985
+ var SIGNAL_NAME = "claude-code-turn";
986
+ var MODEL_PROVIDER = "anthropic";
987
+ var AGENT_CODE_CODEX = "codex";
988
+ var SIGNAL_NAME_CODEX = "codex-turn";
989
+ var MODEL_PROVIDER_CODEX = "openai";
990
+ var CODEX_USAGE_EVENT_NAME = "codex.sse_event";
991
+ var CODEX_INPUT_TOKEN_KEY = "input_token_count";
992
+ var CODEX_OUTPUT_TOKEN_KEY = "output_token_count";
993
+ var CODEX_CACHED_TOKEN_KEY = "cached_token_count";
994
+ var CODEX_REASONING_TOKEN_KEY = "reasoning_token_count";
995
+ var CODEX_TOOL_TOKEN_KEY = "tool_token_count";
996
+ var CODEX_TOKEN_KEYS = [
997
+ CODEX_INPUT_TOKEN_KEY,
998
+ CODEX_OUTPUT_TOKEN_KEY,
999
+ CODEX_CACHED_TOKEN_KEY,
1000
+ CODEX_REASONING_TOKEN_KEY
1001
+ ];
1002
+ var CODEX_EXCLUDED_MODELS = /* @__PURE__ */ new Set(["codex-auto-review"]);
1003
+ var SIGNAL_NAME_TOOL = "claude-code-tool";
1004
+ var SIGNAL_NAME_TOOL_CODEX = "codex-tool";
1005
+ var CLAUDE_TOOL_RESULT_EVENT = "claude_code.tool_result";
1006
+ var CODEX_TOOL_RESULT_EVENT = "codex.tool_result";
1007
+ var TOOL_NAME_KEYS = ["tool_name", "tool.name"];
1008
+ var MODEL_PROVIDER_TOOL = "tool";
1009
+ var BUILTIN_NONBILLABLE_TOOLS = /* @__PURE__ */ new Set([
1010
+ // Claude Code built-ins
1011
+ "Read",
1012
+ "Edit",
1013
+ "Write",
1014
+ "MultiEdit",
1015
+ "NotebookEdit",
1016
+ "NotebookRead",
1017
+ "Bash",
1018
+ "BashOutput",
1019
+ "KillShell",
1020
+ "KillBash",
1021
+ "Grep",
1022
+ "Glob",
1023
+ "LS",
1024
+ "TodoWrite",
1025
+ "ExitPlanMode",
1026
+ "Task",
1027
+ "Agent",
1028
+ // alternate name for Task - hedge against the rename
1029
+ "SlashCommand",
1030
+ "ToolSearch",
1031
+ // Codex built-ins (free + high-volume)
1032
+ "exec_command",
1033
+ "apply_patch"
1034
+ ]);
1035
+ var ENVIRONMENT = "development";
1036
+ var WATCH_POLL_MS = 1e3;
1037
+ var NO_IDENTITY_CUSTOMER = "claude-code-no-identity";
1038
+ var CODEX_NO_IDENTITY_CUSTOMER = "codex-no-identity";
1039
+ function attributesToLookup(attributeList) {
1040
+ const lookup = {};
1041
+ if (!Array.isArray(attributeList)) return lookup;
1042
+ for (const attribute of attributeList) {
1043
+ if (attribute && typeof attribute.key === "string" && attribute.value) {
1044
+ lookup[attribute.key] = attribute.value.stringValue;
1045
+ }
1046
+ }
1047
+ return lookup;
1048
+ }
1049
+ function dataPointTokenCount(dataPoint) {
1050
+ if (dataPoint == null) return 0;
1051
+ if (dataPoint.asInt !== void 0 && dataPoint.asInt !== null) {
1052
+ const parsed = parseInt(String(dataPoint.asInt), 10);
1053
+ return Number.isFinite(parsed) ? parsed : 0;
1054
+ }
1055
+ if (dataPoint.asDouble !== void 0 && dataPoint.asDouble !== null) {
1056
+ const parsed = Math.round(Number(dataPoint.asDouble));
1057
+ return Number.isFinite(parsed) ? parsed : 0;
1058
+ }
1059
+ return 0;
1060
+ }
1061
+ function collectDataPointsForMetric(parsedOtlp, metricName) {
1062
+ const collectedDataPoints = [];
1063
+ const resourceMetricsList = parsedOtlp?.resourceMetrics;
1064
+ if (!Array.isArray(resourceMetricsList)) return collectedDataPoints;
1065
+ for (const resourceMetric of resourceMetricsList) {
1066
+ const scopeMetricsList = resourceMetric?.scopeMetrics;
1067
+ if (!Array.isArray(scopeMetricsList)) continue;
1068
+ for (const scopeMetric of scopeMetricsList) {
1069
+ const metricsList = scopeMetric?.metrics;
1070
+ if (!Array.isArray(metricsList)) continue;
1071
+ for (const metric of metricsList) {
1072
+ if (metric?.name !== metricName) continue;
1073
+ const dataPointsList = metric?.sum?.dataPoints;
1074
+ if (!Array.isArray(dataPointsList)) continue;
1075
+ for (const dataPoint of dataPointsList) {
1076
+ collectedDataPoints.push(dataPoint);
1077
+ }
1078
+ }
1079
+ }
1080
+ }
1081
+ return collectedDataPoints;
1082
+ }
1083
+ function normalizeModelId(rawModelId) {
1084
+ if (typeof rawModelId !== "string") return rawModelId;
1085
+ const withoutContextSuffix = rawModelId.replace(/\[.*\]$/, "");
1086
+ const dottedVersion = withoutContextSuffix.replace(
1087
+ /^(claude-[a-z]+-\d+)-(\d+)$/,
1088
+ "$1.$2"
1089
+ );
1090
+ return dottedVersion;
1091
+ }
1092
+ function idempotencyKeyFor(rawSourceLine, email, rawModel, sessionId, indexWithinLine) {
1093
+ return createHash2("sha256").update(
1094
+ `${rawSourceLine}|${email}|${rawModel}|${sessionId ?? ""}|${indexWithinLine}`
1095
+ ).digest("hex");
1096
+ }
1097
+ function buildMarginFrontRequestBody(parsedOtlp, options = {}) {
1098
+ const foldCache = options.foldCache === true;
1099
+ const fallbackCustomerId = typeof options.fallbackCustomerId === "string" && options.fallbackCustomerId.length > 0 ? options.fallbackCustomerId : null;
1100
+ const tokenDataPoints = collectDataPointsForMetric(
1101
+ parsedOtlp,
1102
+ "claude_code.token.usage"
1103
+ );
1104
+ const costDataPoints = collectDataPointsForMetric(
1105
+ parsedOtlp,
1106
+ "claude_code.cost.usage"
1107
+ );
1108
+ const groupsByKey = /* @__PURE__ */ new Map();
1109
+ function makeGroupKey(email, model, sessionId) {
1110
+ return `${email}
1111
+ ${model}
1112
+ ${sessionId}`;
1113
+ }
1114
+ for (const dataPoint of tokenDataPoints) {
1115
+ const attributes = attributesToLookup(dataPoint.attributes);
1116
+ const email = attributes["user.email"] || fallbackCustomerId;
1117
+ const rawModel = attributes["model"];
1118
+ const sessionId = attributes["session.id"];
1119
+ const tokenType = attributes["type"];
1120
+ const tokenCount = dataPointTokenCount(dataPoint);
1121
+ if (!email || !rawModel || !sessionId) continue;
1122
+ const groupKey = makeGroupKey(email, rawModel, sessionId);
1123
+ let group = groupsByKey.get(groupKey);
1124
+ if (!group) {
1125
+ group = {
1126
+ email,
1127
+ rawModel,
1128
+ sessionId,
1129
+ inputTokens: 0,
1130
+ outputTokens: 0,
1131
+ cacheReadTokens: 0,
1132
+ cacheCreationTokens: 0
1133
+ };
1134
+ groupsByKey.set(groupKey, group);
1135
+ }
1136
+ if (tokenType === "input") {
1137
+ group.inputTokens += tokenCount;
1138
+ } else if (tokenType === "output") {
1139
+ group.outputTokens += tokenCount;
1140
+ } else if (tokenType === "cacheRead") {
1141
+ group.cacheReadTokens += tokenCount;
1142
+ } else if (tokenType === "cacheCreation") {
1143
+ group.cacheCreationTokens += tokenCount;
1144
+ }
1145
+ }
1146
+ function findCostForGroup(email, rawModel, sessionId) {
1147
+ for (const costPoint of costDataPoints) {
1148
+ const costAttributes = attributesToLookup(costPoint.attributes);
1149
+ const costEmail = costAttributes["user.email"] || fallbackCustomerId;
1150
+ if (costEmail === email && costAttributes["model"] === rawModel && costAttributes["session.id"] === sessionId) {
1151
+ return typeof costPoint.asDouble === "number" ? costPoint.asDouble : null;
1152
+ }
1153
+ }
1154
+ return null;
1155
+ }
1156
+ const records = [];
1157
+ for (const group of groupsByKey.values()) {
1158
+ const cacheReadTokens = group.cacheReadTokens;
1159
+ const cacheCreationTokens = group.cacheCreationTokens;
1160
+ const cacheTokens = cacheReadTokens + cacheCreationTokens;
1161
+ const billedInputTokens = foldCache ? group.inputTokens + cacheTokens : group.inputTokens;
1162
+ if (group.inputTokens === 0 && group.outputTokens === 0 && cacheTokens === 0)
1163
+ continue;
1164
+ const claudeCodeCostUsd = findCostForGroup(
1165
+ group.email,
1166
+ group.rawModel,
1167
+ group.sessionId
1168
+ );
1169
+ const record = {
1170
+ // Per-engineer attribution: the engineer's email IS the customer id (or the
1171
+ // no-identity placeholder).
1172
+ customerExternalId: group.email,
1173
+ agentCode: AGENT_CODE,
1174
+ signalName: SIGNAL_NAME,
1175
+ model: normalizeModelId(group.rawModel),
1176
+ modelProvider: MODEL_PROVIDER,
1177
+ inputTokens: billedInputTokens,
1178
+ outputTokens: group.outputTokens,
1179
+ environment: ENVIRONMENT,
1180
+ // usageDate omitted so MarginFront defaults it to "now."
1181
+ metadata: {
1182
+ sessionId: group.sessionId,
1183
+ rawModel: group.rawModel,
1184
+ // Raw cache numbers ALWAYS recorded (audit), whether typed or folded.
1185
+ cacheReadTokens,
1186
+ cacheCreationTokens,
1187
+ // Whether inputTokens includes cache (fold) or not (default).
1188
+ cacheFoldedIntoInput: foldCache,
1189
+ // Claude Code's own cache-accurate cost - reconciliation ground truth,
1190
+ // not the billed figure.
1191
+ claudeCodeCostUsd
1192
+ }
1193
+ };
1194
+ if (!foldCache) {
1195
+ record.cacheReadTokens = cacheReadTokens;
1196
+ record.cacheWriteTokens = cacheCreationTokens;
1197
+ }
1198
+ if (options.rawSourceLine !== void 0) {
1199
+ record.idempotencyKey = idempotencyKeyFor(
1200
+ options.rawSourceLine,
1201
+ group.email,
1202
+ group.rawModel,
1203
+ group.sessionId,
1204
+ records.length
1205
+ );
1206
+ }
1207
+ records.push(record);
1208
+ }
1209
+ return { records };
1210
+ }
1211
+ function logAttributesToLookup(attributeList) {
1212
+ const lookup = {};
1213
+ if (!Array.isArray(attributeList)) return lookup;
1214
+ for (const attribute of attributeList) {
1215
+ if (attribute && typeof attribute.key === "string" && attribute.value) {
1216
+ lookup[attribute.key] = attribute.value;
1217
+ }
1218
+ }
1219
+ return lookup;
1220
+ }
1221
+ function logAttrString(value) {
1222
+ if (value && typeof value.stringValue === "string") return value.stringValue;
1223
+ return void 0;
1224
+ }
1225
+ function logAttrTokenCount(value) {
1226
+ if (value == null) return 0;
1227
+ if (value.intValue !== void 0 && value.intValue !== null) {
1228
+ const parsed = parseInt(String(value.intValue), 10);
1229
+ return Number.isFinite(parsed) ? parsed : 0;
1230
+ }
1231
+ if (value.doubleValue !== void 0 && value.doubleValue !== null) {
1232
+ const parsed = Math.round(Number(value.doubleValue));
1233
+ return Number.isFinite(parsed) ? parsed : 0;
1234
+ }
1235
+ if (typeof value.stringValue === "string") {
1236
+ const parsed = parseInt(value.stringValue, 10);
1237
+ return Number.isFinite(parsed) ? parsed : 0;
1238
+ }
1239
+ return 0;
1240
+ }
1241
+ function isCodexUsageRecord(lookup, eventName) {
1242
+ if (eventName === CODEX_USAGE_EVENT_NAME) return true;
1243
+ return CODEX_TOKEN_KEYS.some((key) => lookup[key] !== void 0);
1244
+ }
1245
+ function buildCodexRequestBody(parsedOtlp, options = {}) {
1246
+ const foldCache = options.foldCache === true;
1247
+ const fallbackCustomerId = typeof options.fallbackCustomerId === "string" && options.fallbackCustomerId.length > 0 ? options.fallbackCustomerId : CODEX_NO_IDENTITY_CUSTOMER;
1248
+ const records = [];
1249
+ const resourceLogsList = parsedOtlp?.resourceLogs;
1250
+ if (!Array.isArray(resourceLogsList)) return { records };
1251
+ for (const resourceLog of resourceLogsList) {
1252
+ const scopeLogsList = resourceLog?.scopeLogs;
1253
+ if (!Array.isArray(scopeLogsList)) continue;
1254
+ for (const scopeLog of scopeLogsList) {
1255
+ const logRecordsList = scopeLog?.logRecords;
1256
+ if (!Array.isArray(logRecordsList)) continue;
1257
+ for (const logRecord of logRecordsList) {
1258
+ const lookup = logAttributesToLookup(logRecord?.attributes);
1259
+ const eventName = logAttrString(lookup["event.name"]) ?? logAttrString(logRecord?.body);
1260
+ if (toolResultSource(eventName) !== null) continue;
1261
+ if (!isCodexUsageRecord(lookup, eventName)) continue;
1262
+ const rawModel = logAttrString(lookup["model"]);
1263
+ if (!rawModel) continue;
1264
+ if (CODEX_EXCLUDED_MODELS.has(rawModel.trim().toLowerCase())) continue;
1265
+ const email = logAttrString(lookup["user.email"]) || fallbackCustomerId;
1266
+ if (!email) continue;
1267
+ const sessionId = logAttrString(lookup["session.id"]) ?? logAttrString(lookup["conversation.id"]) ?? logAttrString(lookup["session_id"]) ?? null;
1268
+ const inputCount = logAttrTokenCount(lookup[CODEX_INPUT_TOKEN_KEY]);
1269
+ const outputCount = logAttrTokenCount(lookup[CODEX_OUTPUT_TOKEN_KEY]);
1270
+ const cachedCount = logAttrTokenCount(lookup[CODEX_CACHED_TOKEN_KEY]);
1271
+ const reasoningCount = logAttrTokenCount(
1272
+ lookup[CODEX_REASONING_TOKEN_KEY]
1273
+ );
1274
+ const toolCount = logAttrTokenCount(lookup[CODEX_TOOL_TOKEN_KEY]);
1275
+ if (inputCount === 0 && outputCount === 0 && cachedCount === 0)
1276
+ continue;
1277
+ const freshInputTokens = Math.max(0, inputCount - cachedCount);
1278
+ const billedInputTokens = foldCache ? inputCount : freshInputTokens;
1279
+ const record = {
1280
+ customerExternalId: email,
1281
+ agentCode: AGENT_CODE_CODEX,
1282
+ signalName: SIGNAL_NAME_CODEX,
1283
+ model: normalizeModelId(rawModel),
1284
+ modelProvider: MODEL_PROVIDER_CODEX,
1285
+ inputTokens: billedInputTokens,
1286
+ // Reasoning is ALREADY inside this number - do NOT add it again.
1287
+ outputTokens: outputCount,
1288
+ environment: ENVIRONMENT,
1289
+ // usageDate omitted so MarginFront defaults it to "now."
1290
+ metadata: {
1291
+ source: "codex",
1292
+ sessionId,
1293
+ rawModel,
1294
+ // Audit-only - nested inside input/output already, never added to the
1295
+ // billed tokens; recorded to prove we didn't drop anything.
1296
+ cachedTokens: cachedCount,
1297
+ reasoningTokens: reasoningCount,
1298
+ toolTokens: toolCount,
1299
+ cacheFoldedIntoInput: foldCache
1300
+ }
1301
+ };
1302
+ if (!foldCache) {
1303
+ record.cacheReadTokens = cachedCount;
1304
+ }
1305
+ if (options.rawSourceLine !== void 0) {
1306
+ record.idempotencyKey = idempotencyKeyFor(
1307
+ options.rawSourceLine,
1308
+ email,
1309
+ rawModel,
1310
+ sessionId,
1311
+ records.length
1312
+ );
1313
+ }
1314
+ records.push(record);
1315
+ }
1316
+ }
1317
+ }
1318
+ return { records };
1319
+ }
1320
+ function toolResultSource(eventName) {
1321
+ if (!eventName) return null;
1322
+ if (eventName === CODEX_TOOL_RESULT_EVENT) return "codex";
1323
+ if (eventName === CLAUDE_TOOL_RESULT_EVENT || eventName === "tool_result") {
1324
+ return "claude-code";
1325
+ }
1326
+ return null;
1327
+ }
1328
+ function buildToolUsageRecords(parsedOtlp, options = {}) {
1329
+ const fallbackCustomerId = typeof options.fallbackCustomerId === "string" && options.fallbackCustomerId.length > 0 ? options.fallbackCustomerId : CODEX_NO_IDENTITY_CUSTOMER;
1330
+ const records = [];
1331
+ const resourceLogsList = parsedOtlp?.resourceLogs;
1332
+ if (!Array.isArray(resourceLogsList)) return { records };
1333
+ const groups = /* @__PURE__ */ new Map();
1334
+ for (const resourceLog of resourceLogsList) {
1335
+ const scopeLogsList = resourceLog?.scopeLogs;
1336
+ if (!Array.isArray(scopeLogsList)) continue;
1337
+ for (const scopeLog of scopeLogsList) {
1338
+ const logRecordsList = scopeLog?.logRecords;
1339
+ if (!Array.isArray(logRecordsList)) continue;
1340
+ for (const logRecord of logRecordsList) {
1341
+ const lookup = logAttributesToLookup(logRecord?.attributes);
1342
+ const eventName = logAttrString(lookup["event.name"]) ?? logAttrString(logRecord?.body);
1343
+ const source = toolResultSource(eventName);
1344
+ if (!source) continue;
1345
+ let toolName;
1346
+ for (const key of TOOL_NAME_KEYS) {
1347
+ const candidate = logAttrString(lookup[key]);
1348
+ if (candidate) {
1349
+ toolName = candidate;
1350
+ break;
1351
+ }
1352
+ }
1353
+ if (!toolName) continue;
1354
+ if (BUILTIN_NONBILLABLE_TOOLS.has(toolName)) continue;
1355
+ const email = logAttrString(lookup["user.email"]) || fallbackCustomerId;
1356
+ if (!email) continue;
1357
+ const sessionId = logAttrString(lookup["session.id"]) ?? logAttrString(lookup["conversation.id"]) ?? logAttrString(lookup["session_id"]) ?? null;
1358
+ const groupKey = `${email}
1359
+ ${sessionId ?? ""}
1360
+ ${source}
1361
+ ${toolName}`;
1362
+ let group = groups.get(groupKey);
1363
+ if (!group) {
1364
+ group = {
1365
+ email,
1366
+ sessionId,
1367
+ telemetrySource: source,
1368
+ toolName,
1369
+ count: 0
1370
+ };
1371
+ groups.set(groupKey, group);
1372
+ }
1373
+ group.count += 1;
1374
+ }
1375
+ }
1376
+ }
1377
+ for (const group of groups.values()) {
1378
+ if (group.count === 0) continue;
1379
+ const isCodex = group.telemetrySource === "codex";
1380
+ const record = {
1381
+ customerExternalId: group.email,
1382
+ agentCode: isCodex ? AGENT_CODE_CODEX : AGENT_CODE,
1383
+ signalName: isCodex ? SIGNAL_NAME_TOOL_CODEX : SIGNAL_NAME_TOOL,
1384
+ // Raw tool NAME as the service id, verbatim - NOT through normalizeModelId
1385
+ // (that's Claude-LLM-name-specific). Catalog matches (model=tool name,
1386
+ // modelProvider="tool").
1387
+ model: group.toolName,
1388
+ modelProvider: MODEL_PROVIDER_TOOL,
1389
+ // Billing volume = times this tool fired this flush; no token fields (not an
1390
+ // LLM turn). Server prices quantity x unitPrice.
1391
+ quantity: group.count,
1392
+ environment: ENVIRONMENT,
1393
+ metadata: {
1394
+ source: "tool",
1395
+ sessionId: group.sessionId,
1396
+ toolName: group.toolName,
1397
+ telemetrySource: group.telemetrySource,
1398
+ estimated: true
1399
+ }
1400
+ };
1401
+ if (options.rawSourceLine !== void 0) {
1402
+ record.idempotencyKey = idempotencyKeyFor(
1403
+ options.rawSourceLine,
1404
+ group.email,
1405
+ `tool:${group.toolName}`,
1406
+ group.sessionId,
1407
+ records.length
1408
+ );
1409
+ }
1410
+ records.push(record);
1411
+ }
1412
+ return { records };
1413
+ }
1414
+ function buildRequestBodyForLine(parsedOtlp, options = {}) {
1415
+ if (parsedOtlp && Array.isArray(parsedOtlp.resourceLogs)) {
1416
+ const codexOptions = options.fallbackCustomerId === NO_IDENTITY_CUSTOMER ? { ...options, fallbackCustomerId: CODEX_NO_IDENTITY_CUSTOMER } : options;
1417
+ const tokenBody = buildCodexRequestBody(parsedOtlp, codexOptions);
1418
+ const toolBody = buildToolUsageRecords(parsedOtlp, codexOptions);
1419
+ return { records: [...tokenBody.records, ...toolBody.records] };
1420
+ }
1421
+ return buildMarginFrontRequestBody(parsedOtlp, options);
1422
+ }
1423
+ function readAndParseOtlpFile(inputFilePath) {
1424
+ if (!existsSync2(inputFilePath)) {
1425
+ console.error(
1426
+ `Could not read/parse ${inputFilePath}: file does not exist. Check the path and try again.`
1427
+ );
1428
+ process2.exit(1);
1429
+ }
1430
+ let fileText;
1431
+ try {
1432
+ fileText = readFileSync2(inputFilePath, "utf8");
1433
+ } catch (readError) {
1434
+ console.error(
1435
+ `Could not read/parse ${inputFilePath}: ${readError.message}`
1436
+ );
1437
+ process2.exit(1);
1438
+ }
1439
+ try {
1440
+ return JSON.parse(fileText);
1441
+ } catch {
1442
+ console.error(
1443
+ `Could not read/parse ${inputFilePath}: the file is not valid JSON. Make sure it is an OTLP/JSON export line (Claude Code metrics or Codex logs), not raw text.`
1444
+ );
1445
+ process2.exit(1);
1446
+ }
1447
+ }
1448
+ async function postToMarginFront(requestBody) {
1449
+ const apiKey = process2.env.MARGINFRONT_API_KEY;
1450
+ if (!apiKey) {
1451
+ return { ok: false, reason: "no-key" };
1452
+ }
1453
+ let response;
1454
+ try {
1455
+ response = await fetch(MARGINFRONT_INGEST_URL, {
1456
+ method: "POST",
1457
+ headers: {
1458
+ "Content-Type": "application/json",
1459
+ // MarginFront authenticates with x-api-key, NOT Bearer/Authorization.
1460
+ "x-api-key": apiKey,
1461
+ // This User-Agent is what lets the server tag these events as source='ccc'
1462
+ // instead of the generic 'api' fallback. Without it, PostHog dashboards
1463
+ // can't distinguish "developer paying for AI tokens via CCC" traffic from
1464
+ // raw API clients - every CCC event would be mislabelled as source='api'.
1465
+ "User-Agent": `@marginfront/code-cost-clarity/${CCC_PACKAGE_VERSION}`
1466
+ },
1467
+ body: JSON.stringify(requestBody)
1468
+ });
1469
+ } catch (networkError) {
1470
+ return {
1471
+ ok: false,
1472
+ reason: "network",
1473
+ message: networkError.message
1474
+ };
1475
+ }
1476
+ const responseText = await response.text();
1477
+ if (!response.ok) {
1478
+ return {
1479
+ ok: false,
1480
+ reason: "http",
1481
+ status: response.status,
1482
+ body: responseText
1483
+ };
1484
+ }
1485
+ let parsed = null;
1486
+ try {
1487
+ parsed = JSON.parse(responseText);
1488
+ } catch {
1489
+ parsed = null;
1490
+ }
1491
+ return { ok: true, parsed, body: responseText };
1492
+ }
1493
+ function successRowsFrom(parsed) {
1494
+ const rows = parsed?.results?.success;
1495
+ return Array.isArray(rows) ? rows : [];
1496
+ }
1497
+ function readWatchCursor(cursorPath) {
1498
+ if (!cursorPath || !existsSync2(cursorPath)) return 0;
1499
+ try {
1500
+ const parsed = parseInt(readFileSync2(cursorPath, "utf8").trim(), 10);
1501
+ return Number.isInteger(parsed) && parsed >= 0 ? parsed : 0;
1502
+ } catch {
1503
+ return 0;
1504
+ }
1505
+ }
1506
+ function writeWatchCursor(cursorPath, byteOffset) {
1507
+ if (!cursorPath) return;
1508
+ try {
1509
+ writeFileSync2(cursorPath, String(byteOffset));
1510
+ } catch {
1511
+ }
1512
+ }
1513
+ function readNewCompleteLines(filePath, fromByte) {
1514
+ if (!existsSync2(filePath)) return { lines: [], nextCursor: fromByte };
1515
+ let size;
1516
+ try {
1517
+ size = statSync(filePath).size;
1518
+ } catch {
1519
+ return { lines: [], nextCursor: fromByte };
1520
+ }
1521
+ let start = fromByte;
1522
+ if (size < start) start = 0;
1523
+ if (size === start) return { lines: [], nextCursor: start };
1524
+ const length = size - start;
1525
+ const buffer = Buffer.alloc(length);
1526
+ let bytesRead = 0;
1527
+ let fd;
1528
+ try {
1529
+ fd = openSync2(filePath, "r");
1530
+ bytesRead = readSync(fd, buffer, 0, length, start);
1531
+ } catch {
1532
+ return { lines: [], nextCursor: fromByte };
1533
+ } finally {
1534
+ if (fd !== void 0) closeSync(fd);
1535
+ }
1536
+ const chunk = buffer.subarray(0, bytesRead);
1537
+ const lastNewline = chunk.lastIndexOf(10);
1538
+ if (lastNewline === -1) {
1539
+ return { lines: [], nextCursor: start };
1540
+ }
1541
+ const completeText = chunk.subarray(0, lastNewline + 1).toString("utf8");
1542
+ const nextCursor = start + lastNewline + 1;
1543
+ const parts = completeText.split("\n");
1544
+ parts.pop();
1545
+ return { lines: parts, nextCursor };
1546
+ }
1547
+ var MAX_QUEUE_RECORDS = 1e4;
1548
+ var MAX_RECORDS_PER_DRAIN = 100;
1549
+ var QUEUE_BACKOFF_START_MS = 2e3;
1550
+ var QUEUE_BACKOFF_MAX_MS = 6e4;
1551
+ var COLLECTOR_REVIVE_BACKOFF_MS = 3e4;
1552
+ function maybeReviveCollector(deps, nextReviveAllowedAt, backoffMs) {
1553
+ if (deps.isCollectorAlive()) return nextReviveAllowedAt;
1554
+ const now = deps.now();
1555
+ if (now < nextReviveAllowedAt) return nextReviveAllowedAt;
1556
+ const log = deps.log ?? console.error;
1557
+ const updatedReviveAllowedAt = now + backoffMs;
1558
+ log("collector was down, restarted it");
1559
+ try {
1560
+ deps.reviveCollector();
1561
+ } catch (err) {
1562
+ log(`collector revive attempt failed: ${err.message}`);
1563
+ }
1564
+ return updatedReviveAllowedAt;
1565
+ }
1566
+ function isRetryablePostResult(result) {
1567
+ if (result.ok) return false;
1568
+ if (result.reason === "network") return true;
1569
+ if (result.reason === "no-key") return false;
1570
+ if (result.reason === "http") {
1571
+ return result.status === 429 || result.status >= 500;
1572
+ }
1573
+ return false;
1574
+ }
1575
+ function readQueue(queuePath) {
1576
+ if (!queuePath || !existsSync2(queuePath)) return [];
1577
+ let text;
1578
+ try {
1579
+ text = readFileSync2(queuePath, "utf8");
1580
+ } catch {
1581
+ return [];
1582
+ }
1583
+ const out = [];
1584
+ for (const line of text.split("\n")) {
1585
+ const trimmed = line.trim();
1586
+ if (!trimmed) continue;
1587
+ try {
1588
+ out.push(JSON.parse(trimmed));
1589
+ } catch {
1590
+ }
1591
+ }
1592
+ return out;
1593
+ }
1594
+ function appendToQueue(queuePath, records) {
1595
+ if (!queuePath || records.length === 0) return;
1596
+ const payload = records.map((r) => JSON.stringify(r) + "\n").join("");
1597
+ try {
1598
+ appendFileSync(queuePath, payload);
1599
+ } catch {
1600
+ }
1601
+ }
1602
+ function writeQueue(queuePath, records) {
1603
+ if (!queuePath) return;
1604
+ try {
1605
+ if (records.length === 0) {
1606
+ if (existsSync2(queuePath)) rmSync2(queuePath, { force: true });
1607
+ return;
1608
+ }
1609
+ writeFileSync2(
1610
+ queuePath,
1611
+ records.map((r) => JSON.stringify(r) + "\n").join("")
1612
+ );
1613
+ } catch {
1614
+ }
1615
+ }
1616
+ function watchAndForward(filePath, options = {}) {
1617
+ const foldCache = options.foldCache === true;
1618
+ const fallbackCustomerId = options.fallbackCustomerId;
1619
+ const cursorPath = options.cursorPath;
1620
+ const pidFilePath = options.pidFilePath;
1621
+ if (!process2.env.MARGINFRONT_API_KEY) {
1622
+ console.error(
1623
+ "MARGINFRONT_API_KEY is not set. Set it in your shell before watching: export MARGINFRONT_API_KEY=..."
1624
+ );
1625
+ process2.exit(1);
1626
+ }
1627
+ if (pidFilePath) {
1628
+ try {
1629
+ writeFileSync2(pidFilePath, String(process2.pid));
1630
+ } catch {
1631
+ }
1632
+ }
1633
+ console.log(
1634
+ `Watching ${filePath} for live Claude Code + Codex usage` + (foldCache ? " (fold-cache: cache tokens rolled into input)" : "") + "\u2026 Ctrl-C to stop."
1635
+ );
1636
+ let cursorBytes = readWatchCursor(cursorPath);
1637
+ let running = false;
1638
+ const queuePath = options.queuePath;
1639
+ let queuedCount = readQueue(queuePath).length;
1640
+ let queueBackoffMs = QUEUE_BACKOFF_START_MS;
1641
+ let nextQueueRetryAt = 0;
1642
+ const hhmmss = () => (/* @__PURE__ */ new Date()).toISOString().slice(11, 19);
1643
+ let nextCollectorReviveAt = 0;
1644
+ const collectorHealDeps = {
1645
+ isCollectorAlive: () => isPidAlive(readCollectorPid()),
1646
+ reviveCollector: () => {
1647
+ startCollector();
1648
+ },
1649
+ now: () => Date.now(),
1650
+ log: (message) => console.error(`[${hhmmss()}] ${message}`)
1651
+ };
1652
+ function logSuccess(body, result, stamp) {
1653
+ if (!result.ok) return;
1654
+ for (const rec of body.records) {
1655
+ const row = successRowsFrom(result.parsed).find(
1656
+ (r) => r?.customerExternalId === rec.customerExternalId
1657
+ );
1658
+ const cost = row?.totalCostUsd != null ? `$${row.totalCostUsd}` : "$?";
1659
+ const eventId = row?.eventId ? ` event=${row.eventId}` : "";
1660
+ const reference = "source" in rec.metadata ? `src=${rec.metadata.source}` : `cc=$${rec.metadata.claudeCodeCostUsd ?? "?"}`;
1661
+ const volume = rec.quantity !== void 0 ? `qty=${rec.quantity} tool=${"toolName" in rec.metadata ? rec.metadata.toolName : "?"}` : `in=${rec.inputTokens} out=${rec.outputTokens}`;
1662
+ console.log(
1663
+ `[${stamp}] recorded ${rec.customerExternalId} \xB7 ${volume} \xB7 server=${cost} \xB7 ${reference}${eventId}`
1664
+ );
1665
+ }
1666
+ }
1667
+ function logFailure(result, stamp) {
1668
+ if (result.ok) return;
1669
+ if (result.reason === "http") {
1670
+ console.error(
1671
+ `[${stamp}] MarginFront HTTP ${result.status}: ${result.body}`
1672
+ );
1673
+ } else if (result.reason === "network") {
1674
+ console.error(
1675
+ `[${stamp}] could not reach MarginFront: ${result.message}`
1676
+ );
1677
+ } else {
1678
+ console.error(`[${stamp}] send failed (${result.reason}).`);
1679
+ }
1680
+ }
1681
+ function enqueueFailed(records, stamp) {
1682
+ const room = Math.max(0, MAX_QUEUE_RECORDS - queuedCount);
1683
+ const toQueue = records.slice(0, room);
1684
+ const shed = records.length - toQueue.length;
1685
+ if (toQueue.length > 0) {
1686
+ appendToQueue(queuePath, toQueue);
1687
+ queuedCount += toQueue.length;
1688
+ console.error(
1689
+ `[${stamp}] send failed - queued ${toQueue.length} record(s) for retry (${queuedCount} now in queue).`
1690
+ );
1691
+ }
1692
+ if (shed > 0) {
1693
+ console.error(
1694
+ `[${stamp}] QUEUE FULL (cap ${MAX_QUEUE_RECORDS}): shed ${shed} record(s) - these turns are LOST. The ingest endpoint has been unreachable for a long time; check connectivity.`
1695
+ );
1696
+ }
1697
+ nextQueueRetryAt = Date.now() + queueBackoffMs;
1698
+ queueBackoffMs = Math.min(queueBackoffMs * 2, QUEUE_BACKOFF_MAX_MS);
1699
+ }
1700
+ async function drainQueue() {
1701
+ if (!queuePath || queuedCount === 0) return;
1702
+ if (Date.now() < nextQueueRetryAt) return;
1703
+ const queued = readQueue(queuePath);
1704
+ queuedCount = queued.length;
1705
+ if (queued.length === 0) return;
1706
+ const batch = queued.slice(0, MAX_RECORDS_PER_DRAIN);
1707
+ const result = await postToMarginFront({ records: batch });
1708
+ const stamp = hhmmss();
1709
+ if (result.ok) {
1710
+ const remaining = queued.slice(batch.length);
1711
+ writeQueue(queuePath, remaining);
1712
+ queuedCount = remaining.length;
1713
+ queueBackoffMs = QUEUE_BACKOFF_START_MS;
1714
+ nextQueueRetryAt = 0;
1715
+ console.log(
1716
+ `[${stamp}] queue: ${batch.length} retried record(s) landed \xB7 ${remaining.length} still queued.`
1717
+ );
1718
+ } else if (isRetryablePostResult(result)) {
1719
+ logFailure(result, stamp);
1720
+ const waitMs = queueBackoffMs;
1721
+ nextQueueRetryAt = Date.now() + waitMs;
1722
+ queueBackoffMs = Math.min(queueBackoffMs * 2, QUEUE_BACKOFF_MAX_MS);
1723
+ console.error(
1724
+ `[${stamp}] queue: retry failed, ${queued.length} record(s) still queued (next attempt in ~${Math.round(waitMs / 1e3)}s).`
1725
+ );
1726
+ } else {
1727
+ const remaining = queued.slice(batch.length);
1728
+ writeQueue(queuePath, remaining);
1729
+ queuedCount = remaining.length;
1730
+ console.error(
1731
+ `[${stamp}] queue: dropping ${batch.length} record(s) - terminal failure (server rejected them, retrying can't help).`
1732
+ );
1733
+ }
1734
+ }
1735
+ async function processNewLines() {
1736
+ if (running) return;
1737
+ running = true;
1738
+ try {
1739
+ await drainQueue();
1740
+ const { lines, nextCursor } = readNewCompleteLines(filePath, cursorBytes);
1741
+ for (const rawLine of lines) {
1742
+ const line = rawLine.trim();
1743
+ if (!line) continue;
1744
+ let parsed;
1745
+ try {
1746
+ parsed = JSON.parse(line);
1747
+ } catch {
1748
+ console.error("(skipped one telemetry line that wasn't valid JSON)");
1749
+ continue;
1750
+ }
1751
+ const body = buildRequestBodyForLine(parsed, {
1752
+ foldCache,
1753
+ fallbackCustomerId,
1754
+ rawSourceLine: line
1755
+ });
1756
+ if (!body.records.length) continue;
1757
+ const result = await postToMarginFront(body);
1758
+ const stamp = hhmmss();
1759
+ if (!result.ok) {
1760
+ logFailure(result, stamp);
1761
+ if (queuePath && isRetryablePostResult(result)) {
1762
+ enqueueFailed(body.records, stamp);
1763
+ } else if (queuePath) {
1764
+ console.error(
1765
+ `[${stamp}] dropping ${body.records.length} record(s): terminal send failure (not retryable).`
1766
+ );
1767
+ }
1768
+ continue;
1769
+ }
1770
+ logSuccess(body, result, stamp);
1771
+ if (queuedCount > 0) {
1772
+ queueBackoffMs = QUEUE_BACKOFF_START_MS;
1773
+ nextQueueRetryAt = 0;
1774
+ }
1775
+ }
1776
+ if (nextCursor !== cursorBytes) {
1777
+ cursorBytes = nextCursor;
1778
+ writeWatchCursor(cursorPath, cursorBytes);
1779
+ }
1780
+ } finally {
1781
+ running = false;
1782
+ }
1783
+ }
1784
+ processNewLines().catch(
1785
+ (e) => console.error(`watch error: ${e.message}`)
1786
+ );
1787
+ const timer = setInterval(() => {
1788
+ nextCollectorReviveAt = maybeReviveCollector(
1789
+ collectorHealDeps,
1790
+ nextCollectorReviveAt,
1791
+ COLLECTOR_REVIVE_BACKOFF_MS
1792
+ );
1793
+ processNewLines().catch(
1794
+ (e) => console.error(`watch error: ${e.message}`)
1795
+ );
1796
+ }, WATCH_POLL_MS);
1797
+ return function stop() {
1798
+ clearInterval(timer);
1799
+ if (pidFilePath) {
1800
+ try {
1801
+ rmSync2(pidFilePath, { force: true });
1802
+ } catch {
1803
+ }
1804
+ }
1805
+ };
1806
+ }
1807
+
1808
+ // src/cli.ts
1809
+ var PKG_NAME = "@marginfront/code-cost-clarity";
1810
+ function readVersion() {
1811
+ return CCC_PACKAGE_VERSION;
1812
+ }
1813
+ function printHelp() {
1814
+ console.log(
1815
+ [
1816
+ `${PKG_NAME} - see your Claude Code + Codex spend in MarginFront`,
1817
+ "",
1818
+ "Usage:",
1819
+ " npx @marginfront/code-cost-clarity <command> [options]",
1820
+ "",
1821
+ "Commands:",
1822
+ " init Set up everything: save your MarginFront secret key (mf_sk_...), not",
1823
+ " your Anthropic or OpenAI key. Wire telemetry into your Claude/Codex",
1824
+ " config (with your OK), and start the background meter.",
1825
+ " start (Re)start the background meter - starts at login, no terminal to keep open.",
1826
+ " status Is the background meter running? Shows recent activity + errors.",
1827
+ " preview <file.json> Show the exact record it would send for a capture. No key needed.",
1828
+ " run Stream your live spend in THIS terminal instead of the background. Ctrl-C to stop.",
1829
+ " stop Pause the meter - the background daemon AND any foreground run.",
1830
+ " uninstall Shut it off, undo every config change, and reclaim disk. --purge also deletes settings.",
1831
+ " help Show this message.",
1832
+ " version Print the version.",
1833
+ "",
1834
+ "Options:",
1835
+ " --no-prompt (init only) Skip BOTH questions (the MarginFront key paste and the",
1836
+ " consent prompt) and proceed - it WILL wire your global config. For CI / scripts.",
1837
+ " --fold-cache Round-up mode: roll cache tokens into billed input. Only use",
1838
+ " for a model MarginFront can't price yet - the default is accurate.",
1839
+ " --purge (uninstall only) Also delete your settings, including your MarginFront secret key (mf_sk_...).",
1840
+ "",
1841
+ "Setup is one step now:",
1842
+ " npx @marginfront/code-cost-clarity init",
1843
+ " \u2192 paste your MarginFront secret key (mf_sk_...), say 'y' to the consent prompt,",
1844
+ " and you're done.",
1845
+ " Then just run `claude` or `codex` (or open the desktop apps) - telemetry flows",
1846
+ " automatically. No `source`, no second terminal. Check on it with `status` anytime.",
1847
+ "",
1848
+ "Prefer a live terminal view instead of the background meter? After init, run:",
1849
+ " npx @marginfront/code-cost-clarity run"
1850
+ ].join("\n")
1851
+ );
1852
+ }
1853
+ function printNoIdentityHint() {
1854
+ console.log(
1855
+ [
1856
+ "Note: usage with no engineer email is attributed to a no-identity placeholder",
1857
+ ` ("${NO_IDENTITY_CUSTOMER}" for Claude Code, "${CODEX_NO_IDENTITY_CUSTOMER}" for Codex).`,
1858
+ " Why: org-managed seats stamp the email automatically; some interactive logins don't.",
1859
+ " Fix: sign in with an org-managed seat, or attach your own MarginFront customer",
1860
+ " mapping. Until then the spend still lands, under the placeholder instead of the",
1861
+ " engineer's name."
1862
+ ].join("\n")
1863
+ );
1864
+ }
1865
+ function promptSecret(promptText) {
1866
+ return new Promise((resolve) => {
1867
+ const rl = readline.createInterface({
1868
+ input: process3.stdin,
1869
+ output: process3.stdout,
1870
+ terminal: true
1871
+ });
1872
+ rl._writeToOutput = () => {
1873
+ };
1874
+ let settled = false;
1875
+ const finish = (answer) => {
1876
+ if (settled) return;
1877
+ settled = true;
1878
+ rl.close();
1879
+ process3.stdout.write("\n");
1880
+ resolve(answer.trim());
1881
+ };
1882
+ process3.stdout.write(promptText);
1883
+ rl.question("", (answer) => finish(answer));
1884
+ rl.on("SIGINT", () => finish(""));
1885
+ });
1886
+ }
1887
+ function promptYesNo(promptText, io = {}, defaultYes = false) {
1888
+ return new Promise((resolve) => {
1889
+ const input = io.input ?? process3.stdin;
1890
+ const output = io.output ?? process3.stdout;
1891
+ const interactive = io.terminal ?? Boolean(input.isTTY);
1892
+ const rl = readline.createInterface({
1893
+ input,
1894
+ output,
1895
+ terminal: interactive
1896
+ });
1897
+ let settled = false;
1898
+ const finish = (answer, fromKeypress) => {
1899
+ if (settled) return;
1900
+ settled = true;
1901
+ rl.close();
1902
+ const normalized = answer.trim().toLowerCase();
1903
+ if (normalized === "") {
1904
+ resolve(fromKeypress && interactive ? defaultYes : false);
1905
+ return;
1906
+ }
1907
+ resolve(normalized === "y" || normalized === "yes");
1908
+ };
1909
+ output.write(promptText);
1910
+ rl.question("", (answer) => finish(answer, true));
1911
+ rl.on("SIGINT", () => finish("", false));
1912
+ rl.on("close", () => finish("", false));
1913
+ });
1914
+ }
1915
+ function printConsentDisclosure(log) {
1916
+ log(
1917
+ [
1918
+ "",
1919
+ "Before I change anything, here's EXACTLY what I'll touch on your computer,",
1920
+ "OUTSIDE this tool's own folder (~/.marginfront-ccc):",
1921
+ "",
1922
+ `1. ${CLAUDE_SETTINGS_PATH}`,
1923
+ ' I add these six settings to its "env" block - and nothing else:',
1924
+ ...Object.entries(CCC_TELEMETRY_VALUES).map(
1925
+ ([key, value]) => ` ${key}=${value}`
1926
+ ),
1927
+ " WHAT THIS MEANS: Claude Code telemetry turns ON for EVERY `claude` session on",
1928
+ " this machine - the command line AND the Claude Desktop app (they share this",
1929
+ " one file) - automatically, with no per-terminal setup. Your MarginFront secret",
1930
+ " key is NOT written here; it stays only in ~/.marginfront-ccc/.env (mode 600).",
1931
+ "",
1932
+ `2. ${CODEX_CONFIG_PATH}`,
1933
+ " I add a small [otel] block so Codex reports to the same local collector. If you",
1934
+ " already have your own [otel] settings, I leave them alone and just print the",
1935
+ " block for you to paste.",
1936
+ "",
1937
+ "3. A small background helper (a macOS LaunchAgent) so the meter runs on its own,",
1938
+ " starts at login, and needs no second terminal.",
1939
+ "",
1940
+ "All of this is fully reversible: `uninstall` removes ONLY what it added.",
1941
+ ""
1942
+ ].join("\n")
1943
+ );
1944
+ }
1945
+ async function cmdInit(noPrompt, deps = {}) {
1946
+ const log = deps.log ?? console.log;
1947
+ const isTTY = deps.isTTY ?? (() => Boolean(process3.stdin.isTTY));
1948
+ const ensureConfig = deps.ensureConfigFiles ?? (() => ensureConfigFiles());
1949
+ const ensureBinary = deps.ensureCollectorBinary ?? (() => ensureCollectorBinary());
1950
+ const loadEnv = deps.loadEnv ?? (() => loadEnvFile());
1951
+ const askSecret = deps.promptSecret ?? promptSecret;
1952
+ const saveKey = deps.writeApiKey ?? ((value) => writeApiKeyToEnv(value));
1953
+ log("Setting up the Claude Code + Codex -> MarginFront connector\u2026\n");
1954
+ ensureConfig();
1955
+ ensureBinary();
1956
+ const savedKey = loadEnv()["MARGINFRONT_API_KEY"];
1957
+ const shouldPromptKey = !noPrompt && isTTY() && !savedKey;
1958
+ let keySaved = Boolean(savedKey);
1959
+ if (shouldPromptKey) {
1960
+ log(
1961
+ [
1962
+ "",
1963
+ "Now paste your MarginFront secret key (mf_sk_...), not your Anthropic or OpenAI key, so we can save it for you.",
1964
+ ' Where to get it: app.marginfront.com -> Build -> API Keys -> "Create Key Pair"',
1965
+ " Direct link: https://app.marginfront.com/developer-zone/api-keys",
1966
+ " Use the SECRET key (mf_sk_*). A publishable key (mf_pk_*) is rejected on send.",
1967
+ ""
1968
+ ].join("\n")
1969
+ );
1970
+ let entered = await askSecret(
1971
+ "Paste your MarginFront SECRET key (mf_sk_*) - not your Anthropic/OpenAI key (Enter to skip): "
1972
+ );
1973
+ if (entered.startsWith("mf_pk_")) {
1974
+ log(
1975
+ " Heads up: that looks like a PUBLISHABLE key (mf_pk_*). The forwarder POSTs your\n usage, and publishable keys are rejected on writes - you want a SECRET key (mf_sk_*)."
1976
+ );
1977
+ const retry = await askSecret(
1978
+ "Paste your SECRET key (mf_sk_*), or press Enter to keep what you typed: "
1979
+ );
1980
+ if (retry) entered = retry;
1981
+ }
1982
+ if (entered) {
1983
+ saveKey(entered);
1984
+ log(
1985
+ `Saved your MarginFront secret key (mf_sk_...) to ${ENV_PATH} (mode 600).`
1986
+ );
1987
+ keySaved = true;
1988
+ }
1989
+ }
1990
+ printConsentDisclosure(log);
1991
+ let consented;
1992
+ if (noPrompt) {
1993
+ log(
1994
+ "--no-prompt set: proceeding without asking (CI / scripted install). This WILL change your global Claude/Codex config as listed above."
1995
+ );
1996
+ consented = true;
1997
+ } else if (isTTY()) {
1998
+ const askConsent = deps.promptConsent ?? (() => promptYesNo(
1999
+ "\nWire it up now? Press Enter for yes, or type n to cancel. [Y/n] ",
2000
+ {},
2001
+ true
2002
+ ));
2003
+ consented = await askConsent();
2004
+ } else {
2005
+ consented = false;
2006
+ }
2007
+ if (!consented) {
2008
+ log(
2009
+ [
2010
+ "",
2011
+ "No problem - I did NOT change ~/.claude/settings.json or ~/.codex/config.toml,",
2012
+ "and I did NOT install the background helper. Nothing outside ~/.marginfront-ccc",
2013
+ "was touched.",
2014
+ "",
2015
+ "Recommended: re-run `init` and answer 'y' so the meter wires itself up and just",
2016
+ "works on its own - no source, no second terminal.",
2017
+ "",
2018
+ "Fallback (only because you declined the auto-wiring): with no background helper,",
2019
+ "you can still run it by hand, one terminal at a time:",
2020
+ " 1. source ~/.marginfront-ccc/.env",
2021
+ " 2. claude (or codex)",
2022
+ " 3. in another terminal: npx @marginfront/code-cost-clarity run"
2023
+ ].join("\n")
2024
+ );
2025
+ return 0;
2026
+ }
2027
+ const writeClaude = deps.writeClaudeTelemetry ?? (() => writeClaudeTelemetrySettings(CCC_TELEMETRY_VALUES, { log }));
2028
+ const claudeResult = writeClaude();
2029
+ if (claudeResult.skipped.length > 0) {
2030
+ log(
2031
+ `Left ${claudeResult.skipped.length} Claude setting(s) you'd already set untouched.`
2032
+ );
2033
+ }
2034
+ const writeCodex = deps.writeCodexConfig ?? (() => writeCodexOtelConfig({ log }));
2035
+ const codexHasBlock = deps.codexHasCccBlock ?? (() => codexConfigHasCccBlock());
2036
+ const codexResult = writeCodex();
2037
+ if (codexResult.action === "skipped-existing" && !codexHasBlock()) {
2038
+ log("");
2039
+ log(renderCodexConfigInstructions());
2040
+ }
2041
+ const keyIsPresent = (deps.hasApiKey ?? (() => keySaved))();
2042
+ if (keyIsPresent) {
2043
+ const install = deps.installDaemon ?? (() => installDaemon({ log }));
2044
+ install();
2045
+ log(
2046
+ [
2047
+ "",
2048
+ "Done. Just run `claude` or `codex` (or open the desktop apps) - telemetry flows",
2049
+ "automatically; no source, no second terminal."
2050
+ ].join("\n")
2051
+ );
2052
+ } else {
2053
+ log(
2054
+ [
2055
+ "",
2056
+ "Telemetry is wired up, but I did NOT start the background meter yet because no",
2057
+ "MarginFront key is saved (without one it would just loop doing nothing).",
2058
+ `Finish up: paste your SECRET key (mf_sk_*) after MARGINFRONT_API_KEY= in ${ENV_PATH}`,
2059
+ " (or re-run `init`), then run: npx @marginfront/code-cost-clarity start"
2060
+ ].join("\n")
2061
+ );
2062
+ }
2063
+ const findZshrc = deps.findZshrcLines ?? (() => findCccZshrcSourceLines());
2064
+ const zshrc = findZshrc();
2065
+ if (zshrc.found) {
2066
+ log(
2067
+ [
2068
+ "",
2069
+ "One more thing: your ~/.zshrc still has a leftover line from the old setup:",
2070
+ ...zshrc.matchedLines.map((line) => ` ${line}`),
2071
+ "Now that telemetry is wired through settings.json, that line is redundant."
2072
+ ].join("\n")
2073
+ );
2074
+ let removeIt = false;
2075
+ if (!noPrompt && isTTY()) {
2076
+ const askRemove = deps.promptRemoveZshrc ?? (() => promptYesNo(
2077
+ "Remove that redundant line from your ~/.zshrc now? [y/N]: "
2078
+ ));
2079
+ removeIt = await askRemove();
2080
+ }
2081
+ if (removeIt) {
2082
+ const removeZshrc = deps.removeZshrcLines ?? (() => {
2083
+ removeCccZshrcSourceLine({ log });
2084
+ });
2085
+ removeZshrc();
2086
+ } else {
2087
+ log(
2088
+ "Left your ~/.zshrc as-is - you can delete that line by hand whenever you like."
2089
+ );
2090
+ }
2091
+ }
2092
+ return 0;
2093
+ }
2094
+ var START_HEALTH_ATTEMPTS = 6;
2095
+ var START_HEALTH_INTERVAL_MS = 300;
2096
+ async function cmdStart(deps = {}) {
2097
+ const log = deps.log ?? console.log;
2098
+ const errorLog = deps.errorLog ?? console.error;
2099
+ const hasApiKey = deps.hasApiKey ?? (() => {
2100
+ loadEnvFile();
2101
+ return Boolean(process3.env.MARGINFRONT_API_KEY);
2102
+ });
2103
+ if (!hasApiKey()) {
2104
+ errorLog(
2105
+ [
2106
+ "Can't start the background meter yet - no MarginFront API key is saved.",
2107
+ "The background job restarts itself whenever it stops, so starting it",
2108
+ " without a key would just loop forever, doing nothing.",
2109
+ `Fix: run npx ${PKG_NAME} init and paste your SECRET key (mf_sk_*),`,
2110
+ ` or add it after MARGINFRONT_API_KEY= in ${ENV_PATH}, then run start again.`
2111
+ ].join("\n")
2112
+ );
2113
+ return 1;
2114
+ }
2115
+ const isSetUp = deps.isSetUp ?? (() => existsSync3(COLLECTOR_BIN_PATH) && existsSync3(COLLECTOR_CONFIG_PATH));
2116
+ if (!isSetUp()) {
2117
+ errorLog(`Not set up yet. Run npx ${PKG_NAME} init first, then start.`);
2118
+ return 1;
2119
+ }
2120
+ const install = deps.installDaemon ?? (() => installDaemon({ log }));
2121
+ install();
2122
+ const queryStatus = deps.queryStatus ?? (() => queryDaemonStatus());
2123
+ const sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
2124
+ let running = false;
2125
+ for (let attempt = 0; attempt < START_HEALTH_ATTEMPTS; attempt++) {
2126
+ await sleep(START_HEALTH_INTERVAL_MS);
2127
+ if (queryStatus().running) {
2128
+ running = true;
2129
+ break;
2130
+ }
2131
+ }
2132
+ if (!running) {
2133
+ const errLogTail = deps.errLogTail ?? (() => readLastLines(DAEMON_STDERR_LOG_PATH, 15));
2134
+ errorLog(
2135
+ [
2136
+ "The background meter was installed, but it isn't running yet.",
2137
+ "macOS loaded the job, but the meter keeps stopping - most often a",
2138
+ " missing or expired MarginFront key (mf_sk_...) or a setup problem it can't fix itself.",
2139
+ "Here are the last lines of its error log:"
2140
+ ].join("\n")
2141
+ );
2142
+ const tail = errLogTail();
2143
+ if (tail.length === 0) {
2144
+ errorLog(` (no errors logged yet at ${DAEMON_STDERR_LOG_PATH})`);
2145
+ } else {
2146
+ for (const line of tail) errorLog(` ${line}`);
2147
+ }
2148
+ errorLog(
2149
+ [
2150
+ "",
2151
+ `Check again anytime: npx ${PKG_NAME} status`,
2152
+ `Re-check your MarginFront key (mf_sk_...) with: npx ${PKG_NAME} init (then run start again)`
2153
+ ].join("\n")
2154
+ );
2155
+ return 1;
2156
+ }
2157
+ log(
2158
+ [
2159
+ "",
2160
+ "The MarginFront cost meter is now running in the background.",
2161
+ " - It starts itself automatically every time you log in.",
2162
+ " - It keeps running after you close this window or reboot.",
2163
+ ` - Check on it anytime: npx ${PKG_NAME} status`,
2164
+ ` - Pause it: npx ${PKG_NAME} stop`,
2165
+ "",
2166
+ "Just use Claude Code (and/or Codex) normally - your spend shows up in MarginFront."
2167
+ ].join("\n")
2168
+ );
2169
+ return 0;
2170
+ }
2171
+ function printDaemonLogTail(label, path) {
2172
+ const lines = readLastLines(path, 15);
2173
+ console.log("");
2174
+ if (lines.length === 0) {
2175
+ console.log(`Recent ${label} (${path}): none yet.`);
2176
+ return;
2177
+ }
2178
+ console.log(`Recent ${label} (last ${lines.length} lines of ${path}):`);
2179
+ for (const line of lines) console.log(` ${line}`);
2180
+ }
2181
+ function cmdStatus() {
2182
+ const status = queryDaemonStatus();
2183
+ if (!status.loaded) {
2184
+ console.log(
2185
+ [
2186
+ "The background cost meter is NOT running (not installed/loaded).",
2187
+ ` Turn it on with: npx ${PKG_NAME} start`
2188
+ ].join("\n")
2189
+ );
2190
+ } else if (status.running) {
2191
+ console.log(
2192
+ "The background cost meter is running" + (status.pid ? ` (pid ${status.pid})` : "") + ". Your spend is being captured."
2193
+ );
2194
+ } else {
2195
+ console.log(
2196
+ "The background cost meter is installed but not running right now (launchd may be restarting it - check the errors below)."
2197
+ );
2198
+ }
2199
+ printDaemonLogTail("output", DAEMON_STDOUT_LOG_PATH);
2200
+ printDaemonLogTail("errors", DAEMON_STDERR_LOG_PATH);
2201
+ return 0;
2202
+ }
2203
+ function cmdPreview(positionals, foldCache) {
2204
+ const inputFilePath = positionals[0];
2205
+ if (!inputFilePath) {
2206
+ console.error(
2207
+ "preview needs a capture file: npx @marginfront/code-cost-clarity preview <capture.json>\nA capture is one OTLP/JSON snapshot - e.g. a line from ~/.marginfront-ccc/live-otlp.jsonl."
2208
+ );
2209
+ return 1;
2210
+ }
2211
+ const parsedOtlp = readAndParseOtlpFile(inputFilePath);
2212
+ const body = buildRequestBodyForLine(parsedOtlp, {
2213
+ foldCache,
2214
+ fallbackCustomerId: NO_IDENTITY_CUSTOMER
2215
+ });
2216
+ console.log(JSON.stringify(body, null, 2));
2217
+ const usedFallback = body.records.some(
2218
+ (r) => r.customerExternalId === NO_IDENTITY_CUSTOMER || r.customerExternalId === CODEX_NO_IDENTITY_CUSTOMER
2219
+ );
2220
+ if (usedFallback) {
2221
+ console.log("");
2222
+ printNoIdentityHint();
2223
+ }
2224
+ return 0;
2225
+ }
2226
+ function runningForwarderPid(pidFilePath) {
2227
+ if (!existsSync3(pidFilePath)) return null;
2228
+ try {
2229
+ const raw = readFileSync3(pidFilePath, "utf8").trim();
2230
+ const parsed = parseInt(raw, 10);
2231
+ if (Number.isInteger(parsed) && isPidAlive(parsed)) return parsed;
2232
+ } catch {
2233
+ }
2234
+ return null;
2235
+ }
2236
+ function cmdRun(foldCache, _overridePidPath) {
2237
+ const guardPath = _overridePidPath ?? FORWARDER_PID_PATH;
2238
+ const livePid = runningForwarderPid(guardPath);
2239
+ if (livePid !== null) {
2240
+ console.error(
2241
+ `A CCC forwarder is already running (pid ${livePid}).
2242
+ Stop it first: npx @marginfront/code-cost-clarity stop
2243
+ (If nothing seems to be running, 'stop' also clears the stale
2244
+ pid file at ${FORWARDER_PID_PATH}.)`
2245
+ );
2246
+ return 1;
2247
+ }
2248
+ loadEnvFile();
2249
+ if (!existsSync3(COLLECTOR_BIN_PATH) || !existsSync3(COLLECTOR_CONFIG_PATH)) {
2250
+ console.error(
2251
+ "Not set up yet. Run `npx @marginfront/code-cost-clarity init` first."
2252
+ );
2253
+ return 1;
2254
+ }
2255
+ if (!process3.env.MARGINFRONT_API_KEY) {
2256
+ console.error(
2257
+ [
2258
+ "No MARGINFRONT_API_KEY found, so there's nothing to send to.",
2259
+ `Fix: paste your MarginFront secret key (mf_sk_...) after MARGINFRONT_API_KEY= in ${ENV_PATH},`,
2260
+ " or export MARGINFRONT_API_KEY=... in this shell, then re-run.",
2261
+ "No key handy? `preview <capture.json>` shows what it would send without one."
2262
+ ].join("\n")
2263
+ );
2264
+ return 1;
2265
+ }
2266
+ const { startedByUs } = startCollector();
2267
+ setDesktopGuiEnv();
2268
+ console.log("");
2269
+ printNoIdentityHint();
2270
+ console.log("");
2271
+ const stopWatch = watchAndForward(LIVE_JSONL_PATH, {
2272
+ foldCache,
2273
+ fallbackCustomerId: NO_IDENTITY_CUSTOMER,
2274
+ // Resume from the last cursor so stop-then-run doesn't re-send (re-bill) a session.
2275
+ cursorPath: FORWARDER_CURSOR_PATH,
2276
+ // Park transient send failures instead of dropping them; drains each poll
2277
+ // (LOWAI-467 Half B - pairs with server dedup for exactly-once delivery).
2278
+ queuePath: FORWARDER_QUEUE_PATH,
2279
+ // Single-instance guard: watchAndForward writes process.pid here and removes it
2280
+ // on clean stop. cmdRun read it above to refuse a duplicate; now pass it so the
2281
+ // forwarder owns the slot for the lifetime of this run. Use guardPath so a
2282
+ // test-path override flows through to the forwarder too.
2283
+ pidFilePath: guardPath
2284
+ });
2285
+ const shutdown = () => {
2286
+ console.log("\nStopping\u2026");
2287
+ stopWatch();
2288
+ if (startedByUs) stopCollector();
2289
+ process3.exit(0);
2290
+ };
2291
+ process3.on("SIGINT", shutdown);
2292
+ process3.on("SIGTERM", shutdown);
2293
+ return null;
2294
+ }
2295
+ var STOP_FORWARDER_POLL_MS = 50;
2296
+ var STOP_FORWARDER_TIMEOUT_MS = 3e3;
2297
+ async function stopForwarderThenStopCollector(deps, pollMs, timeoutMs) {
2298
+ const log = deps.log ?? console.log;
2299
+ if (deps.isForwarderAlive()) {
2300
+ deps.signalForwarder("SIGTERM");
2301
+ const deadline = deps.now() + timeoutMs;
2302
+ while (deps.isForwarderAlive() && deps.now() < deadline) {
2303
+ await deps.sleep(pollMs);
2304
+ }
2305
+ if (deps.isForwarderAlive()) {
2306
+ log("Forwarder didn't stop on its own; forcing it down.");
2307
+ deps.signalForwarder("SIGKILL");
2308
+ const hardDeadline = deps.now() + timeoutMs;
2309
+ while (deps.isForwarderAlive() && deps.now() < hardDeadline) {
2310
+ await deps.sleep(pollMs);
2311
+ }
2312
+ }
2313
+ }
2314
+ deps.stopCollector();
2315
+ }
2316
+ async function cmdStop() {
2317
+ const daemonWasLoaded = bootoutDaemon();
2318
+ if (daemonWasLoaded) {
2319
+ console.log(
2320
+ "Paused the background daemon (it won't auto-restart until you run `start` or log in again)."
2321
+ );
2322
+ }
2323
+ let forwarderPid = null;
2324
+ if (existsSync3(FORWARDER_PID_PATH)) {
2325
+ try {
2326
+ const raw = readFileSync3(FORWARDER_PID_PATH, "utf8").trim();
2327
+ const parsed = parseInt(raw, 10);
2328
+ if (Number.isInteger(parsed)) forwarderPid = parsed;
2329
+ } catch {
2330
+ }
2331
+ }
2332
+ await stopForwarderThenStopCollector(
2333
+ {
2334
+ isForwarderAlive: () => forwarderPid !== null && isPidAlive(forwarderPid),
2335
+ signalForwarder: (signal) => {
2336
+ if (forwarderPid === null) return;
2337
+ try {
2338
+ process3.kill(forwarderPid, signal);
2339
+ if (signal === "SIGTERM") {
2340
+ console.log(
2341
+ `Sent SIGTERM to forwarder (pid ${forwarderPid}). Waiting for it to stop\u2026`
2342
+ );
2343
+ }
2344
+ } catch {
2345
+ }
2346
+ },
2347
+ stopCollector: () => {
2348
+ stopCollector();
2349
+ },
2350
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
2351
+ now: () => Date.now(),
2352
+ log: (message) => console.log(message)
2353
+ },
2354
+ STOP_FORWARDER_POLL_MS,
2355
+ STOP_FORWARDER_TIMEOUT_MS
2356
+ );
2357
+ if (existsSync3(FORWARDER_PID_PATH)) {
2358
+ try {
2359
+ rmSync3(FORWARDER_PID_PATH, { force: true });
2360
+ } catch {
2361
+ }
2362
+ }
2363
+ return 0;
2364
+ }
2365
+ function cmdUninstall(purge, deps = {}) {
2366
+ const log = deps.log ?? console.log;
2367
+ const removeDaemon = deps.uninstallDaemon ?? (() => uninstallDaemon({ log }));
2368
+ const stopColl = deps.stopCollector ?? (() => {
2369
+ stopCollector({ log });
2370
+ });
2371
+ const removeClaude = deps.removeClaudeTelemetry ?? (() => removeClaudeTelemetrySettings({ log }));
2372
+ const removeCodex = deps.removeCodexConfig ?? (() => removeCodexOtelConfig({ log }));
2373
+ const removeFile = deps.removeRuntimeFile ?? ((path) => {
2374
+ if (existsSync3(path)) rmSync3(path, { force: true });
2375
+ });
2376
+ const purgeDir = deps.purgeConfigDir ?? (() => {
2377
+ if (existsSync3(CONFIG_DIR))
2378
+ rmSync3(CONFIG_DIR, { recursive: true, force: true });
2379
+ });
2380
+ const findZshrc = deps.findZshrcLines ?? (() => findCccZshrcSourceLines());
2381
+ const unsetDesktop = deps.unsetDesktopEnv ?? (() => unsetDesktopGuiEnv());
2382
+ removeDaemon();
2383
+ stopColl();
2384
+ removeClaude();
2385
+ removeCodex();
2386
+ unsetDesktop();
2387
+ const toRemove = [
2388
+ COLLECTOR_BIN_PATH,
2389
+ COLLECTOR_CONFIG_PATH,
2390
+ LIVE_JSONL_PATH,
2391
+ COLLECTOR_PID_PATH,
2392
+ COLLECTOR_LOG_PATH,
2393
+ // Clear the resume cursor too - it points into the live file we just removed.
2394
+ FORWARDER_CURSOR_PATH,
2395
+ // And the retry queue - its records reference the now-removed live file (LOWAI-467).
2396
+ FORWARDER_QUEUE_PATH,
2397
+ // And the single-instance guard pidfile - no forwarder will be running after this.
2398
+ FORWARDER_PID_PATH
2399
+ ];
2400
+ for (const path of toRemove) removeFile(path);
2401
+ log("Removed the collector binary, its config, and runtime files.");
2402
+ if (purge) {
2403
+ purgeDir();
2404
+ log(
2405
+ `Purged ${CONFIG_DIR} - your saved settings and MarginFront secret key (mf_sk_...) are gone.`
2406
+ );
2407
+ } else {
2408
+ log(
2409
+ `Kept your settings at ${ENV_PATH}. Re-run init anytime to reinstall, or add --purge to delete settings too.`
2410
+ );
2411
+ }
2412
+ const zshrc = findZshrc();
2413
+ if (zshrc.found) {
2414
+ log(
2415
+ [
2416
+ "Reminder: your ~/.zshrc still has a leftover MarginFront line:",
2417
+ ...zshrc.matchedLines.map((line) => ` ${line}`),
2418
+ "It's harmless now, but you can delete it by hand if you like."
2419
+ ].join("\n")
2420
+ );
2421
+ }
2422
+ log(
2423
+ [
2424
+ "",
2425
+ "Reverted: the background daemon was removed, the collector stopped, the Claude",
2426
+ "telemetry settings and the Codex [otel] block undone, and the collector binary +",
2427
+ "runtime files deleted." + (purge ? " Your saved settings and MarginFront secret key (mf_sk_...) were purged too." : " Your saved settings and MarginFront secret key (mf_sk_...) were kept.")
2428
+ ].join("\n")
2429
+ );
2430
+ return 0;
2431
+ }
2432
+ async function main() {
2433
+ const argv = process3.argv.slice(2);
2434
+ const command = argv[0];
2435
+ const rest = argv.slice(1);
2436
+ const flags = new Set(rest.filter((a) => a.startsWith("--")));
2437
+ const positionals = rest.filter((a) => !a.startsWith("--"));
2438
+ const foldCache = flags.has("--fold-cache");
2439
+ switch (command) {
2440
+ case void 0:
2441
+ case "help":
2442
+ case "--help":
2443
+ case "-h":
2444
+ printHelp();
2445
+ return 0;
2446
+ case "version":
2447
+ case "--version":
2448
+ case "-v":
2449
+ console.log(readVersion());
2450
+ return 0;
2451
+ case "init":
2452
+ return cmdInit(flags.has("--no-prompt"));
2453
+ case "start":
2454
+ return cmdStart();
2455
+ case "status":
2456
+ return cmdStatus();
2457
+ case "preview":
2458
+ return cmdPreview(positionals, foldCache);
2459
+ case "run":
2460
+ return cmdRun(foldCache);
2461
+ case "stop":
2462
+ return cmdStop();
2463
+ case "uninstall":
2464
+ return cmdUninstall(flags.has("--purge"));
2465
+ default:
2466
+ console.error(`Unknown command "${command}".
2467
+ `);
2468
+ printHelp();
2469
+ return 1;
2470
+ }
2471
+ }
2472
+ var __thisFile = fileURLToPath2(import.meta.url);
2473
+ var __isEntryPoint = false;
2474
+ try {
2475
+ __isEntryPoint = process3.argv[1] !== void 0 && realpathSync(process3.argv[1]) === realpathSync(__thisFile);
2476
+ } catch {
2477
+ }
2478
+ if (__isEntryPoint) {
2479
+ main().then((code) => {
2480
+ if (code !== null && code !== void 0) process3.exit(code);
2481
+ }).catch((unexpectedError) => {
2482
+ console.error(
2483
+ `Something went wrong: ${unexpectedError.message}`
2484
+ );
2485
+ process3.exit(1);
2486
+ });
2487
+ }
2488
+ export {
2489
+ cmdInit,
2490
+ cmdRun,
2491
+ cmdStart,
2492
+ cmdUninstall,
2493
+ promptYesNo,
2494
+ runningForwarderPid,
2495
+ stopForwarderThenStopCollector
2496
+ };