magus-cli 7.4.0 → 7.5.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 (67) hide show
  1. package/README.md +9 -8
  2. package/package.json +4 -4
  3. package/src/__tests__/blind/c19-paste-routing.test.ts +252 -0
  4. package/src/__tests__/blind/c20-registry-and-file-safety.test.ts +560 -0
  5. package/src/__tests__/install-command.test.ts +4 -3
  6. package/src/__tests__/models-cli.test.ts +4 -7
  7. package/src/__tests__/models-manager.test.ts +52 -40
  8. package/src/__tests__/profile-absorb.test.ts +1138 -0
  9. package/src/__tests__/profile-command.test.ts +90 -32
  10. package/src/__tests__/profile-migration-offer.test.ts +18 -15
  11. package/src/__tests__/profile-migration.test.ts +246 -117
  12. package/src/__tests__/profile-render.test.ts +141 -0
  13. package/src/__tests__/profile-skills.test.ts +425 -0
  14. package/src/__tests__/profile-startup.test.ts +44 -14
  15. package/src/__tests__/profile-state.test.ts +795 -0
  16. package/src/__tests__/settings-file.test.ts +5 -7
  17. package/src/__tests__/stale-load-repair.test.ts +5 -4
  18. package/src/__tests__/styles-manager.test.ts +47 -74
  19. package/src/__tests__/styles-screen-state.test.ts +11 -10
  20. package/src/cli/bootstrap.ts +87 -27
  21. package/src/cli/doctor.ts +152 -44
  22. package/src/cli/install.ts +92 -127
  23. package/src/cli/models.ts +21 -1
  24. package/src/cli/profile.ts +48 -77
  25. package/src/cli/router.ts +32 -6
  26. package/src/cli/update.ts +43 -6
  27. package/src/data/gitignore-defaults.ts +3 -3
  28. package/src/services/active-closure.ts +3 -3
  29. package/src/services/claude-settings.ts +283 -125
  30. package/src/services/cli-tool-commands.ts +30 -0
  31. package/src/services/conventions-manager.ts +88 -0
  32. package/src/services/git-worktree.ts +21 -0
  33. package/src/services/gitignore-service.ts +39 -22
  34. package/src/services/manifest.ts +4 -5
  35. package/src/services/models-manager.ts +36 -73
  36. package/src/services/profile-absorb.ts +1532 -0
  37. package/src/services/profile-activate.ts +348 -96
  38. package/src/services/profile-live.ts +825 -0
  39. package/src/services/profile-render.ts +115 -0
  40. package/src/services/profile-skills.ts +462 -0
  41. package/src/services/profile-startup.ts +88 -16
  42. package/src/services/profile-state.ts +622 -0
  43. package/src/services/profiles.ts +20 -32
  44. package/src/services/resolver.ts +6 -1
  45. package/src/services/settings-file.ts +14 -8
  46. package/src/services/skills-manager.ts +14 -8
  47. package/src/services/styles-manager.ts +45 -28
  48. package/src/services/tool-probe.ts +48 -3
  49. package/src/types/index.ts +15 -9
  50. package/src/ui/App.tsx +25 -2
  51. package/src/ui/components/modals/ModalContainer.tsx +9 -1
  52. package/src/ui/hooks/useProfileStartupModal.ts +20 -15
  53. package/src/ui/input/KeyRouter.tsx +147 -23
  54. package/src/ui/input/key-dispatch.ts +48 -0
  55. package/src/ui/keymap.ts +8 -1
  56. package/src/ui/renderers/cliToolRenderers.tsx +65 -27
  57. package/src/ui/screens/CliToolsScreen.tsx +74 -13
  58. package/src/ui/screens/PluginsScreen.tsx +6 -2
  59. package/src/ui/screens/ProfilesScreen.tsx +75 -27
  60. package/src/ui/screens/StylesScreen.tsx +2 -1
  61. package/src/ui/state/types.ts +1 -1
  62. package/src/__tests__/profile-materializer.test.ts +0 -238
  63. package/src/__tests__/profile-sync.test.ts +0 -154
  64. package/src/__tests__/symlink-manager.test.ts +0 -180
  65. package/src/services/profile-materializer.ts +0 -133
  66. package/src/services/profile-sync.ts +0 -158
  67. package/src/services/symlink-manager.ts +0 -343
