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.
@@ -0,0 +1,268 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import path from "node:path";
3
+ import fs from "fs-extra";
4
+ import { isNotInstalledAnswer } from "../services/claude-cli.js";
5
+ import type { PluginScope } from "../services/claude-cli.js";
6
+ import {
7
+ inheritedRegistryRows,
8
+ inheritedRowsOf,
9
+ } from "../services/claude-settings.js";
10
+ import { clearWorktreeCache } from "../services/git-worktree.js";
11
+ import {
12
+ type ApplyReporter,
13
+ type PluginApplyDeps,
14
+ applyPlugins,
15
+ } from "../services/update-engine.js";
16
+ import type { PluginUpdateItem } from "../services/update-plan.js";
17
+ import type { InstalledPluginEntry } from "../types/index.js";
18
+ import { addWorktree, initRepo, makeScratch } from "./scratch-git.js";
19
+
20
+ /**
21
+ * Removing a deprecated plugin from a linked worktree whose only registry row
22
+ * belongs to the main checkout: the reader that finds that row, the folder the
23
+ * uninstall runs in, and the failures that must not be swallowed.
24
+ *
25
+ * Repository membership is git's answer (`inheritablePaths`), so the cases that
26
+ * depend on it run against real scratch repositories.
27
+ */
28
+
29
+ const OTHER = "/elsewhere/unrelated";
30
+ const ID = "code-analysis@magus";
31
+
32
+ let scratch: string;
33
+ let MAIN: string;
34
+ let TREE: string;
35
+ // Two INDEPENDENT repositories, the second kept under the first's
36
+ // `.claude/worktrees/` — a path shape that is not repository membership.
37
+ let PARENT: string;
38
+ let NESTED: string;
39
+
40
+ beforeAll(async () => {
41
+ scratch = await makeScratch();
42
+ MAIN = path.join(scratch, "repo");
43
+ TREE = path.join(scratch, "feature");
44
+ await initRepo(MAIN);
45
+ addWorktree(MAIN, TREE, "feature");
46
+ PARENT = path.join(scratch, "parent");
47
+ NESTED = path.join(PARENT, ".claude", "worktrees", "child");
48
+ await initRepo(PARENT);
49
+ await initRepo(NESTED);
50
+ clearWorktreeCache();
51
+ });
52
+
53
+ afterAll(async () => {
54
+ if (scratch) await fs.remove(scratch);
55
+ });
56
+
57
+ const entry = (
58
+ scope: InstalledPluginEntry["scope"],
59
+ projectPath?: string,
60
+ ): InstalledPluginEntry => ({
61
+ scope,
62
+ version: "7.3.0",
63
+ projectPath,
64
+ installPath: "/cache/7.3.0",
65
+ installedAt: "2026-01-01T00:00:00.000Z",
66
+ lastUpdated: "2026-01-01T00:00:00.000Z",
67
+ });
68
+
69
+ describe("inheritedRegistryRows", () => {
70
+ test("finds the main checkout's project and local rows", () => {
71
+ const rows = inheritedRegistryRows(
72
+ [entry("project", MAIN), entry("local", MAIN)],
73
+ TREE,
74
+ [MAIN],
75
+ );
76
+ expect(rows.map((r) => r.scope).sort()).toEqual(["local", "project"]);
77
+ expect(rows.every((r) => r.projectPath === MAIN)).toBe(true);
78
+ });
79
+
80
+ test("does not return the project's own row", () => {
81
+ const rows = inheritedRegistryRows(
82
+ [entry("project", TREE), entry("project", MAIN)],
83
+ TREE,
84
+ [MAIN, TREE],
85
+ );
86
+ expect(rows.map((r) => r.projectPath)).toEqual([MAIN]);
87
+ });
88
+
89
+ test("never returns rows of an unrelated project or the user row", () => {
90
+ const rows = inheritedRegistryRows(
91
+ [entry("project", OTHER), entry("user")],
92
+ TREE,
93
+ [MAIN],
94
+ );
95
+ expect(rows).toEqual([]);
96
+ });
97
+
98
+ test("returns nothing when no same-repository folder is known", () => {
99
+ expect(inheritedRegistryRows([entry("project", MAIN)], TREE, [])).toEqual(
100
+ [],
101
+ );
102
+ expect(inheritedRegistryRows(undefined, TREE, [MAIN])).toEqual([]);
103
+ });
104
+
105
+ test("a linked worktree inherits its main checkout's row, per git", async () => {
106
+ const rows = await inheritedRowsOf(
107
+ [entry("project", MAIN), entry("project", OTHER)],
108
+ TREE,
109
+ );
110
+ expect(rows.map((r) => r.projectPath)).toEqual([MAIN]);
111
+ });
112
+
113
+ test("an unrelated parent repository's row is NEVER returned for a nested independent repository", async () => {
114
+ const rows = await inheritedRowsOf(
115
+ [entry("project", PARENT), entry("local", PARENT)],
116
+ NESTED,
117
+ );
118
+ expect(rows).toEqual([]);
119
+ });
120
+ });
121
+
122
+ describe("isNotInstalledAnswer", () => {
123
+ test("recognises the CLI's per-scope answer only", () => {
124
+ expect(
125
+ isNotInstalledAnswer(
126
+ new Error(`Plugin "${ID}" is not installed in project scope.`),
127
+ ),
128
+ ).toBe(true);
129
+ expect(isNotInstalledAnswer(new Error("claude timed out"))).toBe(false);
130
+ });
131
+ });
132
+
133
+ function harness(
134
+ rows: InstalledPluginEntry[],
135
+ uninstall: (id: string, scope: PluginScope, cwd: string) => Promise<void>,
136
+ ) {
137
+ const unused = async () => {
138
+ throw new Error("a removal must not install, update or repair");
139
+ };
140
+ const deps: PluginApplyDeps = {
141
+ install: unused,
142
+ update: unused,
143
+ repair: unused,
144
+ readInstalled: unused,
145
+ saveInstalled: unused,
146
+ repairStaleLoad: unused,
147
+ registryRows: async () => rows,
148
+ remove: {
149
+ uninstall,
150
+ isInstalled: async () => false,
151
+ dropFromProfile: async () => true,
152
+ },
153
+ };
154
+ const item: PluginUpdateItem = {
155
+ pluginId: ID,
156
+ pinned: "latest",
157
+ installed: null,
158
+ loaded: null,
159
+ available: null,
160
+ target: null,
161
+ action: "remove",
162
+ reason: "deprecated",
163
+ scopes: [],
164
+ beforeVersions: {},
165
+ note: "no longer published",
166
+ };
167
+ const details: string[] = [];
168
+ const reporter: ApplyReporter = {
169
+ begin: () => {},
170
+ finish: (_ok, _id, detail) => {
171
+ details.push(String(detail));
172
+ },
173
+ detach: (work) => work(),
174
+ };
175
+ return { deps, item, reporter, details };
176
+ }
177
+
178
+ describe("applyPlugins: removing an inherited row", () => {
179
+ test("runs the uninstall in the row's own folder", async () => {
180
+ let rows = [entry("project", MAIN), entry("project", OTHER)];
181
+ const calls: Array<[PluginScope, string]> = [];
182
+ const h = harness(rows, async (_id, scope, cwd) => {
183
+ calls.push([scope, cwd]);
184
+ rows = rows.filter((r) => r.projectPath !== cwd);
185
+ });
186
+ h.deps.registryRows = async () => rows;
187
+
188
+ const result = await applyPlugins([h.item], TREE, h.deps, h.reporter);
189
+
190
+ expect(calls).toEqual([["project", MAIN]]);
191
+ expect(result.failed).toEqual([]);
192
+ // The unrelated project's row is untouched.
193
+ expect(rows.map((r) => r.projectPath)).toEqual([OTHER]);
194
+ });
195
+
196
+ test("a real uninstall error surfaces instead of being swallowed", async () => {
197
+ const rows = [entry("project", MAIN)];
198
+ const h = harness(rows, async () => {
199
+ throw new Error("claude plugin uninstall timed out");
200
+ });
201
+
202
+ const result = await applyPlugins([h.item], TREE, h.deps, h.reporter);
203
+
204
+ expect(result.failed).toEqual([ID]);
205
+ expect(h.details.join(" ")).toContain("timed out");
206
+ });
207
+
208
+ test("says where it is still installed when the command reported nothing", async () => {
209
+ const rows = [entry("project", MAIN)];
210
+ const h = harness(rows, async () => {});
211
+
212
+ const result = await applyPlugins([h.item], TREE, h.deps, h.reporter);
213
+
214
+ expect(result.failed).toEqual([ID]);
215
+ const detail = h.details.join(" ");
216
+ expect(detail).toContain(
217
+ `still installed in ${path.basename(MAIN)}, another folder of this repository`,
218
+ );
219
+ // A folder is named, never an absolute path.
220
+ expect(detail).not.toContain(MAIN);
221
+ });
222
+
223
+ test("an unreadable registry after the uninstalls is a failed removal", async () => {
224
+ const rows = [entry("project", MAIN)];
225
+ let reads = 0;
226
+ const h = harness(rows, async () => {
227
+ throw new Error("claude plugin uninstall timed out");
228
+ });
229
+ h.deps.registryRows = async () => {
230
+ reads++;
231
+ if (reads > 1) throw new Error("registry unreadable");
232
+ return rows;
233
+ };
234
+
235
+ const result = await applyPlugins([h.item], TREE, h.deps, h.reporter);
236
+
237
+ expect(result.failed).toEqual([ID]);
238
+ expect(result.removed).toEqual([]);
239
+ });
240
+
241
+ test("an unreadable registry with no error from the CLI says it could not confirm", async () => {
242
+ const rows = [entry("project", MAIN)];
243
+ let reads = 0;
244
+ const h = harness(rows, async () => {});
245
+ h.deps.registryRows = async () => {
246
+ reads++;
247
+ if (reads > 1) throw new Error("registry unreadable");
248
+ return rows;
249
+ };
250
+
251
+ const result = await applyPlugins([h.item], TREE, h.deps, h.reporter);
252
+
253
+ expect(result.failed).toEqual([ID]);
254
+ expect(h.details.join(" ")).toContain("could not confirm it was removed");
255
+ });
256
+
257
+ test("never uninstalls an unrelated parent repository's row from a nested independent repository", async () => {
258
+ const rows = [entry("project", PARENT)];
259
+ const calls: string[] = [];
260
+ const h = harness(rows, async (_id, _scope, cwd) => {
261
+ calls.push(cwd);
262
+ });
263
+
264
+ await applyPlugins([h.item], NESTED, h.deps, h.reporter);
265
+
266
+ expect(calls).toEqual([]);
267
+ });
268
+ });
@@ -0,0 +1,115 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import path from "node:path";
3
+ import fs from "fs-extra";
4
+ import type { PluginScope } from "../services/claude-cli.js";
5
+ import { clearWorktreeCache } from "../services/git-worktree.js";
6
+ import {
7
+ type ApplyReporter,
8
+ type PluginApplyDeps,
9
+ applyPlugins,
10
+ } from "../services/update-engine.js";
11
+ import type { PluginUpdateItem } from "../services/update-plan.js";
12
+ import type { InstalledPluginEntry } from "../types/index.js";
13
+ import { addWorktree, initRepo, makeScratch } from "./scratch-git.js";
14
+
15
+ /**
16
+ * `d` on a deprecated plugin from a linked worktree, when the only registry
17
+ * row for it belongs to the MAIN checkout.
18
+ *
19
+ * The orphan row exists because the registry row is inherited from the main
20
+ * checkout, but no settings file here turns the plugin on, so the plan carries
21
+ * no scopes. The removal then runs zero uninstalls.
22
+ */
23
+
24
+ const ID = "code-analysis@magus";
25
+
26
+ // A real repository with a real linked worktree, so `inheritablePaths` answers
27
+ // the way git does rather than from a path shape.
28
+ let scratch: string;
29
+ let MAIN: string;
30
+ let TREE: string;
31
+
32
+ beforeAll(async () => {
33
+ scratch = await makeScratch();
34
+ MAIN = path.join(scratch, "repo");
35
+ TREE = path.join(scratch, "feature");
36
+ await initRepo(MAIN);
37
+ addWorktree(MAIN, TREE, "feature");
38
+ clearWorktreeCache();
39
+ });
40
+
41
+ afterAll(async () => {
42
+ if (scratch) await fs.remove(scratch);
43
+ });
44
+
45
+ function row(projectPath: string): InstalledPluginEntry {
46
+ return {
47
+ scope: "project",
48
+ version: "7.3.0",
49
+ projectPath,
50
+ installPath: "/cache/7.3.0",
51
+ installedAt: "2026-01-01T00:00:00.000Z",
52
+ lastUpdated: "2026-01-01T00:00:00.000Z",
53
+ };
54
+ }
55
+
56
+ describe("applyPlugins: remove from a linked worktree", () => {
57
+ // REGRESSION: worktree d on a deprecated plugin removed nothing and showed no error — Fixed in /dev:fix session dev-fix-20261026-155239-490d2c7e
58
+ test("should not report success while the main checkout's registry row remains when removing from a linked worktree", async () => {
59
+ let rows: InstalledPluginEntry[] = [row(MAIN)];
60
+ const unused = async () => {
61
+ throw new Error("a removal must not install, update or repair");
62
+ };
63
+ const deps: PluginApplyDeps = {
64
+ install: unused,
65
+ update: unused,
66
+ repair: unused,
67
+ readInstalled: unused,
68
+ saveInstalled: unused,
69
+ repairStaleLoad: unused,
70
+ registryRows: async () => rows,
71
+ remove: {
72
+ // Like `claude plugin uninstall --scope project`: only the row
73
+ // pinned to exactly the cwd it runs in can be removed.
74
+ uninstall: async (_id, scope: PluginScope, cwd) => {
75
+ const hit = rows.some(
76
+ (r) => r.scope === scope && r.projectPath === cwd,
77
+ );
78
+ if (!hit) {
79
+ throw new Error(
80
+ `Plugin "${ID}" is not installed in ${scope} scope.`,
81
+ );
82
+ }
83
+ rows = rows.filter((r) => r.projectPath !== cwd);
84
+ },
85
+ // Settings-derived: no settings turn the plugin on here.
86
+ isInstalled: async () => false,
87
+ dropFromProfile: async () => true,
88
+ },
89
+ };
90
+ const item: PluginUpdateItem = {
91
+ pluginId: ID,
92
+ pinned: "latest",
93
+ installed: null,
94
+ loaded: null,
95
+ available: null,
96
+ target: null,
97
+ action: "remove",
98
+ reason: "deprecated",
99
+ scopes: [],
100
+ beforeVersions: {},
101
+ note: "no longer published",
102
+ };
103
+ const reporter: ApplyReporter = {
104
+ begin: () => {},
105
+ finish: () => {},
106
+ detach: (work) => work(),
107
+ };
108
+
109
+ const result = await applyPlugins([item], TREE, deps, reporter);
110
+
111
+ // Either the row is really gone (uninstall ran in the main checkout), or
112
+ // the removal says it failed. Success with the row still there is the bug.
113
+ expect(rows.length === 0 || result.failed.includes(ID)).toBe(true);
114
+ });
115
+ });
@@ -268,6 +268,15 @@ export async function uninstallPluginViaCli(
268
268
  );
