magus-cli 7.7.0 → 7.8.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 (83) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/blackbox-parity.test.ts +29 -15
  3. package/src/__tests__/blackbox-plugin-state.test.ts +25 -6
  4. package/src/__tests__/blind-780-cli-tools.test.ts +808 -0
  5. package/src/__tests__/blind-780-deprecated.test.ts +631 -0
  6. package/src/__tests__/blind-780-failure-reasons.test.ts +1004 -0
  7. package/src/__tests__/blind-780-plugin-resolution.test.ts +431 -0
  8. package/src/__tests__/blind-780-review2.test.ts +1203 -0
  9. package/src/__tests__/cli-update-view.test.ts +3 -3
  10. package/src/__tests__/deprecated-remove.test.ts +827 -0
  11. package/src/__tests__/doctor.test.ts +159 -1
  12. package/src/__tests__/failure-popup-review2.test.tsx +920 -0
  13. package/src/__tests__/failure-popup.test.tsx +419 -0
  14. package/src/__tests__/final-fixes-780.test.tsx +724 -0
  15. package/src/__tests__/format-update-outcome.test.ts +6 -32
  16. package/src/__tests__/gitignore-profile.test.ts +1 -9
  17. package/src/__tests__/models-screen-state.test.ts +18 -40
  18. package/src/__tests__/plugin-resolution.test.ts +52 -13
  19. package/src/__tests__/plugin-row-text.test.ts +30 -37
  20. package/src/__tests__/profile-dependencies.test.ts +7 -0
  21. package/src/__tests__/profile-startup-old-format.test.ts +0 -3
  22. package/src/__tests__/profile-startup.test.ts +40 -12
  23. package/src/__tests__/stale-load-repair.test.ts +31 -23
  24. package/src/__tests__/update-apply.test.ts +13 -29
  25. package/src/__tests__/update-plan.test.ts +23 -17
  26. package/src/__tests__/useStaleLoadModal.test.ts +1 -57
  27. package/src/__tests__/version-unknown-state.test.ts +15 -17
  28. package/src/cli/doctor.ts +151 -30
  29. package/src/cli/install.ts +5 -6
  30. package/src/cli/update-view.ts +58 -15
  31. package/src/cli/update.ts +27 -5
  32. package/src/services/catalog-cache-store.ts +8 -6
  33. package/src/services/claude-cli.ts +106 -19
  34. package/src/services/claude-settings.ts +84 -36
  35. package/src/services/plugin-env-needs.ts +69 -0
  36. package/src/services/plugin-manager.ts +31 -19
  37. package/src/services/plugin-resolution.ts +34 -42
  38. package/src/services/profile-activate.ts +1 -2
  39. package/src/services/profile-startup.ts +18 -43
  40. package/src/services/profile-state.ts +72 -2
  41. package/src/services/resolver.ts +95 -1
  42. package/src/services/update-engine.ts +326 -31
  43. package/src/services/update-plan.ts +293 -33
  44. package/src/types/index.ts +7 -0
  45. package/src/ui/App.tsx +90 -146
  46. package/src/ui/adapters/cliToolsAdapter.ts +203 -1
  47. package/src/ui/adapters/failureReport.ts +137 -0
  48. package/src/ui/adapters/pluginsAdapter.ts +297 -45
  49. package/src/ui/adapters/startupReports.ts +282 -0
  50. package/src/ui/components/modals/FailureListModal.tsx +231 -0
  51. package/src/ui/components/modals/MessageModal.tsx +7 -12
  52. package/src/ui/components/modals/ModalContainer.tsx +10 -5
  53. package/src/ui/components/modals/index.ts +1 -0
  54. package/src/ui/hooks/startupQueue.ts +56 -0
  55. package/src/ui/hooks/useGitignoreModal.ts +30 -52
  56. package/src/ui/hooks/useProfileStartupModal.ts +119 -142
  57. package/src/ui/hooks/useStaleLoadModal.ts +35 -65
  58. package/src/ui/keymap.ts +14 -1
  59. package/src/ui/renderers/mcpRenderers.tsx +110 -3
  60. package/src/ui/renderers/pluginRenderers.tsx +77 -6
  61. package/src/ui/renderers/pluginRowText.ts +25 -25
  62. package/src/ui/renderers/pluginStatusText.ts +18 -1
  63. package/src/ui/screens/AliasScreen.tsx +38 -45
  64. package/src/ui/screens/CliToolsScreen.tsx +65 -54
  65. package/src/ui/screens/EnvVarsScreen.tsx +17 -5
  66. package/src/ui/screens/GitignoreScreen.tsx +48 -67
  67. package/src/ui/screens/McpRegistryScreen.tsx +67 -38
  68. package/src/ui/screens/McpScreen.tsx +46 -19
  69. package/src/ui/screens/ModelsScreen.tsx +46 -86
  70. package/src/ui/screens/PluginsScreen.tsx +313 -221
  71. package/src/ui/screens/ProfilesScreen.tsx +79 -40
  72. package/src/ui/screens/SkillsScreen.tsx +83 -80
  73. package/src/ui/screens/StylesScreen.tsx +211 -275
  74. package/src/ui/state/AppContext.tsx +66 -44
  75. package/src/ui/state/DimensionsContext.tsx +3 -22
  76. package/src/ui/state/reducer.ts +12 -100
  77. package/src/ui/state/types.ts +48 -69
  78. package/src/utils/user-path.ts +60 -0
  79. package/src/__tests__/notices-rules.test.ts +0 -476
  80. package/src/__tests__/notices.test.tsx +0 -604
  81. package/src/__tests__/styles-status-line.test.ts +0 -72
  82. package/src/__tests__/version-snapshot.test.ts +0 -212
  83. package/src/services/version-snapshot.ts +0 -189