package/README.md CHANGED
@@ -43,7 +43,6 @@ install [profile] Install a profile's plugins, binaries, skills and
43
43
  it. No argument = every profile.
44
44
  --check Report drift only, write nothing (CI gate)
45
45
  --yes, -y Skip the confirmation prompt
46
- --force Discard unsynced local edits instead of refusing
47
46
  update [profile] Bring the ACTIVE profile up to date: install what it
48
47
  declares but this machine lacks, and advance every
49
48
  "latest" pin to the newest published version.
@@ -52,10 +51,10 @@ update [profile] Bring the ACTIVE profile up to date: install what it
52
51
  --yes, -y Skip the confirmation prompt
53
52
  profile list Show every profile; ● marks the active one
54
53
  profile show <name> Print a profile's fully resolved closure
55
- profile switch <name> Repoint the active profile — offline, no reinstall
54
+ profile switch <name> Make <name> active: render its settings.json,
55
+ .mcp.json and models.json, install its skills
56
56
  profile init Write .claude/profiles.json from this project's setup
57
- profile sync Promote local edits back into .claude/profiles.json
58
- doctor [--fix] Check binary deps, profile symlinks, conventions
57
+ doctor [--fix] Check binary deps, the active profile, conventions
59
58
 
60
59
  claude [args...] Check for plugin updates (1h cache), then run claude
61
60
  --force, -f Force the update check
@@ -248,13 +247,15 @@ written. `force-for-plugin` is never set either — that would override your own
248
247
 
249
248
  | Path | Owner | What magus does |
250
249
  |---|---|---|
251
- | `.claude/profiles.json` | you, committed | reads it; `profile sync` writes to it |
252
- | `.claude/_profiles/<name>/` | magus, gitignored | generated build output |
253
- | `.claude/settings.json` | Claude Code | in profile mode, a symlink into `_profiles/` |
250
+ | `.claude/profiles.json` | you, committed | the one source of truth; records every change to the live config |
251
+ | `.claude/profiles/active.json` | magus, gitignored | which profile is active, and what magus last wrote |
252
+ | `.claude/settings.json` | magus + Claude Code, gitignored | in profile mode, rendered from the active profile; Claude Code's edits are recorded back into it |
253
+ | `.claude/models.json` | magus, gitignored | in profile mode, rendered from the active profile's routing |
254
+ | `.claude/skills/<name>/` | magus or you | a skill the active profile lists is installed and gitignored by magus; any other folder is yours and is never touched |
254
255
  | `.claude/settings.local.json` | you, gitignored | env values collected during install |
255
256
  | `.claude/style.json` | you, committed | the project's declared style — written on apply |
256
257
  | `.claude/output-styles/composed*.md` | magus, committed | the generated style Claude Code reads |
257
- | `.mcp.json` | Claude Code | in profile mode, a symlink into `_profiles/` |
258
+ | `.mcp.json` | magus + Claude Code, gitignored | in profile mode, rendered from the active profile; Claude Code's edits are recorded back into it |
258
259
  | `~/.claude/plugins/*` | Claude Code | never written directly — always via the `claude` CLI |
259
260
 
260
261
  magus never hand-edits Claude Code's own registries
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "magus-cli",
3
- "version": "7.4.0",
3
+ "version": "7.5.0",
4
4
  "description": "TUI tool for managing Claude Code plugins, MCPs, and configuration",
5
5
  "type": "module",
6
6
  "main": "src/main.tsx",
@@ -64,8 +64,8 @@
64
64
  "typescript": "^5.6.3"
