claude-token-saver 3.28.3 → 3.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.en.md CHANGED
@@ -173,6 +173,7 @@ Run these in your shell (inside Claude Code, the `/claude-token-saver` Skill is
173
173
  | `claude-token-saver update-check` | Is a newer version out? (`--refresh` to ask now, `--dismiss` to mute this version's offer) |
174
174
  | `claude-token-saver upgrade` | Install the latest release with the package manager that installed this copy (`--print` shows the command only) |
175
175
  | `claude-token-saver install` | Manually register Skill + statusline |
176
+ | `claude-token-saver uninstall [--purge]` | Remove the hooks, statusline and skill it registered. Recorded savings are kept unless `--purge` is given |
176
177
 
177
178
  Switch output language with `mode ko` / `mode en` (English default; statusline chips stay symbolic).
178
179
 
package/README.md CHANGED
@@ -172,6 +172,7 @@ Claude Code 안에서 `/claude-token-saver` Skill을 실행하거나, 칩에 적
172
172
  | `claude-token-saver update-check` | 새 버전이 있는지 확인합니다 (`--refresh`로 즉시 조회, `--dismiss`로 그 버전 안내 끄기) |
173
173
  | `claude-token-saver upgrade` | 설치 경로에 맞는 명령으로 최신 버전을 설치합니다 (`--print`로 실행 없이 명령만 확인) |
174
174
  | `claude-token-saver install` | Skill·statusline 수동 등록 |
175
+ | `claude-token-saver uninstall [--purge]` | 등록한 훅·statusline·Skill 제거. 기록된 절감액은 남기며, `--purge` 를 붙이면 상태 디렉터리까지 지웁니다 |
175
176
 
176
177
  출력 언어는 `mode ko`와 `mode en`으로 전환합니다. 기본값은 영어이며, statusline의 칩은 언제나 기호로 표시합니다. 전체 옵션은 [영문 README](./README.en.md#options)를 참고하십시오.
177
178
 
package/bin/cli.js CHANGED
@@ -141,6 +141,13 @@ async function main() {
141
141
  return (await import('../src/commands/install.js')).run({ hasFlag });
142
142
  }
143
143
 
144
+ // The counterpart to `install`. It was in the known-subcommand list from the
145
+ // start but had no dispatch, so it fell through to the usage report and
146
+ // exited non-zero — an unhelpful answer to "remove this".
147
+ if (args[0] === 'uninstall') {
148
+ return (await import('../src/commands/uninstall.js')).run({ hasFlag, args });
149
+ }
150
+
144
151
  // Subcommand: mode — persist statusline preferences so future runs pick
145
152
  // them up without flags or wrapper edits.
146
153
  // claude-token-saver mode # show current config
@@ -395,6 +402,18 @@ async function main() {
395
402
  ));
396
403
  return;
397
404
  }
405
+ // JSON mode stays JSON. A caller that asked for machine-readable output
406
+ // and got a paragraph of advice has to parse prose to find out nothing
407
+ // was found, which is exactly the failure this format exists to avoid.
408
+ if (getArg('--format') === 'json') {
409
+ console.log(JSON.stringify({
410
+ sessions: 0,
411
+ days,
412
+ error: 'no-session-data',
413
+ message: 'No Claude Code session logs found for the given period.',
414
+ }, null, 2));
415
+ process.exit(1);
416
+ }
398
417
  console.log('No session data found for the given period.');
399
418
  console.log('');
400
419
  console.log('This tool analyzes Claude Code session logs (~/.claude/projects/).');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-token-saver",
3
- "version": "3.28.3",
3
+ "version": "3.29.0",
4
4
  "description": "Route the easy work your expensive Claude model keeps repeating down to haiku/sonnet — post-hoc session analysis, no realtime router, no extra LLM calls.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -268,7 +268,7 @@ export async function run({ args, hasFlag }) {
268
268
  try {
269
269
  const { spawn } = await import('node:child_process');
270
270
  spawn(process.execPath, [process.argv[1], 'route-scan', '--refresh', '--quiet'],
271
- { detached: true, stdio: 'ignore' }).unref();
271
+ { detached: true, stdio: 'ignore', windowsHide: true }).unref();
272
272
  } catch (e) { debug('promote:spawn-refresh', e); /* baseline arrives on the next gated rescan */ }
273
273
  return;
274
274
  }
@@ -205,7 +205,7 @@ export async function run({ args, hasFlag, numArg }) {
205
205
  try {
206
206
  const { spawn } = await import('node:child_process');
207
207
  spawn(process.execPath, [process.argv[1], 'route-scan', '--refresh', '--quiet'],
208
- { detached: true, stdio: 'ignore' }).unref();
208
+ { detached: true, stdio: 'ignore', windowsHide: true }).unref();
209
209
  } catch (e) { debug('route-scan:spawn-refresh', e); /* stale cache is still usable below */ }
210
210
  }
