claudeup 5.0.1 → 6.1.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/scripts/test-isolated.ts +7 -1
- package/src/__tests__/cli-ansi.test.ts +297 -0
- package/src/__tests__/cli-apply-seams.test.ts +281 -0
- package/src/__tests__/cli-live.test.ts +384 -0
- package/src/__tests__/cli-tool-commands.test.ts +239 -0
- package/src/__tests__/cli-update-view.test.ts +286 -0
- package/src/__tests__/format-command.test.ts +78 -0
- package/src/__tests__/gitignore-fixer.test.ts +20 -1
- package/src/__tests__/marketplace-refresh.test.ts +63 -0
- package/src/__tests__/shell-script-callers.test.ts +92 -0
- package/src/__tests__/toolchain.test.ts +176 -31
- package/src/__tests__/ui-version-writers.test.ts +88 -0
- package/src/__tests__/update-apply.test.ts +46 -1
- package/src/cli/ansi.ts +512 -0
- package/src/cli/bootstrap.ts +0 -6
- package/src/cli/install.ts +15 -6
- package/src/cli/live.ts +374 -0
- package/src/cli/profile.ts +1 -1
- package/src/cli/prompt.ts +0 -9
- package/src/cli/router.ts +7 -3
- package/src/cli/update-view.ts +441 -0
- package/src/cli/update.ts +357 -298
- package/src/data/cli-tools.ts +20 -13
- package/src/services/cli-tool-commands.ts +133 -0
- package/src/services/doctor-bins.ts +18 -4
- package/src/services/marketplace-refresh.ts +39 -1
- package/src/services/toolchain.ts +235 -33
- package/src/services/update-engine.ts +412 -0
- package/src/ui/renderers/cliToolRenderers.tsx +40 -39
- package/src/ui/screens/CliToolsScreen.tsx +56 -64
- package/src/ui/screens/PluginsScreen.tsx +119 -64
- package/src/utils/command-utils.ts +102 -1
- package/src/utils/run.ts +67 -0
package/src/cli/update.ts
CHANGED
|
@@ -11,15 +11,30 @@
|
|
|
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
|
-
import
|
|
17
|
-
import type { PluginScope } from "../services/claude-cli.js";
|
|
18
|
-
import {
|
|
19
|
-
isClaudeAvailable,
|
|
20
|
-
repairPlugin,
|
|
21
|
-
updatePlugin,
|
|
22
|
-
} from "../services/claude-cli.js";
|
|
37
|
+
import { isClaudeAvailable } from "../services/claude-cli.js";
|
|
23
38
|
import { clearContentDriftCache } from "../services/content-drift.js";
|
|
24
39
|
import { checkBinaries } from "../services/doctor-bins.js";
|
|
25
40
|
import { validateManifest } from "../services/manifest.js";
|
|
@@ -29,16 +44,18 @@ import {
|
|
|
29
44
|
type PluginInfo,
|
|
30
45
|
clearMarketplaceCache,
|
|
31
46
|
getAvailablePlugins,
|
|
32
|
-
readInstalledVersionForScope,
|
|
33
|
-
saveInstalledPluginVersionForScope,
|
|
34
47
|
} from "../services/plugin-manager.js";
|
|
35
48
|
import { resolveProfile } from "../services/resolver.js";
|
|
36
|
-
import { installSkill } from "../services/skills-manager.js";
|
|
37
49
|
import { getInstalledSkillNames } from "../services/skills-manager.js";
|
|
38
50
|
import { activeProfile } from "../services/symlink-manager.js";
|
|
39
|
-
|
|
51
|
+
// The apply half lives in services/ so the TUI runs the SAME code — see the
|
|
52
|
+
// header of update-engine.ts for what drifted while it did not.
|
|
53
|
+
import {
|
|
54
|
+
type ApplyReporter,
|
|
55
|
+
applyPlan,
|
|
56
|
+
planWorkItems,
|
|
57
|
+
} from "../services/update-engine.js";
|
|
40
58
|
import {
|
|
41
|
-
type PluginUpdateItem,
|
|
42
59
|
type UpdatePlan,
|
|
43
60
|
planBinUpdates,
|
|
44
61
|
planHasWork,
|
|
@@ -47,288 +64,132 @@ import {
|
|
|
47
64
|
planSkillUpdates,
|
|
48
65
|
summarizePlan,
|
|
49
66
|
} from "../services/update-plan.js";
|
|
50
|
-
import
|
|
67
|
+
import { brand } from "../ui/theme.js";
|
|
68
|
+
import { bold, dim, fg, width } from "./ansi.js";
|
|
51
69
|
import { ensureManifest } from "./bootstrap.js";
|
|
52
|
-
import {
|
|
70
|
+
import { LiveRegion, withPaused } from "./live.js";
|
|
71
|
+
import {
|
|
72
|
+
type ApplyState,
|
|
73
|
+
type Step,
|
|
74
|
+
applyFrame,
|
|
75
|
+
applyRow,
|
|
76
|
+
fail as failLine,
|
|
77
|
+
header,
|
|
78
|
+
info,
|
|
79
|
+
note as noteLine,
|
|
80
|
+
ok as okLine,
|
|
81
|
+
planReport,
|
|
82
|
+
stepFrame,
|
|
83
|
+
stepRecord,
|
|
84
|
+
summaryLine,
|
|
85
|
+
warn as warnLine,
|
|
86
|
+
} from "./update-view.js";
|
|
53
87
|
|
|
54
88
|
interface UpdateFlags {
|
|
89
|
+
/** Report drift and exit 1 if anything is behind. The CI gate. */
|
|
55
90
|
check: boolean;
|
|
56
|
-
|
|
91
|
+
/** Show the plan and stop. Always exits 0 — it is a preview, not a gate. */
|
|
92
|
+
dryRun: boolean;
|
|
57
93
|
profile?: string;
|
|
58
94
|
}
|
|
59
95
|
|
|
60
|
-
|
|
96
|
+
/**
|
|
97
|
+
* `--yes` / `-y` are gone, and are silently ignored rather than rejected.
|
|
98
|
+
*
|
|
99
|
+
* They existed to skip a confirmation that no longer exists: `update` applies
|
|
100
|
+
* its plan. Both start with `-`, so an old script or a habit still runs and
|
|
101
|
+
* still does the right thing; there is no flag kept alive to mean nothing.
|
|
102
|
+
*/
|
|
103
|
+
export function parseArgs(args: string[]): UpdateFlags {
|
|
61
104
|
return {
|
|
62
105
|
check: args.includes("--check"),
|
|
63
|
-
|
|
106
|
+
dryRun: args.includes("--dry-run"),
|
|
64
107
|
profile: args.find((a) => !a.startsWith("-")),
|
|
65
108
|
};
|
|
66
109
|
}
|
|
67
110
|
|
|
68
|
-
// ── report ───────────────────────────────────────────────────────────────────
|
|
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 ────────────────────────────────────────────────────────────────────
|
|
135
|
-
|
|
136
|
-
/** Map a manifest skill ref to the SkillInfo shape installSkill expects. */
|
|
137
|
-
function toSkillInfo(ref: ProfileSkillRef): SkillInfo {
|
|
138
|
-
return {
|
|
139
|
-
id: `${ref.repo}/${ref.path}`,
|
|
140
|
-
name: ref.name,
|
|
141
|
-
source: {
|
|
142
|
-
label: ref.repo,
|
|
143
|
-
repo: ref.repo,
|
|
144
|
-
skillsPath: path.dirname(ref.path),
|
|
145
|
-
},
|
|
146
|
-
repoPath: ref.path,
|
|
147
|
-
gitBlobSha: "",
|
|
148
|
-
frontmatter: null,
|
|
149
|
-
installed: false,
|
|
150
|
-
installedScope: null,
|
|
151
|
-
hasUpdate: false,
|
|
152
|
-
};
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
interface ApplyResult {
|
|
156
|
-
ok: number;
|
|
157
|
-
failed: string[];
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
/** An exact pin the installer could not deliver, discovered by reading back. */
|
|
161
|
-
export interface PinMismatch {
|
|
162
|
-
pluginId: string;
|
|
163
|
-
pinned: string;
|
|
164
|
-
actual: string | null;
|
|
165
|
-
scope: PluginScope;
|
|
166
|
-
}
|
|
167
111
|
|
|
168
|
-
|
|
169
|
-
mismatches: PinMismatch[];
|
|
170
|
-
}
|
|
112
|
+
// -- phase runner ------------------------------------------------------------
|
|
171
113
|
|
|
172
114
|
/**
|
|
173
|
-
*
|
|
174
|
-
*
|
|
115
|
+
* Run one checklist step, keeping its row and the animation truthful.
|
|
116
|
+
*
|
|
117
|
+
* `work` gets a `say` callback so a long step can narrate what it is currently
|
|
118
|
+
* blocked on — the marketplace step names the clone it is pulling, the binary
|
|
119
|
+
* step names the binary it is probing. That detail is the difference between
|
|
120
|
+
* "something is happening" and "the network call to THIS marketplace is slow".
|
|
175
121
|
*
|
|
176
|
-
*
|
|
177
|
-
*
|
|
178
|
-
*
|
|
179
|
-
* already use for their impure edges.
|
|
122
|
+
* A step that throws is marked `fail` and RE-THROWN. Marking it matters: the
|
|
123
|
+
* record printed on the way out then names the step that died, instead of a
|
|
124
|
+
* stack trace landing under a checklist whose last row is still spinning.
|
|
180
125
|
*/
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
pluginId: string,
|
|
195
|
-
version: string,
|
|
196
|
-
scope: PluginScope,
|
|
197
|
-
projectPath: string,
|
|
198
|
-
) => Promise<void>;
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
const REAL_PLUGIN_DEPS: PluginApplyDeps = {
|
|
202
|
-
update: updatePlugin,
|
|
203
|
-
repair: repairPlugin,
|
|
204
|
-
readInstalled: readInstalledVersionForScope,
|
|
205
|
-
saveInstalled: saveInstalledPluginVersionForScope,
|
|
206
|
-
};
|
|
207
|
-
|
|
208
|
-
export async function applyPlugins(
|
|
209
|
-
items: PluginUpdateItem[],
|
|
210
|
-
projectPath: string,
|
|
211
|
-
deps: PluginApplyDeps = REAL_PLUGIN_DEPS,
|
|
212
|
-
): Promise<PluginApplyResult> {
|
|
213
|
-
const result: PluginApplyResult = { ok: 0, failed: [], mismatches: [] };
|
|
214
|
-
|
|
215
|
-
for (const item of items) {
|
|
216
|
-
if (item.action === "current" || item.action === "unknown") continue;
|
|
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;
|
|
223
|
-
if (scopes.length === 0) continue;
|
|
224
|
-
|
|
126
|
+
function makeStepRunner(live: LiveRegion, all: Step[]) {
|
|
127
|
+
return async function runStep<T>(
|
|
128
|
+
step: Step,
|
|
129
|
+
work: (say: (detail: string) => void) => Promise<T>,
|
|
130
|
+
): Promise<T> {
|
|
131
|
+
step.state = "running";
|
|
132
|
+
step.startedAt = Date.now();
|
|
133
|
+
step.detail = step.detail || "…";
|
|
134
|
+
live.refresh();
|
|
135
|
+
const say = (detail: string) => {
|
|
136
|
+
step.detail = detail;
|
|
137
|
+
live.refresh();
|
|
138
|
+
};
|
|
225
139
|
try {
|
|
226
|
-
|
|
227
|
-
if (item.action === "repair") {
|
|
228
|
-
await deps.repair(item.pluginId, scope, projectPath);
|
|
229
|
-
} else {
|
|
230
|
-
await deps.update(item.pluginId, scope);
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
// Record what LANDED, not what we asked for.
|
|
234
|
-
//
|
|
235
|
-
// `claude plugin install` accepts no version, so an exact pin is a
|
|
236
|
-
// request the installer cannot honour — it fetches whatever the
|
|
237
|
-
// marketplace publishes now. Writing `item.target` here recorded the
|
|
238
|
-
// pin as satisfied whatever actually installed, and `install --check`
|
|
239
|
-
// reads that same field to decide whether the machine has drifted.
|
|
240
|
-
// The gate would have compared the pin against a copy of itself and
|
|
241
|
-
// reported clean forever.
|
|
242
|
-
const actual = await deps.readInstalled(
|
|
243
|
-
item.pluginId,
|
|
244
|
-
scope,
|
|
245
|
-
projectPath,
|
|
246
|
-
);
|
|
247
|
-
if (actual) {
|
|
248
|
-
await deps.saveInstalled(item.pluginId, actual, scope, projectPath);
|
|
249
|
-
}
|
|
250
|
-
if (item.pinned !== "latest" && actual !== item.pinned) {
|
|
251
|
-
result.mismatches.push({
|
|
252
|
-
pluginId: item.pluginId,
|
|
253
|
-
pinned: item.pinned,
|
|
254
|
-
actual,
|
|
255
|
-
scope,
|
|
256
|
-
});
|
|
257
|
-
}
|
|
258
|
-
}
|
|
259
|
-
console.log(`✓ ${item.pluginId} (${scopes.join(", ")})`);
|
|
260
|
-
result.ok++;
|
|
140
|
+
return await work(say);
|
|
261
141
|
} catch (error) {
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
142
|
+
step.state = "fail";
|
|
143
|
+
step.detail = error instanceof Error ? error.message : String(error);
|
|
144
|
+
step.endedAt = Date.now();
|
|
145
|
+
step.progress = undefined;
|
|
146
|
+
// Collapse to the record on the way out. Without this the exception
|
|
147
|
+
// surfaces under a checklist frozen mid-spin, with the cursor still
|
|
148
|
+
// hidden — the process-exit hook restores the cursor but cannot know
|
|
149
|
+
// what the last frame should have said.
|
|
150
|
+
live.stop(stepRecord(all, Date.now()));
|
|
151
|
+
throw error;
|
|
152
|
+
} finally {
|
|
153
|
+
step.endedAt = Date.now();
|
|
154
|
+
step.progress = undefined;
|
|
155
|
+
if (step.state === "running") step.state = "done";
|
|
156
|
+
live.refresh();
|
|
265
157
|
}
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
return result;
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
async function applyBins(plan: UpdatePlan): Promise<ApplyResult> {
|
|
272
|
-
const result: ApplyResult = { ok: 0, failed: [] };
|
|
273
|
-
for (const item of plan.bins) {
|
|
274
|
-
if (item.action === "current") continue;
|
|
275
|
-
const bin = plan.binSpecs.get(item.name);
|
|
276
|
-
if (!bin) continue;
|
|
277
|
-
const cmd =
|
|
278
|
-
item.action === "install"
|
|
279
|
-
? binInstallCommand(bin)
|
|
280
|
-
: binUpgradeCommand(bin);
|
|
281
|
-
console.log(`\n$ ${cmd}`);
|
|
282
|
-
if (await runShell(cmd)) result.ok++;
|
|
283
|
-
else result.failed.push(item.name);
|
|
284
|
-
}
|
|
285
|
-
return result;
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
async function applySkills(
|
|
289
|
-
plan: UpdatePlan,
|
|
290
|
-
projectPath: string,
|
|
291
|
-
): Promise<ApplyResult> {
|
|
292
|
-
const result: ApplyResult = { ok: 0, failed: [] };
|
|
293
|
-
for (const item of plan.skills) {
|
|
294
|
-
try {
|
|
295
|
-
await installSkill(toSkillInfo(item.ref), "project", projectPath);
|
|
296
|
-
console.log(`✓ skill ${item.name}`);
|
|
297
|
-
result.ok++;
|
|
298
|
-
} catch (error) {
|
|
299
|
-
const msg = error instanceof Error ? error.message : String(error);
|
|
300
|
-
console.warn(`⚠ skill ${item.name}: ${msg}`);
|
|
301
|
-
result.failed.push(item.name);
|
|
302
|
-
}
|
|
303
|
-
}
|
|
304
|
-
return result;
|
|
158
|
+
};
|
|
305
159
|
}
|
|
306
160
|
|
|
307
|
-
//
|
|
161
|
+
// -- entry point -------------------------------------------------------------
|
|
308
162
|
|
|
309
163
|
export async function runUpdateCommand(
|
|
310
164
|
args: string[],
|
|
311
165
|
projectPath: string = process.cwd(),
|
|
312
166
|
): Promise<number> {
|
|
313
167
|
const flags = parseArgs(args);
|
|
168
|
+
const startedAt = Date.now();
|
|
314
169
|
|
|
170
|
+
// The ONE prompt that survives, and only in a repo that has no manifest yet.
|
|
171
|
+
// Writing `.claude/profiles.json` is not part of updating: it authors the
|
|
172
|
+
// file a team commits and shares, possibly derived from this machine's
|
|
173
|
+
// personal user-scope plugin set. That is worth a human, once per repo,
|
|
174
|
+
// forever. Everything after it applies without asking.
|
|
315
175
|
const manifest = await ensureManifest(projectPath, {
|
|
316
|
-
|
|
317
|
-
check: flags.check,
|
|
176
|
+
check: flags.check || flags.dryRun,
|
|
318
177
|
});
|
|
319
178
|
if (!manifest) return 1;
|
|
320
179
|
|
|
321
180
|
const errors = validateManifest(manifest);
|
|
322
181
|
if (errors.length > 0) {
|
|
323
|
-
console.error("Invalid .claude/profiles.json:");
|
|
324
|
-
for (const e of errors) console.error(
|
|
182
|
+
console.error(failLine("Invalid .claude/profiles.json:"));
|
|
183
|
+
for (const e of errors) console.error(noteLine(`${e.path}: ${e.message}`));
|
|
325
184
|
return 1;
|
|
326
185
|
}
|
|
327
186
|
|
|
328
187
|
const profileIds = Object.keys(manifest.profiles);
|
|
329
188
|
if (flags.profile && !manifest.profiles[flags.profile]) {
|
|
330
189
|
console.error(
|
|
331
|
-
|
|
190
|
+
failLine(
|
|
191
|
+
`Profile "${flags.profile}" not found. Available: ${profileIds.join(", ")}`,
|
|
192
|
+
),
|
|
332
193
|
);
|
|
333
194
|
return 1;
|
|
334
195
|
}
|
|
@@ -341,38 +202,150 @@ export async function runUpdateCommand(
|
|
|
341
202
|
(profileIds.length === 1 ? profileIds[0] : undefined);
|
|
342
203
|
if (!targetId) {
|
|
343
204
|
console.error(
|
|
344
|
-
|
|
205
|
+
failLine(
|
|
206
|
+
`No active profile. Pass one: claudeup update <${profileIds.join("|")}>`,
|
|
207
|
+
),
|
|
345
208
|
);
|
|
346
209
|
return 1;
|
|
347
210
|
}
|
|
348
211
|
|
|
349
|
-
const
|
|
212
|
+
for (const line of header(targetId)) console.log(line);
|
|
213
|
+
|
|
214
|
+
// ---- resolve phase, under one animated checklist -------------------------
|
|
215
|
+
|
|
216
|
+
const steps: Record<
|
|
217
|
+
"profile" | "marketplaces" | "catalog" | "binaries" | "skills",
|
|
218
|
+
Step
|
|
219
|
+
> = {
|
|
220
|
+
profile: { label: "profile", state: "pending", detail: "" },
|
|
221
|
+
marketplaces: { label: "marketplaces", state: "pending", detail: "" },
|
|
222
|
+
catalog: { label: "catalog", state: "pending", detail: "" },
|
|
223
|
+
binaries: { label: "binaries", state: "pending", detail: "" },
|
|
224
|
+
skills: { label: "skills", state: "pending", detail: "" },
|
|
225
|
+
};
|
|
226
|
+
const stepList = Object.values(steps);
|
|
227
|
+
|
|
228
|
+
const live = new LiveRegion();
|
|
229
|
+
live.start((tick) => stepFrame(stepList, tick, Date.now()));
|
|
230
|
+
const runStep = makeStepRunner(live, stepList);
|
|
231
|
+
|
|
232
|
+
/** Notices worth keeping, flushed after the checklist collapses. */
|
|
233
|
+
const notices: string[] = [];
|
|
234
|
+
|
|
235
|
+
const closure = await runStep(steps.profile, async (say) => {
|
|
236
|
+
say(`resolving "${targetId}"`);
|
|
237
|
+
const resolved = await resolveProfile(manifest, targetId);
|
|
238
|
+
steps.profile.detail = [
|
|
239
|
+
`${Object.keys(resolved.plugins).length} plugins`,
|
|
240
|
+
`${resolved.bins.length} tools`,
|
|
241
|
+
`${resolved.skills.length} skills`,
|
|
242
|
+
].join(" · ");
|
|
243
|
+
return resolved;
|
|
244
|
+
});
|
|
350
245
|
|
|
351
246
|
// "latest" is only as fresh as the catalog. Fast-forward the marketplace
|
|
352
247
|
// clones first, then drop every cached answer derived from the old HEAD —
|
|
353
248
|
// skipping this is how a clone sits days behind while every plugin reads as
|
|
354
249
|
// up to date.
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
250
|
+
const refresh = await runStep(steps.marketplaces, async (say) => {
|
|
251
|
+
let settled = 0;
|
|
252
|
+
return refreshRegisteredMarketplaces([], {
|
|
253
|
+
onStart: (names) => {
|
|
254
|
+
// Only now is there a denominator; until `onStart` fires the step
|
|
255
|
+
// shows a sweep rather than a meter that would have to guess one.
|
|
256
|
+
steps.marketplaces.progress = { done: 0, total: names.length };
|
|
257
|
+
say(names.length === 0 ? "none eligible" : `pulling ${names.join(", ")}`);
|
|
258
|
+
},
|
|
259
|
+
onSettled: (name, status) => {
|
|
260
|
+
settled++;
|
|
261
|
+
if (steps.marketplaces.progress)
|
|
262
|
+
steps.marketplaces.progress.done = settled;
|
|
263
|
+
say(`${name} ${status}`);
|
|
264
|
+
},
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
steps.marketplaces.detail = [
|
|
268
|
+
`${refresh.refreshed.length} refreshed`,
|
|
269
|
+
refresh.autoUpdateDisabled.length > 0
|
|
270
|
+
? `${refresh.autoUpdateDisabled.length} auto-update off`
|
|
271
|
+
: "",
|
|
272
|
+
refresh.failed.length > 0 ? `${refresh.failed.length} failed` : "",
|
|
273
|
+
refresh.skipped.length > 0 ? `${refresh.skipped.length} skipped` : "",
|
|
274
|
+
]
|
|
275
|
+
.filter(Boolean)
|
|
276
|
+
.join(" · ");
|
|
277
|
+
if (refresh.failed.length > 0) steps.marketplaces.state = "fail";
|
|
278
|
+
else if (refresh.autoUpdateDisabled.length > 0)
|
|
279
|
+
steps.marketplaces.state = "warn";
|
|
280
|
+
|
|
360
281
|
for (const name of refresh.autoUpdateDisabled) {
|
|
361
|
-
|
|
362
|
-
|
|
282
|
+
notices.push(
|
|
283
|
+
warnLine(
|
|
284
|
+
`${bold(name)}: auto-update disabled — its catalog will not refresh, so updates stay hidden.`,
|
|
285
|
+
),
|
|
286
|
+
);
|
|
287
|
+
notices.push(
|
|
288
|
+
noteLine(`claude plugin marketplace update ${name} # to refresh it once`),
|
|
363
289
|
);
|
|
364
290
|
}
|
|
365
291
|
for (const name of refresh.failed) {
|
|
366
|
-
|
|
292
|
+
notices.push(
|
|
293
|
+
failLine(
|
|
294
|
+
`${bold(name)}: refresh failed — clone left intact but stale, so its versions may be behind.`,
|
|
295
|
+
),
|
|
296
|
+
);
|
|
367
297
|
}
|
|
298
|
+
|
|
368
299
|
clearMarketplaceCache();
|
|
369
300
|
clearContentDriftCache();
|
|
370
301
|
|
|
371
|
-
const catalog =
|
|
372
|
-
(
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
302
|
+
const catalog = await runStep(steps.catalog, async (say) => {
|
|
303
|
+
say("reading installed and available plugins");
|
|
304
|
+
const map = new Map<string, PluginInfo>(
|
|
305
|
+
(await getAvailablePlugins(projectPath)).map((p) => [p.id, p]),
|
|
306
|
+
);
|
|
307
|
+
steps.catalog.detail = `${map.size} plugins known`;
|
|
308
|
+
return map;
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
const binChecks = await runStep(steps.binaries, async (say) => {
|
|
312
|
+
if (closure.bins.length === 0) {
|
|
313
|
+
steps.binaries.detail = "none declared";
|
|
314
|
+
return [];
|
|
315
|
+
}
|
|
316
|
+
steps.binaries.progress = { done: 0, total: closure.bins.length };
|
|
317
|
+
const results = await checkBinaries(
|
|
318
|
+
closure.bins,
|
|
319
|
+
undefined,
|
|
320
|
+
(name, index, total) => {
|
|
321
|
+
steps.binaries.progress = { done: index, total };
|
|
322
|
+
say(name);
|
|
323
|
+
},
|
|
324
|
+
);
|
|
325
|
+
const missing = results.filter((r) => !r.present).length;
|
|
326
|
+
steps.binaries.detail = `${results.length} checked${
|
|
327
|
+
missing > 0 ? ` · ${missing} missing` : ""
|
|
328
|
+
}`;
|
|
329
|
+
if (missing > 0) steps.binaries.state = "warn";
|
|
330
|
+
return results;
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
const installedSkills = await runStep(steps.skills, async (say) => {
|
|
334
|
+
say("reading .claude/skills");
|
|
335
|
+
const names = await getInstalledSkillNames("project", projectPath);
|
|
336
|
+
steps.skills.detail =
|
|
337
|
+
closure.skills.length === 0
|
|
338
|
+
? "none declared"
|
|
339
|
+
: `${names.size} installed of ${closure.skills.length} declared`;
|
|
340
|
+
return names;
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
// Collapse the animation into its permanent record, then flush the notices
|
|
344
|
+
// underneath it — a warning printed mid-checklist would have scrolled past.
|
|
345
|
+
live.stop(stepRecord(stepList, Date.now()));
|
|
346
|
+
for (const line of notices) console.log(line);
|
|
347
|
+
|
|
348
|
+
// ---- plan ----------------------------------------------------------------
|
|
376
349
|
|
|
377
350
|
const plan: UpdatePlan = {
|
|
378
351
|
profileId: targetId,
|
|
@@ -382,13 +355,15 @@ export async function runUpdateCommand(
|
|
|
382
355
|
binSpecs: new Map(closure.bins.map((b) => [b.name, b])),
|
|
383
356
|
};
|
|
384
357
|
|
|
385
|
-
console.log();
|
|
386
|
-
printPlan(plan);
|
|
358
|
+
for (const line of planReport(plan)) console.log(line);
|
|
387
359
|
|
|
388
360
|
const counts = summarizePlan(plan);
|
|
389
361
|
if (counts.unknown > 0) {
|
|
362
|
+
console.log("");
|
|
390
363
|
console.log(
|
|
391
|
-
|
|
364
|
+
warnLine(
|
|
365
|
+
`${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.`,
|
|
366
|
+
),
|
|
392
367
|
);
|
|
393
368
|
}
|
|
394
369
|
|
|
@@ -398,27 +373,73 @@ export async function runUpdateCommand(
|
|
|
398
373
|
// is evidence that anything is out of date, and counting them made the gate
|
|
399
374
|
// exit 1 forever for any profile declaring a single skill.
|
|
400
375
|
if (flags.check) {
|
|
376
|
+
console.log("");
|
|
401
377
|
if (!planIsBehind(plan)) {
|
|
402
|
-
console.log("
|
|
378
|
+
console.log(okLine("Nothing missing and nothing behind."));
|
|
403
379
|
return 0;
|
|
404
380
|
}
|
|
405
|
-
console.error(
|
|
381
|
+
console.error(
|
|
382
|
+
failLine(`Updates available. Run ${bold("claudeup update")} to apply.`),
|
|
383
|
+
);
|
|
406
384
|
return 1;
|
|
407
385
|
}
|
|
408
386
|
|
|
409
|
-
|
|
410
|
-
|
|
387
|
+
// Exactly the items the engine will touch. Derived by the engine from the
|
|
388
|
+
// same predicates its loops use, so the meter's denominator cannot drift
|
|
389
|
+
// from the work — a bar stuck short of full reads as a hang.
|
|
390
|
+
const workItems = planWorkItems(plan);
|
|
391
|
+
|
|
392
|
+
if (!planHasWork(plan) || workItems.length === 0) {
|
|
393
|
+
for (const line of summaryLine(
|
|
394
|
+
0,
|
|
395
|
+
counts.current,
|
|
396
|
+
counts.unknown,
|
|
397
|
+
0,
|
|
398
|
+
Date.now() - startedAt,
|
|
399
|
+
))
|
|
400
|
+
console.log(line);
|
|
401
|
+
// `planHasWork` counts an `unknown` plugin as work, because from the
|
|
402
|
+
// planner's side "we could not check this" is genuinely not "this is
|
|
403
|
+
// fine". But nothing is APPLIED for an unknown — the apply loop skips it —
|
|
404
|
+
// so reporting on `planHasWork` alone claimed a run had happened when it
|
|
405
|
+
// had not. Say what is actually true instead.
|
|
406
|
+
if (counts.unknown > 0) {
|
|
407
|
+
console.log(
|
|
408
|
+
warnLine(
|
|
409
|
+
`Nothing to apply. ${counts.unknown} plugin(s) could not be checked — re-run once the catalog is reachable.`,
|
|
410
|
+
),
|
|
411
|
+
);
|
|
412
|
+
return 0;
|
|
413
|
+
}
|
|
414
|
+
console.log(okLine("Everything is up to date."));
|
|
411
415
|
return 0;
|
|
412
416
|
}
|
|
413
417
|
|
|
414
|
-
|
|
415
|
-
|
|
418
|
+
// `--dry-run` stops here: the plan is printed above, nothing is written, and
|
|
419
|
+
// it exits 0. That is the difference from `--check`, which is a CI gate and
|
|
420
|
+
// exits 1 precisely when there IS something to do.
|
|
421
|
+
if (flags.dryRun) {
|
|
422
|
+
console.log("");
|
|
423
|
+
console.log(
|
|
424
|
+
info(
|
|
425
|
+
`Dry run — ${workItems.length} item(s) would be applied. Nothing written.`,
|
|
426
|
+
),
|
|
427
|
+
);
|
|
416
428
|
return 0;
|
|
417
429
|
}
|
|
418
430
|
|
|
431
|
+
// No confirmation. `update` moves the machine toward what the profile
|
|
432
|
+
// declares and can only go FORWARD: `claude plugin install` takes no version
|
|
433
|
+
// and installs the newest published one, so there is no downgrade and no
|
|
434
|
+
// delete for a prompt to protect against. The plan is printed above, so the
|
|
435
|
+
// run is still legible after the fact — and `--dry-run` shows it beforehand.
|
|
436
|
+
//
|
|
437
|
+
// Note this also runs a profile's CLI-tool installers unattended (`brew
|
|
438
|
+
// upgrade`, `bun install -g`). That is the deliberate cost of the choice.
|
|
439
|
+
|
|
419
440
|
if (!(await isClaudeAvailable())) {
|
|
420
441
|
console.error(
|
|
421
|
-
"claude CLI not found on PATH — cannot install or update plugins.",
|
|
442
|
+
failLine("claude CLI not found on PATH — cannot install or update plugins."),
|
|
422
443
|
);
|
|
423
444
|
return 1;
|
|
424
445
|
}
|
|
@@ -428,45 +449,83 @@ export async function runUpdateCommand(
|
|
|
428
449
|
// outside claudeup's built-in list, which is every private or third-party one.
|
|
429
450
|
await registerClosureMarketplaces(closure.marketplaces);
|
|
430
451
|
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
const
|
|
434
|
-
|
|
452
|
+
// ---- apply phase, under an animated meter --------------------------------
|
|
453
|
+
|
|
454
|
+
const nameWidth = Math.max(12, ...workItems.map((n) => width(n)));
|
|
455
|
+
|
|
456
|
+
const state: ApplyState = {
|
|
457
|
+
done: 0,
|
|
458
|
+
total: workItems.length,
|
|
459
|
+
current: "starting",
|
|
460
|
+
startedAt: Date.now(),
|
|
461
|
+
};
|
|
462
|
+
|
|
463
|
+
const applyLive = new LiveRegion();
|
|
464
|
+
const reporter: ApplyReporter = {
|
|
465
|
+
begin: (name) => {
|
|
466
|
+
state.current = name;
|
|
467
|
+
applyLive.refresh();
|
|
468
|
+
},
|
|
469
|
+
finish: (succeeded, name, detail, ms) => {
|
|
470
|
+
state.done++;
|
|
471
|
+
applyLive.note(
|
|
472
|
+
applyRow(
|
|
473
|
+
succeeded,
|
|
474
|
+
name,
|
|
475
|
+
nameWidth,
|
|
476
|
+
succeeded ? dim(detail) : fg(brand.danger, detail),
|
|
477
|
+
ms,
|
|
478
|
+
),
|
|
479
|
+
);
|
|
480
|
+
},
|
|
481
|
+
detach: (work) => withPaused(applyLive, work),
|
|
482
|
+
};
|
|
483
|
+
|
|
484
|
+
console.log("");
|
|
485
|
+
applyLive.start((tick) => applyFrame(state, tick, Date.now()));
|
|
486
|
+
|
|
487
|
+
const outcome = await applyPlan(plan, projectPath, { report: reporter });
|
|
488
|
+
|
|
489
|
+
applyLive.stop();
|
|
435
490
|
|
|
436
|
-
const failed =
|
|
437
|
-
...pluginResult.failed,
|
|
438
|
-
...binResult.failed,
|
|
439
|
-
...skillResult.failed,
|
|
440
|
-
];
|
|
441
|
-
const changed = pluginResult.ok + binResult.ok + skillResult.ok;
|
|
491
|
+
const { failed } = outcome;
|
|
442
492
|
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
493
|
+
for (const line of summaryLine(
|
|
494
|
+
outcome.changed,
|
|
495
|
+
counts.current,
|
|
496
|
+
counts.unknown,
|
|
497
|
+
failed.length,
|
|
498
|
+
Date.now() - startedAt,
|
|
499
|
+
))
|
|
500
|
+
console.log(line);
|
|
448
501
|
|
|
449
502
|
// An exact pin the installer could not deliver. Reported, never fatal: only
|
|
450
503
|
// the newest published version is installable, so this is a standing property
|
|
451
504
|
// of the manifest rather than something this run did wrong, and failing here
|
|
452
505
|
// would make `claudeup update` exit 1 forever for any profile with a pin.
|
|
453
506
|
// `install --check` with `strictVersions` is where pin drift is GATED.
|
|
454
|
-
if (
|
|
507
|
+
if (outcome.plugins.mismatches.length > 0) {
|
|
455
508
|
console.log(
|
|
456
|
-
|
|
509
|
+
warnLine(
|
|
510
|
+
`${bold(String(outcome.plugins.mismatches.length))} pinned version(s) are not what is installed. Only the newest published version can be installed:`,
|
|
511
|
+
),
|
|
457
512
|
);
|
|
458
|
-
for (const m of
|
|
513
|
+
for (const m of outcome.plugins.mismatches) {
|
|
459
514
|
console.log(
|
|
460
|
-
|
|
515
|
+
noteLine(
|
|
516
|
+
`${m.pluginId} (${m.scope}): manifest pins ${m.pinned}, installed ${m.actual ?? "unknown"}`,
|
|
517
|
+
),
|
|
461
518
|
);
|
|
462
519
|
}
|
|
463
520
|
console.log(
|
|
464
|
-
|
|
521
|
+
noteLine(
|
|
522
|
+
"Recorded what is actually installed. Update the pin to match, or leave it as a record of intent.",
|
|
523
|
+
),
|
|
465
524
|
);
|
|
466
525
|
}
|
|
467
526
|
|
|
468
527
|
if (failed.length > 0) {
|
|
469
|
-
console.error(`Failed: ${failed.join(", ")}`);
|
|
528
|
+
console.error(failLine(`Failed: ${failed.join(", ")}`));
|
|
470
529
|
return 1;
|
|
471
530
|
}
|
|
472
531
|
return 0;
|