magus-cli 7.1.0 → 7.2.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.
@@ -0,0 +1,458 @@
1
+ /**
2
+ * What magus requires about profiles before it will work on a project.
3
+ *
4
+ * Two behaviours are pinned here, and both were defects:
5
+ *
6
+ * 1. The TUI could show a Profiles tab listing two profiles while the project
7
+ * ran neither, and say nothing.
8
+ * 2. Both surfaces offered a way to carry on anyway — the TUI as a "Not now"
9
+ * row, the CLI as "using X for this run only". With no active profile there
10
+ * is no profile state to read and nowhere to write updates, so that let the
11
+ * user into a magus that could not do its job.
12
+ *
13
+ * And one thing is pinned by its ABSENCE: nothing compares the manifest to
14
+ * `enabledPlugins`. See the header of `services/profile-startup.ts`.
15
+ */
16
+
17
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
18
+ import { mkdtemp, rm } from "node:fs/promises";
19
+ import { tmpdir } from "node:os";
20
+ import { join } from "node:path";
21
+ import fs from "fs-extra";
22
+ import { validateManifest } from "../services/manifest.js";
23
+ import { materializeAndActivate } from "../services/profile-activate.js";
24
+ import { adoptCurrentProject } from "../services/profile-adopt.js";
25
+ import {
26
+ ACTIVATE_PREFIX,
27
+ ADOPT_VALUE,
28
+ QUIT_VALUE,
29
+ buildProfileOptions,
30
+ defaultOptionIndex,
31
+ describeNeed,
32
+ detectProfileStartupNeed,
33
+ needsNothing,
34
+ parseProfileChoice,
35
+ } from "../services/profile-startup.js";
36
+ import { listProfiles } from "../services/profiles.js";
37
+ import type { ProfileManifest } from "../types/index.js";
38
+ import {
39
+ buildProfileStartupModal,
40
+ describeFailure,
41
+ } from "../ui/hooks/useProfileStartupModal.js";
42
+
43
+ const LEAN: ProfileManifest = {
44
+ version: 2,
45
+ profiles: {
46
+ lean: { name: "Lean", plugins: { "terminal@magus": "latest" } },
47
+ full: {
48
+ name: "Full",
49
+ plugins: { "terminal@magus": "latest", "dev@magus": "latest" },
50
+ },
51
+ },
52
+ };
53
+
54
+ async function enable(project: string, ids: string[]): Promise<void> {
55
+ await fs.outputJson(join(project, ".claude", "settings.json"), {
56
+ enabledPlugins: Object.fromEntries(ids.map((id) => [id, true])),
57
+ });
58
+ }
59
+
60
+ describe("detectProfileStartupNeed", () => {
61
+ let project: string;
62
+ beforeEach(async () => {
63
+ project = await mkdtemp(join(tmpdir(), "startup-"));
64
+ });
65
+ afterEach(async () => {
66
+ await rm(project, { recursive: true, force: true });
67
+ });
68
+
69
+ test("no manifest — unmigrated, offers to adopt, nothing to activate", async () => {
70
+ await enable(project, ["dev@magus"]);
71
+
72
+ const need = await detectProfileStartupNeed(project);
73
+
74
+ expect(need.state).toBe("unmigrated");
75
+ expect(need.adopt).toBe("default");
76
+ expect(need.activate).toEqual([]);
77
+ expect(needsNothing(need)).toBe(false);
78
+ });
79
+
80
+ test("profiles but none active — inactive, offers both routes", async () => {
81
+ await fs.outputJson(join(project, ".claude", "profiles.json"), LEAN);
82
+ await enable(project, ["terminal@magus"]);
83
+
84
+ const need = await detectProfileStartupNeed(project);
85
+
86
+ expect(need.state).toBe("inactive");
87
+ expect(need.activate.map((p) => p.id).sort()).toEqual(["full", "lean"]);
88
+ // Adopting stays available so a manifest full of stale profiles is not a
89
+ // dead end where activating a wrong one is the only way forward.
90
+ expect(need.adopt).toBe("default");
91
+ });
92
+
93
+ test("a profile is active — asks nothing, whatever is enabled", async () => {
94
+ await fs.outputJson(join(project, ".claude", "profiles.json"), LEAN);
95
+ await enable(project, ["terminal@magus"]);
96
+ await materializeAndActivate(LEAN, "lean", project, () => {});
97
+
98
+ expect(
99
+ needsNothing(await detectProfileStartupNeed(project)).valueOf(),
100
+ ).toBe(true);
101
+ });
102
+
103
+ test("an active profile whose generated settings were edited still asks nothing", async () => {
104
+ // THE point of dropping the comparison. `settings.json` is a symlink into
105
+ // `_profiles/lean/`, which is generated FROM the manifest — so judging the
106
+ // manifest against it reads the manifest's own output. The old code offered
107
+ // to create a third profile describing the corrupted file; the right answer
108
+ // is to re-materialize, and the wrong answer is what made magus behave as
109
+ // though the profileless world were still live.
110
+ await fs.outputJson(join(project, ".claude", "profiles.json"), LEAN);
111
+ await enable(project, ["terminal@magus"]);
112
+ await materializeAndActivate(LEAN, "lean", project, () => {});
113
+ await fs.outputJson(
114
+ join(project, ".claude", "_profiles", "lean", "settings.json"),
115
+ { enabledPlugins: { "terminal@magus": true, "brand-new@magus": true } },
116
+ );
117
+
118
+ const need = await detectProfileStartupNeed(project);
119
+
120
+ expect(need.state).toBe("ok");
121
+ expect(need.adopt).toBeNull();
122
+ });
123
+
124
+ test("what is enabled never changes the answer", async () => {
125
+ // Nothing reads enabledPlugins, so two wildly different settings files on
126
+ // the same manifest must produce identical needs.
127
+ await fs.outputJson(join(project, ".claude", "profiles.json"), LEAN);
128
+
129
+ await enable(project, ["terminal@magus"]);
130
+ const matching = await detectProfileStartupNeed(project);
131
+ await enable(project, ["nothing-any-profile-declares@magus"]);
132
+ const mismatching = await detectProfileStartupNeed(project);
133
+
134
+ expect(mismatching.state).toBe(matching.state);
135
+ expect(mismatching.adopt).toBe(matching.adopt);
136
+ expect(mismatching.activate).toEqual(matching.activate);
137
+ });
138
+
139
+ test("listProfiles marks the active profile, and only that one", async () => {
140
+ await fs.outputJson(join(project, ".claude", "profiles.json"), LEAN);
141
+ await enable(project, ["terminal@magus"]);
142
+
143
+ const before = await listProfiles(project);
144
+ expect(before.every((p) => p.active !== true)).toBe(true);
145
+
146
+ await materializeAndActivate(LEAN, "lean", project, () => {});
147
+ const after = await listProfiles(project);
148
+
149
+ expect(after.find((p) => p.id === "lean")?.active).toBe(true);
150
+ expect(after.find((p) => p.id === "full")?.active).toBe(false);
151
+ });
152
+
153
+ test("a declared default is applied without asking on a fresh clone", async () => {
154
+ // The whole point of the field: the repo already answered, and there is
155
+ // nothing of this developer's to replace, so nobody is asked.
156
+ await fs.outputJson(join(project, ".claude", "profiles.json"), {
157
+ ...LEAN,
158
+ default: "full",
159
+ });
160
+ // No settings.json, no .mcp.json — nothing activation would displace.
161
+
162
+ const need = await detectProfileStartupNeed(project);
163
+
164
+ expect(need.state).toBe("inactive");
165
+ expect(need.declaredDefault).toBe("full");
166
+ expect(need.autoActivate).toBe(true);
167
+ });
168
+
169
+ test("a declared default is NOT applied when there is work to replace", async () => {
170
+ await fs.outputJson(join(project, ".claude", "profiles.json"), {
171
+ ...LEAN,
172
+ default: "full",
173
+ });
174
+ await enable(project, ["dev@magus"]);
175
+
176
+ const need = await detectProfileStartupNeed(project);
177
+
178
+ // Still declared, so it is preselected — but never automatic. Replacing
179
+ // someone's working files does not happen without them saying so.
180
+ expect(need.declaredDefault).toBe("full");
181
+ expect(need.autoActivate).toBe(false);
182
+ });
183
+
184
+ test("a default naming a profile that does not exist falls back to asking", async () => {
185
+ await fs.outputJson(join(project, ".claude", "profiles.json"), {
186
+ ...LEAN,
187
+ default: "renamed-away",
188
+ });
189
+
190
+ const need = await detectProfileStartupNeed(project);
191
+
192
+ // validateManifest reports the typo; detection must stay usable meanwhile
193
+ // rather than auto-activating a profile that is not there.
194
+ expect(need.declaredDefault).toBeNull();
195
+ expect(need.autoActivate).toBe(false);
196
+ });
197
+
198
+ test("adopting records the default, and never overwrites one", async () => {
199
+ await enable(project, ["dev@magus"]);
200
+
201
+ const first = await adoptCurrentProject(
202
+ project,
203
+ undefined,
204
+ undefined,
205
+ "default",
206
+ );
207
+ expect(first.manifest.default).toBe("default");
208
+
209
+ // A second adoption into a manifest that already declares one must leave
210
+ // the team's committed choice alone — one developer saving local state is
211
+ // not a decision about what everyone else gets.
212
+ const second = await adoptCurrentProject(
213
+ project,
214
+ undefined,
215
+ { ...LEAN, default: "lean" },
216
+ "default",
217
+ );
218
+ expect(second.manifest.default).toBe("lean");
219
+ });
220
+
221
+ test("adopt never proposes an id that already exists", async () => {
222
+ await fs.outputJson(join(project, ".claude", "profiles.json"), {
223
+ version: 2,
224
+ profiles: {
225
+ default: { name: "Default", plugins: { "terminal@magus": "latest" } },
226
+ },
227
+ });
228
+ await enable(project, ["something-else@magus"]);
229
+
230
+ expect((await detectProfileStartupNeed(project)).adopt).toBe("default-2");
231
+ });
232
+ });
233
+
234
+ describe("what the user is offered", () => {
235
+ const manifest = LEAN;
236
+ const unmigrated = {
237
+ state: "unmigrated" as const,
238
+ adopt: "default",
239
+ activate: [],
240
+ manifest: { version: 2 as const, profiles: {} },
241
+ };
242
+ const inactive = {
243
+ state: "inactive" as const,
244
+ adopt: "default",
245
+ activate: [
246
+ { id: "lean", name: "Lean", pluginCount: 1 },
247
+ { id: "full", name: "Full", pluginCount: 2 },
248
+ ],
249
+ manifest,
250
+ };
251
+
252
+ test("there is NO way to carry on without a profile", () => {
253
+ for (const need of [unmigrated, inactive]) {
254
+ const values = buildProfileOptions(need).map((o) => o.value);
255
+ // The old "Not now" / "for this run only" escape. Its absence is the
256
+ // behaviour, so it is asserted rather than assumed.
257
+ expect(values).not.toContain("dismiss");
258
+ // ...but leaving must always be reachable, and visibly so.
259
+ expect(values[values.length - 1]).toBe(QUIT_VALUE);
260
+ }
261
+ });
262
+
263
+ test("no option claims to know what is currently installed", () => {
264
+ // The "matches now" badge compared declared plugins against enabled ones.
265
+ // It is gone with the comparison; a row may only describe its own profile.
266
+ for (const option of buildProfileOptions(inactive)) {
267
+ expect(option.badge).toBeUndefined();
268
+ }
269
+ });
270
+
271
+ test("activate rows carry the profile's own plugin count", () => {
272
+ const options = buildProfileOptions(inactive);
273
+ expect(options[1]?.meta).toBe("1 plugin");
274
+ expect(options[2]?.meta).toBe("2 plugins");
275
+ });
276
+
277
+ test("each state is titled as the thing that is actually wrong", () => {
278
+ expect(describeNeed(unmigrated).title).toBe("magus uses profiles");
279
+ expect(describeNeed(inactive).title).toBe("No profile selected");
280
+ });
281
+
282
+ test("the unmigrated message reassures instead of warning", () => {
283
+ // Adopting an unmigrated project captures what is already present, so it
284
+ // must NOT carry the overwrite warning the inactive state needs.
285
+ const { message } = describeNeed(unmigrated);
286
+
287
+ expect(message).toContain("nothing about what you run changes");
288
+ expect(message).not.toContain("overwritten");
289
+ });
290
+
291
+ test("the inactive message names what is lost, in concrete terms", () => {
292
+ // Two defects this replaced. It explained magus's internals ("the manifest
293
+ // is not in effect", "nowhere to write updates"), and then it went abstract
294
+ // ("the setup you are running belongs to no profile") — neither said that
295
+ // switching OVERWRITES the plugins and skills currently turned on, which is
296
+ // the only fact that makes the save option worth choosing.
297
+ const { message } = describeNeed(inactive);
298
+
299
+ expect(message).toContain("plugins and skills turned on here");
300
+ expect(message).toContain("not saved in any profile");
301
+ expect(message).toContain("overwritten");
302
+
303
+ // Neither the internal vocabulary nor the abstraction comes back. "backup"
304
+ // is banned here too: promising that nothing is deleted raises a question
305
+ // the reader did not have and implies data loss, when what changes is
306
+ // settings. Where the files went is reported AFTER the switch instead.
307
+ for (const banned of [
308
+ "manifest",
309
+ "profile state",
310
+ "declares profiles",
311
+ "setup",
312
+ "belongs to",
313
+ "backup",
314
+ "deleted",
315
+ ]) {
316
+ expect(message).not.toContain(banned);
317
+ }
318
+ });
319
+
320
+ test("the declared profile's row is the one under the cursor", () => {
321
+ const withDefault = { ...inactive, declaredDefault: "full" };
322
+ // options: [save, Activate Lean, Activate Full, Quit]
323
+ expect(defaultOptionIndex(withDefault)).toBe(2);
324
+
325
+ // Nothing declared: start on the save row, the only option that keeps
326
+ // what is turned on.
327
+ expect(defaultOptionIndex(inactive)).toBe(0);
328
+ // Declared but absent from the offered list — never point past the end.
329
+ expect(defaultOptionIndex({ ...inactive, declaredDefault: "ghost" })).toBe(
330
+ 0,
331
+ );
332
+ });
333
+
334
+ test("a default naming no profile is a manifest error", () => {
335
+ const errors = validateManifest({ ...LEAN, default: "ghost" });
336
+ expect(errors.map((e) => e.path)).toContain("default");
337
+
338
+ // A valid one is not.
339
+ expect(validateManifest({ ...LEAN, default: "lean" })).toEqual([]);
340
+ });
341
+
342
+ test("the save option says what it preserves", () => {
343
+ // "Create <id> from this project" described the mechanism. The reason to
344
+ // pick this row is that it is the only one that keeps what is turned on.
345
+ expect(buildProfileOptions(inactive)[0]?.label).toBe(
346
+ 'Save what you have as "default"',
347
+ );
348
+ });
349
+
350
+ test("a value maps to the action it names", () => {
351
+ expect(parseProfileChoice(ADOPT_VALUE, inactive)).toEqual({
352
+ kind: "adopt",
353
+ id: "default",
354
+ });
355
+ expect(parseProfileChoice(`${ACTIVATE_PREFIX}lean`, inactive)).toEqual({
356
+ kind: "activate",
357
+ id: "lean",
358
+ });
359
+ expect(parseProfileChoice(QUIT_VALUE, inactive)).toBeNull();
360
+ });
361
+
362
+ test("a value that names nothing real cannot act", () => {
363
+ // adopt with nothing to adopt, and activate naming a profile not offered.
364
+ expect(
365
+ parseProfileChoice(ADOPT_VALUE, { ...inactive, adopt: null }),
366
+ ).toBeNull();
367
+ expect(parseProfileChoice(`${ACTIVATE_PREFIX}ghost`, inactive)).toBeNull();
368
+ });
369
+ });
370
+
371
+ describe("a failure that already moved something", () => {
372
+ const at = (name: string) => ({
373
+ link: `/p/.claude/${name}`,
374
+ savedTo: `/p/.claude/.magus-backups/S/${name}`,
375
+ });
376
+
377
+ test("a true no-op says so", () => {
378
+ const msg = describeFailure(new Error("boom"), [], "/p", null);
379
+ expect(msg).toContain("boom");
380
+ expect(msg).toContain("exactly as it was");
381
+ });
382
+
383
+ test("a partial failure never claims nothing happened", () => {
384
+ // Activation moves up to four artifacts in sequence and settings.json goes
385
+ // FIRST, so a throw on the second leaves it already in the backup. The old
386
+ // message said "Nothing was activated" unconditionally — false about the
387
+ // user's own file, and `.magus-backups/` is gitignored, so git status
388
+ // showed the deletion with no sign of the copy.
389
+ const msg = describeFailure(
390
+ new Error("EACCES: skills"),
391
+ [at("settings.json")],
392
+ "/p",
393
+ "lean",
394
+ );
395
+
396
+ expect(msg).not.toContain("Nothing was moved");
397
+ expect(msg).not.toContain("exactly as it was");
398
+ // It must name where the file went, project-relative.
399
+ expect(msg).toContain(".claude/settings.json");
400
+ expect(msg).toContain(".claude/.magus-backups/S/settings.json");
401
+ });
402
+
403
+ test("it warns against the re-attempt that would capture an empty set", () => {
404
+ // Re-choosing save-what-you-have runs adoption again, and adoption reads
405
+ // settings.json — now in the backup. It would capture an empty plugin set
406
+ // into a second profile and call that the user's setup.
407
+ const msg = describeFailure(
408
+ new Error("boom"),
409
+ [at("settings.json")],
410
+ "/p",
411
+ "lean",
412
+ );
413
+
414
+ expect(msg).toContain("Do NOT save-what-you-have");
415
+ expect(msg).toContain("Activating lean again is safe");
416
+ });
417
+ });
418
+
419
+ describe("the TUI modal", () => {
420
+ const inactive = {
421
+ state: "inactive" as const,
422
+ adopt: "default",
423
+ activate: [{ id: "lean", name: "Lean", pluginCount: 1 }],
424
+ manifest: LEAN,
425
+ };
426
+
427
+ test("escape and q do nothing — they cannot dismiss it", () => {
428
+ const calls: string[] = [];
429
+ const modal = buildProfileStartupModal({
430
+ need: inactive,
431
+ onChoose: (c) => calls.push(`${c.kind}:${c.id}`),
432
+ onQuit: () => calls.push("quit"),
433
+ });
434
+
435
+ if (modal.type !== "select") throw new Error("expected a select modal");
436
+ // ModalContainer routes both Escape and `q` here. Neither may leave the
437
+ // user in the app this modal exists to say is unusable.
438
+ modal.onCancel();
439
+
440
+ expect(calls).toEqual([]);
441
+ });
442
+
443
+ test("choosing routes to the action, quitting routes to quit", () => {
444
+ const calls: string[] = [];
445
+ const modal = buildProfileStartupModal({
446
+ need: inactive,
447
+ onChoose: (c) => calls.push(`${c.kind}:${c.id}`),
448
+ onQuit: () => calls.push("quit"),
449
+ });
450
+
451
+ if (modal.type !== "select") throw new Error("expected a select modal");
452
+ modal.onSelect(ADOPT_VALUE);
453
+ modal.onSelect(`${ACTIVATE_PREFIX}lean`);
454
+ modal.onSelect(QUIT_VALUE);
455
+
456
+ expect(calls).toEqual(["adopt:default", "activate:lean", "quit"]);
457
+ });
458
+ });
package/src/cli/ansi.ts CHANGED
@@ -513,6 +513,45 @@ export function badge(label: string, hex: string, ink?: string): string {
513
513
  */
514
514
  const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] as const;
