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.
- package/package.json +4 -4
- package/src/__tests__/blackbox-convergence.test.ts +1 -2
- package/src/__tests__/blackbox-parity.test.ts +16 -14
- package/src/__tests__/blackbox-plugin-state.test.ts +13 -15
- package/src/__tests__/cli-apply-seams.test.ts +3 -3
- package/src/__tests__/cli-profile-prompts.test.ts +1165 -0
- package/src/__tests__/content-drift.test.ts +0 -1
- package/src/__tests__/files-missing-scope.test.ts +235 -0
- package/src/__tests__/{enabled-not-installed.test.ts → files-missing.test.ts} +32 -26
- package/src/__tests__/format-update-outcome.test.ts +367 -0
- package/src/__tests__/installed-version-scope.test.tsx +425 -0
- package/src/__tests__/key-focus-guard.test.tsx +260 -0
- package/src/__tests__/mate-availability.test.ts +11 -13
- package/src/__tests__/plugin-contents.test.ts +0 -1
- package/src/__tests__/plugin-detail-scopes.test.tsx +270 -0
- package/src/__tests__/plugin-row-text.test.ts +18 -21
- package/src/__tests__/profile-command.test.ts +2 -2
- package/src/__tests__/profile-migration-offer.test.ts +5 -4
- package/src/__tests__/profile-name-on-save.test.ts +443 -0
- package/src/__tests__/profile-skills.test.ts +3 -3
- package/src/__tests__/profile-startup-nested.test.ts +329 -0
- package/src/__tests__/profile-startup-old-format.test.ts +390 -0
- package/src/__tests__/profile-startup.test.ts +1 -1
- package/src/__tests__/profile-state.test.ts +27 -22
- package/src/__tests__/prompt-eof.test.ts +245 -0
- package/src/__tests__/scope-action.test.ts +35 -38
- package/src/__tests__/scope-row-lookup.test.ts +126 -0
- package/src/__tests__/select-restore.test.ts +209 -0
- package/src/__tests__/stale-load-repair.test.ts +1 -2
- package/src/__tests__/update-apply.test.ts +18 -2
- package/src/__tests__/update-null-readback.test.ts +300 -0
- package/src/__tests__/update-plan-parity.test.ts +249 -0
- package/src/__tests__/update-plan.test.ts +66 -67
- package/src/__tests__/version-unknown-state.test.ts +38 -39
- package/src/cli/bootstrap.ts +271 -66
- package/src/cli/doctor.ts +4 -3
- package/src/cli/install.ts +83 -27
- package/src/cli/prompt.ts +19 -5
- package/src/cli/router.ts +1 -0
- package/src/cli/select.ts +168 -0
- package/src/cli/update-view.ts +120 -6
- package/src/cli/update.ts +6 -4
- package/src/services/claude-settings.ts +28 -0
- package/src/services/manifest.ts +31 -0
- package/src/services/mate-availability.ts +23 -29
- package/src/services/plugin-manager.ts +247 -105
- package/src/services/profile-absorb.ts +10 -7
- package/src/services/profile-activate.ts +41 -19
- package/src/services/profile-adopt.ts +9 -2
- package/src/services/profile-startup.ts +206 -5
- package/src/services/profile-state.ts +8 -7
- package/src/services/tracked-live-files.ts +24 -3
- package/src/services/update-engine.ts +28 -10
- package/src/services/update-plan.ts +78 -94
- package/src/ui/App.tsx +96 -56
- package/src/ui/adapters/pluginsAdapter.ts +1 -1
- package/src/ui/components/modals/ModalContainer.tsx +3 -0
- package/src/ui/components/primitives/ScopeSquares.tsx +10 -5
- package/src/ui/hooks/useGitignoreModal.ts +21 -3
- package/src/ui/hooks/useProfileStartupModal.ts +214 -15
- package/src/ui/hooks/useStaleLoadModal.ts +5 -1
- package/src/ui/input/KeyRouter.tsx +43 -2
- package/src/ui/keymap.ts +1 -1
- package/src/ui/renderers/pluginRenderers.tsx +39 -45
- package/src/ui/renderers/pluginRowText.ts +6 -2
- package/src/ui/renderers/pluginStatusText.ts +4 -3
- package/src/ui/screens/PluginsScreen.tsx +28 -41
- package/src/cli/tui-prompt.tsx +0 -143
|
@@ -26,9 +26,12 @@ import {
|
|
|
26
26
|
getAvailablePlugins,
|
|
27
27
|
getLocalMarketplacesInfo,
|
|
28
28
|
getMarketplaceFetchFailures,
|
|
29
|
+
isInstalledAnywhere,
|
|
29
30
|
isInstalledInScope,
|
|
30
31
|
refreshAllMarketplaces,
|
|
31
32
|
resolveScopeAction,
|
|
33
|
+
scopeFilesMissing,
|
|
34
|
+
versionInScope,
|
|
32
35
|
} from "../../services/plugin-manager.js";
|
|
33
36
|
import {
|
|
34
37
|
getPluginEnvRequirements,
|
|
@@ -871,17 +874,16 @@ export function PluginsScreen() {
|
|
|
871
874
|
scope: ScopeStatus | undefined,
|
|
872
875
|
desc: string,
|
|
873
876
|
) => {
|
|
874
|
-
const
|
|
875
|
-
|
|
876
|
-
let label = installed ? `● ${name}` : `○ ${name}`;
|
|
877
|
+
const ver = versionInScope(scope);
|
|
878
|
+
let label = isInstalledInScope(scope) ? `● ${name}` : `○ ${name}`;
|
|
877
879
|
label += ` (${desc})`;
|
|
878
880
|
if (ver) label += ` v${ver}`;
|
|
879
881
|
if (scopeIsBehind(status, scopeKey) && status?.target)
|
|
880
882
|
label += ` → v${status.target}`;
|
|
881
|
-
//
|
|
882
|
-
//
|
|
883
|
-
//
|
|
884
|
-
if (
|
|
883
|
+
// Installed with no version. Without saying so the row reads like a
|
|
884
|
+
// healthy install, and the user cannot tell that picking it
|
|
885
|
+
// reinstalls rather than uninstalls.
|
|
886
|
+
if (scopeFilesMissing(scope)) label += " — files missing, reinstalls";
|
|
885
887
|
return label;
|
|
886
888
|
};
|
|
887
889
|
|
|
@@ -930,9 +932,9 @@ export function PluginsScreen() {
|
|
|
930
932
|
? "Project"
|
|
931
933
|
: "Local";
|
|
932
934
|
|
|
933
|
-
//
|
|
934
|
-
//
|
|
935
|
-
//
|
|
935
|
+
// Files missing reinstalls rather than uninstalls — see
|
|
936
|
+
// resolveScopeAction. Update-versus-uninstall is the same
|
|
937
|
+
// `scopeIsBehind` the label above used.
|
|
936
938
|
const action = resolveScopeAction(
|
|
937
939
|
selectedScope,
|
|
938
940
|
scopeIsBehind(status, scopeValue as PluginScope),
|
|
@@ -1060,10 +1062,12 @@ export function PluginsScreen() {
|
|
|
1060
1062
|
};
|
|
1061
1063
|
|
|
1062
1064
|
/**
|
|
1063
|
-
* "Update all" — the SAME
|
|
1064
|
-
* update` runs
|
|
1065
|
-
*
|
|
1066
|
-
*
|
|
1065
|
+
* "Update all" — the SAME plan, the SAME selection and the SAME apply engine
|
|
1066
|
+
* `magus update` runs: every item of {@link planPluginUpdates} handed to
|
|
1067
|
+
* `applyPlugins`, which does the work each one needs (advance, repair,
|
|
1068
|
+
* reinstall missing files) and skips the rest. There used to be a different
|
|
1069
|
+
* selection here — "rows that say update" against the CLI's "what the profile
|
|
1070
|
+
* declares" — and the two surfaces disagreed about the same machine.
|
|
1067
1071
|
*
|
|
1068
1072
|
* This used to be a hand-rolled loop, and it had drifted from the CLI in
|
|
1069
1073
|
* three ways that were bugs, not choices — it touched one scope derived from
|
|
@@ -1074,20 +1078,11 @@ export function PluginsScreen() {
|
|
|
1074
1078
|
const handleUpdateAll = async () => {
|
|
1075
1079
|
if (pluginsState.plugins.status !== "success") return;
|
|
1076
1080
|
|
|
1077
|
-
//
|
|
1078
|
-
//
|
|
1079
|
-
//
|
|
1080
|
-
// the machine with silently outdated files. Broken (`stale-load`) rows are
|
|
1081
|
-
// included because the row itself now names this key — leaving them out
|
|
1082
|
-
// would make that hint a lie. `install` stays out: a plugin nothing has
|
|
1083
|
-
// installed is `s`'s business, per the note above.
|
|
1081
|
+
// Every item that needs work, by the same `pluginNeedsWork` the CLI's
|
|
1082
|
+
// apply uses: updates, content repairs, broken loads, missing files, and a
|
|
1083
|
+
// profile plugin not installed yet — exactly what `magus update` would do.
|
|
1084
1084
|
const items = [...statuses.values()]
|
|
1085
|
-
.filter(
|
|
1086
|
-
(s) =>
|
|
1087
|
-
s.action === "update" ||
|
|
1088
|
-
s.action === "repair" ||
|
|
1089
|
-
s.action === "stale-load",
|
|
1090
|
-
)
|
|
1085
|
+
.filter(pluginNeedsWork)
|
|
1091
1086
|
.sort((a, b) => a.pluginId.localeCompare(b.pluginId));
|
|
1092
1087
|
if (items.length === 0) return;
|
|
1093
1088
|
|
|
@@ -1155,12 +1150,10 @@ export function PluginsScreen() {
|
|
|
1155
1150
|
? plugin.projectScope
|
|
1156
1151
|
: plugin.localScope;
|
|
1157
1152
|
|
|
1158
|
-
//
|
|
1159
|
-
//
|
|
1160
|
-
//
|
|
1161
|
-
//
|
|
1162
|
-
// uninstall is the classifier's `scopeIsBehind`, the same rule the Enter
|
|
1163
|
-
// scope picker labels with.
|
|
1153
|
+
// A scope whose files are missing answers its key with a reinstall. It once
|
|
1154
|
+
// answered with an uninstall, removing the plugin the row was reporting as
|
|
1155
|
+
// broken. Update-versus-uninstall is the classifier's `scopeIsBehind`, the
|
|
1156
|
+
// same rule the Enter scope picker labels with.
|
|
1164
1157
|
const action = resolveScopeAction(
|
|
1165
1158
|
scopeData,
|
|
1166
1159
|
scopeIsBehind(statuses.get(plugin.id), scope),
|
|
@@ -1355,14 +1348,8 @@ export function PluginsScreen() {
|
|
|
1355
1348
|
|
|
1356
1349
|
const scopeLabel = pluginsState.scope === "global" ? "Global" : "Project";
|
|
1357
1350
|
const plugins: PluginInfo[] = shownPlugins ?? [];
|
|
1358
|
-
// "installed"
|
|
1359
|
-
//
|
|
1360
|
-
// on a screen whose every row said "not installed". Same predicate as the
|
|
1361
|
-
// row and the detail panel now.
|
|
1362
|
-
const isInstalledAnywhere = (p: PluginInfo) =>
|
|
1363
|
-
isInstalledInScope(p.userScope) ||
|
|
1364
|
-
isInstalledInScope(p.projectScope) ||
|
|
1365
|
-
isInstalledInScope(p.localScope);
|
|
1351
|
+
// "installed" means the same thing here as in the rows beside it: the one
|
|
1352
|
+
// installed-at-scope test the row and the detail panel read.
|
|
1366
1353
|
const installedCount = plugins.filter(isInstalledAnywhere).length;
|
|
1367
1354
|
// Every state count is the classifier's, over the same map the rows read,
|
|
1368
1355
|
// so a header can never count a row the list beside it does not show.
|
package/src/cli/tui-prompt.tsx
DELETED
|
@@ -1,143 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* One OpenTUI dialog, for a command that is otherwise not a TUI.
|
|
3
|
-
*
|
|
4
|
-
* `magus update` prints a report and exits; it is not the interactive app. But
|
|
5
|
-
* a question it must ask deserves the same dialog the TUI asks with, so this
|
|
6
|
-
* mounts the REAL `SelectModal` — the component tab 5 and tab 0 use — for the
|
|
7
|
-
* length of one question, then tears the renderer down and lets the command
|
|
8
|
-
* carry on printing. Nothing here re-draws a list, a border or a highlight: the
|
|
9
|
-
* component and the theme own all of that, and a second implementation is how
|
|
10
|
-
* the two surfaces drift apart.
|
|
11
|
-
*
|
|
12
|
-
* Every import of `@opentui/*` and `ui/` is DYNAMIC. `main.tsx` splits the TUI
|
|
13
|
-
* out precisely so `magus hook …`, which Claude Code runs on every matching
|
|
14
|
-
* tool call, never pays to load the renderer — a static import here would undo
|
|
15
|
-
* that for every command that imports this module's file, whether or not it
|
|
16
|
-
* ever asks a question.
|
|
17
|
-
*
|
|
18
|
-
* `screenMode: "main-screen"` keeps the dialog in the scrollback beside the
|
|
19
|
-
* report. The alternate screen would take the terminal over and then hand back
|
|
20
|
-
* a screen with no trace the question was ever asked.
|
|
21
|
-
*/
|
|
22
|
-
|
|
23
|
-
import type { SelectOption } from "../ui/state/types.js";
|
|
24
|
-
|
|
25
|
-
export interface SelectDialogInput {
|
|
26
|
-
title: string;
|
|
27
|
-
message: string;
|
|
28
|
-
options: SelectOption[];
|
|
29
|
-
/** Row to start on. Defaults to the first. */
|
|
30
|
-
defaultIndex?: number;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* Ask the user to pick one option. Resolves to the chosen value, or null when
|
|
35
|
-
* they cancel with Esc or Ctrl+C — the caller decides whether that is fatal.
|
|
36
|
-
*
|
|
37
|
-
* Falls back to `null` when stdin or stdout is not a TTY. A renderer needs both:
|
|
38
|
-
* `magus update > file` has a TTY stdin and a pipe for stdout, and mounting
|
|
39
|
-
* there writes escape sequences into the file.
|
|
40
|
-
*/
|
|
41
|
-
export async function selectDialog(
|
|
42
|
-
input: SelectDialogInput,
|
|
43
|
-
): Promise<string | null> {
|
|
44
|
-
if (process.stdin.isTTY !== true || process.stdout.isTTY !== true) {
|
|
45
|
-
return null;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
const [{ createCliRenderer }, { createRoot }, React, modals, dimensions] =
|
|
49
|
-
await Promise.all([
|
|
50
|
-
import("@opentui/core"),
|
|
51
|
-
import("@opentui/react"),
|
|
52
|
-
import("react"),
|
|
53
|
-
import("../ui/components/modals/index.js"),
|
|
54
|
-
import("../ui/state/DimensionsContext.js"),
|
|
55
|
-
]);
|
|
56
|
-
|
|
57
|
-
const renderer = await createCliRenderer({
|
|
58
|
-
screenMode: "main-screen",
|
|
59
|
-
// BOTH, so the renderer registers no handler of its own and teardown
|
|
60
|
-
// below is the only path. With either left at its default, a Ctrl+C
|
|
61
|
-
// destroys the renderer underneath this promise and it never settles.
|
|
62
|
-
exitSignals: [],
|
|
63
|
-
exitOnCtrlC: false,
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
const root = createRoot(renderer);
|
|
67
|
-
|
|
68
|
-
return await new Promise<string | null>((resolve) => {
|
|
69
|
-
// Clamped, not trusted: a caller computing an index from a list it built
|
|
70
|
-
// separately could hand over one that is out of range, and a cursor
|
|
71
|
-
// pointing past the end makes Enter resolve to null.
|
|
72
|
-
let index = Math.min(
|
|
73
|
-
Math.max(0, input.defaultIndex ?? 0),
|
|
74
|
-
Math.max(0, input.options.length - 1),
|
|
75
|
-
);
|
|
76
|
-
let done = false;
|
|
77
|
-
|
|
78
|
-
const finish = (value: string | null): void => {
|
|
79
|
-
if (done) return;
|
|
80
|
-
done = true;
|
|
81
|
-
renderer.keyInput.off("keypress", onKey);
|
|
82
|
-
root.unmount();
|
|
83
|
-
renderer.destroy();
|
|
84
|
-
resolve(value);
|
|
85
|
-
};
|
|
86
|
-
|
|
87
|
-
function onKey(key: {
|
|
88
|
-
name?: string;
|
|
89
|
-
ctrl?: boolean;
|
|
90
|
-
sequence?: string;
|
|
91
|
-
}): void {
|
|
92
|
-
if ((key.ctrl && key.name === "c") || key.name === "escape") {
|
|
93
|
-
finish(null);
|
|
94
|
-
return;
|
|
95
|
-
}
|
|
96
|
-
if (key.name === "return" || key.name === "enter") {
|
|
97
|
-
finish(input.options[index]?.value ?? null);
|
|
98
|
-
return;
|
|
99
|
-
}
|
|
100
|
-
if (key.name === "up" || key.name === "k") {
|
|
101
|
-
index = (index - 1 + input.options.length) % input.options.length;
|
|
102
|
-
draw();
|
|
103
|
-
return;
|
|
104
|
-
}
|
|
105
|
-
if (key.name === "down" || key.name === "j") {
|
|
106
|
-
index = (index + 1) % input.options.length;
|
|
107
|
-
draw();
|
|
108
|
-
return;
|
|
109
|
-
}
|
|
110
|
-
// The numbers stay live: the list is short, and the plain-text prompt
|
|
111
|
-
// this replaced numbered it, so the habit already exists.
|
|
112
|
-
const digit = Number(key.sequence);
|
|
113
|
-
if (
|
|
114
|
-
Number.isInteger(digit) &&
|
|
115
|
-
digit >= 1 &&
|
|
116
|
-
digit <= input.options.length
|
|
117
|
-
) {
|
|
118
|
-
index = digit - 1;
|
|
119
|
-
finish(input.options[index]?.value ?? null);
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
const draw = (): void => {
|
|
124
|
-
root.render(
|
|
125
|
-
React.createElement(
|
|
126
|
-
dimensions.DimensionsProvider,
|
|
127
|
-
null,
|
|
128
|
-
React.createElement(modals.SelectModal, {
|
|
129
|
-
title: input.title,
|
|
130
|
-
message: input.message,
|
|
131
|
-
options: input.options,
|
|
132
|
-
defaultIndex: index,
|
|
133
|
-
onSelect: finish,
|
|
134
|
-
onCancel: () => finish(null),
|
|
135
|
-
}),
|
|
136
|
-
),
|
|
137
|
-
);
|
|
138
|
-
};
|
|
139
|
-
|
|
140
|
-
renderer.keyInput.on("keypress", onKey);
|
|
141
|
-
draw();
|
|
142
|
-
});
|
|
143
|
-
}
|