magus-cli 7.12.1 → 7.12.2

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "magus-cli",
3
- "version": "7.12.1",
3
+ "version": "7.12.2",
4
4
  "description": "TUI tool for managing Claude Code plugins, MCPs, and configuration",
5
5
  "type": "module",
6
6
  "main": "src/main.tsx",
@@ -65,8 +65,8 @@
65
65
  "typescript": "^5.6.3"
66
66
  },
67
67
  "optionalDependencies": {
68
- "magus-cli-darwin-arm64": "7.12.1",
69
- "magus-cli-darwin-x64": "7.12.1",
70
- "magus-cli-linux-x64": "7.12.1"
68
+ "magus-cli-darwin-arm64": "7.12.2",
69
+ "magus-cli-darwin-x64": "7.12.2",
70
+ "magus-cli-linux-x64": "7.12.2"
71
71
  }
72
72
  }
@@ -0,0 +1,241 @@
1
+ /**
2
+ * The Plugins screen installs and uninstalls through `installPluginInScope`
3
+ * and `uninstallPluginInScope`. In a profiled project a PROJECT-scope install
4
+ * turns the plugin on in `.claude/settings.json`; the active profile must list
5
+ * it as soon as the action returns, and the profile's record of the live files
6
+ * must move with it, so the next magus command finds nothing left to absorb.
7
+ *
8
+ * Real files, real reconcile: a temp project with `.claude/profiles.json` and
9
+ * an activated profile, HOME pointed at a temp dir. Only the `claude` CLI is
10
+ * doubled — install sets `enabledPlugins[id] = true` in the scope's
11
+ * settings.json, uninstall deletes it — plus the version read-back.
12
+ */
13
+
14
+ import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
15
+ import { mkdtemp, rm } from "node:fs/promises";
16
+ import { tmpdir } from "node:os";
17
+ import { join } from "node:path";
18
+ import fs from "fs-extra";
19
+ import { readManifest, writeManifest } from "../services/manifest.js";
20
+ import { reconcileActiveProfile } from "../services/profile-absorb.js";
21
+ import { activateProfile } from "../services/profile-activate.js";
22
+ import {
23
+ readActiveMarker,
24
+ writeActiveMarker,
25
+ } from "../services/profile-live.js";
26
+ import {
27
+ type PluginApplyDeps,
28
+ REAL_PLUGIN_DEPS,
29
+ REAL_REMOVE_DEPS,
30
+ installPluginInScope,
31
+ uninstallPluginInScope,
32
+ updatePluginInScope,
33
+ } from "../services/update-engine.js";
34
+ import type { ProfileManifest } from "../types/index.js";
35
+
36
+ type Scope = "user" | "project" | "local";
37
+
38
+ let project: string;
39
+ let home: string;
40
+ let log: ReturnType<typeof spyOn>;
41
+ const saved = {
42
+ HOME: process.env.HOME,
43
+ CLAUDE_CONFIG_DIR: process.env.CLAUDE_CONFIG_DIR,
44
+ };
45
+
46
+ function manifest(): ProfileManifest {
47
+ return {
48
+ version: 2,
49
+ profiles: {
50
+ team: {
51
+ name: "Team",
52
+ plugins: { "dev@magus": "latest", "terminal@magus": "latest" },
53
+ },
54
+ },
55
+ };
56
+ }
57
+
58
+ /** The settings.json `claude plugin … --scope <scope>` writes. */
59
+ function settingsFor(scope: Scope): string {
60
+ if (scope === "user") return join(home, ".claude", "settings.json");
61
+ if (scope === "local") return join(project, ".claude", "settings.local.json");
62
+ return join(project, ".claude", "settings.json");
63
+ }
64
+
65
+ async function setEnabled(
66
+ scope: Scope,
67
+ pluginId: string,
68
+ on: boolean,
69
+ ): Promise<void> {
70
+ const file = settingsFor(scope);
71
+ const json = (await fs.pathExists(file))
72
+ ? ((await fs.readJson(file)) as Record<string, unknown>)
73
+ : {};
74
+ const enabled = {
75
+ ...((json.enabledPlugins as Record<string, boolean>) ?? {}),
76
+ };
77
+ if (on) enabled[pluginId] = true;
78
+ else delete enabled[pluginId];
79
+ json.enabledPlugins = enabled;
80
+ await fs.outputFile(file, JSON.stringify(json));
81
+ }
82
+
83
+ /**
84
+ * The real engine deps with only the `claude` CLI and the version read-back
85
+ * doubled. The loader and registry probes are dropped so `bringLoadForward`
86
+ * skips; every other member stays real.
87
+ */
88
+ function deps(): PluginApplyDeps {
89
+ const {
90
+ loadedVersion: _loaded,
91
+ registryRows: _rows,
92
+ ...real
93
+ } = REAL_PLUGIN_DEPS;
94
+ return {
95
+ ...real,
96
+ install: async (pluginId, scope) => setEnabled(scope, pluginId, true),
97
+ update: async () => {},
98
+ repair: async () => {},
99
+ readInstalled: async () => "1.0.0",
100
+ saveInstalled: async () => {},
101
+ repairStaleLoad: async () => {
102
+ throw new Error("repairStaleLoad must not run here");
103
+ },
104
+ remove: {
105
+ ...REAL_REMOVE_DEPS,
106
+ uninstall: async (pluginId, scope) => setEnabled(scope, pluginId, false),
107
+ },
108
+ };
109
+ }
110
+
111
+ async function enabledIn(scope: Scope): Promise<Record<string, boolean>> {
112
+ const json = (await fs.readJson(settingsFor(scope))) as {
113
+ enabledPlugins?: Record<string, boolean>;
114
+ };
115
+ return json.enabledPlugins ?? {};
116
+ }
117
+
118
+ function profilesJson(): Promise<string> {
119
+ return fs.readFile(join(project, ".claude", "profiles.json"), "utf8");
120
+ }
121
+
122
+ async function profilePlugins(): Promise<Record<string, unknown>> {
123
+ const { profiles } = await readManifest(project);
124
+ return (profiles.team?.plugins ?? {}) as Record<string, unknown>;
125
+ }
126
+
127
+ beforeEach(async () => {
128
+ project = await mkdtemp(join(tmpdir(), "tui-scope-profile-"));
129
+ home = await mkdtemp(join(tmpdir(), "tui-scope-profile-home-"));
130
+ process.env.HOME = home;
131
+ process.env.CLAUDE_CONFIG_DIR = join(home, ".claude");
132
+ log = spyOn(console, "log").mockImplementation(() => {});
133
+ await writeManifest(manifest(), project);
134
+ await activateProfile(manifest(), "team", project, { onDisplace: () => {} });
135
+ });
136
+
137
+ afterEach(async () => {
138
+ log.mockRestore();
139
+ process.env.HOME = saved.HOME;
140
+ if (saved.CLAUDE_CONFIG_DIR === undefined) {
141
+ // biome-ignore lint/performance/noDelete: an unset env var must be absent, not "undefined"
142
+ delete process.env.CLAUDE_CONFIG_DIR;
143
+ } else process.env.CLAUDE_CONFIG_DIR = saved.CLAUDE_CONFIG_DIR;
144
+ await rm(project, { recursive: true, force: true });
145
+ await rm(home, { recursive: true, force: true });
146
+ });
147
+
148
+ describe("Plugins screen scope actions record the change in the active profile", () => {
149
+ test("PROJECT install: the profile lists the plugin, and nothing is left to absorb", async () => {
150
+ await installPluginInScope("aside@magus", "project", project, deps());
151
+ expect(await enabledIn("project")).toHaveProperty("aside@magus", true);
152
+
153
+ expect(await profilePlugins()).toHaveProperty("aside@magus");
154
+ const next = await reconcileActiveProfile(project);
155
+ expect(next.lines).toEqual([]);
156
+ });
157
+
158
+ test("PROJECT uninstall: the profile drops the plugin, and nothing is left to absorb", async () => {
159
+ await uninstallPluginInScope(
160
+ "terminal@magus",
161
+ "project",
162
+ project,
163
+ deps(),
164
+ "team",
165
+ );
166
+
167
+ expect(await profilePlugins()).not.toHaveProperty("terminal@magus");
168
+ const next = await reconcileActiveProfile(project);
169
+ expect(next.lines).toEqual([]);
170
+ });
171
+
172
+ test("USER install: the profile is left as it was", async () => {
173
+ const before = await fs.readFile(
174
+ join(project, ".claude", "profiles.json"),
175
+ "utf8",
176
+ );
177
+
178
+ await installPluginInScope("aside@magus", "user", project, deps());
179
+ expect(await enabledIn("user")).toHaveProperty("aside@magus", true);
180
+
181
+ expect(
182
+ await fs.readFile(join(project, ".claude", "profiles.json"), "utf8"),
183
+ ).toBe(before);
184
+ expect(await profilePlugins()).not.toHaveProperty("aside@magus");
185
+ });
186
+
187
+ test("LOCAL install: the private install never reaches the shared profile", async () => {
188
+ const before = await profilesJson();
189
+
190
+ await installPluginInScope("aside@magus", "local", project, deps());
191
+ expect(await enabledIn("local")).toHaveProperty("aside@magus", true);
192
+
193
+ expect(await profilesJson()).toBe(before);
194
+ expect(await profilePlugins()).not.toHaveProperty("aside@magus");
195
+ });
196
+
197
+ test("PROJECT install under CLI bookkeeping: the profile is left to the wrapper", async () => {
198
+ const marker = await readActiveMarker(project);
199
+ if (!marker) throw new Error("activation wrote no marker");
200
+ await writeActiveMarker({ ...marker, cliBookkeeping: true }, project);
201
+ const before = await profilesJson();
202
+
203
+ await installPluginInScope("aside@magus", "project", project, deps());
204
+ expect(await enabledIn("project")).toHaveProperty("aside@magus", true);
205
+
206
+ expect(await profilesJson()).toBe(before);
207
+ expect((await readActiveMarker(project))?.cliBookkeeping).toBe(true);
208
+ });
209
+
210
+ test("PROJECT update of a declared plugin: nothing new is recorded", async () => {
211
+ const before = await profilesJson();
212
+ const updating: PluginApplyDeps = {
213
+ ...deps(),
214
+ // `claude plugin update` rewrites the scope's settings.json; the
215
+ // plugin stays enabled.
216
+ update: async (pluginId, scope) => setEnabled(scope, pluginId, true),
217
+ };
218
+
219
+ await updatePluginInScope("dev@magus", "project", project, updating);
220
+
221
+ expect(await profilesJson()).toBe(before);
222
+ const next = await reconcileActiveProfile(project);
223
+ expect(next.lines).toEqual([]);
224
+ });
225
+
226
+ test("an unreadable live file: the install is reported done, the profile record refused", async () => {
227
+ const broken: PluginApplyDeps = {
228
+ ...deps(),
229
+ install: async (pluginId, scope) => {
230
+ await setEnabled(scope, pluginId, true);
231
+ await fs.appendFile(settingsFor(scope), "{ not json");
232
+ },
233
+ };
234
+
235
+ await expect(
236
+ installPluginInScope("aside@magus", "project", project, broken),
237
+ ).rejects.toThrow(
238
+ "installed at PROJECT scope, but the profile does not record it",
239
+ );
240
+ });
241
+ });
@@ -267,6 +267,15 @@ export interface PluginApplyDeps {
267
267
  * that did not happen.
268
268
  */
269
269
  remove?: PluginRemoveDeps;
270
+ /**
271
+ * Absorb the live settings into the active profile and bring its record of
272
+ * them forward (`recordScopeActionInProfile`). A Plugins-screen scope action
273
+ * writes the live settings itself, through the `claude` CLI, so the profile
274
+ * must record it at once — otherwise the next magus command absorbs it as an
275
+ * edit the user made by hand. Optional so a double that does not model
276
+ * profiles skips it.
277
+ */
278
+ recordInProfile?: (projectPath: string) => Promise<void>;
270
279
  }