269
269
  }
270
270
 
271
+ /**
272
+ * Is `error` the CLI's answer that the plugin is not installed at the scope it
273
+ * was asked about — the one failure of an uninstall that means "nothing to do".
274
+ */
275
+ export function isNotInstalledAnswer(error: unknown): boolean {
276
+ const message = error instanceof Error ? error.message : String(error);
277
+ return /not installed in (user|project|local) scope/i.test(message);
278
+ }
279
+
271
280
  /**
272
281
  * Repair a plugin whose files drifted from the marketplace under an unchanged
273
282
  * version — uninstall, then install again at the same scope.
@@ -434,6 +434,74 @@ export function ownRegistryEntry(
434
434
  );
435
435
  }
436
436
 
437
+ /**
438
+ * The project and local rows of one plugin that `projectPath` INHERITS: rows
439
+ * pinned to a same-repository folder (`fallbackPaths`, as
440
+ * {@link pickRegistryEntry} reads them) rather than to `projectPath` itself.
441
+ *
442
+ * These are the rows that make a plugin show up in a linked worktree that never
443
+ * installed it, and `claude plugin uninstall` removes them only when run from the
444
+ * row's own folder. Rows of any other project are never returned, and neither is
445
+ * the project's own row ({@link ownRegistryEntry} answers that one).
446
+ */
447
+ export function inheritedRegistryRows(
448
+ entries: readonly InstalledPluginEntry[] | undefined,
449
+ projectPath: string,
450
+ fallbackPaths: readonly string[],
451
+ ): InstalledPluginEntry[] {
452
+ if (!Array.isArray(entries)) return [];
453
+ const own = path.resolve(projectPath);
454
+ const sources = new Set(fallbackPaths.map((p) => path.resolve(p)));
455
+ sources.delete(own);
456
+ return entries.filter(
457
+ (e) =>
458
+ (e.scope === "project" || e.scope === "local") &&
459
+ e.projectPath !== undefined &&
460
+ sources.has(path.resolve(e.projectPath)),
461
+ );
462
+ }
463
+
464
+ /**
465
+ * Same-repository folders whose rows `projectPath` inherits: git's
466
+ * `inheritablePaths`, the one definition the read side ({@link pickRegistryEntry})
467
+ * uses too. `[]` when git cannot answer.
468
+ */
469
+ async function inheritableFolders(projectPath: string): Promise<string[]> {
470
+ return inheritablePaths(projectPath);
471
+ }
472
+
473
+ /**
474
+ * Read-only: the inherited rows of `pluginId` for `projectPath`, read from the
475
+ * registry. `[]` when the registry cannot be read or there are none.
476
+ */
477
+ export async function readInheritedRegistryRows(
478
+ pluginId: string,
479
+ projectPath: string,
480
+ ): Promise<InstalledPluginEntry[]> {
481
+ try {
482
+ const registry = await readInstalledPluginsRegistry();
483
+ return inheritedRegistryRows(
484
+ registry.plugins?.[pluginId],
485
+ projectPath,
486
+ await inheritableFolders(projectPath),
487
+ );
488
+ } catch {
489
+ return [];
490
+ }
491
+ }
492
+
493
+ /** The inherited rows within `rows` already read, for a caller holding them. */
494
+ export async function inheritedRowsOf(
495
+ rows: readonly InstalledPluginEntry[],
496
+ projectPath: string,
497
+ ): Promise<InstalledPluginEntry[]> {
498
+ return inheritedRegistryRows(
499
+ rows,
500
+ projectPath,
501
+ await inheritableFolders(projectPath),
502
+ );
503
+ }
504
+
437
505
  async function overlayRegistryVersions(
438
506
  base: Record<string, string>,
439
507
  scope: "user" | "project" | "local",
@@ -1947,6 +2015,12 @@ export interface StaleLoadRepairResult {
1947
2015
  ok: boolean;
1948
2016
  /** Why it failed. Absent when `ok`. */
1949
2017
  error?: string;
2018
+ /**
2019
+ * Set when it refused because a machine-wide row wins here, naming that
2020
+ * scope — so a caller can tell a real USER install from an orphan USER row
2021
+ * without matching on `error`.
2022
+ */
2023
+ blockedBy?: "user" | "managed";
1950
2024
  }
1951
2025
 
1952
2026
  /**
@@ -2087,11 +2161,19 @@ export async function repairStaleLoad(
2087
2161
  // plugins, which the registry type in types/index.ts does not name — see
2088
2162
  // `LoaderScope` in plugin-resolution.ts.
2089
2163
  const scope: string = winner.scope;
2090
- return fail(
2091
- scope === "managed"
2092
- ? "Claude Code loads the install your administrator manages here — ask them to update it"
2093
- : "Claude Code loads the USER install of it here — update it at USER scope",
2094
- );
2164
+ return scope === "managed"
2165
+ ? {
2166
+ ...fail(
2167
+ "Claude Code loads the install your administrator manages here — ask them to update it",
2168
+ ),
2169
+ blockedBy: "managed",
2170
+ }
2171
+ : {
2172
+ ...fail(
2173
+ "Claude Code loads the USER install of it here — update it at USER scope",
2174
+ ),
2175
+ blockedBy: "user",
2176
+ };
2095
2177
  }
2096
2178
 
2097
2179
  const stale = rows.filter(
@@ -195,6 +195,38 @@ export async function requirersHere(
195
195
  return edges;
196
196
  }
197
197
 
198
+ /**
199
+ * The plugins `profileId` declares that need `pluginId` to load, directly or
200
+ * transitively — sorted ids, none when the profile does not exist.
201
+ *
202
+ * `resolveProfile` re-adds every dependency of a declared plugin, so while one
203
+ * of these is declared, the profile cannot stop enabling `pluginId`. Each
204
+ * declared plugin's closure is walked with the same `resolveDependencies` the
205
+ * resolve uses, so the two cannot disagree about what comes back.
206
+ */
207
+ export async function profileDependentsOf(
208
+ manifest: ProfileManifest,
209
+ profileId: string,
210
+ pluginId: string,
211
+ dependencyResolver: PluginDependencyReader,
212
+ ): Promise<string[]> {
213
+ const raw = manifest.profiles[profileId];
214
+ if (!raw) return [];
215
+ const entry = resolveExtends(raw);
216
+ const off = new Set(
217
+ Object.entries(entry.plugins ?? {})
218
+ .filter(([, ver]) => ver === false)
219
+ .map(([id]) => id),
220
+ );
221
+ const dependents: string[] = [];
222
+ for (const id of Object.keys(normalizePlugins(entry.plugins))) {
223
+ if (id === pluginId) continue;
224
+ const report = await resolveDependencies([id], off, dependencyResolver);
225
+ if (report.derived.includes(pluginId)) dependents.push(id);
226
+ }
227
+ return dependents.sort();
228
+ }
229
+
198
230
  function hasDependencies(report: DependencyReport): boolean {
199
231
  return (
200
232
  report.derived.length > 0 ||