@@ -1,212 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
- import { promises as fs } from "node:fs";
3
- import os from "node:os";
4
- import path from "node:path";
5
- import {
6
- UNKNOWN_VERSION,
7
- diffVersions,
8
- isKnownVersion,
9
- loadSeenVersions,
10
- saveSeenVersions,
11
- } from "../services/version-snapshot.js";
12
-
13
- /**
14
- * Plugins get updated by things magus does not control — Claude Code's own
15
- * `plugin update` writes installed_plugins.json without touching magus's
16
- * bookkeeping. Diffing what we last rendered against reality catches every
17
- * source; logging only magus's own actions catches none of them.
18
- */
19
-
20
- describe("isKnownVersion", () => {
21
- test('"0.0.0" is not a usable version', () => {
22
- // Claude Code records this for a plugin whose version it cannot determine.
23
- expect(isKnownVersion(UNKNOWN_VERSION)).toBe(false);
24
- expect(isKnownVersion(undefined)).toBe(false);
25
- expect(isKnownVersion("")).toBe(false);
26
- expect(isKnownVersion("1.0.0")).toBe(true);
27
- });
28
- });
29
-
30
- describe("diffVersions — first run", () => {
31
- test("no baseline reports nothing at all", () => {
32
- // Without this, the first launch would flag every installed plugin as new,
33
- // which is both wrong and the fastest way to train someone to ignore badges.
34
- expect(
35
- diffVersions(null, { "dev@magus": "2.12.1", "a@mp": UNKNOWN_VERSION }),
36
- ).toEqual([]);
37
- });
38
-
39
- test("an empty baseline is NOT the same as no baseline", () => {
40
- // A real snapshot that happens to be empty means we have looked before and
41
- // saw nothing, so anything present now is genuinely new.
42
- const changes = diffVersions({}, { "dev@magus": "2.12.1" });
43
- expect(changes).toEqual([
44
- { pluginId: "dev@magus", kind: "installed", to: "2.12.1" },
45
- ]);
46
- });
47
- });
48
-
49
- describe("diffVersions — installs", () => {
50
- test("a plugin absent from the baseline is reported as installed, not updated", () => {
51
- const changes = diffVersions(
52
- { "other@mp": "1.0.0" },
53
- { "new@mp": "3.1.0" },
54
- );
55
- expect(changes).toEqual([
56
- { pluginId: "new@mp", kind: "installed", to: "3.1.0" },
57
- ]);
58
- });
59
-
60
- test("an install with an unknown version is still reported", () => {
61
- // The version is unusable but the install is real — the UI shows "new"
62
- // with no version rather than suppressing the plugin entirely.
63
- const changes = diffVersions({}, { "a@mp": UNKNOWN_VERSION });
64
- expect(changes).toEqual([
65
- { pluginId: "a@mp", kind: "installed", to: UNKNOWN_VERSION },
66
- ]);
67
- });
68
- });
69
-
70
- describe("diffVersions — updates", () => {
71
- test("reports a genuine version transition", () => {
72
- expect(
73
- diffVersions({ "dev@magus": "2.12.0" }, { "dev@magus": "2.12.1" }),
74
- ).toEqual([
75
- { pluginId: "dev@magus", kind: "updated", from: "2.12.0", to: "2.12.1" },
76
- ]);
77
- });
78
-
79
- test("unchanged versions report nothing", () => {
80
- expect(
81
- diffVersions({ "dev@magus": "2.12.1" }, { "dev@magus": "2.12.1" }),
82
- ).toEqual([]);
83
- });
84
-
85
- test("learning a previously unknown version is not an update", () => {
86
- // This is exactly what v4.22.0's registry overlay caused: versions that
87
- // could not be resolved suddenly could be. Reporting it would announce
88
- // magus's own bugfix as if the user's plugins had changed.
89
- expect(
90
- diffVersions({ "a@mp": UNKNOWN_VERSION }, { "a@mp": "5.3.0" }),
91
- ).toEqual([]);
92
- });
93
-
94
- test("losing a known version is not an update either", () => {
95
- expect(
96
- diffVersions({ "a@mp": "5.3.0" }, { "a@mp": UNKNOWN_VERSION }),
97
- ).toEqual([]);
98
- });
99
-
100
- test("a downgrade IS reported", () => {
101
- // Rolling back is a real change, arguably the most important to surface.
102
- expect(diffVersions({ "a@mp": "2.0.0" }, { "a@mp": "1.0.0" })).toEqual([
103
- { pluginId: "a@mp", kind: "updated", from: "2.0.0", to: "1.0.0" },
104
- ]);
105
- });
106
- });
107
-
108
- describe("diffVersions — removals and mixed sets", () => {
109
- test("a removed plugin is not reported", () => {
110
- expect(diffVersions({ "gone@mp": "1.0.0" }, {})).toEqual([]);
111
- });
112
-
113
- test("classifies each plugin independently in one pass", () => {
114
- const changes = diffVersions(
115
- { same: "1.0.0", moved: "2.0.0", learned: UNKNOWN_VERSION },
116
- { same: "1.0.0", moved: "2.1.0", learned: "9.0.0", fresh: "0.1.0" },
117
- );
118
- expect(changes).toEqual([
119
- { pluginId: "moved", kind: "updated", from: "2.0.0", to: "2.1.0" },
120
- { pluginId: "fresh", kind: "installed", to: "0.1.0" },
121
- ]);
122
- });
123
- });
124
-
125
- describe("per-project baselines", () => {
126
- /**
127
- * The baseline used to be one flat map for the whole machine, but the
128
- * versions written into it are resolved per project. Two projects
129
- * legitimately on different versions overwrote each other's baseline, so
130
- * every differing plugin was reported as "updated" on the next render — a
131
- * change that never happened.
132
- *
133
- * CLAUDE_CONFIG_DIR, not HOME: os.homedir() reads the passwd database on
134
- * macOS and ignores $HOME, so a HOME-only override writes to the operator's
135
- * real ~/.claude. That is not hypothetical — it happened while writing these.
136
- */
137
- const withSandbox = async (fn: () => Promise<void>): Promise<void> => {
138
- const dir = await fs.mkdtemp(path.join(os.tmpdir(), "vsnap-"));
139
- const prev = process.env.CLAUDE_CONFIG_DIR;
140
- process.env.CLAUDE_CONFIG_DIR = dir;
141
- try {
142
- await fn();
143
- } finally {
144
- // biome-ignore lint/performance/noDelete: assigning undefined to process.env stores the string "undefined"; only delete unsets the variable
145
- if (prev === undefined) delete process.env.CLAUDE_CONFIG_DIR;
146
- else process.env.CLAUDE_CONFIG_DIR = prev;
147
- await fs.rm(dir, { recursive: true, force: true });
148
- }
149
- };
150
-
151
- test("a project with no baseline of its own reports nothing", async () => {
152
- await withSandbox(async () => {
153
- await saveSeenVersions("/proj/a", { "setup@magus": "2.2.0" });
154
- const baseline = await loadSeenVersions("/proj/b");
155
- expect(baseline).toBeNull();
156
- expect(diffVersions(baseline, { "setup@magus": "2.5.0" })).toEqual([]);
157
- });
158
- });
159
-
160
- test("saving one project preserves the others", async () => {
161
- await withSandbox(async () => {
162
- await saveSeenVersions("/proj/a", { "designer@magus": "2.0.0" });
163
- await saveSeenVersions("/proj/b", { "designer@magus": "2.0.1" });
164
- expect(await loadSeenVersions("/proj/a")).toEqual({
165
- "designer@magus": "2.0.0",
166
- });
167
- expect(await loadSeenVersions("/proj/b")).toEqual({
168
- "designer@magus": "2.0.1",
169
- });
170
- });
171
- });
172
-
173
- test("a real update inside one project is still reported", async () => {
174
- await withSandbox(async () => {
175
- await saveSeenVersions("/proj/a", { "dev@magus": "3.0.0" });
176
- const previous = await loadSeenVersions("/proj/a");
177
- expect(diffVersions(previous, { "dev@magus": "3.0.1" })).toEqual([
178
- { pluginId: "dev@magus", kind: "updated", from: "3.0.0", to: "3.0.1" },
179
- ]);
180
- });
181
- });
182
-
183
- test("a pre-migration flat baseline is adopted once, then replaced", async () => {
184
- await withSandbox(async () => {
185
- const file = path.join(
186
- process.env.CLAUDE_CONFIG_DIR as string,
187
- "magus-version-snapshot.json",
188
- );
189
- await fs.writeFile(
190
- file,
191
- JSON.stringify({
192
- seen: { "dev@magus": "3.0.0" },
193
- seededAt: "2026-07-29T00:00:00.000Z",
194
- updatedAt: "2026-07-29T00:00:00.000Z",
195
- }),
196
- );
197
-
198
- // Legacy shape carries no project, so it stands in for whoever asks first
199
- // rather than being thrown away.
200
- expect(await loadSeenVersions("/proj/a")).toEqual({
201
- "dev@magus": "3.0.0",
202
- });
203
-
204
- await saveSeenVersions("/proj/a", { "dev@magus": "3.0.1" });
205
- // Once per-project entries exist, the flat map is no longer consulted.
206
- expect(await loadSeenVersions("/proj/b")).toBeNull();
207
- expect(await loadSeenVersions("/proj/a")).toEqual({
208
- "dev@magus": "3.0.1",
209
- });
210
- });
211
- });
212
- });
@@ -1,189 +0,0 @@
1
- /**
2
- * version-snapshot.ts — detect plugin changes that happened outside magus.
3
- *
4
- * Plugins get updated by things magus does not control: Claude Code's own
5
- * `plugin install`/`update`, or a direct CLI
6
- * call. All of them write `installed_plugins.json`; none of them tell the user
7
- * anything the next time magus opens, so versions move underfoot silently.
8
- *
9
- * Rather than log what magus itself did — which misses every other source —
10
- * this records what magus last *saw* installed and diffs against reality on
11
- * load. Any change is reported, regardless of who made it.
12
- *
13
- * The snapshot is advisory UI state, kept separate from the update-check cache
14
- * so a TTL expiry or a corrupt cache can never make plugins look changed.
15
- */
16
-
17
- import { promises as fs } from "node:fs";
18
- import os from "node:os";
19
- import path from "node:path";
20
-
21
- /**
22
- * Sentinel recorded by Claude Code for a plugin that is installed but whose
23
- * version is unknown — every plugin in the official Anthropic marketplace uses
24
- * it. It is not a real version and must never be rendered as one.
25
- */
26
- export const UNKNOWN_VERSION = "0.0.0";
27
-
28
- /** True when a version string carries no usable information. */
29
- export function isKnownVersion(v: string | undefined): v is string {
30
- return !!v && v !== UNKNOWN_VERSION;
31
- }
32
-
33
- export type PluginChangeKind = "installed" | "updated";
34
-
35
- export interface PluginChange {
36
- pluginId: string;
37
- kind: PluginChangeKind;
38
- /** Previous version. Only set for "updated", and always a known version. */
39
- from?: string;
40
- /** Current version. May be UNKNOWN_VERSION — callers must not render it raw. */
41
- to: string;
42
- }
43
-
44
- interface SnapshotFile {
45
- /**
46
- * Legacy flat baseline: pluginId -> version, with no record of which project
47
- * it was observed from. Kept only so an existing file can be migrated.
48
- */
49
- seen?: Record<string, string>;
50
- /**
51
- * projectPath -> (pluginId -> installedVersion) as of the last time magus
52
- * rendered that project.
53
- *
54
- * Versions are per-project (installed_plugins.json keys installs by
55
- * projectPath), but this file is global. A single flat map therefore let two
56
- * projects legitimately on different versions overwrite each other's
57
- * baseline, and every differing plugin was badged "updated" on the next
58
- * render — a change that never happened. Keying by project removes the
59
- * cross-talk entirely.
60
- */
61
- byProject?: Record<string, Record<string, string>>;
62
- /** Set on the run that first created the baseline. */
63
- seededAt: string;
64
- updatedAt: string;
65
- }
66
-
67
- /**
68
- * Honour CLAUDE_CONFIG_DIR — the same override Claude Code itself uses.
69
- *
70
- * `os.homedir()` reads the passwd database on macOS and ignores $HOME, so a
71
- * test that only overrides HOME writes to the operator's real snapshot file.
72
- * That is not hypothetical: it happened while building the tests for this fix.
73
- */
74
- function snapshotPath(): string {
75
- const configDir =
76
- process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude");
77
- return path.join(configDir, "magus-version-snapshot.json");
78
- }
79
-
80
- async function read(): Promise<SnapshotFile | null> {
81
- try {
82
- const raw = await fs.readFile(snapshotPath(), "utf-8");
83
- const data = JSON.parse(raw) as SnapshotFile;
84
- if (!data) return null;
85
- const hasFlat = typeof data.seen === "object" && data.seen !== null;
86
- const hasByProject =
87
- typeof data.byProject === "object" && data.byProject !== null;
88
- if (!hasFlat && !hasByProject) return null;
89
- return data;
90
- } catch {
91
- return null; // absent or corrupt — treated as "no baseline yet"
92
- }
93
- }
94
-
95
- /**
96
- * Diff what is installed now against the previous snapshot.
97
- *
98
- * Three cases are deliberately silent, because each reflects magus's own
99
- * knowledge changing rather than anything happening to the user's plugins:
100
- *
101
- * 1. No baseline (first run). Every plugin would otherwise be reported as
102
- * newly installed on the very first launch — a guaranteed false flood that
103
- * trains the user to ignore the badge. The first run seeds and says nothing.
104
- *
105
- * 2. A version moving to or from UNKNOWN_VERSION. That is magus learning
106
- * (or losing) a version it could not previously resolve — notably after
107
- * v4.22.0 taught it to read the registry — not the plugin changing.
108
- *
109
- * 3. A plugin disappearing. Uninstalls are visible elsewhere; nothing to badge.
110
- *
111
- * A downgrade IS reported: rolling back is a real change worth surfacing.
112
- */
113
- export function diffVersions(
114
- previous: Record<string, string> | null,
115
- current: Record<string, string>,
116
- ): PluginChange[] {
117
- if (!previous) return [];
118
-
119
- const changes: PluginChange[] = [];
120
- for (const [pluginId, to] of Object.entries(current)) {
121
- const from = previous[pluginId];
122
-
123
- // Never seen before → newly installed, not an update. There is no
124
- // meaningful "from", and `to` may legitimately be UNKNOWN_VERSION.
125
- if (from === undefined) {
126
- changes.push({ pluginId, kind: "installed", to });
127
- continue;
128
- }
129
-
130
- if (from === to) continue;
131
-
132
- // One side carries no real version: we learned or lost it, nothing moved.
133
- if (!isKnownVersion(from) || !isKnownVersion(to)) continue;
134
-
135
- changes.push({ pluginId, kind: "updated", from, to });
136
- }
137
- return changes;
138
- }
139
-
140
- /**
141
- * Load the baseline for one project, or null when that project has none.
142
- *
143
- * A project seen for the first time returns null, so `diffVersions` stays
144
- * silent — the same rule as a first ever run. That is deliberate: opening
145
- * magus in a new project must not badge every plugin as "updated" just
146
- * because this file has never seen that path before.
147
- *
148
- * A pre-migration flat file is adopted as the baseline for whichever project
149
- * asks first, which is the closest thing to the truth it contains.
150
- */
151
- export async function loadSeenVersions(
152
- projectPath: string,
153
- ): Promise<Record<string, string> | null> {
154
- const data = await read();
155
- if (!data) return null;
156
- const scoped = data.byProject?.[projectPath];
157
- if (scoped) return scoped;
158
- // Legacy flat baseline, project unknown — use it once, then it is replaced
159
- // by per-project entries on save.
160
- if (data.seen && !data.byProject) return data.seen;
161
- return null;
162
- }
163
-
164
- /**
165
- * Persist what is installed now, so the same change is not reported twice.
166
- * Best-effort: a failure here must never break the UI — it only means the badge
167
- * shows again next launch.
168
- */
169
- export async function saveSeenVersions(
170
- projectPath: string,
171
- current: Record<string, string>,
172
- ): Promise<void> {
173
- try {
174
- const file = snapshotPath();
175
- await fs.mkdir(path.dirname(file), { recursive: true });
176
- const existing = await read();
177
- const now = new Date().toISOString();
178
- const payload: SnapshotFile = {
179
- // Other projects' baselines are preserved — writing only this project's
180
- // slot is the entire point of the fix.
181
- byProject: { ...(existing?.byProject ?? {}), [projectPath]: current },
182
- seededAt: existing?.seededAt ?? now,
183
- updatedAt: now,
184
- };
185
- await fs.writeFile(file, JSON.stringify(payload, null, 2), "utf-8");
186
- } catch {
187
- // ignore
188
- }
189
- }