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.
- package/package.json +4 -4
- package/src/__tests__/cli-ansi.test.ts +297 -0
- package/src/__tests__/cli-apply-seams.test.ts +233 -0
- package/src/__tests__/cli-live.test.ts +384 -0
- package/src/__tests__/cli-update-view.test.ts +286 -0
- package/src/__tests__/dotenv.test.ts +5 -0
- package/src/__tests__/gitignore-fixer.test.ts +20 -1
- package/src/__tests__/marketplace-refresh.test.ts +63 -0
- package/src/cli/ansi.ts +512 -0
- package/src/cli/bootstrap.ts +0 -6
- package/src/cli/install.ts +4 -1
- package/src/cli/live.ts +374 -0
- package/src/cli/profile.ts +1 -1
- package/src/cli/router.ts +7 -3
- package/src/cli/update-view.ts +438 -0
- package/src/cli/update.ts +457 -129
- package/src/services/doctor-bins.ts +9 -1
- package/src/services/dotenv.ts +9 -4
- package/src/services/marketplace-refresh.ts +39 -1
package/src/cli/update.ts
CHANGED
|
@@ -11,6 +11,27 @@
|
|
|
11
11
|
* dependencies" means the set actually in use. Pass a name to update another.
|
|
12
12
|
*
|
|
13
13
|
* Self-update moved to `claudeup upgrade`.
|
|
14
|
+
*
|
|
15
|
+
* ## Why this file drives a live region
|
|
16
|
+
*
|
|
17
|
+
* Most of an update's wall clock is spent inside four calls that print nothing:
|
|
18
|
+
* a git fetch per marketplace, a catalog read, a serial PATH probe per binary,
|
|
19
|
+
* then an install per plugin. The command used to print its first line after
|
|
20
|
+
* all of the first three had finished, and one line per plugin only once that
|
|
21
|
+
* plugin was already installed. On a slow network that is thirty seconds of a
|
|
22
|
+
* dead terminal, which reads as a hang, not as work.
|
|
23
|
+
*
|
|
24
|
+
* So every phase reports while it runs. `cli/live.ts` owns the mechanism. A
|
|
25
|
+
* line printed while a region is painting must go through the region, or it
|
|
26
|
+
* lands inside the animated block and the next repaint's cursor-up arithmetic
|
|
27
|
+
* erases it along with part of the frame. Prefer `live.note` here for anything
|
|
28
|
+
* this file emits deliberately — but the region also CAPTURES `console` while
|
|
29
|
+
* it paints, so a write from deeper in the call graph is routed rather than
|
|
30
|
+
* corrupting the display. That capture is not belt-and-braces: it is the only
|
|
31
|
+
* thing that covers `getAvailablePlugins`, which reaches a stale-marketplace
|
|
32
|
+
* advisory two layers down and used to erase itself mid-frame.
|
|
33
|
+
*
|
|
34
|
+
* `cli/update-view.ts` owns every string. Nothing in this file formats.
|
|
14
35
|
*/
|
|
15
36
|
|
|
16
37
|
import path from "node:path";
|
|
@@ -48,90 +69,52 @@ import {
|
|
|
48
69
|
summarizePlan,
|
|
49
70
|
} from "../services/update-plan.js";
|
|
50
71
|
import type { ProfileSkillRef, SkillInfo } from "../types/index.js";
|
|
72
|
+
import { brand } from "../ui/theme.js";
|
|
73
|
+
import { bold, dim, fg, width } from "./ansi.js";
|
|
51
74
|
import { ensureManifest } from "./bootstrap.js";
|
|
52
|
-
import {
|
|
75
|
+
import { LiveRegion, withPaused } from "./live.js";
|
|
76
|
+
import { runShell } from "./prompt.js";
|
|
77
|
+
import {
|
|
78
|
+
type ApplyState,
|
|
79
|
+
type Step,
|
|
80
|
+
applyFrame,
|
|
81
|
+
applyRow,
|
|
82
|
+
fail as failLine,
|
|
83
|
+
header,
|
|
84
|
+
info,
|
|
85
|
+
note as noteLine,
|
|
86
|
+
ok as okLine,
|
|
87
|
+
planReport,
|
|
88
|
+
stepFrame,
|
|
89
|
+
stepRecord,
|
|
90
|
+
summaryLine,
|
|
91
|
+
warn as warnLine,
|
|
92
|
+
} from "./update-view.js";
|
|
53
93
|
|
|
54
94
|
interface UpdateFlags {
|
|
95
|
+
/** Report drift and exit 1 if anything is behind. The CI gate. */
|
|
55
96
|
check: boolean;
|
|
56
|
-
|
|
97
|
+
/** Show the plan and stop. Always exits 0 — it is a preview, not a gate. */
|
|
98
|
+
dryRun: boolean;
|
|
57
99
|
profile?: string;
|
|
58
100
|
}
|
|
59
101
|
|
|
60
|
-
|
|
102
|
+
/**
|
|
103
|
+
* `--yes` / `-y` are gone, and are silently ignored rather than rejected.
|
|
104
|
+
*
|
|
105
|
+
* They existed to skip a confirmation that no longer exists: `update` applies
|
|
106
|
+
* its plan. Both start with `-`, so an old script or a habit still runs and
|
|
107
|
+
* still does the right thing; there is no flag kept alive to mean nothing.
|
|
108
|
+
*/
|
|
109
|
+
export function parseArgs(args: string[]): UpdateFlags {
|
|
61
110
|
return {
|
|
62
111
|
check: args.includes("--check"),
|
|
63
|
-
|
|
112
|
+
dryRun: args.includes("--dry-run"),
|
|
64
113
|
profile: args.find((a) => !a.startsWith("-")),
|
|
65
114
|
};
|
|
66
115
|
}
|
|
67
116
|
|
|
68
|
-
//
|
|
69
|
-
|
|
70
|
-
/** Left-pad the id column so versions line up in the report. */
|
|
71
|
-
function pad(text: string, width: number): string {
|
|
72
|
-
return text.length >= width ? text : text + " ".repeat(width - text.length);
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
function describePlugin(item: PluginUpdateItem): string {
|
|
76
|
-
// An exact pin that cannot be delivered carries a note on install/update/
|
|
77
|
-
// current alike. It must ride along on every one of them: the whole point is
|
|
78
|
-
// that the manifest says one thing and the machine will hold another, and a
|
|
79
|
-
// row reading a bare "= 9.9.9" against a pin of 4.6.1 hides exactly that.
|
|
80
|
-
const suffix = item.note ? ` (${item.note})` : "";
|
|
81
|
-
switch (item.action) {
|
|
82
|
-
case "install":
|
|
83
|
-
return `+ install ${item.target ?? "latest"}${suffix}`;
|
|
84
|
-
case "update":
|
|
85
|
-
return `↑ ${item.installed} → ${item.target}${suffix}`;
|
|
86
|
-
case "repair":
|
|
87
|
-
return `⟳ repair ${item.installed} (${item.note})`;
|
|
88
|
-
case "unknown":
|
|
89
|
-
return `? unknown ${item.installed} (${item.note})`;
|
|
90
|
-
case "current":
|
|
91
|
-
return `= ${item.installed}${suffix}`;
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
function printPlan(plan: UpdatePlan): void {
|
|
96
|
-
const width = Math.max(
|
|
97
|
-
12,
|
|
98
|
-
...plan.plugins.map((p) => p.pluginId.length),
|
|
99
|
-
...plan.bins.map((b) => b.name.length),
|
|
100
|
-
...plan.skills.map((s) => s.name.length),
|
|
101
|
-
);
|
|
102
|
-
|
|
103
|
-
if (plan.plugins.length > 0) {
|
|
104
|
-
console.log("Plugins:");
|
|
105
|
-
for (const item of plan.plugins) {
|
|
106
|
-
console.log(` ${pad(item.pluginId, width)} ${describePlugin(item)}`);
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
if (plan.bins.length > 0) {
|
|
111
|
-
console.log("\nCLI tools:");
|
|
112
|
-
for (const item of plan.bins) {
|
|
113
|
-
const spec = plan.binSpecs.get(item.name);
|
|
114
|
-
const verb =
|
|
115
|
-
item.action === "current" || !spec
|
|
116
|
-
? `= pinned ${item.version}`
|
|
117
|
-
: item.action === "install"
|
|
118
|
-
? `+ install ${binInstallCommand(spec)}`
|
|
119
|
-
: `↑ upgrade ${binUpgradeCommand(spec)}`;
|
|
120
|
-
console.log(` ${pad(item.name, width)} ${verb}`);
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
if (plan.skills.length > 0) {
|
|
125
|
-
console.log("\nSkills:");
|
|
126
|
-
for (const item of plan.skills) {
|
|
127
|
-
console.log(
|
|
128
|
-
` ${pad(item.name, width)} ${item.action === "install" ? "+ install" : "↑ refresh"}`,
|
|
129
|
-
);
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
// ── apply ────────────────────────────────────────────────────────────────────
|
|
117
|
+
// -- apply -------------------------------------------------------------------
|
|
135
118
|
|
|
136
119
|
/** Map a manifest skill ref to the SkillInfo shape installSkill expects. */
|
|
137
120
|
function toSkillInfo(ref: ProfileSkillRef): SkillInfo {
|
|
@@ -169,6 +152,39 @@ export interface PluginApplyResult extends ApplyResult {
|
|
|
169
152
|
mismatches: PinMismatch[];
|
|
170
153
|
}
|
|
171
154
|
|
|
155
|
+
/**
|
|
156
|
+
* How the apply loop reports itself.
|
|
157
|
+
*
|
|
158
|
+
* Injected rather than hard-coded, because the loop is the same work whether it
|
|
159
|
+
* is driving an animated meter, printing plain lines into a pipe, or running
|
|
160
|
+
* under a test that wants silence. The default prints one line per item, which
|
|
161
|
+
* is what every non-interactive caller wants.
|
|
162
|
+
*/
|
|
163
|
+
export interface ApplyReporter {
|
|
164
|
+
/** An item is starting. Called before any of its work. */
|
|
165
|
+
begin(name: string): void;
|
|
166
|
+
/** An item finished. `detail` is already formatted for display. */
|
|
167
|
+
finish(ok: boolean, name: string, detail: string, ms: number): void;
|
|
168
|
+
/**
|
|
169
|
+
* Run `work` with the terminal released.
|
|
170
|
+
*
|
|
171
|
+
* A binary upgrade spawns `brew`/`npm` with inherited stdio: it writes and
|
|
172
|
+
* moves the cursor itself, so any animated block must be erased first or the
|
|
173
|
+
* two interleave into garbage.
|
|
174
|
+
*/
|
|
175
|
+
detach<T>(work: () => Promise<T>): Promise<T>;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const PLAIN_REPORTER: ApplyReporter = {
|
|
179
|
+
begin: () => {},
|
|
180
|
+
finish: (ok, name, detail) => {
|
|
181
|
+
const line = `${ok ? "✓" : "⚠"} ${name}${detail ? ` ${detail}` : ""}`;
|
|
182
|
+
if (ok) console.log(line);
|
|
183
|
+
else console.warn(line);
|
|
184
|
+
},
|
|
185
|
+
detach: (work) => work(),
|
|
186
|
+
};
|
|
187
|
+
|
|
172
188
|
/**
|
|
173
189
|
* The four side-effecting calls the plugin apply path makes, injectable so the
|
|
174
190
|
* path itself can be tested.
|
|
@@ -205,23 +221,46 @@ const REAL_PLUGIN_DEPS: PluginApplyDeps = {
|
|
|
205
221
|
saveInstalled: saveInstalledPluginVersionForScope,
|
|
206
222
|
};
|
|
207
223
|
|
|
224
|
+
/**
|
|
225
|
+
* Scopes the apply loop would touch for `item`. Empty means it is skipped.
|
|
226
|
+
*
|
|
227
|
+
* A plugin the profile declares but nothing has installed goes in at PROJECT
|
|
228
|
+
* scope, matching `install`: a profile is a property of one repo, so its
|
|
229
|
+
* plugins must not be enabled machine-wide.
|
|
230
|
+
*/
|
|
231
|
+
function scopesToApply(item: PluginUpdateItem): PluginScope[] {
|
|
232
|
+
if (item.action === "current" || item.action === "unknown") return [];
|
|
233
|
+
return item.action === "install" ? ["project"] : item.scopes;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Whether the apply loop will do anything for `item`.
|
|
238
|
+
*
|
|
239
|
+
* Exported shape for ONE reason: the progress meter's denominator must be the
|
|
240
|
+
* same predicate the loop actually applies. It was previously re-derived at the
|
|
241
|
+
* call site and replicated only the first of the two skips — an actionable item
|
|
242
|
+
* with empty `scopes` was counted but never run, so the bar would stop short of
|
|
243
|
+
* full and sit there looking hung at the end of a successful update.
|
|
244
|
+
*/
|
|
245
|
+
export function pluginNeedsWork(item: PluginUpdateItem): boolean {
|
|
246
|
+
return scopesToApply(item).length > 0;
|
|
247
|
+
}
|
|
248
|
+
|
|
208
249
|
export async function applyPlugins(
|
|
209
250
|
items: PluginUpdateItem[],
|
|
210
251
|
projectPath: string,
|
|
211
252
|
deps: PluginApplyDeps = REAL_PLUGIN_DEPS,
|
|
253
|
+
report: ApplyReporter = PLAIN_REPORTER,
|
|
212
254
|
): Promise<PluginApplyResult> {
|
|
213
255
|
const result: PluginApplyResult = { ok: 0, failed: [], mismatches: [] };
|
|
214
256
|
|
|
215
257
|
for (const item of items) {
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
// A plugin the profile declares but nothing has installed goes in at
|
|
219
|
-
// PROJECT scope, matching `install`: a profile is a property of one repo,
|
|
220
|
-
// so its plugins must not be enabled machine-wide.
|
|
221
|
-
const scopes: PluginScope[] =
|
|
222
|
-
item.action === "install" ? ["project"] : item.scopes;
|
|
258
|
+
const scopes = scopesToApply(item);
|
|
223
259
|
if (scopes.length === 0) continue;
|
|
224
260
|
|
|
261
|
+
report.begin(item.pluginId);
|
|
262
|
+
const startedAt = Date.now();
|
|
263
|
+
|
|
225
264
|
try {
|
|
226
265
|
for (const scope of scopes) {
|
|
227
266
|
if (item.action === "repair") {
|
|
@@ -256,11 +295,16 @@ export async function applyPlugins(
|
|
|
256
295
|
});
|
|
257
296
|
}
|
|
258
297
|
}
|
|
259
|
-
|
|
298
|
+
report.finish(
|
|
299
|
+
true,
|
|
300
|
+
item.pluginId,
|
|
301
|
+
`${scopes.join(", ")}${item.target ? ` → ${item.target}` : ""}`,
|
|
302
|
+
Date.now() - startedAt,
|
|
303
|
+
);
|
|
260
304
|
result.ok++;
|
|
261
305
|
} catch (error) {
|
|
262
306
|
const msg = error instanceof Error ? error.message : String(error);
|
|
263
|
-
|
|
307
|
+
report.finish(false, item.pluginId, msg, Date.now() - startedAt);
|
|
264
308
|
result.failed.push(item.pluginId);
|
|
265
309
|
}
|
|
266
310
|
}
|
|
@@ -268,7 +312,10 @@ export async function applyPlugins(
|
|
|
268
312
|
return result;
|
|
269
313
|
}
|
|
270
314
|
|
|
271
|
-
async function applyBins(
|
|
315
|
+
async function applyBins(
|
|
316
|
+
plan: UpdatePlan,
|
|
317
|
+
report: ApplyReporter = PLAIN_REPORTER,
|
|
318
|
+
): Promise<ApplyResult> {
|
|
272
319
|
const result: ApplyResult = { ok: 0, failed: [] };
|
|
273
320
|
for (const item of plan.bins) {
|
|
274
321
|
if (item.action === "current") continue;
|
|
@@ -278,8 +325,16 @@ async function applyBins(plan: UpdatePlan): Promise<ApplyResult> {
|
|
|
278
325
|
item.action === "install"
|
|
279
326
|
? binInstallCommand(bin)
|
|
280
327
|
: binUpgradeCommand(bin);
|
|
281
|
-
|
|
282
|
-
|
|
328
|
+
report.begin(item.name);
|
|
329
|
+
const startedAt = Date.now();
|
|
330
|
+
// The installer owns the terminal for the duration: it streams its own
|
|
331
|
+
// output through inherited stdio.
|
|
332
|
+
const succeeded = await report.detach(async () => {
|
|
333
|
+
console.log(`\n$ ${cmd}`);
|
|
334
|
+
return runShell(cmd);
|
|
335
|
+
});
|
|
336
|
+
report.finish(succeeded, item.name, cmd, Date.now() - startedAt);
|
|
337
|
+
if (succeeded) result.ok++;
|
|
283
338
|
else result.failed.push(item.name);
|
|
284
339
|
}
|
|
285
340
|
return result;
|
|
@@ -288,47 +343,106 @@ async function applyBins(plan: UpdatePlan): Promise<ApplyResult> {
|
|
|
288
343
|
async function applySkills(
|
|
289
344
|
plan: UpdatePlan,
|
|
290
345
|
projectPath: string,
|
|
346
|
+
report: ApplyReporter = PLAIN_REPORTER,
|
|
291
347
|
): Promise<ApplyResult> {
|
|
292
348
|
const result: ApplyResult = { ok: 0, failed: [] };
|
|
293
349
|
for (const item of plan.skills) {
|
|
350
|
+
report.begin(item.name);
|
|
351
|
+
const startedAt = Date.now();
|
|
294
352
|
try {
|
|
295
353
|
await installSkill(toSkillInfo(item.ref), "project", projectPath);
|
|
296
|
-
|
|
354
|
+
report.finish(true, item.name, item.ref.repo, Date.now() - startedAt);
|
|
297
355
|
result.ok++;
|
|
298
356
|
} catch (error) {
|
|
299
357
|
const msg = error instanceof Error ? error.message : String(error);
|
|
300
|
-
|
|
358
|
+
report.finish(false, item.name, msg, Date.now() - startedAt);
|
|
301
359
|
result.failed.push(item.name);
|
|
302
360
|
}
|
|
303
361
|
}
|
|
304
362
|
return result;
|
|
305
363
|
}
|
|
306
364
|
|
|
307
|
-
//
|
|
365
|
+
// -- phase runner ------------------------------------------------------------
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Run one checklist step, keeping its row and the animation truthful.
|
|
369
|
+
*
|
|
370
|
+
* `work` gets a `say` callback so a long step can narrate what it is currently
|
|
371
|
+
* blocked on — the marketplace step names the clone it is pulling, the binary
|
|
372
|
+
* step names the binary it is probing. That detail is the difference between
|
|
373
|
+
* "something is happening" and "the network call to THIS marketplace is slow".
|
|
374
|
+
*
|
|
375
|
+
* A step that throws is marked `fail` and RE-THROWN. Marking it matters: the
|
|
376
|
+
* record printed on the way out then names the step that died, instead of a
|
|
377
|
+
* stack trace landing under a checklist whose last row is still spinning.
|
|
378
|
+
*/
|
|
379
|
+
function makeStepRunner(live: LiveRegion, all: Step[]) {
|
|
380
|
+
return async function runStep<T>(
|
|
381
|
+
step: Step,
|
|
382
|
+
work: (say: (detail: string) => void) => Promise<T>,
|
|
383
|
+
): Promise<T> {
|
|
384
|
+
step.state = "running";
|
|
385
|
+
step.startedAt = Date.now();
|
|
386
|
+
step.detail = step.detail || "…";
|
|
387
|
+
live.refresh();
|
|
388
|
+
const say = (detail: string) => {
|
|
389
|
+
step.detail = detail;
|
|
390
|
+
live.refresh();
|
|
391
|
+
};
|
|
392
|
+
try {
|
|
393
|
+
return await work(say);
|
|
394
|
+
} catch (error) {
|
|
395
|
+
step.state = "fail";
|
|
396
|
+
step.detail = error instanceof Error ? error.message : String(error);
|
|
397
|
+
step.endedAt = Date.now();
|
|
398
|
+
step.progress = undefined;
|
|
399
|
+
// Collapse to the record on the way out. Without this the exception
|
|
400
|
+
// surfaces under a checklist frozen mid-spin, with the cursor still
|
|
401
|
+
// hidden — the process-exit hook restores the cursor but cannot know
|
|
402
|
+
// what the last frame should have said.
|
|
403
|
+
live.stop(stepRecord(all, Date.now()));
|
|
404
|
+
throw error;
|
|
405
|
+
} finally {
|
|
406
|
+
step.endedAt = Date.now();
|
|
407
|
+
step.progress = undefined;
|
|
408
|
+
if (step.state === "running") step.state = "done";
|
|
409
|
+
live.refresh();
|
|
410
|
+
}
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// -- entry point -------------------------------------------------------------
|
|
308
415
|
|
|
309
416
|
export async function runUpdateCommand(
|
|
310
417
|
args: string[],
|
|
311
418
|
projectPath: string = process.cwd(),
|
|
312
419
|
): Promise<number> {
|
|
313
420
|
const flags = parseArgs(args);
|
|
421
|
+
const startedAt = Date.now();
|
|
314
422
|
|
|
423
|
+
// The ONE prompt that survives, and only in a repo that has no manifest yet.
|
|
424
|
+
// Writing `.claude/profiles.json` is not part of updating: it authors the
|
|
425
|
+
// file a team commits and shares, possibly derived from this machine's
|
|
426
|
+
// personal user-scope plugin set. That is worth a human, once per repo,
|
|
427
|
+
// forever. Everything after it applies without asking.
|
|
315
428
|
const manifest = await ensureManifest(projectPath, {
|
|
316
|
-
|
|
317
|
-
check: flags.check,
|
|
429
|
+
check: flags.check || flags.dryRun,
|
|
318
430
|
});
|
|
319
431
|
if (!manifest) return 1;
|
|
320
432
|
|
|
321
433
|
const errors = validateManifest(manifest);
|
|
322
434
|
if (errors.length > 0) {
|
|
323
|
-
console.error("Invalid .claude/profiles.json:");
|
|
324
|
-
for (const e of errors) console.error(
|
|
435
|
+
console.error(failLine("Invalid .claude/profiles.json:"));
|
|
436
|
+
for (const e of errors) console.error(noteLine(`${e.path}: ${e.message}`));
|
|
325
437
|
return 1;
|
|
326
438
|
}
|
|
327
439
|
|
|
328
440
|
const profileIds = Object.keys(manifest.profiles);
|
|
329
441
|
if (flags.profile && !manifest.profiles[flags.profile]) {
|
|
330
442
|
console.error(
|
|
331
|
-
|
|
443
|
+
failLine(
|
|
444
|
+
`Profile "${flags.profile}" not found. Available: ${profileIds.join(", ")}`,
|
|
445
|
+
),
|
|
332
446
|
);
|
|
333
447
|
return 1;
|
|
334
448
|
}
|
|
@@ -341,38 +455,150 @@ export async function runUpdateCommand(
|
|
|
341
455
|
(profileIds.length === 1 ? profileIds[0] : undefined);
|
|
342
456
|
if (!targetId) {
|
|
343
457
|
console.error(
|
|
344
|
-
|
|
458
|
+
failLine(
|
|
459
|
+
`No active profile. Pass one: claudeup update <${profileIds.join("|")}>`,
|
|
460
|
+
),
|
|
345
461
|
);
|
|
346
462
|
return 1;
|
|
347
463
|
}
|
|
348
464
|
|
|
349
|
-
const
|
|
465
|
+
for (const line of header(targetId)) console.log(line);
|
|
466
|
+
|
|
467
|
+
// ---- resolve phase, under one animated checklist -------------------------
|
|
468
|
+
|
|
469
|
+
const steps: Record<
|
|
470
|
+
"profile" | "marketplaces" | "catalog" | "binaries" | "skills",
|
|
471
|
+
Step
|
|
472
|
+
> = {
|
|
473
|
+
profile: { label: "profile", state: "pending", detail: "" },
|
|
474
|
+
marketplaces: { label: "marketplaces", state: "pending", detail: "" },
|
|
475
|
+
catalog: { label: "catalog", state: "pending", detail: "" },
|
|
476
|
+
binaries: { label: "binaries", state: "pending", detail: "" },
|
|
477
|
+
skills: { label: "skills", state: "pending", detail: "" },
|
|
478
|
+
};
|
|
479
|
+
const stepList = Object.values(steps);
|
|
480
|
+
|
|
481
|
+
const live = new LiveRegion();
|
|
482
|
+
live.start((tick) => stepFrame(stepList, tick, Date.now()));
|
|
483
|
+
const runStep = makeStepRunner(live, stepList);
|
|
484
|
+
|
|
485
|
+
/** Notices worth keeping, flushed after the checklist collapses. */
|
|
486
|
+
const notices: string[] = [];
|
|
487
|
+
|
|
488
|
+
const closure = await runStep(steps.profile, async (say) => {
|
|
489
|
+
say(`resolving "${targetId}"`);
|
|
490
|
+
const resolved = await resolveProfile(manifest, targetId);
|
|
491
|
+
steps.profile.detail = [
|
|
492
|
+
`${Object.keys(resolved.plugins).length} plugins`,
|
|
493
|
+
`${resolved.bins.length} tools`,
|
|
494
|
+
`${resolved.skills.length} skills`,
|
|
495
|
+
].join(" · ");
|
|
496
|
+
return resolved;
|
|
497
|
+
});
|
|
350
498
|
|
|
351
499
|
// "latest" is only as fresh as the catalog. Fast-forward the marketplace
|
|
352
500
|
// clones first, then drop every cached answer derived from the old HEAD —
|
|
353
501
|
// skipping this is how a clone sits days behind while every plugin reads as
|
|
354
502
|
// up to date.
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
503
|
+
const refresh = await runStep(steps.marketplaces, async (say) => {
|
|
504
|
+
let settled = 0;
|
|
505
|
+
return refreshRegisteredMarketplaces([], {
|
|
506
|
+
onStart: (names) => {
|
|
507
|
+
// Only now is there a denominator; until `onStart` fires the step
|
|
508
|
+
// shows a sweep rather than a meter that would have to guess one.
|
|
509
|
+
steps.marketplaces.progress = { done: 0, total: names.length };
|
|
510
|
+
say(names.length === 0 ? "none eligible" : `pulling ${names.join(", ")}`);
|
|
511
|
+
},
|
|
512
|
+
onSettled: (name, status) => {
|
|
513
|
+
settled++;
|
|
514
|
+
if (steps.marketplaces.progress)
|
|
515
|
+
steps.marketplaces.progress.done = settled;
|
|
516
|
+
say(`${name} ${status}`);
|
|
517
|
+
},
|
|
518
|
+
});
|
|
519
|
+
});
|
|
520
|
+
steps.marketplaces.detail = [
|
|
521
|
+
`${refresh.refreshed.length} refreshed`,
|
|
522
|
+
refresh.autoUpdateDisabled.length > 0
|
|
523
|
+
? `${refresh.autoUpdateDisabled.length} auto-update off`
|
|
524
|
+
: "",
|
|
525
|
+
refresh.failed.length > 0 ? `${refresh.failed.length} failed` : "",
|
|
526
|
+
refresh.skipped.length > 0 ? `${refresh.skipped.length} skipped` : "",
|
|
527
|
+
]
|
|
528
|
+
.filter(Boolean)
|
|
529
|
+
.join(" · ");
|
|
530
|
+
if (refresh.failed.length > 0) steps.marketplaces.state = "fail";
|
|
531
|
+
else if (refresh.autoUpdateDisabled.length > 0)
|
|
532
|
+
steps.marketplaces.state = "warn";
|
|
533
|
+
|
|
360
534
|
for (const name of refresh.autoUpdateDisabled) {
|
|
361
|
-
|
|
362
|
-
|
|
535
|
+
notices.push(
|
|
536
|
+
warnLine(
|
|
537
|
+
`${bold(name)}: auto-update disabled — its catalog will not refresh, so updates stay hidden.`,
|
|
538
|
+
),
|
|
539
|
+
);
|
|
540
|
+
notices.push(
|
|
541
|
+
noteLine(`claude plugin marketplace update ${name} # to refresh it once`),
|
|
363
542
|
);
|
|
364
543
|
}
|
|
365
544
|
for (const name of refresh.failed) {
|
|
366
|
-
|
|
545
|
+
notices.push(
|
|
546
|
+
failLine(
|
|
547
|
+
`${bold(name)}: refresh failed — clone left intact but stale, so its versions may be behind.`,
|
|
548
|
+
),
|
|
549
|
+
);
|
|
367
550
|
}
|
|
551
|
+
|
|
368
552
|
clearMarketplaceCache();
|
|
369
553
|
clearContentDriftCache();
|
|
370
554
|
|
|
371
|
-
const catalog =
|
|
372
|
-
(
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
555
|
+
const catalog = await runStep(steps.catalog, async (say) => {
|
|
556
|
+
say("reading installed and available plugins");
|
|
557
|
+
const map = new Map<string, PluginInfo>(
|
|
558
|
+
(await getAvailablePlugins(projectPath)).map((p) => [p.id, p]),
|
|
559
|
+
);
|
|
560
|
+
steps.catalog.detail = `${map.size} plugins known`;
|
|
561
|
+
return map;
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
const binChecks = await runStep(steps.binaries, async (say) => {
|
|
565
|
+
if (closure.bins.length === 0) {
|
|
566
|
+
steps.binaries.detail = "none declared";
|
|
567
|
+
return [];
|
|
568
|
+
}
|
|
569
|
+
steps.binaries.progress = { done: 0, total: closure.bins.length };
|
|
570
|
+
const results = await checkBinaries(
|
|
571
|
+
closure.bins,
|
|
572
|
+
undefined,
|
|
573
|
+
(name, index, total) => {
|
|
574
|
+
steps.binaries.progress = { done: index, total };
|
|
575
|
+
say(name);
|
|
576
|
+
},
|
|
577
|
+
);
|
|
578
|
+
const missing = results.filter((r) => !r.present).length;
|
|
579
|
+
steps.binaries.detail = `${results.length} checked${
|
|
580
|
+
missing > 0 ? ` · ${missing} missing` : ""
|
|
581
|
+
}`;
|
|
582
|
+
if (missing > 0) steps.binaries.state = "warn";
|
|
583
|
+
return results;
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
const installedSkills = await runStep(steps.skills, async (say) => {
|
|
587
|
+
say("reading .claude/skills");
|
|
588
|
+
const names = await getInstalledSkillNames("project", projectPath);
|
|
589
|
+
steps.skills.detail =
|
|
590
|
+
closure.skills.length === 0
|
|
591
|
+
? "none declared"
|
|
592
|
+
: `${names.size} installed of ${closure.skills.length} declared`;
|
|
593
|
+
return names;
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
// Collapse the animation into its permanent record, then flush the notices
|
|
597
|
+
// underneath it — a warning printed mid-checklist would have scrolled past.
|
|
598
|
+
live.stop(stepRecord(stepList, Date.now()));
|
|
599
|
+
for (const line of notices) console.log(line);
|
|
600
|
+
|
|
601
|
+
// ---- plan ----------------------------------------------------------------
|
|
376
602
|
|
|
377
603
|
const plan: UpdatePlan = {
|
|
378
604
|
profileId: targetId,
|
|
@@ -382,13 +608,15 @@ export async function runUpdateCommand(
|
|
|
382
608
|
binSpecs: new Map(closure.bins.map((b) => [b.name, b])),
|
|
383
609
|
};
|
|
384
610
|
|
|
385
|
-
console.log();
|
|
386
|
-
printPlan(plan);
|
|
611
|
+
for (const line of planReport(plan)) console.log(line);
|
|
387
612
|
|
|
388
613
|
const counts = summarizePlan(plan);
|
|
389
614
|
if (counts.unknown > 0) {
|
|
615
|
+
console.log("");
|
|
390
616
|
console.log(
|
|
391
|
-
|
|
617
|
+
warnLine(
|
|
618
|
+
`${bold(String(counts.unknown))} plugin(s) could not be checked. They are NOT reported as up to date — re-run once the catalog is reachable.`,
|
|
619
|
+
),
|
|
392
620
|
);
|
|
393
621
|
}
|
|
394
622
|
|
|
@@ -398,27 +626,77 @@ export async function runUpdateCommand(
|
|
|
398
626
|
// is evidence that anything is out of date, and counting them made the gate
|
|
399
627
|
// exit 1 forever for any profile declaring a single skill.
|
|
400
628
|
if (flags.check) {
|
|
629
|
+
console.log("");
|
|
401
630
|
if (!planIsBehind(plan)) {
|
|
402
|
-
console.log("
|
|
631
|
+
console.log(okLine("Nothing missing and nothing behind."));
|
|
403
632
|
return 0;
|
|
404
633
|
}
|
|
405
|
-
console.error(
|
|
634
|
+
console.error(
|
|
635
|
+
failLine(`Updates available. Run ${bold("claudeup update")} to apply.`),
|
|
636
|
+
);
|
|
406
637
|
return 1;
|
|
407
638
|
}
|
|
408
639
|
|
|
409
|
-
|
|
410
|
-
|
|
640
|
+
// Exactly the items the three apply loops will touch. `pluginNeedsWork` is
|
|
641
|
+
// the SAME predicate applyPlugins uses — re-deriving it is how a meter
|
|
642
|
+
// drifts from the loop it measures.
|
|
643
|
+
const workItems = [
|
|
644
|
+
...plan.plugins.filter(pluginNeedsWork).map((p) => p.pluginId),
|
|
645
|
+
...plan.bins.filter((b) => b.action !== "current").map((b) => b.name),
|
|
646
|
+
...plan.skills.map((s) => s.name),
|
|
647
|
+
];
|
|
648
|
+
|
|
649
|
+
if (!planHasWork(plan) || workItems.length === 0) {
|
|
650
|
+
for (const line of summaryLine(
|
|
651
|
+
0,
|
|
652
|
+
counts.current,
|
|
653
|
+
counts.unknown,
|
|
654
|
+
0,
|
|
655
|
+
Date.now() - startedAt,
|
|
656
|
+
))
|
|
657
|
+
console.log(line);
|
|
658
|
+
// `planHasWork` counts an `unknown` plugin as work, because from the
|
|
659
|
+
// planner's side "we could not check this" is genuinely not "this is
|
|
660
|
+
// fine". But nothing is APPLIED for an unknown — the apply loop skips it —
|
|
661
|
+
// so reporting on `planHasWork` alone claimed a run had happened when it
|
|
662
|
+
// had not. Say what is actually true instead.
|
|
663
|
+
if (counts.unknown > 0) {
|
|
664
|
+
console.log(
|
|
665
|
+
warnLine(
|
|
666
|
+
`Nothing to apply. ${counts.unknown} plugin(s) could not be checked — re-run once the catalog is reachable.`,
|
|
667
|
+
),
|
|
668
|
+
);
|
|
669
|
+
return 0;
|
|
670
|
+
}
|
|
671
|
+
console.log(okLine("Everything is up to date."));
|
|
411
672
|
return 0;
|
|
412
673
|
}
|
|
413
674
|
|
|
414
|
-
|
|
415
|
-
|
|
675
|
+
// `--dry-run` stops here: the plan is printed above, nothing is written, and
|
|
676
|
+
// it exits 0. That is the difference from `--check`, which is a CI gate and
|
|
677
|
+
// exits 1 precisely when there IS something to do.
|
|
678
|
+
if (flags.dryRun) {
|
|
679
|
+
console.log("");
|
|
680
|
+
console.log(
|
|
681
|
+
info(
|
|
682
|
+
`Dry run — ${workItems.length} item(s) would be applied. Nothing written.`,
|
|
683
|
+
),
|
|
684
|
+
);
|
|
416
685
|
return 0;
|
|
417
686
|
}
|
|
418
687
|
|
|
688
|
+
// No confirmation. `update` moves the machine toward what the profile
|
|
689
|
+
// declares and can only go FORWARD: `claude plugin install` takes no version
|
|
690
|
+
// and installs the newest published one, so there is no downgrade and no
|
|
691
|
+
// delete for a prompt to protect against. The plan is printed above, so the
|
|
692
|
+
// run is still legible after the fact — and `--dry-run` shows it beforehand.
|
|
693
|
+
//
|
|
694
|
+
// Note this also runs a profile's CLI-tool installers unattended (`brew
|
|
695
|
+
// upgrade`, `bun install -g`). That is the deliberate cost of the choice.
|
|
696
|
+
|
|
419
697
|
if (!(await isClaudeAvailable())) {
|
|
420
698
|
console.error(
|
|
421
|
-
"claude CLI not found on PATH — cannot install or update plugins.",
|
|
699
|
+
failLine("claude CLI not found on PATH — cannot install or update plugins."),
|
|
422
700
|
);
|
|
423
701
|
return 1;
|
|
424
702
|
}
|
|
@@ -428,10 +706,51 @@ export async function runUpdateCommand(
|
|
|
428
706
|
// outside claudeup's built-in list, which is every private or third-party one.
|
|
429
707
|
await registerClosureMarketplaces(closure.marketplaces);
|
|
430
708
|
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
const
|
|
434
|
-
|
|
709
|
+
// ---- apply phase, under an animated meter --------------------------------
|
|
710
|
+
|
|
711
|
+
const nameWidth = Math.max(12, ...workItems.map((n) => width(n)));
|
|
712
|
+
|
|
713
|
+
const state: ApplyState = {
|
|
714
|
+
done: 0,
|
|
715
|
+
total: workItems.length,
|
|
716
|
+
current: "starting",
|
|
717
|
+
startedAt: Date.now(),
|
|
718
|
+
};
|
|
719
|
+
|
|
720
|
+
const applyLive = new LiveRegion();
|
|
721
|
+
const reporter: ApplyReporter = {
|
|
722
|
+
begin: (name) => {
|
|
723
|
+
state.current = name;
|
|
724
|
+
applyLive.refresh();
|
|
725
|
+
},
|
|
726
|
+
finish: (succeeded, name, detail, ms) => {
|
|
727
|
+
state.done++;
|
|
728
|
+
applyLive.note(
|
|
729
|
+
applyRow(
|
|
730
|
+
succeeded,
|
|
731
|
+
name,
|
|
732
|
+
nameWidth,
|
|
733
|
+
succeeded ? dim(detail) : fg(brand.danger, detail),
|
|
734
|
+
ms,
|
|
735
|
+
),
|
|
736
|
+
);
|
|
737
|
+
},
|
|
738
|
+
detach: (work) => withPaused(applyLive, work),
|
|
739
|
+
};
|
|
740
|
+
|
|
741
|
+
console.log("");
|
|
742
|
+
applyLive.start((tick) => applyFrame(state, tick, Date.now()));
|
|
743
|
+
|
|
744
|
+
const pluginResult = await applyPlugins(
|
|
745
|
+
plan.plugins,
|
|
746
|
+
projectPath,
|
|
747
|
+
REAL_PLUGIN_DEPS,
|
|
748
|
+
reporter,
|
|
749
|
+
);
|
|
750
|
+
const binResult = await applyBins(plan, reporter);
|
|
751
|
+
const skillResult = await applySkills(plan, projectPath, reporter);
|
|
752
|
+
|
|
753
|
+
applyLive.stop();
|
|
435
754
|
|
|
436
755
|
const failed = [
|
|
437
756
|
...pluginResult.failed,
|
|
@@ -440,11 +759,14 @@ export async function runUpdateCommand(
|
|
|
440
759
|
];
|
|
441
760
|
const changed = pluginResult.ok + binResult.ok + skillResult.ok;
|
|
442
761
|
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
762
|
+
for (const line of summaryLine(
|
|
763
|
+
changed,
|
|
764
|
+
counts.current,
|
|
765
|
+
counts.unknown,
|
|
766
|
+
failed.length,
|
|
767
|
+
Date.now() - startedAt,
|
|
768
|
+
))
|
|
769
|
+
console.log(line);
|
|
448
770
|
|
|
449
771
|
// An exact pin the installer could not deliver. Reported, never fatal: only
|
|
450
772
|
// the newest published version is installable, so this is a standing property
|
|
@@ -453,20 +775,26 @@ export async function runUpdateCommand(
|
|
|
453
775
|
// `install --check` with `strictVersions` is where pin drift is GATED.
|
|
454
776
|
if (pluginResult.mismatches.length > 0) {
|
|
455
777
|
console.log(
|
|
456
|
-
|
|
778
|
+
warnLine(
|
|
779
|
+
`${bold(String(pluginResult.mismatches.length))} pinned version(s) are not what is installed. Only the newest published version can be installed:`,
|
|
780
|
+
),
|
|
457
781
|
);
|
|
458
782
|
for (const m of pluginResult.mismatches) {
|
|
459
783
|
console.log(
|
|
460
|
-
|
|
784
|
+
noteLine(
|
|
785
|
+
`${m.pluginId} (${m.scope}): manifest pins ${m.pinned}, installed ${m.actual ?? "unknown"}`,
|
|
786
|
+
),
|
|
461
787
|
);
|
|
462
788
|
}
|
|
463
789
|
console.log(
|
|
464
|
-
|
|
790
|
+
noteLine(
|
|
791
|
+
"Recorded what is actually installed. Update the pin to match, or leave it as a record of intent.",
|
|
792
|
+
),
|
|
465
793
|
);
|
|
466
794
|
}
|
|
467
795
|
|
|
468
796
|
if (failed.length > 0) {
|
|
469
|
-
console.error(`Failed: ${failed.join(", ")}`);
|
|
797
|
+
console.error(failLine(`Failed: ${failed.join(", ")}`));
|
|
470
798
|
return 1;
|
|
471
799
|
}
|
|
472
800
|
return 0;
|