claude-token-saver 3.26.2 → 3.27.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 +15 -0
- package/package.json +1 -1
- package/src/commands/doc2md.js +18 -0
- package/src/commands/route-scan.js +38 -0
- package/src/doc2md.cjs +116 -0
- package/src/installer.js +34 -10
package/README.en.md
CHANGED
|
@@ -384,6 +384,16 @@ Installs with nobody attached — npm `postinstall`, CI, piped stdin — skip th
|
|
|
384
384
|
|
|
385
385
|
**This is opt-in.** Installing the CLI does not turn it on: both commands below are required, and a registered hook with no converter behind it does nothing at all.
|
|
386
386
|
|
|
387
|
+
Three situations, three different interception points:
|
|
388
|
+
|
|
389
|
+
| Situation | Where it is caught |
|
|
390
|
+
|---|---|
|
|
391
|
+
| A document path typed in the prompt (`@path`, quoted, or relative) | `UserPromptSubmit`: converted, and the conversion's path is handed back as context |
|
|
392
|
+
| A document opened with `Read` mid-task | PDFs are caught by `PreToolUse(Read)`. pptx/xlsx/docx are not: Claude Code refuses them as binary *before* any hook runs, so the session-start note tells the model to run `doc2md <path>` instead |
|
|
393
|
+
| A document attached to the message | **Not catchable.** No hook event receives attachment content. The session-start note has the model ask for a path next time |
|
|
394
|
+
|
|
395
|
+
That second row is measured, not assumed: a `.pdf` Read fires the hook, and a `.pptx` Read in the same session leaves no hook log entry at all.
|
|
396
|
+
|
|
387
397
|
```bash
|
|
388
398
|
claude-token-saver doc2md install-converter # markitdown into a dedicated venv
|
|
389
399
|
claude-token-saver doc2md on # register the Read hook
|
|
@@ -502,6 +512,11 @@ Also update `statusLine.command` in `~/.claude/settings.json` to `claude-token-s
|
|
|
502
512
|
|
|
503
513
|
## Release notes
|
|
504
514
|
|
|
515
|
+
### v3.27.0 (2026-09-04)
|
|
516
|
+
- **A document path in the prompt is finally caught.** The 3.26.x `PreToolUse(Read)` hook never reached the formats it was written for: Claude Code refuses pptx/xlsx/docx as binary *before* running any hook. Measured — a `.pdf` Read fires the hook, a `.pptx` Read in the same session leaves no hook log entry. Interception now also happens at `UserPromptSubmit`, which sees the raw prompt: paths there are converted and the conversion is handed back as context. `@path`, quoted and relative forms all count.
|
|
517
|
+
- **A session-start note was added.** It tells the model two things it cannot work out alone: that a binary-file refusal is answered by `doc2md <path>`, and that if the user attached a document, they should be asked for a path next time. An attachment bills its entire contents into the context, and no hook event receives attachment content, so there is nothing the tool itself can do about it.
|
|
518
|
+
- **`doc2md on` registers both hooks**, and `off` removes only its own entries from both.
|
|
519
|
+
|
|
505
520
|
### v3.26.2 (2026-09-04)
|
|
506
521
|
- **The converter installs itself.** The old instruction was `pip install`, which asks the user to modify a system interpreter — and if they skipped it, the hook sat registered and did nothing. `doc2md install-converter` builds a dedicated venv and puts markitdown in it.
|
|
507
522
|
- **`doc2md` status now reports hook registration too.** Showing only the converter made "hook but no converter" and "converter but no hook" look identical, and both look like a broken feature.
|
package/README.md
CHANGED
|
@@ -374,6 +374,16 @@ pptx·xlsx·pdf·docx 를 그대로 `Read` 하면 모델이 읽지 못하는 바
|
|
|
374
374
|
|
|
375
375
|
**이 기능은 옵트인입니다.** 설치만으로는 켜지지 않고, 아래 두 명령을 모두 실행해야 동작합니다. 훅만 등록하고 변환기가 없으면 아무 일도 일어나지 않습니다.
|
|
376
376
|
|
|
377
|
+
세 가지 경로를 덮습니다. 각각 걸리는 지점이 다릅니다.
|
|
378
|
+
|
|
379
|
+
| 상황 | 개입 지점 |
|
|
380
|
+
|---|---|
|
|
381
|
+
| 프롬프트에 문서 경로를 적음 (`@경로`·따옴표·상대 경로 모두) | `UserPromptSubmit`. 변환한 뒤 변환본 경로를 컨텍스트로 넣습니다 |
|
|
382
|
+
| 작업 도중 문서를 `Read` | pdf 는 `PreToolUse(Read)` 가 잡습니다. pptx·xlsx·docx 는 Claude Code 가 이진 파일이라며 훅보다 먼저 거부하므로, 세션 시작 안내문이 모델에게 `doc2md <경로>` 를 실행하도록 지시합니다 |
|
|
383
|
+
| 문서를 메시지에 직접 첨부 | **훅으로 잡을 수 없습니다.** 어떤 훅 이벤트도 첨부 내용을 받지 못합니다. 세션 시작 안내문이 다음부터 경로로 달라고 사용자에게 안내하도록 모델에게 지시합니다 |
|
|
384
|
+
|
|
385
|
+
두 번째 줄의 제약은 실측으로 확인한 것입니다. `.pdf` 를 Read 하면 훅이 실행되고, 같은 세션에서 `.pptx` 를 Read 하면 훅 로그에 아무 기록도 남지 않습니다.
|
|
386
|
+
|
|
377
387
|
```bash
|
|
378
388
|
claude-token-saver doc2md install-converter # 전용 venv에 markitdown 설치
|
|
379
389
|
claude-token-saver doc2md on # Read 훅 등록
|
|
@@ -468,6 +478,11 @@ npm uninstall -g claude-cache-monitor && npm i -g claude-token-saver
|
|
|
468
478
|
|
|
469
479
|
## 릴리스 노트
|
|
470
480
|
|
|
481
|
+
### v3.27.0 (2026-09-04)
|
|
482
|
+
- **문서 경로를 프롬프트에 적으면 이제 실제로 걸립니다.** 3.26.x 의 `PreToolUse(Read)` 훅은 정작 목표한 형식에 닿지 못했습니다. Claude Code 가 pptx·xlsx·docx 를 이진 파일이라며 훅보다 먼저 거부하기 때문입니다(실측: `.pdf` Read 는 훅이 실행되고 `.pptx` Read 는 훅 기록이 남지 않습니다). 개입 지점을 `UserPromptSubmit` 으로 넓혀, 프롬프트에 적힌 경로를 변환한 뒤 변환본 경로를 컨텍스트로 넣습니다. `@경로`·따옴표·상대 경로를 모두 인식합니다.
|
|
483
|
+
- **세션 시작 안내문을 추가했습니다.** 두 가지를 모델에게 알립니다. `Read` 가 이진 파일이라며 거부하면 `doc2md <경로>` 를 실행할 것, 그리고 사용자가 문서를 직접 첨부했으면 다음부터 경로로 달라고 안내할 것입니다. 첨부는 내용 전체가 컨텍스트에 실려 토큰을 크게 쓰는데, 어떤 훅 이벤트도 첨부 내용을 받지 못해 도구가 개입할 방법이 없습니다.
|
|
484
|
+
- **`doc2md on` 이 훅 두 개를 함께 등록합니다.** `off` 는 자기 항목만 골라 양쪽에서 지웁니다.
|
|
485
|
+
|
|
471
486
|
### v3.26.2 (2026-09-04)
|
|
472
487
|
- **doc2md 변환기를 도구가 직접 설치합니다.** 지금까지의 안내는 `pip install` 이었는데, 시스템 파이썬을 건드리라는 요구인 데다 실행하지 않으면 훅만 등록된 채 아무 일도 일어나지 않았습니다. `doc2md install-converter` 가 전용 venv 를 만들어 markitdown 을 넣습니다.
|
|
473
488
|
- **`doc2md` 상태 출력에 훅 등록 여부를 함께 적습니다.** 변환기만 알려 주면 "훅은 있는데 변환기가 없다"와 "변환기는 있는데 훅이 없다"가 똑같이 아무 일도 안 하는 상태로 보여서, 어느 쪽이 빠졌는지 알 수 없었습니다.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-token-saver",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.27.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/doc2md.js
CHANGED
|
@@ -69,6 +69,24 @@ export async function run({ args, hasFlag }) {
|
|
|
69
69
|
return;
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
+
// UserPromptSubmit entry point. Separate from `--hook` because the two speak
|
|
73
|
+
// different protocols: PreToolUse answers with a permission decision, this
|
|
74
|
+
// one answers with plain text that becomes context the model can act on.
|
|
75
|
+
if (hasFlag?.('--hook-prompt') || sub === '--hook-prompt') {
|
|
76
|
+
const { readStdinJson } = await import('../stdin-payload.js');
|
|
77
|
+
const payload = readStdinJson();
|
|
78
|
+
if (!payload) return;
|
|
79
|
+
const { userLanguage } = await import('../config.js');
|
|
80
|
+
try {
|
|
81
|
+
const context = doc2md.contextForPrompt(payload, { lang: userLanguage() });
|
|
82
|
+
if (context) console.log(context);
|
|
83
|
+
} catch {
|
|
84
|
+
// Never break a prompt over a conversion. Silence leaves the session
|
|
85
|
+
// exactly as it would have been without this feature.
|
|
86
|
+
}
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
72
90
|
if (sub === 'on') {
|
|
73
91
|
const { installDoc2mdHook } = await import('../installer.js');
|
|
74
92
|
const res = installDoc2mdHook();
|
|
@@ -5,9 +5,34 @@
|
|
|
5
5
|
import { readFileSync } from 'node:fs';
|
|
6
6
|
import { fileURLToPath } from 'node:url';
|
|
7
7
|
import { dirname, join } from 'node:path';
|
|
8
|
+
import { createRequire } from 'node:module';
|
|
9
|
+
|
|
10
|
+
const require = createRequire(import.meta.url);
|
|
8
11
|
import { readStdinJson } from '../stdin-payload.js';
|
|
9
12
|
import { debug } from '../debug.js';
|
|
10
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Whether the user turned doc2md on.
|
|
16
|
+
*
|
|
17
|
+
* Read from settings.json rather than from a config flag because the hook
|
|
18
|
+
* registration *is* the switch: a note telling the model how to convert
|
|
19
|
+
* documents is noise in a session where nothing will convert them.
|
|
20
|
+
*/
|
|
21
|
+
function doc2mdHookRegistered() {
|
|
22
|
+
try {
|
|
23
|
+
const { homedir } = require('node:os');
|
|
24
|
+
const settings = JSON.parse(readFileSync(join(homedir(), '.claude', 'settings.json'), 'utf8'));
|
|
25
|
+
return Object.values(settings?.hooks || {}).some((matchers) =>
|
|
26
|
+
(matchers || []).some((m) =>
|
|
27
|
+
(m.hooks || []).some((h) => typeof h.command === 'string' && h.command.includes('doc2md --hook')),
|
|
28
|
+
),
|
|
29
|
+
);
|
|
30
|
+
} catch (e) {
|
|
31
|
+
debug('route-scan:doc2md-registered', e);
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
11
36
|
/** This package's own version — the baseline the upgrade offer compares against. */
|
|
12
37
|
function readPackageVersion() {
|
|
13
38
|
try {
|
|
@@ -218,6 +243,17 @@ export async function run({ args, hasFlag, numArg }) {
|
|
|
218
243
|
koreanBlock = koreanStyleInjection();
|
|
219
244
|
} catch (e) { debug('route-scan:korean-style', e); /* style is optional */ }
|
|
220
245
|
|
|
246
|
+
// doc2md's standing note, only when the user turned the feature on.
|
|
247
|
+
// Rides the same SessionStart round-trip as the style block for the same
|
|
248
|
+
// reason: one injection, one cached prefix. It carries the two things the
|
|
249
|
+
// model cannot work out for itself — that a binary-file refusal has a
|
|
250
|
+
// one-command answer, and that an attached document costs far more than
|
|
251
|
+
// the path to it.
|
|
252
|
+
let doc2mdBlock = null;
|
|
253
|
+
try {
|
|
254
|
+
if (doc2mdHookRegistered()) doc2mdBlock = require('../doc2md.cjs').sessionNote(lang);
|
|
255
|
+
} catch (e) { debug('route-scan:doc2md-note', e); /* the note is optional */ }
|
|
256
|
+
|
|
221
257
|
// Upgrade offer. A statusline cannot open a dialog, so session start is
|
|
222
258
|
// where the *asking* happens: the model gets one line telling it a newer
|
|
223
259
|
// version exists and to ask before installing anything. Cached read only
|
|
@@ -252,10 +288,12 @@ export async function run({ args, hasFlag, numArg }) {
|
|
|
252
288
|
|
|
253
289
|
if (open.length === 0 && reviewRules.length === 0) {
|
|
254
290
|
if (updateBlock) console.log(updateBlock);
|
|
291
|
+
if (doc2mdBlock) console.log(doc2mdBlock);
|
|
255
292
|
if (koreanBlock) console.log(koreanBlock);
|
|
256
293
|
return; // nothing else to inject
|
|
257
294
|
}
|
|
258
295
|
if (updateBlock) console.log(updateBlock);
|
|
296
|
+
if (doc2mdBlock) console.log(doc2mdBlock);
|
|
259
297
|
// This text is injected straight into the model's context, so it must
|
|
260
298
|
// follow the user's configured language — a Korean-only briefing in an
|
|
261
299
|
// English session steers the whole first response into Korean.
|
package/src/doc2md.cjs
CHANGED
|
@@ -419,6 +419,119 @@ function decideForRead(context, opts = {}) {
|
|
|
419
419
|
return null;
|
|
420
420
|
}
|
|
421
421
|
|
|
422
|
+
/**
|
|
423
|
+
* Document paths mentioned in a prompt.
|
|
424
|
+
*
|
|
425
|
+
* This exists because the PreToolUse hook cannot reach the formats it was
|
|
426
|
+
* written for. Claude Code rejects pptx/xlsx/docx as binary *before* running
|
|
427
|
+
* the hook — verified: a `.pdf` Read fires the hook, a `.pptx` Read never
|
|
428
|
+
* does — so by the time doc2md could speak, the tool call is already refused.
|
|
429
|
+
* UserPromptSubmit runs earlier than any of that and sees the raw text, so a
|
|
430
|
+
* path the user typed can be converted before the model tries to open it.
|
|
431
|
+
*
|
|
432
|
+
* Quoted, `@`-prefixed and bare paths all count. Windows drive letters are
|
|
433
|
+
* matched too, since the rest of the module is path-agnostic.
|
|
434
|
+
*/
|
|
435
|
+
function documentPathsIn(text) {
|
|
436
|
+
if (typeof text !== 'string' || !text) return [];
|
|
437
|
+
const exts = TARGET_EXTENSIONS.map((e) => e.slice(1)).join('|');
|
|
438
|
+
const re = new RegExp(`[@'"\`]?((?:[A-Za-z]:)?[~./][^\\s'"\`]*\\.(?:${exts}))`, 'gi');
|
|
439
|
+
const found = [];
|
|
440
|
+
for (const m of text.matchAll(re)) {
|
|
441
|
+
const raw = m[1];
|
|
442
|
+
const abs = raw.startsWith('~') ? path.join(os.homedir(), raw.slice(1)) : path.resolve(raw);
|
|
443
|
+
if (!found.includes(abs)) found.push(abs);
|
|
444
|
+
}
|
|
445
|
+
return found;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// One prompt naming a dozen decks should not turn a keystroke into a minute of
|
|
449
|
+
// conversion. The rest are named in the note so nothing disappears quietly.
|
|
450
|
+
const MAX_PROMPT_CONVERSIONS = 3;
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Context to inject for a UserPromptSubmit payload, or null.
|
|
454
|
+
*
|
|
455
|
+
* Returns plain text because that is what this hook can give the model:
|
|
456
|
+
* stdout becomes context it can act on. There is no decision to make here —
|
|
457
|
+
* the prompt is not blocked, it is answered better.
|
|
458
|
+
*/
|
|
459
|
+
function contextForPrompt(payload, opts = {}) {
|
|
460
|
+
const lang = opts.lang === 'ko' ? 'ko' : 'en';
|
|
461
|
+
if (!payload || typeof payload.prompt !== 'string') return null;
|
|
462
|
+
const paths = documentPathsIn(payload.prompt).filter((p) => {
|
|
463
|
+
try { return fs.statSync(p).isFile(); } catch { return false; }
|
|
464
|
+
});
|
|
465
|
+
if (paths.length === 0) return null;
|
|
466
|
+
|
|
467
|
+
const lines = [];
|
|
468
|
+
const targets = paths.slice(0, MAX_PROMPT_CONVERSIONS);
|
|
469
|
+
for (const p of targets) {
|
|
470
|
+
const name = path.basename(p);
|
|
471
|
+
const result = convert(p, opts);
|
|
472
|
+
if (result.ok) {
|
|
473
|
+
lines.push(` ${name} → ${result.cacheFile}`);
|
|
474
|
+
if (result.meta && result.meta.note) lines.push(` ${result.meta.note}`);
|
|
475
|
+
if (result.meta && result.meta.clipped) {
|
|
476
|
+
lines.push(' 변환 결과가 너무 커서 뒷부분을 잘랐습니다. 전체가 필요하면 원본을 직접 다루십시오.');
|
|
477
|
+
}
|
|
478
|
+
} else if (result.reason === 'no-markitdown') {
|
|
479
|
+
lines.push(lang === 'ko'
|
|
480
|
+
? ` ${name}: 변환기가 없어 변환하지 못했습니다. 설치: ${INSTALL_HINT}`
|
|
481
|
+
: ` ${name}: no converter installed. Install it with: ${INSTALL_HINT}`);
|
|
482
|
+
} else if (result.reason === 'sensitive') {
|
|
483
|
+
lines.push(lang === 'ko'
|
|
484
|
+
? ` ${name}: 파일명이 민감 문서 패턴에 걸려 변환하지 않았습니다.`
|
|
485
|
+
: ` ${name}: skipped — the filename matches a sensitive-document pattern.`);
|
|
486
|
+
} else {
|
|
487
|
+
lines.push(lang === 'ko'
|
|
488
|
+
? ` ${name}: 변환하지 못했습니다 (${result.reason}).`
|
|
489
|
+
: ` ${name}: conversion failed (${result.reason}).`);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
if (paths.length > targets.length) {
|
|
493
|
+
lines.push(lang === 'ko'
|
|
494
|
+
? ` (문서 ${paths.length}개 가운데 앞 ${targets.length}개만 변환했습니다. 나머지는 \`claude-token-saver doc2md <경로>\` 로 변환하십시오.)`
|
|
495
|
+
: ` (converted the first ${targets.length} of ${paths.length} documents; run \`claude-token-saver doc2md <path>\` for the rest.)`);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
return lang === 'ko'
|
|
499
|
+
? [
|
|
500
|
+
'[doc2md] 이 프롬프트에 문서 경로가 있어 Markdown 으로 변환해 두었습니다.',
|
|
501
|
+
...lines,
|
|
502
|
+
'원본 대신 위 변환본을 Read 하십시오. pptx·xlsx·docx 는 Read 도구가 이진 파일이라며 거부하므로 원본을 직접 열 수 없습니다.',
|
|
503
|
+
'경로가 주어지지 않은 문서를 열어야 할 때는 `claude-token-saver doc2md <경로>` 를 실행해 변환본 경로를 얻으십시오.',
|
|
504
|
+
].join('\n')
|
|
505
|
+
: [
|
|
506
|
+
'[doc2md] This prompt names document paths, which have been converted to Markdown:',
|
|
507
|
+
...lines,
|
|
508
|
+
'Read the conversions above rather than the originals: Read refuses pptx/xlsx/docx as binary files, so the originals cannot be opened directly.',
|
|
509
|
+
'For a document whose path was not given here, run `claude-token-saver doc2md <path>` to get its conversion.',
|
|
510
|
+
].join('\n');
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* The standing note injected at session start.
|
|
515
|
+
*
|
|
516
|
+
* Two things the model cannot work out on its own: that attaching a document
|
|
517
|
+
* costs far more than naming its path, and that the binary-file refusal it
|
|
518
|
+
* will meet on a pptx has a one-command answer.
|
|
519
|
+
*/
|
|
520
|
+
function sessionNote(lang = 'en') {
|
|
521
|
+
if (lang === 'ko') {
|
|
522
|
+
return [
|
|
523
|
+
'[doc2md] 문서(pptx·xlsx·pdf·docx)를 다룰 때의 규칙입니다.',
|
|
524
|
+
' 문서를 열어야 하는데 Read 가 이진 파일이라며 거부하면, `claude-token-saver doc2md <경로>` 를 실행하고 그 결과로 나온 .md 를 Read 하십시오.',
|
|
525
|
+
' 사용자가 문서를 메시지에 직접 첨부했다면, 첨부는 내용 전체가 컨텍스트에 실려 토큰을 크게 씁니다. 다음부터는 첨부 대신 파일 경로를 적어 달라고 안내하십시오. 경로로 주면 변환본만 읽습니다.',
|
|
526
|
+
].join('\n');
|
|
527
|
+
}
|
|
528
|
+
return [
|
|
529
|
+
'[doc2md] Handling documents (pptx/xlsx/pdf/docx):',
|
|
530
|
+
' If Read refuses a document as a binary file, run `claude-token-saver doc2md <path>` and Read the .md it prints.',
|
|
531
|
+
' If the user attached a document to their message, its full contents were billed into the context. Tell them that naming the file path instead is far cheaper, since only the converted Markdown gets read.',
|
|
532
|
+
].join('\n');
|
|
533
|
+
}
|
|
534
|
+
|
|
422
535
|
/** The decision rendered as the JSON Claude Code expects on stdout. */
|
|
423
536
|
function formatHookOutput(decision) {
|
|
424
537
|
if (!decision) return null;
|
|
@@ -444,6 +557,9 @@ module.exports = {
|
|
|
444
557
|
metaPathFor,
|
|
445
558
|
isTargetPath,
|
|
446
559
|
isSensitivePath,
|
|
560
|
+
documentPathsIn,
|
|
561
|
+
contextForPrompt,
|
|
562
|
+
sessionNote,
|
|
447
563
|
findInterpreter,
|
|
448
564
|
readCache,
|
|
449
565
|
writeCache,
|
package/src/installer.js
CHANGED
|
@@ -358,6 +358,7 @@ export function removeKoreanLintHook() {
|
|
|
358
358
|
// actually bounds the work; the timeout is only a backstop.
|
|
359
359
|
// Installed by `doc2md on`, removed by `doc2md off`. Idempotent.
|
|
360
360
|
const DOC2MD_HOOK_COMMAND = 'claude-token-saver doc2md --hook';
|
|
361
|
+
const DOC2MD_PROMPT_HOOK_COMMAND = 'claude-token-saver doc2md --hook-prompt';
|
|
361
362
|
|
|
362
363
|
export function installDoc2mdHook() {
|
|
363
364
|
const dir = claudeUserDir();
|
|
@@ -388,6 +389,23 @@ export function installDoc2mdHook() {
|
|
|
388
389
|
hooks: [{ type: 'command', command: DOC2MD_HOOK_COMMAND }],
|
|
389
390
|
});
|
|
390
391
|
settings.hooks.PreToolUse = list;
|
|
392
|
+
|
|
393
|
+
// The Read hook alone covers only PDFs. Claude Code refuses pptx/xlsx/docx
|
|
394
|
+
// as binary before any PreToolUse hook runs, so for exactly the formats this
|
|
395
|
+
// feature exists for, the tool call is dead before doc2md is consulted.
|
|
396
|
+
// UserPromptSubmit runs earlier and sees the raw prompt text, which is where
|
|
397
|
+
// a path the user typed can still be turned into Markdown.
|
|
398
|
+
if (settings.hooks.UserPromptSubmit === undefined || Array.isArray(settings.hooks.UserPromptSubmit)) {
|
|
399
|
+
const prompts = Array.isArray(settings.hooks.UserPromptSubmit) ? settings.hooks.UserPromptSubmit : [];
|
|
400
|
+
const hasPromptHook = prompts.some((m) =>
|
|
401
|
+
Array.isArray(m?.hooks) && m.hooks.some((h) => typeof h?.command === 'string' && h.command.includes('doc2md --hook-prompt')),
|
|
402
|
+
);
|
|
403
|
+
if (!hasPromptHook) {
|
|
404
|
+
prompts.push({ hooks: [{ type: 'command', command: DOC2MD_PROMPT_HOOK_COMMAND }] });
|
|
405
|
+
settings.hooks.UserPromptSubmit = prompts;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
391
409
|
writeFileSync(file, JSON.stringify(settings, null, 2) + '\n');
|
|
392
410
|
return { path: file, action: 'created' };
|
|
393
411
|
}
|
|
@@ -401,16 +419,22 @@ export function removeDoc2mdHook() {
|
|
|
401
419
|
} catch (e) {
|
|
402
420
|
return { path: file, action: 'skipped', reason: `unreadable JSON (${e.message})` };
|
|
403
421
|
}
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
//
|
|
407
|
-
|
|
408
|
-
const
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
422
|
+
// Both entries go, and only this tool's own: anything else registered under
|
|
423
|
+
// either event stays exactly where the user put it. The substring covers
|
|
424
|
+
// `--hook` and `--hook-prompt` alike.
|
|
425
|
+
let touched = false;
|
|
426
|
+
for (const event of ['PreToolUse', 'UserPromptSubmit']) {
|
|
427
|
+
const list = settings?.hooks?.[event];
|
|
428
|
+
if (!Array.isArray(list)) continue;
|
|
429
|
+
const kept = list.filter((m) =>
|
|
430
|
+
!(Array.isArray(m?.hooks) && m.hooks.some((h) => typeof h?.command === 'string' && h.command.includes('doc2md --hook'))),
|
|
431
|
+
);
|
|
432
|
+
if (kept.length === list.length) continue;
|
|
433
|
+
touched = true;
|
|
434
|
+
if (kept.length === 0) delete settings.hooks[event];
|
|
435
|
+
else settings.hooks[event] = kept;
|
|
436
|
+
}
|
|
437
|
+
if (!touched) return { path: file, action: 'absent' };
|
|
414
438
|
writeFileSync(file, JSON.stringify(settings, null, 2) + '\n');
|
|
415
439
|
return { path: file, action: 'removed' };
|
|
416
440
|
}
|