magus-cli 7.1.0 → 7.3.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-plugin-state.test.ts +3 -0
- package/src/__tests__/doctor.test.ts +31 -0
- package/src/__tests__/plugin-resolution.test.ts +431 -0
- package/src/__tests__/plugin-row-text.test.ts +66 -0
- package/src/__tests__/profile-migration-offer.test.ts +211 -0
- package/src/__tests__/profile-migration.test.ts +242 -0
- package/src/__tests__/profile-startup.test.ts +458 -0
- package/src/__tests__/stale-load-repair.test.ts +1076 -0
- package/src/__tests__/update-apply.test.ts +146 -1
- package/src/__tests__/update-plan.test.ts +338 -0
- package/src/__tests__/useStaleLoadModal.test.ts +177 -0
- package/src/cli/ansi.ts +39 -0
- package/src/cli/bootstrap.ts +169 -3
- package/src/cli/doctor.ts +128 -2
- package/src/cli/install.ts +5 -1
- package/src/cli/profile.ts +5 -1
- package/src/cli/prompt.ts +44 -12
- package/src/cli/tui-prompt.tsx +143 -0
- package/src/cli/update-view.ts +32 -1
- package/src/cli/update.ts +50 -7
- package/src/main.tsx +10 -1
- package/src/services/active-closure.ts +23 -0
- package/src/services/claude-settings.ts +645 -34
- package/src/services/manifest.ts +21 -0
- package/src/services/plugin-manager.ts +61 -0
- package/src/services/plugin-resolution.ts +280 -0
- package/src/services/profile-activate.ts +147 -0
- package/src/services/profile-adopt.ts +50 -4
- package/src/services/profile-startup.ts +311 -0
- package/src/services/profiles.ts +49 -11
- package/src/services/symlink-manager.ts +184 -30
- package/src/services/update-engine.ts +104 -2
- package/src/services/update-plan.ts +188 -1
- package/src/types/index.ts +44 -23
- package/src/ui/App.tsx +123 -0
- package/src/ui/components/layout/FooterHints.tsx +21 -9
- package/src/ui/components/modals/ModalContainer.tsx +1 -0
- package/src/ui/components/modals/SelectModal.tsx +142 -21
- package/src/ui/hooks/useProfileStartupModal.ts +260 -0
- package/src/ui/hooks/useStaleLoadModal.ts +157 -0
- package/src/ui/renderers/pluginRenderers.tsx +29 -0
- package/src/ui/renderers/pluginStatusText.ts +14 -0
- package/src/ui/renderers/profileRenderers.tsx +27 -4
- package/src/ui/screens/PluginsScreen.tsx +46 -13
- package/src/ui/screens/ProfilesScreen.tsx +1 -1
- package/src/ui/state/types.ts +18 -0
- package/src/ui/theme-mode.ts +54 -0
|
@@ -41,6 +41,20 @@ export function pluginStatusText(
|
|
|
41
41
|
};
|
|
42
42
|
case "repair":
|
|
43
43
|
return { suffix: ` stale — reinstall${withNote}`, tone: "warning" };
|
|
44
|
+
case "stale-load":
|
|
45
|
+
// NO VERSIONS, and the note is deliberately not shown. This row used to
|
|
46
|
+
// print the classifier's note verbatim — "≠ loads v0.3.0, installed
|
|
47
|
+
// v0.5.0" — which is internal vocabulary: it tells a reader neither what
|
|
48
|
+
// is wrong nor what to do, because "loads" versus "installed" is a
|
|
49
|
+
// distinction only this codebase draws. The row's job is the verdict and
|
|
50
|
+
// the key; the mechanism, including which project's registry row wins,
|
|
51
|
+
// belongs in the detail panel, which is where someone curious looks.
|
|
52
|
+
//
|
|
53
|
+
// Red, not the amber it carried while this was report-only. Amber is
|
|
54
|
+
// this file's tone for "cannot tell" and for routine work; a plugin
|
|
55
|
+
// whose session runs a version nobody installed is broken, and there is
|
|
56
|
+
// now a key that fixes it.
|
|
57
|
+
return { suffix: " BROKEN — press a to fix", tone: "danger" };
|
|
44
58
|
case "install":
|
|
45
59
|
// `absent` only ever comes from a profile pin (see `pinForRow`), so it
|
|
46
60
|
// may say so. An undeclared row that is merely enabled must not.
|
|
@@ -68,7 +68,16 @@ export function wrapNames(names: string[], lineMax = 40): string[] {
|
|
|
68
68
|
return lines;
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
-
|
|
71
|
+
/**
|
|
72
|
+
* A profile's timestamp, or an em dash when it has none.
|
|
73
|
+
*
|
|
74
|
+
* `createdAt`/`updatedAt` are bookkeeping the TUI writes and the CLI does not,
|
|
75
|
+
* so a CLI-authored or hand-written profile legitimately has neither. Rendering
|
|
76
|
+
* "—" states that plainly; the alternative, printing "Invalid Date", reads as a
|
|
77
|
+
* corrupt file rather than an absent field.
|
|
78
|
+
*/
|
|
79
|
+
export function formatDate(iso: string | undefined): string {
|
|
80
|
+
if (!iso) return "—";
|
|
72
81
|
try {
|
|
73
82
|
const d = new Date(iso);
|
|
74
83
|
return d.toLocaleDateString("en-US", {
|
|
@@ -232,18 +241,23 @@ const predefinedRenderer: ItemRenderer<{
|
|
|
232
241
|
const savedRenderer: ItemRenderer<{ kind: "saved"; entry: ProfileEntry }> = {
|
|
233
242
|
renderRow: ({ item, isSelected }) => {
|
|
234
243
|
const { entry } = item;
|
|
235
|
-
const pluginCount = Object.keys(entry.plugins).length;
|
|
244
|
+
const pluginCount = Object.keys(entry.plugins ?? {}).length;
|
|
236
245
|
const dateStr = formatDate(entry.updatedAt);
|
|
237
246
|
const label = truncate(
|
|
238
247
|
`${entry.name} — ${pluginCount} plugin${pluginCount !== 1 ? "s" : ""} · ${dateStr}`,
|
|
239
248
|
45,
|
|
240
249
|
);
|
|
250
|
+
// `●` for the profile the project is actually running, matching the glyph
|
|
251
|
+
// `magus profile list` prints. A column that is blank on every row costs
|
|
252
|
+
// two cells and is worth it: without the mark, activating a profile and
|
|
253
|
+
// landing on this tab shows two rows that look identical.
|
|
254
|
+
const marker = entry.active ? "●" : " ";
|
|
241
255
|
|
|
242
256
|
if (isSelected) {
|
|
243
257
|
return (
|
|
244
258
|
<text bg={theme.selection.bg} fg={theme.selection.fg}>
|
|
245
259
|
{" "}
|
|
246
|
-
{label}{" "}
|
|
260
|
+
{marker} {label}{" "}
|
|
247
261
|
</text>
|
|
248
262
|
);
|
|
249
263
|
}
|
|
@@ -254,6 +268,9 @@ const savedRenderer: ItemRenderer<{ kind: "saved"; entry: ProfileEntry }> = {
|
|
|
254
268
|
|
|
255
269
|
return (
|
|
256
270
|
<text fg={theme.colors.text}>
|
|
271
|
+
<span fg={entry.active ? theme.colors.success : theme.colors.text}>
|
|
272
|
+
{marker}{" "}
|
|
273
|
+
</span>
|
|
257
274
|
<span fg={scopeColor}>{scopeLabel} </span>
|
|
258
275
|
<span fg={theme.colors.text}>{label}</span>
|
|
259
276
|
</text>
|
|
@@ -262,7 +279,7 @@ const savedRenderer: ItemRenderer<{ kind: "saved"; entry: ProfileEntry }> = {
|
|
|
262
279
|
|
|
263
280
|
renderDetail: ({ item }) => {
|
|
264
281
|
const { entry: selectedProfile } = item;
|
|
265
|
-
const plugins = Object.keys(selectedProfile.plugins);
|
|
282
|
+
const plugins = Object.keys(selectedProfile.plugins ?? {});
|
|
266
283
|
const cleanPlugins = plugins.map(stripSuffix);
|
|
267
284
|
const scopeColor =
|
|
268
285
|
selectedProfile.scope === "user"
|
|
@@ -281,6 +298,12 @@ const savedRenderer: ItemRenderer<{ kind: "saved"; entry: ProfileEntry }> = {
|
|
|
281
298
|
<box marginTop={1}>
|
|
282
299
|
<text fg={theme.colors.muted}>Scope: </text>
|
|
283
300
|
<text fg={scopeColor}>{scopeLabel}</text>
|
|
301
|
+
{/* Stated in words as well as the row glyph: the detail pane is
|
|
302
|
+
where someone looks to check, and "●" alone is not an answer
|
|
303
|
+
to "is this the one that is running?". */}
|
|
304
|
+
{selectedProfile.active ? (
|
|
305
|
+
<text fg={theme.colors.success}> · active</text>
|
|
306
|
+
) : null}
|
|
284
307
|
</box>
|
|
285
308
|
<box marginTop={1}>
|
|
286
309
|
<text fg={theme.colors.muted}>
|
|
@@ -996,9 +996,16 @@ export function PluginsScreen() {
|
|
|
996
996
|
const handleUpdate = async () => {
|
|
997
997
|
const item = selectableItems[pluginsState.selectedIndex];
|
|
998
998
|
if (!item || item.kind !== "plugin") return;
|
|
999
|
-
// The row said "update"
|
|
999
|
+
// The row said "update", "stale — reinstall" or "BROKEN"; nothing else is
|
|
1000
|
+
// work for this key. `install` stays out — that is the scope keys' and the
|
|
1001
|
+
// profile's business, and the detail panel never offers U for it.
|
|
1000
1002
|
const status = statuses.get(item.plugin.id);
|
|
1001
|
-
if (
|
|
1003
|
+
if (
|
|
1004
|
+
status?.action !== "update" &&
|
|
1005
|
+
status?.action !== "repair" &&
|
|
1006
|
+
status?.action !== "stale-load"
|
|
1007
|
+
)
|
|
1008
|
+
return;
|
|
1002
1009
|
|
|
1003
1010
|
const plugin = item.plugin;
|
|
1004
1011
|
const scope: PluginScope =
|
|
@@ -1010,9 +1017,13 @@ export function PluginsScreen() {
|
|
|
1010
1017
|
const repairing = status.action === "repair";
|
|
1011
1018
|
|
|
1012
1019
|
modal.loading(
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1020
|
+
// A broken plugin runs NO claude command at all, so naming one here would
|
|
1021
|
+
// misdescribe the work: the repair rewrites installed_plugins.json rows.
|
|
1022
|
+
status.action === "stale-load"
|
|
1023
|
+
? `Repairing ${plugin.name}…\nmoving this repository's install records forward to v${status.target ?? "?"}`
|
|
1024
|
+
: repairing
|
|
1025
|
+
? `Repairing ${plugin.name}…\nclaude plugin uninstall ${plugin.id} --scope ${scope} && claude plugin install ${plugin.id} --scope ${scope}`
|
|
1026
|
+
: `Updating ${plugin.name}…\nclaude plugin install ${plugin.id} --scope ${scope}`,
|
|
1016
1027
|
);
|
|
1017
1028
|
const reasons = noMoveReasons();
|
|
1018
1029
|
try {
|
|
@@ -1069,24 +1080,38 @@ export function PluginsScreen() {
|
|
|
1069
1080
|
const handleUpdateAll = async () => {
|
|
1070
1081
|
if (pluginsState.plugins.status !== "success") return;
|
|
1071
1082
|
|
|
1072
|
-
// Exactly the rows that say "update"
|
|
1073
|
-
// under its real pin. Content-stale plugins are
|
|
1074
|
-
// repairing, and excluding them meant "update all" left
|
|
1075
|
-
// silently outdated files.
|
|
1083
|
+
// Exactly the rows that say "update", "stale — reinstall" or "BROKEN — press
|
|
1084
|
+
// a to fix", each planned under its real pin. Content-stale plugins are
|
|
1085
|
+
// included: they need repairing, and excluding them meant "update all" left
|
|
1086
|
+
// the machine with silently outdated files. Broken (`stale-load`) rows are
|
|
1087
|
+
// included because the row itself now names this key — leaving them out
|
|
1088
|
+
// would make that hint a lie. `install` stays out: a plugin nothing has
|
|
1089
|
+
// installed is `s`'s business, per the note above.
|
|
1076
1090
|
const items = [...statuses.values()]
|
|
1077
|
-
.filter(
|
|
1091
|
+
.filter(
|
|
1092
|
+
(s) =>
|
|
1093
|
+
s.action === "update" ||
|
|
1094
|
+
s.action === "repair" ||
|
|
1095
|
+
s.action === "stale-load",
|
|
1096
|
+
)
|
|
1078
1097
|
.sort((a, b) => a.pluginId.localeCompare(b.pluginId));
|
|
1079
1098
|
if (items.length === 0) return;
|
|
1080
1099
|
|
|
1081
1100
|
const projectPath = state.projectPath || process.cwd();
|
|
1082
1101
|
const total = items.filter(pluginNeedsWork).length;
|
|
1083
1102
|
|
|
1103
|
+
// A broken plugin is repaired in the registry, not updated, and the progress
|
|
1104
|
+
// line is the only place the user sees which one happened to which plugin.
|
|
1105
|
+
const actionOf = new Map(items.map((i) => [i.pluginId, i.action]));
|
|
1106
|
+
|
|
1084
1107
|
let done = 0;
|
|
1085
1108
|
const reasons = noMoveReasons();
|
|
1086
1109
|
const reporter: ApplyReporter = {
|
|
1087
1110
|
begin: (name) => {
|
|
1088
1111
|
done++;
|
|
1089
|
-
|
|
1112
|
+
const verb =
|
|
1113
|
+
actionOf.get(name) === "stale-load" ? "Repairing" : "Updating";
|
|
1114
|
+
modal.loading(`${verb} ${name} (${done}/${total})…`);
|
|
1090
1115
|
},
|
|
1091
1116
|
finish: reasons.reporter.finish,
|
|
1092
1117
|
detach: (work) => work(),
|
|
@@ -1348,8 +1373,16 @@ export function PluginsScreen() {
|
|
|
1348
1373
|
// "cannot tell rendered as fine" bug.
|
|
1349
1374
|
const unknownCount = counts.unknown;
|
|
1350
1375
|
const installCount = counts.install;
|
|
1351
|
-
|
|
1352
|
-
|
|
1376
|
+
// Broken: installed at the right version, loading an older one. Its own
|
|
1377
|
+
// number, never folded into `updateCount` or into silence — a clean header
|
|
1378
|
+
// over a session running a version nobody installed is the whole defect.
|
|
1379
|
+
//
|
|
1380
|
+
// "broken", not "loading older". The old wording described the mechanism, so
|
|
1381
|
+
// a reader had to already know what "loads" versus "installed" means here
|
|
1382
|
+
// before the count told them anything. `a` clears these.
|
|
1383
|
+
const brokenCount = counts["stale-load"];
|
|
1384
|
+
const subtitle = `${scopeLabel} │ ${installedCount} installed${updateCount > 0 ? ` │ ${updateCount} updates` : ""}${installCount > 0 ? ` │ ${installCount} to install` : ""}${brokenCount > 0 ? ` │ ${brokenCount} broken` : ""}${unknownCount > 0 ? ` │ ${unknownCount} unverified` : ""}`;
|
|
1385
|
+
const searchPlaceholder = `${scopeLabel} │ ${installedCount} installed${updateCount > 0 ? ` │ ${updateCount} ⬆` : ""}${installCount > 0 ? ` │ ${installCount} +` : ""}${brokenCount > 0 ? ` │ ${brokenCount} ✗` : ""}${unknownCount > 0 ? ` │ ${unknownCount} ?` : ""} │ / to search`;
|
|
1353
1386
|
|
|
1354
1387
|
const catalogNotice = buildCatalogNotice({
|
|
1355
1388
|
failures: catalogFailures,
|
|
@@ -190,7 +190,7 @@ export function ProfilesScreen() {
|
|
|
190
190
|
? "~/.claude/settings.json"
|
|
191
191
|
: ".claude/settings.json";
|
|
192
192
|
|
|
193
|
-
const pluginCount = Object.keys(selectedProfile.plugins).length;
|
|
193
|
+
const pluginCount = Object.keys(selectedProfile.plugins ?? {}).length;
|
|
194
194
|
const emptyWarning =
|
|
195
195
|
pluginCount === 0
|
|
196
196
|
? "\n\nWARNING: This profile has no plugins — applying will disable all plugins."
|
package/src/ui/state/types.ts
CHANGED
|
@@ -12,6 +12,7 @@ import type {
|
|
|
12
12
|
ProfileEntry,
|
|
13
13
|
SkillInfo,
|
|
14
14
|
} from "../../types/index.js";
|
|
15
|
+
import type { ReportBadgeKind } from "../theme-mode.js";
|
|
15
16
|
|
|
16
17
|
// ============================================================================
|
|
17
18
|
// Route Types
|
|
@@ -76,6 +77,13 @@ export interface SelectOption {
|
|
|
76
77
|
label: string;
|
|
77
78
|
value: string;
|
|
78
79
|
description?: string;
|
|
80
|
+
/**
|
|
81
|
+
* A chip for a discrete state — dark ink on a saturated fill, the shape the
|
|
82
|
+
* report already uses. Optional because most selects have no state to say.
|
|
83
|
+
*/
|
|
84
|
+
badge?: { label: string; kind: ReportBadgeKind };
|
|
85
|
+
/** Dim trailing detail — chrome, never the thing being chosen. */
|
|
86
|
+
meta?: string;
|
|
79
87
|
}
|
|
80
88
|
|
|
81
89
|
export type ModalState =
|
|
@@ -102,6 +110,16 @@ export type ModalState =
|
|
|
102
110
|
defaultIndex?: number;
|
|
103
111
|
onSelect: (value: string) => void;
|
|
104
112
|
onCancel: () => void;
|
|
113
|
+
/**
|
|
114
|
+
* False for a question that must be answered.
|
|
115
|
+
*
|
|
116
|
+
* Only changes what the footer PROMISES; `onCancel` decides what Escape
|
|
117
|
+
* actually does. The two must agree, and they did not: the profile gate's
|
|
118
|
+
* `onCancel` is a no-op, so the footer's "esc cancel" was an instruction
|
|
119
|
+
* that did nothing when followed — worse than no hint, because the user
|
|
120
|
+
* concludes the app is stuck rather than that the question is required.
|
|
121
|
+
*/
|
|
122
|
+
cancellable?: boolean;
|
|
105
123
|
}
|
|
106
124
|
| {
|
|
107
125
|
type: "message";
|
package/src/ui/theme-mode.ts
CHANGED
|
@@ -396,6 +396,60 @@ export function trackFill(): string {
|
|
|
396
396
|
return TRACK_FILL[detected];
|
|
397
397
|
}
|
|
398
398
|
|
|
399
|
+
/**
|
|
400
|
+
* A key hint drawn as ONE two-tone chip: the key on a brighter fill, its label
|
|
401
|
+
* on a quieter fill of the same hue, butted together with no gap.
|
|
402
|
+
*
|
|
403
|
+
* Two halves rather than a chip plus loose text, because a bare label beside a
|
|
404
|
+
* filled key reads as prose that happens to follow a badge. Both halves filled
|
|
405
|
+
* makes the pair one object, and the brightness step inside it says which half
|
|
406
|
+
* you press.
|
|
407
|
+
*
|
|
408
|
+
* GREY, and no hue at all. Two earlier passes both failed by colouring it: the
|
|
409
|
+
* first gave `↵` the info blue and `esc` the danger red, which read as a traffic
|
|
410
|
+
* light — three saturated chips of unrelated meaning, none of them signalling
|
|
411
|
+
* anything, since `esc` is not dangerous, it is the way out. The second made all
|
|
412
|
+
* three magus purple, which stopped lying but competed with the selected row's
|
|
413
|
+
* purple fill directly above it, so the two most prominent things in the dialog
|
|
414
|
+
* were the same colour and the eye had nowhere to land.
|
|
415
|
+
*
|
|
416
|
+
* A key hint is chrome. It says "these are keys" and nothing more, so it earns
|
|
417
|
+
* no hue — leaving the dialog exactly one accent, on the row you are choosing.
|
|
418
|
+
* The two halves separate by INTENSITY: the key steps toward the foreground,
|
|
419
|
+
* the label sits near the page.
|
|
420
|
+
*
|
|
421
|
+
* Both pages are built from the greys already in this file — the light label is
|
|
422
|
+
* `SCOPE_OFF_FILL.light`, the dark key is `TRACK_FILL.dark` — so the footer sits
|
|
423
|
+
* in the same neutral family as every other quiet fill rather than introducing a
|
|
424
|
+
* third grey ramp.
|
|
425
|
+
*/
|
|
426
|
+
const HINT_CHIP = {
|
|
427
|
+
light: {
|
|
428
|
+
keyBg: "#B4B4A6",
|
|
429
|
+
keyFg: "#1F1F1A",
|
|
430
|
+
labelBg: "#D9D9CC",
|
|
431
|
+
labelFg: "#44443D",
|
|
432
|
+
},
|
|
433
|
+
dark: {
|
|
434
|
+
keyBg: "#6E6E7A",
|
|
435
|
+
keyFg: "#FBFBFD",
|
|
436
|
+
labelBg: "#44444E",
|
|
437
|
+
labelFg: "#D6D6DE",
|
|
438
|
+
},
|
|
439
|
+
} as const;
|
|
440
|
+
|
|
441
|
+
export interface HintChipInk {
|
|
442
|
+
keyBg: string;
|
|
443
|
+
keyFg: string;
|
|
444
|
+
labelBg: string;
|
|
445
|
+
labelFg: string;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/** The two-tone key-hint chip, for the resolved mode. */
|
|
449
|
+
export function hintChip(): HintChipInk {
|
|
450
|
+
return HINT_CHIP[detected];
|
|
451
|
+
}
|
|
452
|
+
|
|
399
453
|
/**
|
|
400
454
|
* Effort's colour ramp — TEXT only, never a fill, so a row never paints two blocks.
|
|
401
455
|
*
|