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.
Files changed (68) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/blackbox-convergence.test.ts +1 -2
  3. package/src/__tests__/blackbox-parity.test.ts +16 -14
  4. package/src/__tests__/blackbox-plugin-state.test.ts +13 -15
  5. package/src/__tests__/cli-apply-seams.test.ts +3 -3
  6. package/src/__tests__/cli-profile-prompts.test.ts +1165 -0
  7. package/src/__tests__/content-drift.test.ts +0 -1
  8. package/src/__tests__/files-missing-scope.test.ts +235 -0
  9. package/src/__tests__/{enabled-not-installed.test.ts → files-missing.test.ts} +32 -26
  10. package/src/__tests__/format-update-outcome.test.ts +367 -0
  11. package/src/__tests__/installed-version-scope.test.tsx +425 -0
  12. package/src/__tests__/key-focus-guard.test.tsx +260 -0
  13. package/src/__tests__/mate-availability.test.ts +11 -13
  14. package/src/__tests__/plugin-contents.test.ts +0 -1
  15. package/src/__tests__/plugin-detail-scopes.test.tsx +270 -0
  16. package/src/__tests__/plugin-row-text.test.ts +18 -21
  17. package/src/__tests__/profile-command.test.ts +2 -2
  18. package/src/__tests__/profile-migration-offer.test.ts +5 -4
  19. package/src/__tests__/profile-name-on-save.test.ts +443 -0
  20. package/src/__tests__/profile-skills.test.ts +3 -3
  21. package/src/__tests__/profile-startup-nested.test.ts +329 -0
  22. package/src/__tests__/profile-startup-old-format.test.ts +390 -0
  23. package/src/__tests__/profile-startup.test.ts +1 -1
  24. package/src/__tests__/profile-state.test.ts +27 -22
  25. package/src/__tests__/prompt-eof.test.ts +245 -0
  26. package/src/__tests__/scope-action.test.ts +35 -38
  27. package/src/__tests__/scope-row-lookup.test.ts +126 -0
  28. package/src/__tests__/select-restore.test.ts +209 -0
  29. package/src/__tests__/stale-load-repair.test.ts +1 -2
  30. package/src/__tests__/update-apply.test.ts +18 -2
  31. package/src/__tests__/update-null-readback.test.ts +300 -0
  32. package/src/__tests__/update-plan-parity.test.ts +249 -0
  33. package/src/__tests__/update-plan.test.ts +66 -67
  34. package/src/__tests__/version-unknown-state.test.ts +38 -39
  35. package/src/cli/bootstrap.ts +271 -66
  36. package/src/cli/doctor.ts +4 -3
  37. package/src/cli/install.ts +83 -27
  38. package/src/cli/prompt.ts +19 -5
  39. package/src/cli/router.ts +1 -0
  40. package/src/cli/select.ts +168 -0
  41. package/src/cli/update-view.ts +120 -6
  42. package/src/cli/update.ts +6 -4
  43. package/src/services/claude-settings.ts +28 -0
  44. package/src/services/manifest.ts +31 -0
  45. package/src/services/mate-availability.ts +23 -29
  46. package/src/services/plugin-manager.ts +247 -105
  47. package/src/services/profile-absorb.ts +10 -7
  48. package/src/services/profile-activate.ts +41 -19
  49. package/src/services/profile-adopt.ts +9 -2
  50. package/src/services/profile-startup.ts +206 -5
  51. package/src/services/profile-state.ts +8 -7
  52. package/src/services/tracked-live-files.ts +24 -3
  53. package/src/services/update-engine.ts +28 -10
  54. package/src/services/update-plan.ts +78 -94
  55. package/src/ui/App.tsx +96 -56
  56. package/src/ui/adapters/pluginsAdapter.ts +1 -1
  57. package/src/ui/components/modals/ModalContainer.tsx +3 -0
  58. package/src/ui/components/primitives/ScopeSquares.tsx +10 -5
  59. package/src/ui/hooks/useGitignoreModal.ts +21 -3
  60. package/src/ui/hooks/useProfileStartupModal.ts +214 -15
  61. package/src/ui/hooks/useStaleLoadModal.ts +5 -1
  62. package/src/ui/input/KeyRouter.tsx +43 -2
  63. package/src/ui/keymap.ts +1 -1
  64. package/src/ui/renderers/pluginRenderers.tsx +39 -45
  65. package/src/ui/renderers/pluginRowText.ts +6 -2
  66. package/src/ui/renderers/pluginStatusText.ts +4 -3
  67. package/src/ui/screens/PluginsScreen.tsx +28 -41
  68. package/src/cli/tui-prompt.tsx +0 -143
