kerf-cli 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/dist/index.js ADDED
@@ -0,0 +1,1704 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli/index.ts
4
+ import { Command } from "commander";
5
+
6
+ // src/cli/commands/watch.ts
7
+ import React2 from "react";
8
+ import { render } from "ink";
9
+
10
+ // src/core/parser.ts
11
+ import { readFileSync, statSync } from "node:fs";
12
+ import { readdir } from "node:fs/promises";
13
+ import { join as join2, basename } from "node:path";
14
+ import dayjs from "dayjs";
15
+ import { watch } from "chokidar";
16
+
17
+ // src/core/config.ts
18
+ import { homedir } from "node:os";
19
+ import { join } from "node:path";
20
+ var DEFAULT_CONFIG = {
21
+ defaultModel: "sonnet",
22
+ budgetWarningThreshold: 80,
23
+ budgetBlockThreshold: 100,
24
+ pollingInterval: 2e3,
25
+ dataDir: join(homedir(), ".kerf"),
26
+ enableHooks: true
27
+ };
28
+ var CONTEXT_WINDOW_SIZE = 2e5;
29
+ var SYSTEM_PROMPT_TOKENS = 14328;
30
+ var BUILT_IN_TOOLS_TOKENS = 15e3;
31
+ var AUTOCOMPACT_BUFFER_TOKENS = 33e3;
32
+ var MCP_TOKENS_PER_TOOL = 600;
33
+ var BILLING_WINDOW_HOURS = 5;
34
+ var CLAUDE_PROJECTS_DIR = join(homedir(), ".claude", "projects");
35
+ var CLAUDE_SETTINGS_GLOBAL = join(homedir(), ".claude", "settings.json");
36
+ var KERF_DB_PATH = join(homedir(), ".kerf", "kerf.db");
37
+ var KERF_SESSION_LOG = join(homedir(), ".kerf", "session-log.jsonl");
38
+
39
+ // src/core/parser.ts
40
+ function extractUsage(raw) {
41
+ return raw.message?.usage ?? raw.usage ?? raw.delta?.usage ?? null;
42
+ }
43
+ function extractMessageId(raw) {
44
+ return raw.message?.id ?? null;
45
+ }
46
+ function extractModel(raw) {
47
+ return raw.message?.model ?? null;
48
+ }
49
+ function extractTimestamp(raw) {
50
+ return raw.timestamp ?? dayjs().toISOString();
51
+ }
52
+ function parseJsonlLine(line) {
53
+ const trimmed = line.trim();
54
+ if (!trimmed) return null;
55
+ try {
56
+ return JSON.parse(trimmed);
57
+ } catch {
58
+ return null;
59
+ }
60
+ }
61
+ function parseJsonlContent(content, sessionId) {
62
+ const lines = content.split("\n");
63
+ const messageMap = /* @__PURE__ */ new Map();
64
+ let anonymousCounter = 0;
65
+ for (const line of lines) {
66
+ const raw = parseJsonlLine(line);
67
+ if (!raw) continue;
68
+ const usage = extractUsage(raw);
69
+ if (!usage) continue;
70
+ const id = extractMessageId(raw) ?? `anon_${anonymousCounter++}`;
71
+ const model = extractModel(raw) ?? "unknown";
72
+ const timestamp = extractTimestamp(raw);
73
+ const existing = messageMap.get(id);
74
+ const parsedUsage = {
75
+ input_tokens: usage.input_tokens ?? existing?.usage.input_tokens ?? 0,
76
+ output_tokens: usage.output_tokens ?? existing?.usage.output_tokens ?? 0,
77
+ cache_creation_input_tokens: usage.cache_creation_input_tokens ?? existing?.usage.cache_creation_input_tokens ?? 0,
78
+ cache_read_input_tokens: usage.cache_read_input_tokens ?? existing?.usage.cache_read_input_tokens ?? 0
79
+ };
80
+ messageMap.set(id, {
81
+ id,
82
+ model: model !== "unknown" ? model : existing?.model ?? "unknown",
83
+ timestamp,
84
+ usage: parsedUsage,
85
+ totalCostUsd: raw.total_cost_usd ?? existing?.totalCostUsd ?? null
86
+ });
87
+ }
88
+ return Array.from(messageMap.values());
89
+ }
90
+ function parseSessionFile(filePath) {
91
+ const content = readFileSync(filePath, "utf-8");
92
+ const sessionId = basename(filePath, ".jsonl");
93
+ const messages = parseJsonlContent(content, sessionId);
94
+ const totals = messages.reduce(
95
+ (acc, msg) => ({
96
+ input: acc.input + msg.usage.input_tokens,
97
+ output: acc.output + msg.usage.output_tokens,
98
+ cacheRead: acc.cacheRead + msg.usage.cache_read_input_tokens,
99
+ cacheCreation: acc.cacheCreation + msg.usage.cache_creation_input_tokens,
100
+ cost: acc.cost + (msg.totalCostUsd ?? 0)
101
+ }),
102
+ { input: 0, output: 0, cacheRead: 0, cacheCreation: 0, cost: 0 }
103
+ );
104
+ const timestamps = messages.map((m) => m.timestamp).sort();
105
+ return {
106
+ sessionId,
107
+ filePath,
108
+ messages,
109
+ totalInputTokens: totals.input,
110
+ totalOutputTokens: totals.output,
111
+ totalCacheReadTokens: totals.cacheRead,
112
+ totalCacheCreationTokens: totals.cacheCreation,
113
+ totalCostUsd: totals.cost,
114
+ startTime: timestamps[0] ?? "",
115
+ endTime: timestamps[timestamps.length - 1] ?? "",
116
+ messageCount: messages.length
117
+ };
118
+ }
119
+ async function findJsonlFiles(baseDir) {
120
+ const dir = baseDir ?? CLAUDE_PROJECTS_DIR;
121
+ const files = [];
122
+ async function walk(currentDir) {
123
+ let entries;
124
+ try {
125
+ entries = await readdir(currentDir, { withFileTypes: true });
126
+ } catch {
127
+ return;
128
+ }
129
+ for (const entry of entries) {
130
+ const fullPath = join2(currentDir, entry.name);
131
+ if (entry.isDirectory()) {
132
+ await walk(fullPath);
133
+ } else if (entry.name.endsWith(".jsonl")) {
134
+ files.push(fullPath);
135
+ }
136
+ }
137
+ }
138
+ await walk(dir);
139
+ return files;
140
+ }
141
+ async function getActiveSessions(baseDir) {
142
+ const files = await findJsonlFiles(baseDir);
143
+ const cutoff = dayjs().subtract(BILLING_WINDOW_HOURS, "hour");
144
+ const activeSessions = [];
145
+ for (const filePath of files) {
146
+ try {
147
+ const stat = statSync(filePath);
148
+ const lastModified = dayjs(stat.mtime);
149
+ if (lastModified.isAfter(cutoff)) {
150
+ const content = readFileSync(filePath, "utf-8");
151
+ const sessionId = basename(filePath, ".jsonl");
152
+ const messages = parseJsonlContent(content, sessionId);
153
+ if (messages.length === 0) continue;
154
+ const timestamps = messages.map((m) => m.timestamp).sort();
155
+ activeSessions.push({
156
+ sessionId,
157
+ filePath,
158
+ messages,
159
+ startTime: timestamps[0] ?? "",
160
+ endTime: timestamps[timestamps.length - 1] ?? "",
161
+ lastModified: stat.mtime
162
+ });
163
+ }
164
+ } catch {
165
+ continue;
166
+ }
167
+ }
168
+ return activeSessions.sort(
169
+ (a, b) => b.lastModified.getTime() - a.lastModified.getTime()
170
+ );
171
+ }
172
+
173
+ // src/cli/ui/Dashboard.tsx
174
+ import { useState, useEffect } from "react";
175
+ import { Box as Box3, Text as Text3, useInput, useApp } from "ink";
176
+
177
+ // src/cli/ui/CostMeter.tsx
178
+ import { Box, Text } from "ink";
179
+
180
+ // src/core/costCalculator.ts
181
+ import dayjs2 from "dayjs";
182
+ import isoWeek from "dayjs/plugin/isoWeek.js";
183
+ dayjs2.extend(isoWeek);
184
+ var MODEL_PRICING = {
185
+ "claude-sonnet-4-20250514": {
186
+ input: 3,
187
+ output: 15,
188
+ cacheRead: 0.3,
189
+ cacheCreation: 3.75
190
+ },
191
+ "claude-opus-4-20250514": {
192
+ input: 15,
193
+ output: 75,
194
+ cacheRead: 1.5,
195
+ cacheCreation: 18.75
196
+ },
197
+ "claude-haiku-4-20250514": {
198
+ input: 0.8,
199
+ output: 4,
200
+ cacheRead: 0.08,
201
+ cacheCreation: 1
202
+ }
203
+ };
204
+ var MODEL_ALIASES = {
205
+ sonnet: "claude-sonnet-4-20250514",
206
+ opus: "claude-opus-4-20250514",
207
+ haiku: "claude-haiku-4-20250514"
208
+ };
209
+ function resolveModelPricing(model) {
210
+ const resolved = MODEL_ALIASES[model] ?? model;
211
+ if (MODEL_PRICING[resolved]) return MODEL_PRICING[resolved];
212
+ const match = Object.keys(MODEL_PRICING).find((k) => resolved.startsWith(k) || k.startsWith(resolved));
213
+ if (match) return MODEL_PRICING[match];
214
+ return MODEL_PRICING["claude-sonnet-4-20250514"];
215
+ }
216
+ function calculateMessageCost(msg) {
217
+ if (msg.totalCostUsd !== null && msg.totalCostUsd > 0) {
218
+ return {
219
+ inputCost: 0,
220
+ outputCost: 0,
221
+ cacheReadCost: 0,
222
+ cacheCreationCost: 0,
223
+ totalCost: msg.totalCostUsd
224
+ };
225
+ }
226
+ const pricing = resolveModelPricing(msg.model);
227
+ const MILLION = 1e6;
228
+ const inputCost = msg.usage.input_tokens * pricing.input / MILLION;
229
+ const outputCost = msg.usage.output_tokens * pricing.output / MILLION;
230
+ const cacheReadCost = msg.usage.cache_read_input_tokens * pricing.cacheRead / MILLION;
231
+ const cacheCreationCost = msg.usage.cache_creation_input_tokens * pricing.cacheCreation / MILLION;
232
+ return {
233
+ inputCost,
234
+ outputCost,
235
+ cacheReadCost,
236
+ cacheCreationCost,
237
+ totalCost: inputCost + outputCost + cacheReadCost + cacheCreationCost
238
+ };
239
+ }
240
+ function calculateCostVelocity(messages) {
241
+ if (messages.length < 2) {
242
+ return { dollarsPerMinute: 0, tokensPerMinute: 0, projectedWindowCost: 0, minutesRemaining: 0 };
243
+ }
244
+ const recent = messages.slice(-10);
245
+ const firstTime = dayjs2(recent[0].timestamp);
246
+ const lastTime = dayjs2(recent[recent.length - 1].timestamp);
247
+ const durationMinutes = lastTime.diff(firstTime, "minute", true);
248
+ if (durationMinutes <= 0) {
249
+ return { dollarsPerMinute: 0, tokensPerMinute: 0, projectedWindowCost: 0, minutesRemaining: 0 };
250
+ }
251
+ let totalCost = 0;
252
+ let totalTokens = 0;
253
+ for (const msg of recent) {
254
+ totalCost += calculateMessageCost(msg).totalCost;
255
+ totalTokens += msg.usage.input_tokens + msg.usage.output_tokens;
256
+ }
257
+ const dollarsPerMinute = totalCost / durationMinutes;
258
+ const tokensPerMinute = totalTokens / durationMinutes;
259
+ const windowMinutes = BILLING_WINDOW_HOURS * 60;
260
+ const projectedWindowCost = dollarsPerMinute * windowMinutes;
261
+ const windowStart = dayjs2().subtract(BILLING_WINDOW_HOURS, "hour");
262
+ const elapsed = dayjs2().diff(windowStart, "minute", true);
263
+ const minutesRemaining = Math.max(0, windowMinutes - elapsed);
264
+ return { dollarsPerMinute, tokensPerMinute, projectedWindowCost, minutesRemaining };
265
+ }
266
+ function aggregateCosts(messages, period) {
267
+ const groups = /* @__PURE__ */ new Map();
268
+ for (const msg of messages) {
269
+ const key = getPeriodKey(msg.timestamp, period);
270
+ const group = groups.get(key) ?? { messages: [], sessions: /* @__PURE__ */ new Set() };
271
+ group.messages.push(msg);
272
+ group.sessions.add(msg.id.split("_")[0] ?? msg.id);
273
+ groups.set(key, group);
274
+ }
275
+ return Array.from(groups.entries()).map(([key, group]) => {
276
+ let totalCost = 0;
277
+ let totalInput = 0;
278
+ let totalOutput = 0;
279
+ for (const msg of group.messages) {
280
+ totalCost += calculateMessageCost(msg).totalCost;
281
+ totalInput += msg.usage.input_tokens;
282
+ totalOutput += msg.usage.output_tokens;
283
+ }
284
+ return {
285
+ period: key,
286
+ periodLabel: formatPeriodLabel(key, period),
287
+ totalCost,
288
+ totalInputTokens: totalInput,
289
+ totalOutputTokens: totalOutput,
290
+ messageCount: group.messages.length,
291
+ sessionCount: group.sessions.size
292
+ };
293
+ });
294
+ }
295
+ function getPeriodKey(timestamp, period) {
296
+ const d = dayjs2(timestamp);
297
+ switch (period) {
298
+ case "hour":
299
+ return d.format("YYYY-MM-DD-HH");
300
+ case "day":
301
+ return d.format("YYYY-MM-DD");
302
+ case "billing_window":
303
+ const hour = d.hour();
304
+ const windowStart = Math.floor(hour / BILLING_WINDOW_HOURS) * BILLING_WINDOW_HOURS;
305
+ return `${d.format("YYYY-MM-DD")}-W${windowStart}`;
306
+ case "week":
307
+ return `${d.isoWeekYear()}-W${String(d.isoWeek()).padStart(2, "0")}`;
308
+ case "month":
309
+ return d.format("YYYY-MM");
310
+ case "session":
311
+ default:
312
+ return timestamp;
313
+ }
314
+ }
315
+ function formatPeriodLabel(key, period) {
316
+ switch (period) {
317
+ case "hour":
318
+ return dayjs2(key, "YYYY-MM-DD-HH").format("MMM D, h A");
319
+ case "day":
320
+ return dayjs2(key).format("ddd, MMM D");
321
+ case "week":
322
+ return `Week ${key.split("-W")[1]}`;
323
+ case "month":
324
+ return dayjs2(key).format("MMMM YYYY");
325
+ default:
326
+ return key;
327
+ }
328
+ }
329
+ function formatCost(cost) {
330
+ return `$${cost.toFixed(2)}`;
331
+ }
332
+ function formatTokens(tokens) {
333
+ if (tokens >= 1e6) return `${(tokens / 1e6).toFixed(1)}M`;
334
+ if (tokens >= 1e3) return `${(tokens / 1e3).toFixed(1)}K`;
335
+ return String(tokens);
336
+ }
337
+
338
+ // src/cli/ui/CostMeter.tsx
339
+ import { jsx, jsxs } from "react/jsx-runtime";
340
+ function CostMeter({ spent, windowBudget, burnRate, minutesRemaining, model }) {
341
+ const pct = windowBudget > 0 ? spent / windowBudget * 100 : 0;
342
+ const color = pct < 50 ? "green" : pct < 80 ? "yellow" : "red";
343
+ const hours = Math.floor(minutesRemaining / 60);
344
+ const mins = Math.round(minutesRemaining % 60);
345
+ const timeStr = hours > 0 ? `${hours}h ${mins}m` : `${mins}m`;
346
+ return /* @__PURE__ */ jsx(Box, { flexDirection: "column", paddingX: 1, children: /* @__PURE__ */ jsxs(Text, { children: [
347
+ /* @__PURE__ */ jsx(Text, { color, children: ">> " }),
348
+ /* @__PURE__ */ jsx(Text, { bold: true, children: formatCost(spent) }),
349
+ /* @__PURE__ */ jsxs(Text, { dimColor: true, children: [
350
+ " / ~",
351
+ formatCost(windowBudget),
352
+ " window"
353
+ ] }),
354
+ /* @__PURE__ */ jsx(Text, { children: " | " }),
355
+ /* @__PURE__ */ jsxs(Text, { color, children: [
356
+ formatCost(burnRate),
357
+ "/min"
358
+ ] }),
359
+ /* @__PURE__ */ jsx(Text, { children: " | " }),
360
+ /* @__PURE__ */ jsxs(Text, { children: [
361
+ "~",
362
+ timeStr,
363
+ " remaining"
364
+ ] }),
365
+ /* @__PURE__ */ jsxs(Text, { dimColor: true, children: [
366
+ " [",
367
+ model,
368
+ "]"
369
+ ] })
370
+ ] }) });
371
+ }
372
+
373
+ // src/cli/ui/ContextBar.tsx
374
+ import { Box as Box2, Text as Text2 } from "ink";
375
+ import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
376
+ function ContextBar({ used, total, overhead }) {
377
+ const barWidth = 30;
378
+ const usedPct = total > 0 ? used / total * 100 : 0;
379
+ const filledCount = Math.round(usedPct / 100 * barWidth);
380
+ const emptyCount = barWidth - filledCount;
381
+ const color = usedPct < 50 ? "green" : usedPct < 80 ? "yellow" : "red";
382
+ const filled = "\u2588".repeat(filledCount);
383
+ const empty = "\u2591".repeat(emptyCount);
384
+ const formatK = (n) => `${Math.round(n / 1e3)}K`;
385
+ return /* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", paddingX: 1, children: [
386
+ /* @__PURE__ */ jsxs2(Text2, { children: [
387
+ /* @__PURE__ */ jsx2(Text2, { children: "[" }),
388
+ /* @__PURE__ */ jsx2(Text2, { color, children: filled }),
389
+ /* @__PURE__ */ jsx2(Text2, { dimColor: true, children: empty }),
390
+ /* @__PURE__ */ jsx2(Text2, { children: "]" }),
391
+ /* @__PURE__ */ jsxs2(Text2, { children: [
392
+ " ",
393
+ usedPct.toFixed(0),
394
+ "%"
395
+ ] }),
396
+ /* @__PURE__ */ jsxs2(Text2, { children: [
397
+ " | ",
398
+ formatK(used),
399
+ " / ",
400
+ formatK(total),
401
+ " tokens"
402
+ ] })
403
+ ] }),
404
+ /* @__PURE__ */ jsxs2(Text2, { dimColor: true, children: [
405
+ " ",
406
+ "system(",
407
+ formatK(overhead.systemPrompt),
408
+ ") + tools(",
409
+ formatK(overhead.builtInTools),
410
+ ") + mcp(",
411
+ formatK(overhead.mcpTools),
412
+ ") + claude.md(",
413
+ formatK(overhead.claudeMd),
414
+ ")"
415
+ ] })
416
+ ] });
417
+ }
418
+
419
+ // src/core/tokenCounter.ts
420
+ import { readFileSync as readFileSync2, existsSync } from "node:fs";
421
+ import { join as join3 } from "node:path";
422
+ import { homedir as homedir2 } from "node:os";
423
+ function estimateTokens(text) {
424
+ return Math.ceil(text.length / 3.5);
425
+ }
426
+ function countFileTokens(filePath) {
427
+ try {
428
+ const content = readFileSync2(filePath, "utf-8");
429
+ return estimateTokens(content);
430
+ } catch {
431
+ return 0;
432
+ }
433
+ }
434
+ function parseClaudeMdSections(content) {
435
+ const lines = content.split("\n");
436
+ const sections = [];
437
+ let currentTitle = "Preamble";
438
+ let currentContent = [];
439
+ let currentLineStart = 1;
440
+ for (let i = 0; i < lines.length; i++) {
441
+ const line = lines[i];
442
+ if (line.startsWith("## ")) {
443
+ if (currentContent.length > 0 || currentTitle !== "Preamble") {
444
+ const sectionContent = currentContent.join("\n");
445
+ sections.push({
446
+ title: currentTitle,
447
+ content: sectionContent,
448
+ tokens: estimateTokens(sectionContent),
449
+ lineStart: currentLineStart,
450
+ lineEnd: i
451
+ });
452
+ }
453
+ currentTitle = line.replace(/^##\s*/, "");
454
+ currentContent = [];
455
+ currentLineStart = i + 1;
456
+ } else {
457
+ currentContent.push(line);
458
+ }
459
+ }
460
+ if (currentContent.length > 0) {
461
+ const sectionContent = currentContent.join("\n");
462
+ sections.push({
463
+ title: currentTitle,
464
+ content: sectionContent,
465
+ tokens: estimateTokens(sectionContent),
466
+ lineStart: currentLineStart,
467
+ lineEnd: lines.length
468
+ });
469
+ }
470
+ return sections;
471
+ }
472
+ function analyzeClaudeMd(filePath) {
473
+ const paths = filePath ? [filePath] : [
474
+ join3(process.cwd(), "CLAUDE.md"),
475
+ join3(process.cwd(), ".claude", "CLAUDE.md")
476
+ ];
477
+ for (const p of paths) {
478
+ if (existsSync(p)) {
479
+ const content = readFileSync2(p, "utf-8");
480
+ const sections = parseClaudeMdSections(content);
481
+ const totalTokens = sections.reduce((sum, s) => sum + s.tokens, 0);
482
+ const heavySections = sections.filter((s) => s.tokens > 500);
483
+ return { totalTokens, sections, heavySections };
484
+ }
485
+ }
486
+ return { totalTokens: 0, sections: [], heavySections: [] };
487
+ }
488
+ function analyzeMcpServers() {
489
+ const servers = [];
490
+ const paths = [
491
+ join3(process.cwd(), ".mcp.json"),
492
+ join3(homedir2(), ".claude.json")
493
+ ];
494
+ for (const configPath of paths) {
495
+ if (!existsSync(configPath)) continue;
496
+ try {
497
+ const raw = JSON.parse(readFileSync2(configPath, "utf-8"));
498
+ const mcpServers = raw.mcpServers ?? raw.mcp_servers ?? {};
499
+ for (const [name, config] of Object.entries(mcpServers)) {
500
+ const cfg = config;
501
+ const tools = Array.isArray(cfg.tools) ? cfg.tools : [];
502
+ const toolCount = tools.length || 5;
503
+ const estimatedTokens = toolCount * MCP_TOKENS_PER_TOOL;
504
+ servers.push({
505
+ name,
506
+ toolCount,
507
+ estimatedTokens,
508
+ isHeavy: toolCount > 10
509
+ });
510
+ }
511
+ } catch {
512
+ continue;
513
+ }
514
+ }
515
+ return servers;
516
+ }
517
+ function estimateContextOverhead(claudeMdPath) {
518
+ const claudeMd = analyzeClaudeMd(claudeMdPath);
519
+ const mcpServers = analyzeMcpServers();
520
+ const mcpToolTokens = mcpServers.reduce((sum, s) => sum + s.estimatedTokens, 0);
521
+ const totalOverhead = SYSTEM_PROMPT_TOKENS + BUILT_IN_TOOLS_TOKENS + mcpToolTokens + claudeMd.totalTokens + AUTOCOMPACT_BUFFER_TOKENS;
522
+ const effectiveWindow = CONTEXT_WINDOW_SIZE - totalOverhead;
523
+ const percentUsable = effectiveWindow / CONTEXT_WINDOW_SIZE * 100;
524
+ return {
525
+ systemPrompt: SYSTEM_PROMPT_TOKENS,
526
+ builtInTools: BUILT_IN_TOOLS_TOKENS,
527
+ claudeMd: claudeMd.totalTokens,
528
+ mcpTools: mcpToolTokens,
529
+ autocompactBuffer: AUTOCOMPACT_BUFFER_TOKENS,
530
+ totalOverhead,
531
+ effectiveWindow,
532
+ percentUsable
533
+ };
534
+ }
535
+
536
+ // src/cli/ui/Dashboard.tsx
537
+ import { jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
538
+ function Dashboard({ sessionFilePath, interval }) {
539
+ const { exit } = useApp();
540
+ const [session, setSession] = useState(null);
541
+ const [overhead, setOverhead] = useState(estimateContextOverhead());
542
+ const [showBudget, setShowBudget] = useState(false);
543
+ useEffect(() => {
544
+ function refresh() {
545
+ try {
546
+ const parsed = parseSessionFile(sessionFilePath);
547
+ setSession(parsed);
548
+ setOverhead(estimateContextOverhead());
549
+ } catch {
550
+ }
551
+ }
552
+ refresh();
553
+ const timer = setInterval(refresh, interval);
554
+ return () => clearInterval(timer);
555
+ }, [sessionFilePath, interval]);
556
+ useInput((input) => {
557
+ if (input === "q") exit();
558
+ if (input === "b") setShowBudget((prev) => !prev);
559
+ });
560
+ if (!session || session.messages.length === 0) {
561
+ return /* @__PURE__ */ jsx3(Box3, { paddingX: 1, children: /* @__PURE__ */ jsx3(Text3, { dimColor: true, children: "Waiting for session data..." }) });
562
+ }
563
+ const totalCost = session.messages.reduce(
564
+ (sum, msg) => sum + calculateMessageCost(msg).totalCost,
565
+ 0
566
+ );
567
+ const velocity = calculateCostVelocity(session.messages);
568
+ const model = session.messages[session.messages.length - 1]?.model ?? "unknown";
569
+ const windowBudget = velocity.projectedWindowCost || totalCost * 3;
570
+ const totalInput = session.totalInputTokens + session.totalCacheReadTokens;
571
+ const usedTokens = totalInput + session.totalOutputTokens;
572
+ const recentMessages = session.messages.slice(-8);
573
+ return /* @__PURE__ */ jsxs3(Box3, { flexDirection: "column", children: [
574
+ /* @__PURE__ */ jsxs3(Box3, { borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [
575
+ /* @__PURE__ */ jsx3(Text3, { bold: true, color: "cyan", children: "kerf watch" }),
576
+ /* @__PURE__ */ jsxs3(Text3, { children: [
577
+ " | session: ",
578
+ session.sessionId.slice(0, 8),
579
+ "..."
580
+ ] }),
581
+ /* @__PURE__ */ jsxs3(Text3, { children: [
582
+ " | ",
583
+ session.messageCount,
584
+ " messages"
585
+ ] }),
586
+ /* @__PURE__ */ jsx3(Text3, { dimColor: true, children: " (q=quit, b=budget)" })
587
+ ] }),
588
+ /* @__PURE__ */ jsx3(
589
+ CostMeter,
590
+ {
591
+ spent: totalCost,
592
+ windowBudget,
593
+ burnRate: velocity.dollarsPerMinute,
594
+ minutesRemaining: velocity.minutesRemaining,
595
+ model
596
+ }
597
+ ),
598
+ /* @__PURE__ */ jsx3(ContextBar, { used: usedTokens, total: CONTEXT_WINDOW_SIZE, overhead }),
599
+ /* @__PURE__ */ jsxs3(Box3, { flexDirection: "column", paddingX: 1, marginTop: 1, children: [
600
+ /* @__PURE__ */ jsx3(Text3, { bold: true, children: "Recent Messages:" }),
601
+ recentMessages.map((msg, i) => {
602
+ const cost = calculateMessageCost(msg);
603
+ return /* @__PURE__ */ jsxs3(Text3, { children: [
604
+ /* @__PURE__ */ jsx3(Text3, { dimColor: true, children: msg.timestamp.slice(11, 19) }),
605
+ /* @__PURE__ */ jsxs3(Text3, { children: [
606
+ " ",
607
+ formatTokens(msg.usage.input_tokens + msg.usage.output_tokens),
608
+ " tok"
609
+ ] }),
610
+ /* @__PURE__ */ jsxs3(Text3, { color: "yellow", children: [
611
+ " ",
612
+ formatCost(cost.totalCost)
613
+ ] })
614
+ ] }, i);
615
+ })
616
+ ] })
617
+ ] });
618
+ }
619
+
620
+ // src/cli/commands/watch.ts
621
+ function registerWatchCommand(program2) {
622
+ program2.command("watch").description("Real-time cost dashboard (default)").option("-s, --session <id>", "Watch a specific session").option("-p, --project <path>", "Watch sessions for a specific project").option("-i, --interval <ms>", "Polling interval in ms", "2000").option("--no-color", "Disable colors").action(async (opts) => {
623
+ const interval = parseInt(opts.interval, 10);
624
+ let sessionFilePath;
625
+ if (opts.session) {
626
+ const sessions = await getActiveSessions(opts.project);
627
+ const found = sessions.find((s) => s.sessionId.startsWith(opts.session));
628
+ sessionFilePath = found?.filePath;
629
+ } else {
630
+ const sessions = await getActiveSessions(opts.project);
631
+ sessionFilePath = sessions[0]?.filePath;
632
+ }
633
+ if (!sessionFilePath) {
634
+ console.log(
635
+ "No active Claude Code session found. Start Claude Code and run 'kerf watch' again."
636
+ );
637
+ process.exit(0);
638
+ }
639
+ const { waitUntilExit } = render(
640
+ React2.createElement(Dashboard, { sessionFilePath, interval })
641
+ );
642
+ await waitUntilExit();
643
+ });
644
+ }
645
+
646
+ // src/cli/commands/estimate.ts
647
+ import React3 from "react";
648
+ import { render as render2 } from "ink";
649
+ import { glob as glob2 } from "glob";
650
+
651
+ // src/core/estimator.ts
652
+ import { glob } from "glob";
653
+ var COMPLEXITY_PROFILES = {
654
+ simple: { turns: { low: 2, expected: 3, high: 5 }, outputTokensPerTurn: 1e3 },
655
+ medium: { turns: { low: 5, expected: 10, high: 15 }, outputTokensPerTurn: 2e3 },
656
+ complex: { turns: { low: 15, expected: 25, high: 40 }, outputTokensPerTurn: 2500 }
657
+ };
658
+ var SIMPLE_KEYWORDS = ["typo", "rename", "fix typo", "update version", "change name", "remove unused", "delete"];
659
+ var COMPLEX_KEYWORDS = ["refactor", "rewrite", "new module", "implement", "build", "create", "migrate", "redesign", "overhaul", "architecture"];
660
+ function detectComplexity(taskDescription) {
661
+ const lower = taskDescription.toLowerCase();
662
+ if (SIMPLE_KEYWORDS.some((k) => lower.includes(k))) return "simple";
663
+ if (COMPLEX_KEYWORDS.some((k) => lower.includes(k))) return "complex";
664
+ return "medium";
665
+ }
666
+ async function estimateTaskCost(taskDescription, options = {}) {
667
+ const model = options.model ?? "sonnet";
668
+ const cwd = options.cwd ?? process.cwd();
669
+ const pricing = resolveModelPricing(model);
670
+ const MILLION = 1e6;
671
+ const overhead = estimateContextOverhead();
672
+ let fileTokens = 0;
673
+ let fileList = options.files ?? [];
674
+ if (fileList.length === 0) {
675
+ try {
676
+ const { execSync } = await import("node:child_process");
677
+ const output = execSync("git diff --name-only HEAD 2>/dev/null || git ls-files -m 2>/dev/null", {
678
+ cwd,
679
+ encoding: "utf-8"
680
+ });
681
+ fileList = output.split("\n").filter(Boolean).map((f) => `${cwd}/${f}`);
682
+ } catch {
683
+ }
684
+ }
685
+ for (const filePattern of fileList) {
686
+ const matched = await glob(filePattern, { cwd, absolute: true });
687
+ for (const f of matched) {
688
+ fileTokens += countFileTokens(f);
689
+ }
690
+ }
691
+ const complexity = detectComplexity(taskDescription);
692
+ const profile = COMPLEXITY_PROFILES[complexity];
693
+ const contextPerTurn = overhead.totalOverhead + fileTokens;
694
+ const CACHE_HIT_RATE = 0.9;
695
+ function estimateCostForTurns(turns) {
696
+ let totalCost = 0;
697
+ for (let turn = 1; turn <= turns; turn++) {
698
+ const conversationGrowth = (turn - 1) * profile.outputTokensPerTurn;
699
+ const inputTokens = contextPerTurn + conversationGrowth;
700
+ let effectiveInputCost;
701
+ if (turn <= 2) {
702
+ effectiveInputCost = inputTokens * pricing.input / MILLION;
703
+ } else {
704
+ const cachedTokens = inputTokens * CACHE_HIT_RATE;
705
+ const uncachedTokens = inputTokens * (1 - CACHE_HIT_RATE);
706
+ effectiveInputCost = cachedTokens * pricing.cacheRead / MILLION + uncachedTokens * pricing.input / MILLION;
707
+ }
708
+ const outputCost = profile.outputTokensPerTurn * pricing.output / MILLION;
709
+ totalCost += effectiveInputCost + outputCost;
710
+ }
711
+ return totalCost;
712
+ }
713
+ const lowCost = estimateCostForTurns(profile.turns.low);
714
+ const expectedCost = estimateCostForTurns(profile.turns.expected);
715
+ const highCost = estimateCostForTurns(profile.turns.high);
716
+ const expectedInputTokens = contextPerTurn * profile.turns.expected;
717
+ const expectedOutputTokens = profile.outputTokensPerTurn * profile.turns.expected;
718
+ const expectedCachedTokens = expectedInputTokens * CACHE_HIT_RATE;
719
+ const windowMinutes = BILLING_WINDOW_HOURS * 60;
720
+ const percentOfWindow = expectedCost / (expectedCost * 3) * 100;
721
+ const recommendations = [];
722
+ if (model !== "sonnet") {
723
+ const sonnetPricing = resolveModelPricing("sonnet");
724
+ const sonnetCost = estimateCostForTurns(profile.turns.expected);
725
+ const ratio = pricing.output / resolveModelPricing("sonnet").output;
726
+ if (ratio > 2) {
727
+ recommendations.push(
728
+ `Consider Sonnet to save ~${formatCost(expectedCost - expectedCost / ratio)} (${ratio.toFixed(0)}x cheaper)`
729
+ );
730
+ }
731
+ }
732
+ if (model === "sonnet") {
733
+ const opusPricing = resolveModelPricing("opus");
734
+ const ratio = opusPricing.output / pricing.output;
735
+ recommendations.push(`Using Opus would cost ~${formatCost(expectedCost * ratio)} (${ratio.toFixed(0)}x more)`);
736
+ }
737
+ if (overhead.percentUsable < 60) {
738
+ recommendations.push(`High ghost token overhead (${(100 - overhead.percentUsable).toFixed(0)}%). Run 'kerf audit' to optimize.`);
739
+ }
740
+ if (fileTokens > 5e4) {
741
+ recommendations.push(`Large file context (${(fileTokens / 1e3).toFixed(0)}K tokens). Consider narrowing scope.`);
742
+ }
743
+ return {
744
+ model,
745
+ estimatedTurns: profile.turns,
746
+ estimatedTokens: {
747
+ input: Math.round(expectedInputTokens),
748
+ output: Math.round(expectedOutputTokens),
749
+ cached: Math.round(expectedCachedTokens)
750
+ },
751
+ estimatedCost: {
752
+ low: formatCost(lowCost),
753
+ expected: formatCost(expectedCost),
754
+ high: formatCost(highCost)
755
+ },
756
+ contextOverhead: overhead.totalOverhead,
757
+ fileTokens,
758
+ percentOfWindow: Math.round(percentOfWindow),
759
+ recommendations
760
+ };
761
+ }
762
+
763
+ // src/cli/ui/EstimateCard.tsx
764
+ import { Box as Box4, Text as Text4 } from "ink";
765
+ import { jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
766
+ function EstimateCard({ task, estimate }) {
767
+ const formatK = (n) => `${(n / 1e3).toFixed(1)}K`;
768
+ return /* @__PURE__ */ jsxs4(Box4, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 2, paddingY: 1, children: [
769
+ /* @__PURE__ */ jsxs4(Text4, { bold: true, color: "cyan", children: [
770
+ "kerf estimate: '",
771
+ task,
772
+ "'"
773
+ ] }),
774
+ /* @__PURE__ */ jsx4(Text4, { children: " " }),
775
+ /* @__PURE__ */ jsxs4(Text4, { children: [
776
+ "Model: ",
777
+ /* @__PURE__ */ jsx4(Text4, { bold: true, children: estimate.model })
778
+ ] }),
779
+ /* @__PURE__ */ jsxs4(Text4, { children: [
780
+ "Estimated turns: ",
781
+ estimate.estimatedTurns.low,
782
+ "-",
783
+ estimate.estimatedTurns.high,
784
+ " (expected:",
785
+ " ",
786
+ estimate.estimatedTurns.expected,
787
+ ")"
788
+ ] }),
789
+ /* @__PURE__ */ jsxs4(Text4, { children: [
790
+ "Files: ",
791
+ formatK(estimate.fileTokens),
792
+ " tokens"
793
+ ] }),
794
+ /* @__PURE__ */ jsxs4(Text4, { children: [
795
+ "Context overhead: ",
796
+ formatK(estimate.contextOverhead),
797
+ " tokens (ghost tokens)"
798
+ ] }),
799
+ /* @__PURE__ */ jsx4(Text4, { children: " " }),
800
+ /* @__PURE__ */ jsx4(Text4, { bold: true, children: "Estimated Cost:" }),
801
+ /* @__PURE__ */ jsxs4(Text4, { children: [
802
+ " ",
803
+ "Low: ",
804
+ /* @__PURE__ */ jsx4(Text4, { color: "green", children: estimate.estimatedCost.low })
805
+ ] }),
806
+ /* @__PURE__ */ jsxs4(Text4, { children: [
807
+ " ",
808
+ "Expected: ",
809
+ /* @__PURE__ */ jsx4(Text4, { color: "yellow", children: estimate.estimatedCost.expected })
810
+ ] }),
811
+ /* @__PURE__ */ jsxs4(Text4, { children: [
812
+ " ",
813
+ "High: ",
814
+ /* @__PURE__ */ jsx4(Text4, { color: "red", children: estimate.estimatedCost.high })
815
+ ] }),
816
+ /* @__PURE__ */ jsx4(Text4, { children: " " }),
817
+ /* @__PURE__ */ jsxs4(Text4, { children: [
818
+ "Window Usage: ~",
819
+ estimate.percentOfWindow,
820
+ "% of 5-hour window"
821
+ ] }),
822
+ estimate.recommendations.map((rec, i) => /* @__PURE__ */ jsxs4(Text4, { color: "cyan", children: [
823
+ " -> ",
824
+ rec
825
+ ] }, i))
826
+ ] });
827
+ }
828
+
829
+ // src/cli/commands/estimate.ts
830
+ function registerEstimateCommand(program2) {
831
+ program2.command("estimate <task>").description("Pre-flight cost estimation").option("-m, --model <model>", "Model to estimate for", "sonnet").option("-f, --files <glob>", "Specific files that will be touched").option("--compare", "Show Sonnet vs Opus vs Haiku comparison").option("--json", "Output as JSON").action(async (task, opts) => {
832
+ const files = [];
833
+ if (opts.files) {
834
+ const matched = await glob2(opts.files, { absolute: true });
835
+ files.push(...matched);
836
+ }
837
+ if (opts.compare) {
838
+ const models = ["sonnet", "opus", "haiku"];
839
+ for (const model of models) {
840
+ const estimate2 = await estimateTaskCost(task, { model, files, cwd: process.cwd() });
841
+ if (opts.json) {
842
+ console.log(JSON.stringify(estimate2, null, 2));
843
+ } else {
844
+ const { waitUntilExit: waitUntilExit2 } = render2(
845
+ React3.createElement(EstimateCard, { task, estimate: estimate2 })
846
+ );
847
+ await waitUntilExit2();
848
+ }
849
+ }
850
+ return;
851
+ }
852
+ const estimate = await estimateTaskCost(task, {
853
+ model: opts.model,
854
+ files,
855
+ cwd: process.cwd()
856
+ });
857
+ if (opts.json) {
858
+ console.log(JSON.stringify(estimate, null, 2));
859
+ return;
860
+ }
861
+ const { waitUntilExit } = render2(
862
+ React3.createElement(EstimateCard, { task, estimate })
863
+ );
864
+ await waitUntilExit();
865
+ });
866
+ }
867
+
868
+ // src/cli/commands/budget.ts
869
+ import chalk from "chalk";
870
+
871
+ // src/core/budgetManager.ts
872
+ import dayjs3 from "dayjs";
873
+ import isoWeek2 from "dayjs/plugin/isoWeek.js";
874
+ import { basename as basename2 } from "node:path";
875
+
876
+ // src/db/schema.ts
877
+ import Database from "better-sqlite3";
878
+ import { mkdirSync, existsSync as existsSync2 } from "node:fs";
879
+ import { dirname } from "node:path";
880
+ function initDatabase(dbPath) {
881
+ const path = dbPath ?? KERF_DB_PATH;
882
+ const dir = dirname(path);
883
+ if (!existsSync2(dir)) {
884
+ mkdirSync(dir, { recursive: true });
885
+ }
886
+ const db = new Database(path);
887
+ db.pragma("journal_mode = WAL");
888
+ db.pragma("foreign_keys = ON");
889
+ createTables(db);
890
+ return db;
891
+ }
892
+ function createTables(db) {
893
+ db.exec(`
894
+ CREATE TABLE IF NOT EXISTS projects (
895
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
896
+ name TEXT NOT NULL,
897
+ path TEXT NOT NULL UNIQUE,
898
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
899
+ );
900
+
901
+ CREATE TABLE IF NOT EXISTS budgets (
902
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
903
+ project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
904
+ amount_usd REAL NOT NULL,
905
+ period TEXT NOT NULL CHECK (period IN ('daily', 'weekly', 'monthly')),
906
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
907
+ UNIQUE(project_id, period)
908
+ );
909
+
910
+ CREATE TABLE IF NOT EXISTS usage_snapshots (
911
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
912
+ project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
913
+ session_id TEXT NOT NULL,
914
+ tokens_in INTEGER NOT NULL DEFAULT 0,
915
+ tokens_out INTEGER NOT NULL DEFAULT 0,
916
+ cost_usd REAL NOT NULL DEFAULT 0,
917
+ timestamp TEXT NOT NULL,
918
+ UNIQUE(project_id, session_id, timestamp)
919
+ );
920
+
921
+ CREATE INDEX IF NOT EXISTS idx_usage_project_time
922
+ ON usage_snapshots(project_id, timestamp);
923
+ `);
924
+ }
925
+
926
+ // src/db/migrations.ts
927
+ var migrations = [
928
+ {
929
+ version: 1,
930
+ description: "Initial schema",
931
+ up(_db) {
932
+ }
933
+ }
934
+ ];
935
+ function runMigrations(db) {
936
+ db.exec(`
937
+ CREATE TABLE IF NOT EXISTS schema_migrations (
938
+ version INTEGER PRIMARY KEY,
939
+ applied_at TEXT NOT NULL DEFAULT (datetime('now'))
940
+ )
941
+ `);
942
+ const applied = new Set(
943
+ db.prepare("SELECT version FROM schema_migrations").all().map((row) => row.version)
944
+ );
945
+ for (const migration of migrations) {
946
+ if (!applied.has(migration.version)) {
947
+ migration.up(db);
948
+ db.prepare("INSERT INTO schema_migrations (version) VALUES (?)").run(migration.version);
949
+ }
950
+ }
951
+ }
952
+
953
+ // src/core/budgetManager.ts
954
+ dayjs3.extend(isoWeek2);
955
+ var BudgetManager = class {
956
+ db;
957
+ constructor(dbPath) {
958
+ this.db = initDatabase(dbPath);
959
+ runMigrations(this.db);
960
+ }
961
+ getOrCreateProject(projectPath) {
962
+ const name = basename2(projectPath) || projectPath;
963
+ const existing = this.db.prepare("SELECT id FROM projects WHERE path = ?").get(projectPath);
964
+ if (existing) return existing.id;
965
+ const result = this.db.prepare("INSERT INTO projects (name, path) VALUES (?, ?)").run(name, projectPath);
966
+ return Number(result.lastInsertRowid);
967
+ }
968
+ setBudget(projectPath, amount, period) {
969
+ const projectId = this.getOrCreateProject(projectPath);
970
+ this.db.prepare(
971
+ `INSERT INTO budgets (project_id, amount_usd, period)
972
+ VALUES (?, ?, ?)
973
+ ON CONFLICT(project_id, period)
974
+ DO UPDATE SET amount_usd = excluded.amount_usd`
975
+ ).run(projectId, amount, period);
976
+ }
977
+ getBudget(projectPath) {
978
+ const project = this.db.prepare("SELECT id FROM projects WHERE path = ?").get(projectPath);
979
+ if (!project) return null;
980
+ const budget = this.db.prepare("SELECT amount_usd, period FROM budgets WHERE project_id = ? ORDER BY created_at DESC LIMIT 1").get(project.id);
981
+ if (!budget) return null;
982
+ return { amount: budget.amount_usd, period: budget.period };
983
+ }
984
+ recordUsage(projectPath, sessionId, tokensIn, tokensOut, costUsd, timestamp) {
985
+ const projectId = this.getOrCreateProject(projectPath);
986
+ this.db.prepare(
987
+ `INSERT OR IGNORE INTO usage_snapshots (project_id, session_id, tokens_in, tokens_out, cost_usd, timestamp)
988
+ VALUES (?, ?, ?, ?, ?, ?)`
989
+ ).run(projectId, sessionId, tokensIn, tokensOut, costUsd, timestamp);
990
+ }
991
+ getUsage(projectPath, period) {
992
+ const project = this.db.prepare("SELECT id FROM projects WHERE path = ?").get(projectPath);
993
+ if (!project) return 0;
994
+ const start = getPeriodStart(period);
995
+ const result = this.db.prepare(
996
+ `SELECT COALESCE(SUM(cost_usd), 0) as total
997
+ FROM usage_snapshots
998
+ WHERE project_id = ? AND timestamp >= ?`
999
+ ).get(project.id, start.toISOString());
1000
+ return result.total;
1001
+ }
1002
+ checkBudget(projectPath) {
1003
+ const budgetConfig = this.getBudget(projectPath);
1004
+ if (!budgetConfig) return null;
1005
+ const spent = this.getUsage(projectPath, budgetConfig.period);
1006
+ const remaining = Math.max(0, budgetConfig.amount - spent);
1007
+ const percentUsed = budgetConfig.amount > 0 ? spent / budgetConfig.amount * 100 : 0;
1008
+ const periodStart = getPeriodStart(budgetConfig.period);
1009
+ const periodEnd = getPeriodEnd(budgetConfig.period);
1010
+ return {
1011
+ budget: budgetConfig.amount,
1012
+ spent,
1013
+ remaining,
1014
+ percentUsed,
1015
+ isOverBudget: spent > budgetConfig.amount,
1016
+ period: budgetConfig.period,
1017
+ periodStart: periodStart.toISOString(),
1018
+ periodEnd: periodEnd.toISOString()
1019
+ };
1020
+ }
1021
+ listProjects() {
1022
+ const rows = this.db.prepare(
1023
+ `SELECT p.name, p.path, b.amount_usd, b.period
1024
+ FROM projects p
1025
+ LEFT JOIN budgets b ON b.project_id = p.id
1026
+ ORDER BY p.name`
1027
+ ).all();
1028
+ return rows.map((row) => ({
1029
+ name: row.name,
1030
+ path: row.path,
1031
+ budget: row.amount_usd,
1032
+ period: row.period,
1033
+ spent: row.period ? this.getUsage(row.path, row.period) : 0
1034
+ }));
1035
+ }
1036
+ removeBudget(projectPath) {
1037
+ const project = this.db.prepare("SELECT id FROM projects WHERE path = ?").get(projectPath);
1038
+ if (!project) return false;
1039
+ const result = this.db.prepare("DELETE FROM budgets WHERE project_id = ?").run(project.id);
1040
+ return result.changes > 0;
1041
+ }
1042
+ close() {
1043
+ this.db.close();
1044
+ }
1045
+ };
1046
+ function getPeriodStart(period) {
1047
+ switch (period) {
1048
+ case "daily":
1049
+ return dayjs3().startOf("day");
1050
+ case "weekly":
1051
+ return dayjs3().startOf("isoWeek");
1052
+ case "monthly":
1053
+ return dayjs3().startOf("month");
1054
+ }
1055
+ }
1056
+ function getPeriodEnd(period) {
1057
+ switch (period) {
1058
+ case "daily":
1059
+ return dayjs3().endOf("day");
1060
+ case "weekly":
1061
+ return dayjs3().endOf("isoWeek");
1062
+ case "monthly":
1063
+ return dayjs3().endOf("month");
1064
+ }
1065
+ }
1066
+
1067
+ // src/cli/commands/budget.ts
1068
+ function registerBudgetCommand(program2) {
1069
+ const budget = program2.command("budget").description("Per-project budget management");
1070
+ budget.command("set <amount>").description("Set budget for current project").option("-p, --period <period>", "Budget period (daily|weekly|monthly)", "weekly").option("--project <path>", "Project path").action((amount, opts) => {
1071
+ const manager = new BudgetManager();
1072
+ const projectPath = opts.project || process.cwd();
1073
+ const amountNum = parseFloat(amount);
1074
+ if (isNaN(amountNum) || amountNum <= 0) {
1075
+ console.log(chalk.red("Budget amount must be a positive number."));
1076
+ process.exit(1);
1077
+ }
1078
+ manager.setBudget(projectPath, amountNum, opts.period);
1079
+ console.log(
1080
+ chalk.green(`Budget set: ${formatCost(amountNum)}/${opts.period} for ${projectPath}`)
1081
+ );
1082
+ manager.close();
1083
+ });
1084
+ budget.command("show").description("Show current project budget").option("--project <path>", "Project path").option("--json", "Output as JSON").action((opts) => {
1085
+ const manager = new BudgetManager();
1086
+ const projectPath = opts.project || process.cwd();
1087
+ const status = manager.checkBudget(projectPath);
1088
+ if (!status) {
1089
+ console.log("No budget set for this project. Use 'kerf budget set <amount>' to set one.");
1090
+ manager.close();
1091
+ return;
1092
+ }
1093
+ if (opts.json) {
1094
+ console.log(JSON.stringify(status, null, 2));
1095
+ manager.close();
1096
+ return;
1097
+ }
1098
+ const pct = status.percentUsed;
1099
+ const barWidth = 20;
1100
+ const filled = Math.round(Math.min(pct, 100) / 100 * barWidth);
1101
+ const empty = barWidth - filled;
1102
+ const color = pct < 50 ? "green" : pct < 80 ? "yellow" : "red";
1103
+ const barColor = color === "green" ? chalk.green : color === "yellow" ? chalk.yellow : chalk.red;
1104
+ console.log(chalk.bold.cyan("\n kerf budget\n"));
1105
+ console.log(` Period: ${status.period} (${status.periodStart.slice(0, 10)} to ${status.periodEnd.slice(0, 10)})`);
1106
+ console.log(` Budget: ${formatCost(status.budget)}`);
1107
+ console.log(` Spent: ${barColor(formatCost(status.spent))}`);
1108
+ console.log(` ${barColor("[" + "\u2588".repeat(filled) + "\u2591".repeat(empty) + "]")} ${pct.toFixed(1)}%`);
1109
+ if (status.isOverBudget) {
1110
+ console.log(chalk.red.bold(`
1111
+ OVER BUDGET by ${formatCost(status.spent - status.budget)}`));
1112
+ }
1113
+ console.log();
1114
+ manager.close();
1115
+ });
1116
+ budget.command("list").description("List all project budgets").action(() => {
1117
+ const manager = new BudgetManager();
1118
+ const projects = manager.listProjects();
1119
+ if (projects.length === 0) {
1120
+ console.log("No projects with budgets. Use 'kerf budget set <amount>' to set one.");
1121
+ manager.close();
1122
+ return;
1123
+ }
1124
+ console.log(chalk.bold.cyan("\n kerf budget list\n"));
1125
+ for (const p of projects) {
1126
+ const budgetStr = p.budget ? `${formatCost(p.budget)}/${p.period}` : "no budget";
1127
+ const spentStr = p.spent > 0 ? ` (spent: ${formatCost(p.spent)})` : "";
1128
+ console.log(` ${chalk.bold(p.name)} \u2014 ${budgetStr}${spentStr}`);
1129
+ console.log(chalk.dim(` ${p.path}`));
1130
+ }
1131
+ console.log();
1132
+ manager.close();
1133
+ });
1134
+ budget.command("remove").description("Remove budget for current project").option("--project <path>", "Project path").action((opts) => {
1135
+ const manager = new BudgetManager();
1136
+ const projectPath = opts.project || process.cwd();
1137
+ const removed = manager.removeBudget(projectPath);
1138
+ if (removed) {
1139
+ console.log(chalk.green("Budget removed."));
1140
+ } else {
1141
+ console.log("No budget found for this project.");
1142
+ }
1143
+ manager.close();
1144
+ });
1145
+ }
1146
+
1147
+ // src/cli/commands/audit.ts
1148
+ import chalk2 from "chalk";
1149
+
1150
+ // src/audit/ghostTokens.ts
1151
+ function calculateGrade(percentUsable) {
1152
+ if (percentUsable >= 70) return "A";
1153
+ if (percentUsable >= 50) return "B";
1154
+ if (percentUsable >= 30) return "C";
1155
+ return "D";
1156
+ }
1157
+ function analyzeGhostTokens(claudeMdPath) {
1158
+ const overhead = estimateContextOverhead(claudeMdPath);
1159
+ const mcpServers = analyzeMcpServers();
1160
+ const grade = calculateGrade(overhead.percentUsable);
1161
+ const breakdown = [
1162
+ { label: "System prompt", tokens: overhead.systemPrompt, percent: overhead.systemPrompt / CONTEXT_WINDOW_SIZE * 100 },
1163
+ { label: "Built-in tools", tokens: overhead.builtInTools, percent: overhead.builtInTools / CONTEXT_WINDOW_SIZE * 100 },
1164
+ { label: `MCP tools (${mcpServers.length} srv)`, tokens: overhead.mcpTools, percent: overhead.mcpTools / CONTEXT_WINDOW_SIZE * 100 },
1165
+ { label: "CLAUDE.md", tokens: overhead.claudeMd, percent: overhead.claudeMd / CONTEXT_WINDOW_SIZE * 100 },
1166
+ { label: "Autocompact buffer", tokens: overhead.autocompactBuffer, percent: overhead.autocompactBuffer / CONTEXT_WINDOW_SIZE * 100 }
1167
+ ];
1168
+ return {
1169
+ grade,
1170
+ overhead,
1171
+ mcpServers,
1172
+ percentUsable: overhead.percentUsable,
1173
+ breakdown
1174
+ };
1175
+ }
1176
+
1177
+ // src/audit/claudeMdLinter.ts
1178
+ import { readFileSync as readFileSync3, existsSync as existsSync3 } from "node:fs";
1179
+ import { join as join4 } from "node:path";
1180
+ var CRITICAL_RULE_PATTERN = /\b(NEVER|ALWAYS|MUST|IMPORTANT|CRITICAL)\b/i;
1181
+ var SKILL_CANDIDATES = /\b(review|deploy|release|migration|template|boilerplate|scaffold)\b/i;
1182
+ var LINE_LIMIT = 200;
1183
+ function getAttentionZone(position, total) {
1184
+ const pct = total > 0 ? position / total : 0;
1185
+ if (pct <= 0.3) return "high-start";
1186
+ if (pct >= 0.7) return "high-end";
1187
+ return "low-middle";
1188
+ }
1189
+ function lintClaudeMd(filePath) {
1190
+ const paths = filePath ? [filePath] : [
1191
+ join4(process.cwd(), "CLAUDE.md"),
1192
+ join4(process.cwd(), ".claude", "CLAUDE.md")
1193
+ ];
1194
+ let resolvedPath = null;
1195
+ for (const p of paths) {
1196
+ if (existsSync3(p)) {
1197
+ resolvedPath = p;
1198
+ break;
1199
+ }
1200
+ }
1201
+ if (!resolvedPath) return null;
1202
+ const content = readFileSync3(resolvedPath, "utf-8");
1203
+ const lines = content.split("\n");
1204
+ const totalLines = lines.length;
1205
+ const rawSections = parseClaudeMdSections(content);
1206
+ const totalTokens = rawSections.reduce((sum, s) => sum + s.tokens, 0);
1207
+ let criticalRulesInDeadZone = 0;
1208
+ const sectionsToSkill = [];
1209
+ const sections = rawSections.map((s) => {
1210
+ const midpoint = (s.lineStart + s.lineEnd) / 2;
1211
+ const attentionZone = getAttentionZone(midpoint, totalLines);
1212
+ const hasCriticalRules = CRITICAL_RULE_PATTERN.test(s.content);
1213
+ if (hasCriticalRules && attentionZone === "low-middle") {
1214
+ criticalRulesInDeadZone++;
1215
+ }
1216
+ if (SKILL_CANDIDATES.test(s.title) || SKILL_CANDIDATES.test(s.content)) {
1217
+ sectionsToSkill.push(s.title);
1218
+ }
1219
+ return {
1220
+ title: s.title,
1221
+ content: s.content,
1222
+ tokens: s.tokens,
1223
+ lineStart: s.lineStart,
1224
+ lineEnd: s.lineEnd,
1225
+ hasCriticalRules,
1226
+ attentionZone
1227
+ };
1228
+ });
1229
+ const critical = sections.filter((s) => s.hasCriticalRules);
1230
+ const normal = sections.filter((s) => !s.hasCriticalRules && s.tokens <= 500);
1231
+ const heavy = sections.filter((s) => !s.hasCriticalRules && s.tokens > 500);
1232
+ const suggestedReorder = [...critical, ...normal, ...heavy].map((s) => s.title);
1233
+ return {
1234
+ totalLines,
1235
+ totalTokens,
1236
+ sections,
1237
+ criticalRulesInDeadZone,
1238
+ isOverLineLimit: totalLines > LINE_LIMIT,
1239
+ suggestedReorder
1240
+ };
1241
+ }
1242
+
1243
+ // src/audit/mcpAnalyzer.ts
1244
+ import { readFileSync as readFileSync4, existsSync as existsSync4 } from "node:fs";
1245
+ import { join as join5 } from "node:path";
1246
+ import { homedir as homedir3 } from "node:os";
1247
+ var CLI_ALTERNATIVES = {
1248
+ playwright: "Consider using the built-in Bash tool with playwright CLI instead",
1249
+ puppeteer: "Consider using the built-in Bash tool with puppeteer scripts",
1250
+ filesystem: "Claude Code has built-in file tools (Read, Write, Edit, Glob, Grep)",
1251
+ github: "Consider using 'gh' CLI via Bash tool instead",
1252
+ slack: "Consider using 'slack' CLI or curl for API calls"
1253
+ };
1254
+ function analyzeMcp() {
1255
+ const servers = [];
1256
+ const configPaths = [
1257
+ join5(process.cwd(), ".mcp.json"),
1258
+ join5(homedir3(), ".claude.json")
1259
+ ];
1260
+ for (const configPath of configPaths) {
1261
+ if (!existsSync4(configPath)) continue;
1262
+ try {
1263
+ const raw = JSON.parse(readFileSync4(configPath, "utf-8"));
1264
+ const mcpServers = raw.mcpServers ?? raw.mcp_servers ?? {};
1265
+ for (const [name, config] of Object.entries(mcpServers)) {
1266
+ const cfg = config;
1267
+ const tools = Array.isArray(cfg.tools) ? cfg.tools : [];
1268
+ const toolCount = tools.length || 5;
1269
+ const estimatedTokens = toolCount * MCP_TOKENS_PER_TOOL;
1270
+ servers.push({
1271
+ name,
1272
+ toolCount,
1273
+ estimatedTokens,
1274
+ isHeavy: toolCount > 10
1275
+ });
1276
+ }
1277
+ } catch {
1278
+ continue;
1279
+ }
1280
+ }
1281
+ const totalTools = servers.reduce((sum, s) => sum + s.toolCount, 0);
1282
+ const totalTokens = servers.reduce((sum, s) => sum + s.estimatedTokens, 0);
1283
+ const heavyServers = servers.filter((s) => s.isHeavy);
1284
+ let hasToolSearch = false;
1285
+ const settingsPaths = [
1286
+ join5(process.cwd(), ".claude", "settings.json"),
1287
+ join5(homedir3(), ".claude", "settings.json")
1288
+ ];
1289
+ for (const sp of settingsPaths) {
1290
+ if (!existsSync4(sp)) continue;
1291
+ try {
1292
+ const settings = JSON.parse(readFileSync4(sp, "utf-8"));
1293
+ if (settings.enableToolSearch || settings.tool_search) {
1294
+ hasToolSearch = true;
1295
+ break;
1296
+ }
1297
+ } catch {
1298
+ continue;
1299
+ }
1300
+ }
1301
+ const effectiveTokens = hasToolSearch ? Math.round(totalTokens * 0.15) : totalTokens;
1302
+ const recommendations = [];
1303
+ for (const server of heavyServers) {
1304
+ recommendations.push(
1305
+ `'${server.name}' has ${server.toolCount} tools (${server.estimatedTokens.toLocaleString()} tokens). Consider enabling Tool Search to reduce overhead by ~85%.`
1306
+ );
1307
+ }
1308
+ for (const server of servers) {
1309
+ const alt = CLI_ALTERNATIVES[server.name.toLowerCase()];
1310
+ if (alt) {
1311
+ recommendations.push(`${server.name}: ${alt}`);
1312
+ }
1313
+ }
1314
+ return {
1315
+ servers,
1316
+ totalTools,
1317
+ totalTokens,
1318
+ heavyServers,
1319
+ hasToolSearch,
1320
+ effectiveTokens,
1321
+ recommendations
1322
+ };
1323
+ }
1324
+
1325
+ // src/audit/recommendations.ts
1326
+ function runFullAudit(claudeMdPath) {
1327
+ const ghostReport = analyzeGhostTokens(claudeMdPath);
1328
+ const claudeMdAnalysis = lintClaudeMd(claudeMdPath);
1329
+ const mcpAnalysis = analyzeMcp();
1330
+ const recommendations = [];
1331
+ if (claudeMdAnalysis) {
1332
+ if (claudeMdAnalysis.criticalRulesInDeadZone > 0) {
1333
+ recommendations.push({
1334
+ priority: "high",
1335
+ impact: "improved rule adherence",
1336
+ action: `Reorder CLAUDE.md \u2014 ${claudeMdAnalysis.criticalRulesInDeadZone} critical rule(s) in the low-attention dead zone (30-70% position). Move them to the top or bottom.`,
1337
+ category: "claude-md"
1338
+ });
1339
+ }
1340
+ if (claudeMdAnalysis.isOverLineLimit) {
1341
+ recommendations.push({
1342
+ priority: "high",
1343
+ impact: `${claudeMdAnalysis.totalLines - 200} lines over limit`,
1344
+ action: `CLAUDE.md is ${claudeMdAnalysis.totalLines} lines (limit: 200). Trim or move sections to skills.`,
1345
+ category: "claude-md"
1346
+ });
1347
+ }
1348
+ const heavySections = claudeMdAnalysis.sections.filter((s) => s.tokens > 500);
1349
+ for (const section of heavySections) {
1350
+ recommendations.push({
1351
+ priority: "medium",
1352
+ impact: `-${section.tokens} tokens/session`,
1353
+ action: `Move '${section.title}' section to a skill (${section.tokens} tokens \u2014 heavy section)`,
1354
+ category: "claude-md"
1355
+ });
1356
+ }
1357
+ }
1358
+ for (const server of mcpAnalysis.heavyServers) {
1359
+ recommendations.push({
1360
+ priority: "medium",
1361
+ impact: `-${server.estimatedTokens.toLocaleString()} tokens/session`,
1362
+ action: `MCP server '${server.name}' has ${server.toolCount} tools. Consider disabling if unused or enabling Tool Search.`,
1363
+ category: "mcp"
1364
+ });
1365
+ }
1366
+ if (!mcpAnalysis.hasToolSearch && mcpAnalysis.totalTools > 10) {
1367
+ recommendations.push({
1368
+ priority: "high",
1369
+ impact: `~${Math.round(mcpAnalysis.totalTokens * 0.85).toLocaleString()} tokens saved`,
1370
+ action: "Enable Tool Search (deferred tool loading) to reduce MCP overhead by ~85%.",
1371
+ category: "mcp"
1372
+ });
1373
+ }
1374
+ for (const rec of mcpAnalysis.recommendations) {
1375
+ if (rec.includes("CLI")) {
1376
+ recommendations.push({
1377
+ priority: "low",
1378
+ impact: "reduced token overhead",
1379
+ action: rec,
1380
+ category: "mcp"
1381
+ });
1382
+ }
1383
+ }
1384
+ if (ghostReport.percentUsable < 50) {
1385
+ recommendations.push({
1386
+ priority: "high",
1387
+ impact: `only ${ghostReport.percentUsable.toFixed(0)}% usable`,
1388
+ action: `Context window health is poor (grade ${ghostReport.grade}). Total overhead: ${ghostReport.overhead.totalOverhead.toLocaleString()} tokens.`,
1389
+ category: "ghost-tokens"
1390
+ });
1391
+ }
1392
+ const priorityOrder = { high: 0, medium: 1, low: 2 };
1393
+ recommendations.sort((a, b) => priorityOrder[a.priority] - priorityOrder[b.priority]);
1394
+ return {
1395
+ grade: ghostReport.grade,
1396
+ contextOverhead: ghostReport.overhead,
1397
+ claudeMdAnalysis,
1398
+ mcpServers: ghostReport.mcpServers,
1399
+ recommendations
1400
+ };
1401
+ }
1402
+
1403
+ // src/cli/commands/audit.ts
1404
+ function registerAuditCommand(program2) {
1405
+ program2.command("audit").description("Ghost token & CLAUDE.md audit").option("--fix", "Auto-apply safe fixes").option("--claude-md-only", "Only audit CLAUDE.md").option("--mcp-only", "Only audit MCP servers").option("--json", "Output as JSON").action((opts) => {
1406
+ const result = runFullAudit();
1407
+ if (opts.json) {
1408
+ console.log(JSON.stringify(result, null, 2));
1409
+ return;
1410
+ }
1411
+ const gradeColor = result.grade === "A" ? chalk2.green : result.grade === "B" ? chalk2.yellow : chalk2.red;
1412
+ console.log(chalk2.bold.cyan("\n kerf audit report\n"));
1413
+ console.log(
1414
+ ` Context Window Health: ${gradeColor.bold(result.grade)} (${result.contextOverhead.percentUsable.toFixed(0)}% usable)
1415
+ `
1416
+ );
1417
+ if (!opts.mcpOnly) {
1418
+ console.log(chalk2.bold(" Ghost Token Breakdown:"));
1419
+ const oh = result.contextOverhead;
1420
+ const fmt = (label, tokens) => {
1421
+ const pct = (tokens / CONTEXT_WINDOW_SIZE * 100).toFixed(1);
1422
+ return ` ${label.padEnd(22)} ${tokens.toLocaleString().padStart(6)} tokens (${pct}%)`;
1423
+ };
1424
+ console.log(fmt("System prompt:", oh.systemPrompt));
1425
+ console.log(fmt("Built-in tools:", oh.builtInTools));
1426
+ console.log(fmt(`MCP tools (${result.mcpServers.length} srv):`, oh.mcpTools));
1427
+ console.log(fmt("CLAUDE.md:", oh.claudeMd));
1428
+ console.log(fmt("Autocompact buffer:", oh.autocompactBuffer));
1429
+ console.log(" " + "-".repeat(40));
1430
+ console.log(fmt("Total overhead:", oh.totalOverhead));
1431
+ console.log(
1432
+ ` ${"Effective window:".padEnd(22)} ${oh.effectiveWindow.toLocaleString().padStart(6)} tokens (${oh.percentUsable.toFixed(1)}%)
1433
+ `
1434
+ );
1435
+ }
1436
+ if (!opts.mcpOnly && result.claudeMdAnalysis) {
1437
+ const cma = result.claudeMdAnalysis;
1438
+ console.log(chalk2.bold(" CLAUDE.md Analysis:"));
1439
+ console.log(
1440
+ ` Lines: ${cma.totalLines}${cma.isOverLineLimit ? chalk2.yellow(" (over 200 limit)") : ""}`
1441
+ );
1442
+ console.log(` Tokens: ${cma.totalTokens.toLocaleString()}`);
1443
+ console.log(
1444
+ ` Critical rules in dead zone: ${cma.criticalRulesInDeadZone > 0 ? chalk2.red(String(cma.criticalRulesInDeadZone)) : "0"}`
1445
+ );
1446
+ console.log();
1447
+ }
1448
+ if (result.recommendations.length > 0) {
1449
+ console.log(chalk2.bold(" Recommendations:"));
1450
+ result.recommendations.forEach((rec, i) => {
1451
+ const priorityColor = rec.priority === "high" ? chalk2.red : rec.priority === "medium" ? chalk2.yellow : chalk2.dim;
1452
+ console.log(
1453
+ ` ${i + 1}. ${priorityColor(`[${rec.priority.toUpperCase()}]`)} ${rec.action}`
1454
+ );
1455
+ console.log(chalk2.dim(` Impact: ${rec.impact}`));
1456
+ });
1457
+ }
1458
+ console.log();
1459
+ if (opts.fix) {
1460
+ console.log(chalk2.yellow(" --fix: Auto-fix is not yet implemented. Coming in v0.2.0.\n"));
1461
+ }
1462
+ });
1463
+ }
1464
+
1465
+ // src/cli/commands/report.ts
1466
+ import chalk3 from "chalk";
1467
+ import dayjs4 from "dayjs";
1468
+ function registerReportCommand(program2) {
1469
+ program2.command("report").description("Historical cost reports").option("--period <period>", "Time period (today|week|month|all)", "today").option("-p, --project <path>", "Filter to specific project").option("--model", "Show per-model breakdown").option("--sessions", "Show per-session breakdown").option("--csv", "Export as CSV").option("--json", "Export as JSON").action(async (opts) => {
1470
+ const files = await findJsonlFiles(opts.project);
1471
+ if (files.length === 0) {
1472
+ console.log("No session data found. Start using Claude Code to generate data.");
1473
+ return;
1474
+ }
1475
+ const now = dayjs4();
1476
+ let cutoff;
1477
+ switch (opts.period) {
1478
+ case "today":
1479
+ cutoff = now.startOf("day");
1480
+ break;
1481
+ case "week":
1482
+ cutoff = now.subtract(7, "day");
1483
+ break;
1484
+ case "month":
1485
+ cutoff = now.subtract(30, "day");
1486
+ break;
1487
+ case "all":
1488
+ cutoff = dayjs4("2000-01-01");
1489
+ break;
1490
+ default:
1491
+ cutoff = now.startOf("day");
1492
+ }
1493
+ const allMessages = [];
1494
+ const sessionSummaries = [];
1495
+ for (const file of files) {
1496
+ try {
1497
+ const session = parseSessionFile(file);
1498
+ const filteredMessages = session.messages.filter(
1499
+ (m) => dayjs4(m.timestamp).isAfter(cutoff)
1500
+ );
1501
+ if (filteredMessages.length === 0) continue;
1502
+ allMessages.push(...filteredMessages);
1503
+ const sessionCost = filteredMessages.reduce(
1504
+ (sum, m) => sum + calculateMessageCost(m).totalCost,
1505
+ 0
1506
+ );
1507
+ sessionSummaries.push({
1508
+ id: session.sessionId,
1509
+ model: filteredMessages[0]?.model ?? "unknown",
1510
+ cost: sessionCost,
1511
+ messages: filteredMessages.length
1512
+ });
1513
+ } catch {
1514
+ continue;
1515
+ }
1516
+ }
1517
+ if (allMessages.length === 0) {
1518
+ console.log(`No data found for period: ${opts.period}`);
1519
+ return;
1520
+ }
1521
+ let totalCost = 0;
1522
+ let totalInput = 0;
1523
+ let totalOutput = 0;
1524
+ let totalCacheRead = 0;
1525
+ for (const msg of allMessages) {
1526
+ totalCost += calculateMessageCost(msg).totalCost;
1527
+ totalInput += msg.usage.input_tokens;
1528
+ totalOutput += msg.usage.output_tokens;
1529
+ totalCacheRead += msg.usage.cache_read_input_tokens;
1530
+ }
1531
+ const totalCacheable = totalInput + totalCacheRead;
1532
+ const cacheHitRate = totalCacheable > 0 ? totalCacheRead / totalCacheable * 100 : 0;
1533
+ if (opts.json) {
1534
+ console.log(
1535
+ JSON.stringify(
1536
+ {
1537
+ period: opts.period,
1538
+ totalCost,
1539
+ totalInput,
1540
+ totalOutput,
1541
+ cacheHitRate,
1542
+ sessions: sessionSummaries
1543
+ },
1544
+ null,
1545
+ 2
1546
+ )
1547
+ );
1548
+ return;
1549
+ }
1550
+ if (opts.csv) {
1551
+ console.log("session_id,model,cost_usd,messages");
1552
+ for (const s of sessionSummaries) {
1553
+ console.log(`${s.id},${s.model},${s.cost.toFixed(4)},${s.messages}`);
1554
+ }
1555
+ return;
1556
+ }
1557
+ const periodLabel = opts.period === "today" ? now.format("ddd, MMM D, YYYY") : opts.period;
1558
+ console.log(chalk3.bold.cyan(`
1559
+ kerf report -- ${periodLabel}
1560
+ `));
1561
+ console.log(` Total Cost: ${chalk3.bold(formatCost(totalCost))}`);
1562
+ console.log(` Total Tokens: ${formatTokens(totalInput)} in / ${formatTokens(totalOutput)} out`);
1563
+ console.log(` Cache Hit Rate: ${cacheHitRate.toFixed(1)}%`);
1564
+ console.log(` Sessions: ${sessionSummaries.length}`);
1565
+ console.log();
1566
+ if (opts.model || opts.sessions) {
1567
+ const byModel = /* @__PURE__ */ new Map();
1568
+ for (const s of sessionSummaries) {
1569
+ const existing = byModel.get(s.model) ?? { cost: 0, sessions: 0 };
1570
+ existing.cost += s.cost;
1571
+ existing.sessions++;
1572
+ byModel.set(s.model, existing);
1573
+ }
1574
+ console.log(chalk3.bold(" Model Breakdown:"));
1575
+ for (const [model, data] of byModel) {
1576
+ const pct = totalCost > 0 ? (data.cost / totalCost * 100).toFixed(1) : "0";
1577
+ const shortModel = model.replace("claude-", "").replace(/-20\d{6}$/, "");
1578
+ console.log(
1579
+ ` ${shortModel}: ${formatCost(data.cost)} (${pct}%) -- ${data.sessions} session(s)`
1580
+ );
1581
+ }
1582
+ console.log();
1583
+ }
1584
+ if (opts.sessions) {
1585
+ console.log(chalk3.bold(" Session Breakdown:"));
1586
+ for (const s of sessionSummaries.sort((a, b) => b.cost - a.cost)) {
1587
+ console.log(` ${s.id.slice(0, 12)} ${formatCost(s.cost)} ${s.messages} msgs [${s.model}]`);
1588
+ }
1589
+ console.log();
1590
+ }
1591
+ const hourly = aggregateCosts(allMessages, "hour");
1592
+ if (hourly.length > 1) {
1593
+ console.log(chalk3.bold(" Hourly:"));
1594
+ const maxCost = Math.max(...hourly.map((h) => h.totalCost));
1595
+ for (const h of hourly.slice(-8)) {
1596
+ const barLen = maxCost > 0 ? Math.round(h.totalCost / maxCost * 12) : 0;
1597
+ const bar = "\u2588".repeat(barLen) + "\u2591".repeat(12 - barLen);
1598
+ console.log(` ${h.periodLabel.padEnd(14)} ${bar} ${formatCost(h.totalCost)}`);
1599
+ }
1600
+ console.log();
1601
+ }
1602
+ });
1603
+ }
1604
+
1605
+ // src/cli/commands/init.ts
1606
+ import chalk4 from "chalk";
1607
+ import { mkdirSync as mkdirSync2, existsSync as existsSync5, readFileSync as readFileSync5, writeFileSync, copyFileSync } from "node:fs";
1608
+ import { join as join6, dirname as dirname2 } from "node:path";
1609
+ import { homedir as homedir4 } from "node:os";
1610
+ function registerInitCommand(program2) {
1611
+ program2.command("init").description("Set up kerf for the current project").option("--global", "Install hooks globally").option("--hooks-only", "Only install hooks").option("--no-hooks", "Skip hook installation").option("--force", "Skip confirmation prompts").action(async (opts) => {
1612
+ console.log(chalk4.bold.cyan("\n Welcome to kerf!\n"));
1613
+ console.log(" Setting up cost intelligence for Claude Code...\n");
1614
+ const kerfDir = join6(homedir4(), ".kerf");
1615
+ if (!existsSync5(kerfDir)) {
1616
+ mkdirSync2(kerfDir, { recursive: true });
1617
+ console.log(chalk4.green(" Created ~/.kerf/"));
1618
+ }
1619
+ if (!opts.hooksOnly) {
1620
+ try {
1621
+ const db = initDatabase();
1622
+ runMigrations(db);
1623
+ db.close();
1624
+ console.log(chalk4.green(" Created ~/.kerf/kerf.db"));
1625
+ } catch (err) {
1626
+ console.log(chalk4.red(` Failed to create database: ${err}`));
1627
+ }
1628
+ }
1629
+ try {
1630
+ const { execSync } = await import("node:child_process");
1631
+ try {
1632
+ execSync("which rtk", { stdio: "ignore" });
1633
+ console.log(chalk4.green(" Detected RTK (command compression) -- compatible!"));
1634
+ } catch {
1635
+ }
1636
+ try {
1637
+ execSync("which ccusage", { stdio: "ignore" });
1638
+ console.log(chalk4.green(" Detected ccusage -- will import historical data"));
1639
+ } catch {
1640
+ }
1641
+ } catch {
1642
+ }
1643
+ if (opts.hooks !== false) {
1644
+ const settingsPath = opts.global ? join6(homedir4(), ".claude", "settings.json") : join6(process.cwd(), ".claude", "settings.json");
1645
+ console.log("\n Install hooks? These enable:");
1646
+ console.log(" - Real-time token tracking (Notification hook)");
1647
+ console.log(" - Budget enforcement (Stop hook)");
1648
+ console.log(`
1649
+ Hooks will be added to ${opts.global ? "~/.claude" : ".claude"}/settings.json`);
1650
+ try {
1651
+ installHooks(settingsPath);
1652
+ console.log(chalk4.green("\n Hooks installed"));
1653
+ } catch (err) {
1654
+ console.log(chalk4.yellow(`
1655
+ Skipped hook installation: ${err}`));
1656
+ }
1657
+ }
1658
+ console.log(chalk4.bold("\n Recommended settings for your setup:"));
1659
+ console.log(chalk4.dim(" Add to .claude/settings.json or ~/.claude/settings.json:"));
1660
+ console.log(chalk4.dim(JSON.stringify({
1661
+ env: {
1662
+ MAX_THINKING_TOKENS: "10000",
1663
+ CLAUDE_AUTOCOMPACT_PCT_OVERRIDE: "50"
1664
+ }
1665
+ }, null, 4).split("\n").map((l) => " " + l).join("\n")));
1666
+ console.log(chalk4.bold.cyan("\n Run 'kerf watch' to start the live dashboard!\n"));
1667
+ });
1668
+ }
1669
+ function installHooks(settingsPath) {
1670
+ const dir = dirname2(settingsPath);
1671
+ if (!existsSync5(dir)) {
1672
+ mkdirSync2(dir, { recursive: true });
1673
+ }
1674
+ let settings = {};
1675
+ if (existsSync5(settingsPath)) {
1676
+ const backupPath = settingsPath + ".bak";
1677
+ copyFileSync(settingsPath, backupPath);
1678
+ settings = JSON.parse(readFileSync5(settingsPath, "utf-8"));
1679
+ }
1680
+ const hooks = settings.hooks ?? {};
1681
+ if (!hooks.Notification) {
1682
+ hooks.Notification = [];
1683
+ }
1684
+ if (!hooks.Stop) {
1685
+ hooks.Stop = [];
1686
+ }
1687
+ settings.hooks = hooks;
1688
+ writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
1689
+ }
1690
+
1691
+ // src/cli/index.ts
1692
+ var program = new Command();
1693
+ program.name("kerf").version("0.1.0").description("Cost intelligence for Claude Code. Know before you spend.");
1694
+ registerWatchCommand(program);
1695
+ registerEstimateCommand(program);
1696
+ registerBudgetCommand(program);
1697
+ registerAuditCommand(program);
1698
+ registerReportCommand(program);
1699
+ registerInitCommand(program);
1700
+ program.action(async () => {
1701
+ await program.commands.find((c) => c.name() === "watch")?.parseAsync([], { from: "user" });
1702
+ });
1703
+ program.parse();
1704
+ //# sourceMappingURL=index.js.map