magus-cli 7.5.3 → 7.6.0

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.
Files changed (68) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/blackbox-convergence.test.ts +1 -2
  3. package/src/__tests__/blackbox-parity.test.ts +16 -14
  4. package/src/__tests__/blackbox-plugin-state.test.ts +13 -15
  5. package/src/__tests__/cli-apply-seams.test.ts +3 -3
  6. package/src/__tests__/cli-profile-prompts.test.ts +1165 -0
  7. package/src/__tests__/content-drift.test.ts +0 -1
  8. package/src/__tests__/files-missing-scope.test.ts +235 -0
  9. package/src/__tests__/{enabled-not-installed.test.ts → files-missing.test.ts} +32 -26
  10. package/src/__tests__/format-update-outcome.test.ts +367 -0
  11. package/src/__tests__/installed-version-scope.test.tsx +425 -0
  12. package/src/__tests__/key-focus-guard.test.tsx +260 -0
  13. package/src/__tests__/mate-availability.test.ts +11 -13
  14. package/src/__tests__/plugin-contents.test.ts +0 -1
  15. package/src/__tests__/plugin-detail-scopes.test.tsx +270 -0
  16. package/src/__tests__/plugin-row-text.test.ts +18 -21
  17. package/src/__tests__/profile-command.test.ts +2 -2
  18. package/src/__tests__/profile-migration-offer.test.ts +5 -4
  19. package/src/__tests__/profile-name-on-save.test.ts +443 -0
  20. package/src/__tests__/profile-skills.test.ts +3 -3
  21. package/src/__tests__/profile-startup-nested.test.ts +329 -0
  22. package/src/__tests__/profile-startup-old-format.test.ts +390 -0
  23. package/src/__tests__/profile-startup.test.ts +1 -1
  24. package/src/__tests__/profile-state.test.ts +27 -22
  25. package/src/__tests__/prompt-eof.test.ts +245 -0
  26. package/src/__tests__/scope-action.test.ts +35 -38
  27. package/src/__tests__/scope-row-lookup.test.ts +126 -0
  28. package/src/__tests__/select-restore.test.ts +209 -0
  29. package/src/__tests__/stale-load-repair.test.ts +1 -2
  30. package/src/__tests__/update-apply.test.ts +18 -2
  31. package/src/__tests__/update-null-readback.test.ts +300 -0
  32. package/src/__tests__/update-plan-parity.test.ts +249 -0
  33. package/src/__tests__/update-plan.test.ts +66 -67
  34. package/src/__tests__/version-unknown-state.test.ts +38 -39
  35. package/src/cli/bootstrap.ts +271 -66
  36. package/src/cli/doctor.ts +4 -3
  37. package/src/cli/install.ts +83 -27
  38. package/src/cli/prompt.ts +19 -5
  39. package/src/cli/router.ts +1 -0
  40. package/src/cli/select.ts +168 -0
  41. package/src/cli/update-view.ts +120 -6
  42. package/src/cli/update.ts +6 -4
  43. package/src/services/claude-settings.ts +28 -0
  44. package/src/services/manifest.ts +31 -0
  45. package/src/services/mate-availability.ts +23 -29
  46. package/src/services/plugin-manager.ts +247 -105
  47. package/src/services/profile-absorb.ts +10 -7
  48. package/src/services/profile-activate.ts +41 -19
  49. package/src/services/profile-adopt.ts +9 -2
  50. package/src/services/profile-startup.ts +206 -5
  51. package/src/services/profile-state.ts +8 -7
  52. package/src/services/tracked-live-files.ts +24 -3
  53. package/src/services/update-engine.ts +28 -10
  54. package/src/services/update-plan.ts +78 -94
  55. package/src/ui/App.tsx +96 -56
  56. package/src/ui/adapters/pluginsAdapter.ts +1 -1
  57. package/src/ui/components/modals/ModalContainer.tsx +3 -0
  58. package/src/ui/components/primitives/ScopeSquares.tsx +10 -5
  59. package/src/ui/hooks/useGitignoreModal.ts +21 -3
  60. package/src/ui/hooks/useProfileStartupModal.ts +214 -15
  61. package/src/ui/hooks/useStaleLoadModal.ts +5 -1
  62. package/src/ui/input/KeyRouter.tsx +43 -2
  63. package/src/ui/keymap.ts +1 -1
  64. package/src/ui/renderers/pluginRenderers.tsx +39 -45
  65. package/src/ui/renderers/pluginRowText.ts +6 -2
  66. package/src/ui/renderers/pluginStatusText.ts +4 -3
  67. package/src/ui/screens/PluginsScreen.tsx +28 -41
  68. package/src/cli/tui-prompt.tsx +0 -143
