claude-token-saver 3.20.0 → 3.21.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-token-saver",
3
- "version": "3.20.0",
3
+ "version": "3.21.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": {
@@ -9,11 +9,21 @@
9
9
 
10
10
  import { debug } from '../debug.js';
11
11
 
12
+ /** Thrown when the user declines an optional step, so it is not reported as a failure. */
13
+ class SkipStep extends Error {}
14
+
12
15
  export async function run({ hasFlag }) {
13
16
  const { installAll } = await import('../installer.js');
14
17
  const { userLanguage } = await import('../config.js');
18
+ const { canPrompt, confirm } = await import('../prompt.js');
15
19
  const lang = userLanguage();
16
20
  const force = hasFlag('--force');
21
+ // Two features below write to ~/.claude and cost tokens in every session,
22
+ // so the install shows what they contain and asks before turning them on.
23
+ // Asking is only possible with a human attached: postinstall, CI and pipes
24
+ // fall through to the previous automatic defaults so an unattended upgrade
25
+ // behaves exactly as it did before.
26
+ const interactive = canPrompt() && !hasFlag('--yes') && !hasFlag('--no-input');
17
27
  const print = (kind, r) => {
18
28
  const verb = r.action === 'exists' ? 'already exists' : r.action;
19
29
  console.log(` ${kind}: ${r.path} (${verb})`);
@@ -84,6 +94,30 @@ export async function run({ hasFlag }) {
84
94
  ? ` harness: 이미 설정됨 — 🅷 ${before.configured}/${before.total} (${before.file})`
85
95
  : ` harness: already set up — 🅷 ${before.configured}/${before.total} (${before.file})`);
86
96
  } else {
97
+ // Show the five principle headings before writing them. The block goes
98
+ // into the file the model reads at the start of every session, so the
99
+ // user should see its contents before agreeing, not after.
100
+ const { HARNESS_SECTIONS } = await import('../harness-templates.js');
101
+ console.log('');
102
+ console.log(lang === 'ko'
103
+ ? ' harness: 다음 5원칙을 ~/.claude/CLAUDE.md 에 추가합니다 (모든 프로젝트에 적용).'
104
+ : ' harness: the following 5 principles would be added to ~/.claude/CLAUDE.md (all projects).');
105
+ for (const s of HARNESS_SECTIONS) {
106
+ console.log(` ${s.heading.replace(/^###\s*/, '')}`);
107
+ }
108
+ console.log(lang === 'ko'
109
+ ? ' 기존 내용은 지우지 않고 뒤에 덧붙이며, 원본은 .bak 으로 백업됩니다.'
110
+ : ' existing content is kept — the block is appended and the original is backed up as .bak.');
111
+ let proceed = true;
112
+ if (interactive) {
113
+ proceed = await confirm(lang === 'ko' ? ' 지금 설정할까요?' : ' Set this up now?', { defaultValue: true });
114
+ }
115
+ if (!proceed) {
116
+ console.log(lang === 'ko'
117
+ ? ' harness: 건너뛰었습니다 — 나중에 `claude-token-saver harness init --global` 로 설정할 수 있습니다.'
118
+ : ' harness: skipped — set it up later with `claude-token-saver harness init --global`.');
119
+ throw new SkipStep();
120
+ }
87
121
  const h = harnessInit({ scope: 'global' });
88
122
  console.log('');
89
123
  for (const p of h.backedUp) {
@@ -100,12 +134,17 @@ export async function run({ hasFlag }) {
100
134
  : ' to undo: claude-token-saver harness uninit --global');
101
135
  }
102
136
  } catch (e) {
137
+ // A declined prompt already printed its own line; only real failures fall
138
+ // through to the advice below.
139
+ if (e instanceof SkipStep) { /* user said no — nothing to report */ }
140
+ else {
103
141
  // Never fail an install over this — the statusline and Skill are already
104
142
  // in place, and `harness init` remains available as a manual step.
105
143
  debug('install:harness-init', e);
106
144
  console.log(lang === 'ko'
107
145
  ? ' harness: 자동 설정을 건너뛰었습니다 — `claude-token-saver harness init --global` 로 직접 설정하세요.'
108
146
  : ' harness: auto-setup skipped — run `claude-token-saver harness init --global` yourself.');
147
+ }
109
148
  }
110
149
 
111
150
  // Korean writing guidance. Decided here rather than left to a command the
@@ -125,23 +164,49 @@ export async function run({ hasFlag }) {
125
164
  console.log(lang === 'ko'
126
165
  ? ` korean: 기존 설정 유지 — 한국어 문체 지침 ${ks.koreanStyleEnabled() ? '켜짐' : '꺼짐'}`
127
166
  : ` korean: keeping your setting — Korean writing guidance is ${ks.koreanStyleEnabled() ? 'on' : 'off'}`);
128
- } else if (ks.koreanLocaleDetected()) {
129
- ks.setKoreanStyleEnabled(true);
167
+ } else {
168
+ // The locale only decides what the question defaults to. It is a good
169
+ // guess, not an answer, so a user at a terminal gets to overrule it
170
+ // either way — including turning the guidance on where the locale is
171
+ // English but the person writing is not.
172
+ const detected = ks.koreanLocaleDetected();
130
173
  console.log('');
131
174
  console.log(lang === 'ko'
132
- ? ' korean: 한국어 환경이 감지되어 문체 지침을 켰습니다 — 모든 프로젝트의 세션 시작 시 주입됩니다.'
133
- : ' korean: Korean locale detected writing guidance enabled, injected at session start in every project.');
175
+ ? ' korean: 한국어 문체 지침(fluent-korean)을 세션 시작 시 주입할 수 있습니다.'
176
+ : ' korean: Korean writing guidance (fluent-korean) can be injected at session start.');
177
+ console.log(lang === 'ko'
178
+ ? ' 내용: 조사·어미를 생략하지 않고, 명사구가 아니라 서술어로 문장을 끝맺으며,'
179
+ : ' what it does: keeps particles and endings, ends sentences with a predicate,');
180
+ console.log(lang === 'ko'
181
+ ? ' 번역체 대신 자연스러운 한국어를 쓰도록 지시합니다. 코드와 주석에는 적용되지 않습니다.'
182
+ : ' and asks for idiomatic Korean over translationese. Code and comments are exempt.');
183
+ console.log(lang === 'ko'
184
+ ? ' 비용: 세션당 약 1.5k 토큰(턴마다가 아니라 세션 시작 시 1회, 이후 프롬프트 캐시에 포함).'
185
+ : ' cost: ~1.5k tokens per session (once at session start, cached from the second request on).');
134
186
  console.log(lang === 'ko'
135
187
  ? ` 출처: ${ks.KOREAN_STYLE_SOURCE}`
136
188
  : ` source: ${ks.KOREAN_STYLE_SOURCE}`);
137
189
  console.log(lang === 'ko'
138
- ? ' 끄려면: claude-token-saver korean off'
139
- : ' turn off with: claude-token-saver korean off');
140
- } else {
141
- console.log('');
142
- console.log(lang === 'ko'
143
- ? ' korean: 한국어 환경이 아니어서 꺼 두었습니다 — 필요하면 `claude-token-saver korean on`.'
144
- : ' korean: left off (no Korean locale detected) enable with `claude-token-saver korean on`.');
190
+ ? ` 감지된 환경: ${detected ? '한국어 (기본값 켬)' : '한국어 아님 (기본값 끔)'}`
191
+ : ` detected locale: ${detected ? 'Korean (default on)' : 'not Korean (default off)'}`);
192
+ let enable = detected;
193
+ if (interactive) {
194
+ enable = await confirm(lang === 'ko' ? ' 켤까요?' : ' Enable it?', { defaultValue: detected });
195
+ }
196
+ // Record the choice only when it is really a choice. An unattended
197
+ // install on a non-Korean machine leaves the setting undecided so that
198
+ // a later run at a terminal still gets to ask, instead of silently
199
+ // inheriting a default the user never saw.
200
+ if (interactive || enable) ks.setKoreanStyleEnabled(enable);
201
+ if (enable) {
202
+ console.log(lang === 'ko'
203
+ ? ' korean: 켰습니다 — 모든 프로젝트의 세션 시작 시 주입됩니다. 끄려면 `claude-token-saver korean off`.'
204
+ : ' korean: enabled — injected at session start in every project. Turn off with `claude-token-saver korean off`.');
205
+ } else {
206
+ console.log(lang === 'ko'
207
+ ? ' korean: 꺼 두었습니다 — 나중에 `claude-token-saver korean on` 으로 켤 수 있습니다.'
208
+ : ' korean: left off — enable later with `claude-token-saver korean on`.');
209
+ }
145
210
  }