271
280
 
272
281
  /** The three calls a `remove` makes — see {@link applyRemove}. */
@@ -395,6 +404,7 @@ export const REAL_PLUGIN_DEPS: PluginApplyDeps = {
395
404
  return Array.isArray(rows) ? rows : [];
396
405
  },
397
406
  remove: REAL_REMOVE_DEPS,
407
+ recordInProfile: recordScopeActionInProfile,
398
408
  };
399
409
 
400
410
  /**
@@ -1084,6 +1094,53 @@ export async function removeDeprecatedPlugin(
1084
1094
  return { problems, unlistedFrom: unlisted.unlistedFrom };
1085
1095
  }
1086
1096
 
1097
+ /**
1098
+ * Is a `withCliBookkeeping` run (`magus update`) in progress here? Its wrapper
1099
+ * reconciles before it renders, so a reconcile inside the run would write a
1100
+ * fresh marker, clear the bookkeeping flag, and turn the CLI's own install
1101
+ * records into edits the user made.
1102
+ */
1103
+ async function underCliBookkeeping(projectPath: string): Promise<boolean> {
1104
+ const marker = (await hasCurrentMarker(projectPath))
1105
+ ? await readActiveMarker(projectPath)
1106
+ : null;
1107
+ return marker?.cliBookkeeping === true;
1108
+ }
1109
+
1110
+ /**
1111
+ * Absorb the live settings into the active profile after a Plugins-screen
1112
+ * scope action, so the profile lists what the `claude` CLI just turned on or
1113
+ * off and its record of the live files matches them. A no-op when the live
1114
+ * files did not change or no profile is active, and left to the wrapper under
1115
+ * {@link underCliBookkeeping}. Refuses, writing nothing, on an unreadable live
1116
+ * file.
1117
+ *
1118
+ * The reconcile absorbs every pending edit to the live config, not only this
1119
+ * action's own change, exactly as the next magus command would. The TUI shows
1120
+ * no status for what it recorded.
1121
+ */
1122
+ async function recordScopeActionInProfile(projectPath: string): Promise<void> {
1123
+ if (await underCliBookkeeping(projectPath)) return;
1124
+ refuseOnProblems(await reconcileActiveProfile(projectPath));
1125
+ }
1126
+
1127
+ /**
1128
+ * Run `deps.recordInProfile`, rethrowing a failure as one that says the action
1129
+ * itself happened: the plugin is installed or removed, only the profile lags.
1130
+ */
1131
+ async function recordInProfileOrExplain(
1132
+ deps: PluginApplyDeps,
1133
+ projectPath: string,
1134
+ done: string,
1135
+ ): Promise<void> {
1136
+ try {
1137
+ await deps.recordInProfile?.(projectPath);
1138
+ } catch (error) {
1139
+ const why = error instanceof Error ? error.message : String(error);
1140
+ throw new Error(`${done}, but the profile does not record it: ${why}`);
1141
+ }
1142
+ }
1143
+
1087
1144
  /**
1088
1145
  * Stop EVERY profile in the project listing a deprecated plugin, keeping the
1089
1146
  * user's live edits — the profile half of both removals, {@link applyRemove}
@@ -1114,10 +1171,7 @@ async function unlistFromEveryProfile(
1114
1171
  const problems: unknown[] = [];
1115
1172
  const unlistedFrom: string[] = [];
1116
1173
  try {
1117
- const marker = (await hasCurrentMarker(projectPath))
1118
- ? await readActiveMarker(projectPath)
1119
- : null;
1120
- const wrapperReconciles = marker?.cliBookkeeping === true;
1174
+ const wrapperReconciles = await underCliBookkeeping(projectPath);
1121
1175
  // Refuses, writing nothing, on an unreadable live file.
1122
1176
  if (!wrapperReconciles) {
1123
1177
  refuseOnProblems(await reconcileActiveProfile(projectPath));
@@ -1321,6 +1375,7 @@ export async function applyPlugins(
1321
1375
  */
