sprag-cli 3.46.0 → 3.46.1

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.
@@ -1,9 +1,9 @@
1
1
  # Claude Code statusline (Windows PowerShell version)
2
2
  # Mirrors the POSIX sh script — prints "user@host:cwd" then appends
3
- # claude-token-saver output as a second segment.
3
+ # sprag output as a second segment.
4
4
  #
5
5
  # Install:
6
- # 1) npm install -g claude-token-saver
6
+ # 1) npm install -g sprag-cli
7
7
  # 2) Save this file as: %USERPROFILE%\.claude\statusline-command.ps1
8
8
  # 3) In %USERPROFILE%\.claude\settings.json add:
9
9
  # {
@@ -30,14 +30,20 @@ Write-Host -NoNewline "$esc[01;32m$env:USERNAME@$env:COMPUTERNAME$esc[00m`:$esc[
30
30
  # 2) cache monitor (appended). Separator " | ". Falls back silently.
31
31
  Write-Host -NoNewline " $esc[90m|$esc[00m "
32
32
 
33
- $cacheMonitor = Get-Command claude-token-saver -ErrorAction SilentlyContinue
33
+ # `claude-token-saver` is the older binary name, still installed by the current
34
+ # package; the npx fallback names `sprag-cli`, since the old package name is
35
+ # deprecated on npm and would fetch a version that stopped receiving releases.
36
+ $cacheMonitor = Get-Command sprag -ErrorAction SilentlyContinue
37
+ if (-not $cacheMonitor) {
38
+ $cacheMonitor = Get-Command claude-token-saver -ErrorAction SilentlyContinue
39
+ }
34
40
  if ($cacheMonitor) {
35
41
  try {
36
- & claude-token-saver --statusline --icon 2>$null
42
+ & $cacheMonitor.Name --statusline --icon 2>$null
37
43
  } catch { }
38
44
  } else {
39
45
  # fallback: npx (first run downloads the package; subsequent runs are warm)
40
46
  try {
41
- & npx --yes claude-token-saver@latest --statusline --icon 2>$null
47
+ & npx --yes sprag-cli@latest --statusline --icon 2>$null
42
48
  } catch { }
43
49
  }
@@ -1,9 +1,9 @@
1
1
  #!/bin/sh
2
2
  # Claude Code statusline (POSIX sh — works on macOS, Linux, and WSL)
3
- # Prints "user@host:cwd" then appends claude-token-saver as a second segment.
3
+ # Prints "user@host:cwd" then appends sprag as a second segment.
4
4
  #
5
5
  # Install:
6
- # 1) npm install -g claude-token-saver
6
+ # 1) npm install -g sprag-cli
7
7
  # 2) Save this file as: ~/.claude/statusline-command.sh
8
8
  # chmod +x ~/.claude/statusline-command.sh (optional)
9
9
  # 3) In ~/.claude/settings.json:
@@ -29,8 +29,13 @@ printf '\033[01;32m%s@%s\033[00m:\033[01;34m%s\033[00m' "$(whoami)" "$(hostname
29
29
 
30
30
  # 2) cache monitor (appended). Separator " | ". Falls back silently.
31
31
  printf ' \033[90m|\033[00m '
32
- if command -v claude-token-saver >/dev/null 2>&1; then
32
+ # `claude-token-saver` is the older binary name, still installed by the current
33
+ # package; the npx fallback names `sprag-cli`, since the old package name is
34
+ # deprecated on npm and would fetch a version that stopped receiving releases.
35
+ if command -v sprag >/dev/null 2>&1; then
36
+ sprag --statusline --icon 2>/dev/null || true
37
+ elif command -v claude-token-saver >/dev/null 2>&1; then
33
38
  claude-token-saver --statusline --icon 2>/dev/null || true
34
39
  else
35
- npx --yes claude-token-saver@latest --statusline --icon 2>/dev/null || true
40
+ npx --yes sprag-cli@latest --statusline --icon 2>/dev/null || true
36
41
  fi
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sprag-cli",
3
- "version": "3.46.0",
3
+ "version": "3.46.1",
4
4
  "description": "Sprag - a quality ratchet harness for AI coding agents: model-fitting delegation, ratchet rules, doc2md, Korean style gate. Same tool as claude-token-saver, under its new name.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -8,13 +8,14 @@
8
8
  "claude-token-saver": "bin/cli.js"
9
9
  },
