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.
Files changed (48) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/blackbox-plugin-state.test.ts +3 -0
  3. package/src/__tests__/doctor.test.ts +31 -0
  4. package/src/__tests__/plugin-resolution.test.ts +431 -0
  5. package/src/__tests__/plugin-row-text.test.ts +66 -0
  6. package/src/__tests__/profile-migration-offer.test.ts +211 -0
  7. package/src/__tests__/profile-migration.test.ts +242 -0
  8. package/src/__tests__/profile-startup.test.ts +458 -0
  9. package/src/__tests__/stale-load-repair.test.ts +1076 -0
  10. package/src/__tests__/update-apply.test.ts +146 -1
  11. package/src/__tests__/update-plan.test.ts +338 -0
  12. package/src/__tests__/useStaleLoadModal.test.ts +177 -0
  13. package/src/cli/ansi.ts +39 -0
  14. package/src/cli/bootstrap.ts +169 -3
  15. package/src/cli/doctor.ts +128 -2
  16. package/src/cli/install.ts +5 -1
  17. package/src/cli/profile.ts +5 -1
  18. package/src/cli/prompt.ts +44 -12
  19. package/src/cli/tui-prompt.tsx +143 -0
  20. package/src/cli/update-view.ts +32 -1
  21. package/src/cli/update.ts +50 -7
  22. package/src/main.tsx +10 -1
  23. package/src/services/active-closure.ts +23 -0
  24. package/src/services/claude-settings.ts +645 -34
  25. package/src/services/manifest.ts +21 -0
  26. package/src/services/plugin-manager.ts +61 -0
  27. package/src/services/plugin-resolution.ts +280 -0
  28. package/src/services/profile-activate.ts +147 -0
  29. package/src/services/profile-adopt.ts +50 -4
  30. package/src/services/profile-startup.ts +311 -0
  31. package/src/services/profiles.ts +49 -11
  32. package/src/services/symlink-manager.ts +184 -30
  33. package/src/services/update-engine.ts +104 -2
  34. package/src/services/update-plan.ts +188 -1
  35. package/src/types/index.ts +44 -23
  36. package/src/ui/App.tsx +123 -0
  37. package/src/ui/components/layout/FooterHints.tsx +21 -9
  38. package/src/ui/components/modals/ModalContainer.tsx +1 -0
  39. package/src/ui/components/modals/SelectModal.tsx +142 -21
  40. package/src/ui/hooks/useProfileStartupModal.ts +260 -0
  41. package/src/ui/hooks/useStaleLoadModal.ts +157 -0
  42. package/src/ui/renderers/pluginRenderers.tsx +29 -0
  43. package/src/ui/renderers/pluginStatusText.ts +14 -0
  44. package/src/ui/renderers/profileRenderers.tsx +27 -4
  45. package/src/ui/screens/PluginsScreen.tsx +46 -13
  46. package/src/ui/screens/ProfilesScreen.tsx +1 -1
  47. package/src/ui/state/types.ts +18 -0
  48. 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
- export function formatDate(iso: string): string {
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" or "stale — reinstall"; nothing else is work.
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 (status?.action !== "update" && status?.action !== "repair") return;
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
- repairing
1014
- ? `Repairing ${plugin.name}…\nclaude plugin uninstall ${plugin.id} --scope ${scope} && claude plugin install ${plugin.id} --scope ${scope}`
1015
- : `Updating ${plugin.name}…\nclaude plugin install ${plugin.id} --scope ${scope}`,
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" or "stale — reinstall", each planned
1073
- // under its real pin. Content-stale plugins are included: they need
1074
- // repairing, and excluding them meant "update all" left the machine with
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((s) => s.action === "update" || s.action === "repair")
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
- modal.loading(`Updating ${name} (${done}/${total})…`);
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
- const subtitle = `${scopeLabel} │ ${installedCount} installed${updateCount > 0 ? ` │ ${updateCount} updates` : ""}${installCount > 0 ? ` │ ${installCount} to install` : ""}${unknownCount > 0 ? ` │ ${unknownCount} unverified` : ""}`;
1352
- const searchPlaceholder = `${scopeLabel} │ ${installedCount} installed${updateCount > 0 ? ` │ ${updateCount} ⬆` : ""}${installCount > 0 ? ` │ ${installCount} +` : ""}${unknownCount > 0 ? ` │ ${unknownCount} ?` : ""} │ / to search`;
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."
@@ -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";
@@ -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
  *