magus-cli 7.11.3 → 7.11.5

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.
@@ -285,6 +285,37 @@ export function resolveExtends(
285
285
  };
286
286
  }
287
287
 
288
+ /** Whether a profile entry, `extends` included, turns the plugin on. */
289
+ export function profileLists(
290
+ entry: ProfileManifestEntry | undefined,
291
+ pluginId: string,
292
+ ): boolean {
293
+ if (!entry) return false;
294
+ const value = resolveExtends(entry).plugins?.[pluginId];
295
+ return value !== undefined && value !== false;
296
+ }
297
+
298
+ /**
299
+ * Every plugin id some profile in the manifest turns on, `extends` included —
300
+ * by {@link profileLists}, the test a removal uses to find the profiles it
301
+ * must edit.
302
+ *
303
+ * The planner's view of the profiles that are NOT active. The render writes
304
+ * `false` for each of these into the live settings.json, so a deprecated one
305
+ * shows as a row on the Plugins screen even though the active profile never
306
+ * names it; `magus update` and the screen both pass this set to the planner,
307
+ * which is how such a plugin gets planned for removal at all.
308
+ */
309
+ export function pluginsAnyProfileLists(manifest: ProfileManifest): string[] {
310
+ const out = new Set<string>();
311
+ for (const entry of Object.values(manifest.profiles)) {
312
+ for (const pluginId of Object.keys(resolveExtends(entry).plugins ?? {})) {
313
+ if (profileLists(entry, pluginId)) out.add(pluginId);
314
+ }
315
+ }
316
+ return [...out];
317
+ }
318
+
288
319
  /** The pin a selected requirement installs at, if its chosen option carries one. */
