claude-token-saver 3.28.2 → 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 +15 -0
- package/README.md +16 -1
- package/bin/cli.js +19 -0
- package/package.json +1 -1
- package/presets/doc2md/convert.py +62 -0
- 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.cjs +20 -3
- 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
|
|
|
@@ -461,6 +462,20 @@ Conversion is one-way — editing the cached `.md` changes nothing in the source
|
|
|
461
462
|
|
|
462
463
|
All four formats were exercised end to end on 2026-09-06: 10 docx run replacements plus three consecutive re-saves, a pptx bar-to-line chart swap with an added data point, xlsx value edits and a new row, and a fig text edit with re-encode and re-parse. In every case the original was byte-identical afterwards and the re-converted copy showed the change. One caveat: removing a chart shape from a pptx leaves the old chart XML part orphaned — PowerPoint ignores it, but delete the part and its rels for a clean file. Charts and images never appear in a conversion, so visual edits must be confirmed in the application itself.
|
|
463
464
|
|
|
465
|
+
### DRM-wrapped documents
|
|
466
|
+
|
|
467
|
+
Encryption and DRM are different problems with different answers. Enterprise DRM (Fasoo, MarkAny, SoftCamp and the like) does not password a document — it wraps the whole file, and only processes the vendor's agent has whitelisted ever see plaintext. Python is not one of them, so what sits on disk is ciphertext behind a vendor header, and **no password will open it.**
|
|
468
|
+
|
|
469
|
+
The first bytes decide which story to tell: a zip header means a truncated download, an OLE container means a password, and neither means the file is not that format at all.
|
|
470
|
+
|
|
471
|
+
```
|
|
472
|
+
✗ bad-archive: File is not a zip file → download it again
|
|
473
|
+
✗ encrypted: password-protected Office file → ask for an unlocked copy
|
|
474
|
+
✗ drm-protected: DRM-wrapped file (FASOO) → ask for a copy released from DRM
|
|
475
|
+
```
|
|
476
|
+
|
|
477
|
+
Vendor names are matched only to say which client to go to; the classification stands without recognising the vendor. PDFs are judged the same way through their public DRM security-handler names (FOPN_foweb, EBX_HANDLER, Adobe.APS).
|
|
478
|
+
|
|
464
479
|
### Locked documents, and Windows
|
|
465
480
|
|
|
466
481
|
**A password-protected document is a state, not an error.** Office encrypts by wrapping the package in an OLE compound file rather than a zip, so opening one as a zip used to report "not a zip file" — which reads as a broken download and sends the user after the wrong problem. It is now identified before conversion:
|
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
|
|
|
@@ -457,6 +458,20 @@ PDF 는 첨부하면 모델이 내용을 그대로 읽습니다. 대신 페이
|
|
|
457
458
|
|
|
458
459
|
네 형식 모두 실제로 몇 바퀴 돌려 검증했습니다(2026-09-06): docx 텍스트 치환 10건과 3회 연속 재저장, pptx 막대→꺾은선 차트 교체와 데이터 행 추가, xlsx 값 정정·행 추가, fig 텍스트 수정·재인코드·재파싱. 전 케이스에서 원본은 바이트 그대로였고, 수정본 재변환에 변경 내용이 반영됐습니다. 한 가지 주의: pptx 에서 차트 도형을 제거하면 옛 차트 XML 파트가 고아로 남습니다. PowerPoint 는 무시하지만, 깔끔히 하려면 파트와 rels 도 지우십시오. 차트·이미지 같은 시각 요소는 변환본에 잡히지 않으므로, 시각 편집의 최종 확인은 해당 프로그램에서 해야 합니다.
|
|
459
460
|
|
|
461
|
+
### DRM 으로 보호된 문서
|
|
462
|
+
|
|
463
|
+
암호와 DRM 은 다른 문제이고 해법도 다릅니다. 사내 DRM(파수·마크애니·소프트캠프 등)은 문서에 암호를 거는 것이 아니라 파일 전체를 감싸며, 벤더 에이전트가 허용한 프로그램만 평문을 봅니다. 파이썬은 거기 없으므로 디스크에 있는 것은 벤더 헤더가 붙은 암호문입니다. **암호를 입력해서 풀 수 있는 문제가 아닙니다.**
|
|
464
|
+
|
|
465
|
+
판별은 첫 바이트가 무엇을 말하는지로 갈립니다. zip 헤더면 내려받다 끊긴 파일, OLE 컨테이너면 암호 걸린 문서, 둘 다 아니면 애초에 그 형식이 아닙니다.
|
|
466
|
+
|
|
467
|
+
```
|
|
468
|
+
✗ bad-archive: File is not a zip file ← 다시 받으십시오
|
|
469
|
+
✗ encrypted: password-protected Office file ← 암호를 푼 사본을 요청하십시오
|
|
470
|
+
✗ drm-protected: DRM-wrapped file (FASOO) ← DRM 해제본이나 반출 승인 사본을 요청하십시오
|
|
471
|
+
```
|
|
472
|
+
|
|
473
|
+
벤더 이름은 어느 클라이언트로 가야 하는지 알려 주려고 맞춰 볼 뿐이고, 판별 자체는 벤더를 몰라도 성립합니다. PDF 는 공개된 DRM 보안 핸들러 이름(FOPN_foweb·EBX_HANDLER·Adobe.APS)으로 같은 판정을 합니다.
|
|
474
|
+
|
|
460
475
|
### 암호가 걸린 문서와 Windows
|
|
461
476
|
|
|
462
477
|
**암호 문서는 오류가 아니라 상태로 다룹니다.** 사내에서 받는 문서 중 일부는 암호가 걸려 있습니다. 이전에는 암호 걸린 docx 를 "손상된 zip"이라고 알려서 사용자가 원인을 엉뚱한 곳에서 찾게 만들었습니다. 암호가 걸린 Office 문서는 zip 이 아니라 OLE 복합 문서로 저장되기 때문입니다. 지금은 변환 전에 판별해서 이렇게 알립니다.
|
|
@@ -468,7 +483,7 @@ PDF 는 첨부하면 모델이 내용을 그대로 읽습니다. 대신 페이
|
|
|
468
483
|
|
|
469
484
|
모델에게는 암호를 푼 사본을 사용자에게 요청하라고 안내합니다. 이 도구는 암호를 묻지도 저장하지도 않습니다. 어느 경우에도 원본 `Read` 를 막지 않으므로 작업이 중단되지 않습니다. 열람은 자유롭고 인쇄만 제한된 PDF 는 암호 문서가 아니므로 그대로 변환합니다(오탐 확인 완료). 구형 `.xls` 도 원래 OLE 형식이라 암호로 오인하지 않습니다.
|
|
470
485
|
|
|
471
|
-
**Windows 를
|
|
486
|
+
**Windows 를 지원하며, 실제 Windows 러너에서 검증합니다.** 사내에 Windows 사용자가 있어 다음을 맞췄습니다.
|
|
472
487
|
|
|
473
488
|
- 파이썬 탐색이 `py -3` 런처를 씁니다. Windows 에서는 `python3` 가 PATH 에 없는 경우가 많고, 맨 `python` 은 실행 대신 마이크로소프트 스토어를 여는 별칭 스텁일 수 있습니다. venv 기반 인터프리터도 `Scripts\python.exe` 경로로 찾습니다.
|
|
474
489
|
- `.fig` 파서 설치가 `npm.cmd` 를 셸로 호출합니다. 그리고 패키지 지정자에서 캐럿을 뺐습니다(`openfig-core@0.4.x`). cmd.exe 에서 `^` 는 이스케이프 문자라 npm 에 닿기 전에 먹힙니다.
|
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.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": {
|
|
@@ -92,6 +92,62 @@ def encryption_problem(path, ext):
|
|
|
92
92
|
return None
|
|
93
93
|
|
|
94
94
|
|
|
95
|
+
# Enterprise DRM (Fasoo, MarkAny, SoftCamp and the like) does not password a
|
|
96
|
+
# document — it wraps the whole file, and only processes the vendor's agent
|
|
97
|
+
# has whitelisted ever see plaintext. Python is not one of them, so the bytes
|
|
98
|
+
# on disk are ciphertext with a vendor header. Names are matched only to say
|
|
99
|
+
# *which* product to go to; the classification does not depend on them.
|
|
100
|
+
DRM_MARKERS = [b"FASOO", b"MarkAny", b"MAWDRM", b"SoftCamp", b"Sherpa", b"TrustDRM",
|
|
101
|
+
b"DocuGate", b"WISEDRM", b"UNIDOCS", b"SecureDoc"]
|
|
102
|
+
# PDF DRM announces itself as a security handler, and these names are public.
|
|
103
|
+
PDF_DRM_FILTERS = [b"FOPN_foweb", b"EBX_HANDLER", b"Adobe.APS", b"FOPN_fLock"]
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def drm_hint(head):
|
|
107
|
+
"""Vendor name found in the file header, or None."""
|
|
108
|
+
for marker in DRM_MARKERS:
|
|
109
|
+
if marker in head or marker.decode().encode("utf-16-le") in head:
|
|
110
|
+
return marker.decode()
|
|
111
|
+
return None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def wrapper_problem(path, ext):
|
|
115
|
+
"""('drm-protected', detail) when the container is not the format at all."""
|
|
116
|
+
try:
|
|
117
|
+
with open(path, "rb") as fh:
|
|
118
|
+
head = fh.read(8192)
|
|
119
|
+
except OSError:
|
|
120
|
+
return None
|
|
121
|
+
if not head:
|
|
122
|
+
return None
|
|
123
|
+
|
|
124
|
+
if ext in ZIP_EXTS:
|
|
125
|
+
# A truncated download still starts with a zip local-file header; a
|
|
126
|
+
# wrapped file does not start with anything of the format at all.
|
|
127
|
+
# Telling those two apart is the difference between "re-download it"
|
|
128
|
+
# and "get it released from DRM".
|
|
129
|
+
if head[:2] == b"PK" or head[:8] == OLE_MAGIC:
|
|
130
|
+
return None
|
|
131
|
+
vendor = drm_hint(head)
|
|
132
|
+
return ("drm-protected",
|
|
133
|
+
"DRM-wrapped file (%s)" % vendor if vendor else "the file is not an Office container at all")
|
|
134
|
+
|
|
135
|
+
if ext == ".pdf":
|
|
136
|
+
if not head.startswith(b"%PDF"):
|
|
137
|
+
vendor = drm_hint(head)
|
|
138
|
+
return ("drm-protected",
|
|
139
|
+
"DRM-wrapped file (%s)" % vendor if vendor else "the file is not a PDF at all")
|
|
140
|
+
try:
|
|
141
|
+
with open(path, "rb") as fh:
|
|
142
|
+
blob = fh.read(2_000_000)
|
|
143
|
+
except OSError:
|
|
144
|
+
return None
|
|
145
|
+
for f in PDF_DRM_FILTERS:
|
|
146
|
+
if f in blob:
|
|
147
|
+
return ("drm-protected", "PDF with a DRM security handler (%s)" % f.decode())
|
|
148
|
+
return None
|
|
149
|
+
|
|
150
|
+
|
|
95
151
|
def check_zip(path):
|
|
96
152
|
"""Classify an archive before opening it as a document.
|
|
97
153
|
|
|
@@ -245,6 +301,12 @@ def main():
|
|
|
245
301
|
if locked:
|
|
246
302
|
fail(locked[0], locked[1])
|
|
247
303
|
|
|
304
|
+
# After the password check, because an OLE-wrapped Office file is an
|
|
305
|
+
# encrypted document rather than a DRM-wrapped one.
|
|
306
|
+
wrapped = wrapper_problem(path, ext)
|
|
307
|
+
if wrapped:
|
|
308
|
+
fail(wrapped[0], wrapped[1])
|
|
309
|
+
|
|
248
310
|
if ext in ZIP_EXTS:
|
|
249
311
|
problem = check_zip(path)
|
|
250
312
|
if problem:
|
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.cjs
CHANGED
|
@@ -413,8 +413,9 @@ function installConverter({ onProgress = () => {} } = {}) {
|
|
|
413
413
|
/**
|
|
414
414
|
* Convert one file. Returns `{ ok: true, cacheFile, meta }`, or
|
|
415
415
|
* `{ ok: false, reason, detail }` where reason is one of:
|
|
416
|
-
* no-markitdown | python-too-old | no-figparser | encrypted |
|
|
417
|
-
* sensitive | unsafe-archive | no-text |
|
|
416
|
+
* no-markitdown | python-too-old | no-figparser | encrypted |
|
|
417
|
+
* drm-protected | too-large | sensitive | unsafe-archive | no-text |
|
|
418
|
+
* convert-failed | timeout
|
|
418
419
|
*
|
|
419
420
|
* Every failure is a reason to leave the original Read alone, never to break
|
|
420
421
|
* it. That is the whole contract with the hook.
|
|
@@ -680,6 +681,14 @@ function decideForRead(context, opts = {}) {
|
|
|
680
681
|
+ ` 진행 상황: ${INSTALL_HINT} 를 직접 실행하면 설치 로그를 볼 수 있습니다.`,
|
|
681
682
|
};
|
|
682
683
|
}
|
|
684
|
+
if (result.reason === 'drm-protected') {
|
|
685
|
+
return {
|
|
686
|
+
deny: false,
|
|
687
|
+
reason: `[doc2md] ${name} 는 DRM 으로 보호된 파일이라 변환하지 못했습니다(${result.detail || ''}).\n`
|
|
688
|
+
+ ' DRM 은 암호 입력으로 풀리지 않습니다. 벤더 에이전트가 허용한 프로그램에서만 평문이 보이므로, '
|
|
689
|
+
+ '사용자에게 DRM 클라이언트에서 연 뒤 해제본으로 저장하거나 반출 승인을 받은 사본을 요청하십시오.',
|
|
690
|
+
};
|
|
691
|
+
}
|
|
683
692
|
if (result.reason === 'encrypted') {
|
|
684
693
|
return {
|
|
685
694
|
deny: false,
|
|
@@ -754,7 +763,11 @@ function decideForRead(context, opts = {}) {
|
|
|
754
763
|
function documentPathsIn(text) {
|
|
755
764
|
if (typeof text !== 'string' || !text) return [];
|
|
756
765
|
const exts = TARGET_EXTENSIONS.map((e) => e.slice(1)).join('|');
|
|
757
|
-
|
|
766
|
+
// Backslashes count as path characters, not just separators to tolerate:
|
|
767
|
+
// `C:\\Users\\me\\deck.pptx` and `\\\\server\\share\\deck.pptx` are how
|
|
768
|
+
// Windows users write a path, and without them the scanner silently sees no
|
|
769
|
+
// documents at all on that platform.
|
|
770
|
+
const re = new RegExp(`[@'"\`]?((?:[A-Za-z]:)?[~./\\\\][^\\s'"\`]*\\.(?:${exts}))`, 'gi');
|
|
758
771
|
const found = [];
|
|
759
772
|
for (const m of text.matchAll(re)) {
|
|
760
773
|
const raw = m[1];
|
|
@@ -817,6 +830,10 @@ function contextForPrompt(payload, opts = {}) {
|
|
|
817
830
|
lines.push(lang === 'ko'
|
|
818
831
|
? ` ${name}: 변환기를 설치하는 중입니다(첫 실행에만 걸립니다). 설치가 끝나면 다음 요청부터 자동 변환됩니다.`
|
|
819
832
|
: ` ${name}: the converter is installing now (first run only). It will convert automatically from the next request.`);
|
|
833
|
+
} else if (result.reason === 'drm-protected') {
|
|
834
|
+
lines.push(lang === 'ko'
|
|
835
|
+
? ` ${name}: DRM 으로 보호된 파일입니다(${result.detail || ''}). 암호로는 풀 수 없으므로, DRM 클라이언트에서 저장한 해제본이나 반출 승인 사본을 달라고 사용자에게 요청하십시오.`
|
|
836
|
+
: ` ${name}: the file is DRM-wrapped (${result.detail || ''}). A password will not open it — ask the user for a copy released from DRM.`);
|
|
820
837
|
} else if (result.reason === 'encrypted') {
|
|
821
838
|
lines.push(lang === 'ko'
|
|
822
839
|
? ` ${name}: 암호가 걸린 문서입니다. 암호를 푼 사본을 달라고 사용자에게 요청하십시오.`
|
|
@@ -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 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
|
-
|
|
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) {
|