magus-cli 7.11.1 → 7.11.3

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.
@@ -39,9 +39,11 @@ import type {
39
39
  import type { Command } from "../utils/command-utils.js";
40
40
  import { formatCommand } from "../utils/command-utils.js";
41
41
  import { runArgv, succeeded } from "../utils/run.js";
42
+ import { selectProfileId } from "./active-closure.js";
42
43
  import type { PluginScope } from "./claude-cli.js";
43
44
  import {
44
45
  installPlugin,
46
+ isNotInstalledAnswer,
45
47
  repairPlugin,
46
48
  uninstallPluginViaCli,
47
49
  updatePlugin,
@@ -52,19 +54,24 @@ import {
52
54
  getEnabledPlugins,
53
55
  getGlobalEnabledPlugins,
54
56
  getLocalEnabledPlugins,
57
+ inheritedRowsOf,
55
58
  loadedVersionHere,
56
59
  notDownloadedReason,
57
60
  readInstalledPluginsRegistry,
58
61
  repairStaleLoad,
59
62
  rowBelongsToRepository,
60
63
  } from "./claude-settings.js";
64
+ import { readManifest } from "./manifest.js";
65
+ import { readPluginDependencies } from "./plugin-dependencies.js";
61
66
  import {
62
67
  compareVersions,
63
68
  isRepairablyBehind,
64
69
  readInstalledVersionForScope,
65
70
  saveInstalledPluginVersionForScope,
66
71
  } from "./plugin-manager.js";
72
+ import { activeProfile } from "./profile-live.js";
67
73
  import { removePluginFromProfile } from "./profile-state.js";
74
+ import { profileDependentsOf } from "./resolver.js";
68
75
  import { installSkill } from "./skills-manager.js";
69
76
  import {
70
77
  type ToolFacts,
@@ -302,6 +309,17 @@ export interface PluginRemoveDeps {
302
309
  scope: PluginScope,
303
310
  projectPath: string,
304
311
  ) => Promise<void>;
312
+ /**
313
+ * The plugins `profile` declares that need `pluginId` (`profileDependentsOf`).
314
+ * While any is declared, the profile re-adds `pluginId` as their dependency,
315
+ * so dropping it cannot take effect. `profile` undefined means the active
316
+ * one. Optional so a double that does not model dependencies skips the check.
317
+ */
318
+ dependents?: (
319
+ pluginId: string,
320
+ projectPath: string,
321
+ profile: string | null | undefined,
322
+ ) => Promise<string[]>;
305
323
  }
306
324
 
307
325
  /** The settings a scope's install lives in — the classifier's definition. */
@@ -321,6 +339,23 @@ export const REAL_REMOVE_DEPS: PluginRemoveDeps = {
321
339
  dropFromProfile: (pluginId, projectPath, profile) =>
322
340
  removePluginFromProfile(projectPath, pluginId, profile),
323
341
  forgetVersion: forgetInstalledPluginVersion,
342
+ // The profile picked as `removePluginFromProfile` picks it, so the check and
343
+ // the drop judge the same profile. A parse error throws, as it does there.
344
+ dependents: async (pluginId, projectPath, profile) => {
345
+ const manifest = await readManifest(projectPath);
346
+ const id =
347
+ profile === undefined
348
+ ? selectProfileId(
349
+ Object.keys(manifest.profiles),
350
+ undefined,
351
+ await activeProfile(projectPath),
352
+ )
353
+ : profile;
354
+ if (!id) return [];
355
+ return profileDependentsOf(manifest, id, pluginId, (dep) =>
356
+ readPluginDependencies(dep, projectPath),
357
+ );
358
+ },
324
359
  };
325
360
 
326
361
  export const REAL_PLUGIN_DEPS: PluginApplyDeps = {
@@ -455,7 +490,7 @@ async function followMisdirectedUpdate(
455
490
  notFollowed: `it went to v${version}, not the v${target} this update asked for`,
456
491
  };
457
492
  }
458
- const repair = await deps.repairStaleLoad(pluginId, version, projectPath);
493
+ const repair = await repairLoadHere(deps, pluginId, version, projectPath);
459
494
  return {
460
495
  elsewhere,
461
496
  version,
@@ -506,6 +541,39 @@ export async function bringLoadForward(
506
541
  if (!deps.loadedVersion) return null;
507
542
  const loaded = await deps.loadedVersion(pluginId, projectPath);
508
543
  if (!isRepairablyBehind(target, loaded)) return null;
544
+ return repairLoadHere(deps, pluginId, target, projectPath);
545
+ }
546
+
547
+ /**
548
+ * `deps.repairStaleLoad`, clearing an ORPHAN USER row first when that is what
549
+ * blocks it.
550
+ *
551
+ * A USER registry row for a plugin USER settings do not turn on still wins the
552
+ * loader's resolution in every project, so the repair refuses on it — yet the
553
+ * user installs nothing at USER. Claude Code's startup sync adds such rows and
554
+ * never removes them. `claude plugin uninstall --scope user` removes just that
555
+ * row (measured, Claude Code 2.1.308), so it runs once and the repair is
556
+ * retried. A REAL USER install keeps the refusal: moving it is the user's call.
557
+ */
558
+ async function repairLoadHere(
559
+ deps: PluginApplyDeps,
560
+ pluginId: string,
561
+ target: string,
562
+ projectPath: string,
563
+ ): Promise<StaleLoadRepairResult> {
564
+ const first = await deps.repairStaleLoad(pluginId, target, projectPath);
565
+ const remove = deps.remove;
566
+ if (first.ok || first.blockedBy !== "user" || !remove) return first;
567
+ if (await remove.isInstalled(pluginId, "user", projectPath)) return first;
568
+ try {
569
+ await remove.uninstall(pluginId, "user", projectPath);
570
+ } catch (error) {
571
+ // The CLI's own words: no fallback writes the registry in its place.
572
+ return {
573
+ ...first,
574
+ error: error instanceof Error ? error.message : String(error),
575
+ };
576
+ }
509
577
  return deps.repairStaleLoad(pluginId, target, projectPath);
510
578
  }
511
579
 
@@ -747,7 +815,8 @@ async function applyStaleLoad(
747
815
  return;
748
816
  }
749
817
  try {
750
- const outcome = await deps.repairStaleLoad(
818
+ const outcome = await repairLoadHere(
819
+ deps,
751
820
  item.pluginId,
752
821
  item.target,
753
822
  projectPath,
@@ -816,11 +885,33 @@ async function applyRemove(
816
885
  }
817
886
  try {
818
887
  let uninstallError: unknown = null;
888
+ // Errors of the inherited-row uninstalls, apart from the scope loop's: the
889
+ // CLI's "not installed in <scope> scope" is the expected answer there for a
890
+ // scope this folder only inherits, and is not a failure.
891
+ let inheritedError: unknown = null;
819
892
  for (const scope of item.scopes) {
820
893
  try {
821
894
  await remove.uninstall(item.pluginId, scope, projectPath);
822
895
  } catch (error) {
823
- uninstallError ??= error;
896
+ if (!isNotInstalledAnswer(error)) uninstallError ??= error;
897
+ }
898
+ }
899
+ // A row this folder only INHERITS (a linked worktree reading its main
900
+ // checkout's install) is removed from the row's own folder; the CLI finds
901
+ // nothing to remove from anywhere else.
902
+ const inherited = await inheritedRowsOf(
903
+ (await readRows(deps, item.pluginId)) ?? [],
904
+ projectPath,
905
+ );
906
+ for (const row of inherited) {
907
+ try {
908
+ await remove.uninstall(
909
+ item.pluginId,
910
+ row.scope,
911
+ row.projectPath ?? projectPath,
912
+ );
913
+ } catch (error) {
914
+ inheritedError ??= error;
824
915
  }
825
916
  }
826
917
  const unlisted = await remove.dropFromProfile(
@@ -846,6 +937,38 @@ async function applyRemove(
846
937
  .catch(() => {});
847
938
  }
848
939
  }
940
+ // Success needs the rows READ and none remaining: a read-back that fails
941
+ // is a removal nobody confirmed, never "no rows remain".
942
+ const rowsNow = await readRows(deps, item.pluginId);
943
+ if (rowsNow === null && deps.registryRows) {
944
+ if (inheritedError instanceof Error) {
945
+ fail(inheritedError.message, "exception");
946
+ } else {
947
+ fail("could not confirm it was removed");
948
+ }
949
+ return;
950
+ }
951
+ const remaining = await inheritedRowsOf(rowsNow ?? [], projectPath);
952
+ if (remaining.length > 0) {
953
+ if (inheritedError instanceof Error) {
954
+ fail(inheritedError.message, "exception");
955
+ return;
956
+ }
957
+ const names = [
958
+ ...new Set(
959
+ remaining
960
+ .map((r) => r.projectPath)
961
+ .filter((p): p is string => p !== undefined)
962
+ .map((p) => path.basename(p)),
963
+ ),
964
+ ];
965
+ fail(
966
+ names.length > 0
967
+ ? `still installed in ${names.join(", ")}, another folder of this repository`
968
+ : "still installed in another folder of this repository",
969
+ );
970
+ return;
971
+ }
849
972
  if (still.length > 0) {
850
973
  // The CLI's own words when it said anything; ours otherwise.
851
974
  if (uninstallError !== null) {
@@ -1131,6 +1254,80 @@ export async function updatePluginInScope(
1131
1254
  );
1132
1255
  }
1133
1256
 
1257
+ /**
1258
+ * Uninstall a plugin at ONE explicitly chosen scope, and stop the profile
1259
+ * declaring it when that leaves nothing for the profile to keep.
1260
+ *
1261
+ * Without the profile step, a profile-declared plugin came straight back as
1262
+ * "not installed — profile declares it". The profile is dropped when the scope
1263
+ * was PROJECT — the settings the profile itself renders — or when the plugin is
1264
+ * now installed at no scope. Never for a USER or LOCAL uninstall while PROJECT
1265
+ * still installs it: the profile still describes that install.
1266
+ *
1267
+ * Success is read back, as in {@link applyRemove}: still installed at `scope`
1268
+ * afterwards throws, whatever the command said.
1269
+ *
1270
+ * @param profile the profile to drop it from; undefined means the active one.
1271
+ */
1272
+ export async function uninstallPluginInScope(
1273
+ pluginId: string,
1274
+ scope: PluginScope,
1275
+ projectPath: string,
1276
+ deps: PluginApplyDeps = REAL_PLUGIN_DEPS,
1277
+ profile?: string | null,
1278
+ ): Promise<void> {
1279
+ const remove = deps.remove;
1280
+ if (!remove) throw new Error("magus cannot remove plugins from here");
1281
+
1282
+ // Refuse BEFORE uninstalling when the profile drop below would be needed but
1283
+ // cannot hold: a declared plugin that depends on this one re-adds it.
1284
+ if (remove.dependents) {
1285
+ let installedElsewhere = false;
1286
+ for (const other of REMOVE_SCOPES) {
1287
+ if (other === scope) continue;
1288
+ if (await remove.isInstalled(pluginId, other, projectPath)) {
1289
+ installedElsewhere = true;
1290
+ break;
1291
+ }
1292
+ }
1293
+ if (scope === "project" || !installedElsewhere) {
1294
+ const dependents = await remove.dependents(
1295
+ pluginId,
1296
+ projectPath,
1297
+ profile,
1298
+ );
1299
+ if (dependents.length > 0) {
1300
+ const names = dependents.map(pluginName).join(", ");
1301
+ const needs = dependents.length === 1 ? "needs" : "need";
1302
+ throw new Error(
1303
+ `${names} ${needs} ${pluginName(pluginId)} — uninstall ${names} first`,
1304
+ );
1305
+ }
1306
+ }
1307
+ }
1308
+
1309
+ await remove.uninstall(pluginId, scope, projectPath);
1310
+ if (await remove.isInstalled(pluginId, scope, projectPath)) {
1311
+ throw new Error(`still installed at ${scope.toUpperCase()} scope`);
1312
+ }
1313
+ // Best-effort, as in applyRemove: the read-back above is the judge.
1314
+ await remove.forgetVersion?.(pluginId, scope, projectPath).catch(() => {});
1315
+
1316
+ let installedNowhere = true;
1317
+ for (const other of REMOVE_SCOPES) {
1318
+ if (await remove.isInstalled(pluginId, other, projectPath)) {
1319
+ installedNowhere = false;
1320
+ break;
1321
+ }
1322
+ }
1323
+ if (scope !== "project" && !installedNowhere) return;
1324
+ if (!(await remove.dropFromProfile(pluginId, projectPath, profile))) {
1325
+ throw new Error(
1326
+ `uninstalled at ${scope.toUpperCase()} scope, but the profile still lists it`,
1327
+ );
1328
+ }
1329
+ }
1330
+
1134
1331
  // -- binaries ----------------------------------------------------------------
1135
1332
 
1136
1333
  /**
@@ -9,9 +9,14 @@ import {
9
9
  type PluginScope,
10
10
  addMarketplace as cliAddMarketplace,
11
11
  repairPlugin as cliRepairPlugin,
12
- uninstallPlugin as cliUninstallPlugin,
12
+ isNotInstalledAnswer,
13
+ uninstallPluginViaCli,
13
14
  } from "../../services/claude-cli.js";
14
- import { readSettings, setMcpEnvVar } from "../../services/claude-settings.js";
15
+ import {
16
+ readInheritedRegistryRows,
17
+ readSettings,
18
+ setMcpEnvVar,
19
+ } from "../../services/claude-settings.js";
15
20
  import { clearContentDriftCache } from "../../services/content-drift.js";
16
21
  import { missingPluginEnvVars } from "../../services/plugin-env-needs.js";
17
22
  import {
@@ -44,6 +49,7 @@ import {
44
49
  applyPlugins,
45
50
  installPluginInScope,
46
51
  pluginNeedsWork,
52
+ uninstallPluginInScope,
47
53
  updatePluginInScope,
48
54
  } from "../../services/update-engine.js";
49
55
  // The ONE plugin classifier, shared with `magus update`. Every state this
@@ -1019,7 +1025,15 @@ export function PluginsScreen() {
1019
1025
  modal.loading(
1020
1026
  `Uninstalling ${plugin.name} from ${scopeLabel}…\nclaude plugin uninstall ${plugin.id} --scope ${scope}`,
1021
1027
  );
1022
- await cliUninstallPlugin(plugin.id, scope, state.projectPath);
1028
+ // Through the engine, so the profile stops declaring what was
1029
+ // just uninstalled and the result is read back.
1030
+ await uninstallPluginInScope(
1031
+ plugin.id,
1032
+ scope,
1033
+ state.projectPath || process.cwd(),
1034
+ REAL_PLUGIN_DEPS,
1035
+ activeProfileId,
1036
+ );
1023
1037
  } else if (action === "update") {
1024
1038
  modal.loading(
1025
1039
  `Updating ${plugin.name} in ${scopeLabel}…\nclaude plugin install ${plugin.id} --scope ${scope}`,
@@ -1185,7 +1199,14 @@ export function PluginsScreen() {
1185
1199
  modal.loading(
1186
1200
  `Uninstalling ${plugin.name} from ${scopeLabel}…\nclaude plugin uninstall ${plugin.id} --scope ${scope}`,
1187
1201
  );
1188
- await cliUninstallPlugin(plugin.id, scope, state.projectPath);
1202
+ // Engine uninstall: read back, and dropped from the profile — see above.
1203
+ await uninstallPluginInScope(
1204
+ plugin.id,
1205
+ scope,
1206
+ state.projectPath || process.cwd(),
1207
+ REAL_PLUGIN_DEPS,
1208
+ activeProfileId,
1209
+ );
1189
1210
  } else if (action === "update") {
1190
1211
  modal.loading(
1191
1212
  `Updating ${plugin.name} in ${scopeLabel}…\nclaude plugin install ${plugin.id} --scope ${scope}`,
@@ -1244,25 +1265,45 @@ export function PluginsScreen() {
1244
1265
  return;
1245
1266
  }
1246
1267
 
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[] = [];
1247
1271
  try {
1248
1272
  // Remove from all scopes — try all to clean up stale references
1249
1273
  const scopes: PluginScope[] = ["user", "project", "local"];
1250
- // Nothing tallies which scopes actually held it. The migrate dialog
1251
- // that consumed that tally is gone with the `moved` inference, and
1252
- // collecting it for no reader is how a value goes stale unnoticed.
1253
1274
  for (const scope of scopes) {
1254
1275
  try {
1255
1276
  modal.loading(
1256
1277
  `Removing ${plugin.name} from ${scope}…\nclaude plugin uninstall ${plugin.id} --scope ${scope}`,
1257
1278
  );
1258
- await cliUninstallPlugin(plugin.id, scope, state.projectPath);
1259
- } catch {
1260
- // Ignore errors for scopes where it doesn't exist
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(
1287
+ plugin.id,
1288
+ state.projectPath || process.cwd(),
1289
+ );
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);
1261
1299
  }
1262
1300
  }
1263
1301
 
1264
1302
  modal.hideModal();
1265
1303
  fetchData();
1304
+ if (problems.length > 0) {
1305
+ await reportPluginFailure(plugin.id, "removed", problems[0]);
1306
+ }
1266
1307
  } catch (error) {
1267
1308
  modal.hideModal();
1268
1309
  await reportPluginFailure(plugin.id, "removed", error);