1322
1376
  async function applyOneInScope(
1323
1377
  run: (pluginId: string, scope: PluginScope) => Promise<void>,
1378
+ verb: "installed" | "updated",
1324
1379
  pluginId: string,
1325
1380
  scope: PluginScope,
1326
1381
  projectPath: string,
@@ -1338,6 +1393,11 @@ async function applyOneInScope(
1338
1393
  throw new Error(describeLoadNotForward(forward));
1339
1394
  }
1340
1395
  }
1396
+ await recordInProfileOrExplain(
1397
+ deps,
1398
+ projectPath,
1399
+ `${verb} at ${scope.toUpperCase()} scope`,
1400
+ );
1341
1401
  return actual;
1342
1402
  }
1343
1403
 
@@ -1359,7 +1419,14 @@ export async function installPluginInScope(
1359
1419
  projectPath: string,
1360
1420
  deps: PluginApplyDeps = REAL_PLUGIN_DEPS,
1361
1421
  ): Promise<string | null> {
1362
- return applyOneInScope(deps.install, pluginId, scope, projectPath, deps);
1422
+ return applyOneInScope(
1423
+ deps.install,
1424
+ "installed",
1425
+ pluginId,
1426
+ scope,
1427
+ projectPath,
1428
+ deps,
1429
+ );
1363
1430
  }
