claude-token-saver 3.28.3 → 3.30.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 +11 -1
- package/README.md +11 -1
- package/bin/cli.js +19 -0
- package/package.json +1 -1
- package/src/commands/harness.js +1 -1
- package/src/commands/route-scan.js +1 -1
- package/src/commands/uninstall.js +42 -0
- package/src/doc2md-ledger.cjs +27 -8
- package/src/doc2md.cjs +7 -4
- package/src/formatters/statusline.js +13 -3
- package/src/harness.js +5 -1
- package/src/installer.js +81 -2
- package/src/update-check.js +3 -0
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
|
|
|
@@ -451,7 +452,16 @@ Planning documents are moving from PowerPoint to Figma, so the same hook catches
|
|
|
451
452
|
|
|
452
453
|
The result is an outline: pages and frames become headings, text nodes become body lines, and shapes are counted rather than listed — in a planning document the words are the content, and two hundred `Rectangle 173` lines would drown them. A file with no text at all is refused rather than dressed up as an empty document.
|
|
453
454
|
|
|
454
|
-
Verified against real files: a community Bootstrap UI kit (8.1MB, 4,155 nodes, 1,312 of them text) and a 52MB Tailwind kit, each converting in under a second. Both `.fig` vintages parse — the current zip container and the older bare fig-kiwi stream.
|
|
455
|
+
Verified against real files: a community Bootstrap UI kit (8.1MB, 4,155 nodes, 1,312 of them text) and a 52MB Tailwind kit, each converting in under a second. Both `.fig` vintages parse — the current zip container and the older bare fig-kiwi stream.
|
|
456
|
+
|
|
457
|
+
**`.fig` saves the most of any format.** Unlike the Office containers, `Read` does not refuse a `.fig`: the extension means nothing to it, so it pulls the binary in as text and the context window fills with tokenised noise. Measured against the same 42,760-token control:
|
|
458
|
+
|
|
459
|
+
| File | Size | Extra tokens for a Read | Conversion |
|
|
460
|
+
|---|---|---|---|
|
|
461
|
+
| plan.fig | 26KB | +44,195 | 100 tokens |
|
|
462
|
+
| bootstrap-kit.fig | 8.1MB | +43,994 | 18,397 tokens |
|
|
463
|
+
|
|
464
|
+
Two files three hundred times apart in size cost the same, because Read truncates long before the file ends — you pay for a whole document and receive a fraction of one. The baseline is therefore a flat 44,000 tokens. For comparison, the same probe on a pptx cost +317 tokens and on a docx +185: a refusal message, and nothing else.
|
|
455
465
|
|
|
456
466
|
### Editing a document: copy, then script
|
|
457
467
|
|
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
|
|
|
@@ -447,7 +448,16 @@ PDF 는 첨부하면 모델이 내용을 그대로 읽습니다. 대신 페이
|
|
|
447
448
|
|
|
448
449
|
변환 결과는 페이지·프레임 계층을 헤딩으로, 텍스트 노드를 본문으로 정리한 아웃라인입니다. 도형·벡터 같은 시각 요소는 나열하지 않고 개수만 남깁니다. 기획서에서 내용은 글이고, `Rectangle 173` 이 이백 줄 나오면 글이 묻히기 때문입니다. 텍스트가 하나도 없는 파일(순수 그래픽)은 빈 문서로 꾸미지 않고 변환 불가로 알립니다.
|
|
449
450
|
|
|
450
|
-
실제 파일로 검증했습니다: 피그마 커뮤니티의 Bootstrap UI kit(8.1MB, 노드 4,155개, 텍스트 1,312개)와 Tailwind kit(52MB)이 각각 0.2초 안에 71.9KB·44KB 아웃라인으로 변환됐고, 한국어 텍스트 왕복도 무손실이었습니다. `.fig` 는 두 세대가 있습니다. 요즘 익스포트는 zip 컨테이너, 옛 익스포트는 fig-kiwi 바이너리 원형인데 둘 다 처리합니다.
|
|
451
|
+
실제 파일로 검증했습니다: 피그마 커뮤니티의 Bootstrap UI kit(8.1MB, 노드 4,155개, 텍스트 1,312개)와 Tailwind kit(52MB)이 각각 0.2초 안에 71.9KB·44KB 아웃라인으로 변환됐고, 한국어 텍스트 왕복도 무손실이었습니다. `.fig` 는 두 세대가 있습니다. 요즘 익스포트는 zip 컨테이너, 옛 익스포트는 fig-kiwi 바이너리 원형인데 둘 다 처리합니다.
|
|
452
|
+
|
|
453
|
+
**`.fig` 의 절감이 가장 큽니다.** 오피스 형식과 달리 `Read` 가 `.fig` 를 거부하지 않습니다. 확장자를 모르니 이진 파일을 그대로 텍스트로 읽어들이고, 컨텍스트가 토큰화된 잡음으로 찹니다. 같은 42,760 토큰 대조군으로 측정했습니다.
|
|
454
|
+
|
|
455
|
+
| 파일 | 크기 | Read 가 더 쓴 토큰 | 변환본 |
|
|
456
|
+
|---|---|---|---|
|
|
457
|
+
| plan.fig | 26KB | +44,195 | 100 토큰 |
|
|
458
|
+
| bootstrap-kit.fig | 8.1MB | +43,994 | 18,397 토큰 |
|
|
459
|
+
|
|
460
|
+
크기가 300배 차이인데 비용이 같습니다. Read 가 상한에서 자르기 때문인데, 바꿔 말하면 **문서 전체 값을 치르고 일부만 받습니다.** 그래서 기준선은 파일 크기와 무관한 44,000 토큰 고정입니다. 참고로 같은 방법으로 재보니 pptx 는 +317, docx 는 +185 토큰이었습니다. 거부 메시지 한 줄이 전부입니다. 절감액은 청구하지 않습니다. `.fig` 는 압축을 풀어도 또 바이너리라 비교할 대안 자체가 없고, 변환이 유일한 읽기 경로입니다.
|
|
451
461
|
|
|
452
462
|
### 문서를 수정해야 할 때: 복사본 + 스크립트
|
|
453
463
|
|
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.
|
|
3
|
+
"version": "3.30.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": {
|
package/src/commands/harness.js
CHANGED
|
@@ -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
|
+
}
|
package/src/doc2md-ledger.cjs
CHANGED
|
@@ -31,9 +31,11 @@
|
|
|
31
31
|
*
|
|
32
32
|
* pptx/xlsx/docx: unpacking the container. These never reach the model as
|
|
33
33
|
* attachments at all — the same probe on a docx added 78 tokens and the model
|
|
34
|
-
* replied that it had no file
|
|
35
|
-
*
|
|
36
|
-
*
|
|
34
|
+
* replied that it had no file. Read refuses them too, measured the same way:
|
|
35
|
+
* a Read of the 31.8MB deck cost +317 tokens and of the 185KB docx +185, which
|
|
36
|
+
* is a refusal message and nothing more. What a reader does instead is unzip
|
|
37
|
+
* the archive and wade through its XML, where tags and style attributes
|
|
38
|
+
* outweigh the text many times over:
|
|
37
39
|
*
|
|
38
40
|
* aws-summit-seoul.pptx 2.1MB of slide XML ~540,429 tokens 23.8× the conversion
|
|
39
41
|
* 우리은행이력서.docx 312KB of body XML ~78,113 tokens ~46× the conversion
|
|
@@ -43,6 +45,21 @@
|
|
|
43
45
|
* a real fallback — this very session unzipped a pptx to verify a conversion
|
|
44
46
|
* before this ledger existed.
|
|
45
47
|
*
|
|
48
|
+
* .fig: the file itself. Unlike the Office formats, Read does not refuse a
|
|
49
|
+
* .fig — the extension means nothing to it, so it pulls the binary in as text
|
|
50
|
+
* and the context window fills with tokenised noise. Measured against the same
|
|
51
|
+
* 42,760-token control:
|
|
52
|
+
*
|
|
53
|
+
* plan.fig 26KB +44,195 tokens conversion: 100
|
|
54
|
+
* bootstrap-kit.fig 8.1MB +43,994 tokens conversion: 18,397
|
|
55
|
+
*
|
|
56
|
+
* Two files three hundred times apart in size cost the same, because Read
|
|
57
|
+
* truncates at a cap long before the file ends — which also means the reader
|
|
58
|
+
* gets a fraction of a document for the price of a whole one. The baseline is
|
|
59
|
+
* therefore a flat 44,000 tokens rather than anything per-byte. An earlier
|
|
60
|
+
* version of this file claimed .fig had no measurable baseline at all; that
|
|
61
|
+
* was an assumption about Read's behaviour that turned out to be wrong.
|
|
62
|
+
*
|
|
46
63
|
* Erring low is deliberate throughout. A savings figure that flatters the
|
|
47
64
|
* tool is worth less than one the user can trust.
|
|
48
65
|
*
|
|
@@ -79,10 +96,10 @@ const ATTACHMENT_BASELINE = {
|
|
|
79
96
|
'.docx': { markup: true },
|
|
80
97
|
'.xlsx': { markup: true },
|
|
81
98
|
'.xls': { ratio: 1 },
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
//
|
|
85
|
-
'.fig': {
|
|
99
|
+
// Read swallows a .fig instead of refusing it, at a flat ~44,000 tokens
|
|
100
|
+
// whatever the file's size (see the header). Rounded down from the two
|
|
101
|
+
// measurements, both of which landed just under 44,200.
|
|
102
|
+
'.fig': { fixed: 44_000 },
|
|
86
103
|
};
|
|
87
104
|
|
|
88
105
|
/**
|
|
@@ -106,7 +123,9 @@ function estimateSaving({ ext, pages = 0, markupBytes = 0, markdown = '' }) {
|
|
|
106
123
|
const tokens = estimateTokens(markdown);
|
|
107
124
|
const rule = ATTACHMENT_BASELINE[String(ext).toLowerCase()] || { ratio: 1 };
|
|
108
125
|
let baseline;
|
|
109
|
-
if (rule.
|
|
126
|
+
if (rule.fixed) {
|
|
127
|
+
baseline = rule.fixed;
|
|
128
|
+
} else if (rule.perPage && pages > 0) {
|
|
110
129
|
baseline = pages * rule.perPage;
|
|
111
130
|
} else if (rule.markup && markupBytes > 0) {
|
|
112
131
|
baseline = Math.ceil(markupBytes / 4);
|
package/src/doc2md.cjs
CHANGED
|
@@ -165,10 +165,13 @@ function banner(sourcePath, saving) {
|
|
|
165
165
|
if (saving.usd > 0) {
|
|
166
166
|
const savedTokens = Math.max(0, saving.baseline - saving.tokens);
|
|
167
167
|
// The alternative differs by format, so the sentence names it: a PDF
|
|
168
|
-
// would have been attached, a zip document
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
168
|
+
// would have been attached, a zip document unpacked, and a .fig read
|
|
169
|
+
// straight into the context window, which is the one Read does not
|
|
170
|
+
// refuse.
|
|
171
|
+
const ext = path.extname(sourcePath).toLowerCase();
|
|
172
|
+
const alternative = ext === '.pdf' ? '원본을 첨부하면'
|
|
173
|
+
: ext === '.fig' ? '원본을 그대로 Read 하면'
|
|
174
|
+
: '압축을 풀어 본문 XML을 읽으면';
|
|
172
175
|
lines.push(
|
|
173
176
|
`${alternative} 약 ${saving.baseline.toLocaleString('en-US')} 토큰이 드는데, `
|
|
174
177
|
+ `변환본은 ${saving.tokens.toLocaleString('en-US')} 토큰입니다. `
|
|
@@ -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
|
-
|
|
427
|
-
|
|
428
|
-
|
|
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 nobody has
|
|
429
|
+
// measured what the alternative costs. Only .xls sits there now — the
|
|
430
|
+
// pre-2007 binary, which has no container to price against.
|
|
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
|
-
|
|
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 }),
|
package/src/update-check.js
CHANGED
|
@@ -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) {
|