claude-token-saver 3.24.0 โ 3.25.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 +20 -0
- package/README.md +20 -0
- package/bin/cli.js +51 -1
- package/package.json +1 -1
- package/src/commands/route-scan.js +48 -0
- package/src/commands/update-check.js +77 -0
- package/src/commands/upgrade.js +68 -0
- package/src/formatters/statusline.js +42 -2
- package/src/update-check.js +198 -0
package/README.en.md
CHANGED
|
@@ -130,6 +130,8 @@ With an empty ledger (no measured delegation yet) row 1 is not drawn and the lay
|
|
|
130
130
|
| `โฆ current` / `๐
weekly` | 5-hour / 7-day rate-limit window usage + reset time |
|
|
131
131
|
| `๐ฆ` | Context usage (e.g. `Ctx 68% of 1M`) โ colored by fill. Current models default to 1M with no premium, but token volume itself drives per-turn cost and 5H/7D burn |
|
|
132
132
|
| `๐ฐ` | Cumulative savings from prompt caching โ a **different** number from row 1's `๐` (model routing) |
|
|
133
|
+
| `v3.24.0` | The version you are running. Gray, at the tail, when it is the latest one |
|
|
134
|
+
| `โฌ v3.24.0 โ 3.25.0` | A newer release exists. Actionable, so it moves to the front of the line ([Update notifications](#-update-notifications)) |
|
|
133
135
|
|
|
134
136
|
When something is wrong, a **warning chip leads the line**:
|
|
135
137
|
|
|
@@ -161,6 +163,9 @@ Run these in your shell (inside Claude Code, the `/claude-token-saver` Skill is
|
|
|
161
163
|
| `claude-token-saver korean on\|off\|status` | Inject Korean writing guidance at session start and install the write-time check (below) |
|
|
162
164
|
| `claude-token-saver korean lint block\|warn\|off` | How the write-time check handles findings |
|
|
163
165
|
| `claude-token-saver korean lint scope all\|prose` | Check every text file, or documents only |
|
|
166
|
+
| `claude-token-saver --version` | Print the installed version |
|
|
167
|
+
| `claude-token-saver update-check` | Is a newer version out? (`--refresh` to ask now, `--dismiss` to mute this version's offer) |
|
|
168
|
+
| `claude-token-saver upgrade` | Install the latest release with the package manager that installed this copy (`--print` shows the command only) |
|
|
164
169
|
| `claude-token-saver install` | Manually register Skill + statusline |
|
|
165
170
|
|
|
166
171
|
Switch output language with `mode ko` / `mode en` (English default; statusline chips stay symbolic).
|
|
@@ -184,6 +189,16 @@ Switch output language with `mode ko` / `mode en` (English default; statusline c
|
|
|
184
189
|
| `--install-hook` / `--uninstall-hook` | Manage the PostToolUse hook | โ |
|
|
185
190
|
</details>
|
|
186
191
|
|
|
192
|
+
## โฌ Update notifications
|
|
193
|
+
|
|
194
|
+
A statusline cannot open a dialog, and it re-renders every ~300ms, so it can never touch the network while drawing. The notification is therefore split in two:
|
|
195
|
+
|
|
196
|
+
- **The statusline tells you.** Up to date: a quiet gray `v3.24.0` at the tail. Newer release out: `โฌ v3.24.0 โ 3.25.0` in yellow, moved to the front. Never red โ nothing is broken.
|
|
197
|
+
- **Session start asks you.** On a new session or `/clear`, the SessionStart hook injects one line telling the model a newer version exists and to ask before installing anything. Only after you agree does it run `claude-token-saver upgrade`.
|
|
198
|
+
- **Declining sticks.** `claude-token-saver update-check --dismiss` mutes the offer for that version; the next release asks again. The statusline chip stays โ you declined the question, not the fact.
|
|
199
|
+
|
|
200
|
+
The registry lookup runs at most once every 24h in a detached background process and only ever writes a cache file (`update-check.json`) โ the same shape npm's `update-notifier` uses. A failed check still stamps its timestamp, so an offline machine backs off instead of retrying on every render. Turn checks off entirely with `CTS_NO_UPDATE_CHECK=1` or `NO_UPDATE_NOTIFIER`.
|
|
201
|
+
|
|
187
202
|
## ๐
ท Harness mode
|
|
188
203
|
|
|
189
204
|
Bootstrap five engineering principles (Ratchet ยท Evidence ยท PEV ยท Structured Task ยท Default Safe Path) into `CLAUDE.md` with one command; the statusline scores it as `๐
ท 5/5`. When the same error keeps recurring, a `๐
ทโ ratchet?` nudge appears so you can promote it to a rule.
|
|
@@ -439,6 +454,11 @@ Also update `statusLine.command` in `~/.claude/settings.json` to `claude-token-s
|
|
|
439
454
|
|
|
440
455
|
## Release notes
|
|
441
456
|
|
|
457
|
+
### v3.25.0 (2026-09-04)
|
|
458
|
+
- **The statusline now shows which version is running** โ until now the version lived only in the table report's footer, so "which version am I on" meant running a full report. A `--version` flag was added alongside it.
|
|
459
|
+
- **A new release asks at session start** โ a statusline cannot open a dialog, so telling and asking are split. The statusline only reports (`โฌ v3.24.0 โ 3.25.0`); the SessionStart hook injects a line telling the model to ask the user whether to upgrade. On yes it runs `claude-token-saver upgrade`, which uses the package manager that installed this copy; on no, `update-check --dismiss` mutes that version until a newer one ships.
|
|
460
|
+
- **The check never blocks a render** โ the registry lookup runs at most once every 24h in a detached background process, and the render path only reads the cache file. A failed check still stamps its timestamp, so an offline machine backs off instead of retrying every render. Disable with `CTS_NO_UPDATE_CHECK=1` or `NO_UPDATE_NOTIFIER`.
|
|
461
|
+
|
|
442
462
|
### v3.21.0 (2026-08-22)
|
|
443
463
|
- **The install shows what it is about to enable, then asks** โ the harness 5 principles and the Korean writing guidance used to be switched on by the installer, leaving the user with the result rather than the choice. The install now prints the five principle headings, and for the Korean guidance what it changes plus its per-session cost and source, before asking. Locale detection is demoted from an answer to the question's default, so an English-locale machine used for Korean work can enable it on the spot.
|
|
444
464
|
- **Unattended installs behave exactly as before** โ npm `postinstall`, CI, piped stdin and `CTS_NO_INPUT=1` skip the question and apply the old defaults, because a blocked prompt hangs an install. `--yes` and `--no-input` force that path explicitly. A non-interactive install on a non-Korean machine now leaves the setting undecided instead of recording an answer nobody gave, so a later run at a terminal still asks.
|
package/README.md
CHANGED
|
@@ -129,6 +129,8 @@ npm i -g claude-token-saver
|
|
|
129
129
|
| `โฆ current` / `๐
weekly` | 5์๊ฐ / 7์ผ rate-limit ์๋ ์ฌ์ฉ๋ฅ + ๋ฆฌ์
์๊ฐ |
|
|
130
130
|
| `๐ฆ` | ์ปจํ
์คํธ ์ฌ์ฉ๋ฅ ์
๋๋ค(์: `Ctx 68% of 1M`). ์ฌ์ฉ๋ฅ ์ ๋ฐ๋ผ ๋
น์ยท๋
ธ๋์ยท๋นจ๊ฐ์์ผ๋ก ํ์ํฉ๋๋ค. ์ต์ ๋ชจ๋ธ์ 1M ์ปจํ
์คํธ๊ฐ ๊ธฐ๋ณธ์ด๊ณ ๋ณ๋ ์๊ธ์ด ๋ถ์ง ์์ง๋ง, ํ ํฐ๋ ์์ฒด๊ฐ ํด๋น ๋น์ฉ๊ณผ 5์๊ฐยท7์ผ ํ๋๋ฅผ ๋น ๋ฅด๊ฒ ์๋ชจ์ํต๋๋ค |
|
|
131
131
|
| `๐ฐ` | ํ๋กฌํํธ ์บ์๊ฐ ์ ์ฝํด ์ค ๋์ ๊ธ์ก์
๋๋ค. ์ฒซ์งธ ์ค์ `๐`(๋ชจ๋ธ ๋ผ์ฐํ
์ ๊ฐ์ก)์๋ **์๋ก ๋ค๋ฅธ ์์น์
๋๋ค** |
|
|
132
|
+
| `v3.24.0` | ์ง๊ธ ์คํ ์ค์ธ claude-token-saver์ ๋ฒ์ ์
๋๋ค. ์ต์ ์ด๋ฉด ํ์์ผ๋ก ์ค ๋์ ์กฐ์ฉํ ๋์
๋๋ค |
|
|
133
|
+
| `โฌ v3.24.0 โ 3.25.0` | ์ ๋ฒ์ ์ด ๋ฐฐํฌ๋์ด ์๋ค๋ ํ์์
๋๋ค. ์กฐ์น๊ฐ ํ์ํ ์นฉ์ด๋ฏ๋ก ์ค ์์ชฝ์ผ๋ก ์ฌ๋ผ์ต๋๋ค ([์
๋ฐ์ดํธ ์๋ด](#-์
๋ฐ์ดํธ-์๋ด)) |
|
|
132
134
|
|
|
133
135
|
๋ฌธ์ ๊ฐ ๊ฐ์ง๋๋ฉด **๊ฒฝ๊ณ ์นฉ์ ์ค ๋งจ ์์** ๋ถ์
๋๋ค.
|
|
134
136
|
|
|
@@ -160,10 +162,23 @@ Claude Code ์์์ `/claude-token-saver` Skill์ ์คํํ๊ฑฐ๋, ์นฉ์ ์
|
|
|
160
162
|
| `claude-token-saver korean on\|off\|status` | ํ๊ตญ์ด ๋ฌธ์ฒด ์ง์นจ์ ์ธ์
์์ ์ ์ฃผ์
ํ๊ณ , ์ฐ๊ธฐ ์์ ๊ฒ์ฌ๋ฅผ ํจ๊ป ์ค์นํฉ๋๋ค (์๋ ์ฐธ๊ณ ) |
|
|
161
163
|
| `claude-token-saver korean lint block\|warn\|off` | ์ฐ๊ธฐ ์์ ๊ฒ์ฌ๊ฐ ์๋ฐ์ ์ด๋ป๊ฒ ์ฒ๋ฆฌํ ์ง ์ ํฉ๋๋ค |
|
|
162
164
|
| `claude-token-saver korean lint scope all\|prose` | ๊ฒ์ฌ ๋ฒ์๋ฅผ ๋ชจ๋ ํ
์คํธ ํ์ผ๊ณผ ๋ฌธ์ ์ ์ฉ ์ฌ์ด์์ ๊ณ ๋ฆ
๋๋ค |
|
|
165
|
+
| `claude-token-saver --version` | ์ค์น๋ ๋ฒ์ ์ ์ถ๋ ฅํฉ๋๋ค |
|
|
166
|
+
| `claude-token-saver update-check` | ์ ๋ฒ์ ์ด ์๋์ง ํ์ธํฉ๋๋ค (`--refresh`๋ก ์ฆ์ ์กฐํ, `--dismiss`๋ก ๊ทธ ๋ฒ์ ์๋ด ๋๊ธฐ) |
|
|
167
|
+
| `claude-token-saver upgrade` | ์ค์น ๊ฒฝ๋ก์ ๋ง๋ ๋ช
๋ น์ผ๋ก ์ต์ ๋ฒ์ ์ ์ค์นํฉ๋๋ค (`--print`๋ก ์คํ ์์ด ๋ช
๋ น๋ง ํ์ธ) |
|
|
163
168
|
| `claude-token-saver install` | Skillยทstatusline ์๋ ๋ฑ๋ก |
|
|
164
169
|
|
|
165
170
|
์ถ๋ ฅ ์ธ์ด๋ `mode ko`์ `mode en`์ผ๋ก ์ ํํฉ๋๋ค. ๊ธฐ๋ณธ๊ฐ์ ์์ด์ด๋ฉฐ, statusline์ ์นฉ์ ์ธ์ ๋ ๊ธฐํธ๋ก ํ์ํฉ๋๋ค. ์ ์ฒด ์ต์
์ [์๋ฌธ README](./README.en.md#options)๋ฅผ ์ฐธ๊ณ ํ์ญ์์ค.
|
|
166
171
|
|
|
172
|
+
## โฌ ์
๋ฐ์ดํธ ์๋ด
|
|
173
|
+
|
|
174
|
+
statusline์ ๋ํ ์์๋ฅผ ๋์ธ ์ ์๊ณ , 300๋ฐ๋ฆฌ์ด๋ง๋ค ๋ค์ ๊ทธ๋ ค์ง๊ธฐ ๋๋ฌธ์ ๊ทธ๋ฆฌ๋ ์์ ์ ๋คํธ์ํฌ๋ฅผ ์ธ ์๋ ์์ต๋๋ค. ๊ทธ๋์ ์๋ด๋ฅผ ๋ ์ง์ ์ผ๋ก ๋๋์์ต๋๋ค.
|
|
175
|
+
|
|
176
|
+
- **statusline์ ์๋ฆฌ๊ธฐ๋ง ํฉ๋๋ค.** ์ต์ ๋ฒ์ ์ด๋ฉด ์ค ๋์ `v3.24.0`์ ํ์์ผ๋ก ์กฐ์ฉํ ํ์ํ๊ณ , ์ ๋ฒ์ ์ด ์์ผ๋ฉด `โฌ v3.24.0 โ 3.25.0`์ ์ค ์์ชฝ์ ๋
ธ๋์์ผ๋ก ์ฌ๋ฆฝ๋๋ค. ๋นจ๊ฐ์์ ์ฐ์ง ์์ต๋๋ค. ๋ฌด์๋ ๊ณ ์ฅ ๋ ์ํ๊ฐ ์๋๊ธฐ ๋๋ฌธ์
๋๋ค.
|
|
177
|
+
- **๋ฌป๋ ์ผ์ ์ธ์
์์์์ ํฉ๋๋ค.** ์ ์ธ์
์ด๋ `/clear` ์์ ์ SessionStart ํ
์ด "์ ๋ฒ์ ์ด ์์ผ๋ ์ฌ์ฉ์์๊ฒ ์
๊ทธ๋ ์ด๋ํ ์ง ๋ฌผ์ด๋ณด๋ผ"๋ ํ ์ค์ ๋ชจ๋ธ์๊ฒ ์ฃผ์
ํฉ๋๋ค. ๋ชจ๋ธ์ ์ฌ์ฉ์์๊ฒ ํ์ธํ ๋ค์๋ง `claude-token-saver upgrade`๋ฅผ ์คํํฉ๋๋ค. ๋ฌป์ง ์๊ณ ์ค์นํ์ง ์์ต๋๋ค.
|
|
178
|
+
- **๊ฑฐ์ ์ ๊ธฐ์ตํฉ๋๋ค.** ์ฌ์ฉ์๊ฐ ์์น ์์ผ๋ฉด `claude-token-saver update-check --dismiss`๋ก ๊ทธ ๋ฒ์ ์ ๋ฌป์ง ์๋๋ก ์ค์ ํฉ๋๋ค. ๋ ์๋ก์ด ๋ฒ์ ์ด ๋ฐฐํฌ๋๋ฉด ๋ค์ ๋ฌป์ต๋๋ค. statusline ์นฉ์ ๊ทธ๋๋ก ๋จ์ต๋๋ค. ๊ฑฐ์ ํ ๊ฒ์ ์ง๋ฌธ์ด์ง, ์ ๋ฒ์ ์ด ์๋ค๋ ์ฌ์ค์ด ์๋๊ธฐ ๋๋ฌธ์
๋๋ค.
|
|
179
|
+
|
|
180
|
+
๋ฒ์ ์กฐํ๋ 24์๊ฐ์ ํ ๋ฒ, ๋ถ๋ฆฌ๋ ๋ฐฑ๊ทธ๋ผ์ด๋ ํ๋ก์ธ์ค๊ฐ ์ํํ๊ณ ๊ฒฐ๊ณผ๋ง ํ์ผ์ ๋จ๊น๋๋ค(`update-check.json`). ์ด ๋ฐฉ์์ npm์ `update-notifier`๊ฐ ์ฐ๋ ๊ฒ๊ณผ ๊ฐ์ต๋๋ค. ๋คํธ์ํฌ๊ฐ ๋๊ฒจ ์์ด๋ ์คํจ ์๊ฐ์ ๊ธฐ๋กํด ๋๋ฏ๋ก ๋งค ๋ ๋๋ง๋ค ์ฌ์๋ํ์ง ์์ต๋๋ค. ํ์ธ ์์ฒด๋ฅผ ๋๋ ค๋ฉด ํ๊ฒฝ ๋ณ์ `CTS_NO_UPDATE_CHECK=1` ๋๋ `NO_UPDATE_NOTIFIER`๋ฅผ ์ค์ ํ์ญ์์ค.
|
|
181
|
+
|
|
167
182
|
## ๐
ท Harness ๋ชจ๋
|
|
168
183
|
|
|
169
184
|
๋ค์ฏ ์์น(Ratchet ยท Evidence ยท PEV ยท Structured Task ยท Default Safe Path)์ ํ ์ค ๋ช
๋ น์ผ๋ก `CLAUDE.md`์ ์
์
ํ๊ณ statusline์ด `๐
ท 5/5`๋ก ์ ์ํํฉ๋๋ค. ๊ฐ์ ์๋ฌ๊ฐ ๋ฐ๋ณต๋๋ฉด `๐
ทโ ratchet?` ์๋ฆผ์ด ๋ ์ ๋ฃฐ๋ก ์น๊ฒฉํ ์ ์์ต๋๋ค.
|
|
@@ -405,6 +420,11 @@ npm uninstall -g claude-cache-monitor && npm i -g claude-token-saver
|
|
|
405
420
|
|
|
406
421
|
## ๋ฆด๋ฆฌ์ค ๋
ธํธ
|
|
407
422
|
|
|
423
|
+
### v3.25.0 (2026-09-04)
|
|
424
|
+
- **statusline์ ์คํ ์ค์ธ ๋ฒ์ ์ ํ์ํฉ๋๋ค.** ์ง๊ธ๊น์ง๋ ํ ํ์ ๋ฆฌํฌํธ์ ๊ฐ์ฃผ์๋ง ๋ฒ์ ์ด ์์๊ธฐ ๋๋ฌธ์, ์ด๋ค ๋ฒ์ ์ด ๋๋์ง ํ์ธํ๋ ค๋ฉด ์ ์ฒด ๋ฆฌํฌํธ๋ฅผ ์คํํด์ผ ํ์ต๋๋ค. `--version` ํ๋๊ทธ๋ ํจ๊ป ์ถ๊ฐํ์ต๋๋ค.
|
|
425
|
+
- **์ ๋ฒ์ ์ด ๋์ค๋ฉด ์ธ์
์์์ ๋ฌผ์ด๋ด
๋๋ค.** statusline์ ๋ํ ์์๋ฅผ ๋์ธ ์ ์์ผ๋ฏ๋ก, ์๋ฆผ๊ณผ ์ง๋ฌธ์ ๋๋์์ต๋๋ค. statusline์ `โฌ v3.24.0 โ 3.25.0`์ผ๋ก ์๋ฆฌ๊ธฐ๋ง ํ๊ณ , ์ค์ ์ง๋ฌธ์ SessionStart ํ
์ด ๋ชจ๋ธ์๊ฒ "์ฌ์ฉ์์๊ฒ ์
๊ทธ๋ ์ด๋ ์ฌ๋ถ๋ฅผ ํ์ธํ๋ผ"๊ณ ์ฃผ์
ํด์ ์ด๋ฃจ์ด์ง๋๋ค. ์น๋ํ๋ฉด `claude-token-saver upgrade`๊ฐ ์ค์น ๊ฒฝ๋ก์ ๋ง๋ ๋ช
๋ น์ ์คํํ๊ณ , ๊ฑฐ์ ํ๋ฉด `update-check --dismiss`๊ฐ ๊ทธ ๋ฒ์ ์ ๋ ๋ฌป์ง ์๋๋ก ๊ธฐ๋กํฉ๋๋ค.
|
|
426
|
+
- **๋ฒ์ ํ์ธ์ด ๋ ๋๋ฅผ ๋ถ์ก์ง ์์ต๋๋ค.** ์กฐํ๋ 24์๊ฐ์ ํ ๋ฒ ๋ถ๋ฆฌ๋ ๋ฐฑ๊ทธ๋ผ์ด๋ ํ๋ก์ธ์ค๊ฐ ์ํํ๊ณ , ๋ ๋ ๊ฒฝ๋ก๋ ์บ์ ํ์ผ๋ง ์ฝ์ต๋๋ค. ํ์ธ์ ์คํจํด๋ ์๊ฐ์ ๊ธฐ๋กํ๋ฏ๋ก ์คํ๋ผ์ธ์์ ๋งค ๋ ๋๋ง๋ค ์ฌ์๋ํ์ง ์์ต๋๋ค. `CTS_NO_UPDATE_CHECK=1` ๋๋ `NO_UPDATE_NOTIFIER`๋ก ๋ ์ ์์ต๋๋ค.
|
|
427
|
+
|
|
408
428
|
### v3.21.0 (2026-08-22)
|
|
409
429
|
- **์ค์น๊ฐ ๋ฌด์์ ์ผ๋์ง ๋ณด์ฌ ์ค ๋ค ๋ฌผ์ด๋ด
๋๋ค** โ ์ง๊ธ๊น์ง๋ ๐
ท Harness 5์์น๊ณผ ํ๊ตญ์ด ๋ฌธ์ฒด ์ง์นจ์ ์ค์น๊ฐ ์ค์ค๋ก ์ผฐ๊ณ , ์ฌ์ฉ์๋ ๊ฒฐ๊ณผ๋ง ๋ณผ ์ ์์์ต๋๋ค. ์ด์ harness๋ ๋ค์ฏ ์์น์ ์ ๋ชฉ์, ํ๊ตญ์ด ์ง์นจ์ ๋ฐ๊พธ๋ ๋ด์ฉ๊ณผ ์ธ์
๋น ๋น์ฉ๊ณผ ์ถ์ฒ๋ฅผ ๋จผ์ ์ถ๋ ฅํ ๋ค์ ์ผค์ง ๋ฌป์ต๋๋ค. ๋ก์บ ๊ฐ์ง๋ ๋ต์ด ์๋๋ผ ์ง๋ฌธ์ ๊ธฐ๋ณธ๊ฐ์ผ๋ก ๋ด๋ ค์์ผ๋ฏ๋ก, ์์ด ๋ก์บ์์ ํ๊ตญ์ด๋ก ์์
ํ๋ ๊ฒฝ์ฐ์๋ ๊ทธ ์๋ฆฌ์์ ์ผค ์ ์์ต๋๋ค.
|
|
410
430
|
- **์ฌ๋์ด ๋ถ์ด ์์ง ์์ ์ค์น๋ ์์ ๋๋ก ๋์ํฉ๋๋ค** โ npm์ `postinstall`, CI, ํ์ดํ ์
๋ ฅ, `CTS_NO_INPUT=1`์์๋ ์ง๋ฌธ์ ๊ฑด๋๋ฐ๊ณ ๊ธฐ์กด ๊ธฐ๋ณธ๊ฐ์ ๊ทธ๋๋ก ์ ์ฉํฉ๋๋ค. ํ๋กฌํํธ๊ฐ ๋ฉ์ถฐ ์๋ฉด ์ค์น๊ฐ ๊ฑธ๋ฆฌ๊ธฐ ๋๋ฌธ์
๋๋ค. `--yes`์ `--no-input`์ผ๋ก ์ง์ ๊ฐ์ ํ ์๋ ์์ต๋๋ค. ๋น๋ํํ์ด๋ฉด์ ๋ก์บ์ด ํ๊ตญ์ด๊ฐ ์๋ ๊ฒฝ์ฐ์๋ ์ค์ ์ ์ ์ฅํ์ง ์๊ณ ๋ฏธ๊ฒฐ์ ์ผ๋ก ๋จ๊ฒจ ๋๋ฏ๋ก, ๋์ค์ ํฐ๋ฏธ๋์์ ์ค์นํ๋ฉด ๊ทธ๋ ๋ฌผ์ด๋ด
๋๋ค.
|
package/bin/cli.js
CHANGED
|
@@ -52,6 +52,7 @@ import { estimateCost } from '../src/cost.js';
|
|
|
52
52
|
import { chipForIssues } from '../src/advice.js';
|
|
53
53
|
import { debug } from '../src/debug.js';
|
|
54
54
|
import { createArgs } from '../src/cli-args.js';
|
|
55
|
+
import { updateStatus, maybeSpawnUpdateCheck } from '../src/update-check.js';
|
|
55
56
|
|
|
56
57
|
const args = process.argv.slice(2);
|
|
57
58
|
|
|
@@ -66,6 +67,22 @@ const PKG_VERSION = (() => {
|
|
|
66
67
|
|
|
67
68
|
const { getArg, hasFlag, numArg } = createArgs(args);
|
|
68
69
|
|
|
70
|
+
/**
|
|
71
|
+
* Version/update state for the statusline chip. Reads a cache file and, when
|
|
72
|
+
* that cache has aged past the check interval, kicks a detached child to
|
|
73
|
+
* refresh it for a later render. Never awaits the network, never throws into
|
|
74
|
+
* the render: a registry outage must not cost the statusline its other chips.
|
|
75
|
+
*/
|
|
76
|
+
function readUpdateChip() {
|
|
77
|
+
try {
|
|
78
|
+
maybeSpawnUpdateCheck(PKG_VERSION);
|
|
79
|
+
return updateStatus(PKG_VERSION);
|
|
80
|
+
} catch (e) {
|
|
81
|
+
debug('update-check:chip', e);
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
69
86
|
async function main() {
|
|
70
87
|
// Subcommand: last โ print the most recent warning + how to handle it.
|
|
71
88
|
// Designed for the auto-trigger skill so the user immediately sees
|
|
@@ -166,6 +183,37 @@ async function main() {
|
|
|
166
183
|
return (await import('../src/commands/compact-window.js')).run({ args, hasFlag });
|
|
167
184
|
}
|
|
168
185
|
|
|
186
|
+
// `--version` / `-v` โ the flag every CLI is expected to answer. Until now
|
|
187
|
+
// the version was only visible in the table view's footer, which meant
|
|
188
|
+
// "which version am I on" required running a full report.
|
|
189
|
+
if (hasFlag('--version') || hasFlag('-v')) {
|
|
190
|
+
console.log(PKG_VERSION);
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Subcommand: update-check โ the registry lookup behind the โฌ statusline
|
|
195
|
+
// chip and the session-start upgrade offer.
|
|
196
|
+
// claude-token-saver update-check # print cached status
|
|
197
|
+
// claude-token-saver update-check --refresh # hit the registry now (detached child uses this)
|
|
198
|
+
// claude-token-saver update-check --dismiss # stop offering THIS version at session start
|
|
199
|
+
if (args[0] === 'update-check') {
|
|
200
|
+
return (await import('../src/commands/update-check.js')).run({
|
|
201
|
+
hasFlag,
|
|
202
|
+
version: PKG_VERSION,
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Subcommand: upgrade โ run the install command that matches how this copy
|
|
207
|
+
// got here, then confirm the new version.
|
|
208
|
+
// claude-token-saver upgrade # install the latest release
|
|
209
|
+
// claude-token-saver upgrade --print # just show the command, run nothing
|
|
210
|
+
if (args[0] === 'upgrade') {
|
|
211
|
+
return (await import('../src/commands/upgrade.js')).run({
|
|
212
|
+
hasFlag,
|
|
213
|
+
version: PKG_VERSION,
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
|
|
169
217
|
// Hook management
|
|
170
218
|
if (hasFlag('--install-hook')) {
|
|
171
219
|
const { installHook } = await import('../src/hook-manager.js');
|
|
@@ -319,7 +367,7 @@ async function main() {
|
|
|
319
367
|
} catch (e) { debug('caps-cache:persist', e); }
|
|
320
368
|
}
|
|
321
369
|
console.log(formatNoSession(
|
|
322
|
-
{ caps, model, windowLabel },
|
|
370
|
+
{ caps, model, windowLabel, version: PKG_VERSION, update: readUpdateChip() },
|
|
323
371
|
{ color: colorOk, mode: isIcon ? 'icon' : 'text' },
|
|
324
372
|
));
|
|
325
373
|
return;
|
|
@@ -471,6 +519,8 @@ async function main() {
|
|
|
471
519
|
anomalies,
|
|
472
520
|
cost,
|
|
473
521
|
options: { days, windowHours, windowLabel, version: PKG_VERSION },
|
|
522
|
+
// Cached-only; the background refresh it may trigger lands on a later render.
|
|
523
|
+
update: format === 'statusline' ? readUpdateChip() : null,
|
|
474
524
|
lastActivity,
|
|
475
525
|
spikeReport,
|
|
476
526
|
contextWindow,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-token-saver",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.25.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": {
|
|
@@ -2,9 +2,23 @@
|
|
|
2
2
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
+
import { readFileSync } from 'node:fs';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { dirname, join } from 'node:path';
|
|
5
8
|
import { readStdinJson } from '../stdin-payload.js';
|
|
6
9
|
import { debug } from '../debug.js';
|
|
7
10
|
|
|
11
|
+
/** This package's own version โ the baseline the upgrade offer compares against. */
|
|
12
|
+
function readPackageVersion() {
|
|
13
|
+
try {
|
|
14
|
+
const pkg = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'package.json');
|
|
15
|
+
return JSON.parse(readFileSync(pkg, 'utf8')).version || null;
|
|
16
|
+
} catch (e) {
|
|
17
|
+
debug('route-scan:pkg-version', e);
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
8
22
|
export async function run({ args, hasFlag, numArg }) {
|
|
9
23
|
const rs = await import('../route-scan.js');
|
|
10
24
|
const { userLanguage } = await import('../config.js');
|
|
@@ -190,10 +204,44 @@ export async function run({ args, hasFlag, numArg }) {
|
|
|
190
204
|
koreanBlock = koreanStyleInjection();
|
|
191
205
|
} catch (e) { debug('route-scan:korean-style', e); /* style is optional */ }
|
|
192
206
|
|
|
207
|
+
// Upgrade offer. A statusline cannot open a dialog, so session start is
|
|
208
|
+
// where the *asking* happens: the model gets one line telling it a newer
|
|
209
|
+
// version exists and to ask before installing anything. Cached read only
|
|
210
|
+
// โ the refresh runs detached off the statusline path, never here.
|
|
211
|
+
let updateBlock = null;
|
|
212
|
+
try {
|
|
213
|
+
const { updateStatus, maybeSpawnUpdateCheck } = await import('../update-check.js');
|
|
214
|
+
const pkgVersion = readPackageVersion();
|
|
215
|
+
// Session start is the one moment where a stale cache matters most, so
|
|
216
|
+
// kick the detached refresh here too. It lands in time for the
|
|
217
|
+
// statusline and for the next session, not for this line.
|
|
218
|
+
if (pkgVersion) maybeSpawnUpdateCheck(pkgVersion);
|
|
219
|
+
const u = pkgVersion ? updateStatus(pkgVersion) : null;
|
|
220
|
+
// `dismissed` is the whole point of asking once: a user who said no to
|
|
221
|
+
// this version must not be asked again every `/clear`.
|
|
222
|
+
if (u && u.available && !u.dismissed) {
|
|
223
|
+
updateBlock = lang === 'ko'
|
|
224
|
+
? [
|
|
225
|
+
`[claude-token-saver update] ์ ๋ฒ์ ์ด ๋์ ์์ต๋๋ค: v${u.current} โ ${u.latest}.`,
|
|
226
|
+
'์ฌ์ฉ์์๊ฒ ์ง๊ธ ์
๊ทธ๋ ์ด๋ํ ์ง ๋ฌผ์ด๋ณด๊ณ , ์น๋ํ๋ฉด ์๋ ๋ช
๋ น์ ์คํํ์ญ์์ค. ๋ฌป์ง ์๊ณ ์ค์นํ์ง๋ ๋ง์ญ์์ค.',
|
|
227
|
+
' claude-token-saver upgrade # ์
๊ทธ๋ ์ด๋ ์คํ',
|
|
228
|
+
' claude-token-saver update-check --dismiss # ์ฌ์ฉ์๊ฐ ์์น ์์ผ๋ฉด (๋ค์ ๋ฒ์ ์ด ๋์ฌ ๋๊น์ง ๋ค์ ๋ฌป์ง ์์ต๋๋ค)',
|
|
229
|
+
].join('\n')
|
|
230
|
+
: [
|
|
231
|
+
`[claude-token-saver update] A newer version is available: v${u.current} โ ${u.latest}.`,
|
|
232
|
+
'Ask the user whether to upgrade now, and run the command below only if they agree. Do not install without asking.',
|
|
233
|
+
' claude-token-saver upgrade # perform the upgrade',
|
|
234
|
+
' claude-token-saver update-check --dismiss # if they decline (stays quiet until a newer release)',
|
|
235
|
+
].join('\n');
|
|
236
|
+
}
|
|
237
|
+
} catch (e) { debug('route-scan:update-check', e); /* the offer is optional */ }
|
|
238
|
+
|
|
193
239
|
if (open.length === 0 && reviewRules.length === 0) {
|
|
240
|
+
if (updateBlock) console.log(updateBlock);
|
|
194
241
|
if (koreanBlock) console.log(koreanBlock);
|
|
195
242
|
return; // nothing else to inject
|
|
196
243
|
}
|
|
244
|
+
if (updateBlock) console.log(updateBlock);
|
|
197
245
|
// This text is injected straight into the model's context, so it must
|
|
198
246
|
// follow the user's configured language โ a Korean-only briefing in an
|
|
199
247
|
// English session steers the whole first response into Korean.
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subcommand: update-check โ everything around "is a newer version out".
|
|
3
|
+
*
|
|
4
|
+
* claude-token-saver update-check # print the cached answer
|
|
5
|
+
* claude-token-saver update-check --refresh # ask the registry now
|
|
6
|
+
* claude-token-saver update-check --refresh --quiet
|
|
7
|
+
* # what the detached background child runs
|
|
8
|
+
* claude-token-saver update-check --dismiss # stop offering this version at session start
|
|
9
|
+
*
|
|
10
|
+
* The statusline never calls the --refresh path directly; it spawns this
|
|
11
|
+
* command detached so the render itself stays offline and instant.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export async function run({ hasFlag, version }) {
|
|
15
|
+
const {
|
|
16
|
+
updateStatus,
|
|
17
|
+
refreshUpdateState,
|
|
18
|
+
dismissUpdate,
|
|
19
|
+
upgradeCommand,
|
|
20
|
+
updateCheckDisabled,
|
|
21
|
+
updateStatePath,
|
|
22
|
+
} = await import('../update-check.js');
|
|
23
|
+
const { userLanguage } = await import('../config.js');
|
|
24
|
+
const lang = userLanguage();
|
|
25
|
+
const quiet = hasFlag('--quiet');
|
|
26
|
+
|
|
27
|
+
if (hasFlag('--refresh')) {
|
|
28
|
+
const res = await refreshUpdateState(version);
|
|
29
|
+
if (quiet) return;
|
|
30
|
+
if (!res.ok) {
|
|
31
|
+
console.error(lang === 'ko'
|
|
32
|
+
? `์ต์ ๋ฒ์ ์ ํ์ธํ์ง ๋ชปํ์ต๋๋ค: ${res.error}`
|
|
33
|
+
: `Could not reach the registry: ${res.error}`);
|
|
34
|
+
process.exit(1);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (hasFlag('--dismiss')) {
|
|
39
|
+
const s = updateStatus(version);
|
|
40
|
+
if (s.latest) dismissUpdate(s.latest);
|
|
41
|
+
console.log(lang === 'ko'
|
|
42
|
+
? `${s.latest || '์ต์ '} ๋ฒ์ ์๋ด๋ฅผ ๋๋๋ค. ๊ทธ ๋ค์ ๋ฒ์ ์ด ๋์ค๋ฉด ๋ค์ ์๋ดํฉ๋๋ค.`
|
|
43
|
+
: `Muted the ${s.latest || 'latest'} notice. The next release after it will ask again.`);
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (updateCheckDisabled()) {
|
|
48
|
+
console.log(lang === 'ko'
|
|
49
|
+
? `ํ์ฌ ๋ฒ์ v${version} โ ์
๋ฐ์ดํธ ํ์ธ์ด ํ๊ฒฝ ๋ณ์๋ก ๊บผ์ ธ ์์ต๋๋ค (CTS_NO_UPDATE_CHECK / NO_UPDATE_NOTIFIER).`
|
|
50
|
+
: `v${version} โ update checks are disabled by env var (CTS_NO_UPDATE_CHECK / NO_UPDATE_NOTIFIER).`);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const s = updateStatus(version);
|
|
55
|
+
if (s.available) {
|
|
56
|
+
console.log(lang === 'ko'
|
|
57
|
+
? `์ ๋ฒ์ ์ด ์์ต๋๋ค: v${version} โ ${s.latest}`
|
|
58
|
+
: `Update available: v${version} โ ${s.latest}`);
|
|
59
|
+
console.log(lang === 'ko'
|
|
60
|
+
? `์
๊ทธ๋ ์ด๋: ${upgradeCommand()} (๋๋ claude-token-saver upgrade)`
|
|
61
|
+
: `Upgrade with: ${upgradeCommand()} (or: claude-token-saver upgrade)`);
|
|
62
|
+
if (s.dismissed) {
|
|
63
|
+
console.log(lang === 'ko'
|
|
64
|
+
? '์ด ๋ฒ์ ์ ์ฌ์ฉ์๊ฐ ํ ๋ฒ ๋๊ธด ์ํ๋ผ, ์ธ์
์์ ์์๋ ๋ค์ ๋ฌป์ง ์์ต๋๋ค.'
|
|
65
|
+
: 'This version was dismissed, so the session-start offer stays quiet for it.');
|
|
66
|
+
}
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
console.log(lang === 'ko'
|
|
70
|
+
? `์ต์ ๋ฒ์ ์ ์ฐ๊ณ ์์ต๋๋ค (v${version}).`
|
|
71
|
+
: `Up to date (v${version}).`);
|
|
72
|
+
if (!s.latest) {
|
|
73
|
+
console.log(lang === 'ko'
|
|
74
|
+
? `์์ง ํ์ธ๋ ๊ธฐ๋ก์ด ์์ต๋๋ค. ์ง๊ธ ํ์ธํ๋ ค๋ฉด: claude-token-saver update-check --refresh (๊ธฐ๋ก ํ์ผ: ${updateStatePath()})`
|
|
75
|
+
: `No check has completed yet. Run: claude-token-saver update-check --refresh (state: ${updateStatePath()})`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subcommand: upgrade โ install the latest release with the package manager
|
|
3
|
+
* that put this copy on disk.
|
|
4
|
+
*
|
|
5
|
+
* claude-token-saver upgrade # refresh the check, then install
|
|
6
|
+
* claude-token-saver upgrade --print # show the command, run nothing
|
|
7
|
+
*
|
|
8
|
+
* This is the command the model runs after the user says yes to the
|
|
9
|
+
* session-start offer, so it prints the exact command it is about to execute
|
|
10
|
+
* before executing it: an install that writes outside the project should never
|
|
11
|
+
* be a black box.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { spawn } from 'node:child_process';
|
|
15
|
+
|
|
16
|
+
function runCommand(cmd) {
|
|
17
|
+
return new Promise((resolve) => {
|
|
18
|
+
// Shell form: the upgrade command is a fixed string we composed ourselves
|
|
19
|
+
// (no user input reaches it), and `npm install -g` needs the user's PATH
|
|
20
|
+
// resolution to find the same npm that installed us.
|
|
21
|
+
const child = spawn(cmd, { shell: true, stdio: 'inherit' });
|
|
22
|
+
child.on('close', (code) => resolve(code === null ? 1 : code));
|
|
23
|
+
child.on('error', () => resolve(1));
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function run({ hasFlag, version }) {
|
|
28
|
+
const { updateStatus, refreshUpdateState, upgradeCommand } = await import('../update-check.js');
|
|
29
|
+
const { userLanguage } = await import('../config.js');
|
|
30
|
+
const lang = userLanguage();
|
|
31
|
+
const cmd = upgradeCommand();
|
|
32
|
+
|
|
33
|
+
if (hasFlag('--print')) {
|
|
34
|
+
console.log(cmd);
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Refresh first so the "already latest" answer is trustworthy rather than up
|
|
39
|
+
// to 24h stale โ an upgrade is a deliberate, interactive action, so one
|
|
40
|
+
// network round trip is fine here.
|
|
41
|
+
await refreshUpdateState(version);
|
|
42
|
+
const s = updateStatus(version);
|
|
43
|
+
if (!s.available) {
|
|
44
|
+
console.log(lang === 'ko'
|
|
45
|
+
? `์ด๋ฏธ ์ต์ ๋ฒ์ ์
๋๋ค (v${version}). ์ค์นํ ๊ฒ์ด ์์ต๋๋ค.`
|
|
46
|
+
: `Already on the latest version (v${version}). Nothing to install.`);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
console.log(lang === 'ko'
|
|
51
|
+
? `v${version} โ ${s.latest} ๋ก ์
๊ทธ๋ ์ด๋ํฉ๋๋ค.`
|
|
52
|
+
: `Upgrading v${version} โ ${s.latest}.`);
|
|
53
|
+
console.log(`$ ${cmd}`);
|
|
54
|
+
const code = await runCommand(cmd);
|
|
55
|
+
if (code !== 0) {
|
|
56
|
+
console.error(lang === 'ko'
|
|
57
|
+
? `์
๊ทธ๋ ์ด๋ ๋ช
๋ น์ด ์ข
๋ฃ ์ฝ๋ ${code}๋ก ์คํจํ์ต๋๋ค. ์ ์ถ๋ ฅ์ ํ์ธํ๊ณ , ๊ถํ ๋ฌธ์ ๋ผ๋ฉด sudo ์์ด ์ค์น๋๋ ์ ์ญ ๊ฒฝ๋ก์ธ์ง ์ ๊ฒํ์ญ์์ค.`
|
|
58
|
+
: `Upgrade command failed with exit code ${code}. Check the output above; if it is a permissions error, verify your global install prefix.`);
|
|
59
|
+
process.exit(code);
|
|
60
|
+
}
|
|
61
|
+
// The freshly installed copy is a different file on disk, so this process
|
|
62
|
+
// still reports the old version. Refresh the cache against the new latest so
|
|
63
|
+
// the statusline chip clears on the next render instead of lingering.
|
|
64
|
+
await refreshUpdateState(s.latest);
|
|
65
|
+
console.log(lang === 'ko'
|
|
66
|
+
? `์ค์น๊ฐ ๋๋ฌ์ต๋๋ค. ์ ์
ธ์์ claude-token-saver --version ์ผ๋ก ${s.latest} ์ธ์ง ํ์ธํ์ญ์์ค.`
|
|
67
|
+
: `Done. In a fresh shell, run claude-token-saver --version to confirm ${s.latest}.`);
|
|
68
|
+
}
|
|
@@ -157,6 +157,33 @@ function buildKoreanSeg(c, isIcon, verbose) {
|
|
|
157
157
|
}
|
|
158
158
|
}
|
|
159
159
|
|
|
160
|
+
/**
|
|
161
|
+
* Version segment builder โ "which copy of this tool am I looking at", plus
|
|
162
|
+
* the upgrade nudge when a newer one has been published.
|
|
163
|
+
*
|
|
164
|
+
* Two states, deliberately different in weight:
|
|
165
|
+
* - up to date โ `v3.24.0` in gray. Identity context, not news.
|
|
166
|
+
* - update available โ `โฌ v3.24.0 โ 3.25.0` in yellow. Same tone as the
|
|
167
|
+
* other "you should do something eventually" chips, never red: nothing is
|
|
168
|
+
* broken, and a permanently-red statusline trains the eye to ignore red.
|
|
169
|
+
*
|
|
170
|
+
* A statusline cannot open a dialog, so the *asking* happens at session start
|
|
171
|
+
* (see route-scan --hook, which briefs the model to offer the upgrade). This
|
|
172
|
+
* chip is the persistent reminder between those offers, which is why it keeps
|
|
173
|
+
* rendering after the user declines โ declining hides the session-start
|
|
174
|
+
* question, not the fact that a new version exists.
|
|
175
|
+
*/
|
|
176
|
+
function buildVersionSeg(version, update, c, isIcon, verbose) {
|
|
177
|
+
if (!version) return null;
|
|
178
|
+
if (update && update.available && update.latest) {
|
|
179
|
+
const body = verbose
|
|
180
|
+
? `Update v${version} โ ${update.latest}`
|
|
181
|
+
: `v${version} โ ${update.latest}`;
|
|
182
|
+
return `${c(YELLOW)}${isIcon ? 'โฌ ' : ''}${body}${c(RESET)}`;
|
|
183
|
+
}
|
|
184
|
+
return `${c(GRAY)}v${version}${c(RESET)}`;
|
|
185
|
+
}
|
|
186
|
+
|
|
160
187
|
/**
|
|
161
188
|
* Harness ๐
ท segment builder โ shared by the full report and the no-session
|
|
162
189
|
* fallback line. Best-effort: never throws into the statusline (corrupted
|
|
@@ -209,18 +236,21 @@ function buildCapWarnSeg(capWarn, c, isIcon) {
|
|
|
209
236
|
* just because the user has been idle past the window โ so cap-warn,
|
|
210
237
|
* harness, and model chips still render around the "no session data" note.
|
|
211
238
|
*/
|
|
212
|
-
export function formatNoSession({ caps = null, model = null, windowLabel = '' } = {}, { color = true, mode = 'icon' } = {}) {
|
|
239
|
+
export function formatNoSession({ caps = null, model = null, windowLabel = '', version = '', update = null } = {}, { color = true, mode = 'icon' } = {}) {
|
|
213
240
|
const c = (v) => (color ? v : '');
|
|
214
241
|
const isIcon = mode === 'icon';
|
|
215
242
|
const segs = [];
|
|
216
243
|
const capSeg = buildCapWarnSeg(pickCapWarn(caps), c, isIcon);
|
|
217
244
|
if (capSeg) segs.push(capSeg);
|
|
245
|
+
const versionSeg = buildVersionSeg(version, update, c, isIcon, false);
|
|
246
|
+
if (versionSeg && update && update.available) segs.push(versionSeg);
|
|
218
247
|
const harnessSeg = buildHarnessSeg(c, isIcon);
|
|
219
248
|
if (harnessSeg) segs.push(harnessSeg);
|
|
220
249
|
if (typeof model === 'string' && model.length > 0) {
|
|
221
250
|
segs.push(isIcon ? `${c(MAGENTA)}๐ค ${model}${c(RESET)}` : `${c(MAGENTA)}${model}${c(RESET)}`);
|
|
222
251
|
}
|
|
223
252
|
segs.push(`${c(GRAY)}๐ง no session data${windowLabel ? ` ยท ${windowLabel}` : ''}${c(RESET)}`);
|
|
253
|
+
if (versionSeg && !(update && update.available)) segs.push(versionSeg);
|
|
224
254
|
return segs.join(' ยท ') + (color ? '\x1b[K' : '');
|
|
225
255
|
}
|
|
226
256
|
|
|
@@ -231,7 +261,7 @@ export function formatNoSession({ caps = null, model = null, windowLabel = '' }
|
|
|
231
261
|
* @param {boolean} [opts.verbose=false] - longer layout with labels
|
|
232
262
|
* @param {boolean} [opts.timer=true] - show TTL countdown segment
|
|
233
263
|
* @param {'text'|'icon'} [opts.mode='text'] - label style. 'icon' uses ๐ง โณ ๐ฐ instead of word labels.
|
|
234
|
-
* @param {string[]|null} [opts.segments] - whitelist of segments to render. Names: cap-warn, spike, harness, korean, model, hit, ttl, saved, delegated, ctx, period, plus per-window keys (`five_hour`, `seven_day`, โฆ). `5h`/`7d` are kept as aliases for back-compat. Null/undefined = all.
|
|
264
|
+
* @param {string[]|null} [opts.segments] - whitelist of segments to render. Names: cap-warn, spike, version, harness, korean, model, hit, ttl, saved, delegated, ctx, period, plus per-window keys (`five_hour`, `seven_day`, โฆ). `5h`/`7d` are kept as aliases for back-compat. Null/undefined = all.
|
|
235
265
|
* @param {boolean} [opts.singleLine=false] - force the legacy one-line layout. By default, when the delegation ledger has lifetime savings, the routing totals lead on their own first line and everything else moves to line 2 (Claude Code renders multi-line statuslines; `--single-line` is the escape hatch for terminals that only show the first line).
|
|
236
266
|
*/
|
|
237
267
|
export function formatReport(data, { color = true, verbose = false, timer = true, mode = 'text', segments = null, singleLine = false } = {}) {
|
|
@@ -446,6 +476,11 @@ export function formatReport(data, { color = true, verbose = false, timer = true
|
|
|
446
476
|
// a missing section at a glance and know to run `harness init`.
|
|
447
477
|
const harnessSeg = buildHarnessSeg(c, isIcon);
|
|
448
478
|
|
|
479
|
+
// Version / upgrade chip. Read from a cache written by a detached background
|
|
480
|
+
// check โ this render path never touches the network.
|
|
481
|
+
const versionSeg = buildVersionSeg(options.version, data.update, c, isIcon, verbose);
|
|
482
|
+
const updateAvailable = !!(data.update && data.update.available);
|
|
483
|
+
|
|
449
484
|
// Korean-style chip โ rendered only when the session-start injection is on.
|
|
450
485
|
const koreanSeg = buildKoreanSeg(c, isIcon, verbose);
|
|
451
486
|
|
|
@@ -550,6 +585,10 @@ export function formatReport(data, { color = true, verbose = false, timer = true
|
|
|
550
585
|
const segs = [];
|
|
551
586
|
if (capWarnSeg && want('cap-warn')) segs.push(capWarnSeg);
|
|
552
587
|
if (spikeSeg && want('spike')) segs.push(spikeSeg);
|
|
588
|
+
// An available upgrade rides up front with the other "act on this" chips.
|
|
589
|
+
// When there is nothing to upgrade to, the same segment is pure identity and
|
|
590
|
+
// sits at the tail instead (pushed after `saved`, below).
|
|
591
|
+
if (versionSeg && updateAvailable && want('version')) segs.push(versionSeg);
|
|
553
592
|
if (harnessSeg && want('harness')) segs.push(harnessSeg);
|
|
554
593
|
if (koreanSeg && want('korean')) segs.push(koreanSeg);
|
|
555
594
|
if (modelSeg && want('model')) segs.push(modelSeg);
|
|
@@ -569,6 +608,7 @@ export function formatReport(data, { color = true, verbose = false, timer = true
|
|
|
569
608
|
// it sits near the tail. The period label closes the line as a quiet
|
|
570
609
|
// timeframe footer.
|
|
571
610
|
if (want('saved')) segs.push(saveSeg);
|
|
611
|
+
if (versionSeg && !updateAvailable && want('version')) segs.push(versionSeg);
|
|
572
612
|
if (want('period')) segs.push(periodSeg);
|
|
573
613
|
// Trailing erase-to-end-of-line so any leftover characters from a previous
|
|
574
614
|
// (longer) statusline render don't bleed into ours. \x1b[K is the standard
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* update-check โ "is there a newer claude-token-saver?", answered without ever
|
|
3
|
+
* blocking a render.
|
|
4
|
+
*
|
|
5
|
+
* The statusline command runs every ~300ms, so a network call on that path is
|
|
6
|
+
* out of the question. We use the same shape sindresorhus/update-notifier
|
|
7
|
+
* settled on: the foreground only ever READS a cached answer, and when that
|
|
8
|
+
* answer is older than the check interval it spawns a detached, unref'd child
|
|
9
|
+
* that refreshes the cache for the *next* render. Nothing awaits the network.
|
|
10
|
+
*
|
|
11
|
+
* State lives next to the other user-data files:
|
|
12
|
+
* { checkedAt: <ms>, latest: "3.25.0", current: "3.24.0",
|
|
13
|
+
* dismissedVersion: "3.25.0"|undefined }
|
|
14
|
+
*
|
|
15
|
+
* Opt out with CTS_NO_UPDATE_CHECK=1 or NO_UPDATE_NOTIFIER (the de-facto
|
|
16
|
+
* standard env var โ anyone who set it for other CLIs meant us too).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
20
|
+
import { spawn } from 'node:child_process';
|
|
21
|
+
import { join, dirname } from 'node:path';
|
|
22
|
+
import { fileURLToPath } from 'node:url';
|
|
23
|
+
import { userDataDir } from './paths.js';
|
|
24
|
+
import { debug } from './debug.js';
|
|
25
|
+
|
|
26
|
+
const PKG_NAME = 'claude-token-saver';
|
|
27
|
+
const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24h โ the registry is not a health endpoint
|
|
28
|
+
const FETCH_TIMEOUT_MS = 5000;
|
|
29
|
+
|
|
30
|
+
export function updateStatePath() {
|
|
31
|
+
return join(userDataDir(), 'update-check.json');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function updateCheckDisabled() {
|
|
35
|
+
return process.env.CTS_NO_UPDATE_CHECK === '1' || !!process.env.NO_UPDATE_NOTIFIER;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function readUpdateState() {
|
|
39
|
+
try {
|
|
40
|
+
const s = JSON.parse(readFileSync(updateStatePath(), 'utf8'));
|
|
41
|
+
return s && typeof s === 'object' ? s : {};
|
|
42
|
+
} catch {
|
|
43
|
+
return {};
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function writeUpdateState(next) {
|
|
48
|
+
const dir = userDataDir();
|
|
49
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
50
|
+
writeFileSync(updateStatePath(), JSON.stringify(next, null, 2) + '\n');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Compare two semver-ish strings. Returns true when `a` is strictly newer than
|
|
55
|
+
* `b`. Pre-release tags (`3.25.0-beta.1`) are treated as older than the plain
|
|
56
|
+
* release, which is what we want: we never nudge anyone onto a pre-release.
|
|
57
|
+
*/
|
|
58
|
+
export function isNewer(a, b) {
|
|
59
|
+
const parse = (v) => {
|
|
60
|
+
const m = String(v || '').trim().match(/^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/);
|
|
61
|
+
if (!m) return null;
|
|
62
|
+
return { nums: [+m[1], +m[2], +m[3]], pre: m[4] || null };
|
|
63
|
+
};
|
|
64
|
+
const pa = parse(a);
|
|
65
|
+
const pb = parse(b);
|
|
66
|
+
if (!pa || !pb) return false;
|
|
67
|
+
for (let i = 0; i < 3; i++) {
|
|
68
|
+
if (pa.nums[i] !== pb.nums[i]) return pa.nums[i] > pb.nums[i];
|
|
69
|
+
}
|
|
70
|
+
if (pa.pre && !pb.pre) return false; // 3.25.0-beta < 3.25.0
|
|
71
|
+
if (!pa.pre && pb.pre) return true;
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The read-only accessor every render path uses.
|
|
77
|
+
*
|
|
78
|
+
* @returns {{current: string, latest: string|null, available: boolean, dismissed: boolean, stale: boolean}}
|
|
79
|
+
*/
|
|
80
|
+
export function updateStatus(currentVersion) {
|
|
81
|
+
if (updateCheckDisabled()) {
|
|
82
|
+
return { current: currentVersion, latest: null, available: false, dismissed: false, stale: false };
|
|
83
|
+
}
|
|
84
|
+
const s = readUpdateState();
|
|
85
|
+
const latest = typeof s.latest === 'string' ? s.latest : null;
|
|
86
|
+
const available = !!latest && isNewer(latest, currentVersion);
|
|
87
|
+
const age = Date.now() - (Number(s.checkedAt) || 0);
|
|
88
|
+
return {
|
|
89
|
+
current: currentVersion,
|
|
90
|
+
latest,
|
|
91
|
+
available,
|
|
92
|
+
// A version the user already declined stays out of the statusline and out
|
|
93
|
+
// of the session briefing until a newer one ships โ otherwise "no thanks"
|
|
94
|
+
// means "ask me again in five minutes", forever.
|
|
95
|
+
dismissed: available && s.dismissedVersion === latest,
|
|
96
|
+
stale: age >= CHECK_INTERVAL_MS,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Fire the background refresh when the cached answer has aged out. Returns
|
|
102
|
+
* immediately in every case; the child is detached and unref'd so it cannot
|
|
103
|
+
* hold the statusline process open.
|
|
104
|
+
*/
|
|
105
|
+
export function maybeSpawnUpdateCheck(currentVersion) {
|
|
106
|
+
if (updateCheckDisabled()) return false;
|
|
107
|
+
const { stale } = updateStatus(currentVersion);
|
|
108
|
+
if (!stale) return false;
|
|
109
|
+
// Stamp the attempt before spawning. Without this, an offline machine
|
|
110
|
+
// re-spawns a doomed child on every single statusline render โ several per
|
|
111
|
+
// second โ because the cache never gets a fresh timestamp.
|
|
112
|
+
try {
|
|
113
|
+
writeUpdateState({ ...readUpdateState(), checkedAt: Date.now(), current: currentVersion });
|
|
114
|
+
} catch (e) {
|
|
115
|
+
debug('update-check:stamp', e);
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
try {
|
|
119
|
+
spawn(process.execPath, [cliEntryPath(), 'update-check', '--refresh', '--quiet'], {
|
|
120
|
+
detached: true,
|
|
121
|
+
stdio: 'ignore',
|
|
122
|
+
}).unref();
|
|
123
|
+
return true;
|
|
124
|
+
} catch (e) {
|
|
125
|
+
debug('update-check:spawn', e);
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Path to this package's CLI entry point (bin/cli.js). */
|
|
131
|
+
export function cliEntryPath() {
|
|
132
|
+
return join(dirname(fileURLToPath(import.meta.url)), '..', 'bin', 'cli.js');
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Actually hit the registry and persist the answer. Only the detached child
|
|
137
|
+
* and the explicit `update-check --refresh` command call this.
|
|
138
|
+
*/
|
|
139
|
+
export async function refreshUpdateState(currentVersion) {
|
|
140
|
+
const controller = new AbortController();
|
|
141
|
+
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
142
|
+
try {
|
|
143
|
+
// The `latest` dist-tag endpoint returns a few hundred bytes, unlike the
|
|
144
|
+
// full packument which is megabytes for a package with this many releases.
|
|
145
|
+
const res = await fetch(`https://registry.npmjs.org/${PKG_NAME}/latest`, {
|
|
146
|
+
signal: controller.signal,
|
|
147
|
+
headers: { accept: 'application/json' },
|
|
148
|
+
});
|
|
149
|
+
if (!res.ok) throw new Error(`registry responded ${res.status}`);
|
|
150
|
+
const body = await res.json();
|
|
151
|
+
const latest = typeof body.version === 'string' ? body.version : null;
|
|
152
|
+
if (!latest) throw new Error('registry response carried no version');
|
|
153
|
+
const prev = readUpdateState();
|
|
154
|
+
const next = { ...prev, checkedAt: Date.now(), latest, current: currentVersion };
|
|
155
|
+
// A newly published version clears an older dismissal: the user declined
|
|
156
|
+
// 3.25.0, not "all future upgrades".
|
|
157
|
+
if (prev.dismissedVersion && isNewer(latest, prev.dismissedVersion)) {
|
|
158
|
+
delete next.dismissedVersion;
|
|
159
|
+
}
|
|
160
|
+
writeUpdateState(next);
|
|
161
|
+
return { ok: true, latest };
|
|
162
|
+
} catch (e) {
|
|
163
|
+
debug('update-check:refresh', e);
|
|
164
|
+
// Keep the timestamp fresh even on failure so an offline machine backs off
|
|
165
|
+
// for the full interval instead of retrying on every render.
|
|
166
|
+
try {
|
|
167
|
+
writeUpdateState({ ...readUpdateState(), checkedAt: Date.now(), current: currentVersion });
|
|
168
|
+
} catch (e2) {
|
|
169
|
+
debug('update-check:refresh-stamp', e2);
|
|
170
|
+
}
|
|
171
|
+
return { ok: false, error: String(e && e.message ? e.message : e) };
|
|
172
|
+
} finally {
|
|
173
|
+
clearTimeout(timer);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Record that the user said "not now" for this exact version. */
|
|
178
|
+
export function dismissUpdate(version) {
|
|
179
|
+
const s = readUpdateState();
|
|
180
|
+
writeUpdateState({ ...s, dismissedVersion: version });
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* How this copy was installed, and therefore what command upgrades it.
|
|
185
|
+
* Best-effort: the install root is the only reliable signal we have, and when
|
|
186
|
+
* it tells us nothing we fall back to the npm global install, which is how the
|
|
187
|
+
* overwhelming majority of copies got here.
|
|
188
|
+
*/
|
|
189
|
+
export function upgradeCommand() {
|
|
190
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
191
|
+
if (here.includes('/pnpm/')) return `pnpm add -g ${PKG_NAME}@latest`;
|
|
192
|
+
if (here.includes('/.bun/')) return `bun add -g ${PKG_NAME}@latest`;
|
|
193
|
+
if (here.includes('/.yarn/')) return `yarn global add ${PKG_NAME}@latest`;
|
|
194
|
+
return `npm install -g ${PKG_NAME}@latest`;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export const UPDATE_CHECK_INTERVAL_MS = CHECK_INTERVAL_MS;
|
|
198
|
+
export const PACKAGE_NAME = PKG_NAME;
|