claudectx 1.1.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 CHANGED
@@ -28,6 +28,10 @@ A typical Claude Code session costs **$2–$15 in tokens**. Most of it is wasted
28
28
  | Full file reads for small questions | 70% overhead from unnecessary lines | `claudectx mcp` |
29
29
  | No prompt caching configured | Paying 10× for static context | `claudectx optimize --cache` |
30
30
  | No cross-session memory | Repeating the same context every session | `claudectx compress` |
31
+ | Dead `@refs` and stale sections in CLAUDE.md | Silent token waste on every request | `claudectx drift` |
32
+ | Unknown cost before running a big task | Surprise bills after the fact | `claudectx budget` |
33
+ | Cache cold at session start | First request always a full miss | `claudectx warmup` |
34
+ | No visibility into team-wide spend | Can't attribute cost across devs | `claudectx teams` |
31
35
 
32
36
  ### Where your tokens actually go
33
37
 
@@ -59,6 +63,23 @@ claudectx compress
59
63
 
60
64
  # Review your token spend for the last 7 days
61
65
  claudectx report
66
+
67
+ # --- v1.1.0 additions ---
68
+
69
+ # Know the cost before you start a task
70
+ claudectx budget "src/**/*.ts" "tests/**/*.ts"
71
+
72
+ # Pre-warm your prompt cache so the first request is free
73
+ claudectx warmup --api-key $ANTHROPIC_API_KEY
74
+
75
+ # Find dead references and stale sections in CLAUDE.md
76
+ claudectx drift
77
+
78
+ # Convert CLAUDE.md to Cursor / Copilot / Windsurf format
79
+ claudectx convert --to cursor
80
+
81
+ # Export your usage data for team-wide cost attribution
82
+ claudectx teams export
62
83
  ```
63
84
 
64
85
  ## Installation
@@ -229,6 +250,116 @@ DAILY USAGE
229
250
 
230
251
  ---
231
252
 
253
+ ### `claudectx budget` — Know the cost before you start
254
+
255
+ Before running a big task, see exactly which files will be read, how many tokens they'll consume, and what it'll cost.
256
+
257
+ ```bash
258
+ claudectx budget "src/**/*.ts" # Estimate all TypeScript files
259
+ claudectx budget "**/*.py" --threshold 20000 # Warn if total exceeds 20K tokens
260
+ claudectx budget "src/**" --model opus --json # JSON output for scripting
261
+ ```
262
+
263
+ Output shows per-file token counts, cache hit likelihood (based on your recent reads), total cost estimate, and `.claudeignore` recommendations for files that are large but rarely useful.
264
+
265
+ ---
266
+
267
+ ### `claudectx warmup` — Pre-warm the prompt cache
268
+
269
+ Start each session with a cache hit instead of a full miss. Sends a silent priming request to Anthropic so your CLAUDE.md is cached before Claude Code touches it.
270
+
271
+ ```bash
272
+ claudectx warmup --api-key $ANTHROPIC_API_KEY # 5-min cache TTL (default)
273
+ claudectx warmup --ttl 60 --api-key $ANTHROPIC_API_KEY # 60-min extended TTL
274
+ claudectx warmup --cron "0 9 * * 1-5" --api-key ... # Install as daily cron job
275
+ claudectx warmup --json # Output result as JSON
276
+ ```
277
+
278
+ Reports tokens warmed, write cost, savings per cache hit, and break-even request count.
279
+
280
+ ---
281
+
282
+ ### `claudectx drift` — CLAUDE.md stays fresh, not stale
283
+
284
+ Over time CLAUDE.md accumulates dead references and sections nobody reads. `drift` finds them.
285
+
286
+ ```bash
287
+ claudectx drift # Scan current project
288
+ claudectx drift --days 14 # Use 14-day read window (default: 30)
289
+ claudectx drift --fix # Interactively remove flagged lines
290
+ claudectx drift --json # JSON output
291
+ ```
292
+
293
+ Detects 4 types of drift:
294
+
295
+ | Type | Example |
296
+ |---|---|
297
+ | **Dead `@ref`** | `@src/old-service.ts` — file deleted |
298
+ | **Git-deleted mention** | `legacy-auth.py` appears in prose but was removed in git |
299
+ | **Stale section** | `## Android Setup` — zero reads in 30 days |
300
+ | **Dead inline path** | `src/utils/helper.py` mentioned in text, no longer exists |
301
+
302
+ ---
303
+
304
+ ### `claudectx hooks` — Hook marketplace
305
+
306
+ Install named, pre-configured hooks beyond the basic read logger.
307
+
308
+ ```bash
309
+ claudectx hooks list # Show all available hooks
310
+ claudectx hooks add auto-compress --config apiKey=sk-... # Install a hook
311
+ claudectx hooks remove slack-digest # Remove an installed hook
312
+ claudectx hooks status # Show what's installed
313
+ ```
314
+
315
+ **Built-in hooks:**
316
+
317
+ | Hook | Trigger | What it does |
318
+ |---|---|---|
319
+ | `auto-compress` | PostToolUse (Read) | Runs `claudectx compress` after each session |
320
+ | `daily-budget` | PreToolUse | Checks token budget before each tool call |
321
+ | `slack-digest` | Stop | Posts session report to a Slack webhook |
322
+ | `session-warmup` | PostToolUse (Read) | Re-warms the cache after each read |
323
+
324
+ ---
325
+
326
+ ### `claudectx convert` — Use your CLAUDE.md everywhere
327
+
328
+ You wrote great instructions for Claude. Use them with Cursor, Copilot, or Windsurf too.
329
+
330
+ ```bash
331
+ claudectx convert --to cursor # → .cursor/rules/<section>.mdc (one per ## section)
332
+ claudectx convert --to copilot # → .github/copilot-instructions.md
333
+ claudectx convert --to windsurf # → .windsurfrules
334
+ claudectx convert --to cursor --dry-run # Preview without writing
335
+ ```
336
+
337
+ Each `##` section in CLAUDE.md becomes a separate Cursor `.mdc` file with `alwaysApply: true` frontmatter. `@file` references are stripped for assistants that don't support them.
338
+
339
+ ---
340
+
341
+ ### `claudectx teams` — Multi-developer cost attribution
342
+
343
+ See where the money goes across your whole team — without sharing session content.
344
+
345
+ ```bash
346
+ # Step 1: each developer runs this on their own machine
347
+ claudectx teams export
348
+
349
+ # Step 2: collect the JSON files in a shared directory, then aggregate
350
+ claudectx teams aggregate --dir ./reports/
351
+
352
+ # Optional: copy your export to a shared location
353
+ claudectx teams share --to /shared/reports/
354
+
355
+ # Anonymize for review
356
+ claudectx teams aggregate --dir ./reports/ --anonymize
357
+ ```
358
+
359
+ Output shows per-developer spend, cache hit rate, avg request size, and top shared files. Exports are lightweight JSON — no session content, no prompts, just aggregated token counts.
360
+
361
+ ---
362
+
232
363
  ## How it all fits together