211
211
  const open = rs.openCandidates(cache);
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Subcommand: uninstall — take the integration back out of ~/.claude.
3
+ *
4
+ * claude-token-saver uninstall # hooks, statusline, skill
5
+ * claude-token-saver uninstall --purge # the above plus recorded state
6
+ *
7
+ * Recorded savings are kept by default. Someone removing an integration is
8
+ * usually not asking to throw away the ledger that says what it saved, and
9
+ * that data cannot be reconstructed once the transcripts age out.
10
+ */
11
+
12
+ export async function run({ hasFlag, args = [] }) {
13
+ const { uninstallAll } = await import('../installer.js');
14
+ const { userLanguage } = await import('../config.js');
15
+ const lang = userLanguage();
16
+ const purge = hasFlag('--purge') || args.includes('--purge');
17
+
18
+ const r = uninstallAll({ purge });
19
+
20
+ if (r.action === 'skipped') {
21
+ console.error(lang === 'ko'
22
+ ? `✗ ${r.path} 를 읽지 못해 아무것도 지우지 않았습니다: ${r.reason}`
23
+ : `✗ nothing removed — ${r.path} could not be read: ${r.reason}`);
24
+ process.exitCode = 1;
25
+ return;
26
+ }
27
+
28
+ if (r.removed.length === 0) {
29
+ console.log(lang === 'ko' ? '설치된 항목이 없습니다.' : 'Nothing was installed.');
30
+ } else {
31
+ console.log(lang === 'ko' ? `✓ 제거했습니다 (${r.path}):` : `✓ removed (${r.path}):`);
32
+ for (const item of r.removed) console.log(` - ${item}`);
33
+ }
34
+ for (const item of r.kept) {
35
+ console.log(lang === 'ko' ? ` 유지: ${item}` : ` kept: ${item}`);
36
+ }
37
+ if (!purge) {
38
+ console.log(lang === 'ko'
39
+ ? ' 기록된 절감액과 설정까지 지우려면 `claude-token-saver uninstall --purge` 를 실행하십시오.'
40
+ : ' To remove recorded savings and settings too: `claude-token-saver uninstall --purge`.');
41
+ }
42
+ }
@@ -423,9 +423,19 @@ export function formatReport(data, { color = true, verbose = false, timer = true
423
423
  if (!singleLine && doc2mdUsd > 0) {
424
424
  const head = isIcon ? '📄 Doc2md saved' : 'Doc2md saved';
425
425
  const byExt = Array.isArray(doc2md.byExt) ? doc2md.byExt : [];
426
- const extText = byExt
427
- .map((r) => `${c(GRAY)}${r.ext} ${r.docs}× ${formatMoney(r.usd)}${c(RESET)}`)
428
- .join(` ${c(GRAY)}·${c(RESET)} `);
426
+ // Formats that earned money show it; formats with no baseline to measure
427
+ // against are counted instead. Printing "$0.00" next to real amounts reads
428
+ // as "this format saved nothing", when what it means is that there is no
429
+ // working alternative to price it against — for .fig there is no readable
430
+ // fallback at all, so the conversion is the only way to open the file.
431
+ const paid = byExt.filter((r) => r.usd > 0);
432
+ const unpriced = byExt.filter((r) => !(r.usd > 0));
433
+ const parts = paid.map((r) => `${c(GRAY)}${r.ext} ${r.docs}× ${formatMoney(r.usd)}${c(RESET)}`);
434
+ if (unpriced.length) {
435
+ const counted = unpriced.map((r) => `${r.ext} ${r.docs}×`).join(' · ');
436
+ parts.push(`${c(GRAY)}${counted} ${verbose ? '(no baseline)' : '(n/a)'}${c(RESET)}`);
437
+ }
438
+ const extText = parts.join(` ${c(GRAY)}·${c(RESET)} `);
429
439
  doc2mdLine =
430
440
  `${c(GREEN)}${c(BOLD)}${head}${c(RESET)} ` +
431
441
  `${c(GREEN)}${formatMoney(doc2mdUsd)}${c(RESET)}` +
package/src/harness.js CHANGED
@@ -11,6 +11,7 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync, statSync } from 'no
11
11
  import { join, resolve, dirname } from 'node:path';
12
12
  import { homedir } from 'node:os';
13
13
  import { createRequire } from 'node:module';
14
+ import { fileURLToPath } from 'node:url';
14
15
  import {
15
16
  HARNESS_SECTIONS,
16
17
  HARNESS_BLOCK_BEGIN,
@@ -368,7 +369,10 @@ export function harnessPull({ root = findProjectRoot(), scope = 'global' } = {})
368
369
  /** Parse the bundled preset rules (markdown bullets under presets/). */
369
370
  export function presetRules() {
370
371
  try {
371
- const path = join(dirname(new URL(import.meta.url).pathname), '..', 'presets', 'ratchet-rules.md');
372
+ // fileURLToPath, not `.pathname`: the latter yields `/D:/repo/src` on
373
+ // Windows, where the read fails and the catch below turns a missing
374
+ // file into an empty rule set without ever saying so.
375
+ const path = join(dirname(fileURLToPath(import.meta.url)), '..', 'presets', 'ratchet-rules.md');
372
376
  return readFileSync(path, 'utf8')
373
377
  .split('\n')
374
378
  .filter((l) => /^\s*-\s+/.test(l))
package/src/installer.js CHANGED
@@ -13,9 +13,9 @@
13
13
  * exist on every platform.
14
14
  */
15
15
 
16
- import { writeFileSync, mkdirSync, existsSync, unlinkSync, readFileSync } from 'node:fs';
16
+ import { writeFileSync, mkdirSync, existsSync, unlinkSync, readFileSync, rmSync } from 'node:fs';
17
17
  import { join } from 'node:path';
18
- import { claudeUserDir } from './paths.js';
18
+ import { claudeUserDir, userDataDir } from './paths.js';
19
19
 
20
20
  const STATUSLINE_COMMAND = 'claude-token-saver --statusline --icon';
21
21
  const STATUSLINE_REFRESH_INTERVAL = 5;
@@ -469,6 +469,85 @@ export function removeDoc2mdHook() {
469
469
  return { path: file, action: 'removed' };
470
470
  }
471
471
 
472
+ /**
473
+ * Undo what `install` did: every hook this tool registered, the statusline
474
+ * entry when it is still ours, and the skill file.
475
+ *
476
+ * Written because `uninstall` was a name in the subcommand list with no
477
+ * implementation behind it — running it fell through to the usage report and
478
+ * exited non-zero, which is a poor answer to "get this off my machine". A
479
+ * company rollout needs the way out to work as well as the way in.
480
+ *
481
+ * User data (ledgers, config, conversion cache) is deliberately left alone:
482
+ * removing an integration should not throw away months of recorded savings.
483
+ * `--purge` is the separate, explicit request for that.
484
+ */
485
+ export function uninstallAll({ purge = false } = {}) {
486
+ const file = join(claudeUserDir(), 'settings.json');
487
+ const result = { removed: [], kept: [], path: file };
488
+
489
+ if (existsSync(file)) {
490
+ let settings;
491
+ try {
492
+ settings = JSON.parse(readFileSync(file, 'utf8'));
493
+ } catch (e) {
494
+ return { ...result, action: 'skipped', reason: `unreadable JSON (${e.message})` };
495
+ }
496
+
497
+ // Only our own statusline command goes. Someone else's stays exactly
498
+ // where they put it.
499
+ const cur = settings.statusLine;
500
+ if (cur && typeof cur.command === 'string' && cur.command.includes('claude-token-saver')) {
501
+ delete settings.statusLine;
502
+ result.removed.push('statusLine');
503
+ } else if (cur) {
504
+ result.kept.push(`statusLine (${cur.command || 'unrecognised'})`);
505
+ }
506
+
507
+ // Every event, every entry whose command is this CLI. Matching on the
508
+ // binary name rather than a list of exact commands means a hook added by
509
+ // an older version is still removed by a newer one.
510
+ for (const event of Object.keys(settings.hooks || {})) {
511
+ const list = settings.hooks[event];
512
+ if (!Array.isArray(list)) continue;
513
+ const kept = list.filter((m) => !(
514
+ Array.isArray(m?.hooks)
515
+ && m.hooks.some((h) => typeof h?.command === 'string' && h.command.includes('claude-token-saver'))
516
+ ));
517
+ if (kept.length === list.length) continue;
518
+ result.removed.push(`hooks.${event}`);
519
+ if (kept.length === 0) delete settings.hooks[event];
520
+ else settings.hooks[event] = kept;
521
+ }
522
+ if (settings.hooks && Object.keys(settings.hooks).length === 0) delete settings.hooks;
523
+
524
+ writeFileSync(file, JSON.stringify(settings, null, 2) + '\n');
525
+ }
526
+
527
+ const skillDir = join(claudeUserDir(), 'skills', 'claude-token-saver');
528
+ if (existsSync(skillDir)) {
529
+ rmSync(skillDir, { recursive: true, force: true });
530
+ result.removed.push('skill');
531
+ }
532
+ const legacyCommand = join(claudeUserDir(), 'commands', 'token-monitor.md');
533
+ if (existsSync(legacyCommand)) {
534
+ rmSync(legacyCommand, { force: true });
535
+ result.removed.push('legacy command');
536
+ }
537
+
538
+ if (purge) {
539
+ const data = userDataDir();
540
+ if (existsSync(data)) {
541
+ rmSync(data, { recursive: true, force: true });
542
+ result.removed.push('state directory');
543
+ }
544
+ } else {
545
+ result.kept.push('state directory (savings ledgers, config, cache) — remove with --purge');
546
+ }
547
+
548
+ return { ...result, action: 'removed' };
549
+ }
550
+
472
551
  export function installAll({ force = false } = {}) {
473
552
  return {
474
553
  skill: installSkill({ force }),
@@ -119,6 +119,9 @@ export function maybeSpawnUpdateCheck(currentVersion) {
119
119
  spawn(process.execPath, [cliEntryPath(), 'update-check', '--refresh', '--quiet'], {
120
120
  detached: true,
121
121
  stdio: 'ignore',
122
+ // Without this Windows flashes a console window, and this one
123
+ // re-spawns from the statusline — several times a minute.
124
+ windowsHide: true,
122
125
  }).unref();
123
126
  return true;
124
127
  } catch (e) {