my-llm-footprint 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/init.js ADDED
@@ -0,0 +1,217 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * my-llm-footprint CLI
4
+ *
5
+ * Commands:
6
+ * init (default) — validates API key, writes config, installs Claude hook
7
+ * uninstall — removes the hook and config
8
+ *
9
+ * Node.js built-ins only. Requires Node 18+ for fetch().
10
+ */
11
+
12
+ import fs from 'fs';
13
+ import path from 'path';
14
+ import os from 'os';
15
+ import readline from 'readline';
16
+ import { fileURLToPath } from 'url';
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // Paths
20
+ // ---------------------------------------------------------------------------
21
+
22
+ const HOME = os.homedir();
23
+ const CONFIG_DIR = path.join(HOME, '.config', 'my-llm-footprint');
24
+ const CONFIG_PATH = path.join(CONFIG_DIR, 'config.json');
25
+ const CLAUDE_SETTINGS_PATH = path.join(HOME, '.claude', 'settings.json');
26
+
27
+ const ENDPOINT = process.env.LLM_FOOTPRINT_ENDPOINT || 'https://llm-usages.mattjones.wales';
28
+
29
+ // Resolve the absolute path of the hooks directory relative to this file.
30
+ // import.meta.url → file:///…/packages/my-llm-footprint/bin/init.js
31
+ const PACKAGE_ROOT = path.resolve(fileURLToPath(import.meta.url), '..', '..');
32
+ const HOOK_SCRIPT = path.join(PACKAGE_ROOT, 'hooks', 'claude-stop.js');
33
+ const HOOK_COMMAND = `node ${HOOK_SCRIPT}`;
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // Helpers
37
+ // ---------------------------------------------------------------------------
38
+
39
+ /**
40
+ * Prompt the user for a single line of input.
41
+ * @param {string} question
42
+ * @returns {Promise<string>}
43
+ */
44
+ function prompt(question) {
45
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
46
+ return new Promise((resolve) => {
47
+ rl.question(question, (answer) => {
48
+ rl.close();
49
+ resolve(answer.trim());
50
+ });
51
+ });
52
+ }
53
+
54
+ /**
55
+ * Read and parse a JSON file, returning null on any error.
56
+ * @param {string} filePath
57
+ * @returns {object | null}
58
+ */
59
+ function readJson(filePath) {
60
+ try {
61
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
62
+ } catch {
63
+ return null;
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Write an object as pretty-printed JSON, creating parent dirs as needed.
69
+ * @param {string} filePath
70
+ * @param {object} data
71
+ */
72
+ function writeJson(filePath, data) {
73
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
74
+ fs.writeFileSync(filePath, JSON.stringify(data, null, 2) + '\n');
75
+ }
76
+
77
+ // ---------------------------------------------------------------------------
78
+ // init command
79
+ // ---------------------------------------------------------------------------
80
+
81
+ async function runInit() {
82
+ console.log('my-llm-footprint setup\n');
83
+
84
+ const apiKey = await prompt('Enter your API key: ');
85
+
86
+ if (!apiKey) {
87
+ console.error('Usage: my-llm-footprint init');
88
+ console.error('An API key is required.');
89
+ process.exit(1);
90
+ }
91
+
92
+ // --- Validate key ---
93
+ let userName;
94
+ try {
95
+ const res = await fetch(`${ENDPOINT}/api/v1/ingest/verify`, {
96
+ method: 'POST',
97
+ headers: { Authorization: `Bearer ${apiKey}` },
98
+ signal: AbortSignal.timeout(15000),
99
+ });
100
+
101
+ if (!res.ok) {
102
+ console.error(`Invalid API key (HTTP ${res.status}).`);
103
+ process.exit(1);
104
+ }
105
+
106
+ /** @type {{ user: string }} — matches VerifyResponse from @my-llm-footprint/shared */
107
+ const body = await res.json();
108
+ userName = body.user ?? 'unknown';
109
+ } catch (err) {
110
+ console.error(`Failed to validate API key: ${err.message}`);
111
+ process.exit(1);
112
+ }
113
+
114
+ console.log(`API key validated — welcome, ${userName}!`);
115
+
116
+ // --- Write config ---
117
+ const config = { apiKey, endpoint: ENDPOINT };
118
+ writeJson(CONFIG_PATH, config);
119
+
120
+ // --- Update ~/.claude/settings.json ---
121
+ let settings = readJson(CLAUDE_SETTINGS_PATH) ?? {};
122
+
123
+ // Ensure hooks.Stop is an array
124
+ if (!settings.hooks) settings.hooks = {};
125
+ if (!Array.isArray(settings.hooks.Stop)) {
126
+ settings.hooks.Stop = settings.hooks.Stop ? [settings.hooks.Stop] : [];
127
+ }
128
+
129
+ // Check whether our hook is already present
130
+ const alreadyInstalled = settings.hooks.Stop.some((entry) => {
131
+ const hooks = Array.isArray(entry?.hooks) ? entry.hooks : [];
132
+ return hooks.some((h) => typeof h.command === 'string' && h.command.includes('my-llm-footprint'));
133
+ });
134
+
135
+ if (alreadyInstalled) {
136
+ console.log('Hook already installed — skipping.');
137
+ } else {
138
+ settings.hooks.Stop.push({
139
+ hooks: [{ type: 'command', command: HOOK_COMMAND }],
140
+ });
141
+ writeJson(CLAUDE_SETTINGS_PATH, settings);
142
+ console.log('Claude Stop hook installed.');
143
+ }
144
+
145
+ // --- Done ---
146
+ console.log(`\nDone! Tracking usage for ${userName}.`);
147
+ console.log(`View your dashboard: ${ENDPOINT}/dashboard`);
148
+ }
149
+
150
+ // ---------------------------------------------------------------------------
151
+ // uninstall command
152
+ // ---------------------------------------------------------------------------
153
+
154
+ function runUninstall() {
155
+ // Remove hook from settings
156
+ const settings = readJson(CLAUDE_SETTINGS_PATH);
157
+ if (settings?.hooks?.Stop && Array.isArray(settings.hooks.Stop)) {
158
+ const before = settings.hooks.Stop.length;
159
+ settings.hooks.Stop = settings.hooks.Stop.filter((entry) => {
160
+ const hooks = Array.isArray(entry?.hooks) ? entry.hooks : [];
161
+ return !hooks.some((h) => typeof h.command === 'string' && h.command.includes('my-llm-footprint'));
162
+ });
163
+ if (settings.hooks.Stop.length !== before) {
164
+ writeJson(CLAUDE_SETTINGS_PATH, settings);
165
+ console.log('Removed hook from ~/.claude/settings.json');
166
+ }
167
+ }
168
+
169
+ // Remove config file
170
+ try {
171
+ fs.rmSync(CONFIG_PATH, { force: true });
172
+ console.log('Removed config file.');
173
+ } catch (err) {
174
+ console.error(`Could not remove config: ${err.message}`);
175
+ }
176
+
177
+ console.log('Uninstalled my-llm-footprint');
178
+ }
179
+
180
+ // ---------------------------------------------------------------------------
181
+ // Entry point
182
+ // ---------------------------------------------------------------------------
183
+
184
+ const args = process.argv.slice(2);
185
+ const command = args[0];
186
+
187
+ if (command === '--help' || command === '-h') {
188
+ console.log(`my-llm-footprint — track your Claude AI usage
189
+
190
+ Usage:
191
+ my-llm-footprint [init] Set up tracking (default command)
192
+ my-llm-footprint uninstall Remove hook and config
193
+ my-llm-footprint --help Show this help
194
+
195
+ Description:
196
+ init Prompts for an API key, validates it, writes
197
+ ~/.config/my-llm-footprint/config.json, and installs
198
+ a Claude Code Stop hook so every session is reported.
199
+
200
+ uninstall Removes the Stop hook from ~/.claude/settings.json
201
+ and deletes ~/.config/my-llm-footprint/config.json.
202
+ `);
203
+ process.exit(0);
204
+ }
205
+
206
+ if (!command || command === 'init') {
207
+ runInit().catch((err) => {
208
+ console.error(err.message);
209
+ process.exit(1);
210
+ });
211
+ } else if (command === 'uninstall') {
212
+ runUninstall();
213
+ } else {
214
+ console.error(`Unknown command: ${command}`);
215
+ console.error('Run with --help for usage.');
216
+ process.exit(1);
217
+ }
@@ -0,0 +1,414 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Claude Code hook: my-llm-footprint token reporter.
4
+ *
5
+ * Runs on every Claude Code Stop event.
6
+ * - Reads JSON from stdin (transcript_path, session_id)
7
+ * - Parses the JSONL transcript to extract token usage
8
+ * - POSTs raw counts to the configured API endpoint
9
+ * - Prints an energy/water summary to stderr
10
+ * - On failure: writes event to ~/.config/my-llm-footprint/pending.json
11
+ * - On success: flushes any pending events from the queue
12
+ *
13
+ * No external dependencies — Node.js built-ins only.
14
+ */
15
+
16
+ import fs from 'fs';
17
+ import path from 'path';
18
+ import os from 'os';
19
+
20
+ // --- EcoLogits-based energy/water constants ---
21
+ // Source: https://ecologits.ai/latest/methodology/llm_inference/
22
+ // GPU energy regression fitted to ML.ENERGY H100 benchmarks:
23
+ // gpu_energy_per_token (Wh) = α × e^(β × B) × P_active + γ
24
+ const GPU_ENERGY_ALPHA = 1.1665e-6;
25
+ const GPU_ENERGY_BETA = -0.01121;
26
+ const GPU_ENERGY_GAMMA = 4.0529e-5;
27
+ const BATCH_SIZE = 64;
28
+ const GPU_MEMORY_GB = 80;
29
+ const QUANTIZATION_BITS = 16;
30
+ const SERVER_POWER_KW = 1.2;
31
+ const SERVER_GPUS = 8;
32
+
33
+ // Default Anthropic provider config (USA datacenter)
34
+ const PUE = 1.11;
35
+ const WUE_ONSITE = 0.56; // L/kWh
36
+ const GRID_WUE_OFFSITE = 3.1321; // L/kWh
37
+
38
+ // Default model: Claude Sonnet-class (MoE, ~440B total, ~88B active)
39
+ const DEFAULT_TOTAL_PARAMS_B = 440;
40
+ const DEFAULT_ACTIVE_PARAMS_B = 88;
41
+
42
+ const PHONE_CHARGE_WH = 15.0;
43
+ const ML_PER_SIP = 20.0;
44
+
45
+ // --- Paths ---
46
+ const CONFIG_DIR = path.join(os.homedir(), '.config', 'my-llm-footprint');
47
+ const CONFIG_PATH = path.join(CONFIG_DIR, 'config.json');
48
+ const PENDING_PATH = path.join(CONFIG_DIR, 'pending.json');
49
+
50
+ // ---------------------------------------------------------------------------
51
+ // Transcript parsing
52
+ // ---------------------------------------------------------------------------
53
+
54
+ /**
55
+ * Parse a JSONL transcript file and return an array of per-turn usage objects.
56
+ * Each object has: input, output, cache_create, cache_read, model.
57
+ *
58
+ * @param {string} filePath
59
+ * @returns {{ input: number, output: number, cache_create: number, cache_read: number, model: string }[]}
60
+ */
61
+ function parseTranscript(filePath) {
62
+ let raw;
63
+ try {
64
+ raw = fs.readFileSync(filePath, 'utf8');
65
+ } catch {
66
+ return [];
67
+ }
68
+
69
+ const turns = [];
70
+ for (const line of raw.split('\n')) {
71
+ const trimmed = line.trim();
72
+ if (!trimmed) continue;
73
+
74
+ let entry;
75
+ try {
76
+ entry = JSON.parse(trimmed);
77
+ } catch {
78
+ continue;
79
+ }
80
+
81
+ if (entry.type !== 'assistant') continue;
82
+
83
+ const msg = entry.message;
84
+ if (!msg || typeof msg !== 'object') continue;
85
+
86
+ const usage = msg.usage;
87
+ if (!usage) continue;
88
+
89
+ turns.push({
90
+ input: usage.input_tokens || 0,
91
+ output: usage.output_tokens || 0,
92
+ cache_create: usage.cache_creation_input_tokens || 0,
93
+ cache_read: usage.cache_read_input_tokens || 0,
94
+ model: msg.model || entry.model || 'unknown',
95
+ });
96
+ }
97
+
98
+ return turns;
99
+ }
100
+
101
+ // ---------------------------------------------------------------------------
102
+ // Stats computation
103
+ // ---------------------------------------------------------------------------
104
+
105
+ /**
106
+ * @param {{ input: number, output: number, cache_create: number, cache_read: number, model: string }[]} turns
107
+ */
108
+ function computeStats(turns) {
109
+ let totalInput = 0;
110
+ let totalOutput = 0;
111
+ let totalCacheCreate = 0;
112
+ let totalCacheRead = 0;
113
+
114
+ for (const t of turns) {
115
+ totalInput += t.input;
116
+ totalOutput += t.output;
117
+ totalCacheCreate += t.cache_create;
118
+ totalCacheRead += t.cache_read;
119
+ }
120
+
121
+ const last = turns.length > 0 ? turns[turns.length - 1] : null;
122
+
123
+ const lastInput = last ? last.input : 0;
124
+ const lastOutput = last ? last.output : 0;
125
+ const lastCacheCreate = last ? last.cache_create : 0;
126
+ const lastCacheRead = last ? last.cache_read : 0;
127
+
128
+ // For energy/water calcs use combined input (input + cache tokens)
129
+ const totalInAll = totalInput + totalCacheCreate + totalCacheRead;
130
+ const lastInAll = lastInput + lastCacheCreate + lastCacheRead;
131
+ const totalTokens = totalInAll + totalOutput;
132
+ const lastTokens = lastInAll + lastOutput;
133
+
134
+ // EcoLogits-based calculations
135
+ function gpuCount(totalParamsB) {
136
+ const memGB = 1.2 * totalParamsB * QUANTIZATION_BITS / 8;
137
+ const raw = Math.ceil(memGB / GPU_MEMORY_GB);
138
+ return Math.pow(2, Math.ceil(Math.log2(Math.max(raw, 1))));
139
+ }
140
+
141
+ function energy(inp, out) {
142
+ const gpus = gpuCount(DEFAULT_TOTAL_PARAMS_B);
143
+ const whPerToken = GPU_ENERGY_ALPHA * Math.exp(GPU_ENERGY_BETA * BATCH_SIZE) * DEFAULT_ACTIVE_PARAMS_B + GPU_ENERGY_GAMMA;
144
+ const gpuEnergy = out * (whPerToken / 1000); // kWh
145
+ const tps = DEFAULT_ACTIVE_PARAMS_B < 30 ? 80 : DEFAULT_ACTIVE_PARAMS_B < 100 ? 50 : 30;
146
+ const latency = out / tps;
147
+ const srvEnergy = (latency / 3600) * SERVER_POWER_KW * (gpus / SERVER_GPUS) * (1 / BATCH_SIZE);
148
+ return PUE * (srvEnergy + gpus * gpuEnergy) * 1000; // Wh
149
+ }
150
+
151
+ function water(tok, energyWh) {
152
+ const kwh = (energyWh !== undefined ? energyWh : energy(Math.round(tok * 0.8), tok - Math.round(tok * 0.8))) / 1000;
153
+ const liters = kwh * (WUE_ONSITE + PUE * GRID_WUE_OFFSITE);
154
+ return liters * 1000; // mL
155
+ }
156
+
157
+ return {
158
+ turns: turns.length,
159
+ model: last ? last.model : 'unknown',
160
+
161
+ // Raw delta (last turn) — for API payload
162
+ deltaInput: lastInput,
163
+ deltaOutput: lastOutput,
164
+ deltaCacheCreate: lastCacheCreate,
165
+ deltaCacheRead: lastCacheRead,
166
+
167
+ // Cumulative raw — for API payload
168
+ cumulativeIn: totalInput,
169
+ cumulativeOut: totalOutput,
170
+
171
+ // For display
172
+ totalTokens,
173
+ totalInAll,
174
+ totalOutput,
175
+ lastTokens,
176
+ lastInAll,
177
+ lastOutput,
178
+
179
+ energySessionWh: energy(totalInAll, totalOutput),
180
+ energyLastWh: energy(lastInAll, lastOutput),
181
+ waterSessionMl: water(totalTokens, energy(totalInAll, totalOutput)),
182
+ waterLastMl: water(lastTokens, energy(lastInAll, lastOutput)),
183
+ };
184
+ }
185
+
186
+ // ---------------------------------------------------------------------------
187
+ // Stderr output
188
+ // ---------------------------------------------------------------------------
189
+
190
+ /**
191
+ * @param {ReturnType<typeof computeStats>} s
192
+ */
193
+ function formatOutput(s) {
194
+ if (s.turns === 0) return '';
195
+
196
+ const lines = [];
197
+
198
+ lines.push(
199
+ `Energy ${s.energySessionWh.toFixed(4)} Wh session | ${s.energyLastWh.toFixed(4)} Wh last turn`,
200
+ );
201
+
202
+ lines.push(
203
+ `Water ${s.waterSessionMl.toFixed(2)} mL session | ${s.waterLastMl.toFixed(2)} mL last turn`,
204
+ );
205
+
206
+ const turnWord = s.turns === 1 ? 'turn' : 'turns';
207
+ lines.push(
208
+ `Tokens ${s.totalTokens.toLocaleString()} total ` +
209
+ `(${s.totalInAll.toLocaleString()} in / ${s.totalOutput.toLocaleString()} out) ` +
210
+ `over ${s.turns} ${turnWord}`,
211
+ );
212
+
213
+ const phonePct = (s.energySessionWh / PHONE_CHARGE_WH) * 100;
214
+ const sips = s.waterSessionMl / ML_PER_SIP;
215
+
216
+ const comparisons = [];
217
+ comparisons.push(phonePct < 0.01 ? '<0.01% of a phone charge' : `${phonePct.toFixed(2)}% of a phone charge`);
218
+ comparisons.push(sips < 0.1 ? '<0.1 sips of water' : `~${sips.toFixed(1)} sips of water`);
219
+
220
+ lines.push(` = ${comparisons.join(' | ')}`);
221
+
222
+ return lines.join('\n');
223
+ }
224
+
225
+ // ---------------------------------------------------------------------------
226
+ // Config
227
+ // ---------------------------------------------------------------------------
228
+
229
+ /**
230
+ * @returns {{ apiKey: string, endpoint: string } | null}
231
+ */
232
+ function readConfig() {
233
+ try {
234
+ const raw = fs.readFileSync(CONFIG_PATH, 'utf8');
235
+ const cfg = JSON.parse(raw);
236
+ if (cfg && cfg.apiKey && cfg.endpoint) return cfg;
237
+ } catch {
238
+ // Config not yet installed or unreadable — silent skip
239
+ }
240
+ return null;
241
+ }
242
+
243
+ // ---------------------------------------------------------------------------
244
+ // Pending queue
245
+ // ---------------------------------------------------------------------------
246
+
247
+ /**
248
+ * @returns {object[]}
249
+ */
250
+ function readPending() {
251
+ try {
252
+ const raw = fs.readFileSync(PENDING_PATH, 'utf8');
253
+ const parsed = JSON.parse(raw);
254
+ return Array.isArray(parsed) ? parsed : [];
255
+ } catch {
256
+ return [];
257
+ }
258
+ }
259
+
260
+ /**
261
+ * @param {object[]} queue
262
+ */
263
+ function writePending(queue) {
264
+ try {
265
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
266
+ fs.writeFileSync(PENDING_PATH, JSON.stringify(queue, null, 2));
267
+ } catch {
268
+ // Best-effort
269
+ }
270
+ }
271
+
272
+ // ---------------------------------------------------------------------------
273
+ // API call
274
+ // ---------------------------------------------------------------------------
275
+
276
+ /**
277
+ * POST a single event payload to the API.
278
+ *
279
+ * @param {{ apiKey: string, endpoint: string }} config
280
+ * @param {object} payload
281
+ * @returns {Promise<void>} Resolves on 2xx, rejects otherwise.
282
+ */
283
+ async function postEvent(config, payload) {
284
+ const url = `${config.endpoint.replace(/\/$/, '')}/api/v1/ingest/events`;
285
+
286
+ const res = await fetch(url, {
287
+ method: 'POST',
288
+ headers: {
289
+ 'Content-Type': 'application/json',
290
+ Authorization: `Bearer ${config.apiKey}`,
291
+ },
292
+ body: JSON.stringify(payload),
293
+ signal: AbortSignal.timeout(10000),
294
+ });
295
+
296
+ if (!res.ok) {
297
+ throw new Error(`HTTP ${res.status}: ${await res.text()}`);
298
+ }
299
+ }
300
+
301
+ // ---------------------------------------------------------------------------
302
+ // Flush pending queue
303
+ // ---------------------------------------------------------------------------
304
+
305
+ /**
306
+ * Attempt to flush all pending events. Silently stops on first failure.
307
+ *
308
+ * @param {{ apiKey: string, endpoint: string }} config
309
+ */
310
+ async function flushPending(config) {
311
+ const queue = readPending();
312
+ if (queue.length === 0) return;
313
+
314
+ const remaining = [];
315
+ for (const event of queue) {
316
+ try {
317
+ await postEvent(config, event);
318
+ } catch {
319
+ // Keep this and all subsequent events for next time
320
+ remaining.push(event);
321
+ break;
322
+ }
323
+ }
324
+
325
+ // If we drained everything, remove the file; otherwise write what's left
326
+ if (remaining.length === 0) {
327
+ try {
328
+ fs.unlinkSync(PENDING_PATH);
329
+ } catch {
330
+ writePending([]);
331
+ }
332
+ } else {
333
+ // Find where we stopped and preserve the rest
334
+ const failedIdx = queue.indexOf(remaining[0]);
335
+ writePending(failedIdx >= 0 ? queue.slice(failedIdx) : queue);
336
+ }
337
+ }
338
+
339
+ // ---------------------------------------------------------------------------
340
+ // Main
341
+ // ---------------------------------------------------------------------------
342
+
343
+ async function main() {
344
+ // 1. Read stdin
345
+ let stdinData = '';
346
+ process.stdin.setEncoding('utf8');
347
+ for await (const chunk of process.stdin) {
348
+ stdinData += chunk;
349
+ }
350
+
351
+ let event;
352
+ try {
353
+ event = JSON.parse(stdinData);
354
+ } catch {
355
+ // Malformed input — nothing to do
356
+ process.exit(0);
357
+ }
358
+
359
+ const transcriptPath = event.transcript_path || '';
360
+ const sessionId = event.session_id || 'unknown';
361
+
362
+ // 2. Parse transcript
363
+ if (!transcriptPath || !fs.existsSync(transcriptPath)) {
364
+ process.exit(0);
365
+ }
366
+
367
+ const turns = parseTranscript(transcriptPath);
368
+
369
+ // 3. Print energy/water summary to stderr (even without config)
370
+ if (turns.length > 0) {
371
+ const stats = computeStats(turns);
372
+ const output = formatOutput(stats);
373
+ if (output) {
374
+ process.stderr.write(`\n${output}\n`);
375
+ }
376
+
377
+ // 4. Read API config
378
+ const config = readConfig();
379
+ if (!config) {
380
+ // No config installed yet — just display the summary and exit
381
+ process.exit(0);
382
+ }
383
+
384
+ // 5. Build payload
385
+ const payload = {
386
+ session_id: sessionId,
387
+ provider: 'anthropic',
388
+ model: stats.model,
389
+ input_tokens: stats.deltaInput,
390
+ output_tokens: stats.deltaOutput,
391
+ cache_create: stats.deltaCacheCreate,
392
+ cache_read: stats.deltaCacheRead,
393
+ cumulative_in: stats.cumulativeIn,
394
+ cumulative_out: stats.cumulativeOut,
395
+ turn_number: stats.turns,
396
+ };
397
+
398
+ // 6. POST to API
399
+ try {
400
+ await postEvent(config, payload);
401
+ // 7. On success: flush pending queue
402
+ await flushPending(config);
403
+ } catch {
404
+ // 6a. On failure: queue the event
405
+ const queue = readPending();
406
+ queue.push(payload);
407
+ writePending(queue);
408
+ }
409
+ }
410
+
411
+ process.exit(0);
412
+ }
413
+
414
+ main().catch(() => process.exit(0));
package/package.json ADDED
@@ -0,0 +1,10 @@
1
+ {
2
+ "name": "my-llm-footprint",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "bin": {
6
+ "my-llm-footprint": "./bin/init.js"
7
+ },
8
+ "files": ["bin", "hooks"],
9
+ "engines": { "node": ">=18" }
10
+ }