10
10
  "scripts": {
11
- "test": "node --test",
11
+ "deploy": "node scripts/deploy.mjs",
12
12
  "docs:statusline": "node scripts/docs-statusline.mjs",
13
13
  "postinstall": "node bin/cli.js install || true",
14
+ "prepublishOnly": "node scripts/sync-readme-ko.mjs",
14
15
  "publish:legacy": "node scripts/publish-legacy.mjs",
15
- "sync:ko": "node scripts/sync-readme-ko.mjs",
16
16
  "release:notes": "node scripts/release-notes.mjs",
17
- "prepublishOnly": "node scripts/sync-readme-ko.mjs"
17
+ "sync:ko": "node scripts/sync-readme-ko.mjs",
18
+ "test": "node --test"
18
19
  },
19
20
  "files": [
20
21
  "bin/",
package/src/changelog.js CHANGED
@@ -1,5 +1,6 @@
1
1
  /**
2
- * changelog — read one version's section out of CHANGELOG.md.
2
+ * changelog — read one version's section out of CHANGELOG.md, and say whether a
3
+ * notes draft is still waiting on a person.
3
4
  *
4
5
  * Separate from scripts/release-notes.mjs, which is a CLI: importing that file
5
6
  * runs its argument parsing and exits, so the extraction it depends on cannot be
@@ -7,6 +8,12 @@
7
8
  * pulled one line short drops the release's last item, and one line long carries
8
9
  * the previous release's first, and the upgrade offer shows either without
9
10
  * complaint.
11
+ *
12
+ * The draft marker lives here for a reason. Two scripts gate on it — the one that
13
+ * writes drafts and the one that deploys — and they used to carry the string
14
+ * separately. Renaming it in one left the other looking for text that no longer
15
+ * existed, and a gate that greps for an absent string does not fail: it passes,
16
+ * and an unreviewed draft goes out as the release. One definition, imported.
10
17
  */
11
18
 
12
19
  /**
@@ -32,3 +39,32 @@ export function sectionFor(changelog, version) {
32
39
  }
33
40
  return out.join('\n').trim();
34
41
  }
42
+
43
+ /**
44
+ * The line `release-notes.mjs` writes at the top of a fresh draft, and the line
45
+ * every gate refuses on. A draft still carrying it has not been through a person.
46
+ */
47
+ export const DRAFT_MARKER =
48
+ '<!-- REVIEW: edit this draft, add the English half, then delete this line -->';
49
+
50
+ /** Heading that opens the Korean half of a bilingual release body. */
51
+ export const KO_HEADING = '## 한국어';
52
+
53
+ /**
54
+ * The opening of the marker, which is what the gate actually matches. Spelled out
55
+ * rather than sliced off `DRAFT_MARKER`, so that editing the marker's front does
56
+ * not silently change what is being tested for.
57
+ */
58
+ const REVIEW_OPENING = '<!-- REVIEW:';
59
+
60
+ /**
61
+ * Whether a draft still needs review. Matches on the `<!-- REVIEW:` opening
62
+ * rather than the whole line, so editing the wording of the instruction does not
63
+ * quietly disarm the gate.
64
+ *
65
+ * @param {string} draft - the draft file's contents
66
+ * @returns {boolean} true while the draft is unreviewed
67
+ */
68
+ export function needsReview(draft) {
69
+ return String(draft || '').includes(REVIEW_OPENING);
70
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * cli-name — the name this tool tells the user to type.
3
+ *
4
+ * One definition, because the alternative was measured: the marker string in the
5
+ * release scripts lived in two files, one was renamed, and the gate that grepped
6
+ * for the other silently stopped gating. A command name printed for the user to
7
+ * copy fails more quietly still — nothing errors, the user simply types a name
8
+ * that is not the package they installed.
9
+ *
10
+ * `sprag-cli` is the package; `claude-token-saver` is the deprecated name it
11
+ * shipped under, and both binaries are still in `package.json`'s `bin`, so the
12
+ * old name keeps working for anyone whose settings or notes carry it.
13
+ *
14
+ * NOT the same thing as the on-disk identifiers. The state directory, the harness
15
+ * block markers, and the skill directory are all still spelled with the old name
16
+ * on purpose: renaming those would orphan existing users' ledgers and stop the
17
+ * harness markers matching text already in their CLAUDE.md. Those are addresses,
18
+ * not instructions, and an address only has to be stable.
19
+ */
20
+
21
+ /** The command name to print in anything a user is expected to type or read. */
22
+ export const CLI_NAME = 'sprag';
23
+
24
+ /** The deprecated name. Still a working binary; recognised, never printed. */
25
+ export const LEGACY_CLI_NAME = 'claude-token-saver';
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Subcommand: uninstall — take the integration back out of ~/.claude.
3
3
  *
4
- * claude-token-saver uninstall # hooks, statusline, skill
5
- * claude-token-saver uninstall --purge # the above plus recorded state
4
+ * sprag uninstall # hooks, statusline, skill
5
+ * sprag uninstall --purge # the above plus recorded state
6
6
  *
7
7
  * Recorded savings are kept by default. Someone removing an integration is
8
8
  * usually not asking to throw away the ledger that says what it saved, and
@@ -12,6 +12,7 @@
12
12
  export async function run({ hasFlag, args = [] }) {
13
13
  const { uninstallAll } = await import('../installer.js');
14
14
  const { userLanguage } = await import('../config.js');
15
+ const { CLI_NAME } = await import('../cli-name.js');
15
16
  const lang = userLanguage();
16
17
  const purge = hasFlag('--purge') || args.includes('--purge');
17
18
 
@@ -36,7 +37,7 @@ export async function run({ hasFlag, args = [] }) {
36
37
  }
37
38
  if (!purge) {
38
39
  console.log(lang === 'ko'
39
- ? ' 기록된 절감액과 설정까지 지우려면 `claude-token-saver uninstall --purge` 를 실행하십시오.'
40
- : ' To remove recorded savings and settings too: `claude-token-saver uninstall --purge`.');
40
+ ? ` 기록된 절감액과 설정까지 지우려면 \`${CLI_NAME} uninstall --purge\` 를 실행하십시오.`
41
+ : ` To remove recorded savings and settings too: \`${CLI_NAME} uninstall --purge\`.`);
41
42
  }
42
43
  }
@@ -2,6 +2,7 @@
2
2
  * Terminal table formatter — zero dependencies.
3
3
  */
4
4
  import { ISSUE_MESSAGES } from '../advice.js';
5
+ import { CLI_NAME } from '../cli-name.js';
5
6
  import { formatResetIn, formatResetClock } from '../format-time.js';
6
7
  import { labelForKey } from '../window-labels.js';
7
8
 
@@ -139,7 +140,7 @@ function renderSpikeSection(spikes, contextWindow) {
139
140
  lines.push('');
140
141
  }
141
142
  }
