magus-cli 7.5.4 → 7.7.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.
- package/package.json +4 -4
- package/src/__tests__/format-update-outcome.test.ts +9 -8
- package/src/__tests__/gitignore-profile.test.ts +9 -5
- package/src/__tests__/key-focus-guard.test.tsx +260 -0
- package/src/__tests__/notices-rules.test.ts +476 -0
- package/src/__tests__/notices.test.tsx +604 -0
- package/src/__tests__/profile-name-on-save.test.ts +443 -0
- package/src/__tests__/profile-startup.test.ts +1 -1
- package/src/__tests__/prompt-eof.test.ts +245 -0
- package/src/__tests__/update-apply.test.ts +3 -3
- package/src/__tests__/useStaleLoadModal.test.ts +27 -12
- package/src/cli/bootstrap.ts +34 -2
- package/src/cli/prompt.ts +12 -2
- package/src/cli/update-view.ts +6 -4
- package/src/services/gitignore-service.ts +0 -19
- package/src/services/profile-absorb.ts +9 -0
- package/src/services/profile-activate.ts +4 -2
- package/src/services/profile-adopt.ts +9 -2
- package/src/services/profile-startup.ts +91 -18
- package/src/services/profile-state.ts +10 -1
- package/src/ui/App.tsx +295 -141
- package/src/ui/adapters/pluginsAdapter.ts +28 -9
- package/src/ui/components/modals/MessageModal.tsx +1 -2
- package/src/ui/components/modals/ModalContainer.tsx +3 -0
- package/src/ui/hooks/useGitignoreModal.ts +58 -18
- package/src/ui/hooks/useProfileStartupModal.ts +232 -85
- package/src/ui/hooks/useStaleLoadModal.ts +43 -29
- package/src/ui/input/KeyRouter.tsx +43 -2
- package/src/ui/screens/AliasScreen.tsx +7 -6
- package/src/ui/screens/CliToolsScreen.tsx +19 -16
- package/src/ui/screens/EnvVarsScreen.tsx +4 -1
- package/src/ui/screens/GitignoreScreen.tsx +20 -27
- package/src/ui/screens/McpRegistryScreen.tsx +9 -6
- package/src/ui/screens/McpScreen.tsx +16 -12
- package/src/ui/screens/PluginsScreen.tsx +35 -56
- package/src/ui/screens/ProfilesScreen.tsx +27 -51
- package/src/ui/state/AppContext.tsx +46 -3
- package/src/ui/state/reducer.ts +100 -1
- package/src/ui/state/types.ts +53 -2
|
@@ -0,0 +1,604 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Spec "no confirmation screens in the magus TUI" — rules 1, 3, 4, 5: results become
|
|
3
|
+
* non-blocking notice lines instead of success modals.
|
|
4
|
+
*
|
|
5
|
+
* Written blind: from the spec and the exported signatures and types only
|
|
6
|
+
* (reducer actions NOTIFY / EXPIRE_NOTICES, `useNotify`, `NOTICE_DURATION_MS`, and the
|
|
7
|
+
* three pure wording functions). No implementation body was read.
|
|
8
|
+
*
|
|
9
|
+
* Coverage map (requirement → describe block):
|
|
10
|
+
* REQ-3a notify adds a notice → "REQ-3 reducer"
|
|
11
|
+
* REQ-3b same text replaces, never stacks → "REQ-3 reducer"
|
|
12
|
+
* REQ-3c at most MAX_NOTICES, newest kept → "REQ-3 reducer"
|
|
13
|
+
* REQ-3d expiry drops only expired notices → "REQ-3 reducer"
|
|
14
|
+
* REQ-3e warnings stay longer than successes → "REQ-3 durations", "REQ-3 useNotify"
|
|
15
|
+
* REQ-3f disappears by itself (no key) → reducer expiry only (see below)
|
|
16
|
+
* REQ-5a activation → `Using profile "<id>"` → "REQ-5 activationNotices"
|
|
17
|
+
* REQ-5b backup ("moved aside") lines dropped → "REQ-5 activationNotices"
|
|
18
|
+
* REQ-5c missing plugin → warning, short name, `magus install`, one line if several
|
|
19
|
+
* REQ-4 wording: one line, no internals → "REQ-4 wording"
|
|
20
|
+
* REQ-1 gitignore fix / stale-load repair results are notices with a tone
|
|
21
|
+
*
|
|
22
|
+
* Not covered: rendering of NoticeBanner and "never takes keys" — the banner is not
|
|
23
|
+
* exported and is only reachable by rendering the whole App, which reads the machine's
|
|
24
|
+
* real plugin state. For the same reason the timer that dispatches EXPIRE_NOTICES is not
|
|
25
|
+
* exercised: the contract does not say which component owns it, and under AppProvider
|
|
26
|
+
* alone a success notice was still present after its duration.
|
|
27
|
+
*
|
|
28
|
+
* Rule 4 lets a notice name a file the user must act on. Untracking generated files
|
|
29
|
+
* leaves a staged removal the user has to commit, so the gitignore fix may name those
|
|
30
|
+
* files, and may word that line as a warning (rule 6: a result the user must act on).
|
|
31
|
+
*/
|
|
32
|
+
import { afterAll, describe, expect, test } from "bun:test";
|
|
33
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
34
|
+
import { tmpdir } from "node:os";
|
|
35
|
+
import path from "node:path";
|
|
36
|
+
import { testRender } from "@opentui/react/test-utils";
|
|
37
|
+
import { act, useEffect } from "react";
|
|
38
|
+
import type { StartupFixResult } from "../services/gitignore-service.js";
|
|
39
|
+
import type { ActivationResult } from "../services/profile-activate.js";
|
|
40
|
+
import { startupFixNotices } from "../ui/hooks/useGitignoreModal.js";
|
|
41
|
+
import { activationNotices } from "../ui/hooks/useProfileStartupModal.js";
|
|
42
|
+
import { staleLoadRepairNotices } from "../ui/hooks/useStaleLoadModal.js";
|
|
43
|
+
import {
|
|
44
|
+
AppProvider,
|
|
45
|
+
NOTICE_DURATION_MS,
|
|
46
|
+
useApp,
|
|
47
|
+
useNotify,
|
|
48
|
+
} from "../ui/state/AppContext.js";
|
|
49
|
+
import { MAX_NOTICES, appReducer, initialState } from "../ui/state/reducer.js";
|
|
50
|
+
import type {
|
|
51
|
+
AppState,
|
|
52
|
+
Notice,
|
|
53
|
+
NoticeLine,
|
|
54
|
+
NoticeTone,
|
|
55
|
+
} from "../ui/state/types.js";
|
|
56
|
+
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
// Builders
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
let nextId = 1;
|
|
62
|
+
function notice(
|
|
63
|
+
text: string,
|
|
64
|
+
tone: NoticeTone = "success",
|
|
65
|
+
expiresAt = 10_000,
|
|
66
|
+
): Notice {
|
|
67
|
+
return { id: nextId++, text, tone, expiresAt };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function stateWith(notices: Notice[] = []): AppState {
|
|
71
|
+
return { ...initialState, notices };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function notify(state: AppState, n: Notice): AppState {
|
|
75
|
+
return appReducer(state, { type: "NOTIFY", notice: n });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function activation(
|
|
79
|
+
overrides: Partial<ActivationResult> = {},
|
|
80
|
+
): ActivationResult {
|
|
81
|
+
return {
|
|
82
|
+
profileId: "magus-main",
|
|
83
|
+
displaced: [],
|
|
84
|
+
skills: { skills: {}, installed: [], removed: [], failed: [] },
|
|
85
|
+
conversion: {
|
|
86
|
+
converted: false,
|
|
87
|
+
managed: {},
|
|
88
|
+
restored: [],
|
|
89
|
+
conflicts: [],
|
|
90
|
+
keptAt: null,
|
|
91
|
+
},
|
|
92
|
+
tracked: [],
|
|
93
|
+
keptAt: null,
|
|
94
|
+
...overrides,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The spec's own example: a file moved aside into the backup folder. */
|
|
99
|
+
const DISPLACED = [
|
|
100
|
+
{
|
|
101
|
+
link: ".claude/settings.json",
|
|
102
|
+
savedTo: ".claude/.magus-backups/2026-09-28T04-06-00-138Z/settings.json",
|
|
103
|
+
},
|
|
104
|
+
];
|
|
105
|
+
|
|
106
|
+
const FORBIDDEN = [
|
|
107
|
+
".magus-backups",
|
|
108
|
+
"plugin.json",
|
|
109
|
+
"settings.json",
|
|
110
|
+
"installed_plugins",
|
|
111
|
+
"registry",
|
|
112
|
+
"marker",
|
|
113
|
+
];
|
|
114
|
+
|
|
115
|
+
/** `allowed`: file names the user must act on, which rule 4 permits naming. */
|
|
116
|
+
function expectUserTerms(
|
|
117
|
+
lines: readonly NoticeLine[],
|
|
118
|
+
allowed: readonly string[] = [],
|
|
119
|
+
): void {
|
|
120
|
+
for (const line of lines) {
|
|
121
|
+
expect(line.text.length).toBeGreaterThan(0);
|
|
122
|
+
expect(line.text).not.toContain("\n");
|
|
123
|
+
expect(["success", "warning"]).toContain(line.tone);
|
|
124
|
+
for (const word of FORBIDDEN.filter((w) => !allowed.includes(w))) {
|
|
125
|
+
expect({ text: line.text, leaks: line.text.includes(word) }).toEqual({
|
|
126
|
+
text: line.text,
|
|
127
|
+
leaks: false,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
// REQ-3 reducer
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
describe("REQ-3 reducer: NOTIFY", () => {
|
|
138
|
+
test("REQ-3a adds the notice to an empty banner", () => {
|
|
139
|
+
const n = notice("Profile saved");
|
|
140
|
+
|
|
141
|
+
const next = notify(stateWith(), n);
|
|
142
|
+
|
|
143
|
+
expect(next.notices).toEqual([n]);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("REQ-3a keeps earlier distinct notices, oldest first", () => {
|
|
147
|
+
const a = notice("first");
|
|
148
|
+
const b = notice("second", "warning");
|
|
149
|
+
|
|
150
|
+
const next = notify(notify(stateWith(), a), b);
|
|
151
|
+
|
|
152
|
+
expect(next.notices).toEqual([a, b]);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("REQ-3b the same text replaces the earlier notice instead of stacking", () => {
|
|
156
|
+
const first = notice('Using profile "magus-main"', "success", 1_000);
|
|
157
|
+
const again = notice('Using profile "magus-main"', "success", 9_000);
|
|
158
|
+
|
|
159
|
+
const next = notify(notify(stateWith(), first), again);
|
|
160
|
+
|
|
161
|
+
expect(next.notices).toEqual([again]);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("REQ-3b same text replaces while other notices stay", () => {
|
|
165
|
+
const other = notice("other");
|
|
166
|
+
const first = notice("repeat");
|
|
167
|
+
const again = notice("repeat", "warning", 20_000);
|
|
168
|
+
|
|
169
|
+
const next = notify(notify(notify(stateWith(), other), first), again);
|
|
170
|
+
|
|
171
|
+
expect(next.notices.filter((n) => n.text === "repeat")).toEqual([again]);
|
|
172
|
+
expect(next.notices.map((n) => n.text).sort()).toEqual(["other", "repeat"]);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("REQ-3c MAX_NOTICES is 4", () => {
|
|
176
|
+
expect(MAX_NOTICES).toBe(4);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("REQ-3c a fifth distinct notice drops the oldest one", () => {
|
|
180
|
+
const five = ["one", "two", "three", "four", "five"].map((t) => notice(t));
|
|
181
|
+
|
|
182
|
+
const next = five.reduce(notify, stateWith());
|
|
183
|
+
|
|
184
|
+
expect(next.notices).toEqual(five.slice(1));
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
describe("REQ-3 reducer: EXPIRE_NOTICES", () => {
|
|
189
|
+
test("REQ-3d removes only the notices whose time has passed", () => {
|
|
190
|
+
const expired = notice("old", "success", 1_000);
|
|
191
|
+
const live = notice("new", "warning", 5_000);
|
|
192
|
+
|
|
193
|
+
const next = appReducer(stateWith([expired, live]), {
|
|
194
|
+
type: "EXPIRE_NOTICES",
|
|
195
|
+
now: 3_000,
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
expect(next.notices).toEqual([live]);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("REQ-3d removes every notice once all have expired", () => {
|
|
202
|
+
const next = appReducer(
|
|
203
|
+
stateWith([notice("a", "success", 1_000), notice("b", "warning", 2_000)]),
|
|
204
|
+
{ type: "EXPIRE_NOTICES", now: 50_000 },
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
expect(next.notices).toEqual([]);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test("REQ-3d returns the very same state object when nothing expired", () => {
|
|
211
|
+
const state = stateWith([notice("a", "success", 5_000)]);
|
|
212
|
+
|
|
213
|
+
const next = appReducer(state, { type: "EXPIRE_NOTICES", now: 1_000 });
|
|
214
|
+
|
|
215
|
+
expect(next).toBe(state);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test("REQ-3d returns the very same state object when there are no notices", () => {
|
|
219
|
+
const state = stateWith();
|
|
220
|
+
|
|
221
|
+
const next = appReducer(state, { type: "EXPIRE_NOTICES", now: 1_000 });
|
|
222
|
+
|
|
223
|
+
expect(next).toBe(state);
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
describe("REQ-3 durations", () => {
|
|
228
|
+
test("REQ-3e a warning stays visible longer than a success", () => {
|
|
229
|
+
expect(NOTICE_DURATION_MS.warning).toBeGreaterThan(
|
|
230
|
+
NOTICE_DURATION_MS.success,
|
|
231
|
+
);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test("REQ-3e a success disappears within a few seconds", () => {
|
|
235
|
+
expect(NOTICE_DURATION_MS.success).toBeGreaterThan(0);
|
|
236
|
+
expect(NOTICE_DURATION_MS.success).toBeLessThanOrEqual(10_000);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test("REQ-3e posted together, the success expires while the warning is still shown", () => {
|
|
240
|
+
const posted = 100_000;
|
|
241
|
+
const ok = notice("done", "success", posted + NOTICE_DURATION_MS.success);
|
|
242
|
+
const warn = notice(
|
|
243
|
+
"careful",
|
|
244
|
+
"warning",
|
|
245
|
+
posted + NOTICE_DURATION_MS.warning,
|
|
246
|
+
);
|
|
247
|
+
|
|
248
|
+
const next = appReducer(stateWith([ok, warn]), {
|
|
249
|
+
type: "EXPIRE_NOTICES",
|
|
250
|
+
now: posted + NOTICE_DURATION_MS.success + 1,
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
expect(next.notices).toEqual([warn]);
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
// ---------------------------------------------------------------------------
|
|
258
|
+
// REQ-3 useNotify, through the real provider
|
|
259
|
+
// ---------------------------------------------------------------------------
|
|
260
|
+
|
|
261
|
+
const scratch = mkdtempSync(path.join(tmpdir(), "magus-notices-"));
|
|
262
|
+
afterAll(() => rmSync(scratch, { recursive: true, force: true }));
|
|
263
|
+
|
|
264
|
+
interface Posted {
|
|
265
|
+
text: string;
|
|
266
|
+
tone?: NoticeTone;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** Renders a provider whose child posts `posts` on mount; `seen()` reads the banner state. */
|
|
270
|
+
async function withProvider(posts: Posted[]) {
|
|
271
|
+
let current: Notice[] = [];
|
|
272
|
+
let modal: unknown = "never rendered";
|
|
273
|
+
function Probe() {
|
|
274
|
+
const notifyFn = useNotify();
|
|
275
|
+
const { state } = useApp();
|
|
276
|
+
current = state.notices;
|
|
277
|
+
modal = state.modal;
|
|
278
|
+
useEffect(() => {
|
|
279
|
+
for (const p of posts) {
|
|
280
|
+
if (p.tone === undefined) notifyFn(p.text);
|
|
281
|
+
else notifyFn(p.text, p.tone);
|
|
282
|
+
}
|
|
283
|
+
}, [notifyFn, posts]);
|
|
284
|
+
return <text>probe</text>;
|
|
285
|
+
}
|
|
286
|
+
const before = Date.now();
|
|
287
|
+
const { renderOnce, renderer } = await testRender(
|
|
288
|
+
<AppProvider initialProjectPath={scratch}>
|
|
289
|
+
<Probe />
|
|
290
|
+
</AppProvider>,
|
|
291
|
+
{ width: 40, height: 5 },
|
|
292
|
+
);
|
|
293
|
+
await act(async () => {
|
|
294
|
+
await renderOnce();
|
|
295
|
+
await renderOnce();
|
|
296
|
+
});
|
|
297
|
+
const after = Date.now();
|
|
298
|
+
return {
|
|
299
|
+
before,
|
|
300
|
+
after,
|
|
301
|
+
seen: () => current,
|
|
302
|
+
modal: () => modal,
|
|
303
|
+
destroy: () => act(async () => renderer.destroy()),
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
describe("REQ-3 useNotify", () => {
|
|
308
|
+
test("REQ-3a notify without a tone posts a success that expires after the success duration", async () => {
|
|
309
|
+
const h = await withProvider([{ text: "Plugin updated" }]);
|
|
310
|
+
|
|
311
|
+
const seen = h.seen();
|
|
312
|
+
await h.destroy();
|
|
313
|
+
|
|
314
|
+
expect(seen.map((n) => ({ text: n.text, tone: n.tone }))).toEqual([
|
|
315
|
+
{ text: "Plugin updated", tone: "success" },
|
|
316
|
+
]);
|
|
317
|
+
expect(seen[0].expiresAt).toBeGreaterThanOrEqual(
|
|
318
|
+
h.before + NOTICE_DURATION_MS.success,
|
|
319
|
+
);
|
|
320
|
+
expect(seen[0].expiresAt).toBeLessThanOrEqual(
|
|
321
|
+
h.after + NOTICE_DURATION_MS.success,
|
|
322
|
+
);
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
test("REQ-3e a warning notice expires after the longer warning duration", async () => {
|
|
326
|
+
const h = await withProvider([
|
|
327
|
+
{ text: "typescript-lsp isn't downloaded yet", tone: "warning" },
|
|
328
|
+
]);
|
|
329
|
+
|
|
330
|
+
const seen = h.seen();
|
|
331
|
+
await h.destroy();
|
|
332
|
+
|
|
333
|
+
expect(seen).toHaveLength(1);
|
|
334
|
+
expect(seen[0].tone).toBe("warning");
|
|
335
|
+
expect(seen[0].expiresAt).toBeGreaterThanOrEqual(
|
|
336
|
+
h.before + NOTICE_DURATION_MS.warning,
|
|
337
|
+
);
|
|
338
|
+
expect(seen[0].expiresAt).toBeLessThanOrEqual(
|
|
339
|
+
h.after + NOTICE_DURATION_MS.warning,
|
|
340
|
+
);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
test("REQ-3b notifying the same text twice shows it once", async () => {
|
|
344
|
+
const h = await withProvider([{ text: "Saved" }, { text: "Saved" }]);
|
|
345
|
+
|
|
346
|
+
const seen = h.seen();
|
|
347
|
+
await h.destroy();
|
|
348
|
+
|
|
349
|
+
expect(seen.map((n) => n.text)).toEqual(["Saved"]);
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
test("REQ-3 a notice never occupies the modal slot", async () => {
|
|
353
|
+
const h = await withProvider([{ text: "Done", tone: "warning" }]);
|
|
354
|
+
|
|
355
|
+
const seen = h.seen();
|
|
356
|
+
const modal = h.modal();
|
|
357
|
+
await h.destroy();
|
|
358
|
+
|
|
359
|
+
expect(seen).toHaveLength(1);
|
|
360
|
+
expect(modal).toBeNull();
|
|
361
|
+
});
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
// ---------------------------------------------------------------------------
|
|
365
|
+
// REQ-5 activationNotices
|
|
366
|
+
// ---------------------------------------------------------------------------
|
|
367
|
+
|
|
368
|
+
describe("REQ-5 activationNotices", () => {
|
|
369
|
+
test('REQ-5a a clean activation of magus-main is one success line: Using profile "magus-main"', () => {
|
|
370
|
+
const lines = activationNotices(activation());
|
|
371
|
+
|
|
372
|
+
expect(lines).toHaveLength(1);
|
|
373
|
+
expect(lines[0].tone).toBe("success");
|
|
374
|
+
expect(lines[0].text).toContain('Using profile "magus-main"');
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
test("REQ-5a the success line names the activated profile, whatever it is", () => {
|
|
378
|
+
const lines = activationNotices(activation({ profileId: "frontend" }));
|
|
379
|
+
|
|
380
|
+
const success = lines.filter((l) => l.tone === "success");
|
|
381
|
+
expect(
|
|
382
|
+
success.some((l) => l.text.includes('Using profile "frontend"')),
|
|
383
|
+
).toBe(true);
|
|
384
|
+
expect(lines.some((l) => l.text.includes("magus-main"))).toBe(false);
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
test("REQ-5b files moved aside at activation add nothing to the notices", () => {
|
|
388
|
+
const clean = activationNotices(activation());
|
|
389
|
+
|
|
390
|
+
const withBackup = activationNotices(activation({ displaced: DISPLACED }));
|
|
391
|
+
|
|
392
|
+
expect(withBackup).toEqual(clean);
|
|
393
|
+
for (const line of withBackup) {
|
|
394
|
+
expect(line.text).not.toContain(".magus-backups");
|
|
395
|
+
expect(line.text.toLowerCase()).not.toContain("moved aside");
|
|
396
|
+
}
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
test("REQ-5c a plugin whose details are missing is a warning naming its short name and magus install", () => {
|
|
400
|
+
const lines = activationNotices(
|
|
401
|
+
activation({
|
|
402
|
+
dependencies: {
|
|
403
|
+
derived: [],
|
|
404
|
+
unreadable: ["typescript-lsp@claude-plugins-official"],
|
|
405
|
+
blocked: [],
|
|
406
|
+
},
|
|
407
|
+
}),
|
|
408
|
+
);
|
|
409
|
+
|
|
410
|
+
const warnings = lines.filter((l) => l.tone === "warning");
|
|
411
|
+
expect(warnings).toHaveLength(1);
|
|
412
|
+
expect(warnings[0].text).toContain("typescript-lsp");
|
|
413
|
+
expect(warnings[0].text).toContain("magus install");
|
|
414
|
+
expect(warnings[0].text).not.toContain("@claude-plugins-official");
|
|
415
|
+
expect(warnings[0].text).not.toContain("plugin.json");
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
test("REQ-5c a missing plugin still reports the profile as in use", () => {
|
|
419
|
+
const lines = activationNotices(
|
|
420
|
+
activation({
|
|
421
|
+
dependencies: {
|
|
422
|
+
derived: [],
|
|
423
|
+
unreadable: ["typescript-lsp@claude-plugins-official"],
|
|
424
|
+
blocked: [],
|
|
425
|
+
},
|
|
426
|
+
}),
|
|
427
|
+
);
|
|
428
|
+
|
|
429
|
+
expect(
|
|
430
|
+
lines.some(
|
|
431
|
+
(l) =>
|
|
432
|
+
l.tone === "success" && l.text.includes('Using profile "magus-main"'),
|
|
433
|
+
),
|
|
434
|
+
).toBe(true);
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
test("REQ-5c several missing plugins combine into one warning line naming each", () => {
|
|
438
|
+
const lines = activationNotices(
|
|
439
|
+
activation({
|
|
440
|
+
dependencies: {
|
|
441
|
+
derived: [],
|
|
442
|
+
unreadable: [
|
|
443
|
+
"typescript-lsp@claude-plugins-official",
|
|
444
|
+
"gopls-lsp@claude-plugins-official",
|
|
445
|
+
"code-search@magus",
|
|
446
|
+
],
|
|
447
|
+
blocked: [],
|
|
448
|
+
},
|
|
449
|
+
}),
|
|
450
|
+
);
|
|
451
|
+
|
|
452
|
+
const install = lines.filter((l) => l.text.includes("magus install"));
|
|
453
|
+
expect(install).toHaveLength(1);
|
|
454
|
+
expect(install[0].tone).toBe("warning");
|
|
455
|
+
for (const name of ["typescript-lsp", "gopls-lsp", "code-search"]) {
|
|
456
|
+
expect(install[0].text).toContain(name);
|
|
457
|
+
}
|
|
458
|
+
expect(install[0].text).not.toContain("@claude-plugins-official");
|
|
459
|
+
expect(install[0].text).not.toContain("@magus");
|
|
460
|
+
});
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
// ---------------------------------------------------------------------------
|
|
464
|
+
// REQ-1 result notices for the gitignore fix and the stale-load repair
|
|
465
|
+
// ---------------------------------------------------------------------------
|
|
466
|
+
|
|
467
|
+
describe("REQ-1 startupFixNotices", () => {
|
|
468
|
+
test("REQ-1 adding missing ignore entries, with nothing to commit, yields success notices only", () => {
|
|
469
|
+
const lines = startupFixNotices({
|
|
470
|
+
untracked: [],
|
|
471
|
+
appended: [".claude/settings.json", ".mcp.json"],
|
|
472
|
+
failed: [],
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
expect(lines.length).toBeGreaterThan(0);
|
|
476
|
+
expect(lines.every((l) => l.tone === "success")).toBe(true);
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
test("REQ-1 untracking generated files yields at least one notice", () => {
|
|
480
|
+
const lines = startupFixNotices({
|
|
481
|
+
untracked: [".claude/settings.json", ".mcp.json"],
|
|
482
|
+
appended: [".claude/settings.json", ".mcp.json"],
|
|
483
|
+
failed: [],
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
expect(lines.length).toBeGreaterThan(0);
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
test("REQ-1 a fix with a file that could not be untracked yields a warning", () => {
|
|
490
|
+
const lines = startupFixNotices({
|
|
491
|
+
untracked: [".claude/settings.json"],
|
|
492
|
+
appended: [],
|
|
493
|
+
failed: [{ path: ".mcp.json", reason: "pathspec did not match" }],
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
expect(lines.some((l) => l.tone === "warning")).toBe(true);
|
|
497
|
+
});
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
describe("REQ-1 staleLoadRepairNotices", () => {
|
|
501
|
+
test("REQ-1 every repair succeeding yields success notices only", () => {
|
|
502
|
+
const lines = staleLoadRepairNotices([
|
|
503
|
+
{ pluginId: "code-search@magus", target: "2.3.0", ok: true },
|
|
504
|
+
{ pluginId: "dev@magus", target: "3.1.0", ok: true },
|
|
505
|
+
]);
|
|
506
|
+
|
|
507
|
+
expect(lines.length).toBeGreaterThan(0);
|
|
508
|
+
expect(lines.every((l) => l.tone === "success")).toBe(true);
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
test("REQ-1 a repair that failed yields a warning", () => {
|
|
512
|
+
const lines = staleLoadRepairNotices([
|
|
513
|
+
{ pluginId: "code-search@magus", target: "2.3.0", ok: true },
|
|
514
|
+
{
|
|
515
|
+
pluginId: "dev@magus",
|
|
516
|
+
target: "3.1.0",
|
|
517
|
+
ok: false,
|
|
518
|
+
error: "permission denied",
|
|
519
|
+
},
|
|
520
|
+
]);
|
|
521
|
+
|
|
522
|
+
expect(lines.some((l) => l.tone === "warning")).toBe(true);
|
|
523
|
+
});
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
// ---------------------------------------------------------------------------
|
|
527
|
+
// REQ-4 wording: one line, the user's terms, no internals
|
|
528
|
+
// ---------------------------------------------------------------------------
|
|
529
|
+
|
|
530
|
+
describe("REQ-4 wording", () => {
|
|
531
|
+
test("REQ-4 activationNotices never names backups, plugin.json, settings.json, the registry or the marker", () => {
|
|
532
|
+
const lines = activationNotices(
|
|
533
|
+
activation({
|
|
534
|
+
displaced: DISPLACED,
|
|
535
|
+
keptAt: ".claude/.magus-backups/2026-09-28T04-06-00-138Z/_profiles",
|
|
536
|
+
skills: {
|
|
537
|
+
skills: { "api-design": "abc123" },
|
|
538
|
+
installed: ["api-design"],
|
|
539
|
+
removed: ["old-skill"],
|
|
540
|
+
failed: [{ skill: "broken-skill", reason: "download failed" }],
|
|
541
|
+
},
|
|
542
|
+
conversion: {
|
|
543
|
+
converted: true,
|
|
544
|
+
managed: { "api-design": "abc123" },
|
|
545
|
+
restored: ["my-custom-skill"],
|
|
546
|
+
conflicts: [
|
|
547
|
+
{ folder: "dup-skill", kept: "dup-skill", other: "dup-skill-2" },
|
|
548
|
+
],
|
|
549
|
+
keptAt: ".claude/.magus-backups/2026-09-28T04-06-00-138Z/_profiles",
|
|
550
|
+
},
|
|
551
|
+
absorbed: ["Added the plugin dev@magus"],
|
|
552
|
+
dependencies: {
|
|
553
|
+
derived: ["claudish@magus"],
|
|
554
|
+
unreadable: ["typescript-lsp@claude-plugins-official"],
|
|
555
|
+
blocked: [
|
|
556
|
+
{ plugin: "multimodel@magus", dependency: "claudish@magus" },
|
|
557
|
+
],
|
|
558
|
+
},
|
|
559
|
+
committedSkills: ["team-skill"],
|
|
560
|
+
releasedCommitted: ["shared-skill"],
|
|
561
|
+
}),
|
|
562
|
+
);
|
|
563
|
+
|
|
564
|
+
expect(lines.length).toBeGreaterThan(0);
|
|
565
|
+
expectUserTerms(lines);
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
test("REQ-4 startupFixNotices for added ignore entries names no file and no internals", () => {
|
|
569
|
+
const lines = startupFixNotices({
|
|
570
|
+
untracked: [],
|
|
571
|
+
appended: [".claude/settings.json", ".claude/settings.local.json"],
|
|
572
|
+
failed: [],
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
expect(lines.length).toBeGreaterThan(0);
|
|
576
|
+
expectUserTerms(lines);
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
test("REQ-4 startupFixNotices never names backups, plugin.json, the registry or the marker, even when files must be committed", () => {
|
|
580
|
+
const lines = startupFixNotices({
|
|
581
|
+
untracked: [".claude/settings.json", ".claude/settings.local.json"],
|
|
582
|
+
appended: [".claude/settings.json"],
|
|
583
|
+
failed: [{ path: ".mcp.json", reason: "pathspec did not match" }],
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
expect(lines.length).toBeGreaterThan(0);
|
|
587
|
+
expectUserTerms(lines, ["settings.json"]);
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
test("REQ-4 staleLoadRepairNotices never names settings.json, plugin.json, backups, the registry or the marker", () => {
|
|
591
|
+
const lines = staleLoadRepairNotices([
|
|
592
|
+
{ pluginId: "code-search@magus", target: "2.3.0", ok: true },
|
|
593
|
+
{
|
|
594
|
+
pluginId: "dev@magus",
|
|
595
|
+
target: "3.1.0",
|
|
596
|
+
ok: false,
|
|
597
|
+
error: "permission denied",
|
|
598
|
+
},
|
|
599
|
+
]);
|
|
600
|
+
|
|
601
|
+
expect(lines.length).toBeGreaterThan(0);
|
|
602
|
+
expectUserTerms(lines);
|
|
603
|
+
});
|
|
604
|
+
});
|