@@ -304,7 +304,6 @@ describe("contentDriftCandidate — which plugins are even asked", () => {
304
304
  description: "",
305
305
  marketplace: "magus",
306
306
  marketplaceDisplay: "magus",
307
- enabled: true,
308
307
  installedVersion: "3.0.0",
309
308
  ...over,
310
309
  });
@@ -0,0 +1,235 @@
1
+ /**
2
+ * Files missing at a scope is repaired AT THAT SCOPE.
3
+ *
4
+ * The plan gave reason `files-missing` no scopes, and the engine read every
5
+ * `install` as "project scope". So a plugin installed at user scope with its
6
+ * files missing ran `claude plugin install <id> --scope project`, which turns it
7
+ * on in the project's settings.json — and for a plugin the active profile does
8
+ * not declare, the reconcile after the run took that record for the user's own
9
+ * edit and wrote it into the team's committed profiles.json. The TUI's `a`
10
+ * reached the same path.
11
+ *
12
+ * Every plan here comes from the real classifier (`planPluginUpdates`) and every
13
+ * apply from the real engine (`applyPlugins`); only the Claude CLI is faked.
14
+ */
15
+
16
+ import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
17
+ import { mkdtemp, readFile, rm } from "node:fs/promises";
18
+ import { tmpdir } from "node:os";
19
+ import { join } from "node:path";
20
+ import fs from "fs-extra";
21
+ import type { PluginScope } from "../services/claude-cli.js";
22
+ import { writeManifest } from "../services/manifest.js";
23
+ import type { PluginInfo, ScopeStatus } from "../services/plugin-manager.js";
24
+ import { activateProfile } from "../services/profile-activate.js";
25
+ import { readActiveMarker } from "../services/profile-live.js";
26
+ import { withCliBookkeeping } from "../services/profile-state.js";
27
+ import {
28
+ type PluginApplyDeps,
29
+ applyPlugins,
30
+ pluginNeedsWork,
31
+ } from "../services/update-engine.js";
32
+ import {
33
+ type PluginUpdateItem,
34
+ planPluginUpdates,
35
+ } from "../services/update-plan.js";
36
+ import type { ProfileManifest } from "../types/index.js";
37
+
38
+ const STRAY = "stray@magus";
39
+ const DEV = "dev@magus";
40
+
41
+ function info(
42
+ id: string,
43
+ scopes: {
44
+ user?: ScopeStatus;
45
+ project?: ScopeStatus;
46
+ local?: ScopeStatus;
47
+ },
48
+ ): PluginInfo {
49
+ const [name, marketplace] = id.split("@") as [string, string];
50
+ return {
51
+ id,
52
+ name,
53
+ version: "1.0.0",
54
+ description: "",
55
+ marketplace,
56
+ marketplaceDisplay: "Magus",
57
+ userScope: scopes.user,
58
+ projectScope: scopes.project,
59
+ localScope: scopes.local,
60
+ };
61
+ }
62
+
63
+ /** The plan item for `id`, from the real classifier. */
64
+ function planFor(
65
+ id: string,
66
+ plugins: PluginInfo[],
67
+ pins: Record<string, string> = {},
68
+ ): PluginUpdateItem {
69
+ const item = planPluginUpdates(pins, plugins).find((i) => i.pluginId === id);
70
+ if (!item) throw new Error(`no plan item for ${id}`);
71
+ return item;
72
+ }
73
+
74
+ /**
75
+ * A fake Claude CLI that records each command's scope. `onInstall` is what the
76
+ * real one writes as a side effect — see the reconcile case.
77
+ */
78
+ function fakeCli(
79
+ onInstall: (id: string, scope: PluginScope) => Promise<void> = async () => {},
80
+ ) {
81
+ const installs: Array<{ id: string; scope: PluginScope }> = [];
82
+ const updates: Array<{ id: string; scope: PluginScope }> = [];
83
+ const deps: PluginApplyDeps = {
84
+ install: async (id, scope) => {
85
+ installs.push({ id, scope });
86
+ await onInstall(id, scope);
87
+ },
88
+ update: async (id, scope) => {
89
+ updates.push({ id, scope });
90
+ },
91
+ repair: async () => {},
92
+ readInstalled: async () => "1.0.0",
93
+ saveInstalled: async () => {},
94
+ repairStaleLoad: async () => {
95
+ throw new Error("no stale-load repair expected");
96
+ },
97
+ };
98
+ return { deps, installs, updates };
99
+ }
100
+
101
+ describe("the plan names the scopes whose files are missing", () => {
102
+ test("files missing at user scope plans user scope", () => {
103
+ const item = planFor(STRAY, [info(STRAY, { user: { installed: true } })]);
104
+ expect(item.action).toBe("install");
105
+ expect(item.reason).toBe("files-missing");
106
+ expect(item.scopes).toEqual(["user"]);
107
+ });
108
+
109
+ test("files missing at local scope plans local scope", () => {
110
+ const item = planFor(STRAY, [info(STRAY, { local: { installed: true } })]);
111
+ expect(item.reason).toBe("files-missing");
112
+ expect(item.scopes).toEqual(["local"]);
113
+ });
114
+
115
+ test("only the installed scopes with no version, user → project → local", () => {
116
+ // The project row is inherited from another checkout and not turned on
117
+ // here: it is neither installed nor missing, so it is not repaired.
118
+ const item = planFor(STRAY, [
119
+ info(STRAY, {
120
+ local: { installed: true },
121
+ project: { installed: false, version: "0.8.1" },
122
+ user: { installed: true },
123
+ }),
124
+ ]);
125
+ expect(item.scopes).toEqual(["user", "local"]);
126
+ });
127
+
128
+ test("control: a declared plugin installed nowhere is `absent`, with no scopes", () => {
129
+ const item = planFor(DEV, [info(DEV, {})], { [DEV]: "latest" });
130
+ expect(item.reason).toBe("absent");
131
+ expect(item.scopes).toEqual([]);
132
+ });
133
+ });
134
+
135
+ describe("the engine reinstalls where the files are missing, never elsewhere", () => {
136
+ test("files missing at USER scope installs with --scope user, never project", async () => {
137
+ const item = planFor(STRAY, [info(STRAY, { user: { installed: true } })]);
138
+ const cli = fakeCli();
139
+
140
+ await applyPlugins([item], "/tmp/project", cli.deps);
141
+
142
+ // `REAL_PLUGIN_DEPS.install` is `installPlugin`, which passes this scope
143
+ // to `claude plugin install` as `--scope`.
144
+ expect(cli.installs).toEqual([{ id: STRAY, scope: "user" }]);
145
+ expect(cli.installs.map((c) => c.scope)).not.toContain("project");
146
+ expect(cli.updates).toEqual([]);
147
+ });
148
+
149
+ test("files missing at local scope installs with --scope local", async () => {
150
+ const item = planFor(STRAY, [info(STRAY, { local: { installed: true } })]);
151
+ const cli = fakeCli();
152
+
153
+ await applyPlugins([item], "/tmp/project", cli.deps);
154
+
155
+ expect(cli.installs).toEqual([{ id: STRAY, scope: "local" }]);
156
+ });
157
+
158
+ test("a progress meter counts it as work, so the bar reaches full", () => {
159
+ const item = planFor(STRAY, [info(STRAY, { user: { installed: true } })]);
160
+ expect(pluginNeedsWork(item)).toBe(true);
161
+ });
162
+
163
+ test("control: an `absent` profile plugin still goes in at project scope", async () => {
164
+ const item = planFor(DEV, [info(DEV, {})], { [DEV]: "latest" });
165
+ const cli = fakeCli();
166
+
167
+ await applyPlugins([item], "/tmp/project", cli.deps);
168
+
169
+ expect(cli.installs).toEqual([{ id: DEV, scope: "project" }]);
170
+ });
171
+ });
172
+
173
+ describe("repairing an undeclared user-scope plugin leaves profiles.json alone", () => {
174
+ let project: string;
175
+ let log: ReturnType<typeof spyOn>;
176
+
177
+ beforeEach(async () => {
178
+ project = await mkdtemp(join(tmpdir(), "files-missing-scope-"));
179
+ log = spyOn(console, "log").mockImplementation(() => {});
180
+ });
181
+ afterEach(async () => {
182
+ log.mockRestore();
183
+ await rm(project, { recursive: true, force: true });
184
+ });
185
+
186
+ test("the full run — plan, apply under withCliBookkeeping, reconcile — writes nothing into profiles.json", async () => {
187
+ const manifest: ProfileManifest = {
188
+ version: 2,
189
+ profiles: { team: { name: "Team", plugins: { [DEV]: "latest" } } },
190
+ };
191
+ await writeManifest(manifest, project);
192
+ await activateProfile(manifest, "team", project, { onDisplace: () => {} });
193
+ const profilesPath = join(project, ".claude", "profiles.json");
194
+ const settingsPath = join(project, ".claude", "settings.json");
195
+ const before = await readFile(profilesPath, "utf8");
196
+ // Without an active marker withCliBookkeeping skips the reconcile, and
197
+ // this case would pass without testing anything.
198
+ expect(await readActiveMarker(project)).not.toBeNull();
199
+
200
+ // What `magus update` plans: the profile's plugin, installed and current
201
+ // at project scope, and an undeclared plugin installed at user scope with
202
+ // its files missing.
203
+ const plan = planPluginUpdates({ [DEV]: "latest" }, [
204
+ info(DEV, { project: { installed: true, version: "1.0.0" } }),
205
+ info(STRAY, { user: { installed: true } }),
206
+ ]);
207
+ // `claude plugin install --scope project` turns the plugin on in the
208
+ // project's settings.json. The user-scope write lands in
209
+ // ~/.claude/settings.json, which a reconcile never reads.
210
+ const cli = fakeCli(async (id, scope) => {
211
+ if (scope !== "project") return;
212
+ const settings = await fs.readJson(settingsPath);
213
+ settings.enabledPlugins = { ...settings.enabledPlugins, [id]: true };
214
+ await fs.writeFile(settingsPath, JSON.stringify(settings));
215
+ });
216
+ const reported: string[] = [];
217
+
218
+ await withCliBookkeeping(
219
+ project,
220
+ () => applyPlugins(plan, project, cli.deps),
221
+ {
222
+ // Exactly what `magus update` passes: the active profile's plugins.
223
+ installable: [DEV],
224
+ report: (line) => reported.push(line),
225
+ },
226
+ );
227
+
228
+ // The outcome first — nothing absorbed — then the cause.
229
+ expect(reported).toEqual([]);
230
+ expect(await readFile(profilesPath, "utf8")).toBe(before);
231
+ const live = await fs.readJson(settingsPath);
232
+ expect(live.enabledPlugins?.[STRAY]).toBeUndefined();
233
+ expect(cli.installs).toEqual([{ id: STRAY, scope: "user" }]);
234
+ });
235
+ });
@@ -1,10 +1,11 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { isEnabledButNotInstalled } from "../services/plugin-manager.js";
2
+ import { hasMissingFiles } from "../services/plugin-manager.js";
3
3
  import type { PluginInfo } from "../services/plugin-manager.js";