142
- lines.push(rbl(' ▶ 상세 처치법: ') + rb('claude-token-saver last'));
143
+ lines.push(rbl(' ▶ 상세 처치법: ') + rb(`${CLI_NAME} last`));
143
144
  lines.push('');
144
145
  return lines;
145
146
  }
@@ -165,10 +166,10 @@ function renderCapWarnSection(caps) {
165
166
  }
166
167
  lines.push('');
167
168
  lines.push(r(' Back up work before the cap hits:'));
168
- lines.push(rb(' claude-token-saver handoff'));
169
+ lines.push(rb(` ${CLI_NAME} handoff`));
169
170
  lines.push(r(' (writes a HANDOFF-*.md so a fresh session can pick up.)'));
170
171
  lines.push('');
171
- lines.push(rbl(' ▶ 상세 처치법: ') + rb('claude-token-saver last'));
172
+ lines.push(rbl(' ▶ 상세 처치법: ') + rb(`${CLI_NAME} last`));
172
173
  lines.push('');
173
174
  return lines;
174
175
  }
@@ -179,7 +180,7 @@ export function formatReport({ summary: sum, trend, ttl, anomalies, cost, option
179
180
  // Header
180
181
  lines.push('');
181
182
  lines.push(` Claude Token Saver — Last ${options.days} day${options.days === 1 ? '' : 's'}`);
182
- lines.push(` (claude-token-saver v${options.version || ''})`.trimEnd());
183
+ lines.push(` (${CLI_NAME} v${options.version || ''})`.trimEnd());
183
184
  lines.push(` ${'═'.repeat(50)}`);
184
185
  lines.push('');
185
186
 
@@ -12,6 +12,7 @@
12
12
  import { readFile, writeFile, rm } from 'node:fs/promises';
13
13
  import { join } from 'node:path';
14
14
  import { homedir } from 'node:os';
15
+ import { CLI_NAME } from './cli-name.js';
15
16
 
16
17
  const SETTINGS_PATH = join(homedir(), '.claude', 'settings.json');
17
18
  // Legacy copied-file location — removed on install/uninstall so machines that
@@ -48,7 +49,7 @@ export async function installHook({ threshold = 0.7 } = {}) {
48
49
  hooks: [
49
50
  {
50
51
  type: 'command',
51
- command: `claude-token-saver --hook-run --threshold ${threshold}`,
52
+ command: `${CLI_NAME} --hook-run --threshold ${threshold}`,
52
53
  timeout: 10,
53
54
  },
54
55
  ],
@@ -59,7 +60,7 @@ export async function installHook({ threshold = 0.7 } = {}) {
59
60
  // Clean up the legacy copy left by older versions.
60
61
  await rm(LEGACY_HOOK_DEST, { force: true }).catch(() => {});
61
62
 
62
- console.log('✓ Hook installed (PostToolUse → claude-token-saver --hook-run)');
63
+ console.log(`✓ Hook installed (PostToolUse → ${CLI_NAME} --hook-run)`);
63
64
  console.log(` Settings updated: ${SETTINGS_PATH}`);
64
65
  console.log(` Threshold: ${(threshold * 100).toFixed(0)}%`);
65
66
  console.log(` Stats file: ~/.claude/cache-stats.jsonl`);
package/src/installer.js CHANGED
@@ -14,10 +14,64 @@
14
14
  */
15
15
 
16
16
  import { writeFileSync, mkdirSync, existsSync, unlinkSync, readFileSync, rmSync } from 'node:fs';
17
+ import { CLI_NAME, LEGACY_CLI_NAME } from './cli-name.js';
17
18
  import { join } from 'node:path';
18
19
  import { claudeUserDir, userDataDir } from './paths.js';
19
20
 
20
- const STATUSLINE_COMMAND = 'claude-token-saver --statusline --icon';
21
+ /**
22
+ * The command every entry we write invokes.
23
+ *
24
+ * Both binaries ship in `package.json`'s `bin`, so either name works — but the
25
+ * package is `sprag-cli`, and a fresh install writing `claude-token-saver` into
26
+ * the user's settings puts a name they never typed in a file they read.
27
+ *
28
+ * The reverse direction is not symmetrical: entries ALREADY on disk carry the
29
+ * old name, so anything that recognises our own work has to accept both. That is
30
+ * what `isOurCommand` is for — narrowing it to the new name would make install
31
+ * miss an existing hook and add a second one beside it.
32
+ */
33
+ const CLI = CLI_NAME;
34
+ const LEGACY_CLI = LEGACY_CLI_NAME;
35
+
36
+ /**
37
+ * Whether a settings command belongs to this tool, under either name.
38
+ *
39
+ * The name has to sit where the executable goes, not just appear somewhere in the
40
+ * string. `sprag` is five characters, and `uninstall` deletes what this returns
41
+ * true for: a substring test claims `node ~/.claude/probe/sprag-probe.mjs` and
42
+ * anyone's `my-sprag-wrapper`, then removes them as ours. That is not
43
+ * hypothetical — the probe entry is on this machine.
44
+ *
45
+ * A wrapper is allowed in front, because the entry may legitimately be run
46
+ * through one: `npx sprag-cli brief --hook` and `/usr/local/bin/sprag doc2md`
47
+ * are both ours. So the executable is taken as the last path segment of the
48
+ * first token — or of the token after a runner like `npx` or `yarn dlx` — and
49
+ * matched whole, with `sprag-cli` accepted there since that is what npx resolves.
50
+ *
51
+ * The runner list follows `upgradeCommand`, which already prints npm, pnpm, bun
52
+ * and yarn instructions. Missing one is the duplicate-hook failure rather than
53
+ * the deletion one: an entry we do not recognise gets a second hook added beside
54
+ * it, and both then run on every prompt.
55
+ */
56
+ const RUNNERS = /^(npx|bunx|pnpx)$/;
57
+ const RUNNER_SUBCOMMANDS = { pnpm: ['dlx', 'exec'], yarn: ['dlx', 'run'], bun: ['x', 'run'], npm: ['exec'] };
58
+
59
+ function isOurCommand(command) {
60
+ if (typeof command !== 'string') return false;
61
+ const tokens = command.trim().split(/\s+/).filter(Boolean);
62
+ if (!tokens.length) return false;
63
+ // `npx <pkg>` puts the package one token in, `yarn dlx <pkg>` two.
64
+ let i = 0;
65
+ if (RUNNERS.test(tokens[0])) i = 1;
66
+ else if (RUNNER_SUBCOMMANDS[tokens[0]]?.includes(tokens[1])) i = 2;
67
+ const token = tokens[i];
68
+ if (!token) return false;
69
+ // A path invokes the binary by its last segment; strip a Windows .cmd/.exe too.
70
+ const exe = token.split(/[/\\]/).pop().replace(/\.(cmd|exe|ps1)$/i, '');
71
+ return exe === CLI || exe === LEGACY_CLI || exe === `${CLI}-cli`;
72
+ }
73
+
74
+ const STATUSLINE_COMMAND = `${CLI} --statusline --icon`;
21
75
  const STATUSLINE_REFRESH_INTERVAL = 5;
22
76
 
23
77
  const SKILL_BODY = `---
@@ -181,7 +235,7 @@ export function installStatusline({ force = false } = {}) {
181
235
  }
182
236
 
183
237
  const cur = settings.statusLine;
184
- const targetsUs = cur && typeof cur.command === 'string' && cur.command.includes('claude-token-saver');
238
+ const targetsUs = !!cur && isOurCommand(cur.command);
185
239
 
186
240
  if (!cur) {
187
241
  settings.statusLine = {
@@ -221,7 +275,7 @@ export function installStatusline({ force = false } = {}) {
221
275
  // candidates as session context (startup + /clear). Idempotent: skips when a
222
276
  // sprag route-scan hook is already present; never touches other
223
277
  // hooks the user configured.
224
- const ROUTE_SCAN_HOOK_COMMAND = 'claude-token-saver route-scan --hook';
278
+ const ROUTE_SCAN_HOOK_COMMAND = `${CLI} route-scan --hook`;
225
279
 
226
280
  export function installSessionStartHook() {
227
281
  const dir = claudeUserDir();
@@ -273,7 +327,7 @@ export function installSessionStartHook() {
273
327
  * produce identical output; a delegation is precisely the event that changes it,
274
328
  * so this hook rescans on the spot. Detached, so the tool call does not wait.
275
329
  */
276
- const ROUTE_SCAN_DELEGATED_HOOK_COMMAND = 'claude-token-saver route-scan --hook-delegated';
330
+ const ROUTE_SCAN_DELEGATED_HOOK_COMMAND = `${CLI} route-scan --hook-delegated`;
277
331
 
278
332
  export function installDelegationHook() {
279
333
  const dir = claudeUserDir();
@@ -311,7 +365,7 @@ export function installDelegationHook() {
311
365
  return { path: file, action: 'created' };
312
366
  }
313
367
 
314
- const BRIEF_HOOK_COMMAND = 'claude-token-saver brief --hook';
368
+ const BRIEF_HOOK_COMMAND = `${CLI} brief --hook`;
315
369
 
316
370
  export function installBriefHook() {
317
371
  const dir = claudeUserDir();
@@ -353,7 +407,7 @@ export function installBriefHook() {
353
407
  // drift surfaces only when a human reads the artifact. This hook closes that
354
408
  // gap: it runs on the file the model just wrote, while it can still fix it.
355
409
  // Installed by `korean on`, removed by `korean off`. Idempotent.
356
- const KOREAN_LINT_HOOK_COMMAND = 'claude-token-saver korean --hook';
410
+ const KOREAN_LINT_HOOK_COMMAND = `${CLI} korean --hook`;
357
411
  // Bash belongs on this matcher because a heredoc, a `tee`, or a `sed -i` puts
358
412
  // Korean on disk exactly like Write does. A subagent without the Write tool
359
413
  // reaches for `cat > file` first, so leaving Bash off the list exempted every
@@ -436,8 +490,8 @@ export function removeKoreanLintHook() {
436
490
  // workbook adding a minute on top. The row cap inside the converter is what
437
491
  // actually bounds the work; the timeout is only a backstop.
438
492
  // Installed by `doc2md on`, removed by `doc2md off`. Idempotent.
439
- const DOC2MD_HOOK_COMMAND = 'claude-token-saver doc2md --hook';
440
- const DOC2MD_PROMPT_HOOK_COMMAND = 'claude-token-saver doc2md --hook-prompt';
493
+ const DOC2MD_HOOK_COMMAND = `${CLI} doc2md --hook`;
494
+ const DOC2MD_PROMPT_HOOK_COMMAND = `${CLI} doc2md --hook-prompt`;
441
495
 
442
496
  export function installDoc2mdHook() {
443
497
  const dir = claudeUserDir();
@@ -576,7 +630,7 @@ export function uninstallAll({ purge = false } = {}) {
576
630
  // Only our own statusline command goes. Someone else's stays exactly
577
631
  // where they put it.
578
632
  const cur = settings.statusLine;
579
- if (cur && typeof cur.command === 'string' && cur.command.includes('claude-token-saver')) {
633
+ if (cur && isOurCommand(cur.command)) {
580
634
  delete settings.statusLine;
581
635
  result.removed.push('statusLine');
582
636
  } else if (cur) {
@@ -586,15 +640,28 @@ export function uninstallAll({ purge = false } = {}) {
586
640
  // Every event, every entry whose command is this CLI. Matching on the
587
641
  // binary name rather than a list of exact commands means a hook added by
588
642
  // an older version is still removed by a newer one.
643
+ //
644
+ // Removal is per hook, not per matcher group. Dropping whole groups was
645
+ // taking other tools' hooks with them whenever Claude Code had merged them
646
+ // into one group — which it does for entries sharing a matcher, so a user who
647
+ // installed us second lost the first tool's hook on uninstall. A group is
648
+ // deleted only once nothing of anyone else's is left in it.
649
+ const isOurs = (h) => isOurCommand(h?.command)
650
+ || (typeof h?.command === 'string' && h.command.includes('cache-monitor-hook'));
589
651
  for (const event of Object.keys(settings.hooks || {})) {
590
652
  const list = settings.hooks[event];
591
653
  if (!Array.isArray(list)) continue;
592
- const kept = list.filter((m) => !(
593
- Array.isArray(m?.hooks)
594
- && m.hooks.some((h) => typeof h?.command === 'string'
595
- && (h.command.includes('claude-token-saver') || h.command.includes('cache-monitor-hook')))
596
- ));
597
- if (kept.length === list.length) continue;
654
+ let touched = false;
655
+ const kept = [];
656
+ for (const m of list) {
657
+ if (!Array.isArray(m?.hooks)) { kept.push(m); continue; }
658
+ const hooks = m.hooks.filter((h) => !isOurs(h));
659
+ if (hooks.length === m.hooks.length) { kept.push(m); continue; }
660
+ touched = true;
661
+ // The group survives if it still carries someone else's hook.
662
+ if (hooks.length) kept.push({ ...m, hooks });
663
+ }
664
+ if (!touched) continue;
598
665
  result.removed.push(`hooks.${event}`);
599
666
  if (kept.length === 0) delete settings.hooks[event];
600
667
  else settings.hooks[event] = kept;
@@ -652,7 +719,7 @@ export function migrateLegacyCacheMonitorHook() {
652
719
  for (const h of m.hooks) {
653
720
  if (typeof h?.command !== 'string' || !h.command.includes('cache-monitor-hook')) continue;
654
721
  const th = h.command.match(/--threshold\s+([\d.]+)/);
655
- h.command = `claude-token-saver --hook-run --threshold ${th ? th[1] : '0.7'}`;
722
+ h.command = `${CLI} --hook-run --threshold ${th ? th[1] : '0.7'}`;
656
723
  migrated = true;
657
724
  }
658
725
  }
package/src/seed-rules.js CHANGED
@@ -24,6 +24,7 @@
24
24
  */
25
25
 
26
26
  import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
27
+ import { CLI_NAME } from './cli-name.js';
27
28
  import { join, dirname } from 'node:path';
28
29
  import { fileURLToPath } from 'node:url';
29
30
  import { createHash } from 'node:crypto';
@@ -242,7 +243,7 @@ export function seedOfferBlock({ lang = userLanguage(), root = findProjectRoot()
242
243
  const fixes = pending.filter((s) => s.kind === 'ratchet');
243
244
  const lines = [];
244
245
  if (lang === 'ko') {
245
- lines.push(`[claude-token-saver seed] 아직 등록되지 않은 추천 룰이 ${pending.length}건 있습니다 (패키지 동봉 프리셋).`);
246
+ lines.push(`[${CLI_NAME} seed] 아직 등록되지 않은 추천 룰이 ${pending.length}건 있습니다 (패키지 동봉 프리셋).`);
246
247
  lines.push('**이 세션의 첫 응답에서 다른 작업보다 먼저 물어보십시오.** 사용자가 먼저 꺼낸 용건이 있으면 그 답변을 마친 직후에 이어서 묻고, 용건이 끝나기를 기다리지 마십시오. 사용자가 이 안내 문구에 대해 질문할 때까지 미루면 안 됩니다.');
247
248
  lines.push(`첫 질문에서 다음 네 선택지를 **이 순서대로** 제시하십시오. ${pending.length}건을 한 건씩 답하는 것은 대부분의 사용자에게 과한 부담이므로, 일괄 등록이 맨 앞에 옵니다.`);
248
249
  lines.push(' 1. 전부 글로벌로 등록 — `seed accept all --global`');
@@ -263,11 +264,11 @@ export function seedOfferBlock({ lang = userLanguage(), root = findProjectRoot()
263
264
  lines.push(` · 래칫 룰 ${fixes.length}건 — 같은 실수를 반복하지 않도록 세션마다 읽히는 규칙입니다.`);
264
265
  for (const s of fixes) lines.push(` [${s.id}] ${s.ruleText}`);
265
266
  }
266
- lines.push(' 일괄: claude-token-saver seed accept all --global|--project # 적용 범위는 반드시 사용자에게 확인');
267
- lines.push(' 개별: claude-token-saver seed accept <id> --global|--project');
268
- lines.push(' 거절: claude-token-saver seed skip <id>|all # 다시 묻지 않습니다');
267
+ lines.push(` 일괄: ${CLI_NAME} seed accept all --global|--project # 적용 범위는 반드시 사용자에게 확인`);
268
+ lines.push(` 개별: ${CLI_NAME} seed accept <id> --global|--project`);
269
+ lines.push(` 거절: ${CLI_NAME} seed skip <id>|all # 다시 묻지 않습니다`);
269
270
  } else {
270
- lines.push(`[claude-token-saver seed] ${pending.length} recommended rule(s) from the bundled presets are not registered yet.`);
271
+ lines.push(`[${CLI_NAME} seed] ${pending.length} recommended rule(s) from the bundled presets are not registered yet.`);
271
272
  lines.push('**Ask in your very first reply of this session, before anything else.** If the user opened with their own request, answer it and then ask right away; do not wait for their task to finish, and never wait until they ask about this notice.');
272
273
  lines.push(`Offer these four choices, **in this order**. Answering ${pending.length} rules one by one is more than most users want to do, so registering them all comes first.`);
273
274
  lines.push(' 1. Register all, globally — `seed accept all --global`');
@@ -288,9 +289,9 @@ export function seedOfferBlock({ lang = userLanguage(), root = findProjectRoot()
288
289
  lines.push(` · ${fixes.length} ratchet rule(s) — read at the start of every session so the same mistake is not repeated.`);
289
290
  for (const s of fixes) lines.push(` [${s.id}] ${s.ruleText}`);
290
291
  }
291
- lines.push(' all: claude-token-saver seed accept all --global|--project # ALWAYS confirm the scope with the user');
292
- lines.push(' one: claude-token-saver seed accept <id> --global|--project');
293
- lines.push(' decline: claude-token-saver seed skip <id>|all # never offered again');
292
+ lines.push(` all: ${CLI_NAME} seed accept all --global|--project # ALWAYS confirm the scope with the user`);
293
+ lines.push(` one: ${CLI_NAME} seed accept <id> --global|--project`);
294
+ lines.push(` decline: ${CLI_NAME} seed skip <id>|all # never offered again`);
294
295
  }
295
296
  return lines.join('\n');
296
297
  }
@@ -112,7 +112,9 @@ export function updateStatus(currentVersion) {
112
112
  dismissed: available && s.dismissedVersion === latest,
113
113
  stale: age >= CHECK_INTERVAL_MS,
114
114
  // Only when they describe THIS version. A cache written for an earlier
115
- // release would otherwise sell the wrong upgrade.
115
+ // release would otherwise sell the wrong upgrade. Language is not checked
116
+ // here: a mismatch is corrected by the next refresh, and showing the other
117
+ // language in the meantime beats showing nothing.
116
118
  highlights: Array.isArray(s.highlights) && s.highlightsFor === latest ? s.highlights : [],
117
119
  };
118
120
  }
@@ -163,6 +165,28 @@ const RELEASES_API = 'https://api.github.com/repos/rootstudioyaml/sprag/releases
163
165
  const MAX_HIGHLIGHTS = 3;
164
166
  const MAX_HIGHLIGHT_LEN = 110;
165
167
 
168
+ /**
169
+ * A release body may carry both languages, English first and Korean under a
170
+ * heading of its own. Read only the half that matches the reader: mixing them
171
+ * would put the same change on two of the three lines the notice has room for,
172
+ * and showing English to someone who set `lang=ko` wastes the notice entirely.
173
+ *
174
+ * The Korean half starts at a heading whose text is Korean; everything before
175
+ * that first Korean heading is the English half. A body in one language only has
176
+ * no such heading, so English readers get all of it and Korean readers fall back
177
+ * to the same text rather than to nothing.
178
+ *
179
+ * @param {string} body
180
+ * @param {string} lang - 'ko' | 'en'
181
+ * @returns {string} the half to read
182
+ */
183
+ function sectionForLang(body, lang) {
184
+ const lines = body.split(/\r?\n/);
185
+ const at = lines.findIndex((l) => /^#{1,6}\s/.test(l) && /[가-힣]/.test(l));
186
+ if (at < 0) return body;
187
+ return lang === 'ko' ? lines.slice(at + 1).join('\n') : lines.slice(0, at).join('\n');
188
+ }
189
+
166
190
  /**
167
191
  * The headline items of a release body, as short single lines.
168
192
  *
@@ -174,10 +198,11 @@ const MAX_HIGHLIGHT_LEN = 110;
174
198
  * rather than truncated into nonsense.
175
199
  *
176
200
  * @param {string} body - the release body as published
201
+ * @param {string} [lang] - 'ko' | 'en'; picks the half of a bilingual body
177
202
  * @returns {string[]} at most MAX_HIGHLIGHTS lines, or [] when nothing fits
178
203
  */
179
- export function releaseHighlights(body) {
180
- const text = String(body || '');
204
+ export function releaseHighlights(body, lang = 'en') {
205
+ const text = sectionForLang(String(body || ''), lang);
181
206
  if (!text.trim()) return [];
182
207
  const out = [];
183
208
  for (const raw of text.split(/\r?\n/)) {
@@ -246,7 +271,7 @@ export function releaseHighlights(body) {
246
271
  * is worth showing with a bare version number, so a missing release, a rate
247
272
  * limit, or an offline machine must not cost the user the notice itself.
248
273
  */
249
- export async function fetchHighlights(version, { timeoutMs = FETCH_TIMEOUT_MS } = {}) {
274
+ export async function fetchHighlights(version, { timeoutMs = FETCH_TIMEOUT_MS, lang = 'en' } = {}) {
250
275
  const controller = new AbortController();
251
276
  const timer = setTimeout(() => controller.abort(), timeoutMs);
252
277
  try {
@@ -256,7 +281,7 @@ export async function fetchHighlights(version, { timeoutMs = FETCH_TIMEOUT_MS }
256
281
  });
257
282
  if (!res.ok) throw new Error(`releases responded ${res.status}`);
258
283
  const body = await res.json();
259
- return releaseHighlights(body && body.body);
284
+ return releaseHighlights(body && body.body, lang);
260
285
  } catch (e) {
261
286
  debug('update-check:highlights', e);
262
287
  return [];
@@ -293,16 +318,31 @@ export async function refreshUpdateState(currentVersion) {
293
318
  // What the new version adds, so the notice can answer "why would I".
294
319
  // Only worth a second request when there is actually an upgrade to describe,
295
320
  // and only when we do not already hold the notes for that exact version.
296
- if (isNewer(latest, currentVersion) && prev.highlightsFor !== latest) {
297
- const highlights = await fetchHighlights(latest);
321
+ // The reader's own language: a release body carries both halves, and the
322
+ // notice has room for three lines in one of them.
323
+ let lang = 'en';
324
+ try {
325
+ const { userLanguage } = await import('./config.js');
326
+ lang = userLanguage();
327
+ } catch (e) { debug('update-check:lang', e); }
328
+ // Fetch when the version is new, and also when the reader switched language
329
+ // since these notes were cached — otherwise they keep reading the half they
330
+ // left behind.
331
+ const staleLang = prev.highlightsFor === latest && prev.highlightsLang !== lang;
332
+ if (isNewer(latest, currentVersion) && (prev.highlightsFor !== latest || staleLang)) {
333
+ const highlights = await fetchHighlights(latest, { lang });
298
334
  if (highlights.length) {
299
335
  next.highlights = highlights;
300
336
  next.highlightsFor = latest;
337
+ // Which language these are in. Someone who switches `lang` afterwards
338
+ // would otherwise keep reading the notes in the language they left.
339
+ next.highlightsLang = lang;
301
340
  } else {
302
341
  // Drop notes belonging to an older version rather than showing them
303
342
  // against this one.
304
343
  delete next.highlights;
305
344
  delete next.highlightsFor;
345
+ delete next.highlightsLang;
306
346
  }
307
347
  }
308
348
  writeUpdateState(next);