kerf-cli 0.1.5 → 0.2.1
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/CHANGELOG.md +72 -0
- package/LAUNCH-POST.md +173 -0
- package/README.md +81 -91
- package/dist/index.js +252 -102
- package/dist/index.js.map +1 -1
- package/package.json +13 -2
package/dist/index.js
CHANGED
|
@@ -8,7 +8,7 @@ import React2 from "react";
|
|
|
8
8
|
import { render } from "ink";
|
|
9
9
|
|
|
10
10
|
// src/core/parser.ts
|
|
11
|
-
import { readFileSync, statSync } from "node:fs";
|
|
11
|
+
import { readFileSync, statSync, readdirSync, openSync, readSync, closeSync } from "node:fs";
|
|
12
12
|
import { readdir } from "node:fs/promises";
|
|
13
13
|
import { join as join2, basename } from "node:path";
|
|
14
14
|
import dayjs from "dayjs";
|
|
@@ -79,6 +79,7 @@ function parseJsonlContent(content, sessionId) {
|
|
|
79
79
|
};
|
|
80
80
|
messageMap.set(id, {
|
|
81
81
|
id,
|
|
82
|
+
sessionId,
|
|
82
83
|
model: model !== "unknown" ? model : existing?.model ?? "unknown",
|
|
83
84
|
timestamp,
|
|
84
85
|
usage: parsedUsage,
|
|
@@ -138,6 +139,28 @@ async function findJsonlFiles(baseDir) {
|
|
|
138
139
|
await walk(dir);
|
|
139
140
|
return files;
|
|
140
141
|
}
|
|
142
|
+
function findJsonlFilesSync(baseDir) {
|
|
143
|
+
const dir = baseDir ?? CLAUDE_PROJECTS_DIR;
|
|
144
|
+
const files = [];
|
|
145
|
+
function walkSync(currentDir) {
|
|
146
|
+
let entries;
|
|
147
|
+
try {
|
|
148
|
+
entries = readdirSync(currentDir, { withFileTypes: true });
|
|
149
|
+
} catch {
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
for (const entry of entries) {
|
|
153
|
+
const fullPath = join2(currentDir, entry.name);
|
|
154
|
+
if (entry.isDirectory()) {
|
|
155
|
+
walkSync(fullPath);
|
|
156
|
+
} else if (entry.name.endsWith(".jsonl")) {
|
|
157
|
+
files.push(fullPath);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
walkSync(dir);
|
|
162
|
+
return files;
|
|
163
|
+
}
|
|
141
164
|
async function getActiveSessions(baseDir) {
|
|
142
165
|
const files = await findJsonlFiles(baseDir);
|
|
143
166
|
const cutoff = dayjs().subtract(BILLING_WINDOW_HOURS, "hour");
|
|
@@ -224,11 +247,11 @@ function calculateMessageCost(msg) {
|
|
|
224
247
|
};
|
|
225
248
|
}
|
|
226
249
|
const pricing = resolveModelPricing(msg.model);
|
|
227
|
-
const
|
|
228
|
-
const inputCost = msg.usage.input_tokens * pricing.input /
|
|
229
|
-
const outputCost = msg.usage.output_tokens * pricing.output /
|
|
230
|
-
const cacheReadCost = msg.usage.cache_read_input_tokens * pricing.cacheRead /
|
|
231
|
-
const cacheCreationCost = msg.usage.cache_creation_input_tokens * pricing.cacheCreation /
|
|
250
|
+
const MILLION2 = 1e6;
|
|
251
|
+
const inputCost = msg.usage.input_tokens * pricing.input / MILLION2;
|
|
252
|
+
const outputCost = msg.usage.output_tokens * pricing.output / MILLION2;
|
|
253
|
+
const cacheReadCost = msg.usage.cache_read_input_tokens * pricing.cacheRead / MILLION2;
|
|
254
|
+
const cacheCreationCost = msg.usage.cache_creation_input_tokens * pricing.cacheCreation / MILLION2;
|
|
232
255
|
return {
|
|
233
256
|
inputCost,
|
|
234
257
|
outputCost,
|
|
@@ -269,7 +292,7 @@ function aggregateCosts(messages, period) {
|
|
|
269
292
|
const key = getPeriodKey(msg.timestamp, period);
|
|
270
293
|
const group = groups.get(key) ?? { messages: [], sessions: /* @__PURE__ */ new Set() };
|
|
271
294
|
group.messages.push(msg);
|
|
272
|
-
group.sessions.add(msg.
|
|
295
|
+
group.sessions.add(msg.sessionId);
|
|
273
296
|
groups.set(key, group);
|
|
274
297
|
}
|
|
275
298
|
return Array.from(groups.entries()).map(([key, group]) => {
|
|
@@ -690,17 +713,41 @@ var COMPLEXITY_PROFILES = {
|
|
|
690
713
|
};
|
|
691
714
|
var SIMPLE_KEYWORDS = ["typo", "rename", "fix typo", "update version", "change name", "remove unused", "delete"];
|
|
692
715
|
var COMPLEX_KEYWORDS = ["refactor", "rewrite", "new module", "implement", "build", "create", "migrate", "redesign", "overhaul", "architecture"];
|
|
716
|
+
var TYPICAL_WINDOW_COSTS = {
|
|
717
|
+
sonnet: 15,
|
|
718
|
+
opus: 75,
|
|
719
|
+
haiku: 4
|
|
720
|
+
};
|
|
693
721
|
function detectComplexity(taskDescription) {
|
|
694
722
|
const lower = taskDescription.toLowerCase();
|
|
695
723
|
if (SIMPLE_KEYWORDS.some((k) => lower.includes(k))) return "simple";
|
|
696
724
|
if (COMPLEX_KEYWORDS.some((k) => lower.includes(k))) return "complex";
|
|
697
725
|
return "medium";
|
|
698
726
|
}
|
|
727
|
+
var MILLION = 1e6;
|
|
728
|
+
var CACHE_HIT_RATE = 0.9;
|
|
729
|
+
function estimateCostForTurns(turns, modelPricing, contextPerTurn, outputTokensPerTurn) {
|
|
730
|
+
let totalCost = 0;
|
|
731
|
+
for (let turn = 1; turn <= turns; turn++) {
|
|
732
|
+
const conversationGrowth = (turn - 1) * outputTokensPerTurn;
|
|
733
|
+
const inputTokens = contextPerTurn + conversationGrowth;
|
|
734
|
+
let effectiveInputCost;
|
|
735
|
+
if (turn <= 2) {
|
|
736
|
+
effectiveInputCost = inputTokens * modelPricing.input / MILLION;
|
|
737
|
+
} else {
|
|
738
|
+
const cachedTokens = inputTokens * CACHE_HIT_RATE;
|
|
739
|
+
const uncachedTokens = inputTokens * (1 - CACHE_HIT_RATE);
|
|
740
|
+
effectiveInputCost = cachedTokens * modelPricing.cacheRead / MILLION + uncachedTokens * modelPricing.input / MILLION;
|
|
741
|
+
}
|
|
742
|
+
const outputCost = outputTokensPerTurn * modelPricing.output / MILLION;
|
|
743
|
+
totalCost += effectiveInputCost + outputCost;
|
|
744
|
+
}
|
|
745
|
+
return totalCost;
|
|
746
|
+
}
|
|
699
747
|
async function estimateTaskCost(taskDescription, options = {}) {
|
|
700
748
|
const model = options.model ?? "sonnet";
|
|
701
749
|
const cwd = options.cwd ?? process.cwd();
|
|
702
750
|
const pricing = resolveModelPricing(model);
|
|
703
|
-
const MILLION = 1e6;
|
|
704
751
|
const overhead = estimateContextOverhead();
|
|
705
752
|
let fileTokens = 0;
|
|
706
753
|
let fileList = options.files ?? [];
|
|
@@ -724,48 +771,29 @@ async function estimateTaskCost(taskDescription, options = {}) {
|
|
|
724
771
|
const complexity = detectComplexity(taskDescription);
|
|
725
772
|
const profile = COMPLEXITY_PROFILES[complexity];
|
|
726
773
|
const contextPerTurn = overhead.totalOverhead + fileTokens;
|
|
727
|
-
const
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
for (let turn = 1; turn <= turns; turn++) {
|
|
731
|
-
const conversationGrowth = (turn - 1) * profile.outputTokensPerTurn;
|
|
732
|
-
const inputTokens = contextPerTurn + conversationGrowth;
|
|
733
|
-
let effectiveInputCost;
|
|
734
|
-
if (turn <= 2) {
|
|
735
|
-
effectiveInputCost = inputTokens * pricing.input / MILLION;
|
|
736
|
-
} else {
|
|
737
|
-
const cachedTokens = inputTokens * CACHE_HIT_RATE;
|
|
738
|
-
const uncachedTokens = inputTokens * (1 - CACHE_HIT_RATE);
|
|
739
|
-
effectiveInputCost = cachedTokens * pricing.cacheRead / MILLION + uncachedTokens * pricing.input / MILLION;
|
|
740
|
-
}
|
|
741
|
-
const outputCost = profile.outputTokensPerTurn * pricing.output / MILLION;
|
|
742
|
-
totalCost += effectiveInputCost + outputCost;
|
|
743
|
-
}
|
|
744
|
-
return totalCost;
|
|
745
|
-
}
|
|
746
|
-
const lowCost = estimateCostForTurns(profile.turns.low);
|
|
747
|
-
const expectedCost = estimateCostForTurns(profile.turns.expected);
|
|
748
|
-
const highCost = estimateCostForTurns(profile.turns.high);
|
|
774
|
+
const lowCost = estimateCostForTurns(profile.turns.low, pricing, contextPerTurn, profile.outputTokensPerTurn);
|
|
775
|
+
const expectedCost = estimateCostForTurns(profile.turns.expected, pricing, contextPerTurn, profile.outputTokensPerTurn);
|
|
776
|
+
const highCost = estimateCostForTurns(profile.turns.high, pricing, contextPerTurn, profile.outputTokensPerTurn);
|
|
749
777
|
const expectedInputTokens = contextPerTurn * profile.turns.expected;
|
|
750
778
|
const expectedOutputTokens = profile.outputTokensPerTurn * profile.turns.expected;
|
|
751
779
|
const expectedCachedTokens = expectedInputTokens * CACHE_HIT_RATE;
|
|
752
|
-
const
|
|
753
|
-
const percentOfWindow =
|
|
780
|
+
const typicalWindowCost = TYPICAL_WINDOW_COSTS[model] ?? TYPICAL_WINDOW_COSTS.sonnet;
|
|
781
|
+
const percentOfWindow = Math.min(100, Math.round(expectedCost / typicalWindowCost * 100));
|
|
754
782
|
const recommendations = [];
|
|
755
783
|
if (model !== "sonnet") {
|
|
756
784
|
const sonnetPricing = resolveModelPricing("sonnet");
|
|
757
|
-
const
|
|
758
|
-
const
|
|
759
|
-
if (
|
|
785
|
+
const sonnetExpected = estimateCostForTurns(profile.turns.expected, sonnetPricing, contextPerTurn, profile.outputTokensPerTurn);
|
|
786
|
+
const savings = expectedCost - sonnetExpected;
|
|
787
|
+
if (savings > 0.01) {
|
|
760
788
|
recommendations.push(
|
|
761
|
-
`Consider Sonnet to save ~${formatCost(
|
|
789
|
+
`Consider Sonnet to save ~${formatCost(savings)} (${(expectedCost / sonnetExpected).toFixed(1)}x cheaper)`
|
|
762
790
|
);
|
|
763
791
|
}
|
|
764
792
|
}
|
|
765
793
|
if (model === "sonnet") {
|
|
766
794
|
const opusPricing = resolveModelPricing("opus");
|
|
767
|
-
const
|
|
768
|
-
recommendations.push(`Using Opus would cost ~${formatCost(
|
|
795
|
+
const opusExpected = estimateCostForTurns(profile.turns.expected, opusPricing, contextPerTurn, profile.outputTokensPerTurn);
|
|
796
|
+
recommendations.push(`Using Opus would cost ~${formatCost(opusExpected)} (${(opusExpected / expectedCost).toFixed(1)}x more)`);
|
|
769
797
|
}
|
|
770
798
|
if (overhead.percentUsable < 60) {
|
|
771
799
|
recommendations.push(`High ghost token overhead (${(100 - overhead.percentUsable).toFixed(0)}%). Run 'kerf-cli audit' to optimize.`);
|
|
@@ -788,7 +816,7 @@ async function estimateTaskCost(taskDescription, options = {}) {
|
|
|
788
816
|
},
|
|
789
817
|
contextOverhead: overhead.totalOverhead,
|
|
790
818
|
fileTokens,
|
|
791
|
-
percentOfWindow
|
|
819
|
+
percentOfWindow,
|
|
792
820
|
recommendations
|
|
793
821
|
};
|
|
794
822
|
}
|
|
@@ -1046,6 +1074,39 @@ var BudgetManager = class {
|
|
|
1046
1074
|
VALUES (?, ?, ?, ?, ?, ?)`
|
|
1047
1075
|
).run(projectId, sessionId, tokensIn, tokensOut, costUsd, timestamp);
|
|
1048
1076
|
}
|
|
1077
|
+
syncFromJsonl(projectPath) {
|
|
1078
|
+
const projectName = basename2(projectPath);
|
|
1079
|
+
const allFiles = findJsonlFilesSync();
|
|
1080
|
+
const encodedPath = projectPath.replace(/\//g, "-");
|
|
1081
|
+
const filesToProcess = allFiles.filter(
|
|
1082
|
+
(f) => f.includes(projectName) || f.includes(encodeURIComponent(projectPath)) || f.includes(encodedPath)
|
|
1083
|
+
);
|
|
1084
|
+
if (filesToProcess.length === 0) return 0;
|
|
1085
|
+
let synced = 0;
|
|
1086
|
+
for (const file of filesToProcess) {
|
|
1087
|
+
try {
|
|
1088
|
+
const session = parseSessionFile(file);
|
|
1089
|
+
for (const msg of session.messages) {
|
|
1090
|
+
const cost = calculateMessageCost(msg);
|
|
1091
|
+
try {
|
|
1092
|
+
this.recordUsage(
|
|
1093
|
+
projectPath,
|
|
1094
|
+
session.sessionId,
|
|
1095
|
+
msg.usage.input_tokens,
|
|
1096
|
+
msg.usage.output_tokens,
|
|
1097
|
+
cost.totalCost,
|
|
1098
|
+
msg.timestamp
|
|
1099
|
+
);
|
|
1100
|
+
synced++;
|
|
1101
|
+
} catch {
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
} catch {
|
|
1105
|
+
continue;
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
return synced;
|
|
1109
|
+
}
|
|
1049
1110
|
getUsage(projectPath, period) {
|
|
1050
1111
|
const project = this.db.prepare("SELECT id FROM projects WHERE path = ?").get(projectPath);
|
|
1051
1112
|
if (!project) return 0;
|
|
@@ -1060,6 +1121,7 @@ var BudgetManager = class {
|
|
|
1060
1121
|
checkBudget(projectPath) {
|
|
1061
1122
|
const budgetConfig = this.getBudget(projectPath);
|
|
1062
1123
|
if (!budgetConfig) return null;
|
|
1124
|
+
this.syncFromJsonl(projectPath);
|
|
1063
1125
|
const spent = this.getUsage(projectPath, budgetConfig.period);
|
|
1064
1126
|
const remaining = Math.max(0, budgetConfig.amount - spent);
|
|
1065
1127
|
const percentUsed = budgetConfig.amount > 0 ? spent / budgetConfig.amount * 100 : 0;
|
|
@@ -1235,6 +1297,7 @@ function analyzeGhostTokens(claudeMdPath) {
|
|
|
1235
1297
|
// src/audit/claudeMdLinter.ts
|
|
1236
1298
|
import { readFileSync as readFileSync3, existsSync as existsSync3 } from "node:fs";
|
|
1237
1299
|
import { join as join4 } from "node:path";
|
|
1300
|
+
import { homedir as homedir3 } from "node:os";
|
|
1238
1301
|
var CRITICAL_RULE_PATTERN = /\b(NEVER|ALWAYS|MUST|IMPORTANT|CRITICAL)\b/i;
|
|
1239
1302
|
var SKILL_CANDIDATES = /\b(review|deploy|release|migration|template|boilerplate|scaffold)\b/i;
|
|
1240
1303
|
var LINE_LIMIT = 200;
|
|
@@ -1247,7 +1310,9 @@ function getAttentionZone(position, total) {
|
|
|
1247
1310
|
function lintClaudeMd(filePath) {
|
|
1248
1311
|
const paths = filePath ? [filePath] : [
|
|
1249
1312
|
join4(process.cwd(), "CLAUDE.md"),
|
|
1250
|
-
join4(process.cwd(), ".claude", "CLAUDE.md")
|
|
1313
|
+
join4(process.cwd(), ".claude", "CLAUDE.md"),
|
|
1314
|
+
...findGitRootClaudeMd(),
|
|
1315
|
+
join4(homedir3(), ".claude", "CLAUDE.md")
|
|
1251
1316
|
];
|
|
1252
1317
|
let resolvedPath = null;
|
|
1253
1318
|
for (const p of paths) {
|
|
@@ -1301,7 +1366,7 @@ function lintClaudeMd(filePath) {
|
|
|
1301
1366
|
// src/audit/mcpAnalyzer.ts
|
|
1302
1367
|
import { readFileSync as readFileSync4, existsSync as existsSync4 } from "node:fs";
|
|
1303
1368
|
import { join as join5 } from "node:path";
|
|
1304
|
-
import { homedir as
|
|
1369
|
+
import { homedir as homedir4 } from "node:os";
|
|
1305
1370
|
var CLI_ALTERNATIVES = {
|
|
1306
1371
|
playwright: "Consider using the built-in Bash tool with playwright CLI instead",
|
|
1307
1372
|
puppeteer: "Consider using the built-in Bash tool with puppeteer scripts",
|
|
@@ -1310,39 +1375,14 @@ var CLI_ALTERNATIVES = {
|
|
|
1310
1375
|
slack: "Consider using 'slack' CLI or curl for API calls"
|
|
1311
1376
|
};
|
|
1312
1377
|
function analyzeMcp() {
|
|
1313
|
-
const servers =
|
|
1314
|
-
const configPaths = [
|
|
1315
|
-
join5(process.cwd(), ".mcp.json"),
|
|
1316
|
-
join5(homedir3(), ".claude.json")
|
|
1317
|
-
];
|
|
1318
|
-
for (const configPath of configPaths) {
|
|
1319
|
-
if (!existsSync4(configPath)) continue;
|
|
1320
|
-
try {
|
|
1321
|
-
const raw = JSON.parse(readFileSync4(configPath, "utf-8"));
|
|
1322
|
-
const mcpServers = raw.mcpServers ?? raw.mcp_servers ?? {};
|
|
1323
|
-
for (const [name, config] of Object.entries(mcpServers)) {
|
|
1324
|
-
const cfg = config;
|
|
1325
|
-
const tools = Array.isArray(cfg.tools) ? cfg.tools : [];
|
|
1326
|
-
const toolCount = tools.length || 5;
|
|
1327
|
-
const estimatedTokens = toolCount * MCP_TOKENS_PER_TOOL;
|
|
1328
|
-
servers.push({
|
|
1329
|
-
name,
|
|
1330
|
-
toolCount,
|
|
1331
|
-
estimatedTokens,
|
|
1332
|
-
isHeavy: toolCount > 10
|
|
1333
|
-
});
|
|
1334
|
-
}
|
|
1335
|
-
} catch {
|
|
1336
|
-
continue;
|
|
1337
|
-
}
|
|
1338
|
-
}
|
|
1378
|
+
const servers = analyzeMcpServers();
|
|
1339
1379
|
const totalTools = servers.reduce((sum, s) => sum + s.toolCount, 0);
|
|
1340
1380
|
const totalTokens = servers.reduce((sum, s) => sum + s.estimatedTokens, 0);
|
|
1341
1381
|
const heavyServers = servers.filter((s) => s.isHeavy);
|
|
1342
1382
|
let hasToolSearch = false;
|
|
1343
1383
|
const settingsPaths = [
|
|
1344
1384
|
join5(process.cwd(), ".claude", "settings.json"),
|
|
1345
|
-
join5(
|
|
1385
|
+
join5(homedir4(), ".claude", "settings.json")
|
|
1346
1386
|
];
|
|
1347
1387
|
for (const sp of settingsPaths) {
|
|
1348
1388
|
if (!existsSync4(sp)) continue;
|
|
@@ -1558,6 +1598,44 @@ function registerAuditCommand(program2) {
|
|
|
1558
1598
|
// src/cli/commands/report.ts
|
|
1559
1599
|
import chalk4 from "chalk";
|
|
1560
1600
|
import dayjs4 from "dayjs";
|
|
1601
|
+
|
|
1602
|
+
// src/core/cacheAnalyzer.ts
|
|
1603
|
+
function analyzeCacheUsage(messages) {
|
|
1604
|
+
let totalCacheReads = 0;
|
|
1605
|
+
let totalCacheCreations = 0;
|
|
1606
|
+
let totalInputTokens = 0;
|
|
1607
|
+
const perMessage = messages.map((msg) => {
|
|
1608
|
+
const cacheRead = msg.usage.cache_read_input_tokens;
|
|
1609
|
+
const cacheCreation = msg.usage.cache_creation_input_tokens;
|
|
1610
|
+
const input = msg.usage.input_tokens;
|
|
1611
|
+
totalCacheReads += cacheRead;
|
|
1612
|
+
totalCacheCreations += cacheCreation;
|
|
1613
|
+
totalInputTokens += input;
|
|
1614
|
+
const totalCacheable2 = cacheRead + cacheCreation + input;
|
|
1615
|
+
const hitRate = totalCacheable2 > 0 ? cacheRead / totalCacheable2 * 100 : 0;
|
|
1616
|
+
return {
|
|
1617
|
+
id: msg.id,
|
|
1618
|
+
cacheRead,
|
|
1619
|
+
cacheCreation,
|
|
1620
|
+
input,
|
|
1621
|
+
hitRate
|
|
1622
|
+
};
|
|
1623
|
+
});
|
|
1624
|
+
const totalCacheable = totalCacheReads + totalCacheCreations + totalInputTokens;
|
|
1625
|
+
const cacheHitRate = totalCacheable > 0 ? totalCacheReads / totalCacheable * 100 : 0;
|
|
1626
|
+
const savingsMultiplier = 0.9;
|
|
1627
|
+
const estimatedSavings = totalCacheReads * savingsMultiplier;
|
|
1628
|
+
return {
|
|
1629
|
+
totalCacheReads,
|
|
1630
|
+
totalCacheCreations,
|
|
1631
|
+
totalInputTokens,
|
|
1632
|
+
cacheHitRate,
|
|
1633
|
+
estimatedSavings,
|
|
1634
|
+
perMessageBreakdown: perMessage
|
|
1635
|
+
};
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1638
|
+
// src/cli/commands/report.ts
|
|
1561
1639
|
function registerReportCommand(program2) {
|
|
1562
1640
|
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) => {
|
|
1563
1641
|
const files = await findJsonlFiles(opts.project);
|
|
@@ -1614,15 +1692,13 @@ function registerReportCommand(program2) {
|
|
|
1614
1692
|
let totalCost = 0;
|
|
1615
1693
|
let totalInput = 0;
|
|
1616
1694
|
let totalOutput = 0;
|
|
1617
|
-
let totalCacheRead = 0;
|
|
1618
1695
|
for (const msg of allMessages) {
|
|
1619
1696
|
totalCost += calculateMessageCost(msg).totalCost;
|
|
1620
1697
|
totalInput += msg.usage.input_tokens;
|
|
1621
1698
|
totalOutput += msg.usage.output_tokens;
|
|
1622
|
-
totalCacheRead += msg.usage.cache_read_input_tokens;
|
|
1623
1699
|
}
|
|
1624
|
-
const
|
|
1625
|
-
const cacheHitRate =
|
|
1700
|
+
const cacheAnalysis = analyzeCacheUsage(allMessages);
|
|
1701
|
+
const cacheHitRate = cacheAnalysis.cacheHitRate;
|
|
1626
1702
|
if (opts.json) {
|
|
1627
1703
|
console.log(
|
|
1628
1704
|
JSON.stringify(
|
|
@@ -1697,16 +1773,107 @@ function registerReportCommand(program2) {
|
|
|
1697
1773
|
|
|
1698
1774
|
// src/cli/commands/init.ts
|
|
1699
1775
|
import chalk5 from "chalk";
|
|
1700
|
-
import { mkdirSync as
|
|
1776
|
+
import { mkdirSync as mkdirSync3, existsSync as existsSync6 } from "node:fs";
|
|
1777
|
+
import { join as join7 } from "node:path";
|
|
1778
|
+
import { homedir as homedir6 } from "node:os";
|
|
1779
|
+
|
|
1780
|
+
// src/hooks/installer.ts
|
|
1781
|
+
import { existsSync as existsSync5, readFileSync as readFileSync5, writeFileSync, copyFileSync, mkdirSync as mkdirSync2 } from "node:fs";
|
|
1701
1782
|
import { join as join6, dirname as dirname2 } from "node:path";
|
|
1702
|
-
import { homedir as
|
|
1783
|
+
import { homedir as homedir5 } from "node:os";
|
|
1784
|
+
var NOTIFICATION_HOOK = `#!/bin/bash
|
|
1785
|
+
# kerf-cli notification hook
|
|
1786
|
+
KERF_LOG="\${HOME}/.kerf/session-log.jsonl"
|
|
1787
|
+
mkdir -p "$(dirname "$KERF_LOG")"
|
|
1788
|
+
INPUT=$(cat)
|
|
1789
|
+
TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
|
1790
|
+
SESSION_ID=$(echo "$INPUT" | grep -o '"session_id"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/.*: *"//;s/"//')
|
|
1791
|
+
echo "{\\"timestamp\\":\\"$TIMESTAMP\\",\\"session_id\\":\\"$SESSION_ID\\",\\"event\\":\\"notification\\",\\"raw\\":$INPUT}" >> "$KERF_LOG"
|
|
1792
|
+
`;
|
|
1793
|
+
var STOP_HOOK = `#!/bin/bash
|
|
1794
|
+
# kerf-cli stop hook \u2014 budget enforcement
|
|
1795
|
+
KERF_BIN=$(which kerf-cli 2>/dev/null || echo "npx kerf-cli@latest")
|
|
1796
|
+
BUDGET_CHECK=$($KERF_BIN budget show --json 2>/dev/null)
|
|
1797
|
+
if [ $? -ne 0 ] || [ -z "$BUDGET_CHECK" ]; then
|
|
1798
|
+
exit 0
|
|
1799
|
+
fi
|
|
1800
|
+
PERCENT_USED=$(echo "$BUDGET_CHECK" | grep -o '"percentUsed"[[:space:]]*:[[:space:]]*[0-9.]*' | head -1 | sed 's/.*: *//')
|
|
1801
|
+
IS_OVER=$(echo "$BUDGET_CHECK" | grep -o '"isOverBudget"[[:space:]]*:[[:space:]]*\\(true\\|false\\)' | head -1 | sed 's/.*: *//')
|
|
1802
|
+
if [ "$IS_OVER" = "true" ]; then
|
|
1803
|
+
echo '{"reason":"Budget exceeded. Run kerf-cli budget show for details."}'
|
|
1804
|
+
exit 0
|
|
1805
|
+
fi
|
|
1806
|
+
if [ -n "$PERCENT_USED" ]; then
|
|
1807
|
+
THRESHOLD=80
|
|
1808
|
+
OVER=$(echo "$PERCENT_USED > $THRESHOLD" | bc -l 2>/dev/null || echo "0")
|
|
1809
|
+
if [ "$OVER" = "1" ]; then
|
|
1810
|
+
echo "{\\"reason\\":\\"Budget warning: $PERCENT_USED% used. Run kerf-cli budget show for details.\\"}"
|
|
1811
|
+
fi
|
|
1812
|
+
fi
|
|
1813
|
+
exit 0
|
|
1814
|
+
`;
|
|
1815
|
+
function installHooks(options = {}) {
|
|
1816
|
+
const settingsPath = options.global ? join6(homedir5(), ".claude", "settings.json") : join6(process.cwd(), ".claude", "settings.json");
|
|
1817
|
+
const dir = dirname2(settingsPath);
|
|
1818
|
+
if (!existsSync5(dir)) {
|
|
1819
|
+
mkdirSync2(dir, { recursive: true });
|
|
1820
|
+
}
|
|
1821
|
+
let settings = {};
|
|
1822
|
+
if (existsSync5(settingsPath)) {
|
|
1823
|
+
const backupPath = settingsPath + ".kerf-backup";
|
|
1824
|
+
copyFileSync(settingsPath, backupPath);
|
|
1825
|
+
settings = JSON.parse(readFileSync5(settingsPath, "utf-8"));
|
|
1826
|
+
}
|
|
1827
|
+
if (!settings.hooks) {
|
|
1828
|
+
settings.hooks = {};
|
|
1829
|
+
}
|
|
1830
|
+
const installed = [];
|
|
1831
|
+
const skipped = [];
|
|
1832
|
+
const hooksDir = join6(homedir5(), ".kerf", "hooks");
|
|
1833
|
+
if (!existsSync5(hooksDir)) {
|
|
1834
|
+
mkdirSync2(hooksDir, { recursive: true });
|
|
1835
|
+
}
|
|
1836
|
+
const notificationPath = join6(hooksDir, "notification.sh");
|
|
1837
|
+
const stopPath = join6(hooksDir, "stop.sh");
|
|
1838
|
+
writeFileSync(notificationPath, NOTIFICATION_HOOK, { mode: 493 });
|
|
1839
|
+
writeFileSync(stopPath, STOP_HOOK, { mode: 493 });
|
|
1840
|
+
if (!hasKerfHook(settings.hooks, "Notification")) {
|
|
1841
|
+
addHook(settings.hooks, "Notification", notificationPath);
|
|
1842
|
+
installed.push("Notification");
|
|
1843
|
+
} else {
|
|
1844
|
+
skipped.push("Notification (already installed)");
|
|
1845
|
+
}
|
|
1846
|
+
if (!hasKerfHook(settings.hooks, "Stop")) {
|
|
1847
|
+
addHook(settings.hooks, "Stop", stopPath);
|
|
1848
|
+
installed.push("Stop");
|
|
1849
|
+
} else {
|
|
1850
|
+
skipped.push("Stop (already installed)");
|
|
1851
|
+
}
|
|
1852
|
+
writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
|
|
1853
|
+
return { installed, skipped, settingsPath };
|
|
1854
|
+
}
|
|
1855
|
+
function hasKerfHook(hooks, event) {
|
|
1856
|
+
const eventHooks = hooks[event] ?? [];
|
|
1857
|
+
return eventHooks.some((h) => h.hooks.some((hh) => hh.command.includes("kerf")));
|
|
1858
|
+
}
|
|
1859
|
+
function addHook(hooks, event, scriptPath) {
|
|
1860
|
+
if (!hooks[event]) {
|
|
1861
|
+
hooks[event] = [];
|
|
1862
|
+
}
|
|
1863
|
+
hooks[event].push({
|
|
1864
|
+
matcher: "",
|
|
1865
|
+
hooks: [{ type: "command", command: `bash ${scriptPath}` }]
|
|
1866
|
+
});
|
|
1867
|
+
}
|
|
1868
|
+
|
|
1869
|
+
// src/cli/commands/init.ts
|
|
1703
1870
|
function registerInitCommand(program2) {
|
|
1704
1871
|
program2.command("init").description("Set up kerf-cli 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) => {
|
|
1705
1872
|
console.log(chalk5.bold.cyan("\n Welcome to kerf-cli!\n"));
|
|
1706
1873
|
console.log(" Setting up cost intelligence for Claude Code...\n");
|
|
1707
|
-
const kerfDir =
|
|
1708
|
-
if (!
|
|
1709
|
-
|
|
1874
|
+
const kerfDir = join7(homedir6(), ".kerf");
|
|
1875
|
+
if (!existsSync6(kerfDir)) {
|
|
1876
|
+
mkdirSync3(kerfDir, { recursive: true });
|
|
1710
1877
|
console.log(chalk5.green(" Created ~/.kerf/"));
|
|
1711
1878
|
}
|
|
1712
1879
|
if (!opts.hooksOnly) {
|
|
@@ -1734,15 +1901,19 @@ function registerInitCommand(program2) {
|
|
|
1734
1901
|
} catch {
|
|
1735
1902
|
}
|
|
1736
1903
|
if (opts.hooks !== false) {
|
|
1737
|
-
const settingsPath = opts.global ? join6(homedir4(), ".claude", "settings.json") : join6(process.cwd(), ".claude", "settings.json");
|
|
1738
1904
|
console.log("\n Install hooks? These enable:");
|
|
1739
1905
|
console.log(" - Real-time token tracking (Notification hook)");
|
|
1740
1906
|
console.log(" - Budget enforcement (Stop hook)");
|
|
1741
1907
|
console.log(`
|
|
1742
1908
|
Hooks will be added to ${opts.global ? "~/.claude" : ".claude"}/settings.json`);
|
|
1743
1909
|
try {
|
|
1744
|
-
installHooks(
|
|
1745
|
-
|
|
1910
|
+
const result = installHooks({ global: opts.global, force: opts.force });
|
|
1911
|
+
for (const hook of result.installed) {
|
|
1912
|
+
console.log(chalk5.green(` Installed ${hook} hook`));
|
|
1913
|
+
}
|
|
1914
|
+
for (const hook of result.skipped) {
|
|
1915
|
+
console.log(chalk5.dim(` Skipped ${hook}`));
|
|
1916
|
+
}
|
|
1746
1917
|
} catch (err) {
|
|
1747
1918
|
console.log(chalk5.yellow(`
|
|
1748
1919
|
Skipped hook installation: ${err}`));
|
|
@@ -1759,31 +1930,10 @@ function registerInitCommand(program2) {
|
|
|
1759
1930
|
console.log(chalk5.bold.cyan("\n Run 'kerf-cli watch' to start the live dashboard!\n"));
|
|
1760
1931
|
});
|
|
1761
1932
|
}
|
|
1762
|
-
function installHooks(settingsPath) {
|
|
1763
|
-
const dir = dirname2(settingsPath);
|
|
1764
|
-
if (!existsSync5(dir)) {
|
|
1765
|
-
mkdirSync2(dir, { recursive: true });
|
|
1766
|
-
}
|
|
1767
|
-
let settings = {};
|
|
1768
|
-
if (existsSync5(settingsPath)) {
|
|
1769
|
-
const backupPath = settingsPath + ".bak";
|
|
1770
|
-
copyFileSync(settingsPath, backupPath);
|
|
1771
|
-
settings = JSON.parse(readFileSync5(settingsPath, "utf-8"));
|
|
1772
|
-
}
|
|
1773
|
-
const hooks = settings.hooks ?? {};
|
|
1774
|
-
if (!hooks.Notification) {
|
|
1775
|
-
hooks.Notification = [];
|
|
1776
|
-
}
|
|
1777
|
-
if (!hooks.Stop) {
|
|
1778
|
-
hooks.Stop = [];
|
|
1779
|
-
}
|
|
1780
|
-
settings.hooks = hooks;
|
|
1781
|
-
writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
|
|
1782
|
-
}
|
|
1783
1933
|
|
|
1784
1934
|
// src/cli/index.ts
|
|
1785
1935
|
var program = new Command();
|
|
1786
|
-
program.name("kerf-cli").version("0.1
|
|
1936
|
+
program.name("kerf-cli").version("0.2.1").description("Cost intelligence for Claude Code. Know before you spend.");
|
|
1787
1937
|
registerWatchCommand(program);
|
|
1788
1938
|
registerEstimateCommand(program);
|
|
1789
1939
|
registerBudgetCommand(program);
|