65
65
  },
66
66
  "optionalDependencies": {
67
- "magus-cli-darwin-arm64": "7.4.0",
68
- "magus-cli-darwin-x64": "7.4.0",
69
- "magus-cli-linux-x64": "7.4.0"
67
+ "magus-cli-darwin-arm64": "7.5.0",
68
+ "magus-cli-darwin-x64": "7.5.0",
69
+ "magus-cli-linux-x64": "7.5.0"
70
70
  }
71
71
  }
@@ -0,0 +1,252 @@
1
+ /**
2
+ * Blind tests — contract C19 (requirement R4.9).
3
+ *
4
+ * Written from requirements.md and architecture.md only. A terminal delivers a
5
+ * paste as ONE event, not as keys, and C19 routes that event through the same
6
+ * layer order as a keypress. The layers here are stubs that record every
7
+ * handlePaste() call and the exact text each one received, so "a lower layer
8
+ * never sees the text" and "the text is passed through unchanged" are observed
9
+ * directly rather than inferred.
10
+ */
11
+ import { describe, expect, test } from "bun:test";
12
+
13
+ const load = () => import("../../ui/input/key-dispatch.js");
14
+
15
+ type LayerId = "system" | "modal" | "input" | "screen" | "scroll" | "global";
16
+
17
+ const ORDER: LayerId[] = [
18
+ "system",
19
+ "modal",
20
+ "input",
21
+ "screen",
22
+ "scroll",
23
+ "global",
24
+ ];
25
+
26
+ interface Recorder {
27
+ /** Layer ids in the order their handlePaste() was called. */
28
+ calls: LayerId[];
29
+ /** Every (layer, text) pair a handler actually received. */
30
+ seen: { id: LayerId; text: string }[];
31
+ layers: {
32
+ id: LayerId;
33
+ active: boolean;
34
+ handlePaste(text: string): boolean;
35
+ }[];
36
+ }
37
+
38
+ /** Build one paste layer per id. `consumes[id]` decides; absent means "never consumes". */
39
+ function stack(
40
+ consumes: Partial<Record<LayerId, (text: string) => boolean>>,
41
+ active: Partial<Record<LayerId, boolean>> = {},
42
+ order: LayerId[] = ORDER,
43
+ ): Recorder {
44
+ const calls: LayerId[] = [];
45
+ const seen: { id: LayerId; text: string }[] = [];
46
+ const layers = order.map((id) => ({
47
+ id,
48
+ active: active[id] ?? true,
49
+ handlePaste(text: string): boolean {
50
+ calls.push(id);
51
+ seen.push({ id, text });
52
+ return consumes[id]?.(text) ?? false;
53
+ },
54
+ }));
55
+ return { calls, seen, layers };
56
+ }
57
+
58
+ const all = () => true;
59
+ const TEXT = "pasted search term";
60
+
61
+ describe("C19 — dispatchPaste: layer order", () => {
62
+ test("R4.9 a paste is offered to layers in LAYER_ORDER regardless of array order", async () => {
63
+ const { dispatchPaste, LAYER_ORDER } = await load();
64
+ expect([...LAYER_ORDER]).toEqual(ORDER);
65
+
66
+ const shuffled: LayerId[] = [
67
+ "global",
68
+ "screen",
69
+ "system",
70
+ "scroll",
71
+ "input",
72
+ "modal",
73
+ ];
74
+ const r = stack({}, {}, shuffled);
75
+ expect(dispatchPaste(r.layers, TEXT)).toBeNull();
76
+ expect(r.calls).toEqual(ORDER);
77
+ });
78
+
79
+ test("R4.9 with every layer consuming in reversed array order, the topmost layer wins alone", async () => {
80
+ const { dispatchPaste } = await load();
81
+ const r = stack(
82
+ {
83
+ system: all,
84
+ modal: all,
85
+ input: all,
86
+ screen: all,
87
+ scroll: all,
88
+ global: all,
89
+ },
90
+ {},
91
+ [...ORDER].reverse(),
92
+ );
93
+ expect(dispatchPaste(r.layers, TEXT)).toBe("system");
94
+ expect(r.calls).toEqual(["system"]);
95
+ });
96
+
97
+ for (const [i, consumer] of ORDER.entries()) {
98
+ test(`R4.9 when ${consumer} consumes, dispatchPaste returns "${consumer}" and no lower layer sees the text`, async () => {
99
+ const { dispatchPaste } = await load();
100
+ // Every layer from the consumer down would also consume — only order decides.
101
+ const consumes: Partial<Record<LayerId, (text: string) => boolean>> = {};
102
+ for (const id of ORDER.slice(i)) consumes[id] = all;
103
+ const r = stack(consumes);
104
+ expect(dispatchPaste(r.layers, TEXT)).toBe(consumer);
105
+ expect(r.calls).toEqual(ORDER.slice(0, i + 1));
106
+ expect(r.seen.map((s) => s.id)).toEqual(ORDER.slice(0, i + 1));
107
+ });
108
+ }
109
+
110
+ test("R4.9 inactive layers are skipped: their handlePaste() is never called", async () => {
111
+ const { dispatchPaste } = await load();
112
+ const r = stack(
113
+ { modal: all, input: all, global: all },
114
+ { modal: false, input: false },
115
+ );
116
+ expect(dispatchPaste(r.layers, TEXT)).toBe("global");
117
+ expect(r.calls).toEqual(["system", "screen", "scroll", "global"]);
118
+ });
119
+
120
+ test("R4.9 an inactive consumer does not stop the paste: the next active consumer wins", async () => {
121
+ const { dispatchPaste } = await load();
122
+ const r = stack({ input: all, screen: all }, { input: false });
123
+ expect(dispatchPaste(r.layers, TEXT)).toBe("screen");
124
+ expect(r.calls).toEqual(["system", "modal", "screen"]);
125
+ });
126
+
127
+ test("R4.9 dispatchPaste returns null when no layer consumes, calling each active layer once", async () => {
128
+ const { dispatchPaste } = await load();
129
+ const r = stack({});
130
+ expect(dispatchPaste(r.layers, TEXT)).toBeNull();
131
+ expect(r.calls).toEqual(ORDER);
132
+ });
133
+
134
+ test("R4.9 missing layers are fine: a stack of only screen and global", async () => {
135
+ const { dispatchPaste } = await load();
136
+ const r = stack({ global: all }, {}, ["global", "screen"]);
137
+ expect(dispatchPaste(r.layers, TEXT)).toBe("global");
138
+ expect(r.calls).toEqual(["screen", "global"]);
139
+ });
140
+
141
+ test("R4.9 an empty layer list consumes nothing", async () => {
142
+ const { dispatchPaste } = await load();
143
+ expect(dispatchPaste([], TEXT)).toBeNull();
144
+ });
145
+ });
146
+
147
+ describe("C19 — dispatchPaste: a focused search box takes the paste", () => {
148
+ test("R4.9 a focused input consumes the paste and the screen below never sees the text", async () => {
149
+ const { dispatchPaste } = await load();
150
+ const typed: string[] = [];
151
+ const r = stack({
152
+ input: (text) => {
153
+ typed.push(text);
154
+ return true;
155
+ },
156
+ screen: all,
157
+ global: all,
158
+ });
159
+ expect(dispatchPaste(r.layers, TEXT)).toBe("input");
160
+ expect(typed).toEqual([TEXT]);
161
+ expect(r.calls).toEqual(["system", "modal", "input"]);
162
+ expect(r.seen.some((s) => s.id === "screen")).toBe(false);
163
+ expect(r.seen.some((s) => s.id === "global")).toBe(false);
164
+ });
165
+
166
+ test("R4.9 with no active input layer, the paste reaches the lower layers in order", async () => {
167
+ const { dispatchPaste } = await load();
168
+ const r = stack({ input: all }, { input: false });
169
+ expect(dispatchPaste(r.layers, TEXT)).toBeNull();
170
+ expect(r.calls).toEqual(["system", "modal", "screen", "scroll", "global"]);
171
+ expect(r.seen.every((s) => s.text === TEXT)).toBe(true);
172
+ });
173
+
174
+ test("R4.9 with no input layer in the stack at all, nothing consumes the paste", async () => {
175
+ const { dispatchPaste } = await load();
176
+ const r = stack({}, {}, ["system", "modal", "screen", "scroll", "global"]);
177
+ expect(dispatchPaste(r.layers, TEXT)).toBeNull();
178
+ expect(r.calls).toEqual(["system", "modal", "screen", "scroll", "global"]);
179
+ });
180
+
181
+ test("R4.9 a modal above the input takes the paste first", async () => {
182
+ const { dispatchPaste } = await load();
183
+ const r = stack({ modal: all, input: all });
184
+ expect(dispatchPaste(r.layers, TEXT)).toBe("modal");
185
+ expect(r.calls).toEqual(["system", "modal"]);
186
+ });
187
+ });
188
+
189
+ describe("C19 — dispatchPaste: empty text", () => {
190
+ test("R4.9 empty text consumes nothing and returns null", async () => {
191
+ const { dispatchPaste } = await load();
192
+ const r = stack({
193
+ system: all,
194
+ modal: all,
195
+ input: all,
196
+ screen: all,
197
+ scroll: all,
198
+ global: all,
199
+ });
200
+ expect(dispatchPaste(r.layers, "")).toBeNull();
201
+ });
202
+
203
+ test("R4.9 empty text calls no handler, not even a focused input", async () => {
204
+ const { dispatchPaste } = await load();
205
+ const r = stack({ input: all });
206
+ dispatchPaste(r.layers, "");
207
+ expect(r.calls).toEqual([]);
208
+ expect(r.seen).toEqual([]);
209
+ });
210
+ });
211
+
212
+ describe("C19 — dispatchPaste: the text passes through unchanged", () => {
213
+ const PAYLOADS: [string, string][] = [
214
+ ["a plain term", "node_modules"],
215
+ ["leading and trailing spaces", " spaced term "],
216
+ ["a single newline", "first line\nsecond line"],
217
+ ["a trailing newline", "term\n"],
218
+ ["CRLF line endings", "one\r\ntwo\r\n"],
219
+ ["several lines", "a\nb\nc\nd"],
220
+ ["a tab", "left\tright"],
221
+ ["unicode accents", "café naïve"],
222
+ ["CJK", "日本語のテキスト"],
223
+ ["emoji", "🚀 ship it 🎉"],
224
+ ["combining marks", "égalité"],
225
+ ["a path with spaces", "/tmp/some dir/file.json"],
226
+ ["JSON-ish punctuation", '{"key": "value", "n": 1}'],
227
+ ["a single space", " "],
228
+ ["a lone newline", "\n"],
229
+ ];
230
+
231
+ for (const [label, text] of PAYLOADS) {
232
+ test(`R4.9 ${label} reaches the consuming layer byte-for-byte`, async () => {
233
+ const { dispatchPaste } = await load();
234
+ const got: string[] = [];
235
+ const r = stack({
236
+ input: (t) => {
237
+ got.push(t);
238
+ return true;
239
+ },
240
+ });
241
+ expect(dispatchPaste(r.layers, text)).toBe("input");
242
+ expect(got).toEqual([text]);
243
+ });
244
+
245
+ test(`R4.9 ${label} reaches every layer it is offered to unchanged`, async () => {
246
+ const { dispatchPaste } = await load();
247
+ const r = stack({});
248
+ expect(dispatchPaste(r.layers, text)).toBeNull();
249
+ expect(r.seen).toEqual(ORDER.map((id) => ({ id, text })));
250
+ });
251
+ }
252
+ });