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
@@ -0,0 +1,367 @@
1
+ /**
2
+ * Spec 7.5.4 re-review — REQ-12: a refused format update is a failure in the TUI, as in
3
+ * the CLI.
4
+ *
5
+ * Written blind: from the spec and the exported signatures of `prepareProfileStartup`,
6
+ * `ProfileStartup`, `ProfileStartupNeed`, `formatUpdateOutcome` and `FormatUpdateOutcome`
7
+ * only. `formatUpdateOutcome` is what the TUI shows after "Update it now"; `problems` is
8
+ * what the gate refused — the lines the CLI stops on.
9
+ *
10
+ * REQ-12a–12d call the pure `formatUpdateOutcome` in-process on hand-built results.
11
+ *
12
+ * REQ-12e–12g run `prepareProfileStartup` on a real git repository holding the old
13
+ * layout, in a CHILD process that starts with HOME, CLAUDE_CONFIG_DIR and XDG_* already
14
+ * pointing at temp dirs (Bun's `os.homedir()` ignores a HOME changed at runtime), with
15
+ * stdin/stdout forced non-TTY and colours off. The child's PATH carries only `bun` and
16
+ * `git`, so nothing here can reach a real `claude` CLI or the real `~/.claude`. The child
17
+ * also computes `formatUpdateOutcome` on the result and writes everything as JSON.
18
+ */
19
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
20
+ import { existsSync } from "node:fs";
21
+ import {
22
+ mkdir,
23
+ mkdtemp,
24
+ readFile,
25
+ realpath,
26
+ rm,
27
+ symlink,
28
+ writeFile,
29
+ } from "node:fs/promises";
30
+ import { tmpdir } from "node:os";
31
+ import { dirname, join } from "node:path";
32
+ import {
33
+ type ProfileStartup,
34
+ type ProfileStartupNeed,
35
+ type ProfileStartupState,
36
+ formatUpdateOutcome,
37
+ } from "../services/profile-startup.js";
38
+
39
+ const SRC = join(import.meta.dir, "..");
40
+ const MODULES = {
41
+ ansi: join(SRC, "cli", "ansi.ts"),
42
+ startup: join(SRC, "services", "profile-startup.ts"),
43
+ };
44
+
45
+ const CHILD_TIMEOUT_MS = 45_000;
46
+ const CHILD_TEST_TIMEOUT_MS = 60_000;
47
+
48
+ // ---------------------------------------------------------------------------
49
+ // Pure cases
50
+ // ---------------------------------------------------------------------------
51
+
52
+ function needIn(state: ProfileStartupState): ProfileStartupNeed {
53
+ return {
54
+ state,
55
+ adopt: null,
56
+ activate: [],
57
+ declaredDefault: null,
58
+ autoActivate: false,
59
+ manifest: { version: 2, profiles: {} },
60
+ };
61
+ }
62
+
63
+ describe("REQ-12 formatUpdateOutcome: what the TUI shows after Update it now", () => {
64
+ test('REQ-12a a problem makes the outcome "failed" with the fixed title and the problem in the message, even when the state reads ok', () => {
65
+ const result: ProfileStartup = {
66
+ need: needIn("ok"),
67
+ notices: [],
68
+ problems: ["boom"],
69
+ };
70
+
71
+ const outcome = formatUpdateOutcome(result);
72
+
73
+ expect(outcome).toEqual({
74
+ kind: "failed",
75
+ title: "Could not update the profile format",
76
+ message: expect.stringContaining("boom"),
77
+ });
78
+ });
79
+
80
+ test('REQ-12b no problems and a need still to answer gives "ask" carrying the same need object', () => {
81
+ const need = needIn("inactive");
82
+ const result: ProfileStartup = { need, notices: [], problems: [] };
83
+
84
+ const outcome = formatUpdateOutcome(result);
85
+
86
+ expect(outcome.kind).toBe("ask");
87
+ expect((outcome as { need?: unknown }).need).toBe(need);
88
+ });
89
+
90
+ test('REQ-12c no problems, state ok and a notice gives "updated" titled Profile format updated with the notice in the message', () => {
91
+ const result: ProfileStartup = {
92
+ need: needIn("ok"),
93
+ notices: ["absorbed 1 edit"],
94
+ problems: [],
95
+ };
96
+
97
+ const outcome = formatUpdateOutcome(result);
98
+
99
+ expect(outcome).toEqual({
100
+ kind: "updated",
101
+ title: "Profile format updated",
102
+ message: expect.stringContaining("absorbed 1 edit"),
103
+ });
104
+ });
105
+
106
+ test('REQ-12d no problems, state ok and no notices gives "updated" with the fixed current-format message', () => {
107
+ const result: ProfileStartup = {
108
+ need: needIn("ok"),
109
+ notices: [],
110
+ problems: [],
111
+ };
112
+
113
+ const outcome = formatUpdateOutcome(result);
114
+
115
+ expect(outcome).toEqual({
116
+ kind: "updated",
117
+ title: expect.any(String),
118
+ message: "This project now uses the current profile format.",
119
+ });
120
+ });
121
+ });
122
+
123
+ // ---------------------------------------------------------------------------
124
+ // Real-tree cases
125
+ // ---------------------------------------------------------------------------
126
+
127
+ /** mode `prepare`: prepareProfileStartup with NO options. mode `convert`: `{ convert: true }`. */
128
+ const STARTUP_CHILD = `
129
+ import { writeFileSync } from "node:fs";
130
+
131
+ const [mode, project, resultPath] = process.argv.slice(2);
132
+ const ansi = await import(${JSON.stringify(MODULES.ansi)});
133
+ const startup = await import(${JSON.stringify(MODULES.startup)});
134
+
135
+ if (typeof ansi.setColorEnabled === "function") ansi.setColorEnabled(false);
136
+ for (const stream of [process.stdin, process.stdout, process.stderr]) {
137
+ Object.defineProperty(stream, "isTTY", { value: false, configurable: true });
138
+ }
139
+
140
+ const out = {
141
+ state: null,
142
+ notices: null,
143
+ problems: null,
144
+ outcome: null,
145
+ error: null,
146
+ };
147
+ try {
148
+ const prepared =
149
+ mode === "convert"
150
+ ? await startup.prepareProfileStartup(project, { convert: true })
151
+ : await startup.prepareProfileStartup(project);
152
+ out.state = prepared.need.state;
153
+ out.notices = prepared.notices;
154
+ out.problems = Array.isArray(prepared.problems) ? prepared.problems : null;
155
+ const outcome = startup.formatUpdateOutcome(prepared);
156
+ out.outcome =
157
+ outcome.kind === "ask"
158
+ ? { kind: "ask", needState: outcome.need.state }
159
+ : { kind: outcome.kind, title: outcome.title, message: outcome.message };
160
+ } catch (e) {
161
+ out.error = e instanceof Error ? (e.stack ?? e.message) : String(e);
162
+ }
163
+ writeFileSync(resultPath, JSON.stringify(out));
164
+ process.exit(0);
165
+ `;
166
+
167
+ interface StartupRun {
168
+ state: ProfileStartupState | null;
169
+ notices: string[] | null;
170
+ /** Null when the result carried no `problems` array at all. */
171
+ problems: string[] | null;
172
+ outcome:
173
+ | { kind: "ask"; needState: ProfileStartupState }
174
+ | { kind: "updated" | "failed"; title: string; message: string }
175
+ | null;
176
+ error: string | null;
177
+ }
178
+
179
+ /** The target file the settings symlink names, when it does NOT parse (REQ-12e). */
180
+ const UNPARSEABLE_TARGET = '{"enabledPlugins": {"dev@magus": true,},}\n';
181
+ /** The same file when it parses (REQ-12f). */
182
+ const PARSEABLE_TARGET = '{"enabledPlugins":{"dev@magus":true}}\n';
183
+ const PROFILES_JSON =
184
+ '{"version":2,"profiles":{"team":{"name":"team","plugins":{"dev@magus":true}}}}\n';
185
+
186
+ const scratch: string[] = [];
187
+ let helperDir = "";
188
+ let binDir = "";
189
+
190
+ async function tempRoot(prefix: string): Promise<string> {
191
+ const dir = await realpath(await mkdtemp(join(tmpdir(), prefix)));
192
+ scratch.push(dir);
193
+ return dir;
194
+ }
195
+
196
+ beforeAll(async () => {
197
+ helperDir = await tempRoot("fuo-helpers-");
198
+ binDir = join(helperDir, "bin");
199
+ await mkdir(binDir);
200
+ await symlink(process.execPath, join(binDir, "bun"));
201
+ const git = Bun.which("git");
202
+ if (git) await symlink(git, join(binDir, "git"));
203
+ await writeFile(join(helperDir, "startup-child.ts"), STARTUP_CHILD);
204
+ });
205
+
206
+ afterAll(async () => {
207
+ for (const dir of scratch) await rm(dir, { recursive: true, force: true });
208
+ });
209
+
210
+ interface Project {
211
+ base: string;
212
+ project: string;
213
+ env: Record<string, string>;
214
+ }
215
+
216
+ /**
217
+ * A real git repository holding the old layout, everything committed:
218
+ * `.claude/profiles.json` defining `team`, `.claude/_profiles/team/settings.json` with
219
+ * `targetContent`, and `.claude/settings.json` a SYMLINK to the RELATIVE path
220
+ * `_profiles/team/settings.json`. HOME, CLAUDE_CONFIG_DIR (`<HOME>/.claude`) and XDG_*
221
+ * are temp dirs beside it.
222
+ */
223
+ async function oldLayoutProject(targetContent: string): Promise<Project> {
224
+ const base = await tempRoot("fuo-");
225
+ const home = join(base, "home");
226
+ const xdg = join(base, "xdg");
227
+ await mkdir(join(home, ".claude"), { recursive: true });
228
+ for (const kind of ["config", "cache", "data", "state"]) {
229
+ await mkdir(join(xdg, kind), { recursive: true });
230
+ }
231
+ const env = {
232
+ PATH: `${binDir}:/usr/bin:/bin`,
233
+ HOME: home,
234
+ CLAUDE_CONFIG_DIR: join(home, ".claude"),
235
+ XDG_CONFIG_HOME: join(xdg, "config"),
236
+ XDG_CACHE_HOME: join(xdg, "cache"),
237
+ XDG_DATA_HOME: join(xdg, "data"),
238
+ XDG_STATE_HOME: join(xdg, "state"),
239
+ TMPDIR: tmpdir(),
240
+ NO_COLOR: "1",
241
+ TERM: "dumb",
242
+ GIT_CONFIG_NOSYSTEM: "1",
243
+ GIT_CONFIG_GLOBAL: "/dev/null",
244
+ };
245
+ const project = join(base, "proj");
246
+ await mkdir(project, { recursive: true });
247
+ const git = (...args: string[]): void => {
248
+ const r = Bun.spawnSync({
249
+ cmd: [
250
+ "git",
251
+ "-c",
252
+ "user.email=t@t",
253
+ "-c",
254
+ "user.name=t",
255
+ "-c",
256
+ "commit.gpgsign=false",
257
+ ...args,
258
+ ],
259
+ cwd: project,
260
+ env,
261
+ });
262
+ if (r.exitCode !== 0) {
263
+ throw new Error(`git ${args.join(" ")} failed: ${r.stderr.toString()}`);
264
+ }
265
+ };
266
+ git("init", "-q");
267
+ const target = join(project, ".claude", "_profiles", "team", "settings.json");
268
+ await mkdir(dirname(target), { recursive: true });
269
+ await writeFile(target, targetContent);
270
+ await symlink(
271
+ "_profiles/team/settings.json",
272
+ join(project, ".claude", "settings.json"),
273
+ );
274
+ await writeFile(join(project, ".claude", "profiles.json"), PROFILES_JSON);
275
+ git("add", "-A");
276
+ git("commit", "-q", "-m", "init");
277
+ return { base, project, env };
278
+ }
279
+
280
+ async function runStartup(
281
+ proj: Project,
282
+ mode: "prepare" | "convert",
283
+ ): Promise<StartupRun> {
284
+ const resultPath = join(proj.base, `result-${mode}.json`);
285
+ const proc = Bun.spawn({
286
+ cmd: [
287
+ process.execPath,
288
+ "--no-env-file",
289
+ join(helperDir, "startup-child.ts"),
290
+ mode,
291
+ proj.project,
292
+ resultPath,
293
+ ],
294
+ cwd: proj.project,
295
+ env: proj.env,
296
+ stdin: "ignore",
297
+ stdout: "pipe",
298
+ stderr: "pipe",
299
+ });
300
+ const timer = setTimeout(() => proc.kill(), CHILD_TIMEOUT_MS);
301
+ const [stdout, stderr, code] = await Promise.all([
302
+ new Response(proc.stdout).text(),
303
+ new Response(proc.stderr).text(),
304
+ proc.exited,
305
+ ]);
306
+ clearTimeout(timer);
307
+ let raw: string;
308
+ try {
309
+ raw = await readFile(resultPath, "utf8");
310
+ } catch {
311
+ throw new Error(
312
+ `startup child wrote no result (exit ${code}).\nstdout:\n${stdout}\nstderr:\n${stderr}`,
313
+ );
314
+ }
315
+ return JSON.parse(raw) as StartupRun;
316
+ }
317
+
318
+ describe("REQ-12 a refused format update is a failure in the TUI, as in the CLI", () => {
319
+ test(
320
+ 'REQ-12e converting an old layout whose settings target does not parse returns a problem, a "failed" outcome, and leaves .claude/_profiles/ in place',
321
+ async () => {
322
+ const proj = await oldLayoutProject(UNPARSEABLE_TARGET);
323
+ const oldDir = join(proj.project, ".claude", "_profiles");
324
+ expect(existsSync(oldDir)).toBe(true);
325
+
326
+ const run = await runStartup(proj, "convert");
327
+
328
+ expect(run.error).toBeNull();
329
+ expect(run.problems).not.toBeNull();
330
+ expect(run.problems?.length ?? 0).toBeGreaterThan(0);
331
+ expect(run.outcome?.kind).toBe("failed");
332
+ expect(existsSync(oldDir)).toBe(true);
333
+ },
334
+ CHILD_TEST_TIMEOUT_MS,
335
+ );
336
+
337
+ test(
338
+ 'REQ-12f negative control: converting an old layout whose settings target parses returns no problems and an outcome that is not "failed"',
339
+ async () => {
340
+ const proj = await oldLayoutProject(PARSEABLE_TARGET);
341
+
342
+ const run = await runStartup(proj, "convert");
343
+
344
+ expect(run.error).toBeNull();
345
+ expect(run.problems).toEqual([]);
346
+ expect(run.outcome).not.toBeNull();
347
+ expect(run.outcome?.kind).not.toBe("failed");
348
+ },
349
+ CHILD_TEST_TIMEOUT_MS,
350
+ );
351
+
352
+ test(
353
+ 'REQ-12g without options, the unparseable old layout is asked about as "old-format", refuses nothing and converts nothing',
354
+ async () => {
355
+ const proj = await oldLayoutProject(UNPARSEABLE_TARGET);
356
+ const oldDir = join(proj.project, ".claude", "_profiles");
357
+
358
+ const run = await runStartup(proj, "prepare");
359
+
360
+ expect(run.error).toBeNull();
361
+ expect(run.state).toBe("old-format");
362
+ expect(run.problems).toEqual([]);
363
+ expect(existsSync(oldDir)).toBe(true);
364
+ },
365
+ CHILD_TEST_TIMEOUT_MS,
366
+ );
367
+ });