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.
@@ -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) {
@@ -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: { short: 'BUDGET', long: 'LiteLLM key budget', icon: '🔑', usageLabel: '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
  }