515
515
 
516
+ /**
517
+ * A bordered dialog: title in the top rule, body inside, one blank row of
518
+ * breathing space top and bottom.
519
+ *
520
+ * Built on `padEnd`, which measures visible cells, so a body line carrying
521
+ * colour still aligns — padding a coloured string by `String.length` counts the
522
+ * escape bytes and shears the right border by however many bytes the colour
523
+ * cost.
524
+ *
525
+ * The border and the rule are dim and the body is not: a frame that competes
526
+ * with its own contents is why a boxed message can read as louder but say less.
527
+ */
528
+ export function dialog(
529
+ title: string,
530
+ lines: string[],
531
+ opts: { width?: number; color?: string } = {},
532
+ ): string[] {
533
+ const inner = Math.max(
534
+ opts.width ?? 0,
535
+ ...lines.map((l) => width(l)),
536
+ width(title) + 4,
537
+ );
538
+ const accent = opts.color;
539
+ const paint = (s: string) => (accent ? fg(accent, s) : s);
540
+
541
+ // Every row must be `inner + 4` cells: corner, space, body, space, corner.
542
+ // The head spends 2 on "╭─" and `width(title) + 2` on the titled gap, so the
543
+ // rule takes the rest. Getting this off by one is invisible in source and
544
+ // obvious on screen — the right border jags one column in at the title row.
545
+ const head = `${paint("╭─")}${paint(` ${title} `)}${dim(
546
+ "─".repeat(Math.max(0, inner - width(title) - 1)),
547
+ )}${dim("╮")}`;
548
+ const foot = dim(`╰${"─".repeat(inner + 2)}╯`);
549
+ const row = (text: string) =>
550
+ `${dim("│")} ${padEnd(text, inner)} ${dim("│")}`;
551
+
552
+ return [head, row(""), ...lines.map(row), row(""), foot];
553
+ }
554
+
516
555
  export function spinner(tick: number): string {
517
556
  return SPINNER[Math.abs(Math.trunc(tick)) % SPINNER.length];
518
557
  }