magus-cli 7.7.0 → 7.8.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__/blackbox-parity.test.ts +29 -15
- package/src/__tests__/blackbox-plugin-state.test.ts +25 -6
- package/src/__tests__/blind-780-cli-tools.test.ts +808 -0
- package/src/__tests__/blind-780-deprecated.test.ts +631 -0
- package/src/__tests__/blind-780-failure-reasons.test.ts +1004 -0
- package/src/__tests__/blind-780-plugin-resolution.test.ts +431 -0
- package/src/__tests__/blind-780-review2.test.ts +1203 -0
- package/src/__tests__/cli-update-view.test.ts +3 -3
- package/src/__tests__/deprecated-remove.test.ts +827 -0
- package/src/__tests__/doctor.test.ts +159 -1
- package/src/__tests__/failure-popup-review2.test.tsx +920 -0
- package/src/__tests__/failure-popup.test.tsx +419 -0
- package/src/__tests__/final-fixes-780.test.tsx +724 -0
- package/src/__tests__/format-update-outcome.test.ts +6 -32
- package/src/__tests__/gitignore-profile.test.ts +1 -9
- package/src/__tests__/models-screen-state.test.ts +18 -40
- package/src/__tests__/plugin-resolution.test.ts +52 -13
- package/src/__tests__/plugin-row-text.test.ts +30 -37
- package/src/__tests__/profile-dependencies.test.ts +7 -0
- package/src/__tests__/profile-startup-old-format.test.ts +0 -3
- package/src/__tests__/profile-startup.test.ts +40 -12
- package/src/__tests__/stale-load-repair.test.ts +31 -23
- package/src/__tests__/update-apply.test.ts +13 -29
- package/src/__tests__/update-plan.test.ts +23 -17
- package/src/__tests__/useStaleLoadModal.test.ts +1 -57
- package/src/__tests__/version-unknown-state.test.ts +15 -17
- package/src/cli/doctor.ts +151 -30
- package/src/cli/install.ts +5 -6
- package/src/cli/update-view.ts +58 -15
- package/src/cli/update.ts +27 -5
- package/src/services/catalog-cache-store.ts +8 -6
- package/src/services/claude-cli.ts +106 -19
- package/src/services/claude-settings.ts +84 -36
- package/src/services/plugin-env-needs.ts +69 -0
- package/src/services/plugin-manager.ts +31 -19
- package/src/services/plugin-resolution.ts +34 -42
- package/src/services/profile-activate.ts +1 -2
- package/src/services/profile-startup.ts +18 -43
- package/src/services/profile-state.ts +72 -2
- package/src/services/resolver.ts +95 -1
- package/src/services/update-engine.ts +326 -31
- package/src/services/update-plan.ts +293 -33
- package/src/types/index.ts +7 -0
- package/src/ui/App.tsx +90 -146
- package/src/ui/adapters/cliToolsAdapter.ts +203 -1
- package/src/ui/adapters/failureReport.ts +137 -0
- package/src/ui/adapters/pluginsAdapter.ts +297 -45
- package/src/ui/adapters/startupReports.ts +282 -0
- package/src/ui/components/modals/FailureListModal.tsx +231 -0
- package/src/ui/components/modals/MessageModal.tsx +7 -12
- package/src/ui/components/modals/ModalContainer.tsx +10 -5
- package/src/ui/components/modals/index.ts +1 -0
- package/src/ui/hooks/startupQueue.ts +56 -0
- package/src/ui/hooks/useGitignoreModal.ts +30 -52
- package/src/ui/hooks/useProfileStartupModal.ts +119 -142
- package/src/ui/hooks/useStaleLoadModal.ts +35 -65
- package/src/ui/keymap.ts +14 -1
- package/src/ui/renderers/mcpRenderers.tsx +110 -3
- package/src/ui/renderers/pluginRenderers.tsx +77 -6
- package/src/ui/renderers/pluginRowText.ts +25 -25
- package/src/ui/renderers/pluginStatusText.ts +18 -1
- package/src/ui/screens/AliasScreen.tsx +38 -45
- package/src/ui/screens/CliToolsScreen.tsx +65 -54
- package/src/ui/screens/EnvVarsScreen.tsx +17 -5
- package/src/ui/screens/GitignoreScreen.tsx +48 -67
- package/src/ui/screens/McpRegistryScreen.tsx +67 -38
- package/src/ui/screens/McpScreen.tsx +46 -19
- package/src/ui/screens/ModelsScreen.tsx +46 -86
- package/src/ui/screens/PluginsScreen.tsx +313 -221
- package/src/ui/screens/ProfilesScreen.tsx +79 -40
- package/src/ui/screens/SkillsScreen.tsx +83 -80
- package/src/ui/screens/StylesScreen.tsx +211 -275
- package/src/ui/state/AppContext.tsx +66 -44
- package/src/ui/state/DimensionsContext.tsx +3 -22
- package/src/ui/state/reducer.ts +12 -100
- package/src/ui/state/types.ts +48 -69
- package/src/utils/user-path.ts +60 -0
- package/src/__tests__/notices-rules.test.ts +0 -476
- package/src/__tests__/notices.test.tsx +0 -604
- package/src/__tests__/styles-status-line.test.ts +0 -72
- package/src/__tests__/version-snapshot.test.ts +0 -212
- package/src/services/version-snapshot.ts +0 -189
|
@@ -40,10 +40,20 @@ import type { Command } from "../utils/command-utils.js";
|
|
|
40
40
|
import { formatCommand } from "../utils/command-utils.js";
|
|
41
41
|
import { runCommand } from "../utils/run.js";
|
|
42
42
|
import type { PluginScope } from "./claude-cli.js";
|
|
43
|
-
import {
|
|
43
|
+
import {
|
|
44
|
+
installPlugin,
|
|
45
|
+
repairPlugin,
|
|
46
|
+
uninstallPluginViaCli,
|
|
47
|
+
updatePlugin,
|
|
48
|
+
} from "./claude-cli.js";
|
|
44
49
|
import {
|
|
45
50
|
type StaleLoadRepairResult,
|
|
51
|
+
forgetInstalledPluginVersion,
|
|
52
|
+
getEnabledPlugins,
|
|
53
|
+
getGlobalEnabledPlugins,
|
|
54
|
+
getLocalEnabledPlugins,
|
|
46
55
|
loadedVersionHere,
|
|
56
|
+
notDownloadedReason,
|
|
47
57
|
readInstalledPluginsRegistry,
|
|
48
58
|
repairStaleLoad,
|
|
49
59
|
rowBelongsToRepository,
|
|
@@ -54,6 +64,7 @@ import {
|
|
|
54
64
|
readInstalledVersionForScope,
|
|
55
65
|
saveInstalledPluginVersionForScope,
|
|
56
66
|
} from "./plugin-manager.js";
|
|
67
|
+
import { removePluginFromProfile } from "./profile-state.js";
|
|
57
68
|
import { installSkill } from "./skills-manager.js";
|
|
58
69
|
import {
|
|
59
70
|
type ToolFacts,
|
|
@@ -95,6 +106,13 @@ export interface PinMismatch {
|
|
|
95
106
|
|
|
96
107
|
export interface PluginApplyResult extends ApplyResult {
|
|
97
108
|
mismatches: PinMismatch[];
|
|
109
|
+
/**
|
|
110
|
+
* Deprecated plugins removed — installed at no scope here afterwards, and no
|
|
111
|
+
* longer listed by the profile. Also counted in `ok`; named separately so the
|
|
112
|
+
* CLI can say "Removed …" ({@link describeRemoved}). The Plugins screen needs
|
|
113
|
+
* no line: the rows are gone.
|
|
114
|
+
*/
|
|
115
|
+
removed: string[];
|
|
98
116
|
}
|
|
99
117
|
|
|
100
118
|
/** Everything one full apply produced, for whichever front end is reporting. */
|
|
@@ -121,8 +139,22 @@ export interface UpdateOutcome {
|
|
|
121
139
|
export interface ApplyReporter {
|
|
122
140
|
/** An item is starting. Called before any of its work. */
|
|
123
141
|
begin(name: string): void;
|
|
124
|
-
/**
|
|
125
|
-
|
|
142
|
+
/**
|
|
143
|
+
* An item finished. `detail` is already formatted for display, in the user's
|
|
144
|
+
* terms: a failure's detail is the REASON — the CLI prints it, and the
|
|
145
|
+
* Plugins screen shows it on the plugin's row.
|
|
146
|
+
*
|
|
147
|
+
* `cause` is `"exception"` when the detail is instead an error thrown from
|
|
148
|
+
* something the engine called — a Claude Code command's own output, a file
|
|
149
|
+
* that would not parse — rather than a reason the engine worded itself.
|
|
150
|
+
*/
|
|
151
|
+
finish(
|
|
152
|
+
ok: boolean,
|
|
153
|
+
name: string,
|
|
154
|
+
detail: string,
|
|
155
|
+
ms: number,
|
|
156
|
+
cause?: FinishCause,
|
|
157
|
+
): void;
|
|
126
158
|
/**
|
|
127
159
|
* Run `work` with the terminal released.
|
|
128
160
|
*
|
|
@@ -134,6 +166,9 @@ export interface ApplyReporter {
|
|
|
134
166
|
detach<T>(work: () => Promise<T>): Promise<T>;
|
|
135
167
|
}
|
|
136
168
|
|
|
169
|
+
/** Where a finished item's detail came from — see {@link ApplyReporter.finish}. */
|
|
170
|
+
export type FinishCause = "reason" | "exception";
|
|
171
|
+
|
|
137
172
|
/** Reports nothing. The right default for a caller that renders its own state. */
|
|
138
173
|
export const SILENT_REPORTER: ApplyReporter = {
|
|
139
174
|
begin: () => {},
|
|
@@ -211,8 +246,83 @@ export interface PluginApplyDeps {
|
|
|
211
246
|
row: InstalledPluginEntry,
|
|
212
247
|
projectPath: string,
|
|
213
248
|
) => boolean;
|
|
249
|
+
/**
|
|
250
|
+
* What removing a deprecated plugin takes. Optional so the many doubles that
|
|
251
|
+
* model only installs and updates need not grow three stubs each; a `remove`
|
|
252
|
+
* item applied without it FAILS, saying so, rather than reporting a removal
|
|
253
|
+
* that did not happen.
|
|
254
|
+
*/
|
|
255
|
+
remove?: PluginRemoveDeps;
|
|
214
256
|
}
|
|
215
257
|
|
|
258
|
+
/** The three calls a `remove` makes — see {@link applyRemove}. */
|
|
259
|
+
export interface PluginRemoveDeps {
|
|
260
|
+
/**
|
|
261
|
+
* `claude plugin uninstall <id> --scope <scope>`, and only that
|
|
262
|
+
* (`uninstallPluginViaCli`, claude-cli.ts): it THROWS when the command
|
|
263
|
+
* fails. The wrapper with a fallback (`uninstallPlugin`) edits the settings
|
|
264
|
+
* files itself on failure, which is a direct write of Claude Code's files and
|
|
265
|
+
* hides the command's error from the read-back. Measured on Claude Code
|
|
266
|
+
* 2.1.284: the command uninstalls a plugin its marketplace no longer lists,
|
|
267
|
+
* deleting the plugin from that scope's settings.
|
|
268
|
+
*/
|
|
269
|
+
uninstall: (
|
|
270
|
+
pluginId: string,
|
|
271
|
+
scope: PluginScope,
|
|
272
|
+
projectPath: string,
|
|
273
|
+
) => Promise<void>;
|
|
274
|
+
/**
|
|
275
|
+
* Installed at `scope` here, by the one definition the classifier uses: that
|
|
276
|
+
* scope's settings turn the plugin on. The read-back after uninstalling.
|
|
277
|
+
*/
|
|
278
|
+
isInstalled: (
|
|
279
|
+
pluginId: string,
|
|
280
|
+
scope: PluginScope,
|
|
281
|
+
projectPath: string,
|
|
282
|
+
) => Promise<boolean>;
|
|
283
|
+
/**
|
|
284
|
+
* Stop `profile` listing the plugin; answers whether it no longer does.
|
|
285
|
+
* `profile` undefined means the active one — see `removePluginFromProfile`.
|
|
286
|
+
*/
|
|
287
|
+
dropFromProfile: (
|
|
288
|
+
pluginId: string,
|
|
289
|
+
projectPath: string,
|
|
290
|
+
profile: string | null | undefined,
|
|
291
|
+
) => Promise<boolean>;
|
|
292
|
+
/**
|
|
293
|
+
* Clear magus's OWN `installedPluginVersions` claim for the plugin at a
|
|
294
|
+
* scope it was removed from (`forgetInstalledPluginVersion`). Claude Code
|
|
295
|
+
* never writes that field, so its uninstall leaves the claim behind, and a
|
|
296
|
+
* leftover claim is a plugin that still reads as installed. Optional so a
|
|
297
|
+
* double that does not model it skips it; best-effort, because the removal
|
|
298
|
+
* itself is judged by `isInstalled`, never by this field.
|
|
299
|
+
*/
|
|
300
|
+
forgetVersion?: (
|
|
301
|
+
pluginId: string,
|
|
302
|
+
scope: PluginScope,
|
|
303
|
+
projectPath: string,
|
|
304
|
+
) => Promise<void>;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** The settings a scope's install lives in — the classifier's definition. */
|
|
308
|
+
async function enabledAt(
|
|
309
|
+
scope: PluginScope,
|
|
310
|
+
projectPath: string,
|
|
311
|
+
): Promise<Record<string, boolean>> {
|
|
312
|
+
if (scope === "user") return getGlobalEnabledPlugins();
|
|
313
|
+
if (scope === "local") return getLocalEnabledPlugins(projectPath);
|
|
314
|
+
return getEnabledPlugins(projectPath);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
export const REAL_REMOVE_DEPS: PluginRemoveDeps = {
|
|
318
|
+
uninstall: uninstallPluginViaCli,
|
|
319
|
+
isInstalled: async (pluginId, scope, projectPath) =>
|
|
320
|
+
(await enabledAt(scope, projectPath))[pluginId] === true,
|
|
321
|
+
dropFromProfile: (pluginId, projectPath, profile) =>
|
|
322
|
+
removePluginFromProfile(projectPath, pluginId, profile),
|
|
323
|
+
forgetVersion: forgetInstalledPluginVersion,
|
|
324
|
+
};
|
|
325
|
+
|
|
216
326
|
export const REAL_PLUGIN_DEPS: PluginApplyDeps = {
|
|
217
327
|
install: installPlugin,
|
|
218
328
|
update: updatePlugin,
|
|
@@ -225,6 +335,7 @@ export const REAL_PLUGIN_DEPS: PluginApplyDeps = {
|
|
|
225
335
|
const rows = (await readInstalledPluginsRegistry()).plugins?.[pluginId];
|
|
226
336
|
return Array.isArray(rows) ? rows : [];
|
|
227
337
|
},
|
|
338
|
+
remove: REAL_REMOVE_DEPS,
|
|
228
339
|
};
|
|
229
340
|
|
|
230
341
|
/**
|
|
@@ -330,18 +441,23 @@ async function followMisdirectedUpdate(
|
|
|
330
441
|
return {
|
|
331
442
|
elsewhere,
|
|
332
443
|
version,
|
|
333
|
-
notFollowed: repair.ok
|
|
334
|
-
? null
|
|
335
|
-
: (repair.error ?? "the registry was not rewritten"),
|
|
444
|
+
notFollowed: repair.ok ? null : (repair.error ?? "nothing was changed"),
|
|
336
445
|
};
|
|
337
446
|
}
|
|
338
447
|
|
|
339
|
-
/**
|
|
448
|
+
/**
|
|
449
|
+
* The words for a misdirected update, for an item's detail. In the user's
|
|
450
|
+
* terms: which OTHER folders were touched is counted, not listed — their paths
|
|
451
|
+
* are the mechanism, and the fact is that this folder was not the one updated.
|
|
452
|
+
*/
|
|
340
453
|
function describeMisdirected(m: MisdirectedUpdate): string {
|
|
341
|
-
const where =
|
|
454
|
+
const where =
|
|
455
|
+
m.elsewhere.length === 1
|
|
456
|
+
? "another folder of this repository"
|
|
457
|
+
: `${m.elsewhere.length} other folders of this repository`;
|
|
342
458
|
return m.notFollowed === null
|
|
343
|
-
? `Claude Code updated ${where} instead of this
|
|
344
|
-
: `Claude Code updated ${where} instead of this
|
|
459
|
+
? `Claude Code updated ${where} instead of this one — this folder now loads v${m.version} too`
|
|
460
|
+
: `Claude Code updated ${where} instead of this one, and this folder could not follow: ${m.notFollowed}`;
|
|
345
461
|
}
|
|
346
462
|
|
|
347
463
|
/**
|
|
@@ -377,9 +493,49 @@ export async function bringLoadForward(
|
|
|
377
493
|
|
|
378
494
|
/** The words for a load brought forward, appended to an item's detail. */
|
|
379
495
|
function describeLoadForward(outcome: StaleLoadRepairResult): string {
|
|
380
|
-
return `Claude Code now loads v${outcome.target}
|
|
381
|
-
|
|
382
|
-
|
|
496
|
+
return `Claude Code now loads v${outcome.target} in this folder`;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* The words for an install that landed but does not load — the failure detail
|
|
501
|
+
* `magus update`, the Plugins screen and `magus install` all print.
|
|
502
|
+
*
|
|
503
|
+
* When the repair's own check named the version still loading, that version IS
|
|
504
|
+
* the reason, and nothing is appended: the repair's message says the same thing
|
|
505
|
+
* a second time. When it refused before writing (`loaded` is then null), its
|
|
506
|
+
* message is the only reason there is, so it follows — except the refusal for
|
|
507
|
+
* a version with no download, which names the version again: "installed
|
|
508
|
+
* v1.3.0, but v1.3.0 is not downloaded" reads as a contradiction, so that one
|
|
509
|
+
* says the install did not download.
|
|
510
|
+
*/
|
|
511
|
+
export function describeLoadNotForward(forward: StaleLoadRepairResult): string {
|
|
512
|
+
// `target` is the version that landed: the repair was asked to make it load.
|
|
513
|
+
const installed = forward.target;
|
|
514
|
+
if (forward.loaded) {
|
|
515
|
+
return `installed v${installed}, but Claude Code still loads v${forward.loaded} in this folder`;
|
|
516
|
+
}
|
|
517
|
+
if (forward.error === notDownloadedReason(installed)) {
|
|
518
|
+
return `installed v${installed}, but it did not download — run magus install`;
|
|
519
|
+
}
|
|
520
|
+
return `installed v${installed}, but ${
|
|
521
|
+
forward.error ?? "Claude Code still loads an older version in this folder"
|
|
522
|
+
}`;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* The line `magus update` prints for the deprecated plugins an apply removed,
|
|
527
|
+
* or null when it removed none. Plugin names, not ids: the line is
|
|
528
|
+
* about what the user knew them as.
|
|
529
|
+
*/
|
|
530
|
+
export function describeRemoved(removed: readonly string[]): string | null {
|
|
531
|
+
if (removed.length === 0) return null;
|
|
532
|
+
return `Removed ${removed.map(pluginName).join(", ")} — no longer published`;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/** A plugin id's name part: `kanban@magus` → `kanban`. */
|
|
536
|
+
export function pluginName(pluginId: string): string {
|
|
537
|
+
const at = pluginId.indexOf("@");
|
|
538
|
+
return at > 0 ? pluginId.slice(0, at) : pluginId;
|
|
383
539
|
}
|
|
384
540
|
|
|
385
541
|
// -- selection ---------------------------------------------------------------
|
|
@@ -409,10 +565,15 @@ function scopesToApply(item: PluginUpdateItem): PluginScope[] {
|
|
|
409
565
|
// measured NOT to clear it — it writes another row for this project while the
|
|
410
566
|
// row the loader picks belongs to a different one — which is exactly why it
|
|
411
567
|
// must not come through here. {@link pluginNeedsWork} counts it separately.
|
|
568
|
+
//
|
|
569
|
+
// `remove` has its own branch too ({@link applyRemove}): its scopes are where
|
|
570
|
+
// to UNINSTALL, and a declared plugin installed nowhere still has work — the
|
|
571
|
+
// profile edit — with none at all.
|
|
412
572
|
if (
|
|
413
573
|
item.action === "current" ||
|
|
414
574
|
item.action === "unknown" ||
|
|
415
|
-
item.action === "stale-load"
|
|
575
|
+
item.action === "stale-load" ||
|
|
576
|
+
item.action === "remove"
|
|
416
577
|
)
|
|
417
578
|
return [];
|
|
418
579
|
return item.action === "install" && item.reason === "absent"
|
|
@@ -429,10 +590,16 @@ function scopesToApply(item: PluginUpdateItem): PluginScope[] {
|
|
|
429
590
|
* looks exactly like a hang.
|
|
430
591
|
*
|
|
431
592
|
* `stale-load` is work without scopes — see {@link scopesToApply} — so it is
|
|
432
|
-
* named here rather than counted through the scope list.
|
|
593
|
+
* named here rather than counted through the scope list. So is `remove`: a
|
|
594
|
+
* deprecated plugin the profile declares and nothing installed is still work,
|
|
595
|
+
* because the profile must stop declaring it.
|
|
433
596
|
*/
|
|
434
597
|
export function pluginNeedsWork(item: PluginUpdateItem): boolean {
|
|
435
|
-
return
|
|
598
|
+
return (
|
|
599
|
+
item.action === "stale-load" ||
|
|
600
|
+
item.action === "remove" ||
|
|
601
|
+
scopesToApply(item).length > 0
|
|
602
|
+
);
|
|
436
603
|
}
|
|
437
604
|
|
|
438
605
|
// -- plugins -----------------------------------------------------------------
|
|
@@ -555,7 +722,7 @@ async function applyStaleLoad(
|
|
|
555
722
|
report.finish(
|
|
556
723
|
false,
|
|
557
724
|
item.pluginId,
|
|
558
|
-
|
|
725
|
+
"no version is installed in this folder for Claude Code to load instead",
|
|
559
726
|
Date.now() - startedAt,
|
|
560
727
|
);
|
|
561
728
|
result.failed.push(item.pluginId);
|
|
@@ -571,9 +738,7 @@ async function applyStaleLoad(
|
|
|
571
738
|
report.finish(
|
|
572
739
|
true,
|
|
573
740
|
item.pluginId,
|
|
574
|
-
|
|
575
|
-
outcome.rewritten === 1 ? "" : "s"
|
|
576
|
-
} moved forward)`,
|
|
741
|
+
describeLoadForward(outcome),
|
|
577
742
|
Date.now() - startedAt,
|
|
578
743
|
);
|
|
579
744
|
result.ok++;
|
|
@@ -581,32 +746,159 @@ async function applyStaleLoad(
|
|
|
581
746
|
report.finish(
|
|
582
747
|
false,
|
|
583
748
|
item.pluginId,
|
|
584
|
-
outcome.error ??
|
|
749
|
+
outcome.error ??
|
|
750
|
+
`Claude Code still does not load v${item.target} in this folder`,
|
|
585
751
|
Date.now() - startedAt,
|
|
586
752
|
);
|
|
587
753
|
result.failed.push(item.pluginId);
|
|
588
754
|
}
|
|
589
755
|
} catch (error) {
|
|
590
756
|
const msg = error instanceof Error ? error.message : String(error);
|
|
591
|
-
report.finish(
|
|
757
|
+
report.finish(
|
|
758
|
+
false,
|
|
759
|
+
item.pluginId,
|
|
760
|
+
msg,
|
|
761
|
+
Date.now() - startedAt,
|
|
762
|
+
"exception",
|
|
763
|
+
);
|
|
592
764
|
result.failed.push(item.pluginId);
|
|
593
765
|
}
|
|
594
766
|
}
|
|
595
767
|
|
|
768
|
+
/**
|
|
769
|
+
* Remove ONE deprecated plugin — the `remove` half of {@link applyPlugins}.
|
|
770
|
+
*
|
|
771
|
+
* Uninstalls it at every scope the plan found it installed at, through the
|
|
772
|
+
* Claude Code CLI wrapper, then stops the profile listing it. Both halves run
|
|
773
|
+
* even when the first fails part-way: a profile that keeps a plugin no
|
|
774
|
+
* marketplace publishes only ever asks for an install that cannot happen.
|
|
775
|
+
*
|
|
776
|
+
* Success is READ BACK, never "the calls returned": installed at no scope here
|
|
777
|
+
* afterwards — all three are checked, not just the planned ones — and the
|
|
778
|
+
* profile no longer lists it.
|
|
779
|
+
*/
|
|
780
|
+
async function applyRemove(
|
|
781
|
+
item: PluginUpdateItem,
|
|
782
|
+
projectPath: string,
|
|
783
|
+
deps: PluginApplyDeps,
|
|
784
|
+
report: ApplyReporter,
|
|
785
|
+
result: PluginApplyResult,
|
|
786
|
+
profile: string | null | undefined,
|
|
787
|
+
): Promise<void> {
|
|
788
|
+
report.begin(item.pluginId);
|
|
789
|
+
const startedAt = Date.now();
|
|
790
|
+
const fail = (detail: string, cause?: FinishCause) => {
|
|
791
|
+
report.finish(false, item.pluginId, detail, Date.now() - startedAt, cause);
|
|
792
|
+
result.failed.push(item.pluginId);
|
|
793
|
+
};
|
|
794
|
+
const remove = deps.remove;
|
|
795
|
+
if (!remove) {
|
|
796
|
+
fail("magus cannot remove plugins from here");
|
|
797
|
+
return;
|
|
798
|
+
}
|
|
799
|
+
try {
|
|
800
|
+
let uninstallError: unknown = null;
|
|
801
|
+
for (const scope of item.scopes) {
|
|
802
|
+
try {
|
|
803
|
+
await remove.uninstall(item.pluginId, scope, projectPath);
|
|
804
|
+
} catch (error) {
|
|
805
|
+
uninstallError ??= error;
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
const unlisted = await remove.dropFromProfile(
|
|
809
|
+
item.pluginId,
|
|
810
|
+
projectPath,
|
|
811
|
+
profile,
|
|
812
|
+
);
|
|
813
|
+
|
|
814
|
+
const still: PluginScope[] = [];
|
|
815
|
+
for (const scope of REMOVE_SCOPES) {
|
|
816
|
+
if (await remove.isInstalled(item.pluginId, scope, projectPath)) {
|
|
817
|
+
still.push(scope);
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
// magus's own claim goes with the install, at each scope it was removed
|
|
821
|
+
// from. Best-effort: a claim left behind no longer counts as an install
|
|
822
|
+
// anywhere, so failing to clear it must not fail a removal that happened.
|
|
823
|
+
if (remove.forgetVersion) {
|
|
824
|
+
for (const scope of item.scopes) {
|
|
825
|
+
if (still.includes(scope)) continue;
|
|
826
|
+
await remove
|
|
827
|
+
.forgetVersion(item.pluginId, scope, projectPath)
|
|
828
|
+
.catch(() => {});
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
if (still.length > 0) {
|
|
832
|
+
// The CLI's own words when it said anything; ours otherwise.
|
|
833
|
+
if (uninstallError !== null) {
|
|
834
|
+
fail(
|
|
835
|
+
uninstallError instanceof Error
|
|
836
|
+
? uninstallError.message
|
|
837
|
+
: String(uninstallError),
|
|
838
|
+
"exception",
|
|
839
|
+
);
|
|
840
|
+
} else {
|
|
841
|
+
fail(
|
|
842
|
+
`still installed at ${still.map((s) => s.toUpperCase()).join(", ")} scope`,
|
|
843
|
+
);
|
|
844
|
+
}
|
|
845
|
+
return;
|
|
846
|
+
}
|
|
847
|
+
if (!unlisted) {
|
|
848
|
+
fail("the profile still lists it");
|
|
849
|
+
return;
|
|
850
|
+
}
|
|
851
|
+
report.finish(
|
|
852
|
+
true,
|
|
853
|
+
item.pluginId,
|
|
854
|
+
item.scopes.length > 0
|
|
855
|
+
? `removed at ${item.scopes.map((s) => s.toUpperCase()).join(", ")} · no longer published`
|
|
856
|
+
: "removed from the profile · no longer published",
|
|
857
|
+
Date.now() - startedAt,
|
|
858
|
+
);
|
|
859
|
+
result.ok++;
|
|
860
|
+
result.removed.push(item.pluginId);
|
|
861
|
+
} catch (error) {
|
|
862
|
+
fail(error instanceof Error ? error.message : String(error), "exception");
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
/** Every scope a removal must leave the plugin uninstalled at. */
|
|
867
|
+
const REMOVE_SCOPES: readonly PluginScope[] = ["user", "project", "local"];
|
|
868
|
+
|
|
869
|
+
/** Options for {@link applyPlugins}. */
|
|
870
|
+
export interface ApplyPluginsOptions {
|
|
871
|
+
/**
|
|
872
|
+
* The profile the plan was made from — the one a removed plugin is dropped
|
|
873
|
+
* from. Undefined means the active profile; `magus update <name>` passes the
|
|
874
|
+
* named one, which need not be active.
|
|
875
|
+
*/
|
|
876
|
+
profile?: string | null;
|
|
877
|
+
}
|
|
878
|
+
|
|
596
879
|
export async function applyPlugins(
|
|
597
880
|
items: PluginUpdateItem[],
|
|
598
881
|
projectPath: string,
|
|
599
882
|
deps: PluginApplyDeps = REAL_PLUGIN_DEPS,
|
|
600
883
|
report: ApplyReporter = SILENT_REPORTER,
|
|
884
|
+
opts: ApplyPluginsOptions = {},
|
|
601
885
|
): Promise<PluginApplyResult> {
|
|
602
886
|
const result: PluginApplyResult = {
|
|
603
887
|
ok: 0,
|
|
604
888
|
failed: [],
|
|
605
889
|
unchanged: [],
|
|
606
890
|
mismatches: [],
|
|
891
|
+
removed: [],
|
|
607
892
|
};
|
|
608
893
|
|
|
609
894
|
for (const item of items) {
|
|
895
|
+
// A deprecated plugin is uninstalled and dropped from the profile — no
|
|
896
|
+
// installer, no version to read back — so it too has its own branch.
|
|
897
|
+
if (item.action === "remove") {
|
|
898
|
+
await applyRemove(item, projectPath, deps, report, result, opts.profile);
|
|
899
|
+
continue;
|
|
900
|
+
}
|
|
901
|
+
|
|
610
902
|
// A stale load is repaired in the REGISTRY, not by any installer, so it
|
|
611
903
|
// leaves the scope loop entirely. Its own branch rather than a scope with a
|
|
612
904
|
// special case inside: everything below reads back a per-scope version and
|
|
@@ -695,9 +987,7 @@ export async function applyPlugins(
|
|
|
695
987
|
report.finish(
|
|
696
988
|
false,
|
|
697
989
|
item.pluginId,
|
|
698
|
-
|
|
699
|
-
forward.loaded ?? "an older version"
|
|
700
|
-
} here — ${forward.error ?? "the older records could not be moved forward"}`,
|
|
990
|
+
describeLoadNotForward(forward),
|
|
701
991
|
Date.now() - startedAt,
|
|
702
992
|
);
|
|
703
993
|
result.failed.push(item.pluginId);
|
|
@@ -726,7 +1016,13 @@ export async function applyPlugins(
|
|
|
726
1016
|
}
|
|
727
1017
|
} catch (error) {
|
|
728
1018
|
const msg = error instanceof Error ? error.message : String(error);
|
|
729
|
-
report.finish(
|
|
1019
|
+
report.finish(
|
|
1020
|
+
false,
|
|
1021
|
+
item.pluginId,
|
|
1022
|
+
msg,
|
|
1023
|
+
Date.now() - startedAt,
|
|
1024
|
+
"exception",
|
|
1025
|
+
);
|
|
730
1026
|
result.failed.push(item.pluginId);
|
|
731
1027
|
}
|
|
732
1028
|
}
|
|
@@ -764,11 +1060,7 @@ async function applyOneInScope(
|
|
|
764
1060
|
if (actual) {
|
|
765
1061
|
const forward = await bringLoadForward(pluginId, actual, projectPath, deps);
|
|
766
1062
|
if (forward && !forward.ok) {
|
|
767
|
-
throw new Error(
|
|
768
|
-
`installed v${actual}, but Claude Code still loads v${
|
|
769
|
-
forward.loaded ?? "an older version"
|
|
770
|
-
} here — ${forward.error ?? "the older records could not be moved forward"}`,
|
|
771
|
-
);
|
|
1063
|
+
throw new Error(describeLoadNotForward(forward));
|
|
772
1064
|
}
|
|
773
1065
|
}
|
|
774
1066
|
return actual;
|
|
@@ -1045,6 +1337,9 @@ export async function applyPlan(
|
|
|
1045
1337
|
projectPath,
|
|
1046
1338
|
opts.deps ?? REAL_PLUGIN_DEPS,
|
|
1047
1339
|
report,
|
|
1340
|
+
// The profile planned is the one a removed plugin leaves — which, under
|
|
1341
|
+
// `magus update <name>`, need not be the active one.
|
|
1342
|
+
{ profile: plan.profileId },
|
|
1048
1343
|
);
|
|
1049
1344
|
const bins = await applyBins(plan, report, opts.run, opts.probe);
|
|
1050
1345
|
const skills = await applySkills(plan, projectPath, report);
|