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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "magus-cli",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.7.0",
|
|
4
4
|
"description": "TUI tool for managing Claude Code plugins, MCPs, and configuration",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/main.tsx",
|
|
@@ -65,8 +65,8 @@
|
|
|
65
65
|
"typescript": "^5.6.3"
|
|
66
66
|
},
|
|
67
67
|
"optionalDependencies": {
|
|
68
|
-
"magus-cli-darwin-arm64": "7.
|
|
69
|
-
"magus-cli-darwin-x64": "7.
|
|
70
|
-
"magus-cli-linux-x64": "7.
|
|
68
|
+
"magus-cli-darwin-arm64": "7.7.0",
|
|
69
|
+
"magus-cli-darwin-x64": "7.7.0",
|
|
70
|
+
"magus-cli-linux-x64": "7.7.0"
|
|
71
71
|
}
|
|
72
72
|
}
|
|
@@ -87,7 +87,7 @@ describe("REQ-12 formatUpdateOutcome: what the TUI shows after Update it now", (
|
|
|
87
87
|
expect((outcome as { need?: unknown }).need).toBe(need);
|
|
88
88
|
});
|
|
89
89
|
|
|
90
|
-
test('REQ-12c no problems, state ok and a notice gives "updated"
|
|
90
|
+
test('REQ-12c no problems, state ok and a notice gives "updated" with one notice line, not the gate\'s working log', () => {
|
|
91
91
|
const result: ProfileStartup = {
|
|
92
92
|
need: needIn("ok"),
|
|
93
93
|
notices: ["absorbed 1 edit"],
|
|
@@ -98,12 +98,11 @@ describe("REQ-12 formatUpdateOutcome: what the TUI shows after Update it now", (
|
|
|
98
98
|
|
|
99
99
|
expect(outcome).toEqual({
|
|
100
100
|
kind: "updated",
|
|
101
|
-
|
|
102
|
-
message: expect.stringContaining("absorbed 1 edit"),
|
|
101
|
+
notice: "Profile format updated",
|
|
103
102
|
});
|
|
104
103
|
});
|
|
105
104
|
|
|
106
|
-
test('REQ-12d no problems, state ok and no notices gives "updated"
|
|
105
|
+
test('REQ-12d no problems, state ok and no notices gives the same "updated" notice', () => {
|
|
107
106
|
const result: ProfileStartup = {
|
|
108
107
|
need: needIn("ok"),
|
|
109
108
|
notices: [],
|
|
@@ -114,8 +113,7 @@ describe("REQ-12 formatUpdateOutcome: what the TUI shows after Update it now", (
|
|
|
114
113
|
|
|
115
114
|
expect(outcome).toEqual({
|
|
116
115
|
kind: "updated",
|
|
117
|
-
|
|
118
|
-
message: "This project now uses the current profile format.",
|
|
116
|
+
notice: "Profile format updated",
|
|
119
117
|
});
|
|
120
118
|
});
|
|
121
119
|
});
|
|
@@ -156,7 +154,9 @@ try {
|
|
|
156
154
|
out.outcome =
|
|
157
155
|
outcome.kind === "ask"
|
|
158
156
|
? { kind: "ask", needState: outcome.need.state }
|
|
159
|
-
:
|
|
157
|
+
: outcome.kind === "updated"
|
|
158
|
+
? { kind: "updated", notice: outcome.notice }
|
|
159
|
+
: { kind: outcome.kind, title: outcome.title, message: outcome.message };
|
|
160
160
|
} catch (e) {
|
|
161
161
|
out.error = e instanceof Error ? (e.stack ?? e.message) : String(e);
|
|
162
162
|
}
|
|
@@ -171,7 +171,8 @@ interface StartupRun {
|
|
|
171
171
|
problems: string[] | null;
|
|
172
172
|
outcome:
|
|
173
173
|
| { kind: "ask"; needState: ProfileStartupState }
|
|
174
|
-
| { kind: "updated"
|
|
174
|
+
| { kind: "updated"; notice: string }
|
|
175
|
+
| { kind: "failed"; title: string; message: string }
|
|
175
176
|
| null;
|
|
176
177
|
error: string | null;
|
|
177
178
|
}
|
|
@@ -17,7 +17,6 @@ import fs from "fs-extra";
|
|
|
17
17
|
import { startupGitignoreNeed } from "../services/gitignore-check.js";
|
|
18
18
|
import {
|
|
19
19
|
applyStartupFixes,
|
|
20
|
-
describeStartupFix,
|
|
21
20
|
loadGitignoreState,
|
|
22
21
|
} from "../services/gitignore-service.js";
|
|
23
22
|
import { writeManifest } from "../services/manifest.js";
|
|
@@ -32,7 +31,10 @@ import {
|
|
|
32
31
|
prepareProfileState,
|
|
33
32
|
} from "../services/profile-state.js";
|
|
34
33
|
import type { ProfileManifest } from "../types/index.js";
|
|
35
|
-
import {
|
|
34
|
+
import {
|
|
35
|
+
buildGitignoreModal,
|
|
36
|
+
startupFixNotices,
|
|
37
|
+
} from "../ui/hooks/useGitignoreModal.js";
|
|
36
38
|
|
|
37
39
|
const MANIFEST: ProfileManifest = {
|
|
38
40
|
version: 2,
|
|
@@ -153,9 +155,11 @@ describe("the startup fix untracks generated files in place", () => {
|
|
|
153
155
|
expect(await fs.readFile(join(project, ".gitignore"), "utf8")).toContain(
|
|
154
156
|
".claude/settings.json",
|
|
155
157
|
);
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
158
|
+
// Untracking is unfinished until the user commits, so it is a warning.
|
|
159
|
+
expect(startupFixNotices(result)).toContainEqual({
|
|
160
|
+
text: "Stopped tracking .claude/settings.json — commit the staged removal",
|
|
161
|
+
tone: "warning",
|
|
162
|
+
});
|
|
159
163
|
// Asked again on the next open: nothing generated is tracked any more.
|
|
160
164
|
expect((await startupGitignoreNeed(project))?.generated ?? []).toEqual([]);
|
|
161
165
|
});
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Key focus rule: "A key goes only to the input that had focus when the key was
|
|
3
|
+
* pressed."
|
|
4
|
+
*
|
|
5
|
+
* Written blind — from the spec and the exported signatures of KeyRouter,
|
|
6
|
+
* AppProvider/useModal, DimensionsProvider and ModalContainer only. The real
|
|
7
|
+
* providers and the real ModalContainer are mounted; keys are sent through the
|
|
8
|
+
* test renderer's mock keyboard, and the outcome is read from what the user
|
|
9
|
+
* sees (the frame) and what the caller receives (onSubmit).
|
|
10
|
+
*
|
|
11
|
+
* REQ-A1 a select modal's onSelect opens an input modal in the same Enter:
|
|
12
|
+
* that Enter shows the input and does NOT submit it.
|
|
13
|
+
* REQ-A2 the next Enter submits the input's default value ("default-2").
|
|
14
|
+
* REQ-A3 keys typed after the input opened do reach it.
|
|
15
|
+
* REQ-A4 an input modal that was already open receives typed text: "abc" +
|
|
16
|
+
* Enter submits "abc".
|
|
17
|
+
* REQ-A5 a screen-level key that opens an input modal is not typed into it:
|
|
18
|
+
* the default value is unchanged after the key.
|
|
19
|
+
*/
|
|
20
|
+
import { afterEach, describe, expect, mock, test } from "bun:test";
|
|
21
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
22
|
+
import { tmpdir } from "node:os";
|
|
23
|
+
import { join } from "node:path";
|
|
24
|
+
import { testRender } from "@opentui/react/test-utils";
|
|
25
|
+
import { act } from "react";
|
|
26
|
+
import { ModalContainer } from "../ui/components/modals/ModalContainer.js";
|
|
27
|
+
import { KeyRouter, useKeyLayer } from "../ui/input/KeyRouter.js";
|
|
28
|
+
import { AppProvider, useModal } from "../ui/state/AppContext.js";
|
|
29
|
+
import { DimensionsProvider } from "../ui/state/DimensionsContext.js";
|
|
30
|
+
import type { ModalState } from "../ui/state/types.js";
|
|
31
|
+
|
|
32
|
+
type ModalApi = ReturnType<typeof useModal>;
|
|
33
|
+
|
|
34
|
+
const SELECT_TITLE = "Pick a setup";
|
|
35
|
+
const INPUT_TITLE = "Name the new profile";
|
|
36
|
+
const INPUT_LABEL = "Profile name";
|
|
37
|
+
|
|
38
|
+
let cleanup: Array<() => Promise<void> | void> = [];
|
|
39
|
+
|
|
40
|
+
afterEach(async () => {
|
|
41
|
+
for (const fn of cleanup.reverse()) await fn();
|
|
42
|
+
cleanup = [];
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
interface Mounted {
|
|
46
|
+
modal: () => ModalApi;
|
|
47
|
+
frame: () => Promise<string>;
|
|
48
|
+
/** Run input actions, then let React and the renderer settle. */
|
|
49
|
+
send: (fn: () => void | Promise<void>) => Promise<void>;
|
|
50
|
+
mockInput: Awaited<ReturnType<typeof testRender>>["mockInput"];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** A screen that opens an input modal on its own key `e`. */
|
|
54
|
+
function ScreenWithKey({
|
|
55
|
+
onKey,
|
|
56
|
+
}: {
|
|
57
|
+
onKey: () => void;
|
|
58
|
+
}) {
|
|
59
|
+
useKeyLayer("screen", true, (key) => {
|
|
60
|
+
if (key.name === "e" && !key.ctrl && !key.meta) {
|
|
61
|
+
onKey();
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
return false;
|
|
65
|
+
});
|
|
66
|
+
return <text>screen body</text>;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function mount(
|
|
70
|
+
opts: { screenKey?: (modal: ModalApi) => void } = {},
|
|
71
|
+
): Promise<Mounted> {
|
|
72
|
+
// A throwaway project path: nothing here reads or writes a real project.
|
|
73
|
+
const project = mkdtempSync(join(tmpdir(), "key-focus-guard-"));
|
|
74
|
+
cleanup.push(() => rmSync(project, { recursive: true, force: true }));
|
|
75
|
+
|
|
76
|
+
let api: ModalApi | null = null;
|
|
77
|
+
function Harness() {
|
|
78
|
+
const modal = useModal();
|
|
79
|
+
api = modal;
|
|
80
|
+
return (
|
|
81
|
+
<box flexDirection="column" width="100%" height="100%">
|
|
82
|
+
{opts.screenKey ? (
|
|
83
|
+
<ScreenWithKey onKey={() => opts.screenKey?.(modal)} />
|
|
84
|
+
) : (
|
|
85
|
+
<text>screen body</text>
|
|
86
|
+
)}
|
|
87
|
+
<ModalContainer />
|
|
88
|
+
</box>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const { renderer, mockInput, renderOnce, captureCharFrame } =
|
|
93
|
+
await testRender(
|
|
94
|
+
<AppProvider initialProjectPath={project}>
|
|
95
|
+
<DimensionsProvider>
|
|
96
|
+
<KeyRouter>
|
|
97
|
+
<Harness />
|
|
98
|
+
</KeyRouter>
|
|
99
|
+
</DimensionsProvider>
|
|
100
|
+
</AppProvider>,
|
|
101
|
+
{ width: 100, height: 30 },
|
|
102
|
+
);
|
|
103
|
+
cleanup.push(async () => {
|
|
104
|
+
await act(async () => renderer.destroy());
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
const settle = async () => {
|
|
108
|
+
for (let i = 0; i < 3; i++) {
|
|
109
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
110
|
+
await renderOnce();
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
await act(settle);
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
mockInput,
|
|
117
|
+
modal: () => {
|
|
118
|
+
if (!api) throw new Error("useModal was never reached");
|
|
119
|
+
return api;
|
|
120
|
+
},
|
|
121
|
+
frame: async () => {
|
|
122
|
+
await act(settle);
|
|
123
|
+
return captureCharFrame();
|
|
124
|
+
},
|
|
125
|
+
send: async (fn) => {
|
|
126
|
+
await act(async () => {
|
|
127
|
+
await fn();
|
|
128
|
+
await settle();
|
|
129
|
+
});
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function inputModal(
|
|
135
|
+
defaultValue: string | undefined,
|
|
136
|
+
onSubmit: (value: string) => void,
|
|
137
|
+
): ModalState {
|
|
138
|
+
return {
|
|
139
|
+
type: "input",
|
|
140
|
+
title: INPUT_TITLE,
|
|
141
|
+
label: INPUT_LABEL,
|
|
142
|
+
...(defaultValue !== undefined ? { defaultValue } : {}),
|
|
143
|
+
onSubmit,
|
|
144
|
+
onCancel: () => {},
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Open a select whose onSelect opens the input modal, in the same handler. */
|
|
149
|
+
async function openSelectThatOpensInput(
|
|
150
|
+
m: Mounted,
|
|
151
|
+
onSelect: (value: string) => void,
|
|
152
|
+
onSubmit: (value: string) => void,
|
|
153
|
+
): Promise<void> {
|
|
154
|
+
await m.send(() => {
|
|
155
|
+
m.modal().showModal({
|
|
156
|
+
type: "select",
|
|
157
|
+
title: SELECT_TITLE,
|
|
158
|
+
message: "Which setup should this project start from?",
|
|
159
|
+
options: [
|
|
160
|
+
{ label: "Default setup", value: "default" },
|
|
161
|
+
{ label: "Empty setup", value: "empty" },
|
|
162
|
+
],
|
|
163
|
+
defaultIndex: 0,
|
|
164
|
+
onSelect: (value) => {
|
|
165
|
+
onSelect(value);
|
|
166
|
+
m.modal().showModal(inputModal("default-2", onSubmit));
|
|
167
|
+
},
|
|
168
|
+
onCancel: () => {},
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
const frame = await m.frame();
|
|
172
|
+
// Precondition: the select is on screen, the input is not.
|
|
173
|
+
expect(frame).toContain(SELECT_TITLE);
|
|
174
|
+
expect(frame).not.toContain(INPUT_TITLE);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
describe("key focus: a key goes only to the input that had focus when it was pressed", () => {
|
|
178
|
+
test("REQ-A1 one Enter on a select whose onSelect opens an input shows the input and does not submit it", async () => {
|
|
179
|
+
const m = await mount();
|
|
180
|
+
const onSelect = mock((_v: string) => {});
|
|
181
|
+
const onSubmit = mock((_v: string) => {});
|
|
182
|
+
await openSelectThatOpensInput(m, onSelect, onSubmit);
|
|
183
|
+
|
|
184
|
+
await m.send(() => m.mockInput.pressEnter());
|
|
185
|
+
|
|
186
|
+
expect(onSelect).toHaveBeenCalledTimes(1);
|
|
187
|
+
expect(onSelect).toHaveBeenCalledWith("default");
|
|
188
|
+
const frame = await m.frame();
|
|
189
|
+
expect(frame).toContain(INPUT_TITLE);
|
|
190
|
+
expect(frame).toContain("default-2");
|
|
191
|
+
expect(onSubmit).not.toHaveBeenCalled();
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test('REQ-A2 a second Enter submits the input\'s default value "default-2"', async () => {
|
|
195
|
+
const m = await mount();
|
|
196
|
+
const onSubmit = mock((_v: string) => {});
|
|
197
|
+
await openSelectThatOpensInput(m, () => {}, onSubmit);
|
|
198
|
+
|
|
199
|
+
await m.send(() => m.mockInput.pressEnter());
|
|
200
|
+
expect(onSubmit).not.toHaveBeenCalled();
|
|
201
|
+
|
|
202
|
+
await m.send(() => m.mockInput.pressEnter());
|
|
203
|
+
expect(onSubmit).toHaveBeenCalledTimes(1);
|
|
204
|
+
expect(onSubmit).toHaveBeenCalledWith("default-2");
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test('REQ-A3 keys typed after the input opened reach it: "x" then Enter submits "default-2x"', async () => {
|
|
208
|
+
const m = await mount();
|
|
209
|
+
const onSubmit = mock((_v: string) => {});
|
|
210
|
+
await openSelectThatOpensInput(m, () => {}, onSubmit);
|
|
211
|
+
|
|
212
|
+
await m.send(() => m.mockInput.pressEnter());
|
|
213
|
+
await m.send(() => m.mockInput.typeText("x"));
|
|
214
|
+
expect(await m.frame()).toContain("default-2x");
|
|
215
|
+
|
|
216
|
+
await m.send(() => m.mockInput.pressEnter());
|
|
217
|
+
expect(onSubmit).toHaveBeenCalledTimes(1);
|
|
218
|
+
expect(onSubmit).toHaveBeenCalledWith("default-2x");
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test('REQ-A4 an input modal that was already open receives typing: "abc" then Enter submits "abc"', async () => {
|
|
222
|
+
const m = await mount();
|
|
223
|
+
const onSubmit = mock((_v: string) => {});
|
|
224
|
+
await m.send(() => m.modal().showModal(inputModal(undefined, onSubmit)));
|
|
225
|
+
expect(await m.frame()).toContain(INPUT_TITLE);
|
|
226
|
+
|
|
227
|
+
await m.send(() => m.mockInput.typeText("abc"));
|
|
228
|
+
expect(await m.frame()).toContain("abc");
|
|
229
|
+
expect(onSubmit).not.toHaveBeenCalled();
|
|
230
|
+
|
|
231
|
+
await m.send(() => m.mockInput.pressEnter());
|
|
232
|
+
expect(onSubmit).toHaveBeenCalledTimes(1);
|
|
233
|
+
expect(onSubmit).toHaveBeenCalledWith("abc");
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test('REQ-A5 a screen key that opens an input modal is not typed into it: default "cfg" stays "cfg"', async () => {
|
|
237
|
+
const onSubmit = mock((_v: string) => {});
|
|
238
|
+
const opened = mock(() => {});
|
|
239
|
+
const m = await mount({
|
|
240
|
+
screenKey: (modal) => {
|
|
241
|
+
opened();
|
|
242
|
+
modal.showModal(inputModal("cfg", onSubmit));
|
|
243
|
+
},
|
|
244
|
+
});
|
|
245
|
+
expect(await m.frame()).not.toContain(INPUT_TITLE);
|
|
246
|
+
|
|
247
|
+
await m.send(() => m.mockInput.pressKey("e"));
|
|
248
|
+
|
|
249
|
+
expect(opened).toHaveBeenCalledTimes(1);
|
|
250
|
+
const frame = await m.frame();
|
|
251
|
+
expect(frame).toContain(INPUT_TITLE);
|
|
252
|
+
expect(frame).toContain("cfg");
|
|
253
|
+
expect(frame).not.toContain("cfge");
|
|
254
|
+
expect(onSubmit).not.toHaveBeenCalled();
|
|
255
|
+
|
|
256
|
+
await m.send(() => m.mockInput.pressEnter());
|
|
257
|
+
expect(onSubmit).toHaveBeenCalledTimes(1);
|
|
258
|
+
expect(onSubmit).toHaveBeenCalledWith("cfg");
|
|
259
|
+
});
|
|
260
|
+
});
|