1364
1431
 
1365
1432
  /**
@@ -1381,6 +1448,7 @@ export async function updatePluginInScope(
1381
1448
  async (id, sc) => {
1382
1449
  await advanceInScope(deps, id, sc, projectPath);
1383
1450
  },
1451
+ "updated",
1384
1452
  pluginId,
1385
1453
  scope,
1386
1454
  projectPath,
@@ -1454,12 +1522,13 @@ export async function uninstallPluginInScope(
1454
1522
  break;
1455
1523
  }
1456
1524
  }
1457
- if (scope !== "project" && !installedNowhere) return;
1458
- if (!(await remove.dropFromProfile(pluginId, projectPath, profile))) {
1459
- throw new Error(
1460
- `uninstalled at ${scope.toUpperCase()} scope, but the profile still lists it`,
1461
- );
1525
+ const done = `uninstalled at ${scope.toUpperCase()} scope`;
1526
+ if (scope === "project" || installedNowhere) {
1527
+ if (!(await remove.dropFromProfile(pluginId, projectPath, profile))) {
1528
+ throw new Error(`${done}, but the profile still lists it`);
1529
+ }
1462
1530
  }
1531
+ await recordInProfileOrExplain(deps, projectPath, done);
1463
1532
  }
1464
1533
 
1465
1534
  // -- binaries ----------------------------------------------------------------