146
211
  } catch (e) {
147
212
  debug('install:korean-style', e); // optional feature; never fail install
@@ -145,11 +145,12 @@ export function pickCapWarn(caps) {
145
145
  function buildKoreanSeg(c, isIcon, verbose) {
146
146
  try {
147
147
  if (!koreanStyleEnabled()) return null;
148
- // Deliberately quiet (gray, one syllable): this is a "yes, it is on"
148
+ // Deliberately quiet (gray, one glyph): this is a "yes, it is on"
149
149
  // confirmation, not a warning. Without it a silently-failed hook looks
150
150
  // exactly like a working one, because the style only shows up when the
151
- // model happens to write Korean.
152
- if (isIcon) return `${c(GRAY)}${verbose ? '가 Korean style' : '가'}${c(RESET)}`;
151
+ // model happens to write Korean. The icon says "writing guidance", not
152
+ // "Korean" the verbose label already carries the language.
153
+ if (isIcon) return `${c(GRAY)}${verbose ? '✍️ Korean style' : '✍️'}${c(RESET)}`;
153
154
  return `${c(GRAY)}Korean style${c(RESET)}`;
154
155
  } catch {
155
156
  return null;
package/src/prompt.js ADDED
@@ -0,0 +1,52 @@
1
+ /**
2
+ * prompt — minimal yes/no prompting for the install flow.
3
+ *
4
+ * Everything here exists to answer one question: may we stop and ask, or must
5
+ * we fall back to a default? Most installs of this package run as npm's
6
+ * postinstall, where stdin is not a terminal and a readline prompt would either
7
+ * hang the install or read garbage. So the rule is: ask only when a human is
8
+ * demonstrably on the other end, and otherwise keep the previous
9
+ * decide-it-for-them behavior untouched.
10
+ */
11
+
12
+ import { createInterface } from 'node:readline';
13
+
14
+ /**
15
+ * Whether it is safe to block on a question.
16
+ *
17
+ * `npm_lifecycle_event` is checked in addition to the TTY test because npm can
18
+ * leave a TTY attached while still running the script unattended; CI is
19
+ * checked because build agents deadlock rather than answer.
20
+ */
21
+ export function canPrompt({ env = process.env, stdin = process.stdin, stdout = process.stdout } = {}) {
22
+ if (env.CTS_NO_INPUT === '1') return false;
23
+ if (env.CI && env.CI !== 'false') return false;
24
+ if (env.npm_lifecycle_event === 'postinstall') return false;
25
+ return Boolean(stdin.isTTY && stdout.isTTY);
26
+ }
27
+
28
+ /**
29
+ * Ask a yes/no question and resolve to a boolean.
30
+ *
31
+ * An empty answer takes `defaultValue`, which is also what a closed stream
32
+ * resolves to, so a prompt that somehow runs unattended still terminates with
33
+ * the same choice the non-interactive path would have made.
34
+ */
35
+ export function confirm(question, { defaultValue = true, input = process.stdin, output = process.stdout } = {}) {
36
+ const hint = defaultValue ? '[Y/n]' : '[y/N]';
37
+ return new Promise((resolve) => {
38
+ const rl = createInterface({ input, output });
39
+ let answered = false;
40
+ rl.question(`${question} ${hint} `, (answer) => {
41
+ answered = true;
42
+ rl.close();
43
+ const a = String(answer).trim().toLowerCase();
44
+ if (a === 'y' || a === 'yes') return resolve(true);
45
+ if (a === 'n' || a === 'no') return resolve(false);
46
+ resolve(defaultValue); // empty line, or anything we do not recognize
47
+ });
48
+ // A stream that ends without a line — a closed pipe, Ctrl-D — must still
49
+ // settle the promise, or the install would wait forever.
50
+ rl.on('close', () => { if (!answered) resolve(defaultValue); });
51
+ });
52
+ }