sprag-cli 3.45.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.
- package/README.ko.md +1 -1
- package/README.md +2 -2
- package/bin/cli.js +6 -1
- package/examples/statusline-command.ps1 +11 -5
- package/examples/statusline-command.sh +9 -4
- package/package.json +5 -3
- package/src/changelog.js +70 -0
- package/src/cli-name.js +25 -0
- package/src/commands/install.js +1 -0
- package/src/commands/route-scan.js +66 -7
- package/src/commands/uninstall.js +5 -4
- package/src/formatters/statusline.js +72 -67
- package/src/formatters/table.js +5 -4
- package/src/glyphs.js +17 -0
- package/src/hook-manager.js +3 -2
- package/src/installer.js +132 -16
- package/src/litellm-budget.js +16 -2
- package/src/route-inject.js +10 -2
- package/src/route-scan.js +24 -1
- package/src/seed-rules.js +29 -16
- package/src/update-check.js +178 -1
package/README.ko.md
CHANGED
|
@@ -66,7 +66,7 @@ gemini-2.5-pro 57.9% $734 ▰▰▰▰▰▰▰▰▰▰▰
|
|
|
66
66
|
|
|
67
67
|
```
|
|
68
68
|
🔀 라우팅 절감 $2.09 | fable→sonnet 1회 $0.72 · opus→haiku 1회 $0.57
|
|
69
|
-
🚨 5H
|
|
69
|
+
🚨 5H ▰▰▰▰▰▰▰▰▰▰▰▱ 94% 🔄 12:36 · 🅷 5/5 · 🤖 Opus 5 · 🧠 Cache hit 98.8% · ⏳ Cache expires 59:46 · 📅 weekly ▰▰▰▰▰▱▱▱▱▱▱▱ 38% · 💵 Sep $42 · 📦 Ctx 47% of 1M
|
|
70
70
|
```
|
|
71
71
|
|
|
72
72
|
문제가 생기면 경고 칩이 줄 맨 앞으로 옵니다. `🚨 5H/7D NN%`, `⚠ Ctx 500k+`, `⚠ Cache miss`, `⚠ Input spike`, `⚠ Output heavy`, `⚠ Call surge`, `⚠ Rebuild churn`, `⚠ 5m TTL` 여덟 가지이며, 칩 문구를 그대로 말하면 Skill이 원인 코드와 조치 순서를 꺼내 줍니다. 세그먼트별 의미와 색 기준은 [statusline 문서](https://github.com/rootstudioyaml/sprag/blob/main/docs/STATUSLINE.md)에 정리했습니다.
|
package/README.md
CHANGED
|
@@ -72,7 +72,7 @@ gemini-2.5-pro 57.9% $734 ▰▰▰▰▰▰▰▰▰▰▰
|
|
|
72
72
|
|
|
73
73
|
```
|
|
74
74
|
🔀 라우팅 절감 $2.09 | fable→sonnet 1회 $0.72 · opus→haiku 1회 $0.57
|
|
75
|
-
🚨 5H
|
|
75
|
+
🚨 5H ▰▰▰▰▰▰▰▰▰▰▰▱ 94% 🔄 12:36 · 🅷 5/5 · 🤖 Opus 5 · 🧠 Cache hit 98.8% · ⏳ Cache expires 59:46 · 📅 weekly ▰▰▰▰▰▱▱▱▱▱▱▱ 38% · 💵 Sep $42 · 📦 Ctx 47% of 1M
|
|
76
76
|
```
|
|
77
77
|
|
|
78
78
|
문제가 생기면 경고 칩이 줄 맨 앞으로 옵니다. `🚨 5H/7D NN%`, `⚠ Ctx 500k+`, `⚠ Cache miss`, `⚠ Input spike`, `⚠ Output heavy`, `⚠ Call surge`, `⚠ Rebuild churn`, `⚠ 5m TTL` 여덟 가지이며, 칩 문구를 그대로 말하면 Skill이 원인 코드와 조치 순서를 꺼내 줍니다. 세그먼트별 의미와 색 기준은 [statusline 문서](https://github.com/rootstudioyaml/sprag/blob/main/docs/STATUSLINE.md)에 정리했습니다.
|
|
@@ -211,7 +211,7 @@ The measured −18.6% comes from the harness and ratchet; routing and conversion
|
|
|
211
211
|
|
|
212
212
|
```
|
|
213
213
|
🔀 Routing saved $2.09 | fable→sonnet 1× $0.72 · opus→haiku 1× $0.57
|
|
214
|
-
🚨 5H
|
|
214
|
+
🚨 5H ▰▰▰▰▰▰▰▰▰▰▰▱ 94% 🔄 12:36 · 🅷 5/5 · 🤖 Opus 5 · 🧠 Cache hit 98.8% · ⏳ Cache expires 59:46 · 📅 weekly ▰▰▰▰▰▱▱▱▱▱▱▱ 38% · 💵 Sep $42 · 📦 Ctx 47% of 1M
|
|
215
215
|
```
|
|
216
216
|
|
|
217
217
|
When something is wrong the warning chip leads the line: `🚨 5H/7D NN%`, `⚠ Ctx 500k+`, `⚠ Cache miss`, `⚠ Input spike`, `⚠ Output heavy`, `⚠ Call surge`, `⚠ Rebuild churn`, `⚠ 5m TTL`. Say the chip wording inside Claude and the Skill surfaces the root-cause code and the fix. Segment-by-segment meanings live in [the statusline reference](./docs/STATUSLINE.md).
|
package/bin/cli.js
CHANGED
|
@@ -325,7 +325,12 @@ async function main() {
|
|
|
325
325
|
console.log('예산 캐시가 비어 있습니다. `sprag litellm-budget --refresh` 로 먼저 조회하십시오.');
|
|
326
326
|
return;
|
|
327
327
|
}
|
|
328
|
-
|
|
328
|
+
// Same resolver the statusline uses: this report draws a gauge too, and a
|
|
329
|
+
// glyph the terminal's font lacks garbles here exactly as it does there.
|
|
330
|
+
const { resolveLabelMode } = await import('../src/statusline-mode.js');
|
|
331
|
+
const { statuslineDefaults } = await import('../src/config.js');
|
|
332
|
+
const { mode: labelMode } = resolveLabelMode({ hasFlag, cfg: statuslineDefaults() });
|
|
333
|
+
for (const line of formatBudgetReport(state, new Date(), labelMode)) console.log(line);
|
|
329
334
|
return;
|
|
330
335
|
}
|
|
331
336
|
|
|
@@ -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
|
-
#
|
|
3
|
+
# sprag output as a second segment.
|
|
4
4
|
#
|
|
5
5
|
# Install:
|
|
6
|
-
# 1) npm install -g
|
|
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
|
-
|
|
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
|
-
&
|
|
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
|
|
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
|
|
3
|
+
# Prints "user@host:cwd" then appends sprag as a second segment.
|
|
4
4
|
#
|
|
5
5
|
# Install:
|
|
6
|
-
# 1) npm install -g
|
|
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
|
-
|
|
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
|
|
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.
|
|
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,12 +8,14 @@
|
|
|
8
8
|
"claude-token-saver": "bin/cli.js"
|
|
9
9
|
},
|
|
10
10
|
"scripts": {
|
|
11
|
-
"
|
|
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",
|
|
16
|
+
"release:notes": "node scripts/release-notes.mjs",
|
|
15
17
|
"sync:ko": "node scripts/sync-readme-ko.mjs",
|
|
16
|
-
"
|
|
18
|
+
"test": "node --test"
|
|
17
19
|
},
|
|
18
20
|
"files": [
|
|
19
21
|
"bin/",
|
package/src/changelog.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* changelog — read one version's section out of CHANGELOG.md, and say whether a
|
|
3
|
+
* notes draft is still waiting on a person.
|
|
4
|
+
*
|
|
5
|
+
* Separate from scripts/release-notes.mjs, which is a CLI: importing that file
|
|
6
|
+
* runs its argument parsing and exits, so the extraction it depends on cannot be
|
|
7
|
+
* tested through it. The boundary logic is the part worth pinning — a section
|
|
8
|
+
* pulled one line short drops the release's last item, and one line long carries
|
|
9
|
+
* the previous release's first, and the upgrade offer shows either without
|
|
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.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The body of one version's section, heading excluded.
|
|
21
|
+
*
|
|
22
|
+
* @param {string} changelog - CHANGELOG.md contents
|
|
23
|
+
* @param {string} version - e.g. "3.45.0"
|
|
24
|
+
* @returns {string|null} the section text, or null when that version has none
|
|
25
|
+
*/
|
|
26
|
+
export function sectionFor(changelog, version) {
|
|
27
|
+
const lines = String(changelog).split(/\r?\n/);
|
|
28
|
+
// The version has to match to its end: without the boundary, "3.42.0" would
|
|
29
|
+
// also match the "### v3.42.7" heading and publish the wrong notes.
|
|
30
|
+
const head = new RegExp(`^### v${String(version).replace(/\./g, '\\.')}(\\s|$|\\()`);
|
|
31
|
+
const start = lines.findIndex((l) => head.test(l));
|
|
32
|
+
if (start < 0) return null;
|
|
33
|
+
const out = [];
|
|
34
|
+
for (let i = start + 1; i < lines.length; i += 1) {
|
|
35
|
+
// The next version heading ends this section, and so does a shallower one:
|
|
36
|
+
// "## All releases" is a container, not part of any release.
|
|
37
|
+
if (/^### v/.test(lines[i]) || /^## /.test(lines[i])) break;
|
|
38
|
+
out.push(lines[i]);
|
|
39
|
+
}
|
|
40
|
+
return out.join('\n').trim();
|
|
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
|
+
}
|
package/src/cli-name.js
ADDED
|
@@ -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';
|
package/src/commands/install.js
CHANGED
|
@@ -71,6 +71,7 @@ export async function run({ hasFlag }) {
|
|
|
71
71
|
const r = installAll({ force });
|
|
72
72
|
print('skill', r.skill);
|
|
73
73
|
print('SessionStart hook (route-scan)', r.sessionStartHook);
|
|
74
|
+
print('PostToolUse hook (delegation rescan)', r.delegationHook);
|
|
74
75
|
print('UserPromptSubmit hook (brief)', r.briefHook);
|
|
75
76
|
{
|
|
76
77
|
let s = r.statusline;
|
|
@@ -174,11 +174,27 @@ export async function run({ args, hasFlag, numArg }) {
|
|
|
174
174
|
const scan = rs.readRouteScan();
|
|
175
175
|
if (scan?.unresolvedRuns > 0) {
|
|
176
176
|
const ids = (scan.unresolvedModels || []).join(', ');
|
|
177
|
+
// Unlike the statusline chip, this listing reports even a single one: the
|
|
178
|
+
// user ran this command to find out why a rule reads as never fired, and
|
|
179
|
+
// there is room here to answer properly. But a gateway's opaque profile
|
|
180
|
+
// ARN is normally resolved by learning from observed runs, which needs
|
|
181
|
+
// MIN_VOTES of them — so below that, the honest advice is to wait rather
|
|
182
|
+
// than to go edit a config file.
|
|
183
|
+
const { MIN_VOTES } = await import('../model-alias.js');
|
|
184
|
+
const learning = scan.unresolvedRuns < MIN_VOTES;
|
|
185
|
+
const remedyKo = learning
|
|
186
|
+
? `\n 위임이 ${MIN_VOTES}건 이상 쌓이면 자동으로 해석될 수 있습니다. 그때까지 기다렸다가 다시 확인하고,`
|
|
187
|
+
+ '\n 게이트웨이가 모델 이름을 아예 알려 주지 않는 환경이라면 profile-map.json 의 modelAliases 에'
|
|
188
|
+
+ '\n 해당 ID 를 매핑한 뒤 route-scan --refresh 를 실행하십시오.'
|
|
189
|
+
: '\n profile-map.json 의 modelAliases 에 해당 ID 를 매핑한 뒤 route-scan --refresh 를 실행하십시오.';
|
|
190
|
+
const remedyEn = learning
|
|
191
|
+
? `\n These may resolve on their own once ${MIN_VOTES} or more delegated runs accumulate — check again then.`
|
|
192
|
+
+ '\n If the gateway never reports a model name at all, map the id under modelAliases in'
|
|
193
|
+
+ '\n profile-map.json and run route-scan --refresh.'
|
|
194
|
+
: '\n Map it under modelAliases in profile-map.json, then run route-scan --refresh.';
|
|
177
195
|
console.log(lang === 'ko'
|
|
178
|
-
? `\n⚠ 해석되지 않은 모델 ID 때문에 위임 ${scan.unresolvedRuns}건이 집계에서 제외됐습니다${ids ? ` (${ids})` : ''}.`
|
|
179
|
-
|
|
180
|
-
: `\n⚠ ${scan.unresolvedRuns} delegated run(s) were excluded — unpriceable model id${ids ? ` (${ids})` : ''}.`
|
|
181
|
-
+ '\n Map it under modelAliases in profile-map.json, then run route-scan --refresh.');
|
|
196
|
+
? `\n⚠ 해석되지 않은 모델 ID 때문에 위임 ${scan.unresolvedRuns}건이 집계에서 제외됐습니다${ids ? ` (${ids})` : ''}.` + remedyKo
|
|
197
|
+
: `\n⚠ ${scan.unresolvedRuns} delegated run(s) were excluded — unpriceable model id${ids ? ` (${ids})` : ''}.` + remedyEn);
|
|
182
198
|
}
|
|
183
199
|
console.log(lang === 'ko'
|
|
184
200
|
? '\n제거: sprag route-scan rules rm <N>'
|
|
@@ -203,6 +219,33 @@ export async function run({ args, hasFlag, numArg }) {
|
|
|
203
219
|
return;
|
|
204
220
|
}
|
|
205
221
|
|
|
222
|
+
// --hook-delegated: PostToolUse hook for Task/Agent. A delegation just
|
|
223
|
+
// finished, which is the one event guaranteed to change what a scan
|
|
224
|
+
// produces — its savings and its error rate both move. Kicks a detached
|
|
225
|
+
// refresh so the statusline shows the new figure on its next render
|
|
226
|
+
// instead of waiting out the hourly gate, and prints nothing: a tool
|
|
227
|
+
// result is not a place for our commentary.
|
|
228
|
+
if (hasFlag('--hook-delegated')) {
|
|
229
|
+
const payload = readStdinJson() || {};
|
|
230
|
+
// Claude Code matches the hook by tool name already, but the payload is
|
|
231
|
+
// the only thing that proves it: a mis-registered matcher would otherwise
|
|
232
|
+
// have us rescanning after every Read.
|
|
233
|
+
const tool = payload.tool_name;
|
|
234
|
+
if (tool !== 'Task' && tool !== 'Agent') return;
|
|
235
|
+
try {
|
|
236
|
+
const cache = rs.readRouteScan();
|
|
237
|
+
if (!(await rs.shouldRescan(cache, { afterDelegation: true }))) return;
|
|
238
|
+
const { spawn } = await import('node:child_process');
|
|
239
|
+
spawn(process.execPath, [process.argv[1], 'route-scan', '--refresh', '--quiet'],
|
|
240
|
+
{ detached: true, stdio: 'ignore', windowsHide: true }).unref();
|
|
241
|
+
} catch (e) {
|
|
242
|
+
// A missed refresh only means the figure lags, which is what happened
|
|
243
|
+
// before this hook existed. Never fail the tool call over it.
|
|
244
|
+
debug('route-scan:delegated-refresh', e);
|
|
245
|
+
}
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
|
|
206
249
|
// --hook: SessionStart hook mode. Never scans inline (session start must
|
|
207
250
|
// stay fast) — reads the cache, kicks a detached refresh when stale, and
|
|
208
251
|
// prints delegation-candidate context for the new session.
|
|
@@ -299,16 +342,32 @@ export async function run({ args, hasFlag, numArg }) {
|
|
|
299
342
|
// `dismissed` is the whole point of asking once: a user who said no to
|
|
300
343
|
// this version must not be asked again every `/clear`.
|
|
301
344
|
if (u && u.available && !u.dismissed) {
|
|
345
|
+
// What the release adds, when we managed to read it. "A newer version
|
|
346
|
+
// exists" is not a reason to upgrade; the reason is in this list, and
|
|
347
|
+
// without it the user is being asked to decide on no information.
|
|
348
|
+
const notes = Array.isArray(u.highlights) ? u.highlights : [];
|
|
349
|
+
const koNotes = notes.length
|
|
350
|
+
? [' 이 버전에서 새로 생긴 것:', ...notes.map((n) => ` · ${n}`)]
|
|
351
|
+
: [];
|
|
352
|
+
const enNotes = notes.length
|
|
353
|
+
? [' What this release adds:', ...notes.map((n) => ` · ${n}`)]
|
|
354
|
+
: [];
|
|
302
355
|
updateBlock = lang === 'ko'
|
|
303
356
|
? [
|
|
304
357
|
`[sprag update] 새 버전이 나와 있습니다: v${u.current} → ${u.latest}.`,
|
|
305
|
-
|
|
306
|
-
|
|
358
|
+
...koNotes,
|
|
359
|
+
notes.length
|
|
360
|
+
? ' 위 내용을 사용자에게 보여 주고 업그레이드할지 물어보십시오. 승낙하면 아래 명령을 실행하고, 묻지 않고 설치하지는 마십시오.'
|
|
361
|
+
: ' 사용자에게 지금 업그레이드할지 물어보고, 승낙하면 아래 명령을 실행하십시오. 묻지 않고 설치하지는 마십시오.',
|
|
362
|
+
' sprag upgrade # 업그레이드 실행',
|
|
307
363
|
' sprag update-check --dismiss # 사용자가 원치 않으면 (다음 버전이 나올 때까지 다시 묻지 않습니다)',
|
|
308
364
|
].join('\n')
|
|
309
365
|
: [
|
|
310
366
|
`[sprag update] A newer version is available: v${u.current} → ${u.latest}.`,
|
|
311
|
-
|
|
367
|
+
...enNotes,
|
|
368
|
+
notes.length
|
|
369
|
+
? ' Show that list to the user and ask whether to upgrade. Run the command below only if they agree; do not install without asking.'
|
|
370
|
+
: 'Ask the user whether to upgrade now, and run the command below only if they agree. Do not install without asking.',
|
|
312
371
|
' sprag upgrade # perform the upgrade',
|
|
313
372
|
' sprag update-check --dismiss # if they decline (stays quiet until a newer release)',
|
|
314
373
|
].join('\n');
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Subcommand: uninstall — take the integration back out of ~/.claude.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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
|
-
?
|
|
40
|
-
:
|
|
40
|
+
? ` 기록된 절감액과 설정까지 지우려면 \`${CLI_NAME} uninstall --purge\` 를 실행하십시오.`
|
|
41
|
+
: ` To remove recorded savings and settings too: \`${CLI_NAME} uninstall --purge\`.`);
|
|
41
42
|
}
|
|
42
43
|
}
|
|
@@ -23,6 +23,7 @@ import { labelForKey } from '../window-labels.js';
|
|
|
23
23
|
import { harnessStatusForStatusline } from '../harness.js';
|
|
24
24
|
import { loadConfig } from '../config.js';
|
|
25
25
|
import { koreanStyleEnabled } from '../korean-style.js';
|
|
26
|
+
import { MIN_VOTES } from '../model-alias.js';
|
|
26
27
|
|
|
27
28
|
// The 8-color ANSI defaults (RED=31, GREEN=32, YELLOW=33…) read as garish
|
|
28
29
|
// next to each other — terminal palettes set them with unbalanced perceptual
|
|
@@ -67,88 +68,86 @@ function formatPct(v) {
|
|
|
67
68
|
}
|
|
68
69
|
|
|
69
70
|
/**
|
|
70
|
-
* Render
|
|
71
|
-
* the same Unicode "Block Elements" density family — `█` (100%) → `▓` (75%)
|
|
72
|
-
* → `▒` (50%) → `░` (25%) — so the fill→empty boundary reads as one smooth
|
|
73
|
-
* gradient instead of an awkward step.
|
|
71
|
+
* Render the gauge for `pct` (0..100) as a row of separate ticks.
|
|
74
72
|
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
73
|
+
* The bar used to be six cells of one continuous block, with the boundary cell
|
|
74
|
+
* subdivided into eighths for sub-cell precision. That precision had no reader:
|
|
75
|
+
* every chip that draws a gauge prints the exact percentage immediately after it
|
|
76
|
+
* (`▰▰▰▱▱▱ 26%`), so the bar's job is the glance, not the number. What the solid
|
|
77
|
+
* run cost instead was legibility — adjacent full cells fuse into one shape, and
|
|
78
|
+
* a shape has no quantity you can read off it. Separate ticks do: twelve of them
|
|
79
|
+
* are countable at a glance and land on a familiar 1/12 grid.
|
|
80
80
|
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
81
|
+
* Two ends get special treatment, because they are the two readings that change
|
|
82
|
+
* a decision. A full row means the limit is reached, so nothing below 100% may
|
|
83
|
+
* draw one; conversely anything above zero draws at least one tick, so "barely
|
|
84
|
+
* started" never looks like "untouched". Rounding alone gets both wrong: it fills
|
|
85
|
+
* the last cell from 96% and leaves the first empty until 5%.
|
|
86
|
+
*
|
|
87
|
+
* Glyphs come from the mode's set (src/glyphs.js) rather than being fixed here.
|
|
88
|
+
* ▰▱ is the shape we want, but JetBrains Mono ships neither character, and a
|
|
89
|
+
* missing glyph is drawn by a fallback font whose advance width does not match
|
|
90
|
+
* the cell grid — which is the whole reason narrow mode exists. So narrow draws
|
|
91
|
+
* the same segmented bar with ■□, which that font does have.
|
|
92
|
+
*
|
|
93
|
+
* @param {number} pct
|
|
94
|
+
* @param {string} [mode] label mode; selects the tick glyphs
|
|
95
|
+
* @returns {{ filled: string, empty: string }} the two halves, so a caller can
|
|
96
|
+
* color them separately
|
|
84
97
|
*/
|
|
85
|
-
export function gaugeParts(pct) {
|
|
86
|
-
const
|
|
98
|
+
export function gaugeParts(pct, mode = 'icon') {
|
|
99
|
+
const CELLS = 12;
|
|
100
|
+
const g = glyphsFor(mode);
|
|
87
101
|
const clamped = Math.max(0, Math.min(100, pct));
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
//
|
|
92
|
-
|
|
93
|
-
//
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
// cell height and 10% of its width inset — so a bar mixing them looks visibly
|
|
100
|
-
// misaligned, the empty half riding high and shifted inward. ▒ shares the box
|
|
101
|
-
// with the filled glyphs and still reads clearly lighter than █.
|
|
102
|
-
const step = Math.round(remainder * 8);
|
|
103
|
-
// A boundary cell rounded up to 8/8 is a full cell, not a ninth glyph.
|
|
104
|
-
const extra = step === 8 ? 1 : 0;
|
|
105
|
-
const totalFull = Math.min(cells, fullCells + extra);
|
|
106
|
-
const partial = totalFull < cells && extra === 0 ? EIGHTHS[step] : '';
|
|
107
|
-
const usedCells = totalFull + (partial ? 1 : 0);
|
|
108
|
-
const empty = '▒'.repeat(Math.max(0, cells - usedCells));
|
|
109
|
-
return { filled: '█'.repeat(totalFull) + partial, empty };
|
|
102
|
+
let ticks = Math.round((clamped / 100) * CELLS);
|
|
103
|
+
// A full row is reserved for an actual 100%: at the cap the gauge alone has to
|
|
104
|
+
// say so, and 96% rounding up to twelve would spend that signal on a window
|
|
105
|
+
// that still has room.
|
|
106
|
+
if (ticks >= CELLS && clamped < 100) ticks = CELLS - 1;
|
|
107
|
+
// And any nonzero usage earns a tick, so an empty row means untouched.
|
|
108
|
+
if (ticks === 0 && clamped > 0) ticks = 1;
|
|
109
|
+
return {
|
|
110
|
+
filled: g.gaugeFull.repeat(ticks),
|
|
111
|
+
empty: g.gaugeEmpty.repeat(CELLS - ticks),
|
|
112
|
+
};
|
|
110
113
|
}
|
|
111
114
|
|
|
112
115
|
/**
|
|
113
116
|
* The bar as one string, for callers that render it in a single color.
|
|
114
117
|
* @param {number} pct
|
|
118
|
+
* @param {string} [mode] label mode; selects the tick glyphs
|
|
115
119
|
* @returns {string}
|
|
116
120
|
*/
|
|
117
|
-
export function gaugeBar(pct) {
|
|
118
|
-
const { filled, empty } = gaugeParts(pct);
|
|
121
|
+
export function gaugeBar(pct, mode = 'icon') {
|
|
122
|
+
const { filled, empty } = gaugeParts(pct, mode);
|
|
119
123
|
return filled + empty;
|
|
120
124
|
}
|
|
121
125
|
|
|
122
126
|
/**
|
|
123
|
-
* The bar with the unused
|
|
127
|
+
* The bar with the unused ticks dimmed.
|
|
124
128
|
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
* text after the bar keeps the color
|
|
129
|
+
* The hollow tick already distinguishes the two halves by shape, so color is not
|
|
130
|
+
* carrying that distinction the way it had to when both halves were the same
|
|
131
|
+
* solid block. It still helps: graying the remainder keeps the filled run as the
|
|
132
|
+
* only thing at full brightness, which is what the eye should land on. The
|
|
133
|
+
* segment tone is reinstated at the end so the text after the bar keeps the color
|
|
134
|
+
* it started in.
|
|
130
135
|
*
|
|
131
136
|
* @param {number} pct
|
|
132
137
|
* @param {(code: string) => string} c - color emitter (returns '' when color is off)
|
|
133
138
|
* @param {string} tone - the segment's own color code
|
|
134
139
|
* @param {boolean} color - whether ANSI is allowed. Passed explicitly rather
|
|
135
140
|
* than inferred from `c` emitting nothing: that inference reads the emitter's
|
|
136
|
-
* current implementation, and a caller that wraps or replaces `c` would
|
|
137
|
-
*
|
|
141
|
+
* current implementation, and a caller that wraps or replaces `c` would change
|
|
142
|
+
* behaviour here as a side effect nobody asked for.
|
|
143
|
+
* @param {string} [mode] label mode; selects the tick glyphs
|
|
138
144
|
* @returns {string}
|
|
139
145
|
*/
|
|
140
|
-
function gaugeColored(pct, c, tone, color) {
|
|
141
|
-
const { filled, empty } = gaugeParts(pct);
|
|
146
|
+
function gaugeColored(pct, c, tone, color, mode = 'icon') {
|
|
147
|
+
const { filled, empty } = gaugeParts(pct, mode);
|
|
142
148
|
if (!empty) return filled;
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
// as noise beside a solid fill — the two halves look like different materials
|
|
146
|
-
// rather than two states of one bar. One glyph in two colors reads as a track
|
|
147
|
-
// filling up, and it sidesteps the glyph-box mismatch between the shades and
|
|
148
|
-
// the blocks entirely. Without color there is nothing but texture to tell them
|
|
149
|
-
// apart, so the shading stays.
|
|
150
|
-
const track = color ? '█'.repeat(empty.length) : empty;
|
|
151
|
-
return `${filled}${c(RESET)}${c(GRAY)}${track}${c(RESET)}${c(tone)}`;
|
|
149
|
+
if (!color) return filled + empty;
|
|
150
|
+
return `${filled}${c(RESET)}${c(GRAY)}${empty}${c(RESET)}${c(tone)}`;
|
|
152
151
|
}
|
|
153
152
|
|
|
154
153
|
/**
|
|
@@ -266,11 +265,9 @@ function buildHarnessSeg(c, isIcon, g) {
|
|
|
266
265
|
* the wall-clock reset time rides along in the same `🔄 HH:MM` shape as the
|
|
267
266
|
* always-on usage segments.
|
|
268
267
|
*/
|
|
269
|
-
function buildCapWarnSeg(capWarn, c, isIcon, g, color) {
|
|
270
|
-
// `color`
|
|
271
|
-
//
|
|
272
|
-
// anyway, because a change to the cap-warn threshold or the bar width would
|
|
273
|
-
// otherwise start drawing the wrong track with nothing to catch it.
|
|
268
|
+
function buildCapWarnSeg(capWarn, c, isIcon, g, color, mode) {
|
|
269
|
+
// `color` and `mode` are both for the gauge: the first decides whether the
|
|
270
|
+
// unused ticks are dimmed, the second which pair of tick glyphs to draw.
|
|
274
271
|
if (!capWarn) return null;
|
|
275
272
|
const pct = Math.round(capWarn.usedPct);
|
|
276
273
|
const clock = formatResetClock(capWarn.resetsAt);
|
|
@@ -281,7 +278,7 @@ function buildCapWarnSeg(capWarn, c, isIcon, g, color) {
|
|
|
281
278
|
// Gauge keeps shape parity with the always-on usage segment — the
|
|
282
279
|
// cap-warn is just the same gauge "filled to alarm". Visual continuity
|
|
283
280
|
// helps the eye understand "this is the 5H bar I was watching, just red now."
|
|
284
|
-
const bar = gaugeColored(pct, c, RED, color);
|
|
281
|
+
const bar = gaugeColored(pct, c, RED, color, mode);
|
|
285
282
|
return `${c(BOLD)}${c(RED)}${g.capWarn} ${capWarn.label} ${bar} ${pct}%${clockTail}${c(RESET)}`;
|
|
286
283
|
}
|
|
287
284
|
return `${c(BOLD)}${c(RED)}${capWarn.label} cap ${pct}%${clockTail}${c(RESET)}`;
|
|
@@ -301,7 +298,7 @@ export function formatNoSession({ caps = null, model = null, windowLabel = '', v
|
|
|
301
298
|
const isIcon = mode === 'icon' || mode === 'narrow';
|
|
302
299
|
const g = glyphsFor(mode);
|
|
303
300
|
const segs = [];
|
|
304
|
-
const capSeg = buildCapWarnSeg(pickCapWarn(caps), c, isIcon, g, color);
|
|
301
|
+
const capSeg = buildCapWarnSeg(pickCapWarn(caps), c, isIcon, g, color, mode);
|
|
305
302
|
if (capSeg) segs.push(capSeg);
|
|
306
303
|
const versionSeg = buildVersionSeg(version, update, c, isIcon, false, g);
|
|
307
304
|
if (versionSeg && update && update.available) segs.push(versionSeg);
|
|
@@ -443,10 +440,18 @@ export function formatReport(data, { color = true, verbose = false, timer = true
|
|
|
443
440
|
// identical, so users in the second case had no reason to suspect anything
|
|
444
441
|
// was wrong. The count gets a chip; the explanation stays in `route-scan
|
|
445
442
|
// rules`, where there is room for it.
|
|
443
|
+
//
|
|
444
|
+
// But only once there are enough of them to mean anything. A gateway reports
|
|
445
|
+
// an opaque profile ARN, and the fix for that is learned from observed runs —
|
|
446
|
+
// which needs MIN_VOTES of them before it can conclude anything. Below that
|
|
447
|
+
// threshold an unresolved run is not a problem, it is the first day of a
|
|
448
|
+
// working mechanism, and warning about it makes a fresh install look broken.
|
|
449
|
+
// Tied to MIN_VOTES rather than a literal so the two cannot drift: the chip
|
|
450
|
+
// appears exactly when learning has had its chance and still came up short.
|
|
446
451
|
const unresolvedRuns = Number(data.unresolvedRuns) || 0;
|
|
447
452
|
const delegateSeg = delegationSaved > 0
|
|
448
453
|
? `${c(GREEN)}${delegateLabel}${c(RESET)} ${formatMoney(delegationSaved)}`
|
|
449
|
-
: (unresolvedRuns
|
|
454
|
+
: (unresolvedRuns >= MIN_VOTES
|
|
450
455
|
? `${c(YELLOW)}${g.routing} ${unresolvedRuns} unresolved${c(RESET)}`
|
|
451
456
|
: null);
|
|
452
457
|
|
|
@@ -750,7 +755,7 @@ export function formatReport(data, { color = true, verbose = false, timer = true
|
|
|
750
755
|
const src = info.source === 'user' ? ' (user)' : '';
|
|
751
756
|
if (isIcon) {
|
|
752
757
|
const labelPart = labels.usageLabel ? `${labels.usageLabel}${src} ` : '';
|
|
753
|
-
const bar = gaugeColored(pct, c, tone, color);
|
|
758
|
+
const bar = gaugeColored(pct, c, tone, color, mode);
|
|
754
759
|
const winGlyph = mode === 'narrow' ? (labels.narrowIcon || labels.icon) : labels.icon;
|
|
755
760
|
return `${c(tone)}${winGlyph} ${labelPart}${bar} ${pct}%${money}${tail}${c(RESET)}`;
|
|
756
761
|
}
|
|
@@ -787,7 +792,7 @@ export function formatReport(data, { color = true, verbose = false, timer = true
|
|
|
787
792
|
}
|
|
788
793
|
}
|
|
789
794
|
|
|
790
|
-
const capWarnSeg = buildCapWarnSeg(capWarn, c, isIcon, g, color);
|
|
795
|
+
const capWarnSeg = buildCapWarnSeg(capWarn, c, isIcon, g, color, mode);
|
|
791
796
|
|
|
792
797
|
// Warning chip leads — a glance at the statusline catches "something's wrong"
|
|
793
798
|
// before parsing any numbers. Healthy states have no chip and look unchanged.
|