claudeup 5.0.0 → 6.0.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,8 +10,27 @@ import {
10
10
  } from "../services/gitignore-fixer";
11
11
  import type { Violation } from "../types/gitignore";
12
12
 
13
+ // `commit.gpgsign=false` is not tidiness — without it these tests HANG.
14
+ //
15
+ // Two tests here run `git commit` in a temp repo, which inherits the
16
+ // developer's global config. On any machine with commit signing enabled and a
17
+ // GUI signer (`gpg.format=ssh` pointing at 1Password's `op-ssh-sign` is the
18
+ // common setup here), the commit blocks waiting for an approval dialog nobody
19
+ // is watching, and both tests fail on a 5s timeout. It presents as a flake
20
+ // because the approval is cached once granted, so the suite goes green until
21
+ // the cache expires. `marketplace-refresh.test.ts` already guards this the same
22
+ // way; this file did not.
23
+ const GIT_CFG = [
24
+ "-c",
25
+ "core.excludesFile=/dev/null",
26
+ "-c",
27
+ "commit.gpgsign=false",
28
+ "-c",
29
+ "init.defaultBranch=main",
30
+ ];
31
+
13
32
  function git(cwd: string, ...args: string[]): { stdout: string; status: number } {
14
- const r = spawnSync("git", ["-c", "core.excludesFile=/dev/null", ...args], {
33
+ const r = spawnSync("git", [...GIT_CFG, ...args], {
15
34
  cwd,
16
35
  encoding: "utf8",
17
36
  });
@@ -243,6 +243,69 @@ describe("refreshRegisteredMarketplaces — selection & skips", () => {
243
243
  });
244
244
  });
245
245
 
246
+ describe("refreshRegisteredMarketplaces — progress taps", () => {
247
+ it("reports the eligible set before pulling, then each clone as it settles", async () => {
248
+ // The whole reason these exist: this call is the slowest thing on the
249
+ // update path and used to report only once every clone had settled.
250
+ installClone("magus");
251
+ installClone("other");
252
+ publishV2();
253
+ configured = { magus: gh(), other: gh("x/y") };
254
+
255
+ const started: string[][] = [];
256
+ const settled: Array<[string, string]> = [];
257
+ const r = await refreshRegisteredMarketplaces([], {
258
+ onStart: (names) => started.push([...names]),
259
+ onSettled: (name, status) => settled.push([name, status]),
260
+ });
261
+
262
+ expect(started).toHaveLength(1);
263
+ expect(started[0]!.sort()).toEqual(["magus", "other"]);
264
+ // One event per eligible clone, and the statuses agree with the result.
265
+ expect(settled.map(([n]) => n).sort()).toEqual(["magus", "other"]);
266
+ expect(settled.every(([, s]) => s === "refreshed")).toBe(true);
267
+ expect(r.refreshed.sort()).toEqual(["magus", "other"]);
268
+ });
269
+
270
+ it("excludes an autoUpdate-disabled marketplace from the denominator", async () => {
271
+ // The progress meter's total comes from onStart. Counting a marketplace
272
+ // that is never pulled would leave the bar permanently short.
273
+ installClone("magus");
274
+ installClone("other");
275
+ publishV2();
276
+ configured = { magus: gh(), other: gh("x/y") };
277
+ autoUpdate = { magus: false };
278
+
279
+ const started: string[][] = [];
280
+ await refreshRegisteredMarketplaces([], {
281
+ onStart: (names) => started.push([...names]),
282
+ });
283
+
284
+ expect(started[0]).toEqual(["other"]);
285
+ });
286
+
287
+ it("a throwing observer cannot fail the refresh", async () => {
288
+ // onSettled fires inside the Promise.all fan-out, so an unguarded throw
289
+ // would reject the batch — turning a display bug into a failed refresh
290
+ // and breaking this function's documented "never throws" contract.
291
+ installClone("magus");
292
+ publishV2();
293
+ configured = { magus: gh() };
294
+
295
+ const r = await refreshRegisteredMarketplaces([], {
296
+ onStart: () => {
297
+ throw new Error("display exploded");
298
+ },
299
+ onSettled: () => {
300
+ throw new Error("display exploded again");
301
+ },
302
+ });
303
+
304
+ expect(r.refreshed).toEqual(["magus"]);
305
+ expect(r.failed).toEqual([]);
306
+ });
307
+ });
308
+
246
309
  describe("fastForwardCloneIfPresent — single-clone helper (shared with claude-cli recovery)", () => {
247
310
  it("returns 'absent' when no clone is on disk (caller then clones via add)", async () => {
248
311
  expect(await fastForwardCloneIfPresent("magus")).toBe("absent");
@@ -0,0 +1,512 @@
1
+ /**
2
+ * ansi.ts — colour, colour maths and one-row widgets for the printed commands.
3
+ *
4
+ * The interactive TUI draws through OpenTUI. `claudeup update`, `install` and
5
+ * `doctor` cannot: they print into the user's scrollback, interleave readline
6
+ * prompts, and hand the terminal to subprocesses that inherit stdio. Mounting a
7
+ * renderer around that fights all three. So the same visual language is rebuilt
8
+ * here on raw escape sequences — one blended colour per meter cell, badges with
9
+ * ink we own, dim chrome and saturated signal.
10
+ *
11
+ * PALETTE PARITY IS THE POINT. Every hue comes from `ui/theme.ts`'s `brand`, so
12
+ * a colour means one thing across the whole product: amber is "behind" in the
13
+ * TUI's plugin list and in an update plan alike. Nothing here invents a hue.
14
+ *
15
+ * The two `ramps` are the one place brand tokens are transformed, and they say
16
+ * why: `brand` is tuned to clear 3:1 as INK on both light and dark terminals,
17
+ * which makes it mid-dark. A solid block is a UI component, not text — WCAG's
18
+ * text ratio does not apply to it, and mid-dark blocks read as mud. So the fill
19
+ * ramps lighten those tokens. Ink colours are never lightened.
20
+ *
21
+ * No OpenTUI type crosses this boundary: hex in, escaped string out.
22
+ */
23
+
24
+ import { brand } from "../ui/theme.js";
25
+
26
+ // -- colour capability -------------------------------------------------------
27
+
28
+ /** Test seam. `null` restores environment detection. */
29
+ let colorOverride: boolean | null = null;
30
+
31
+ /** Force colour on or off; pass null to go back to detecting it. Tests only. */
32
+ export function setColorEnabled(value: boolean | null): void {
33
+ colorOverride = value;
34
+ }
35
+
36
+ /**
37
+ * Whether to emit escape sequences at all.
38
+ *
39
+ * Order matters: NO_COLOR (any non-empty value, per no-color.org) beats
40
+ * FORCE_COLOR, which beats TTY detection. The other order would hand colour to
41
+ * a user who exported NO_COLOR in their shell profile the moment they ran under
42
+ * a CI that sets FORCE_COLOR.
43
+ */
44
+ export function colorEnabled(): boolean {
45
+ if (colorOverride !== null) return colorOverride;
46
+ const no = process.env.NO_COLOR;
47
+ if (no !== undefined && no !== "") return false;
48
+ const force = process.env.FORCE_COLOR;
49
+ if (force !== undefined) return force !== "0" && force !== "false";
50
+ if (process.env.TERM === "dumb") return false;
51
+ return process.stdout.isTTY === true;
52
+ }
53
+
54
+ // -- hex and channels --------------------------------------------------------
55
+
56
+ const clamp01 = (n: number): number =>
57
+ Number.isFinite(n) ? Math.min(1, Math.max(0, n)) : 0;
58
+
59
+ /** `#rgb` and `#rrggbb`, hash optional. Anything else resolves to black. */
60
+ function parseHex(hex: string): [number, number, number] {
61
+ let h = hex.trim().replace(/^#/, "");
62
+ if (h.length === 3) h = h[0]! + h[0]! + h[1]! + h[1]! + h[2]! + h[2]!;
63
+ if (!/^[0-9a-fA-F]{6}$/.test(h)) return [0, 0, 0];
64
+ return [
65
+ Number.parseInt(h.slice(0, 2), 16),
66
+ Number.parseInt(h.slice(2, 4), 16),
67
+ Number.parseInt(h.slice(4, 6), 16),
68
+ ];
69
+ }
70
+
71
+ const toHex = (c: [number, number, number]): string =>
72
+ `#${c
73
+ .map((n) =>
74
+ Math.round(Math.min(255, Math.max(0, n)))
75
+ .toString(16)
76
+ .padStart(2, "0"),
77
+ )
78
+ .join("")}`;
79
+
80
+ // -- colour maths ------------------------------------------------------------
81
+
82
+ /**
83
+ * Linear interpolation in sRGB channel space, `t` clamped to 0..1.
84
+ *
85
+ * sRGB is a deliberate choice with a known cost: a two-stop blend between
86
+ * distant hues desaturates through the middle. Both ramps below therefore carry
87
+ * an explicit midtone stop rather than trusting the interpolator to find a good
88
+ * one — a chosen control point beats any colour space.
89
+ */
90
+ export function mix(from: string, to: string, t: number): string {
91
+ const k = clamp01(t);
92
+ const a = parseHex(from);
93
+ const b = parseHex(to);
94
+ return toHex([
95
+ a[0] + (b[0] - a[0]) * k,
96
+ a[1] + (b[1] - a[1]) * k,
97
+ a[2] + (b[2] - a[2]) * k,
98
+ ]);
99
+ }
100
+
101
+ /**
102
+ * `steps` colours from `from` to `to`, BOTH ENDPOINTS INCLUSIVE.
103
+ *
104
+ * out.length === steps, out[0] === from, out[steps - 1] === to
105
+ *
106
+ * `steps === 1` takes the `from` end, so there is no division by zero. Zero,
107
+ * negative and non-integer all give `[]`. Call it with the widget's real width:
108
+ * one colour per cell is what reads as continuous.
109
+ */
110
+ export function blend1D(steps: number, from: string, to: string): string[] {
111
+ if (!Number.isInteger(steps) || steps <= 0) return [];
112
+ if (steps === 1) return [toHex(parseHex(from))];
113
+ return Array.from({ length: steps }, (_, i) => mix(from, to, i / (steps - 1)));
114
+ }
115
+
116
+ /** Multi-stop ramp: `steps` colours spread evenly across every stop, in order. */
117
+ export function blendStops(steps: number, ...stops: string[]): string[] {
118
+ if (!Number.isInteger(steps) || steps <= 0 || stops.length === 0) return [];
119
+ if (stops.length === 1 || steps === 1)
120
+ return blend1D(steps, stops[0]!, stops.at(-1)!);
121
+ const segs = stops.length - 1;
122
+ return Array.from({ length: steps }, (_, i) => {
123
+ const p = (i / (steps - 1)) * segs;
124
+ const s = Math.min(segs - 1, Math.floor(p));
125
+ return mix(stops[s]!, stops[s + 1]!, p - s);
126
+ });
127
+ }
128
+
129
+ /** Scale toward black by `amount` (0..1). */
130
+ export function darken(hex: string, amount: number): string {
131
+ const k = 1 - clamp01(amount);
132
+ const [r, g, b] = parseHex(hex);
133
+ return toHex([r * k, g * k, b * k]);
134
+ }
135
+
136
+ /** Scale toward white by `amount` (0..1). */
137
+ export function lighten(hex: string, amount: number): string {
138
+ const k = clamp01(amount);
139
+ const [r, g, b] = parseHex(hex);
140
+ return toHex([r + (255 - r) * k, g + (255 - g) * k, b + (255 - b) * k]);
141
+ }
142
+
143
+ /**
144
+ * Ink that stays readable on a filled block of `hex`.
145
+ *
146
+ * Perceived luminance, not the channel average: green contributes nearly six
147
+ * times what blue does, so an average puts white ink on amber and loses it.
148
+ */
149
+ export function inkOn(hex: string): string {
150
+ const [r, g, b] = parseHex(hex);
151
+ return 0.299 * r + 0.587 * g + 0.114 * b > 140 ? "#101014" : brand.ink;
152
+ }
153
+
154
+ // -- styling -----------------------------------------------------------------
155
+
156
+ const RESET = "\x1b[0m";
157
+
158
+ /** Paint `text` in `hex` as foreground. Returns `text` unchanged when off. */
159
+ export function fg(hex: string, text: string): string {
160
+ if (!colorEnabled()) return text;
161
+ const [r, g, b] = parseHex(hex);
162
+ return `\x1b[38;2;${r};${g};${b}m${text}${RESET}`;
163
+ }
164
+
165
+ /** Paint `text` on a `hex` background. Returns `text` unchanged when off. */
166
+ export function bg(hex: string, text: string): string {
167
+ if (!colorEnabled()) return text;
168
+ const [r, g, b] = parseHex(hex);
169
+ return `\x1b[48;2;${r};${g};${b}m${text}${RESET}`;
170
+ }
171
+
172
+ /** Both in one sequence, so nothing nested can reset half a badge. */
173
+ export function on(bgHex: string, fgHex: string, text: string): string {
174
+ if (!colorEnabled()) return text;
175
+ const [br, bgn, bb] = parseHex(bgHex);
176
+ const [fr, fgn, fb] = parseHex(fgHex);
177
+ return `\x1b[48;2;${br};${bgn};${bb};38;2;${fr};${fgn};${fb}m${text}${RESET}`;
178
+ }
179
+
180
+ export function bold(text: string): string {
181
+ return colorEnabled() ? `\x1b[1m${text}${RESET}` : text;
182
+ }
183
+
184
+ /** De-emphasis by colour, not by SGR 2 — faint is unreliable across terminals. */
185
+ export function dim(text: string): string {
186
+ return fg(brand.muted, text);
187
+ }
188
+
189
+ // -- measuring ---------------------------------------------------------------
190
+
191
+ /** Every CSI sequence, not only SGR — the live region emits cursor moves too. */
192
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: ESC is the thing being matched.
193
+ const CSI = /\x1b\[[0-9;?]*[\x20-\x2f]*[\x40-\x7e]/g;
194
+
195
+ export function stripAnsi(text: string): string {
196
+ return text.replace(CSI, "");
197
+ }
198
+
199
+ /**
200
+ * Cells one codepoint occupies: 0, 1 or 2.
201
+ *
202
+ * UNDER-COUNTING IS THE DANGEROUS DIRECTION. A line measured narrower than it
203
+ * renders reaches the terminal's last column, auto-wraps into a second row, and
204
+ * then the live region's cursor-up erase — which counted it as one row — starts
205
+ * inside the block and smears it down the screen. Over-counting only clips a
206
+ * character early. So where a range is genuinely in doubt, widen.
207
+ *
208
+ * The ranges are East Asian Wide plus the emoji blocks that render double in
209
+ * every terminal worth supporting. EAW-AMBIGUOUS characters (most of the
210
+ * U+2500 box-drawing and arrow range, including `↑` and `→` used in the update
211
+ * report) are deliberately counted as 1: terminals genuinely disagree, and
212
+ * treating them as wide would misalign the common, correct case.
213
+ */
214
+ function charWidth(cp: number): number {
215
+ // Combining marks, variation selectors, and the zero-width joiner take no
216
+ // cell. ZWJ matters: without it a three-person emoji family measures as
217
+ // three separate wide glyphs instead of one.
218
+ if (
219
+ (cp >= 0x0300 && cp <= 0x036f) ||
220
+ (cp >= 0xfe00 && cp <= 0xfe0f) ||
221
+ cp === 0x200d ||
222
+ cp === 0xfeff
223
+ )
224
+ return 0;
225
+ if (
226
+ (cp >= 0x1100 && cp <= 0x115f) ||
227
+ // Individually-Wide dingbats and symbols scattered through U+2000-27BF.
228
+ cp === 0x231a ||
229
+ cp === 0x231b ||
230
+ (cp >= 0x23e9 && cp <= 0x23ec) ||
231
+ cp === 0x23f0 ||
232
+ cp === 0x23f3 ||
233
+ (cp >= 0x25fd && cp <= 0x25fe) ||
234
+ (cp >= 0x2614 && cp <= 0x2615) ||
235
+ (cp >= 0x2648 && cp <= 0x2653) ||
236
+ cp === 0x267f ||
237
+ cp === 0x2693 ||
238
+ cp === 0x26a1 ||
239
+ (cp >= 0x26aa && cp <= 0x26ab) ||
240
+ (cp >= 0x26bd && cp <= 0x26be) ||
241
+ (cp >= 0x26c4 && cp <= 0x26c5) ||
242
+ cp === 0x26ce ||
243
+ cp === 0x26d4 ||
244
+ cp === 0x26ea ||
245
+ (cp >= 0x26f2 && cp <= 0x26f3) ||
246
+ cp === 0x26f5 ||
247
+ cp === 0x26fa ||
248
+ cp === 0x26fd ||
249
+ cp === 0x2705 ||
250
+ (cp >= 0x270a && cp <= 0x270b) ||
251
+ cp === 0x2728 ||
252
+ cp === 0x274c ||
253
+ cp === 0x274e ||
254
+ (cp >= 0x2753 && cp <= 0x2755) ||
255
+ cp === 0x2757 ||
256
+ (cp >= 0x2795 && cp <= 0x2797) ||
257
+ cp === 0x27b0 ||
258
+ cp === 0x27bf ||
259
+ (cp >= 0x2b1b && cp <= 0x2b1c) ||
260
+ cp === 0x2b50 ||
261
+ cp === 0x2b55 ||
262
+ (cp >= 0x2e80 && cp <= 0x303e) ||
263
+ (cp >= 0x3041 && cp <= 0x33ff) ||
264
+ (cp >= 0x3400 && cp <= 0x4dbf) ||
265
+ (cp >= 0x4e00 && cp <= 0x9fff) ||
266
+ (cp >= 0xa000 && cp <= 0xa4cf) ||
267
+ (cp >= 0xac00 && cp <= 0xd7a3) ||
268
+ (cp >= 0xf900 && cp <= 0xfaff) ||
269
+ (cp >= 0xfe30 && cp <= 0xfe6f) ||
270
+ (cp >= 0xff00 && cp <= 0xff60) ||
271
+ (cp >= 0xffe0 && cp <= 0xffe6) ||
272
+ // Emoji planes, contiguous rather than the three islands this used to
273
+ // carry — the gaps (transport at U+1F680, flags at U+1F1E6) all render
274
+ // wide, so excluding them was pure under-count.
275
+ (cp >= 0x1f000 && cp <= 0x1f9ff) ||
276
+ (cp >= 0x1fa70 && cp <= 0x1faff) ||
277
+ (cp >= 0x20000 && cp <= 0x3fffd)
278
+ )
279
+ return 2;
280
+ return 1;
281
+ }
282
+
283
+ /** Visible cell width, ignoring escape sequences. */
284
+ export function width(text: string): number {
285
+ let w = 0;
286
+ for (const ch of stripAnsi(text)) w += charWidth(ch.codePointAt(0)!);
287
+ return w;
288
+ }
289
+
290
+ /** Pad on the right to `n` visible cells. Never truncates. */
291
+ export function padEnd(text: string, n: number): string {
292
+ const w = width(text);
293
+ return w >= n ? text : text + " ".repeat(n - w);
294
+ }
295
+
296
+ /** Pad on the left to `n` visible cells — the only safe padding for numerals. */
297
+ export function padStart(text: string, n: number): string {
298
+ const w = width(text);
299
+ return w >= n ? text : " ".repeat(n - w) + text;
300
+ }
301
+
302
+ /**
303
+ * Clip to `n` visible cells, stepping over escape sequences rather than
304
+ * counting them.
305
+ *
306
+ * The live region depends on this. A line one cell too wide occupies two
307
+ * terminal rows while the repainter still counts it as one, so the cursor-up
308
+ * arithmetic drifts by a row per frame and the block smears down the screen.
309
+ */
310
+ export function truncate(text: string, n: number): string {
311
+ if (n <= 0) return "";
312
+ if (width(text) <= n) return text;
313
+ let out = "";
314
+ let w = 0;
315
+ let i = 0;
316
+ while (i < text.length) {
317
+ CSI.lastIndex = i;
318
+ const m = CSI.exec(text);
319
+ if (m && m.index === i) {
320
+ out += m[0];
321
+ i += m[0].length;
322
+ continue;
323
+ }
324
+ const ch = String.fromCodePoint(text.codePointAt(i)!);
325
+ const cw = charWidth(ch.codePointAt(0)!);
326
+ if (w + cw > n) break;
327
+ out += ch;
328
+ w += cw;
329
+ i += ch.length;
330
+ }
331
+ return out + (colorEnabled() ? RESET : "");
332
+ }
333
+
334
+ // -- palette derived for fills -----------------------------------------------
335
+
336
+ /**
337
+ * Gradient stops for filled blocks. Lightened brand tokens — the file header
338
+ * says why ink hues and fill hues cannot be the same values.
339
+ */
340
+ export const ramps = {
341
+ /**
342
+ * Work in progress to done. Purple is claudeup's brand, green is "settled",
343
+ * and the blue midtone is the chosen control point that stops a two-hue
344
+ * blend desaturating into grey at the halfway mark.
345
+ */
346
+ progress: [
347
+ lighten(brand.accent, 0.15),
348
+ lighten(brand.link, 0.2),
349
+ lighten(brand.success, 0.25),
350
+ ],
351
+ /**
352
+ * Severity: green through amber to red, for any magnitude that is bad news
353
+ * as it grows — a step's share of the run's wall clock, a count of items
354
+ * behind. Same three hues as the badges, so "amber means look at this" holds
355
+ * whether it is a bar or a chip.
356
+ */
357
+ severity: [
358
+ lighten(brand.success, 0.25),
359
+ lighten(brand.warning, 0.2),
360
+ lighten(brand.danger, 0.15),
361
+ ],
362
+ } as const;
363
+
364
+ // -- widgets -----------------------------------------------------------------
365
+
366
+ const FILL = "█";
367
+ const TRACK = "░";
368
+
369
+ /**
370
+ * The unfilled part of a bar.
371
+ *
372
+ * Deliberately much darker than `brand.muted`. At full `muted` a 20-cell `░`
373
+ * track reads as hatched texture rather than as emptiness, and it competes with
374
+ * the fill for the eye — which is the opposite of "dim the chrome, saturate the
375
+ * signal". A track is chrome.
376
+ */
377
+ const TRACK_COLOR = darken(brand.muted, 0.68);
378
+
379
+ /**
380
+ * Gradient meter: `cells` wide, one blended colour per filled cell.
381
+ *
382
+ * `pct` is 0..100, never 0..1 — the single most common way this widget goes
383
+ * silently wrong. The track glyph is the same width as the fill glyph, so the
384
+ * row cannot change length as it fills.
385
+ */
386
+ export function meter(
387
+ pct: number,
388
+ cells: number,
389
+ stops: readonly string[] = ramps.progress,
390
+ ): string {
391
+ if (cells <= 0) return "";
392
+ const p = clamp01((Number.isFinite(pct) ? pct : 0) / 100);
393
+ const filled = Math.round(p * cells);
394
+ const cols = blendStops(cells, ...(stops.length > 0 ? stops : ramps.progress));
395
+ let out = "";
396
+ for (let i = 0; i < cells; i++) {
397
+ out += i < filled ? fg(cols[i]!, FILL) : fg(TRACK_COLOR, TRACK);
398
+ }
399
+ return out;
400
+ }
401
+
402
+ /** One share of a stacked bar, in one semantic colour. */
403
+ export interface BarSegment {
404
+ value: number;
405
+ color: string;
406
+ }
407
+
408
+ /**
409
+ * Stacked bar for a category distribution — the counts ARE the shape.
410
+ *
411
+ * Exactly `cells` wide, always. Two rules fight for that width and the order
412
+ * between them is the whole design:
413
+ *
414
+ * 1. Every non-zero segment gets at least one cell, so one outlier in a large
415
+ * set stays visible instead of rounding to nothing.
416
+ * 2. The row is never wider than `cells`.
417
+ *
418
+ * Rule 2 wins. When there are more non-zero segments than cells, rule 1 is
419
+ * impossible — the earlier version tried anyway: its shrink loop gave up once
420
+ * every segment was down to one cell and returned a row WIDER than asked for
421
+ * (measured: 5 cells for `cells = 3` with five segments), silently breaking the
422
+ * alignment every caller depends on. Now the largest segments keep their cell
423
+ * and the smallest are dropped, which is lossy but bounded and visible.
424
+ */
425
+ export function stackedBar(cells: number, segments: BarSegment[]): string {
426
+ if (cells <= 0) return "";
427
+ let live = segments.filter((s) => s.value > 0);
428
+ if (live.length === 0) return fg(TRACK_COLOR, TRACK.repeat(cells));
429
+ // More segments than cells: keep the biggest `cells` of them, in place.
430
+ if (live.length > cells) {
431
+ const cutoff = [...live]
432
+ .sort((a, b) => b.value - a.value)
433
+ .slice(0, cells)
434
+ .reduce((min, s) => Math.min(min, s.value), Number.POSITIVE_INFINITY);
435
+ const kept: BarSegment[] = [];
436
+ for (const s of live) {
437
+ if (s.value >= cutoff && kept.length < cells) kept.push(s);
438
+ }
439
+ live = kept;
440
+ }
441
+ const total = live.reduce((n, s) => n + s.value, 0);
442
+ const widths = live.map((s) =>
443
+ Math.max(1, Math.floor((s.value / total) * cells)),
444
+ );
445
+ let drift = cells - widths.reduce((n, w) => n + w, 0);
446
+ while (drift !== 0) {
447
+ const i = widths.indexOf(Math.max(...widths));
448
+ if (drift > 0) {
449
+ widths[i]!++;
450
+ drift--;
451
+ } else if (widths[i]! > 1) {
452
+ widths[i]!--;
453
+ drift++;
454
+ } else break;
455
+ }
456
+ return live.map((s, i) => fg(s.color, FILL.repeat(widths[i]!))).join("");
457
+ }
458
+
459
+ /**
460
+ * Indeterminate progress: a comet sweeping a dim track, `cells` wide.
461
+ *
462
+ * The honest widget for work with no denominator — a catalog read, a git fetch
463
+ * whose object count nobody exposes. A determinate meter would have to invent a
464
+ * percentage, and an invented percentage that sticks at 90% is worse than no
465
+ * bar at all. The head is full `hue`, the tail fades toward black over `band`
466
+ * cells, and the whole thing wraps every `cells + band` ticks.
467
+ */
468
+ export function sweep(
469
+ cells: number,
470
+ tick: number,
471
+ hue: string,
472
+ band = 5,
473
+ ): string {
474
+ if (cells <= 0) return "";
475
+ const period = cells + band;
476
+ const head = ((Math.trunc(tick) % period) + period) % period;
477
+ let out = "";
478
+ for (let i = 0; i < cells; i++) {
479
+ const behind = head - i;
480
+ const heat = behind >= 0 && behind < band ? 1 - behind / band : 0;
481
+ out +=
482
+ heat <= 0
483
+ ? fg(TRACK_COLOR, TRACK)
484
+ : fg(mix(darken(hue, 0.75), hue, heat), FILL);
485
+ }
486
+ return out;
487
+ }
488
+
489
+ /** A discrete status, as dark-or-white ink on a saturated fill. */
490
+ export function badge(label: string, hex: string): string {
491
+ return on(hex, inkOn(hex), ` ${label} `);
492
+ }
493
+
494
+ /**
495
+ * Braille spinner. Ten frames, so a 12 fps repaint cycles in under a second:
496
+ * fast enough to read as motion, slow enough not to strobe.
497
+ */
498
+ const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] as const;
499
+
500
+ export function spinner(tick: number): string {
501
+ return SPINNER[Math.abs(Math.trunc(tick)) % SPINNER.length]!;
502
+ }
503
+
504
+ /** Elapsed time sized to its magnitude: `340ms`, `1.4s`, `2m04s`. */
505
+ export function elapsed(ms: number): string {
506
+ if (!Number.isFinite(ms) || ms < 0) return "--";
507
+ if (ms < 1000) return `${Math.round(ms)}ms`;
508
+ if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
509
+ const m = Math.floor(ms / 60_000);
510
+ const s = Math.round((ms - m * 60_000) / 1000);
511
+ return `${m}m${String(s).padStart(2, "0")}s`;
512
+ }
@@ -67,12 +67,6 @@ function printAdoptionPlan(result: AdoptResult): void {
67
67
  }
68
68
 
69
69
  export interface EnsureManifestOptions {
70
- /**
71
- * Skip confirmation prompts. Deliberately does NOT authorize adoption —
72
- * `--yes` means "I already know what this will do", and nobody passing it to
73
- * an install has decided what their team's committed manifest should contain.
74
- */
75
- yes?: boolean;
76
70
  /** Read-only mode: never write, just report that adoption is needed. */
77
71
  check?: boolean;
78
72
  /**
@@ -241,8 +241,11 @@ export async function runInstallCommand(
241
241
 
242
242
  // A repo with no manifest is offered adoption rather than an error — see
243
243
  // cli/bootstrap.ts for why there is no manifest-free install path.
244
+ // `--yes` is deliberately NOT forwarded: it means "do not ask me about the
245
+ // thing I invoked", and someone running `claudeup install --yes` in CI
246
+ // invoked an install, not the authoring of their team's committed manifest.
247
+ // Creating that file needs a human at the prompt, or `profile init`.
244
248
  const manifest = await ensureManifest(projectPath, {
245
- yes: flags.yes,
246
249
  check: flags.check,
247
250
  });
248
251
  if (!manifest) return 1;