233
364
 
234
365
  ```
@@ -261,7 +392,7 @@ git clone https://github.com/Horilla/claudectx.git
261
392
  cd claudectx
262
393
  npm install
263
394
  npm run build
264
- npm test # 199 tests, should all pass
395
+ npm test # 278 tests, should all pass
265
396
  npm run lint # 0 errors expected
266
397
  ```
267
398
 
package/dist/index.js CHANGED
@@ -2243,12 +2243,12 @@ async function handleLogStdin() {
2243
2243
  }
2244
2244
  }
2245
2245
  function readStdin() {
2246
- return new Promise((resolve11) => {
2246
+ return new Promise((resolve12) => {
2247
2247
  let data = "";
2248
2248
  process.stdin.setEncoding("utf-8");
2249
2249
  process.stdin.on("data", (chunk) => data += chunk);
2250
- process.stdin.on("end", () => resolve11(data));
2251
- setTimeout(() => resolve11(data), 500);
2250
+ process.stdin.on("end", () => resolve12(data));
2251
+ setTimeout(() => resolve12(data), 500);
2252
2252
  });
2253
2253
  }
2254
2254
 
@@ -3241,29 +3241,65 @@ async function executeWarmup(claudeMdContent, model, ttl, client) {
3241
3241
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
3242
3242
  };
3243
3243
  }
3244
- async function installCron(cronExpr, apiKey) {
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
+ }
3245
3258
  const { execSync: execSync3 } = await import("child_process");
3246
- const command = `claudectx warmup --api-key ${apiKey}`;
3259
+ const command = `claudectx warmup`;
3247
3260
  const cronLine = `${cronExpr} ${command}`;
3261
+ const marker = "# claudectx warmup";
3248
3262
  try {
3249
3263
  let existing = "";
3250
3264
  try {
3251
- existing = execSync3("crontab -l 2>/dev/null", { encoding: "utf-8" });
3265
+ existing = execSync3("crontab -l", {
3266
+ encoding: "utf-8",
3267
+ stdio: ["pipe", "pipe", "pipe"]
3268
+ });
3252
3269
  } catch {
3253
3270
  existing = "";
3254
3271
  }
3255
- if (existing.includes("claudectx warmup")) {
3256
- process.stdout.write("Cron job already installed for claudectx warmup.\n");
3272
+ if (existing.includes(marker)) {
3273
+ process.stdout.write(" Cron job already installed for claudectx warmup.\n");
3257
3274
  return;
3258
3275
  }
3259
- const newCrontab = existing.trimEnd() + "\n" + cronLine + "\n";
3260
- execSync3(`echo ${JSON.stringify(newCrontab)} | crontab -`);
3261
- process.stdout.write(`\u2713 Cron job installed: ${cronLine}
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}
3262
3294
  `);
3295
+ process.stdout.write(" Note: Set ANTHROPIC_API_KEY in your cron environment (e.g. via ~/.profile).\n");
3263
3296
  } catch {
3264
- process.stdout.write(`Could not install cron automatically. Add manually:
3297
+ process.stdout.write(
3298
+ ` Could not install cron automatically. Add this line manually with "crontab -e":
3299
+ ANTHROPIC_API_KEY=<your-key>
3265
3300
  ${cronLine}
3266
- `);
3301
+ `
3302
+ );
3267
3303
  }
3268
3304
  }
3269
3305
  async function warmupCommand(options) {
@@ -3333,7 +3369,7 @@ async function warmupCommand(options) {
3333
3369
  }
3334
3370
  process.stdout.write("\n");
3335
3371
  if (options.cron) {
3336
- await installCron(options.cron, apiKey);
3372
+ await installCron(options.cron);
3337
3373
  }
3338
3374
  }
3339
3375
 
@@ -3392,15 +3428,15 @@ async function findGitDeletedMentions(content, projectRoot) {
3392
3428
  for (let i = 0; i < lines.length; i++) {
3393
3429
  const line = lines[i];
3394
3430
  for (const deleted of deletedFiles) {
3395
- const basename7 = path22.basename(deleted);
3396
- if (line.includes(basename7) || line.includes(deleted)) {
3431
+ const basename8 = path22.basename(deleted);
3432
+ if (line.includes(basename8) || line.includes(deleted)) {
3397
3433
  issues.push({
3398
3434
  type: "git-deleted",
3399
3435
  line: i + 1,
3400
3436
  text: line.trim(),
3401
3437
  severity: "warning",
3402
3438
  estimatedTokenWaste: countTokens(line),
3403
- suggestion: `References "${basename7}" which was deleted from git. Consider removing this mention.`
3439
+ suggestion: `References "${basename8}" which was deleted from git. Consider removing this mention.`
3404
3440
  });
3405
3441
  break;
3406
3442
  }
@@ -3607,11 +3643,34 @@ async function applyFix(claudeMdPath, issues) {
3607
3643
  const lines = content.split("\n");
3608
3644
  const lineSet = new Set(selectedLines.map((l) => l - 1));
3609
3645
  const newLines = lines.filter((_, i) => !lineSet.has(i));
3610
- fs20.writeFileSync(claudeMdPath, newLines.join("\n"), "utf-8");
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
+ }
3611
3667
  process.stdout.write(`
3612
- \u2713 Removed ${selectedLines.length} line(s) from ${claudeMdPath}
3668
+ \u2713 Removed ${selectedLines.length} line(s) from ${path23.basename(claudeMdPath)}
3669
+ `);
3670
+ process.stdout.write(` \u2713 Backup saved to ${backupPath}
3613
3671
 
3614
3672
  `);
3673
+ void os5;
3615
3674
  }
3616
3675
 
3617
3676
  // src/commands/hooks.ts
@@ -3627,10 +3686,10 @@ var HOOK_REGISTRY = [
3627
3686
  description: "Compress session when token count exceeds threshold",
3628
3687
  triggerEvent: "PostToolUse",
3629
3688
  matcher: "Read",
3630
- commandTemplate: "claudectx compress --auto --api-key {{config.apiKey}}",
3689
+ // API key is read from ANTHROPIC_API_KEY env var — never stored in settings.json
3690
+ commandTemplate: "claudectx compress --auto",
3631
3691
  configSchema: {
3632
- threshold: { type: "number", default: 5e4, description: "Token threshold to trigger compression" },
3633
- apiKey: { type: "string", description: "Anthropic API key for AI summarization", required: true }
3692
+ threshold: { type: "number", default: 5e4, description: "Token threshold to trigger compression" }
3634
3693
  },
3635
3694
  category: "compression"
3636
3695
  },
@@ -3663,10 +3722,9 @@ var HOOK_REGISTRY = [
3663
3722
  description: "Pre-warm the Anthropic prompt cache on each session start",
3664
3723
  triggerEvent: "PostToolUse",
3665
3724
  matcher: "Read",
3666
- commandTemplate: "claudectx warmup --api-key {{config.apiKey}}",
3667
- configSchema: {
3668
- apiKey: { type: "string", description: "Anthropic API key", required: true }
3669
- },
3725
+ // API key is read from ANTHROPIC_API_KEY env var — never stored in settings.json
3726
+ commandTemplate: "claudectx warmup",
3727
+ configSchema: {},
3670
3728
  category: "warmup"
3671
3729
  }
3672
3730
  ];
@@ -3695,9 +3753,18 @@ function buildHookEntry(def, config) {
3695
3753
  // src/commands/hooks.ts
3696
3754
  function readInstalledHooks(projectRoot) {
3697
3755
  const settingsPath = path24.join(projectRoot, ".claude", "settings.local.json");
3756
+ if (!fs21.existsSync(settingsPath)) return {};
3698
3757
  try {
3699
3758
  return JSON.parse(fs21.readFileSync(settingsPath, "utf-8"));
3700
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
+ }
3701
3768
  return {};
3702
3769
  }
3703
3770
  }
@@ -3784,6 +3851,16 @@ async function hooksAdd(name, projectRoot, configPairs) {
3784
3851
  `);
3785
3852
  process.exit(1);
3786
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
+ }
3787
3864
  const entry = { ...buildHookEntry(def, config), name };
3788
3865
  const settings = readInstalledHooks(projectRoot);
3789
3866
  const hooksObj = settings.hooks ?? {};
@@ -3948,7 +4025,9 @@ Converting CLAUDE.md \u2192 ${files.length} Cursor rule file(s)
3948
4025
  `);
3949
4026
  for (const file of files) {
3950
4027
  const filePath = path25.join(targetDir, file.filename);
3951
- process.stdout.write(` ${options.dryRun ? "[dry-run] " : ""}\u2192 .cursor/rules/${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}
3952
4031
  `);
3953
4032
  if (!options.dryRun) {
3954
4033
  fs22.mkdirSync(targetDir, { recursive: true });
@@ -3959,8 +4038,9 @@ Converting CLAUDE.md \u2192 ${files.length} Cursor rule file(s)
3959
4038
  } else if (to === "copilot") {
3960
4039
  const converted = claudeMdToCopilot(content);
3961
4040
  const targetPath = path25.join(projectRoot, ".github", "copilot-instructions.md");
4041
+ const exists = fs22.existsSync(targetPath);
3962
4042
  process.stdout.write(`
3963
- Converting CLAUDE.md \u2192 .github/copilot-instructions.md
4043
+ Converting CLAUDE.md \u2192 .github/copilot-instructions.md${exists ? " [overwrite]" : ""}
3964
4044
  `);
3965
4045
  if (!options.dryRun) {
3966
4046
  fs22.mkdirSync(path25.dirname(targetPath), { recursive: true });
@@ -3976,8 +4056,9 @@ Converting CLAUDE.md \u2192 .github/copilot-instructions.md
3976
4056
  } else if (to === "windsurf") {
3977
4057
  const converted = claudeMdToWindsurf(content);
3978
4058
  const targetPath = path25.join(projectRoot, ".windsurfrules");
4059
+ const exists = fs22.existsSync(targetPath);
3979
4060
  process.stdout.write(`
3980
- Converting CLAUDE.md \u2192 .windsurfrules
4061
+ Converting CLAUDE.md \u2192 .windsurfrules${exists ? " [overwrite]" : ""}
3981
4062
  `);
3982
4063
  if (!options.dryRun) {
3983
4064
  fs22.writeFileSync(targetPath, converted, "utf-8");
@@ -4251,7 +4332,26 @@ async function teamsShare(options) {
4251
4332
  }
4252
4333
  const latest = exportFiles[0];
4253
4334
  const src = path27.join(storeDir, latest);
4254
- const destPath = fs24.statSync(dest).isDirectory() ? path27.join(dest, latest) : dest;
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
+ }
4255
4355
  fs24.copyFileSync(src, destPath);
4256
4356
  process.stdout.write(` \u2713 Copied ${latest} \u2192 ${destPath}
4257
4357