sprag-cli 3.44.0 → 3.46.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.ko.md +3 -1
- package/README.md +6 -2
- package/bin/cli.js +23 -25
- package/package.json +2 -1
- package/presets/model-rules.json +50 -9
- package/src/changelog.js +34 -0
- package/src/commands/brief.js +13 -1
- package/src/commands/install.js +1 -0
- package/src/commands/mode.js +22 -2
- package/src/commands/route-scan.js +66 -7
- package/src/config.js +22 -3
- package/src/formatters/statusline.js +279 -119
- package/src/glyphs.js +95 -0
- package/src/installer.js +50 -1
- package/src/litellm-budget.js +16 -2
- package/src/model-rules.js +53 -17
- package/src/route-inject.js +94 -0
- package/src/route-scan.js +24 -1
- package/src/seed-rules.js +26 -14
- package/src/statusline-mode.js +95 -0
- package/src/update-check.js +138 -1
- package/src/window-labels.js +17 -7
package/src/update-check.js
CHANGED
|
@@ -10,7 +10,16 @@
|
|
|
10
10
|
*
|
|
11
11
|
* State lives next to the other user-data files:
|
|
12
12
|
* { checkedAt: <ms>, latest: "3.25.0", current: "3.24.0",
|
|
13
|
-
* dismissedVersion: "3.25.0"|undefined
|
|
13
|
+
* dismissedVersion: "3.25.0"|undefined,
|
|
14
|
+
* highlights: ["…", "…"]|undefined, highlightsFor: "3.25.0"|undefined }
|
|
15
|
+
*
|
|
16
|
+
* `highlights` is what the new version actually adds, read from the GitHub
|
|
17
|
+
* release for that tag. A bare "v3.24 → v3.25 is available" gives the user
|
|
18
|
+
* nothing to weigh: the answer to "should I upgrade" is in what changed, and
|
|
19
|
+
* asking them to go find that themselves is how an upgrade notice becomes
|
|
20
|
+
* something to dismiss. The release body is fetched on the same detached,
|
|
21
|
+
* once-a-day pass as the version check, and its absence never blocks the
|
|
22
|
+
* notice — a version number alone is still worth saying.
|
|
14
23
|
*
|
|
15
24
|
* Opt out with CTS_NO_UPDATE_CHECK=1 or NO_UPDATE_NOTIFIER (the de-facto
|
|
16
25
|
* standard env var — anyone who set it for other CLIs meant us too).
|
|
@@ -102,6 +111,9 @@ export function updateStatus(currentVersion) {
|
|
|
102
111
|
// means "ask me again in five minutes", forever.
|
|
103
112
|
dismissed: available && s.dismissedVersion === latest,
|
|
104
113
|
stale: age >= CHECK_INTERVAL_MS,
|
|
114
|
+
// Only when they describe THIS version. A cache written for an earlier
|
|
115
|
+
// release would otherwise sell the wrong upgrade.
|
|
116
|
+
highlights: Array.isArray(s.highlights) && s.highlightsFor === latest ? s.highlights : [],
|
|
105
117
|
};
|
|
106
118
|
}
|
|
107
119
|
|
|
@@ -143,6 +155,116 @@ export function cliEntryPath() {
|
|
|
143
155
|
return join(dirname(fileURLToPath(import.meta.url)), '..', 'bin', 'cli.js');
|
|
144
156
|
}
|
|
145
157
|
|
|
158
|
+
// Release notes come from the repository's own releases, because the registry
|
|
159
|
+
// does not carry them: `/latest` answers with package metadata and no changelog
|
|
160
|
+
// of any kind. Unauthenticated reads are allowed at 60/hour, and this runs once
|
|
161
|
+
// a day behind the same interval as the version check, so the budget is ample.
|
|
162
|
+
const RELEASES_API = 'https://api.github.com/repos/rootstudioyaml/sprag/releases/tags';
|
|
163
|
+
const MAX_HIGHLIGHTS = 3;
|
|
164
|
+
const MAX_HIGHLIGHT_LEN = 110;
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* The headline items of a release body, as short single lines.
|
|
168
|
+
*
|
|
169
|
+
* Release notes are written for people reading a web page: headings, prose
|
|
170
|
+
* paragraphs, links, and bold runs. A session-start notice has one line per
|
|
171
|
+
* item and no room for any of that, so this keeps only what reads as an item
|
|
172
|
+
* of a list and strips it to plain text. Anything that survives is a sentence
|
|
173
|
+
* the user can judge an upgrade by; anything that does not is left behind
|
|
174
|
+
* rather than truncated into nonsense.
|
|
175
|
+
*
|
|
176
|
+
* @param {string} body - the release body as published
|
|
177
|
+
* @returns {string[]} at most MAX_HIGHLIGHTS lines, or [] when nothing fits
|
|
178
|
+
*/
|
|
179
|
+
export function releaseHighlights(body) {
|
|
180
|
+
const text = String(body || '');
|
|
181
|
+
if (!text.trim()) return [];
|
|
182
|
+
const out = [];
|
|
183
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
184
|
+
const line = raw.trim();
|
|
185
|
+
// Bullets only. A heading names a section, and a prose paragraph in a
|
|
186
|
+
// release body is usually the rationale rather than the change itself.
|
|
187
|
+
const m = /^[-*+]\s+(.*)$/.exec(line);
|
|
188
|
+
if (!m) continue;
|
|
189
|
+
// Markup goes first, so the sentence split below sees the text a reader
|
|
190
|
+
// would: `**Bash writes are checked.**` is one sentence, not a bold run.
|
|
191
|
+
// One exception has to be caught before that: a stop followed straight by a
|
|
192
|
+
// code span (`…checked.\`install\` migrates…`) is a sentence boundary, and
|
|
193
|
+
// stripping the backtick first leaves a lowercase letter the split ignores.
|
|
194
|
+
// A space there is what the author meant, and it costs nothing if not.
|
|
195
|
+
let item = m[1]
|
|
196
|
+
.replace(/([.。])(`)/g, '$1 $2')
|
|
197
|
+
.replace(/`([^`]*)`/g, '$1') // code spans read fine as plain text
|
|
198
|
+
.replace(/\*\*([^*]*)\*\*/g, '$1') // bold
|
|
199
|
+
.replace(/\*([^*]*)\*/g, '$1') // italics
|
|
200
|
+
.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1') // links keep their label
|
|
201
|
+
.replace(/\s+/g, ' ')
|
|
202
|
+
.trim();
|
|
203
|
+
if (!item) continue;
|
|
204
|
+
// Release notes lead with the change and follow with the reasoning, often
|
|
205
|
+
// for several sentences. The first sentence is the change, so that is what
|
|
206
|
+
// a one-line notice keeps — cutting at a character count instead lands
|
|
207
|
+
// mid-clause and reads as a broken string rather than a short one.
|
|
208
|
+
//
|
|
209
|
+
// The break does not require a space after the stop: "…checked.The matcher…"
|
|
210
|
+
// is a typo a release body can carry, and requiring one there merged the two
|
|
211
|
+
// sentences into a single over-long line. A capital letter or a Hangul
|
|
212
|
+
// syllable directly after the stop is enough to say a new sentence started;
|
|
213
|
+
// a digit is not, so "0.5s" and "v3.45.0" stay intact.
|
|
214
|
+
//
|
|
215
|
+
// Abbreviations are excluded by name. "Fixed e.g. the matcher" would
|
|
216
|
+
// otherwise end after two words, which is worse than a long line: it reads
|
|
217
|
+
// as a complete thought and is not one. This list covers what actually turns
|
|
218
|
+
// up in release prose rather than trying to be exhaustive.
|
|
219
|
+
// `etc.` is deliberately absent: it ends a sentence as often as it continues
|
|
220
|
+
// one, so either choice is wrong half the time and the wrong one here merges
|
|
221
|
+
// two sentences rather than truncating one. Merging is the milder failure,
|
|
222
|
+
// and the length cap below bounds it.
|
|
223
|
+
const ABBREV = /\b(?:e\.g|i\.e|vs|cf|Dr|Mr|Ms|St|approx|no)\.$/i;
|
|
224
|
+
const parts = item.split(/(?<=[.。])(?:\s+|(?=[A-Z가-힣]))/);
|
|
225
|
+
let first = parts[0];
|
|
226
|
+
for (let k = 1; k < parts.length && ABBREV.test(first); k += 1) {
|
|
227
|
+
// The stop belonged to an abbreviation, so the sentence continues.
|
|
228
|
+
first = `${first} ${parts[k]}`;
|
|
229
|
+
}
|
|
230
|
+
item = first || item;
|
|
231
|
+
// Some first sentences are themselves a paragraph. Those get cut, but at a
|
|
232
|
+
// word boundary and with the ellipsis that says so.
|
|
233
|
+
if (item.length > MAX_HIGHLIGHT_LEN) {
|
|
234
|
+
const cut = item.slice(0, MAX_HIGHLIGHT_LEN);
|
|
235
|
+
const space = cut.lastIndexOf(' ');
|
|
236
|
+
item = (space > 40 ? cut.slice(0, space) : cut).replace(/[,;:—\-]$/, '').trim() + '…';
|
|
237
|
+
}
|
|
238
|
+
out.push(item);
|
|
239
|
+
if (out.length >= MAX_HIGHLIGHTS) break;
|
|
240
|
+
}
|
|
241
|
+
return out;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Fetch the highlights for one version. Returns [] on any failure: the notice
|
|
246
|
+
* is worth showing with a bare version number, so a missing release, a rate
|
|
247
|
+
* limit, or an offline machine must not cost the user the notice itself.
|
|
248
|
+
*/
|
|
249
|
+
export async function fetchHighlights(version, { timeoutMs = FETCH_TIMEOUT_MS } = {}) {
|
|
250
|
+
const controller = new AbortController();
|
|
251
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
252
|
+
try {
|
|
253
|
+
const res = await fetch(`${RELEASES_API}/v${version}`, {
|
|
254
|
+
signal: controller.signal,
|
|
255
|
+
headers: { accept: 'application/vnd.github+json' },
|
|
256
|
+
});
|
|
257
|
+
if (!res.ok) throw new Error(`releases responded ${res.status}`);
|
|
258
|
+
const body = await res.json();
|
|
259
|
+
return releaseHighlights(body && body.body);
|
|
260
|
+
} catch (e) {
|
|
261
|
+
debug('update-check:highlights', e);
|
|
262
|
+
return [];
|
|
263
|
+
} finally {
|
|
264
|
+
clearTimeout(timer);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
146
268
|
/**
|
|
147
269
|
* Actually hit the registry and persist the answer. Only the detached child
|
|
148
270
|
* and the explicit `update-check --refresh` command call this.
|
|
@@ -168,6 +290,21 @@ export async function refreshUpdateState(currentVersion) {
|
|
|
168
290
|
if (prev.dismissedVersion && isNewer(latest, prev.dismissedVersion)) {
|
|
169
291
|
delete next.dismissedVersion;
|
|
170
292
|
}
|
|
293
|
+
// What the new version adds, so the notice can answer "why would I".
|
|
294
|
+
// Only worth a second request when there is actually an upgrade to describe,
|
|
295
|
+
// 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);
|
|
298
|
+
if (highlights.length) {
|
|
299
|
+
next.highlights = highlights;
|
|
300
|
+
next.highlightsFor = latest;
|
|
301
|
+
} else {
|
|
302
|
+
// Drop notes belonging to an older version rather than showing them
|
|
303
|
+
// against this one.
|
|
304
|
+
delete next.highlights;
|
|
305
|
+
delete next.highlightsFor;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
171
308
|
writeUpdateState(next);
|
|
172
309
|
return { ok: true, latest };
|
|
173
310
|
} catch (e) {
|
package/src/window-labels.js
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Friendly labels + icons for `rate_limits.*` keys from Claude Code's stdin
|
|
2
|
+
* Friendly labels + icons for `rate_limits.*` keys from Claude Code's stdin.
|
|
3
|
+
*
|
|
4
|
+
* Each window carries two glyphs. `icon` is the emoji; `narrowIcon` is the
|
|
5
|
+
* one-cell glyph used where emoji garble the line, chosen by JetBrains Mono
|
|
6
|
+
* coverage for the reason src/glyphs.js explains.
|
|
3
7
|
* payload. Known keys (`five_hour`, `seven_day`) get curated short/long labels
|
|
4
8
|
* and dedicated icons. Unknown keys are passed through with a derived label so
|
|
5
9
|
* any future window Anthropic adds (e.g. `seven_day_sonnet`) renders without
|
|
@@ -17,15 +21,21 @@ const KNOWN = {
|
|
|
17
21
|
// 'current' mirrors how `weekly` reads next to 7D — names the window in
|
|
18
22
|
// plain English so a glance at `✦ current ████▒░ 72%` tells the eye what's
|
|
19
23
|
// being measured without parsing the icon's meaning.
|
|
20
|
-
five_hour: { short: '5H', long: 'Current session', icon: '✦', usageLabel: 'current' },
|
|
21
|
-
seven_day: { short: '7D', long: 'Current week', icon: '📅', usageLabel: 'weekly' },
|
|
24
|
+
five_hour: { short: '5H', long: 'Current session', icon: '✦', narrowIcon: '✶', usageLabel: 'current' },
|
|
25
|
+
seven_day: { short: '7D', long: 'Current week', icon: '📅', narrowIcon: '⌸', usageLabel: 'weekly' },
|
|
22
26
|
// Speculative — `/usage` shows a Sonnet-only weekly bucket, so if Anthropic
|
|
23
27
|
// ever surfaces it on stdin we render with a sensible default already.
|
|
24
|
-
seven_day_sonnet: { short: '7D-S', long: 'Current week (Sonnet)', icon: '🅂', usageLabel: 'weekly (Sonnet)' },
|
|
25
|
-
seven_day_opus: { short: '7D-O', long: 'Current week (Opus)', icon: '🅾', usageLabel: 'weekly (Opus)' },
|
|
28
|
+
seven_day_sonnet: { short: '7D-S', long: 'Current week (Sonnet)', icon: '🅂', narrowIcon: '◇', usageLabel: 'weekly (Sonnet)' },
|
|
29
|
+
seven_day_opus: { short: '7D-O', long: 'Current week (Opus)', icon: '🅾', narrowIcon: '◆', usageLabel: 'weekly (Opus)' },
|
|
30
|
+
// 💳 는 지출 한도를 가리킨다. 이전에 쓰던 🔑 은 인증 수단을 연상시키므로,
|
|
31
|
+
// 이 칩이 새로 보는 사람에게 키 자신의 상태로 읽힐 여지가 있었다.
|
|
26
32
|
// LiteLLM 게이트웨이 키 예산. rate_limits 가 없는 환경에서 cap 게이지를
|
|
27
33
|
// 대신하는 합성 윈도우라서, 여기 라벨만 있으면 나머지 렌더는 공용 경로를 탄다.
|
|
28
|
-
litellm_budget: {
|
|
34
|
+
litellm_budget: {
|
|
35
|
+
short: 'BUDGET', long: 'LiteLLM key budget', icon: '💳', narrowIcon: '◫', usageLabel: 'budget',
|
|
36
|
+
// Budgets reset on a billing cycle, so "in 3d 4h" beats a bare weekday.
|
|
37
|
+
resetStyle: 'countdown',
|
|
38
|
+
},
|
|
29
39
|
};
|
|
30
40
|
|
|
31
41
|
function deriveShort(key) {
|
|
@@ -60,5 +70,5 @@ function deriveLong(key) {
|
|
|
60
70
|
export function labelForKey(key) {
|
|
61
71
|
if (KNOWN[key]) return KNOWN[key];
|
|
62
72
|
const short = deriveShort(key);
|
|
63
|
-
return { short, long: deriveLong(key), icon: '⏱', usageLabel: short };
|
|
73
|
+
return { short, long: deriveLong(key), icon: '⏱', narrowIcon: '◕', usageLabel: short };
|
|
64
74
|
}
|