289
320
  export function pinOfBin(bin: SelectedBin): string | undefined {
290
321
  const option = bin.chosen;
@@ -57,6 +57,7 @@ import {
57
57
  inheritedRowsOf,
58
58
  loadedVersionHere,
59
59
  notDownloadedReason,
60
+ readInheritedRegistryRows,
60
61
  readInstalledPluginsRegistry,
61
62
  repairStaleLoad,
62
63
  rowBelongsToRepository,
@@ -69,9 +70,15 @@ import {
69
70
  readInstalledVersionForScope,
70
71
  saveInstalledPluginVersionForScope,
71
72
  } from "./plugin-manager.js";
72
- import { activeProfile } from "./profile-live.js";
73
+ import { reconcileActiveProfile, refuseOnProblems } from "./profile-absorb.js";
74
+ import { regenerateLiveFiles } from "./profile-activate.js";
75
+ import {
76
+ activeProfile,
77
+ hasCurrentMarker,
78
+ readActiveMarker,
79
+ } from "./profile-live.js";
73
80
  import { removePluginFromProfile } from "./profile-state.js";
74
- import { profileDependentsOf } from "./resolver.js";
81
+ import { profileDependentsOf, profileLists } from "./resolver.js";
75
82
  import { installSkill } from "./skills-manager.js";
76
83
  import {
77
84
  type ToolFacts,
@@ -296,6 +303,17 @@ export interface PluginRemoveDeps {
296
303
  projectPath: string,
297
304
  profile: string | null | undefined,
298
305
  ) => Promise<boolean>;
306
+ /**
307
+ * Stop EVERY profile listing a deprecated plugin, the live files absorbed
308
+ * first (`unlistFromEveryProfile`); answers whether none lists it, read
309
+ * back, and throws the first failure. What {@link applyRemove} runs in
310
+ * place of `dropFromProfile` when present. Optional so a double that models
311
+ * one profile keeps doing it through `dropFromProfile`.
312
+ */
313
+ unlistEverywhere?: (
314
+ pluginId: string,
315
+ projectPath: string,
316
+ ) => Promise<boolean>;
299
317
  /**
300
318
  * Clear magus's OWN `installedPluginVersions` claim for the plugin at a
301
319
  * scope it was removed from (`forgetInstalledPluginVersion`). Claude Code
@@ -338,6 +356,12 @@ export const REAL_REMOVE_DEPS: PluginRemoveDeps = {
338
356
  (await enabledAt(scope, projectPath))[pluginId] === true,
339
357
  dropFromProfile: (pluginId, projectPath, profile) =>
340
358
  removePluginFromProfile(projectPath, pluginId, profile),
359
+ unlistEverywhere: async (pluginId, projectPath) => {
360
+ const { problems } = await unlistFromEveryProfile(pluginId, projectPath);
361
+ if (problems.length > 0) throw problems[0];
362
+ const { profiles } = await readManifest(projectPath);
363
+ return !Object.values(profiles).some((e) => profileLists(e, pluginId));
364
+ },
341
365
  forgetVersion: forgetInstalledPluginVersion,
342
366
  // The profile picked as `removePluginFromProfile` picks it, so the check and
343
367
  // the drop judge the same profile. A parse error throws, as it does there.
@@ -856,13 +880,14 @@ async function applyStaleLoad(
856
880
  * Remove ONE deprecated plugin — the `remove` half of {@link applyPlugins}.
857
881
  *
858
882
  * Uninstalls it at every scope the plan found it installed at, through the
859
- * Claude Code CLI wrapper, then stops the profile listing it. Both halves run
860
- * even when the first fails part-way: a profile that keeps a plugin no
861
- * marketplace publishes only ever asks for an install that cannot happen.
883
+ * Claude Code CLI wrapper, then stops every profile listing it
884
+ * (`unlistEverywhere`). Both halves run even when the first fails part-way: a
885
+ * profile that keeps a plugin no marketplace publishes only ever asks for an
886
+ * install that cannot happen.
862
887
  *
863
888
  * Success is READ BACK, never "the calls returned": installed at no scope here
864
- * afterwards — all three are checked, not just the planned ones — and the
865
- * profile no longer lists it.
889
+ * afterwards — all three are checked, not just the planned ones — and no
890
+ * profile lists it.
866
891
  */
867
892
  async function applyRemove(
868
893
  item: PluginUpdateItem,
@@ -914,11 +939,11 @@ async function applyRemove(
914
939
  inheritedError ??= error;
915
940
  }
916
941
  }
917
- const unlisted = await remove.dropFromProfile(
918
- item.pluginId,
919
- projectPath,
920
- profile,
921
- );
942
+ // Every profile that lists it, or one left listing it brings the row back
943
+ // on the next render.
944
+ const unlisted = remove.unlistEverywhere
945
+ ? await remove.unlistEverywhere(item.pluginId, projectPath)
946
+ : await remove.dropFromProfile(item.pluginId, projectPath, profile);
922
947
 
923
948
  const still: PluginScope[] = [];
924
949
  for (const scope of REMOVE_SCOPES) {
@@ -1007,6 +1032,115 @@ async function applyRemove(
1007
1032
  /** Every scope a removal must leave the plugin uninstalled at. */
1008
1033
  const REMOVE_SCOPES: readonly PluginScope[] = ["user", "project", "local"];
1009
1034
 
1035
+ /**
1036
+ * Remove an orphan the plan holds no `remove` item for — the Plugins screen's
1037
+ * `d` on a row the planner keeps rather than removes: it moved to another
1038
+ * marketplace, another plugin still requires it, or its marketplace was not
1039
+ * read. The user asked for it gone, so it goes.
1040
+ *
1041
+ * Uninstalls at every scope here, then each inherited row from its own folder,
1042
+ * then {@link unlistFromEveryProfile}: a key the render writes `false` for is
1043
+ * cleared only once no profile lists the id.
1044
+ *
1045
+ * `problems` holds every real failure; the CLI's "nothing at this scope here"
1046
+ * answer is not one. `unlistedFrom` names the profiles that listed it.
1047
+ */
1048
+ export async function removeDeprecatedPlugin(
1049
+ pluginId: string,
1050
+ projectPath: string,
1051
+ deps: {
1052
+ uninstall?: (id: string, scope: PluginScope, cwd: string) => Promise<void>;
1053
+ inheritedRows?: (
1054
+ id: string,
1055
+ projectPath: string,
1056
+ ) => Promise<InstalledPluginEntry[]>;
1057
+ } = {},
1058
+ ): Promise<{ problems: unknown[]; unlistedFrom: string[] }> {
1059
+ const uninstall = deps.uninstall ?? uninstallPluginViaCli;
1060
+ const inheritedRows = deps.inheritedRows ?? readInheritedRegistryRows;
1061
+ const problems: unknown[] = [];
1062
+ const unlistedFrom: string[] = [];
1063
+
1064
+ for (const scope of REMOVE_SCOPES) {
1065
+ try {
1066
+ await uninstall(pluginId, scope, projectPath);
1067
+ } catch (error) {
1068
+ if (!isNotInstalledAnswer(error)) problems.push(error);
1069
+ }
1070
+ }
1071
+ // A folder of the same repository may hold the install this one only
1072
+ // shows: the CLI removes it only when run from that folder.
1073
+ for (const row of await inheritedRows(pluginId, projectPath)) {
1074
+ if (!row.projectPath) continue;
1075
+ try {
1076
+ await uninstall(pluginId, row.scope, row.projectPath);
1077
+ } catch (error) {
1078
+ problems.push(error);
1079
+ }
1080
+ }
1081
+
1082
+ const unlisted = await unlistFromEveryProfile(pluginId, projectPath);
1083
+ problems.push(...unlisted.problems);
1084
+ return { problems, unlistedFrom: unlisted.unlistedFrom };
1085
+ }
1086
+
1087
+ /**
1088
+ * Stop EVERY profile in the project listing a deprecated plugin, keeping the
1089
+ * user's live edits — the profile half of both removals, {@link applyRemove}
1090
+ * (`magus update`, the Plugins screen's `a` and `d`) and
1091
+ * {@link removeDeprecatedPlugin}.
1092
+ *
1093
+ * Every profile, not only the active one: the render writes `false` for each
1094
+ * id ANY profile lists, so one profile left listing it brings the row back on
1095
+ * the next render. A deprecated plugin can never be installed, so no profile
1096
+ * loses anything it could use.
1097
+ *
1098
+ * The live files are absorbed into the active profile BEFORE any profile is
1099
+ * written, and rendered again after, so what Claude Code or the user wrote
1100
+ * since the last render survives and the id's key is gone. Under
1101
+ * `withCliBookkeeping` (`magus update`) both are left to the wrapper, whose
1102
+ * reconcile absorbs before it renders: reconciling here would write a fresh
1103
+ * marker mid-run, clearing the bookkeeping flag, so the installs that follow
1104
+ * would read as the user's own edits.
1105
+ *
1106
+ * `unlistedFrom` names the profiles that listed it, each READ BACK as no longer
1107
+ * listing it; `problems` holds every failure, a refused reconcile included,
1108
+ * in which case nothing was written.
1109
+ */
1110
+ async function unlistFromEveryProfile(
1111
+ pluginId: string,
1112
+ projectPath: string,
1113
+ ): Promise<{ problems: unknown[]; unlistedFrom: string[] }> {
1114
+ const problems: unknown[] = [];
1115
+ const unlistedFrom: string[] = [];
1116
+ try {
1117
+ const marker = (await hasCurrentMarker(projectPath))
1118
+ ? await readActiveMarker(projectPath)
1119
+ : null;
1120
+ const wrapperReconciles = marker?.cliBookkeeping === true;
1121
+ // Refuses, writing nothing, on an unreadable live file.
1122
+ if (!wrapperReconciles) {
1123
+ refuseOnProblems(await reconcileActiveProfile(projectPath));
1124
+ }
1125
+ const manifest = await readManifest(projectPath);
1126
+ for (const [id, entry] of Object.entries(manifest.profiles)) {
1127
+ if (!profileLists(entry, pluginId)) continue;
1128
+ if (await removePluginFromProfile(projectPath, pluginId, id)) {
1129
+ unlistedFrom.push(id);
1130
+ } else {
1131
+ problems.push(
1132
+ new Error(`the ${entry.name ?? id} profile still lists it`),
1133
+ );
1134
+ }
1135
+ }
1136
+ // Renders nothing, and answers false, when no profile is active.
1137
+ if (!wrapperReconciles) await regenerateLiveFiles(projectPath);
1138
+ } catch (error) {
1139
+ problems.push(error);
1140
+ }
1141
+ return { problems, unlistedFrom };
1142
+ }
1143
+
1010
1144
  /** Options for {@link applyPlugins}. */
1011
1145
  export interface ApplyPluginsOptions {
1012
1146
  /**
@@ -879,6 +879,8 @@ export function classifyPlugin(
879
879
  * Every plugin the active profile declares, under its pin, and every plugin
880
880
  * installed in any scope (user, project, local), under "latest" — the rule
881
881
  * {@link pinForRow} states — each classified by {@link classifyPlugin}, sorted.
882
+ * A plugin only other profiles list joins the plan only to be removed, when it
883
+ * is deprecated ({@link onlyOtherProfilesList}).
882
884
  *
883
885
  * ONE list, read by both surfaces: the screen's rows, header counts and
884
886
  * "update all", and the CLI's report and apply. It used to be two — the CLI
@@ -891,11 +893,16 @@ export function classifyPlugin(
891
893
  * @param catalog getAvailablePlugins() output — keyed by id, or as a list
892
894
  * @param requiredBy closure.dependencies?.requiredBy — which plugin needs each
893
895
  * dependency the profile does not name itself
896
+ * @param listedByProfiles every plugin id ANY profile in the manifest lists
897
+ * (`pluginsAnyProfileLists`). One neither pinned nor
898
+ * installed is planned only as a deprecated `remove`; see
899
+ * {@link onlyOtherProfilesList}.
894
900
  */
895
901
  export function planPluginUpdates(
896
902
  pinned: Readonly<Record<string, string>>,
897
903
  catalog: ReadonlyMap<string, PluginInfo> | readonly PluginInfo[],
898
904
  requiredBy: RequiredBy = {},
905
+ listedByProfiles: readonly string[] = [],
899
906
  ): PluginUpdateItem[] {
900
907
  const index = indexCatalog(catalog);
901
908
  const plugins =
@@ -908,13 +915,42 @@ export function planPluginUpdates(
908
915
  const pin = pinForRow(info, pinned);
909
916
  if (pin !== null) pins.set(info.id, pin);
910
917
  }
918
+ const othersOnly = new Set<string>();
919
+ for (const pluginId of listedByProfiles) {
920
+ if (pins.has(pluginId)) continue;
921
+ othersOnly.add(pluginId);
922
+ pins.set(pluginId, "latest");
923
+ }
911
924
  return settleDeprecated(
912
925
  [...pins].map(([pluginId, pin]) =>
913
926
  classifyPlugin(pluginId, pin, index, requiredBy),
914
927
  ),
915
928
  index,
916
929
  requiredBy,
917
- ).sort((a, b) => a.pluginId.localeCompare(b.pluginId));
930
+ )
931
+ .filter(
932
+ (item) => !othersOnly.has(item.pluginId) || onlyOtherProfilesList(item),
933
+ )
934
+ .sort((a, b) => a.pluginId.localeCompare(b.pluginId));
935
+ }
936
+
937
+ /**
938
+ * The one item a plugin gets when only profiles that are NOT active list it,
939
+ * and nothing here installs it: a deprecated `remove`.
940
+ *
941
+ * The render writes `false` for it into the live settings.json, so the Plugins
942
+ * screen shows it as a row, and a profile that keeps a plugin no marketplace
943
+ * publishes only ever asks for an install that cannot happen. Removing it here
944
+ * drops it from every profile that lists it, which is what clears the row.
945
+ *
946
+ * Anything else — published, moved, still required, a marketplace we did not
947
+ * read — is no work of this profile's: it is not active, so nothing is
948
+ * installed, updated or kept for it, and it gets no item at all. Judged after
949
+ * {@link settleDeprecated}, so retired plugins that only require each other are
950
+ * still removed together.
951
+ */
952
+ function onlyOtherProfilesList(item: PluginUpdateItem): boolean {
953
+ return item.action === "remove" && item.reason === "deprecated";
918
954
  }
919
955
 
920
956
  /**
@@ -951,9 +987,13 @@ export function classifyPluginRows(
951
987
  plugins: readonly PluginInfo[],
952
988
  pins: Readonly<Record<string, string>>,
953
989
  requiredBy: RequiredBy = {},
990
+ listedByProfiles: readonly string[] = [],
954
991
  ): Map<string, PluginUpdateItem> {
955
992
  return new Map(
956
- planPluginUpdates(pins, plugins, requiredBy).map((i) => [i.pluginId, i]),
993
+ planPluginUpdates(pins, plugins, requiredBy, listedByProfiles).map((i) => [
994
+ i.pluginId,
995
+ i,
996
+ ]),
957
997
  );
958
998
  }
959
999
 
@@ -9,14 +9,8 @@ import {
9
9
  type PluginScope,
10
10
  addMarketplace as cliAddMarketplace,
11
11
  repairPlugin as cliRepairPlugin,
12
- isNotInstalledAnswer,
13
- uninstallPluginViaCli,
14
12
  } from "../../services/claude-cli.js";
15
- import {
16
- readInheritedRegistryRows,
17
- readSettings,
18
- setMcpEnvVar,
19
- } from "../../services/claude-settings.js";
13
+ import { readSettings, setMcpEnvVar } from "../../services/claude-settings.js";
20
14
  import { clearContentDriftCache } from "../../services/content-drift.js";
21
15
  import { missingPluginEnvVars } from "../../services/plugin-env-needs.js";
22
16
  import {
@@ -49,6 +43,7 @@ import {
49
43
  applyPlugins,
50
44
  installPluginInScope,
51
45
  pluginNeedsWork,
46
+ removeDeprecatedPlugin,
52
47
  uninstallPluginInScope,
53
48
  updatePluginInScope,
54
49
  } from "../../services/update-engine.js";
@@ -176,6 +171,14 @@ export function PluginsScreen() {
176
171
  const [requiredBy, setRequiredBy] = useState<
177
172
  Readonly<Record<string, readonly string[]>>
178
173
  >({});
174
+ /**
175
+ * Every plugin any profile lists — the same set `magus update` plans with,
176
+ * so a deprecated plugin only another profile keeps is a `remove` row here
177
+ * and a removal there.
178
+ */
179
+ const [listedByProfiles, setListedByProfiles] = useState<readonly string[]>(
180
+ [],
181
+ );
179
182
 
180
183
  // Fetch data (always fetches all scopes). Only the most recently started
181
184
  // load may touch the screen.
@@ -209,6 +212,7 @@ export function PluginsScreen() {
209
212
  setPins(active?.closure.plugins ?? null);
210
213
  setActiveProfileId(active?.profileId ?? null);
211
214
  setRequiredBy(required);
215
+ setListedByProfiles(active?.listedByProfiles ?? []);
212
216
 
213
217
  // Which catalogs answered, and which only appeared to. Read straight
214
218
  // after the fetch, while the record still describes this attempt.
@@ -300,9 +304,14 @@ export function PluginsScreen() {
300
304
  const statuses = useMemo(
301
305
  (): Map<string, PluginUpdateItem> =>
302
306
  shownPlugins
303
- ? classifyPluginRows(shownPlugins, pins ?? {}, requiredBy)
307
+ ? classifyPluginRows(
308
+ shownPlugins,
309
+ pins ?? {},
310
+ requiredBy,
311
+ listedByProfiles,
312
+ )
304
313
  : new Map(),
305
- [shownPlugins, pins, requiredBy],
314
+ [shownPlugins, pins, requiredBy, listedByProfiles],
306
315
  );
307
316
 
308
317
  /**
@@ -1265,39 +1274,16 @@ export function PluginsScreen() {
1265
1274
  return;
1266
1275
  }
1267
1276
 
1268
- // Errors are collected, not dropped: only the CLI's "is not installed in
1269
- // <scope> scope" answer is expected, and only for a scope with no row.
1270
- const problems: unknown[] = [];
1277
+ // An orphan the plan keeps rather than removes — it moved, another plugin
1278
+ // still requires it, or its marketplace was not read — and the user asked
1279
+ // for it gone. The engine uninstalls everywhere, drops it from every
1280
+ // profile and renders the active one again, so the row does not come back.
1271
1281
  try {
1272
- // Remove from all scopes — try all to clean up stale references
1273
- const scopes: PluginScope[] = ["user", "project", "local"];
1274
- for (const scope of scopes) {
1275
- try {
1276
- modal.loading(
1277
- `Removing ${plugin.name} from ${scope}…\nclaude plugin uninstall ${plugin.id} --scope ${scope}`,
1278
- );
1279
- await uninstallPluginViaCli(plugin.id, scope, state.projectPath);
1280
- } catch (error) {
1281
- if (!isNotInstalledAnswer(error)) problems.push(error);
1282
- }
1283
- }
1284
- // A folder of the same repository may hold the install this one only
1285
- // shows: the CLI removes it only when run from that folder.
1286
- const inherited = await readInheritedRegistryRows(
1282
+ modal.loading(`Removing ${plugin.name}…`);
1283
+ const { problems } = await removeDeprecatedPlugin(
1287
1284
  plugin.id,
1288
1285
  state.projectPath || process.cwd(),
1289
1286
  );
1290
- for (const row of inherited) {
1291
- if (!row.projectPath) continue;
1292
- try {
1293
- modal.loading(
1294
- `Removing ${plugin.name} from the repository's main folder, which this folder shares…\nclaude plugin uninstall ${plugin.id} --scope ${row.scope}`,
1295
- );
1296
- await uninstallPluginViaCli(plugin.id, row.scope, row.projectPath);
1297
- } catch (error) {
1298
- problems.push(error);
1299
- }
1300
- }
1301
1287
 
1302
1288
  modal.hideModal();
1303
1289
  fetchData();