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.
- package/package.json +4 -4
- package/src/__tests__/blind/requires-min-version/manifests.test.ts +5 -3
- package/src/__tests__/orphan-user-row-repair.test.ts +102 -0
- package/src/__tests__/scope-uninstall-dependents.test.ts +173 -0
- package/src/__tests__/scope-uninstall-profile.test.ts +103 -0
- package/src/__tests__/scratch-git.ts +48 -0
- package/src/__tests__/worktree-deprecated-remove-reader.test.ts +268 -0
- package/src/__tests__/worktree-deprecated-remove.test.ts +115 -0
- package/src/services/claude-cli.ts +9 -0
- package/src/services/claude-settings.ts +87 -5
- package/src/services/resolver.ts +32 -0
- package/src/services/update-engine.ts +200 -3
- package/src/ui/screens/PluginsScreen.tsx +51 -10
|
@@ -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
|
|
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
|
|
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
|
-
|
|
12
|
+
isNotInstalledAnswer,
|
|
13
|
+
uninstallPluginViaCli,
|
|
13
14
|
} from "../../services/claude-cli.js";
|
|
14
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1259
|
-
} catch {
|
|
1260
|
-
|
|
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);
|