claudectx 1.0.0 → 1.1.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/README.md +132 -1
- package/dist/index.js +1423 -27
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1423 -27
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -3
package/dist/index.js
CHANGED
|
@@ -1295,6 +1295,9 @@ function detectRedundantContent(claudeMdContent, memoryContent) {
|
|
|
1295
1295
|
// src/analyzer/cost-calculator.ts
|
|
1296
1296
|
init_cjs_shims();
|
|
1297
1297
|
init_models();
|
|
1298
|
+
function tokenCost(tokens, model) {
|
|
1299
|
+
return calculateCost(tokens, model);
|
|
1300
|
+
}
|
|
1298
1301
|
function sessionCost(tokensPerRequest, model) {
|
|
1299
1302
|
const perRequest = calculateCost(tokensPerRequest, model);
|
|
1300
1303
|
return {
|
|
@@ -1917,6 +1920,14 @@ function applyHooksInstall(result) {
|
|
|
1917
1920
|
}
|
|
1918
1921
|
fs6.writeFileSync(result.settingsPath, JSON.stringify(result.mergedSettings, null, 2) + "\n", "utf-8");
|
|
1919
1922
|
}
|
|
1923
|
+
function writeHooksSettings(projectRoot, mergedSettings) {
|
|
1924
|
+
const settingsPath = path6.join(projectRoot, ".claude", "settings.local.json");
|
|
1925
|
+
const dir = path6.dirname(settingsPath);
|
|
1926
|
+
if (!fs6.existsSync(dir)) {
|
|
1927
|
+
fs6.mkdirSync(dir, { recursive: true });
|
|
1928
|
+
}
|
|
1929
|
+
fs6.writeFileSync(settingsPath, JSON.stringify(mergedSettings, null, 2) + "\n", "utf-8");
|
|
1930
|
+
}
|
|
1920
1931
|
function isAlreadyInstalled(projectRoot) {
|
|
1921
1932
|
const settingsPath = path6.join(projectRoot, ".claude", "settings.local.json");
|
|
1922
1933
|
if (!fs6.existsSync(settingsPath)) return false;
|
|
@@ -2232,12 +2243,12 @@ async function handleLogStdin() {
|
|
|
2232
2243
|
}
|
|
2233
2244
|
}
|
|
2234
2245
|
function readStdin() {
|
|
2235
|
-
return new Promise((
|
|
2246
|
+
return new Promise((resolve12) => {
|
|
2236
2247
|
let data = "";
|
|
2237
2248
|
process.stdin.setEncoding("utf-8");
|
|
2238
2249
|
process.stdin.on("data", (chunk) => data += chunk);
|
|
2239
|
-
process.stdin.on("end", () =>
|
|
2240
|
-
setTimeout(() =>
|
|
2250
|
+
process.stdin.on("end", () => resolve12(data));
|
|
2251
|
+
setTimeout(() => resolve12(data), 500);
|
|
2241
2252
|
});
|
|
2242
2253
|
}
|
|
2243
2254
|
|
|
@@ -2491,8 +2502,8 @@ async function summariseWithAI(conversationText, apiKey) {
|
|
|
2491
2502
|
if (!key) {
|
|
2492
2503
|
throw new Error("No API key available");
|
|
2493
2504
|
}
|
|
2494
|
-
const { default:
|
|
2495
|
-
const client = new
|
|
2505
|
+
const { default: Anthropic2 } = await import("@anthropic-ai/sdk");
|
|
2506
|
+
const client = new Anthropic2({ apiKey: key });
|
|
2496
2507
|
const response = await client.messages.create({
|
|
2497
2508
|
model: SUMMARY_MODEL,
|
|
2498
2509
|
max_tokens: SUMMARY_MAX_TOKENS,
|
|
@@ -2999,28 +3010,1413 @@ async function reportCommand(options) {
|
|
|
2999
3010
|
process.stdout.write(output + "\n");
|
|
3000
3011
|
}
|
|
3001
3012
|
|
|
3002
|
-
// src/
|
|
3003
|
-
|
|
3004
|
-
var
|
|
3005
|
-
|
|
3006
|
-
|
|
3007
|
-
|
|
3008
|
-
|
|
3009
|
-
|
|
3010
|
-
|
|
3011
|
-
|
|
3012
|
-
|
|
3013
|
-
|
|
3014
|
-
|
|
3015
|
-
|
|
3016
|
-
|
|
3017
|
-
|
|
3018
|
-
|
|
3019
|
-
|
|
3020
|
-
|
|
3021
|
-
|
|
3022
|
-
|
|
3023
|
-
|
|
3013
|
+
// src/commands/budget.ts
|
|
3014
|
+
init_cjs_shims();
|
|
3015
|
+
var path20 = __toESM(require("path"));
|
|
3016
|
+
|
|
3017
|
+
// src/analyzer/budget-estimator.ts
|
|
3018
|
+
init_cjs_shims();
|
|
3019
|
+
var fs17 = __toESM(require("fs"));
|
|
3020
|
+
var path19 = __toESM(require("path"));
|
|
3021
|
+
var import_glob2 = require("glob");
|
|
3022
|
+
init_tokenizer();
|
|
3023
|
+
init_session_store();
|
|
3024
|
+
function resolveGlobs(globs, projectRoot) {
|
|
3025
|
+
const results = [];
|
|
3026
|
+
for (const pattern of globs) {
|
|
3027
|
+
try {
|
|
3028
|
+
const matches = import_glob2.glob.sync(pattern, {
|
|
3029
|
+
cwd: projectRoot,
|
|
3030
|
+
absolute: true,
|
|
3031
|
+
nodir: true,
|
|
3032
|
+
ignore: ["**/node_modules/**", "**/dist/**", "**/.git/**", "**/*.min.js"]
|
|
3033
|
+
});
|
|
3034
|
+
results.push(...matches);
|
|
3035
|
+
} catch {
|
|
3036
|
+
}
|
|
3037
|
+
}
|
|
3038
|
+
return [...new Set(results)];
|
|
3039
|
+
}
|
|
3040
|
+
function classifyCacheHit(recentReadCount) {
|
|
3041
|
+
if (recentReadCount >= 3) return "high";
|
|
3042
|
+
if (recentReadCount >= 1) return "medium";
|
|
3043
|
+
return "low";
|
|
3044
|
+
}
|
|
3045
|
+
function suggestClaudeignoreAdditions(files, projectRoot) {
|
|
3046
|
+
const ignorePath = path19.join(projectRoot, ".claudeignore");
|
|
3047
|
+
let ignorePatterns = [];
|
|
3048
|
+
try {
|
|
3049
|
+
const content = fs17.readFileSync(ignorePath, "utf-8");
|
|
3050
|
+
ignorePatterns = content.split("\n").filter(Boolean);
|
|
3051
|
+
} catch {
|
|
3052
|
+
}
|
|
3053
|
+
const recommendations = [];
|
|
3054
|
+
for (const file of files) {
|
|
3055
|
+
if (file.tokenCount <= WASTE_THRESHOLDS.MAX_REFERENCE_FILE_TOKENS) continue;
|
|
3056
|
+
const rel = path19.relative(projectRoot, file.filePath);
|
|
3057
|
+
const alreadyIgnored = ignorePatterns.some((pattern) => {
|
|
3058
|
+
const cleanPattern = pattern.replace(/^!/, "");
|
|
3059
|
+
return rel.startsWith(cleanPattern.replace(/\*/g, "").replace(/\//g, path19.sep));
|
|
3060
|
+
});
|
|
3061
|
+
if (!alreadyIgnored) {
|
|
3062
|
+
recommendations.push(rel);
|
|
3063
|
+
}
|
|
3064
|
+
}
|
|
3065
|
+
return recommendations;
|
|
3066
|
+
}
|
|
3067
|
+
async function estimateBudget(globs, projectRoot, model, thresholdTokens) {
|
|
3068
|
+
const filePaths = resolveGlobs(globs, projectRoot);
|
|
3069
|
+
const events = readAllEvents();
|
|
3070
|
+
const readCounts = /* @__PURE__ */ new Map();
|
|
3071
|
+
for (const event of events) {
|
|
3072
|
+
const count = readCounts.get(event.filePath) ?? 0;
|
|
3073
|
+
readCounts.set(event.filePath, count + 1);
|
|
3074
|
+
}
|
|
3075
|
+
const files = [];
|
|
3076
|
+
for (const filePath of filePaths) {
|
|
3077
|
+
let content = "";
|
|
3078
|
+
try {
|
|
3079
|
+
content = fs17.readFileSync(filePath, "utf-8");
|
|
3080
|
+
} catch {
|
|
3081
|
+
continue;
|
|
3082
|
+
}
|
|
3083
|
+
const tokenCount = countTokens(content);
|
|
3084
|
+
const recentReadCount = readCounts.get(filePath) ?? 0;
|
|
3085
|
+
const cacheHitLikelihood = classifyCacheHit(recentReadCount);
|
|
3086
|
+
const estimatedCostUsd = tokenCost(tokenCount, model);
|
|
3087
|
+
files.push({
|
|
3088
|
+
filePath,
|
|
3089
|
+
tokenCount,
|
|
3090
|
+
recentReadCount,
|
|
3091
|
+
cacheHitLikelihood,
|
|
3092
|
+
estimatedCostUsd
|
|
3093
|
+
});
|
|
3094
|
+
}
|
|
3095
|
+
files.sort((a, b) => b.tokenCount - a.tokenCount);
|
|
3096
|
+
const totalTokens = files.reduce((sum, f) => sum + f.tokenCount, 0);
|
|
3097
|
+
const totalEstimatedCostUsd = tokenCost(totalTokens, model);
|
|
3098
|
+
const CACHE_WEIGHTS = { high: 0.85, medium: 0.5, low: 0 };
|
|
3099
|
+
let weightedSum = 0;
|
|
3100
|
+
for (const f of files) {
|
|
3101
|
+
weightedSum += f.tokenCount * CACHE_WEIGHTS[f.cacheHitLikelihood];
|
|
3102
|
+
}
|
|
3103
|
+
const cacheHitPotential = totalTokens > 0 ? Math.round(weightedSum / totalTokens * 100) : 0;
|
|
3104
|
+
const claudeignoreRecommendations = suggestClaudeignoreAdditions(files, projectRoot);
|
|
3105
|
+
return {
|
|
3106
|
+
globs,
|
|
3107
|
+
model,
|
|
3108
|
+
files,
|
|
3109
|
+
totalTokens,
|
|
3110
|
+
totalEstimatedCostUsd,
|
|
3111
|
+
thresholdExceeded: totalTokens > thresholdTokens,
|
|
3112
|
+
thresholdTokens,
|
|
3113
|
+
cacheHitPotential,
|
|
3114
|
+
claudeignoreRecommendations
|
|
3115
|
+
};
|
|
3116
|
+
}
|
|
3117
|
+
function formatBudgetReport(report) {
|
|
3118
|
+
const lines = [];
|
|
3119
|
+
lines.push("");
|
|
3120
|
+
lines.push("claudectx budget \u2014 context cost estimate");
|
|
3121
|
+
lines.push("\u2550".repeat(50));
|
|
3122
|
+
if (report.files.length === 0) {
|
|
3123
|
+
lines.push("No files matched the given glob patterns.");
|
|
3124
|
+
return lines.join("\n");
|
|
3125
|
+
}
|
|
3126
|
+
const LIKELIHOOD_ICON = { high: "\u{1F7E2}", medium: "\u{1F7E1}", low: "\u{1F534}" };
|
|
3127
|
+
const maxPathLen = Math.min(
|
|
3128
|
+
Math.max(...report.files.map((f) => path19.basename(f.filePath).length)),
|
|
3129
|
+
40
|
|
3130
|
+
);
|
|
3131
|
+
lines.push(
|
|
3132
|
+
` ${"File".padEnd(maxPathLen)} ${"Tokens".padStart(7)} Cache Cost`
|
|
3133
|
+
);
|
|
3134
|
+
lines.push("\u2500".repeat(50));
|
|
3135
|
+
for (const file of report.files.slice(0, 20)) {
|
|
3136
|
+
const name = path19.basename(file.filePath).slice(0, maxPathLen).padEnd(maxPathLen);
|
|
3137
|
+
const tokens = file.tokenCount.toLocaleString().padStart(7);
|
|
3138
|
+
const cache = `${LIKELIHOOD_ICON[file.cacheHitLikelihood]} ${file.cacheHitLikelihood.padEnd(6)}`;
|
|
3139
|
+
const cost = formatCost(file.estimatedCostUsd).padStart(7);
|
|
3140
|
+
lines.push(` ${name} ${tokens} ${cache} ${cost}`);
|
|
3141
|
+
}
|
|
3142
|
+
if (report.files.length > 20) {
|
|
3143
|
+
lines.push(` ... and ${report.files.length - 20} more files`);
|
|
3144
|
+
}
|
|
3145
|
+
lines.push("\u2500".repeat(50));
|
|
3146
|
+
const thresholdStatus = report.thresholdExceeded ? `\u26A0 EXCEEDS threshold (${report.thresholdTokens.toLocaleString()} tokens)` : `\u2713 Within threshold (${report.thresholdTokens.toLocaleString()} tokens)`;
|
|
3147
|
+
lines.push(` Total tokens: ${report.totalTokens.toLocaleString().padStart(10)}`);
|
|
3148
|
+
lines.push(` Estimated cost: ${formatCost(report.totalEstimatedCostUsd).padStart(10)}`);
|
|
3149
|
+
lines.push(` Cache potential: ${`${report.cacheHitPotential}%`.padStart(10)}`);
|
|
3150
|
+
lines.push(` ${thresholdStatus}`);
|
|
3151
|
+
if (report.claudeignoreRecommendations.length > 0) {
|
|
3152
|
+
lines.push("");
|
|
3153
|
+
lines.push(" \u{1F4A1} Add to .claudeignore to exclude large files:");
|
|
3154
|
+
for (const rec of report.claudeignoreRecommendations.slice(0, 5)) {
|
|
3155
|
+
lines.push(` ${rec}`);
|
|
3156
|
+
}
|
|
3157
|
+
}
|
|
3158
|
+
lines.push("");
|
|
3159
|
+
return lines.join("\n");
|
|
3160
|
+
}
|
|
3161
|
+
|
|
3162
|
+
// src/commands/budget.ts
|
|
3163
|
+
init_models();
|
|
3164
|
+
async function budgetCommand(globs, options) {
|
|
3165
|
+
const projectPath = options.path ? path20.resolve(options.path) : process.cwd();
|
|
3166
|
+
const projectRoot = findProjectRoot(projectPath) ?? projectPath;
|
|
3167
|
+
const model = resolveModel(options.model ?? "sonnet");
|
|
3168
|
+
const thresholdTokens = parseInt(options.threshold ?? "10000", 10);
|
|
3169
|
+
if (globs.length === 0) {
|
|
3170
|
+
process.stderr.write("Error: at least one glob pattern is required.\n");
|
|
3171
|
+
process.stderr.write('Example: claudectx budget "src/**/*.ts"\n');
|
|
3172
|
+
process.exit(1);
|
|
3173
|
+
}
|
|
3174
|
+
const report = await estimateBudget(globs, projectRoot, model, thresholdTokens);
|
|
3175
|
+
if (options.json) {
|
|
3176
|
+
process.stdout.write(JSON.stringify(report, null, 2) + "\n");
|
|
3177
|
+
return;
|
|
3178
|
+
}
|
|
3179
|
+
process.stdout.write(formatBudgetReport(report));
|
|
3180
|
+
}
|
|
3181
|
+
|
|
3182
|
+
// src/commands/warmup.ts
|
|
3183
|
+
init_cjs_shims();
|
|
3184
|
+
var path21 = __toESM(require("path"));
|
|
3185
|
+
var import_sdk = __toESM(require("@anthropic-ai/sdk"));
|
|
3186
|
+
init_models();
|
|
3187
|
+
var import_fs3 = __toESM(require("fs"));
|
|
3188
|
+
function buildWarmupMessages(claudeMdContent) {
|
|
3189
|
+
const systemBlock = {
|
|
3190
|
+
type: "text",
|
|
3191
|
+
text: claudeMdContent || "# Project\nNo CLAUDE.md found.",
|
|
3192
|
+
// @ts-expect-error — cache_control is valid but not yet in the TS types for all SDKs
|
|
3193
|
+
cache_control: { type: "ephemeral" }
|
|
3194
|
+
};
|
|
3195
|
+
return {
|
|
3196
|
+
system: [systemBlock],
|
|
3197
|
+
messages: [
|
|
3198
|
+
{
|
|
3199
|
+
role: "user",
|
|
3200
|
+
content: "ping"
|
|
3201
|
+
}
|
|
3202
|
+
]
|
|
3203
|
+
};
|
|
3204
|
+
}
|
|
3205
|
+
function calculateBreakEven(writeTokens, model, ttlMinutes) {
|
|
3206
|
+
const pricing = MODEL_PRICING[model];
|
|
3207
|
+
const writeMultiplier = ttlMinutes === 60 ? 2 : 1.25;
|
|
3208
|
+
const writeCostPerMillion = pricing.inputPerMillion * writeMultiplier;
|
|
3209
|
+
const writeCostUsd = writeTokens / 1e6 * writeCostPerMillion;
|
|
3210
|
+
const readCostUsd = writeTokens / 1e6 * pricing.cacheReadPerMillion;
|
|
3211
|
+
const inputCostUsd = writeTokens / 1e6 * pricing.inputPerMillion;
|
|
3212
|
+
const savingsPerHit = inputCostUsd - readCostUsd;
|
|
3213
|
+
const breakEvenRequests = savingsPerHit > 0 ? Math.ceil(writeCostUsd / savingsPerHit) : 999;
|
|
3214
|
+
return { breakEvenRequests, savingsPerHit, writeCostUsd };
|
|
3215
|
+
}
|
|
3216
|
+
async function executeWarmup(claudeMdContent, model, ttl, client) {
|
|
3217
|
+
const { system, messages } = buildWarmupMessages(claudeMdContent);
|
|
3218
|
+
const betas = ["prompt-caching-2024-07-31"];
|
|
3219
|
+
if (ttl === 60) betas.push("extended-cache-ttl-2025-02-19");
|
|
3220
|
+
const response = await client.beta.messages.create({
|
|
3221
|
+
model,
|
|
3222
|
+
max_tokens: 8,
|
|
3223
|
+
system,
|
|
3224
|
+
messages,
|
|
3225
|
+
betas
|
|
3226
|
+
});
|
|
3227
|
+
const usage = response.usage;
|
|
3228
|
+
const tokensWarmed = usage.cache_creation_input_tokens ?? 0;
|
|
3229
|
+
const { breakEvenRequests, savingsPerHit, writeCostUsd } = calculateBreakEven(
|
|
3230
|
+
tokensWarmed,
|
|
3231
|
+
model,
|
|
3232
|
+
ttl
|
|
3233
|
+
);
|
|
3234
|
+
return {
|
|
3235
|
+
model,
|
|
3236
|
+
tokensWarmed,
|
|
3237
|
+
cacheWriteCostUsd: writeCostUsd,
|
|
3238
|
+
estimatedSavingsPerHit: savingsPerHit,
|
|
3239
|
+
breakEvenRequests,
|
|
3240
|
+
ttlMinutes: ttl,
|
|
3241
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
3242
|
+
};
|
|
3243
|
+
}
|
|
3244
|
+
function isValidCronExpr(expr) {
|
|
3245
|
+
const parts = expr.trim().split(/\s+/);
|
|
3246
|
+
if (parts.length < 5 || parts.length > 6) return false;
|
|
3247
|
+
return parts.every((p) => /^[0-9*,/\-]+$/.test(p));
|
|
3248
|
+
}
|
|
3249
|
+
async function installCron(cronExpr) {
|
|
3250
|
+
if (!isValidCronExpr(cronExpr)) {
|
|
3251
|
+
process.stderr.write(
|
|
3252
|
+
`Error: invalid cron expression "${cronExpr}".
|
|
3253
|
+
Example: "0 9 * * 1-5" (weekdays at 9am)
|
|
3254
|
+
`
|
|
3255
|
+
);
|
|
3256
|
+
process.exit(1);
|
|
3257
|
+
}
|
|
3258
|
+
const { execSync: execSync3 } = await import("child_process");
|
|
3259
|
+
const command = `claudectx warmup`;
|
|
3260
|
+
const cronLine = `${cronExpr} ${command}`;
|
|
3261
|
+
const marker = "# claudectx warmup";
|
|
3262
|
+
try {
|
|
3263
|
+
let existing = "";
|
|
3264
|
+
try {
|
|
3265
|
+
existing = execSync3("crontab -l", {
|
|
3266
|
+
encoding: "utf-8",
|
|
3267
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
3268
|
+
});
|
|
3269
|
+
} catch {
|
|
3270
|
+
existing = "";
|
|
3271
|
+
}
|
|
3272
|
+
if (existing.includes(marker)) {
|
|
3273
|
+
process.stdout.write(" Cron job already installed for claudectx warmup.\n");
|
|
3274
|
+
return;
|
|
3275
|
+
}
|
|
3276
|
+
const newCrontab = existing.trimEnd() + `
|
|
3277
|
+
${marker}
|
|
3278
|
+
${cronLine}
|
|
3279
|
+
`;
|
|
3280
|
+
const { writeFileSync: writeFileSync11, unlinkSync: unlinkSync2 } = await import("fs");
|
|
3281
|
+
const { tmpdir } = await import("os");
|
|
3282
|
+
const { join: join17 } = await import("path");
|
|
3283
|
+
const tmpFile = join17(tmpdir(), `claudectx-cron-${Date.now()}.txt`);
|
|
3284
|
+
try {
|
|
3285
|
+
writeFileSync11(tmpFile, newCrontab, "utf-8");
|
|
3286
|
+
execSync3(`crontab ${tmpFile}`, { stdio: ["pipe", "pipe", "pipe"] });
|
|
3287
|
+
} finally {
|
|
3288
|
+
try {
|
|
3289
|
+
unlinkSync2(tmpFile);
|
|
3290
|
+
} catch {
|
|
3291
|
+
}
|
|
3292
|
+
}
|
|
3293
|
+
process.stdout.write(` \u2713 Cron job installed: ${cronLine}
|
|
3294
|
+
`);
|
|
3295
|
+
process.stdout.write(" Note: Set ANTHROPIC_API_KEY in your cron environment (e.g. via ~/.profile).\n");
|
|
3296
|
+
} catch {
|
|
3297
|
+
process.stdout.write(
|
|
3298
|
+
` Could not install cron automatically. Add this line manually with "crontab -e":
|
|
3299
|
+
ANTHROPIC_API_KEY=<your-key>
|
|
3300
|
+
${cronLine}
|
|
3301
|
+
`
|
|
3302
|
+
);
|
|
3303
|
+
}
|
|
3304
|
+
}
|
|
3305
|
+
async function warmupCommand(options) {
|
|
3306
|
+
const projectPath = options.path ? path21.resolve(options.path) : process.cwd();
|
|
3307
|
+
const projectRoot = findProjectRoot(projectPath) ?? projectPath;
|
|
3308
|
+
const model = resolveModel(options.model ?? "haiku");
|
|
3309
|
+
const ttl = options.ttl === "60" ? 60 : 5;
|
|
3310
|
+
const apiKey = options.apiKey ?? process.env.ANTHROPIC_API_KEY;
|
|
3311
|
+
if (!apiKey) {
|
|
3312
|
+
process.stderr.write(
|
|
3313
|
+
"Error: Anthropic API key required. Use --api-key or set ANTHROPIC_API_KEY.\n"
|
|
3314
|
+
);
|
|
3315
|
+
process.exit(1);
|
|
3316
|
+
}
|
|
3317
|
+
let claudeMdContent = "";
|
|
3318
|
+
const claudeMdPath = path21.join(projectRoot, "CLAUDE.md");
|
|
3319
|
+
try {
|
|
3320
|
+
claudeMdContent = import_fs3.default.readFileSync(claudeMdPath, "utf-8");
|
|
3321
|
+
} catch {
|
|
3322
|
+
process.stderr.write(`Warning: No CLAUDE.md found at ${claudeMdPath}
|
|
3323
|
+
`);
|
|
3324
|
+
}
|
|
3325
|
+
const client = new import_sdk.default({ apiKey });
|
|
3326
|
+
if (!options.json) {
|
|
3327
|
+
process.stdout.write(`Warming up prompt cache (model: ${model}, TTL: ${ttl}min)...
|
|
3328
|
+
`);
|
|
3329
|
+
}
|
|
3330
|
+
let result;
|
|
3331
|
+
try {
|
|
3332
|
+
result = await executeWarmup(claudeMdContent, model, ttl, client);
|
|
3333
|
+
} catch (err) {
|
|
3334
|
+
process.stderr.write(`Error: ${err instanceof Error ? err.message : String(err)}
|
|
3335
|
+
`);
|
|
3336
|
+
process.exit(1);
|
|
3337
|
+
}
|
|
3338
|
+
if (options.json) {
|
|
3339
|
+
process.stdout.write(JSON.stringify(result, null, 2) + "\n");
|
|
3340
|
+
return;
|
|
3341
|
+
}
|
|
3342
|
+
process.stdout.write("\n");
|
|
3343
|
+
process.stdout.write("claudectx warmup \u2014 prompt cache primed\n");
|
|
3344
|
+
process.stdout.write("\u2550".repeat(45) + "\n");
|
|
3345
|
+
process.stdout.write(` Model: ${result.model}
|
|
3346
|
+
`);
|
|
3347
|
+
process.stdout.write(` Tokens warmed: ${result.tokensWarmed.toLocaleString()}
|
|
3348
|
+
`);
|
|
3349
|
+
process.stdout.write(` Cache write cost: ${formatCost(result.cacheWriteCostUsd)}
|
|
3350
|
+
`);
|
|
3351
|
+
process.stdout.write(` Savings per hit: ${formatCost(result.estimatedSavingsPerHit)}
|
|
3352
|
+
`);
|
|
3353
|
+
process.stdout.write(` Break-even after: ${result.breakEvenRequests} requests
|
|
3354
|
+
`);
|
|
3355
|
+
process.stdout.write(` TTL: ${result.ttlMinutes} minutes
|
|
3356
|
+
`);
|
|
3357
|
+
process.stdout.write("\n");
|
|
3358
|
+
if (result.tokensWarmed === 0) {
|
|
3359
|
+
process.stdout.write(
|
|
3360
|
+
` \u26A0 No tokens were cached. CLAUDE.md may be below the minimum token threshold
|
|
3361
|
+
(${model === "claude-haiku-4-5" ? "4,096" : "1,024"} tokens for ${model}).
|
|
3362
|
+
`
|
|
3363
|
+
);
|
|
3364
|
+
} else {
|
|
3365
|
+
process.stdout.write(
|
|
3366
|
+
` \u2713 First ${result.ttlMinutes === 60 ? "60-minute" : "5-minute"} window of requests will benefit from cache hits.
|
|
3367
|
+
`
|
|
3368
|
+
);
|
|
3369
|
+
}
|
|
3370
|
+
process.stdout.write("\n");
|
|
3371
|
+
if (options.cron) {
|
|
3372
|
+
await installCron(options.cron);
|
|
3373
|
+
}
|
|
3374
|
+
}
|
|
3375
|
+
|
|
3376
|
+
// src/commands/drift.ts
|
|
3377
|
+
init_cjs_shims();
|
|
3378
|
+
var path23 = __toESM(require("path"));
|
|
3379
|
+
var fs20 = __toESM(require("fs"));
|
|
3380
|
+
|
|
3381
|
+
// src/analyzer/drift-detector.ts
|
|
3382
|
+
init_cjs_shims();
|
|
3383
|
+
var fs19 = __toESM(require("fs"));
|
|
3384
|
+
var path22 = __toESM(require("path"));
|
|
3385
|
+
var childProcess = __toESM(require("child_process"));
|
|
3386
|
+
init_tokenizer();
|
|
3387
|
+
init_session_store();
|
|
3388
|
+
var INLINE_PATH_RE = /(?:^|\s)((?:\.{1,2}\/|src\/|lib\/|docs\/|app\/|tests?\/)\S+\.\w{1,6})/gm;
|
|
3389
|
+
var AT_REF_RE = /^@(.+)$/;
|
|
3390
|
+
function findDeadAtReferences(content, projectRoot) {
|
|
3391
|
+
const issues = [];
|
|
3392
|
+
const lines = content.split("\n");
|
|
3393
|
+
for (let i = 0; i < lines.length; i++) {
|
|
3394
|
+
const match = lines[i].match(AT_REF_RE);
|
|
3395
|
+
if (!match) continue;
|
|
3396
|
+
const ref = match[1].trim();
|
|
3397
|
+
const absPath = path22.isAbsolute(ref) ? ref : path22.join(projectRoot, ref);
|
|
3398
|
+
if (!fs19.existsSync(absPath)) {
|
|
3399
|
+
const lineText = lines[i];
|
|
3400
|
+
issues.push({
|
|
3401
|
+
type: "dead-ref",
|
|
3402
|
+
line: i + 1,
|
|
3403
|
+
text: lineText,
|
|
3404
|
+
severity: "error",
|
|
3405
|
+
estimatedTokenWaste: countTokens(lineText),
|
|
3406
|
+
suggestion: `File "${ref}" does not exist. Remove this @reference or update the path.`
|
|
3407
|
+
});
|
|
3408
|
+
}
|
|
3409
|
+
}
|
|
3410
|
+
return issues;
|
|
3411
|
+
}
|
|
3412
|
+
async function findGitDeletedMentions(content, projectRoot) {
|
|
3413
|
+
const issues = [];
|
|
3414
|
+
let deletedFiles = /* @__PURE__ */ new Set();
|
|
3415
|
+
try {
|
|
3416
|
+
const output = childProcess.execSync(
|
|
3417
|
+
"git log --diff-filter=D --name-only --pretty=format: --",
|
|
3418
|
+
{ cwd: projectRoot, encoding: "utf-8", timeout: 5e3, stdio: ["pipe", "pipe", "pipe"] }
|
|
3419
|
+
);
|
|
3420
|
+
deletedFiles = new Set(
|
|
3421
|
+
output.split("\n").map((f) => f.trim()).filter(Boolean)
|
|
3422
|
+
);
|
|
3423
|
+
} catch {
|
|
3424
|
+
return [];
|
|
3425
|
+
}
|
|
3426
|
+
if (deletedFiles.size === 0) return [];
|
|
3427
|
+
const lines = content.split("\n");
|
|
3428
|
+
for (let i = 0; i < lines.length; i++) {
|
|
3429
|
+
const line = lines[i];
|
|
3430
|
+
for (const deleted of deletedFiles) {
|
|
3431
|
+
const basename8 = path22.basename(deleted);
|
|
3432
|
+
if (line.includes(basename8) || line.includes(deleted)) {
|
|
3433
|
+
issues.push({
|
|
3434
|
+
type: "git-deleted",
|
|
3435
|
+
line: i + 1,
|
|
3436
|
+
text: line.trim(),
|
|
3437
|
+
severity: "warning",
|
|
3438
|
+
estimatedTokenWaste: countTokens(line),
|
|
3439
|
+
suggestion: `References "${basename8}" which was deleted from git. Consider removing this mention.`
|
|
3440
|
+
});
|
|
3441
|
+
break;
|
|
3442
|
+
}
|
|
3443
|
+
}
|
|
3444
|
+
}
|
|
3445
|
+
return issues;
|
|
3446
|
+
}
|
|
3447
|
+
function findStaleSections(content, events, dayWindow) {
|
|
3448
|
+
const issues = [];
|
|
3449
|
+
const cutoff = Date.now() - dayWindow * 24 * 60 * 60 * 1e3;
|
|
3450
|
+
const recentEvents = events.filter((e) => new Date(e.timestamp).getTime() >= cutoff);
|
|
3451
|
+
const recentPaths = new Set(
|
|
3452
|
+
recentEvents.map((e) => e.filePath.toLowerCase())
|
|
3453
|
+
);
|
|
3454
|
+
const lines = content.split("\n");
|
|
3455
|
+
const sections = [];
|
|
3456
|
+
let currentSection = null;
|
|
3457
|
+
for (let i = 0; i < lines.length; i++) {
|
|
3458
|
+
const sectionMatch = lines[i].match(/^#{1,3}\s+(.+)$/);
|
|
3459
|
+
if (sectionMatch) {
|
|
3460
|
+
if (currentSection) sections.push(currentSection);
|
|
3461
|
+
currentSection = { line: i + 1, header: sectionMatch[1], bodyLines: [] };
|
|
3462
|
+
} else if (currentSection) {
|
|
3463
|
+
currentSection.bodyLines.push(lines[i]);
|
|
3464
|
+
}
|
|
3465
|
+
}
|
|
3466
|
+
if (currentSection) sections.push(currentSection);
|
|
3467
|
+
for (const section of sections) {
|
|
3468
|
+
const headerWords = section.header.toLowerCase().split(/\W+/).filter((w) => w.length > 3);
|
|
3469
|
+
const matched = headerWords.some(
|
|
3470
|
+
(word) => [...recentPaths].some((p) => {
|
|
3471
|
+
if (p.includes(word)) return true;
|
|
3472
|
+
const segments = p.split(/[/\\.]/).filter((s) => s.length >= 4);
|
|
3473
|
+
return segments.some((seg) => word.startsWith(seg) || seg.startsWith(word));
|
|
3474
|
+
})
|
|
3475
|
+
);
|
|
3476
|
+
if (!matched && recentEvents.length > 0) {
|
|
3477
|
+
const sectionContent = section.bodyLines.join("\n");
|
|
3478
|
+
issues.push({
|
|
3479
|
+
type: "stale-section",
|
|
3480
|
+
line: section.line,
|
|
3481
|
+
text: `## ${section.header}`,
|
|
3482
|
+
severity: "info",
|
|
3483
|
+
estimatedTokenWaste: countTokens(`## ${section.header}
|
|
3484
|
+
${sectionContent}`),
|
|
3485
|
+
suggestion: `Section "## ${section.header}" had no matching file reads in the last ${dayWindow} days. Consider removing or archiving it.`
|
|
3486
|
+
});
|
|
3487
|
+
}
|
|
3488
|
+
}
|
|
3489
|
+
return issues;
|
|
3490
|
+
}
|
|
3491
|
+
function findDeadInlinePaths(content, projectRoot) {
|
|
3492
|
+
const issues = [];
|
|
3493
|
+
const lines = content.split("\n");
|
|
3494
|
+
for (let i = 0; i < lines.length; i++) {
|
|
3495
|
+
const line = lines[i];
|
|
3496
|
+
if (AT_REF_RE.test(line)) continue;
|
|
3497
|
+
let match;
|
|
3498
|
+
INLINE_PATH_RE.lastIndex = 0;
|
|
3499
|
+
const seen = /* @__PURE__ */ new Set();
|
|
3500
|
+
while ((match = INLINE_PATH_RE.exec(line)) !== null) {
|
|
3501
|
+
const rawPath = match[1].trim();
|
|
3502
|
+
if (seen.has(rawPath)) continue;
|
|
3503
|
+
seen.add(rawPath);
|
|
3504
|
+
const absPath = path22.isAbsolute(rawPath) ? rawPath : path22.join(projectRoot, rawPath);
|
|
3505
|
+
if (!fs19.existsSync(absPath)) {
|
|
3506
|
+
issues.push({
|
|
3507
|
+
type: "dead-inline-path",
|
|
3508
|
+
line: i + 1,
|
|
3509
|
+
text: line.trim(),
|
|
3510
|
+
severity: "warning",
|
|
3511
|
+
estimatedTokenWaste: countTokens(rawPath),
|
|
3512
|
+
suggestion: `Path "${rawPath}" no longer exists. Update or remove this reference.`
|
|
3513
|
+
});
|
|
3514
|
+
break;
|
|
3515
|
+
}
|
|
3516
|
+
}
|
|
3517
|
+
}
|
|
3518
|
+
return issues;
|
|
3519
|
+
}
|
|
3520
|
+
async function detectDrift(projectRoot, dayWindow) {
|
|
3521
|
+
const claudeMdPath = path22.join(projectRoot, "CLAUDE.md");
|
|
3522
|
+
let content = "";
|
|
3523
|
+
try {
|
|
3524
|
+
content = fs19.readFileSync(claudeMdPath, "utf-8");
|
|
3525
|
+
} catch {
|
|
3526
|
+
return {
|
|
3527
|
+
claudeMdPath,
|
|
3528
|
+
analyzedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3529
|
+
dayWindow,
|
|
3530
|
+
issues: [],
|
|
3531
|
+
totalWastedTokens: 0
|
|
3532
|
+
};
|
|
3533
|
+
}
|
|
3534
|
+
const events = readAllEvents();
|
|
3535
|
+
const [deadRefs, gitDeleted, staleSections, deadInlinePaths] = await Promise.all([
|
|
3536
|
+
Promise.resolve(findDeadAtReferences(content, projectRoot)),
|
|
3537
|
+
findGitDeletedMentions(content, projectRoot),
|
|
3538
|
+
Promise.resolve(findStaleSections(content, events, dayWindow)),
|
|
3539
|
+
Promise.resolve(findDeadInlinePaths(content, projectRoot))
|
|
3540
|
+
]);
|
|
3541
|
+
const allIssues = [...deadRefs, ...gitDeleted, ...staleSections, ...deadInlinePaths];
|
|
3542
|
+
allIssues.sort((a, b) => a.line - b.line);
|
|
3543
|
+
const totalWastedTokens = allIssues.reduce((sum, i) => sum + i.estimatedTokenWaste, 0);
|
|
3544
|
+
return {
|
|
3545
|
+
claudeMdPath,
|
|
3546
|
+
analyzedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3547
|
+
dayWindow,
|
|
3548
|
+
issues: allIssues,
|
|
3549
|
+
totalWastedTokens
|
|
3550
|
+
};
|
|
3551
|
+
}
|
|
3552
|
+
|
|
3553
|
+
// src/commands/drift.ts
|
|
3554
|
+
var SEVERITY_ICON = {
|
|
3555
|
+
error: "\u2716",
|
|
3556
|
+
warning: "\u26A0",
|
|
3557
|
+
info: "\xB7"
|
|
3558
|
+
};
|
|
3559
|
+
var TYPE_LABEL = {
|
|
3560
|
+
"dead-ref": "Dead @ref",
|
|
3561
|
+
"git-deleted": "Git deleted",
|
|
3562
|
+
"stale-section": "Stale section",
|
|
3563
|
+
"dead-inline-path": "Dead path"
|
|
3564
|
+
};
|
|
3565
|
+
async function driftCommand(options) {
|
|
3566
|
+
const projectPath = options.path ? path23.resolve(options.path) : process.cwd();
|
|
3567
|
+
const projectRoot = findProjectRoot(projectPath) ?? projectPath;
|
|
3568
|
+
const dayWindow = parseInt(options.days ?? "30", 10);
|
|
3569
|
+
const report = await detectDrift(projectRoot, dayWindow);
|
|
3570
|
+
if (options.json) {
|
|
3571
|
+
process.stdout.write(JSON.stringify(report, null, 2) + "\n");
|
|
3572
|
+
return;
|
|
3573
|
+
}
|
|
3574
|
+
process.stdout.write("\n");
|
|
3575
|
+
process.stdout.write("claudectx drift \u2014 CLAUDE.md staleness check\n");
|
|
3576
|
+
process.stdout.write("\u2550".repeat(55) + "\n");
|
|
3577
|
+
process.stdout.write(` File: ${report.claudeMdPath}
|
|
3578
|
+
`);
|
|
3579
|
+
process.stdout.write(` Window: last ${report.dayWindow} days
|
|
3580
|
+
`);
|
|
3581
|
+
process.stdout.write("\n");
|
|
3582
|
+
if (report.issues.length === 0) {
|
|
3583
|
+
process.stdout.write(" \u2713 No drift detected. CLAUDE.md looks clean.\n\n");
|
|
3584
|
+
return;
|
|
3585
|
+
}
|
|
3586
|
+
process.stdout.write(
|
|
3587
|
+
` ${"Line".padEnd(5)} ${"Type".padEnd(14)} ${"Waste".padStart(6)} Issue
|
|
3588
|
+
`
|
|
3589
|
+
);
|
|
3590
|
+
process.stdout.write("\u2500".repeat(55) + "\n");
|
|
3591
|
+
for (const issue of report.issues) {
|
|
3592
|
+
const icon = SEVERITY_ICON[issue.severity];
|
|
3593
|
+
const typeLabel = TYPE_LABEL[issue.type].padEnd(14);
|
|
3594
|
+
const waste = `${issue.estimatedTokenWaste}t`.padStart(6);
|
|
3595
|
+
const lineNum = String(issue.line).padEnd(5);
|
|
3596
|
+
const text = issue.text.slice(0, 40);
|
|
3597
|
+
process.stdout.write(` ${lineNum} ${typeLabel} ${waste} ${icon} ${text}
|
|
3598
|
+
`);
|
|
3599
|
+
}
|
|
3600
|
+
process.stdout.write("\u2500".repeat(55) + "\n");
|
|
3601
|
+
process.stdout.write(
|
|
3602
|
+
` ${report.issues.length} issue(s) found \xB7 ~${report.totalWastedTokens} tokens wasted
|
|
3603
|
+
`
|
|
3604
|
+
);
|
|
3605
|
+
process.stdout.write("\n");
|
|
3606
|
+
if (report.issues.length > 0) {
|
|
3607
|
+
process.stdout.write(" Suggestions:\n");
|
|
3608
|
+
const shown = /* @__PURE__ */ new Set();
|
|
3609
|
+
for (const issue of report.issues.slice(0, 5)) {
|
|
3610
|
+
if (!shown.has(issue.suggestion)) {
|
|
3611
|
+
process.stdout.write(` Line ${issue.line}: ${issue.suggestion}
|
|
3612
|
+
`);
|
|
3613
|
+
shown.add(issue.suggestion);
|
|
3614
|
+
}
|
|
3615
|
+
}
|
|
3616
|
+
process.stdout.write("\n");
|
|
3617
|
+
}
|
|
3618
|
+
if (options.fix && report.issues.length > 0) {
|
|
3619
|
+
await applyFix(report.claudeMdPath, report.issues);
|
|
3620
|
+
} else if (report.issues.length > 0 && !options.fix) {
|
|
3621
|
+
process.stdout.write(" Run with --fix to interactively remove flagged lines.\n\n");
|
|
3622
|
+
}
|
|
3623
|
+
}
|
|
3624
|
+
async function applyFix(claudeMdPath, issues) {
|
|
3625
|
+
const { checkbox: checkbox2 } = await import("@inquirer/prompts").catch(() => {
|
|
3626
|
+
process.stderr.write("Error: @inquirer/prompts is required for --fix mode. Run: npm install -g @inquirer/prompts\n");
|
|
3627
|
+
process.exit(1);
|
|
3628
|
+
});
|
|
3629
|
+
const choices = issues.map((issue) => ({
|
|
3630
|
+
name: `Line ${issue.line}: ${issue.text.slice(0, 60)} (${issue.estimatedTokenWaste}t)`,
|
|
3631
|
+
value: issue.line,
|
|
3632
|
+
checked: issue.severity === "error"
|
|
3633
|
+
}));
|
|
3634
|
+
const selectedLines = await checkbox2({
|
|
3635
|
+
message: "Select lines to remove from CLAUDE.md:",
|
|
3636
|
+
choices
|
|
3637
|
+
});
|
|
3638
|
+
if (selectedLines.length === 0) {
|
|
3639
|
+
process.stdout.write("No lines selected. Nothing changed.\n");
|
|
3640
|
+
return;
|
|
3641
|
+
}
|
|
3642
|
+
const content = fs20.readFileSync(claudeMdPath, "utf-8");
|
|
3643
|
+
const lines = content.split("\n");
|
|
3644
|
+
const lineSet = new Set(selectedLines.map((l) => l - 1));
|
|
3645
|
+
const newLines = lines.filter((_, i) => !lineSet.has(i));
|
|
3646
|
+
const newContent = newLines.join("\n");
|
|
3647
|
+
const backupPath = `${claudeMdPath}.bak`;
|
|
3648
|
+
fs20.writeFileSync(backupPath, content, "utf-8");
|
|
3649
|
+
const os5 = await import("os");
|
|
3650
|
+
const tmpPath = `${claudeMdPath}.tmp-${Date.now()}`;
|
|
3651
|
+
try {
|
|
3652
|
+
fs20.writeFileSync(tmpPath, newContent, "utf-8");
|
|
3653
|
+
fs20.renameSync(tmpPath, claudeMdPath);
|
|
3654
|
+
} catch (err) {
|
|
3655
|
+
try {
|
|
3656
|
+
fs20.copyFileSync(backupPath, claudeMdPath);
|
|
3657
|
+
} catch {
|
|
3658
|
+
}
|
|
3659
|
+
try {
|
|
3660
|
+
fs20.unlinkSync(tmpPath);
|
|
3661
|
+
} catch {
|
|
3662
|
+
}
|
|
3663
|
+
process.stderr.write(`Error writing CLAUDE.md: ${err instanceof Error ? err.message : String(err)}
|
|
3664
|
+
`);
|
|
3665
|
+
process.exit(1);
|
|
3666
|
+
}
|
|
3667
|
+
process.stdout.write(`
|
|
3668
|
+
\u2713 Removed ${selectedLines.length} line(s) from ${path23.basename(claudeMdPath)}
|
|
3669
|
+
`);
|
|
3670
|
+
process.stdout.write(` \u2713 Backup saved to ${backupPath}
|
|
3671
|
+
|
|
3672
|
+
`);
|
|
3673
|
+
void os5;
|
|
3674
|
+
}
|
|
3675
|
+
|
|
3676
|
+
// src/commands/hooks.ts
|
|
3677
|
+
init_cjs_shims();
|
|
3678
|
+
var fs21 = __toESM(require("fs"));
|
|
3679
|
+
var path24 = __toESM(require("path"));
|
|
3680
|
+
|
|
3681
|
+
// src/hooks/registry.ts
|
|
3682
|
+
init_cjs_shims();
|
|
3683
|
+
var HOOK_REGISTRY = [
|
|
3684
|
+
{
|
|
3685
|
+
name: "auto-compress",
|
|
3686
|
+
description: "Compress session when token count exceeds threshold",
|
|
3687
|
+
triggerEvent: "PostToolUse",
|
|
3688
|
+
matcher: "Read",
|
|
3689
|
+
// API key is read from ANTHROPIC_API_KEY env var — never stored in settings.json
|
|
3690
|
+
commandTemplate: "claudectx compress --auto",
|
|
3691
|
+
configSchema: {
|
|
3692
|
+
threshold: { type: "number", default: 5e4, description: "Token threshold to trigger compression" }
|
|
3693
|
+
},
|
|
3694
|
+
category: "compression"
|
|
3695
|
+
},
|
|
3696
|
+
{
|
|
3697
|
+
name: "daily-budget",
|
|
3698
|
+
description: "Warn when daily spend exceeds a dollar limit",
|
|
3699
|
+
triggerEvent: "PreToolUse",
|
|
3700
|
+
commandTemplate: "claudectx report --days 1 --json",
|
|
3701
|
+
configSchema: {
|
|
3702
|
+
dailyLimit: { type: "number", default: 5, description: "Daily spend limit in USD" }
|
|
3703
|
+
},
|
|
3704
|
+
category: "budget"
|
|
3705
|
+
},
|
|
3706
|
+
{
|
|
3707
|
+
name: "slack-digest",
|
|
3708
|
+
description: "POST daily usage summary to a Slack incoming webhook",
|
|
3709
|
+
triggerEvent: "Stop",
|
|
3710
|
+
commandTemplate: 'claudectx report --json | curl -s -X POST -H "Content-Type: application/json" --data-binary @- {{config.webhookUrl}}',
|
|
3711
|
+
configSchema: {
|
|
3712
|
+
webhookUrl: {
|
|
3713
|
+
type: "string",
|
|
3714
|
+
description: "Slack Incoming Webhook URL",
|
|
3715
|
+
required: true
|
|
3716
|
+
}
|
|
3717
|
+
},
|
|
3718
|
+
category: "notification"
|
|
3719
|
+
},
|
|
3720
|
+
{
|
|
3721
|
+
name: "session-warmup",
|
|
3722
|
+
description: "Pre-warm the Anthropic prompt cache on each session start",
|
|
3723
|
+
triggerEvent: "PostToolUse",
|
|
3724
|
+
matcher: "Read",
|
|
3725
|
+
// API key is read from ANTHROPIC_API_KEY env var — never stored in settings.json
|
|
3726
|
+
commandTemplate: "claudectx warmup",
|
|
3727
|
+
configSchema: {},
|
|
3728
|
+
category: "warmup"
|
|
3729
|
+
}
|
|
3730
|
+
];
|
|
3731
|
+
function getHook(name) {
|
|
3732
|
+
return HOOK_REGISTRY.find((h) => h.name === name);
|
|
3733
|
+
}
|
|
3734
|
+
function interpolateCommand(template, config) {
|
|
3735
|
+
return template.replace(/\{\{config\.(\w+)\}\}/g, (_, key) => {
|
|
3736
|
+
const val = config[key];
|
|
3737
|
+
return val !== void 0 ? String(val) : `{{config.${key}}}`;
|
|
3738
|
+
});
|
|
3739
|
+
}
|
|
3740
|
+
function buildHookEntry(def, config) {
|
|
3741
|
+
const command = interpolateCommand(def.commandTemplate, config);
|
|
3742
|
+
const hookItem = {
|
|
3743
|
+
type: "command",
|
|
3744
|
+
command
|
|
3745
|
+
};
|
|
3746
|
+
const entry = { hooks: [hookItem] };
|
|
3747
|
+
if (def.matcher) {
|
|
3748
|
+
entry.matcher = def.matcher;
|
|
3749
|
+
}
|
|
3750
|
+
return entry;
|
|
3751
|
+
}
|
|
3752
|
+
|
|
3753
|
+
// src/commands/hooks.ts
|
|
3754
|
+
function readInstalledHooks(projectRoot) {
|
|
3755
|
+
const settingsPath = path24.join(projectRoot, ".claude", "settings.local.json");
|
|
3756
|
+
if (!fs21.existsSync(settingsPath)) return {};
|
|
3757
|
+
try {
|
|
3758
|
+
return JSON.parse(fs21.readFileSync(settingsPath, "utf-8"));
|
|
3759
|
+
} catch {
|
|
3760
|
+
process.stderr.write(
|
|
3761
|
+
`Warning: ${settingsPath} exists but contains invalid JSON. Existing settings will be preserved as a backup.
|
|
3762
|
+
`
|
|
3763
|
+
);
|
|
3764
|
+
try {
|
|
3765
|
+
fs21.copyFileSync(settingsPath, `${settingsPath}.bak`);
|
|
3766
|
+
} catch {
|
|
3767
|
+
}
|
|
3768
|
+
return {};
|
|
3769
|
+
}
|
|
3770
|
+
}
|
|
3771
|
+
function removeHookByName(settings, name) {
|
|
3772
|
+
const hooks = settings.hooks ?? {};
|
|
3773
|
+
const updated = {};
|
|
3774
|
+
for (const [event, entries] of Object.entries(hooks)) {
|
|
3775
|
+
updated[event] = entries.filter((entry) => {
|
|
3776
|
+
const hookItems = entry.hooks ?? [];
|
|
3777
|
+
return !hookItems.some((h) => h.command?.includes(`claudectx`) && entry.name === name);
|
|
3778
|
+
});
|
|
3779
|
+
}
|
|
3780
|
+
return { ...settings, hooks: updated };
|
|
3781
|
+
}
|
|
3782
|
+
function parseConfigPairs(pairs) {
|
|
3783
|
+
const result = {};
|
|
3784
|
+
for (const pair of pairs) {
|
|
3785
|
+
const idx = pair.indexOf("=");
|
|
3786
|
+
if (idx === -1) continue;
|
|
3787
|
+
const key = pair.slice(0, idx).trim();
|
|
3788
|
+
const value = pair.slice(idx + 1).trim();
|
|
3789
|
+
if (key) result[key] = value;
|
|
3790
|
+
}
|
|
3791
|
+
return result;
|
|
3792
|
+
}
|
|
3793
|
+
async function hooksList(projectRoot) {
|
|
3794
|
+
const settings = readInstalledHooks(projectRoot);
|
|
3795
|
+
const installedCommands = JSON.stringify(settings.hooks ?? {});
|
|
3796
|
+
process.stdout.write("\n");
|
|
3797
|
+
process.stdout.write("claudectx hooks \u2014 available hooks\n");
|
|
3798
|
+
process.stdout.write("\u2550".repeat(55) + "\n");
|
|
3799
|
+
process.stdout.write(
|
|
3800
|
+
` ${"Name".padEnd(16)} ${"Category".padEnd(12)} ${"Trigger".padEnd(12)} Status
|
|
3801
|
+
`
|
|
3802
|
+
);
|
|
3803
|
+
process.stdout.write("\u2500".repeat(55) + "\n");
|
|
3804
|
+
for (const hook of HOOK_REGISTRY) {
|
|
3805
|
+
const installed = installedCommands.includes(hook.name) ? "\u2713 installed" : " available";
|
|
3806
|
+
process.stdout.write(
|
|
3807
|
+
` ${hook.name.padEnd(16)} ${hook.category.padEnd(12)} ${hook.triggerEvent.padEnd(12)} ${installed}
|
|
3808
|
+
`
|
|
3809
|
+
);
|
|
3810
|
+
}
|
|
3811
|
+
process.stdout.write("\n");
|
|
3812
|
+
process.stdout.write(" Use: claudectx hooks add <name> [--config key=value]\n\n");
|
|
3813
|
+
}
|
|
3814
|
+
async function hooksAdd(name, projectRoot, configPairs) {
|
|
3815
|
+
const def = getHook(name);
|
|
3816
|
+
if (!def) {
|
|
3817
|
+
process.stderr.write(
|
|
3818
|
+
`Error: unknown hook "${name}". Run "claudectx hooks list" to see available hooks.
|
|
3819
|
+
`
|
|
3820
|
+
);
|
|
3821
|
+
process.exit(1);
|
|
3822
|
+
}
|
|
3823
|
+
const config = parseConfigPairs(configPairs);
|
|
3824
|
+
const requiredMissing = Object.entries(def.configSchema).filter(
|
|
3825
|
+
([key, field]) => field.required && config[key] === void 0
|
|
3826
|
+
);
|
|
3827
|
+
if (requiredMissing.length > 0) {
|
|
3828
|
+
try {
|
|
3829
|
+
const { input } = await import("@inquirer/prompts");
|
|
3830
|
+
for (const [key, field] of requiredMissing) {
|
|
3831
|
+
const value = await input({ message: `${field.description} (${key}):` });
|
|
3832
|
+
config[key] = value;
|
|
3833
|
+
}
|
|
3834
|
+
} catch {
|
|
3835
|
+
process.stderr.write(
|
|
3836
|
+
`Error: required config fields missing: ${requiredMissing.map(([k]) => k).join(", ")}
|
|
3837
|
+
Use: claudectx hooks add ${name} --config ${requiredMissing.map(([k]) => `${k}=<value>`).join(" --config ")}
|
|
3838
|
+
`
|
|
3839
|
+
);
|
|
3840
|
+
process.exit(1);
|
|
3841
|
+
}
|
|
3842
|
+
}
|
|
3843
|
+
for (const [key, field] of Object.entries(def.configSchema)) {
|
|
3844
|
+
if (config[key] === void 0 && field.default !== void 0) {
|
|
3845
|
+
config[key] = String(field.default);
|
|
3846
|
+
}
|
|
3847
|
+
}
|
|
3848
|
+
const command = interpolateCommand(def.commandTemplate, config);
|
|
3849
|
+
if (command.includes("{{config.")) {
|
|
3850
|
+
process.stderr.write(`Error: unresolved config placeholders in command: ${command}
|
|
3851
|
+
`);
|
|
3852
|
+
process.exit(1);
|
|
3853
|
+
}
|
|
3854
|
+
const sensitiveKeys = Object.keys(config).filter(
|
|
3855
|
+
(k) => /key|token|secret|password|webhook/i.test(k)
|
|
3856
|
+
);
|
|
3857
|
+
if (sensitiveKeys.length > 0) {
|
|
3858
|
+
process.stderr.write(
|
|
3859
|
+
`Warning: config field(s) [${sensitiveKeys.join(", ")}] will be stored in plain text in
|
|
3860
|
+
.claude/settings.local.json \u2014 ensure this file is in your .gitignore.
|
|
3861
|
+
`
|
|
3862
|
+
);
|
|
3863
|
+
}
|
|
3864
|
+
const entry = { ...buildHookEntry(def, config), name };
|
|
3865
|
+
const settings = readInstalledHooks(projectRoot);
|
|
3866
|
+
const hooksObj = settings.hooks ?? {};
|
|
3867
|
+
const eventList = hooksObj[def.triggerEvent] ?? [];
|
|
3868
|
+
if (eventList.some((e) => e.name === name)) {
|
|
3869
|
+
process.stdout.write(`Hook "${name}" is already installed.
|
|
3870
|
+
`);
|
|
3871
|
+
return;
|
|
3872
|
+
}
|
|
3873
|
+
const updatedSettings = {
|
|
3874
|
+
...settings,
|
|
3875
|
+
hooks: { ...hooksObj, [def.triggerEvent]: [...eventList, entry] }
|
|
3876
|
+
};
|
|
3877
|
+
writeHooksSettings(projectRoot, updatedSettings);
|
|
3878
|
+
process.stdout.write(`
|
|
3879
|
+
\u2713 Hook "${name}" installed (${def.triggerEvent}${def.matcher ? ` / ${def.matcher}` : ""}).
|
|
3880
|
+
|
|
3881
|
+
`);
|
|
3882
|
+
}
|
|
3883
|
+
async function hooksRemove(name, projectRoot) {
|
|
3884
|
+
const settings = readInstalledHooks(projectRoot);
|
|
3885
|
+
const updated = removeHookByName(settings, name);
|
|
3886
|
+
writeHooksSettings(projectRoot, updated);
|
|
3887
|
+
process.stdout.write(` \u2713 Hook "${name}" removed.
|
|
3888
|
+
|
|
3889
|
+
`);
|
|
3890
|
+
}
|
|
3891
|
+
async function hooksStatus(projectRoot) {
|
|
3892
|
+
const settings = readInstalledHooks(projectRoot);
|
|
3893
|
+
const hooks = settings.hooks ?? {};
|
|
3894
|
+
const entries = Object.entries(hooks).flatMap(
|
|
3895
|
+
([event, arr]) => arr.map((e) => ({ event, ...e }))
|
|
3896
|
+
);
|
|
3897
|
+
if (entries.length === 0) {
|
|
3898
|
+
process.stdout.write('\n No hooks installed. Run "claudectx hooks add <name>" to add one.\n\n');
|
|
3899
|
+
return;
|
|
3900
|
+
}
|
|
3901
|
+
process.stdout.write("\n Installed hooks:\n");
|
|
3902
|
+
for (const entry of entries) {
|
|
3903
|
+
const name = entry.name ?? "unnamed";
|
|
3904
|
+
const event = entry.event;
|
|
3905
|
+
const matcher = entry.matcher ? ` / ${entry.matcher}` : "";
|
|
3906
|
+
process.stdout.write(` ${name.padEnd(18)} ${event}${matcher}
|
|
3907
|
+
`);
|
|
3908
|
+
}
|
|
3909
|
+
process.stdout.write("\n");
|
|
3910
|
+
}
|
|
3911
|
+
async function hooksCommand(subcommand, options) {
|
|
3912
|
+
const projectPath = options.path ? path24.resolve(options.path) : process.cwd();
|
|
3913
|
+
const projectRoot = findProjectRoot(projectPath) ?? projectPath;
|
|
3914
|
+
const sub = subcommand ?? "list";
|
|
3915
|
+
switch (sub) {
|
|
3916
|
+
case "list":
|
|
3917
|
+
await hooksList(projectRoot);
|
|
3918
|
+
break;
|
|
3919
|
+
case "add": {
|
|
3920
|
+
const name = options.name;
|
|
3921
|
+
if (!name) {
|
|
3922
|
+
process.stderr.write("Usage: claudectx hooks add <name> [--config key=value]\n");
|
|
3923
|
+
process.exit(1);
|
|
3924
|
+
}
|
|
3925
|
+
await hooksAdd(name, projectRoot, options.config ?? []);
|
|
3926
|
+
break;
|
|
3927
|
+
}
|
|
3928
|
+
case "remove": {
|
|
3929
|
+
const name = options.name;
|
|
3930
|
+
if (!name) {
|
|
3931
|
+
process.stderr.write("Usage: claudectx hooks remove <name>\n");
|
|
3932
|
+
process.exit(1);
|
|
3933
|
+
}
|
|
3934
|
+
await hooksRemove(name, projectRoot);
|
|
3935
|
+
break;
|
|
3936
|
+
}
|
|
3937
|
+
case "status":
|
|
3938
|
+
await hooksStatus(projectRoot);
|
|
3939
|
+
break;
|
|
3940
|
+
default:
|
|
3941
|
+
process.stderr.write(
|
|
3942
|
+
`Unknown sub-command "${sub}". Use: list | add <name> | remove <name> | status
|
|
3943
|
+
`
|
|
3944
|
+
);
|
|
3945
|
+
process.exit(1);
|
|
3946
|
+
}
|
|
3947
|
+
}
|
|
3948
|
+
|
|
3949
|
+
// src/commands/convert.ts
|
|
3950
|
+
init_cjs_shims();
|
|
3951
|
+
var fs22 = __toESM(require("fs"));
|
|
3952
|
+
var path25 = __toESM(require("path"));
|
|
3953
|
+
function slugify2(text) {
|
|
3954
|
+
return text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
3955
|
+
}
|
|
3956
|
+
function claudeMdToCursorRules(content) {
|
|
3957
|
+
const files = [];
|
|
3958
|
+
const lines = content.split("\n");
|
|
3959
|
+
let currentHeader = "";
|
|
3960
|
+
let currentBody = [];
|
|
3961
|
+
function flushSection() {
|
|
3962
|
+
if (!currentHeader) return;
|
|
3963
|
+
const slug = slugify2(currentHeader);
|
|
3964
|
+
if (!slug) return;
|
|
3965
|
+
const mdc = [
|
|
3966
|
+
"---",
|
|
3967
|
+
`description: ${currentHeader}`,
|
|
3968
|
+
"alwaysApply: true",
|
|
3969
|
+
"---",
|
|
3970
|
+
"",
|
|
3971
|
+
...currentBody
|
|
3972
|
+
].join("\n");
|
|
3973
|
+
files.push({ filename: `${slug}.mdc`, content: mdc });
|
|
3974
|
+
}
|
|
3975
|
+
for (const line of lines) {
|
|
3976
|
+
const match = line.match(/^##\s+(.+)$/);
|
|
3977
|
+
if (match) {
|
|
3978
|
+
flushSection();
|
|
3979
|
+
currentHeader = match[1];
|
|
3980
|
+
currentBody = [];
|
|
3981
|
+
} else if (currentHeader) {
|
|
3982
|
+
currentBody.push(line);
|
|
3983
|
+
}
|
|
3984
|
+
}
|
|
3985
|
+
flushSection();
|
|
3986
|
+
if (files.length === 0 && content.trim()) {
|
|
3987
|
+
files.push({
|
|
3988
|
+
filename: "project-instructions.mdc",
|
|
3989
|
+
content: ["---", "description: Project Instructions", "alwaysApply: true", "---", "", content].join("\n")
|
|
3990
|
+
});
|
|
3991
|
+
}
|
|
3992
|
+
return files;
|
|
3993
|
+
}
|
|
3994
|
+
function claudeMdToCopilot(content) {
|
|
3995
|
+
return content.split("\n").filter((line) => !line.match(/^@.+$/)).join("\n").replace(/\n{3,}/g, "\n\n").trim();
|
|
3996
|
+
}
|
|
3997
|
+
function claudeMdToWindsurf(content) {
|
|
3998
|
+
return content.split("\n").filter((line) => !line.match(/^@.+$/)).join("\n").replace(/\n{3,}/g, "\n\n").trim();
|
|
3999
|
+
}
|
|
4000
|
+
async function convertCommand(options) {
|
|
4001
|
+
const projectPath = options.path ? path25.resolve(options.path) : process.cwd();
|
|
4002
|
+
const projectRoot = findProjectRoot(projectPath) ?? projectPath;
|
|
4003
|
+
const from = options.from ?? "claude";
|
|
4004
|
+
const to = options.to;
|
|
4005
|
+
if (from !== "claude") {
|
|
4006
|
+
process.stderr.write(`Error: --from "${from}" is not yet supported. Only --from claude is available.
|
|
4007
|
+
`);
|
|
4008
|
+
process.exit(1);
|
|
4009
|
+
}
|
|
4010
|
+
const claudeMdPath = path25.join(projectRoot, "CLAUDE.md");
|
|
4011
|
+
let content = "";
|
|
4012
|
+
try {
|
|
4013
|
+
content = fs22.readFileSync(claudeMdPath, "utf-8");
|
|
4014
|
+
} catch {
|
|
4015
|
+
process.stderr.write(`Error: CLAUDE.md not found at ${claudeMdPath}
|
|
4016
|
+
`);
|
|
4017
|
+
process.exit(1);
|
|
4018
|
+
}
|
|
4019
|
+
if (to === "cursor") {
|
|
4020
|
+
const files = claudeMdToCursorRules(content);
|
|
4021
|
+
const targetDir = path25.join(projectRoot, ".cursor", "rules");
|
|
4022
|
+
process.stdout.write(`
|
|
4023
|
+
Converting CLAUDE.md \u2192 ${files.length} Cursor rule file(s)
|
|
4024
|
+
|
|
4025
|
+
`);
|
|
4026
|
+
for (const file of files) {
|
|
4027
|
+
const filePath = path25.join(targetDir, file.filename);
|
|
4028
|
+
const exists = fs22.existsSync(filePath);
|
|
4029
|
+
const prefix = options.dryRun ? "[dry-run] " : exists ? "[overwrite] " : "";
|
|
4030
|
+
process.stdout.write(` ${prefix}\u2192 .cursor/rules/${file.filename}
|
|
4031
|
+
`);
|
|
4032
|
+
if (!options.dryRun) {
|
|
4033
|
+
fs22.mkdirSync(targetDir, { recursive: true });
|
|
4034
|
+
fs22.writeFileSync(filePath, file.content, "utf-8");
|
|
4035
|
+
}
|
|
4036
|
+
}
|
|
4037
|
+
process.stdout.write("\n");
|
|
4038
|
+
} else if (to === "copilot") {
|
|
4039
|
+
const converted = claudeMdToCopilot(content);
|
|
4040
|
+
const targetPath = path25.join(projectRoot, ".github", "copilot-instructions.md");
|
|
4041
|
+
const exists = fs22.existsSync(targetPath);
|
|
4042
|
+
process.stdout.write(`
|
|
4043
|
+
Converting CLAUDE.md \u2192 .github/copilot-instructions.md${exists ? " [overwrite]" : ""}
|
|
4044
|
+
`);
|
|
4045
|
+
if (!options.dryRun) {
|
|
4046
|
+
fs22.mkdirSync(path25.dirname(targetPath), { recursive: true });
|
|
4047
|
+
fs22.writeFileSync(targetPath, converted, "utf-8");
|
|
4048
|
+
process.stdout.write(` \u2713 Written to ${targetPath}
|
|
4049
|
+
|
|
4050
|
+
`);
|
|
4051
|
+
} else {
|
|
4052
|
+
process.stdout.write(` [dry-run] Would write ${converted.length} chars to ${targetPath}
|
|
4053
|
+
|
|
4054
|
+
`);
|
|
4055
|
+
}
|
|
4056
|
+
} else if (to === "windsurf") {
|
|
4057
|
+
const converted = claudeMdToWindsurf(content);
|
|
4058
|
+
const targetPath = path25.join(projectRoot, ".windsurfrules");
|
|
4059
|
+
const exists = fs22.existsSync(targetPath);
|
|
4060
|
+
process.stdout.write(`
|
|
4061
|
+
Converting CLAUDE.md \u2192 .windsurfrules${exists ? " [overwrite]" : ""}
|
|
4062
|
+
`);
|
|
4063
|
+
if (!options.dryRun) {
|
|
4064
|
+
fs22.writeFileSync(targetPath, converted, "utf-8");
|
|
4065
|
+
process.stdout.write(` \u2713 Written to ${targetPath}
|
|
4066
|
+
|
|
4067
|
+
`);
|
|
4068
|
+
} else {
|
|
4069
|
+
process.stdout.write(` [dry-run] Would write ${converted.length} chars to ${targetPath}
|
|
4070
|
+
|
|
4071
|
+
`);
|
|
4072
|
+
}
|
|
4073
|
+
} else {
|
|
4074
|
+
process.stderr.write(`Error: unknown target "${to}". Use: cursor | copilot | windsurf
|
|
4075
|
+
`);
|
|
4076
|
+
process.exit(1);
|
|
4077
|
+
}
|
|
4078
|
+
}
|
|
4079
|
+
|
|
4080
|
+
// src/commands/teams.ts
|
|
4081
|
+
init_cjs_shims();
|
|
4082
|
+
var path27 = __toESM(require("path"));
|
|
4083
|
+
var fs24 = __toESM(require("fs"));
|
|
4084
|
+
init_models();
|
|
4085
|
+
|
|
4086
|
+
// src/reporter/team-aggregator.ts
|
|
4087
|
+
init_cjs_shims();
|
|
4088
|
+
var fs23 = __toESM(require("fs"));
|
|
4089
|
+
var path26 = __toESM(require("path"));
|
|
4090
|
+
var os4 = __toESM(require("os"));
|
|
4091
|
+
var childProcess2 = __toESM(require("child_process"));
|
|
4092
|
+
init_session_reader();
|
|
4093
|
+
init_session_store();
|
|
4094
|
+
init_models();
|
|
4095
|
+
function getDeveloperIdentity() {
|
|
4096
|
+
try {
|
|
4097
|
+
const email = childProcess2.execSync("git config user.email", { encoding: "utf-8", timeout: 2e3, stdio: ["pipe", "pipe", "pipe"] }).trim();
|
|
4098
|
+
if (email) return email;
|
|
4099
|
+
} catch {
|
|
4100
|
+
}
|
|
4101
|
+
return os4.hostname();
|
|
4102
|
+
}
|
|
4103
|
+
function calcCost3(inputTokens, outputTokens, model) {
|
|
4104
|
+
const p = MODEL_PRICING[model];
|
|
4105
|
+
return inputTokens / 1e6 * p.inputPerMillion + outputTokens / 1e6 * p.outputPerMillion;
|
|
4106
|
+
}
|
|
4107
|
+
function isoDate2(d) {
|
|
4108
|
+
return d.toISOString().slice(0, 10);
|
|
4109
|
+
}
|
|
4110
|
+
function anonymizeExport(report, index) {
|
|
4111
|
+
return {
|
|
4112
|
+
...report,
|
|
4113
|
+
developer: {
|
|
4114
|
+
...report.developer,
|
|
4115
|
+
identity: `Dev ${index + 1}`
|
|
4116
|
+
}
|
|
4117
|
+
};
|
|
4118
|
+
}
|
|
4119
|
+
async function buildTeamExport(days, model, anonymize) {
|
|
4120
|
+
const now = /* @__PURE__ */ new Date();
|
|
4121
|
+
const cutoff = new Date(now);
|
|
4122
|
+
cutoff.setDate(cutoff.getDate() - days);
|
|
4123
|
+
const cutoffMs = cutoff.getTime();
|
|
4124
|
+
const sessionFiles = listSessionFiles().filter((f) => f.mtimeMs >= cutoffMs);
|
|
4125
|
+
const bucketMap = /* @__PURE__ */ new Map();
|
|
4126
|
+
for (let i = 0; i < days; i++) {
|
|
4127
|
+
const d = new Date(now);
|
|
4128
|
+
d.setDate(d.getDate() - i);
|
|
4129
|
+
const dateStr = isoDate2(d);
|
|
4130
|
+
bucketMap.set(dateStr, {
|
|
4131
|
+
date: dateStr,
|
|
4132
|
+
sessions: 0,
|
|
4133
|
+
inputTokens: 0,
|
|
4134
|
+
outputTokens: 0,
|
|
4135
|
+
cacheReadTokens: 0,
|
|
4136
|
+
requests: 0,
|
|
4137
|
+
costUsd: 0
|
|
4138
|
+
});
|
|
4139
|
+
}
|
|
4140
|
+
let totalInput = 0;
|
|
4141
|
+
let totalOutput = 0;
|
|
4142
|
+
let totalCacheRead = 0;
|
|
4143
|
+
let totalRequests = 0;
|
|
4144
|
+
for (const sf of sessionFiles) {
|
|
4145
|
+
const dateStr = isoDate2(new Date(sf.mtimeMs));
|
|
4146
|
+
const bucket = bucketMap.get(dateStr);
|
|
4147
|
+
const usage = readSessionUsage(sf.filePath);
|
|
4148
|
+
if (bucket) {
|
|
4149
|
+
bucket.sessions++;
|
|
4150
|
+
bucket.inputTokens += usage.inputTokens;
|
|
4151
|
+
bucket.outputTokens += usage.outputTokens;
|
|
4152
|
+
bucket.cacheReadTokens += usage.cacheReadTokens;
|
|
4153
|
+
bucket.requests += usage.requestCount;
|
|
4154
|
+
bucket.costUsd += calcCost3(usage.inputTokens, usage.outputTokens, model);
|
|
4155
|
+
}
|
|
4156
|
+
totalInput += usage.inputTokens;
|
|
4157
|
+
totalOutput += usage.outputTokens;
|
|
4158
|
+
totalCacheRead += usage.cacheReadTokens;
|
|
4159
|
+
totalRequests += usage.requestCount;
|
|
4160
|
+
}
|
|
4161
|
+
const fileEvents = readAllEvents().filter(
|
|
4162
|
+
(e) => new Date(e.timestamp).getTime() >= cutoffMs
|
|
4163
|
+
);
|
|
4164
|
+
const topWasteFiles = aggregateStats(fileEvents).slice(0, 10).map((s) => ({ filePath: s.filePath, readCount: s.readCount }));
|
|
4165
|
+
const totalCostUsd = calcCost3(totalInput, totalOutput, model);
|
|
4166
|
+
const cacheHitRate = totalInput > 0 ? Math.round(totalCacheRead / totalInput * 100) : 0;
|
|
4167
|
+
const uniqueSessions = new Set(sessionFiles.map((f) => f.sessionId)).size;
|
|
4168
|
+
const developer = {
|
|
4169
|
+
identity: getDeveloperIdentity(),
|
|
4170
|
+
exportedAt: now.toISOString(),
|
|
4171
|
+
periodDays: days,
|
|
4172
|
+
totalCostUsd,
|
|
4173
|
+
totalInputTokens: totalInput,
|
|
4174
|
+
totalOutputTokens: totalOutput,
|
|
4175
|
+
cacheHitRate,
|
|
4176
|
+
avgRequestSize: totalRequests > 0 ? Math.round(totalInput / totalRequests) : 0,
|
|
4177
|
+
topWasteFiles,
|
|
4178
|
+
sessionCount: uniqueSessions
|
|
4179
|
+
};
|
|
4180
|
+
const byDay = [...bucketMap.values()].sort((a, b) => a.date.localeCompare(b.date));
|
|
4181
|
+
const exportData = { version: "1", developer, byDay };
|
|
4182
|
+
if (anonymize) return anonymizeExport(exportData, 0);
|
|
4183
|
+
return exportData;
|
|
4184
|
+
}
|
|
4185
|
+
function aggregateTeamReports(exports2) {
|
|
4186
|
+
const developers = exports2.map((e) => e.developer);
|
|
4187
|
+
developers.sort((a, b) => b.totalCostUsd - a.totalCostUsd);
|
|
4188
|
+
const teamTotalCostUsd = developers.reduce((sum, d) => sum + d.totalCostUsd, 0);
|
|
4189
|
+
const teamCacheHitRate = developers.length > 0 ? Math.round(developers.reduce((sum, d) => sum + d.cacheHitRate, 0) / developers.length) : 0;
|
|
4190
|
+
const fileMap = /* @__PURE__ */ new Map();
|
|
4191
|
+
for (const dev of developers) {
|
|
4192
|
+
for (const wf of dev.topWasteFiles) {
|
|
4193
|
+
const existing = fileMap.get(wf.filePath);
|
|
4194
|
+
if (existing) {
|
|
4195
|
+
existing.readCount += wf.readCount;
|
|
4196
|
+
existing.developers.push(dev.identity);
|
|
4197
|
+
} else {
|
|
4198
|
+
fileMap.set(wf.filePath, { readCount: wf.readCount, developers: [dev.identity] });
|
|
4199
|
+
}
|
|
4200
|
+
}
|
|
4201
|
+
}
|
|
4202
|
+
const topWasteFiles = [...fileMap.entries()].map(([filePath, data]) => ({ filePath, ...data })).sort((a, b) => b.readCount - a.readCount).slice(0, 10);
|
|
4203
|
+
const periodDays = exports2[0]?.developer.periodDays ?? 30;
|
|
4204
|
+
return {
|
|
4205
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
4206
|
+
totalDevelopers: exports2.length,
|
|
4207
|
+
periodDays,
|
|
4208
|
+
developers,
|
|
4209
|
+
teamTotalCostUsd,
|
|
4210
|
+
teamCacheHitRate,
|
|
4211
|
+
topWasteFiles
|
|
4212
|
+
};
|
|
4213
|
+
}
|
|
4214
|
+
function writeTeamExport(exportData) {
|
|
4215
|
+
const storeDir = getStoreDir();
|
|
4216
|
+
if (!fs23.existsSync(storeDir)) fs23.mkdirSync(storeDir, { recursive: true });
|
|
4217
|
+
const date = isoDate2(/* @__PURE__ */ new Date());
|
|
4218
|
+
const filePath = path26.join(storeDir, `team-export-${date}.json`);
|
|
4219
|
+
fs23.writeFileSync(filePath, JSON.stringify(exportData, null, 2), "utf-8");
|
|
4220
|
+
return filePath;
|
|
4221
|
+
}
|
|
4222
|
+
function readTeamExports(dir) {
|
|
4223
|
+
const exports2 = [];
|
|
4224
|
+
if (!fs23.existsSync(dir)) return exports2;
|
|
4225
|
+
const files = fs23.readdirSync(dir).filter((f) => f.match(/^team-export-.*\.json$/));
|
|
4226
|
+
for (const file of files) {
|
|
4227
|
+
try {
|
|
4228
|
+
const raw = fs23.readFileSync(path26.join(dir, file), "utf-8");
|
|
4229
|
+
exports2.push(JSON.parse(raw));
|
|
4230
|
+
} catch {
|
|
4231
|
+
}
|
|
4232
|
+
}
|
|
4233
|
+
return exports2;
|
|
4234
|
+
}
|
|
4235
|
+
|
|
4236
|
+
// src/commands/teams.ts
|
|
4237
|
+
init_session_store();
|
|
4238
|
+
async function teamsExport(options) {
|
|
4239
|
+
const model = resolveModel(options.model ?? "sonnet");
|
|
4240
|
+
const days = parseInt(options.days ?? "30", 10);
|
|
4241
|
+
const anonymize = options.anonymize ?? false;
|
|
4242
|
+
const exportData = await buildTeamExport(days, model, anonymize);
|
|
4243
|
+
if (options.json) {
|
|
4244
|
+
process.stdout.write(JSON.stringify(exportData, null, 2) + "\n");
|
|
4245
|
+
return;
|
|
4246
|
+
}
|
|
4247
|
+
const filePath = writeTeamExport(exportData);
|
|
4248
|
+
process.stdout.write("\n");
|
|
4249
|
+
process.stdout.write(`claudectx teams export
|
|
4250
|
+
`);
|
|
4251
|
+
process.stdout.write("\u2550".repeat(45) + "\n");
|
|
4252
|
+
process.stdout.write(` Developer: ${exportData.developer.identity}
|
|
4253
|
+
`);
|
|
4254
|
+
process.stdout.write(` Period: Last ${days} days
|
|
4255
|
+
`);
|
|
4256
|
+
process.stdout.write(` Sessions: ${exportData.developer.sessionCount}
|
|
4257
|
+
`);
|
|
4258
|
+
process.stdout.write(` Total cost: ${formatCost(exportData.developer.totalCostUsd)}
|
|
4259
|
+
`);
|
|
4260
|
+
process.stdout.write(` Cache rate: ${exportData.developer.cacheHitRate}%
|
|
4261
|
+
`);
|
|
4262
|
+
process.stdout.write(`
|
|
4263
|
+
\u2713 Saved to: ${filePath}
|
|
4264
|
+
`);
|
|
4265
|
+
process.stdout.write(" Share this file with your team lead for aggregation.\n\n");
|
|
4266
|
+
}
|
|
4267
|
+
async function teamsAggregate(options) {
|
|
4268
|
+
const dir = options.dir ?? getStoreDir();
|
|
4269
|
+
const anonymize = options.anonymize ?? false;
|
|
4270
|
+
const exports2 = readTeamExports(dir);
|
|
4271
|
+
if (exports2.length === 0) {
|
|
4272
|
+
process.stderr.write(
|
|
4273
|
+
`No team export files found in ${dir}.
|
|
4274
|
+
Run "claudectx teams export" on each developer machine first.
|
|
4275
|
+
`
|
|
4276
|
+
);
|
|
4277
|
+
process.exit(1);
|
|
4278
|
+
}
|
|
4279
|
+
const anonymized = anonymize ? exports2.map((e, i) => anonymizeExport(e, i)) : exports2;
|
|
4280
|
+
const report = aggregateTeamReports(anonymized);
|
|
4281
|
+
if (options.json) {
|
|
4282
|
+
process.stdout.write(JSON.stringify(report, null, 2) + "\n");
|
|
4283
|
+
return;
|
|
4284
|
+
}
|
|
4285
|
+
process.stdout.write("\n");
|
|
4286
|
+
process.stdout.write("claudectx teams aggregate \u2014 team cost report\n");
|
|
4287
|
+
process.stdout.write("\u2550".repeat(55) + "\n");
|
|
4288
|
+
process.stdout.write(` Developers: ${report.totalDevelopers}
|
|
4289
|
+
`);
|
|
4290
|
+
process.stdout.write(` Period: Last ${report.periodDays} days
|
|
4291
|
+
`);
|
|
4292
|
+
process.stdout.write(` Team total cost: ${formatCost(report.teamTotalCostUsd)}
|
|
4293
|
+
`);
|
|
4294
|
+
process.stdout.write(` Team cache rate: ${report.teamCacheHitRate}%
|
|
4295
|
+
`);
|
|
4296
|
+
process.stdout.write("\n");
|
|
4297
|
+
process.stdout.write(
|
|
4298
|
+
` ${"Developer".padEnd(30)} ${"Cost".padStart(8)} ${"Cache".padStart(6)} Sessions
|
|
4299
|
+
`
|
|
4300
|
+
);
|
|
4301
|
+
process.stdout.write("\u2500".repeat(55) + "\n");
|
|
4302
|
+
for (const dev of report.developers) {
|
|
4303
|
+
const identity = dev.identity.slice(0, 30).padEnd(30);
|
|
4304
|
+
const cost = formatCost(dev.totalCostUsd).padStart(8);
|
|
4305
|
+
const cache = `${dev.cacheHitRate}%`.padStart(6);
|
|
4306
|
+
const sessions = String(dev.sessionCount).padStart(8);
|
|
4307
|
+
process.stdout.write(` ${identity} ${cost} ${cache} ${sessions}
|
|
4308
|
+
`);
|
|
4309
|
+
}
|
|
4310
|
+
process.stdout.write("\n");
|
|
4311
|
+
if (report.topWasteFiles.length > 0) {
|
|
4312
|
+
process.stdout.write(" Top shared files (by read count across team):\n");
|
|
4313
|
+
for (const f of report.topWasteFiles.slice(0, 5)) {
|
|
4314
|
+
const devList = f.developers.slice(0, 3).join(", ");
|
|
4315
|
+
process.stdout.write(` ${f.readCount}x ${path27.basename(f.filePath)} (${devList})
|
|
4316
|
+
`);
|
|
4317
|
+
}
|
|
4318
|
+
process.stdout.write("\n");
|
|
4319
|
+
}
|
|
4320
|
+
}
|
|
4321
|
+
async function teamsShare(options) {
|
|
4322
|
+
const dest = options.to;
|
|
4323
|
+
if (!dest) {
|
|
4324
|
+
process.stderr.write("Usage: claudectx teams share --to <path>\n");
|
|
4325
|
+
process.exit(1);
|
|
4326
|
+
}
|
|
4327
|
+
const storeDir = getStoreDir();
|
|
4328
|
+
const exportFiles = fs24.readdirSync(storeDir).filter((f) => f.match(/^team-export-.*\.json$/)).sort().reverse();
|
|
4329
|
+
if (exportFiles.length === 0) {
|
|
4330
|
+
process.stderr.write('No team export files found. Run "claudectx teams export" first.\n');
|
|
4331
|
+
process.exit(1);
|
|
4332
|
+
}
|
|
4333
|
+
const latest = exportFiles[0];
|
|
4334
|
+
const src = path27.join(storeDir, latest);
|
|
4335
|
+
let destPath;
|
|
4336
|
+
try {
|
|
4337
|
+
const stat = fs24.statSync(dest);
|
|
4338
|
+
destPath = stat.isDirectory() ? path27.join(dest, latest) : dest;
|
|
4339
|
+
} catch {
|
|
4340
|
+
destPath = dest;
|
|
4341
|
+
}
|
|
4342
|
+
const destDir = path27.dirname(path27.resolve(destPath));
|
|
4343
|
+
let resolvedDir;
|
|
4344
|
+
try {
|
|
4345
|
+
resolvedDir = fs24.realpathSync(destDir);
|
|
4346
|
+
} catch {
|
|
4347
|
+
resolvedDir = destDir;
|
|
4348
|
+
}
|
|
4349
|
+
const systemDirs = ["/etc", "/bin", "/sbin", "/usr", "/var", "/proc", "/sys"];
|
|
4350
|
+
if (systemDirs.some((d) => resolvedDir === d || resolvedDir.startsWith(d + "/"))) {
|
|
4351
|
+
process.stderr.write(`Error: destination path resolves to a system directory (${resolvedDir}). Aborting.
|
|
4352
|
+
`);
|
|
4353
|
+
process.exit(1);
|
|
4354
|
+
}
|
|
4355
|
+
fs24.copyFileSync(src, destPath);
|
|
4356
|
+
process.stdout.write(` \u2713 Copied ${latest} \u2192 ${destPath}
|
|
4357
|
+
|
|
4358
|
+
`);
|
|
4359
|
+
}
|
|
4360
|
+
async function teamsCommand(subcommand, options) {
|
|
4361
|
+
switch (subcommand) {
|
|
4362
|
+
case "export":
|
|
4363
|
+
await teamsExport(options);
|
|
4364
|
+
break;
|
|
4365
|
+
case "aggregate":
|
|
4366
|
+
await teamsAggregate(options);
|
|
4367
|
+
break;
|
|
4368
|
+
case "share":
|
|
4369
|
+
await teamsShare(options);
|
|
4370
|
+
break;
|
|
4371
|
+
default:
|
|
4372
|
+
process.stderr.write(
|
|
4373
|
+
`Unknown sub-command "${subcommand}". Use: export | aggregate | share
|
|
4374
|
+
`
|
|
4375
|
+
);
|
|
4376
|
+
process.exit(1);
|
|
4377
|
+
}
|
|
4378
|
+
}
|
|
4379
|
+
|
|
4380
|
+
// src/index.ts
|
|
4381
|
+
var VERSION = "1.0.0";
|
|
4382
|
+
var DESCRIPTION = "Reduce Claude Code token usage by up to 80%. Context analyzer, auto-optimizer, live dashboard, and smart MCP tools.";
|
|
4383
|
+
var program = new import_commander.Command();
|
|
4384
|
+
program.name("claudectx").description(DESCRIPTION).version(VERSION);
|
|
4385
|
+
program.command("analyze").alias("a").description("Analyze token usage in the current Claude Code project").option("-p, --path <path>", "Path to project directory (default: cwd)").option("-j, --json", "Output raw JSON (for scripting)").option("-m, --model <model>", "Claude model to estimate costs for (haiku|sonnet|opus)", "sonnet").option("-w, --watch", "Re-run analysis on CLAUDE.md / MEMORY.md changes").action(async (options) => {
|
|
4386
|
+
await analyzeCommand(options);
|
|
4387
|
+
});
|
|
4388
|
+
program.command("optimize").alias("o").description("Auto-fix token waste issues in CLAUDE.md, .claudeignore, and hooks").option("-p, --path <path>", "Path to project directory (default: cwd)").option("--apply", "Apply all fixes without prompting").option("--dry-run", "Preview changes without applying").option("--claudemd", "Only optimize CLAUDE.md (split into @files)").option("--ignorefile", "Only generate .claudeignore").option("--cache", "Only fix cache-busting content").option("--hooks", "Only install session hooks").option("--api-key <key>", "Anthropic API key (for AI-powered CLAUDE.md rewriting)").action(async (options) => {
|
|
4389
|
+
await optimizeCommand(options);
|
|
4390
|
+
});
|
|
4391
|
+
program.command("watch").alias("w").description("Live token-usage dashboard \u2014 tracks files read and session cost in real time").option("--session <id>", "Watch a specific session ID (default: most recent)").option("-m, --model <model>", "Model for cost estimates (haiku|sonnet|opus)", "sonnet").option("--log-stdin", "Read hook JSON from stdin and log the file path (called by Claude Code hook)").option("--clear", "Clear the session file-read log and exit").action(async (options) => {
|
|
4392
|
+
await watchCommand(options);
|
|
4393
|
+
});
|
|
4394
|
+
program.command("mcp").description("Start the smart MCP server \u2014 symbol-level file reading for Claude Code").option("-p, --path <path>", "Project root (default: cwd)").option("--port <port>", "HTTP transport port (stdio is default; HTTP coming soon)").option("--install", "Add server to .claude/settings.json and exit").action(async (options) => {
|
|
4395
|
+
await mcpCommand(options);
|
|
4396
|
+
});
|
|
4397
|
+
program.command("compress").alias("c").description("Compress a Claude Code session into a compact MEMORY.md entry").option("-p, --path <path>", "Project directory (default: cwd)").option("--session <id>", "Compress specific session ID (default: most recent)").option("--auto", "Non-interactive mode (for hooks)").option("--prune", "Also prune old MEMORY.md entries").option("--days <n>", "Days threshold for pruning (with --prune)", "30").option("--api-key <key>", "Anthropic API key for AI-powered summarization").action(async (options) => {
|
|
4398
|
+
await compressCommand(options);
|
|
4399
|
+
});
|
|
4400
|
+
program.command("report").alias("r").description("Show token usage analytics for the last N days").option("-p, --path <path>", "Project directory (default: cwd)").option("--days <n>", "Number of days to include", "7").option("--json", "Machine-readable JSON output").option("--markdown", "GitHub-flavoured Markdown output").option("-m, --model <model>", "Claude model for cost estimates (haiku|sonnet|opus)", "sonnet").action(async (options) => {
|
|
4401
|
+
await reportCommand(options);
|
|
4402
|
+
});
|
|
4403
|
+
program.command("budget <globs...>").description("Estimate token cost before running a task").option("-m, --model <model>", "Model for cost estimates (haiku|sonnet|opus)", "sonnet").option("--threshold <n>", "Warn if total exceeds N tokens", "10000").option("-p, --path <path>", "Project directory").option("--json", "JSON output").action(async (globs, options) => {
|
|
4404
|
+
await budgetCommand(globs, options);
|
|
4405
|
+
});
|
|
4406
|
+
program.command("warmup").description("Pre-warm the Anthropic prompt cache with your CLAUDE.md").option("-m, --model <model>", "Model (haiku|sonnet|opus)", "haiku").option("--ttl <minutes>", "Cache TTL: 5 or 60", "5").option("--cron <expr>", 'Install as cron job (e.g. "0 9 * * 1-5")').option("--api-key <key>", "Anthropic API key").option("-p, --path <path>", "Project directory").option("--json", "JSON output").action(async (options) => {
|
|
4407
|
+
await warmupCommand(options);
|
|
4408
|
+
});
|
|
4409
|
+
program.command("drift").description("Detect stale references and dead sections in CLAUDE.md").option("-p, --path <path>", "Project directory").option("--days <n>", "Days window for section usage", "30").option("--fix", "Interactively remove flagged lines").option("--json", "JSON output").action(async (options) => {
|
|
4410
|
+
await driftCommand(options);
|
|
4411
|
+
});
|
|
4412
|
+
program.command("hooks [subcommand] [name]").description("Hook marketplace: list | add <name> | remove <name> | status").option("-p, --path <path>", "Project directory").option("--config <pair...>", "key=value config pairs for add").action(async (subcommand, name, options) => {
|
|
4413
|
+
await hooksCommand(subcommand, { ...options, name });
|
|
4414
|
+
});
|
|
4415
|
+
program.command("convert").description("Convert CLAUDE.md to another AI assistant format").option("--from <assistant>", "Source format (default: claude)", "claude").requiredOption("--to <assistant>", "Target format: cursor | copilot | windsurf").option("--dry-run", "Preview without writing").option("-p, --path <path>", "Project directory").action(async (options) => {
|
|
4416
|
+
await convertCommand(options);
|
|
4417
|
+
});
|
|
4418
|
+
program.command("teams [subcommand]").description("Multi-developer cost attribution (export | aggregate | share)").option("--days <n>", "Days to include", "30").option("-m, --model <model>", "Model", "sonnet").option("--anonymize", "Replace identities with Dev 1, Dev 2...").option("--dir <path>", "Directory with team export JSON files").option("--to <path>", "Destination for share sub-command").option("--json", "JSON output").action(async (subcommand, options) => {
|
|
4419
|
+
await teamsCommand(subcommand ?? "export", options);
|
|
3024
4420
|
});
|
|
3025
4421
|
program.parse();
|
|
3026
4422
|
//# sourceMappingURL=index.js.map
|