4
4
 
5
5
  /**
6
- * "Enabled but not installed" is a broken state that previously rendered
7
- * identically to a healthy install: a green scope square and no version.
6
+ * "Files missing" — installed at a scope, with no version resolving — is a
7
+ * broken state that previously rendered identically to a healthy install: a
8
+ * green scope square and no version.
8
9
  *
9
10
  * It is also invisible to every update path, because the check is
10
11
  * `installedVersion && compare(latest, installedVersion) > 0` — with no
@@ -19,18 +20,17 @@ const plugin = (over: Partial<PluginInfo>): PluginInfo =>
19
20
  description: "",
20
21
  marketplace: "magus",
21
22
  marketplaceDisplay: "Magus",
22
- enabled: true,
23
23
  ...over,
24
24
  }) as PluginInfo;
25
25
 
26
- describe("isEnabledButNotInstalled", () => {
27
- test("enabled in project scope with no installed version is flagged", () => {
26
+ describe("hasMissingFiles", () => {
27
+ test("installed in project scope with no installed version is flagged", () => {
28
28
  // The real case: terminal/code-search are in this project's enabledPlugins
29
29
  // but have no registry entry for it and none at user scope.
30
30
  expect(
31
- isEnabledButNotInstalled(
31
+ hasMissingFiles(
32
32
  plugin({
33
- projectScope: { enabled: true },
33
+ projectScope: { installed: true },
34
34
  installedVersion: undefined,
35
35
  }),
36
36
  ),
@@ -39,53 +39,59 @@ describe("isEnabledButNotInstalled", () => {
39
39
 
40
40
  test("a healthy install is not flagged", () => {
41
41
  expect(
42
- isEnabledButNotInstalled(
43
- plugin({ projectScope: { enabled: true }, installedVersion: "1.0.0" }),
42
+ hasMissingFiles(
43
+ plugin({
44
+ projectScope: { installed: true, version: "1.0.0" },
45
+ installedVersion: "1.0.0",
46
+ }),
44
47
  ),
45
48
  ).toBe(false);
46
49
  });
47
50
 
48
- test('installedVersion "0.0.0" means installed-with-unknown-version, not missing', () => {
51
+ test('a "0.0.0" version means installed-with-unknown-version, not missing', () => {
49
52
  // Anthropic's official plugins record exactly this. Treating it as missing
50
53
  // would mislabel every one of them as broken.
51
54
  expect(
52
- isEnabledButNotInstalled(
53
- plugin({ userScope: { enabled: true }, installedVersion: "0.0.0" }),
55
+ hasMissingFiles(
56
+ plugin({
57
+ userScope: { installed: true, version: "0.0.0" },
58
+ installedVersion: "0.0.0",
59
+ }),
54
60
  ),
55
61
  ).toBe(false);
56
62
  });
57
63
 
58
- test("not enabled anywhere is not flagged — it is simply available", () => {
59
- expect(
60
- isEnabledButNotInstalled(plugin({ installedVersion: undefined })),
61
- ).toBe(false);
64
+ test("not installed anywhere is not flagged — it is simply available", () => {
65
+ expect(hasMissingFiles(plugin({ installedVersion: undefined }))).toBe(
66
+ false,
67
+ );
62
68
  });
63
69
 
64
70
  test("orphaned plugins are excluded — they have their own 'deprecated' state", () => {
65
71
  expect(
66
- isEnabledButNotInstalled(
72
+ hasMissingFiles(
67
73
  plugin({
68
74
  isOrphaned: true,
69
- projectScope: { enabled: true },
75
+ projectScope: { installed: true },
70
76
  installedVersion: undefined,
71
77
  }),
72
78
  ),
73
79
  ).toBe(false);
74
80
  });
75
81
 
76
- test("any enabled scope counts, not just project", () => {
82
+ test("any installed scope counts, not just project", () => {
77
83
  for (const scope of ["userScope", "projectScope", "localScope"] as const) {
78
- expect(
79
- isEnabledButNotInstalled(plugin({ [scope]: { enabled: true } })),
80
- ).toBe(true);
84
+ expect(hasMissingFiles(plugin({ [scope]: { installed: true } }))).toBe(
85
+ true,
86
+ );
81
87
  }
82
88
  });
83
89
 
84
- test("a scope present but disabled does not count as enabled", () => {
90
+ test("a scope present but not installed does not count", () => {
85
91
  expect(
86
- isEnabledButNotInstalled(
92
+ hasMissingFiles(
87
93
  plugin({
88
- projectScope: { enabled: false },
94
+ projectScope: { installed: false },
89
95
  installedVersion: undefined,
90
96
  }),
91
97
  ),