@@ -15,7 +15,7 @@
15
15
  * leave, which is why it needs the app's own exit.
16
16
  */
17
17
 
18
- import { useCallback } from "react";
18
+ import { useCallback, useRef } from "react";
19
19
  import { getManifestPath } from "../../services/manifest.js";
20
20
  import {
21
21
  type ActivationResult,
@@ -25,10 +25,14 @@ import {
25
25
  import type { DisplacedArtifact } from "../../services/profile-live.js";
26
26
  import {
27
27
  type ProfileStartupNeed,
28
+ UPDATE_FORMAT_VALUE,
28
29
  buildProfileOptions,
29
30
  defaultOptionIndex,
30
31
  describeNeed,
32
+ formatUpdateOutcome,
31
33
  parseProfileChoice,
34
+ prepareProfileStartup,
35
+ profileIdForName,
32
36
  unparseableNeed,
33
37
  } from "../../services/profile-startup.js";
34
38
  import { applyProfileChoice } from "../../services/profile-state.js";
@@ -39,6 +43,8 @@ import type { ModalState } from "../state/types.js";
39
43
  export interface BuildProfileModalArgs {
40
44
  need: ProfileStartupNeed;
41
45
  onChoose: (choice: ProfileChoice) => void;
46
+ /** "Update it now" on the `old-format` question — not a profile choice. */
47
+ onUpdateFormat?: () => void;
42
48
  /**
43
49
  * Leave magus. NOT "dismiss" — there is deliberately no way to carry on
44
50
  * without a profile, because there is nothing to carry on with.
@@ -49,7 +55,7 @@ export interface BuildProfileModalArgs {
49
55
  export function buildProfileStartupModal(
50
56
  args: BuildProfileModalArgs,
51
57
  ): ModalState {
52
- const { need, onChoose, onQuit } = args;
58
+ const { need, onChoose, onUpdateFormat, onQuit } = args;
53
59
  const { title, message } = describeNeed(need);
54
60
  return {
55
61
  type: "select",
@@ -60,6 +66,10 @@ export function buildProfileStartupModal(
60
66
  // developer who has local work and therefore still gets asked.
61
67
  defaultIndex: defaultOptionIndex(need),
62
68
  onSelect: (value: string) => {
69
+ if (value === UPDATE_FORMAT_VALUE && onUpdateFormat) {
70
+ onUpdateFormat();
71
+ return;
72
+ }
63
73
  const choice = parseProfileChoice(value, need);
64
74
  if (choice) onChoose(choice);
65
75
  else onQuit();
@@ -157,26 +167,85 @@ export function describeFailure(
157
167
  ].join("\n");
158
168
  }
159
169
 
160
- export function useProfileStartupModal(
161
- onQuit: () => void,
170
+ export interface ProfileStartupModalCallbacks {
171
+ /** Leave magus: the modal's only non-action. */
172
+ onQuit: () => void;
162
173
  /** Called once a choice has activated a profile — see App's gitignore re-check. */
163
- onActivated?: () => void,
164
- ) {
174
+ onActivated?: () => void;
175
+ /**
176
+ * The question is ANSWERED: a profile is active, or the user chose to quit.
177
+ * Until this fires the caller must treat the question as outstanding and
178
+ * call `show` again whenever the modal slot is empty — see `show`.
179
+ */
180
+ onAnswered: () => void;
181
+ }
182
+
183
+ /**
184
+ * Where the profile question stands between `show` calls. `need` is the
185
+ * question on screen NOW, which is not always the one magus opened with: "Update
186
+ * it now" asks a new one, and a manifest that stopped parsing turns the question
187
+ * into the unparseable one. `working` is true while an activation or a format
188
+ * update is running — its result modal takes the slot when it settles.
189
+ */
190
+ interface ProfileFlow {
191
+ need: ProfileStartupNeed;
192
+ working: boolean;
193
+ }
194
+
195
+ export function useProfileStartupModal({
196
+ onQuit,
197
+ onActivated,
198
+ onAnswered,
199
+ }: ProfileStartupModalCallbacks) {
165
200
  const { state, dispatch } = useApp();
166
201
  const { navigateToScreen } = useNavigation();
167
202
  const projectPath = state.projectPath;
203
+ // A ref, not state: `show` is recreated on every render, and the flow must
204
+ // survive that — a re-show has to know what was last asked, and that work is
205
+ // in flight, without a render in between.
206
+ const flowRef = useRef<ProfileFlow | null>(null);
168
207
 
208
+ /**
209
+ * Show the profile question, or put it back.
210
+ *
211
+ * The caller calls this again whenever the modal slot is empty and the
212
+ * question is unanswered. The modal cannot be dismissed, so an empty slot
213
+ * then means something else took the slot and closed it — a screen's
214
+ * `confirm` resolving, say, before its loading modal lands. So a second call
215
+ * re-asks the question that was on screen, and never re-runs an automatic
216
+ * activation. While work is in flight it does nothing: that work puts its own
217
+ * result up when it settles, and asking again meanwhile would let a second
218
+ * choice race the first.
219
+ */
169
220
  const show = useCallback(
170
221
  (need: ProfileStartupNeed) => {
171
- /** Apply a choice, reporting the outcome either way. */
172
- const apply = (choice: ProfileChoice, automatic: boolean): void => {
222
+ const answered = (): void => {
223
+ flowRef.current = null;
224
+ onAnswered();
225
+ };
226
+ const quit = (): void => {
227
+ answered();
228
+ onQuit();
229
+ };
230
+
231
+ /**
232
+ * Apply a choice, reporting the outcome either way. `current` is the
233
+ * question it answers — after "Update it now" that is a NEW question,
234
+ * whose manifest is the converted one, not the one magus opened with.
235
+ */
236
+ const apply = (
237
+ choice: ProfileChoice,
238
+ automatic: boolean,
239
+ current: ProfileStartupNeed = need,
240
+ ): void => {
241
+ flowRef.current = { need: current, working: true };
173
242
  dispatch({
174
243
  type: "SHOW_MODAL",
175
244
  modal: {
176
245
  type: "loading",
177
246
  message:
178
247
  choice.kind === "adopt"
179
- ? `Creating "${choice.id}"…`
248
+ ? `Creating "${choice.name ?? choice.id}"…`
180
249
  : `Activating ${choice.id}…`,
181
250
  },
182
251
  });
@@ -191,10 +260,14 @@ export function useProfileStartupModal(
191
260
  // where they left it. `.magus-backups/` is gitignored, so git status
192
261
  // showed the deletion and no sign of the copy.
193
262
  const moved: DisplacedArtifact[] = [];
194
- void applyProfileChoice(choice, need.manifest, projectPath, {
263
+ void applyProfileChoice(choice, current.manifest, projectPath, {
195
264
  onDisplace: (artifact) => moved.push(artifact),
196
265
  })
197
266
  .then((result: ActivationResult) => {
267
+ // Answered here, on success — NOT when the question was shown.
268
+ // Clearing it on show let a screen's modal replace it and the
269
+ // question never came back.
270
+ answered();
198
271
  onActivated?.();
199
272
  const { profileId, displaced } = result;
200
273
  dispatch({
@@ -228,7 +301,10 @@ export function useProfileStartupModal(
228
301
  error instanceof SettingsParseError &&
229
302
  error.path === getManifestPath(projectPath)
230
303
  ? unparseableNeed(error)
231
- : need;
304
+ : current;
305
+ // Still unanswered, and `next` is what a lost error modal
306
+ // returns to.
307
+ flowRef.current = { need: next, working: false };
232
308
  dispatch({
233
309
  type: "SHOW_MODAL",
234
310
  modal: {
@@ -241,7 +317,7 @@ export function useProfileStartupModal(
241
317
  error,
242
318
  moved,
243
319
  projectPath,
244
- need.declaredDefault,
320
+ current.declaredDefault,
245
321
  ),
246
322
  variant: "error",
247
323
  // A failure leaves the project without an active profile, so
@@ -254,17 +330,140 @@ export function useProfileStartupModal(
254
330
  });
255
331
  };
256
332
 
333
+ /**
334
+ * "Update it now" on an old profile format: update it — the same gate
335
+ * the CLI runs after its own question — then ask whatever is left, or
336
+ * say it is done.
337
+ */
338
+ const updateFormat = (): void => {
339
+ flowRef.current = { need, working: true };
340
+ dispatch({
341
+ type: "SHOW_MODAL",
342
+ modal: { type: "loading", message: "Updating the profile format…" },
343
+ });
344
+ void prepareProfileStartup(projectPath, { convert: true })
345
+ .then((result) => {
346
+ const outcome = formatUpdateOutcome(result);
347
+ if (outcome.kind === "ask") {
348
+ // Converted, but no profile is active yet: a new question,
349
+ // still unanswered.
350
+ onActivated?.();
351
+ ask(outcome.need);
352
+ return;
353
+ }
354
+ if (outcome.kind === "failed") {
355
+ // Refused, so still the old format: back to the question,
356
+ // as the CLI stops on the same lines.
357
+ flowRef.current = { need, working: false };
358
+ dispatch({
359
+ type: "SHOW_MODAL",
360
+ modal: {
361
+ type: "message",
362
+ title: outcome.title,
363
+ message: outcome.message,
364
+ variant: "error",
365
+ onDismiss: () => ask(need),
366
+ },
367
+ });
368
+ return;
369
+ }
370
+ // Nothing left to ask: the converted profile is active.
371
+ answered();
372
+ onActivated?.();
373
+ dispatch({
374
+ type: "SHOW_MODAL",
375
+ modal: {
376
+ type: "message",
377
+ title: outcome.title,
378
+ message: outcome.message,
379
+ variant: "success",
380
+ onDismiss: () => {
381
+ dispatch({ type: "HIDE_MODAL" });
382
+ navigateToScreen("profiles");
383
+ },
384
+ },
385
+ });
386
+ })
387
+ .catch((error: unknown) => {
388
+ flowRef.current = { need, working: false };
389
+ dispatch({
390
+ type: "SHOW_MODAL",
391
+ modal: {
392
+ type: "message",
393
+ title: "Could not update the profile format",
394
+ message: `${error instanceof Error ? error.message : String(error)}\n\nYour profiles are kept. Open magus again to retry.`,
395
+ variant: "error",
396
+ onDismiss: () => ask(need),
397
+ },
398
+ });
399
+ });
400
+ };
401
+
402
+ /**
403
+ * "Save what you have" picked: ask what to call it, with the generated
404
+ * id as the suggestion. An invalid name is explained above the field and
405
+ * asked again with what was typed still in it; Escape creates nothing
406
+ * and goes back to the profile question.
407
+ */
408
+ const askName = (
409
+ suggestion: string,
410
+ current: ProfileStartupNeed,
411
+ typed: string = suggestion,
412
+ reason?: string,
413
+ ): void => {
414
+ dispatch({
415
+ type: "SHOW_MODAL",
416
+ modal: {
417
+ type: "input",
418
+ title: "Save as a new profile",
419
+ label: reason ? `${reason}\n\nProfile name` : "Profile name",
420
+ defaultValue: typed,
421
+ onSubmit: (value: string) => {
422
+ const check = profileIdForName(
423
+ value.trim() === "" ? suggestion : value,
424
+ current.manifest,
425
+ );
426
+ if (!check.ok) {
427
+ askName(suggestion, current, value, check.reason);
428
+ return;
429
+ }
430
+ apply(
431
+ { kind: "adopt", id: check.id, name: check.name },
432
+ false,
433
+ current,
434
+ );
435
+ },
436
+ onCancel: () => ask(current),
437
+ },
438
+ });
439
+ };
440
+
257
441
  function ask(current: ProfileStartupNeed = need): void {
442
+ flowRef.current = { need: current, working: false };
258
443
  dispatch({
259
444
  type: "SHOW_MODAL",
260
445
  modal: buildProfileStartupModal({
261
446
  need: current,
262
- onChoose: (choice) => apply(choice, false),
263
- onQuit,
447
+ onChoose: (choice) =>
448
+ choice.kind === "adopt"
449
+ ? askName(choice.id, current)
450
+ : apply(choice, false, current),
451
+ onUpdateFormat: updateFormat,
452
+ onQuit: quit,
264
453
  }),
265
454
  });
266
455
  }
267
456
 
457
+ const flow = flowRef.current;
458
+ // Work in flight owns the slot's next state; see the doc comment above.
459
+ if (flow?.working) return;
460
+ // Shown before and lost: put back the question that was on screen, not
461
+ // the one magus opened with, and never re-run an automatic activation.
462
+ if (flow) {
463
+ ask(flow.need);
464
+ return;
465
+ }
466
+
268
467
  // The repo already answered and there is nothing of the user's to
269
468
  // replace: asking here would ask someone to re-decide what their team
270
469
  // decided, with nothing at stake. Reported in the outcome modal, so it
@@ -275,7 +474,7 @@ export function useProfileStartupModal(
275
474
  }
276
475
  ask();
277
476
  },
278
- [dispatch, navigateToScreen, projectPath, onQuit, onActivated],
477
+ [dispatch, navigateToScreen, projectPath, onQuit, onActivated, onAnswered],
279
478
  );
280
479
 
281
480
  return { show };
@@ -91,7 +91,11 @@ export function useStaleLoadModal() {
91
91
  // the loader after writing and can legitimately answer "still not
92
92
  // loading the target"; swallowing that would leave the user believing
93
93
  // a broken plugin was fixed.
94
- void Promise.resolve(args.onFix())
94
+ //
95
+ // Called inside `.then` so a synchronous throw reaches `.catch`
96
+ // too; otherwise the loading modal stays up and swallows every key.
97
+ void Promise.resolve()
98
+ .then(() => args.onFix())
95
99
  .then((summary) => {
96
100
  dispatch({
97
101
  type: "SHOW_MODAL",
@@ -25,6 +25,10 @@
25
25
  * The paste event is deliberately NOT stopped: OpenTUI hands it to the focused
26
26
  * renderable afterwards, which is how the input modal's `<input>` still takes a
27
27
  * paste of its own — exactly the arrangement the modal layer relies on for keys.
28
+ *
29
+ * With ONE exception, for keys and pastes alike: an event never reaches a
30
+ * renderable that took focus while this router was dispatching it. See
31
+ * {@link dispatchThenGuardFocus}.
28
32
  */
29
33
 
30
34
  import { appendFileSync } from "node:fs";
@@ -133,6 +137,41 @@ function describeKey(key: KeyPress): string {
133
137
  return `name=${JSON.stringify(key.name)} seq=${JSON.stringify(key.sequence)} ctrl=${key.ctrl} meta=${key.meta} shift=${key.shift}`;
134
138
  }
135
139
 
140
+ /**
141
+ * Run `dispatch` for one key or paste event, committed synchronously, and then
142
+ * keep that event away from any renderable that took focus DURING it.
143
+ *
144
+ * OpenTUI delivers an event in two phases: every global listener first (this
145
+ * router is one), then every focused renderable. The renderable set is read
146
+ * AFTER the global phase returns (`InternalKeyHandler.emitWithPriority` in
147
+ * @opentui/core 0.1.107), not when the event arrived. And `flushSync` commits
148
+ * inside the global phase, so a modal this key opened is mounted, and its
149
+ * `<input focused>` has registered its handler, before that read happens.
150
+ * Left alone, the one keypress acts twice. MEASURED: Enter on "Save what you
151
+ * have" in the startup select opened the "Save as a new profile" input AND
152
+ * submitted its prefilled name, so the profile was created with no chance to
153
+ * type. By the same path, a screen key that opens an input before its first
154
+ * `await` (`n` on Styles, `r` on Profiles) reaches the new box as a typed
155
+ * letter, and Enter on such a row submits it.
156
+ *
157
+ * So the rule: an event goes to the renderable that held focus when it was
158
+ * pressed, or to nobody. When focus moved during dispatch, `preventDefault()`
159
+ * makes OpenTUI skip the renderable phase. Keys meant for an input modal that
160
+ * was already open are untouched: the router's modal layer does not move focus
161
+ * for them, and they reach the `<input>` as before.
162
+ */
163
+ function dispatchThenGuardFocus(
164
+ renderer: { readonly currentFocusedRenderable: unknown },
165
+ event: { preventDefault(): void },
166
+ dispatch: () => void,
167
+ ): void {
168
+ const focusedBefore = renderer.currentFocusedRenderable;
169
+ flushSync(dispatch);
170
+ if (renderer.currentFocusedRenderable !== focusedBefore) {
171
+ event.preventDefault();
172
+ }
173
+ }
174
+
136
175
  export function KeyRouter({ children }: { children: ReactNode }) {
137
176
  const [layers] = useState<LayerRegistry>(() => ({
138
177
  keys: new Set<KeyLayer>(),
@@ -143,7 +182,7 @@ export function KeyRouter({ children }: { children: ReactNode }) {
143
182
  useKeyboard((event) => {
144
183
  const key = toKeyPress(event);
145
184
  let consumer: KeyLayerId | null = null;
146
- flushSync(() => {
185
+ dispatchThenGuardFocus(renderer, event, () => {
147
186
  consumer = dispatchKey([...layers.keys], key);
148
187
  });
149
188
  logKey(describeKey(key), consumer);
@@ -156,7 +195,9 @@ export function KeyRouter({ children }: { children: ReactNode }) {
156
195
  const onPaste = (event: PasteEvent): void => {
157
196
  const text = pastedText(event);
158
197
  let consumer: KeyLayerId | null = null;
159
- flushSync(() => {
198
+ // Same guard as keys: no paste handler moves focus today, but a paste
199
+ // that did must not land in the box it just opened.
200
+ dispatchThenGuardFocus(renderer, event, () => {
160
201
  consumer = dispatchPaste([...layers.pastes], text);
161
202
  });
162
203
  logKey(`paste=${JSON.stringify(text)}`, consumer);
package/src/ui/keymap.ts CHANGED
@@ -156,7 +156,7 @@ const PLUGINS = [
156
156
  "save-profile",
157
157
  ["s"],
158
158
  "profile",
159
- "Save the enabled plugins as a profile",
159
+ "Save the installed plugins as a profile",
160
160
  60,
161
161
  ),
162
162
  bind(
@@ -2,8 +2,11 @@ import type React from "react";
2
2
  import { getMarketplaceVersion } from "../../services/marketplace-fetcher.js";
3
3
  import {
4
4
  compareVersions,
5
- isEnabledButNotInstalled,
5
+ hasMissingFiles,
6
+ isInstalledAnywhere,
6
7
  isInstalledInScope,
8
+ scopeFilesMissing,
9
+ versionInScope,
7
10
  } from "../../services/plugin-manager.js";
8
11
  import type { ScopeStatus } from "../../services/plugin-manager.js";
9
12
  import type { PluginComponentKind } from "../../services/skills-manager.js";
@@ -195,9 +198,12 @@ function pluginRow(
195
198
  isSelected: boolean,
196
199
  ): React.ReactNode {
197
200
  const { plugin } = item;
198
- const hasUser = !!plugin.userScope?.enabled;
199
- const hasProject = !!plugin.projectScope?.enabled;
200
- const hasLocal = !!plugin.localScope?.enabled;
201
+ // The squares and the name colour read the one installed-at-scope test, the
202
+ // same one the detail panel's dots read, so the row and the panel beside it
203
+ // cannot show two different states.
204
+ const hasUser = isInstalledInScope(plugin.userScope);
205
+ const hasProject = isInstalledInScope(plugin.projectScope);
206
+ const hasLocal = isInstalledInScope(plugin.localScope);
201
207
  const hasAnyScope = hasUser || hasProject || hasLocal;
202
208
 
203
209
  // Everything after the name — version, history badge, status — comes from
@@ -257,24 +263,13 @@ function pluginRow(
257
263
  );
258
264
  }
259
265
 
260
- const installedSomewhere =
261
- isInstalledInScope(plugin.userScope) ||
262
- isInstalledInScope(plugin.projectScope) ||
263
- isInstalledInScope(plugin.localScope);
264
-
265
266
  return (
266
267
  <text fg={theme.colors.text}>
267
268
  <span> </span>
268
269
  <ScopeSquares user={hasUser} project={hasProject} local={hasLocal} />
269
270
  <span> </span>
270
271
  <span fg={theme.colors.dim}>{arrow}</span>
271
- <span
272
- fg={
273
- hasAnyScope && installedSomewhere
274
- ? theme.colors.text
275
- : theme.colors.muted
276
- }
277
- >
272
+ <span fg={hasAnyScope ? theme.colors.text : theme.colors.muted}>
278
273
  {displayName}
279
274
  </span>
280
275
  <RowSegments segments={segments} selected={false} />
@@ -344,14 +339,10 @@ function pluginDetail(
344
339
  fetched?: PluginContentsEntry,
345
340
  ): React.ReactNode {
346
341
  const { plugin, status } = item;
347
- // "Installed" has to mean the same thing here as in the list row. Reading the
348
- // `enabledPlugins` flag alone made this panel print "● Installed" for the
349
- // exact plugin the row beside it was labelling "not installed".
350
- const isInstalled =
351
- isInstalledInScope(plugin.userScope) ||
352
- isInstalledInScope(plugin.projectScope) ||
353
- isInstalledInScope(plugin.localScope);
354
- const brokenInstall = isEnabledButNotInstalled(plugin);
342
+ // "Installed" means the same thing here as in the list row: the one
343
+ // installed-at-scope test, at any scope.
344
+ const isInstalled = isInstalledAnywhere(plugin);
345
+ const filesMissing = hasMissingFiles(plugin);
355
346
  // The ⬆ marker and the U hint claim that pressing U does something. That is
356
347
  // exactly the classifier's update/repair, and nothing else.
357
348
  const needsWork = status?.action === "update" || status?.action === "repair";
@@ -453,25 +444,25 @@ function pluginDetail(
453
444
  <box marginTop={1}>
454
445
  <text
455
446
  fg={
456
- isInstalled
457
- ? theme.colors.success
458
- : brokenInstall
459
- ? theme.colors.danger
447
+ filesMissing
448
+ ? theme.colors.danger
449
+ : isInstalled
450
+ ? theme.colors.success
460
451
  : theme.colors.muted
461
452
  }
462
453
  >
463
- {isInstalled
464
- ? "● Installed"
465
- : brokenInstall
466
- ? "○ Not installed — enabled in settings, no files on disk"
454
+ {filesMissing
455
+ ? "● Installed — files missing"
456
+ : isInstalled
457
+ ? "● Installed"
467
458
  : "○ Not installed"}
468
459
  </text>
469
460
  </box>
470
- {brokenInstall ? (
461
+ {filesMissing ? (
471
462
  <box>
472
463
  <text fg={theme.colors.muted}>
473
464
  Claude Code cannot load it in this state. Press the scope key to
474
- repair.
465
+ reinstall.
475
466
  </text>
476
467
  </box>
477
468
  ) : null}
@@ -631,10 +622,15 @@ function pluginDetail(
631
622
  /**
632
623
  * One line of the detail panel's scope breakdown.
633
624
  *
634
- * The filled dot means installed — registry-backed, same as everywhere else.
635
- * A scope that is enabled with nothing installed is called out explicitly
636
- * rather than shown as a plain empty dot, because those two states need
637
- * different actions from the user and used to look identical.
625
+ * The filled dot means what the list row's square means — installed at this
626
+ * scope — because both read `isInstalledInScope`. The version shows only where
627
+ * the plugin is installed: a registry row can be inherited from another
628
+ * directory of the same repository, and magus run in `tools/magus` once showed
629
+ * "Project team v0.8.1" for a plugin that directory's settings never turn on and
630
+ * Claude Code started there never loads.
631
+ *
632
+ * Installed with no version is named, in the danger colour, because it needs an
633
+ * action — the scope key reinstalls — and a plain dot would read as healthy.
638
634
  */
639
635
  function ScopeLine({
640
636
  keyHint,
@@ -647,18 +643,16 @@ function ScopeLine({
647
643
  qualifier: string;
648
644
  scope?: ScopeStatus;
649
645
  }): React.ReactNode {
650
- const installed = isInstalledInScope(scope);
646
+ const version = versionInScope(scope);
651
647
  return (
652
648
  <text fg={theme.colors.text}>
653
649
  <ScopeKeyChip scopeKey={keyHint} />
654
- <ScopeDot installed={installed} />
650
+ <ScopeDot installed={isInstalledInScope(scope)} />
655
651
  <span>{name}</span>
656
652
  <span fg={theme.colors.muted}> {qualifier}</span>
657
- {scope?.version ? (
658
- <span fg={theme.colors.info}> v{scope.version}</span>
659
- ) : null}
660
- {!installed && scope?.enabled ? (
661
- <span fg={theme.colors.danger}> enabled, not installed</span>
653
+ {version ? <span fg={theme.colors.info}> v{version}</span> : null}
654
+ {scopeFilesMissing(scope) ? (
655
+ <span fg={theme.colors.danger}> files missing</span>
662
656
  ) : null}
663
657
  </text>
664
658
  );
@@ -38,8 +38,12 @@ export function pluginRowText(item: PluginPluginItem): PluginRowText {
38
38
  // A version is shown only when it carries information. "0.0.0" means
39
39
  // installed with an unknown version (Anthropic's official plugins record
40
40
  // exactly that) — it is never printed, and nothing below depends on it.
41
- const installed = status?.installed ?? plugin.installedVersion;
42
- const shown = isKnownVersion(installed ?? undefined) ? installed : undefined;
41
+ //
42
+ // The classifier's version and nothing else. A fallback to
43
+ // `plugin.installedVersion` printed a version where the classifier had none —
44
+ // "v0.8.1 files missing", with 0.8.1 read off a scope not installed here.
45
+ const installed = status?.installed ?? undefined;
46
+ const shown = isKnownVersion(installed) ? installed : undefined;
43
47
 
44
48
  // "What just changed" and "what state it is in now" are independent facts
45
49
  // and both can be true at once: an external update can move a plugin
@@ -57,13 +57,14 @@ export function pluginStatusText(
57
57
  return { suffix: " BROKEN — press a to fix", tone: "danger" };
58
58
  case "install":
59
59
  // `absent` only ever comes from a profile pin (see `pinForRow`), so it
60
- // may say so. An undeclared row that is merely enabled must not.
60
+ // may say so. An undeclared row whose files are missing must not.
61
61
  return status.reason === "absent"
62
62
  ? { suffix: " not installed — profile declares it", tone: "warning" }
63
- : // Enabled with nothing on disk is a broken state; a missing
63
+ : // Files missing is installed, and broken — never "not installed",
64
+ // which would contradict the filled square beside it. A missing
64
65
  // declared plugin is routine work. Red against amber is the
65
66
  // distinction that matters.
66
- { suffix: ` not installed${withNote}`, tone: "danger" };
67
+ { suffix: ` ${status.note ?? "files missing"}`, tone: "danger" };
67
68
  case "current":
68
69
  return { suffix: withNote, tone: "muted" };
69
70
  }