magus-cli 7.0.0 → 7.2.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-parity.test.ts +58 -17
  3. package/src/__tests__/blackbox-plugin-state.test.ts +41 -19
  4. package/src/__tests__/cli-update-view.test.ts +100 -21
  5. package/src/__tests__/local-marketplace-scan.test.ts +74 -0
  6. package/src/__tests__/marketplace-refresh.test.ts +12 -7
  7. package/src/__tests__/plugin-row-text.test.ts +33 -24
  8. package/src/__tests__/profile-migration-offer.test.ts +211 -0
  9. package/src/__tests__/profile-migration.test.ts +242 -0
  10. package/src/__tests__/profile-startup.test.ts +458 -0
  11. package/src/__tests__/theme-env.test.ts +29 -0
  12. package/src/__tests__/update-plan.test.ts +57 -34
  13. package/src/__tests__/version-unknown-state.test.ts +29 -14
  14. package/src/cli/ansi.ts +58 -11
  15. package/src/cli/bootstrap.ts +169 -3
  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/router.ts +59 -2
  20. package/src/cli/tui-prompt.tsx +143 -0
  21. package/src/cli/update-view.ts +237 -45
  22. package/src/cli/update.ts +59 -29
  23. package/src/main.tsx +11 -2
  24. package/src/services/claude-settings.ts +15 -40
  25. package/src/services/local-marketplace.ts +76 -6
  26. package/src/services/manifest.ts +21 -0
  27. package/src/services/marketplace-refresh.ts +7 -15
  28. package/src/services/plugin-manager.ts +0 -31
  29. package/src/services/profile-activate.ts +147 -0
  30. package/src/services/profile-adopt.ts +50 -4
  31. package/src/services/profile-startup.ts +311 -0
  32. package/src/services/profiles.ts +49 -11
  33. package/src/services/resolver.ts +33 -0
  34. package/src/services/symlink-manager.ts +184 -30
  35. package/src/services/update-plan.ts +23 -66
  36. package/src/types/index.ts +44 -23
  37. package/src/ui/App.tsx +60 -3
  38. package/src/ui/components/layout/FooterHints.tsx +21 -9
  39. package/src/ui/components/modals/ModalContainer.tsx +1 -0
  40. package/src/ui/components/modals/SelectModal.tsx +142 -21
  41. package/src/ui/hooks/useProfileStartupModal.ts +260 -0
  42. package/src/ui/renderers/profileRenderers.tsx +27 -4
  43. package/src/ui/screens/PluginsScreen.tsx +9 -50
  44. package/src/ui/screens/ProfilesScreen.tsx +1 -1
  45. package/src/ui/state/types.ts +18 -0
  46. package/src/ui/theme-mode.ts +142 -0
  47. package/src/utils/command-utils.ts +19 -14
  48. package/src/__tests__/moved-marketplace.test.ts +0 -86
@@ -1193,71 +1193,30 @@ export function PluginsScreen() {
1193
1193
 
1194
1194
  const plugin = item.plugin;
1195
1195
 
1196
- // A plugin that moved marketplace is not deprecated — it is still
1197
- // published under a new namespace. Offering only "remove" silently drops
1198
- // something the user still wants (the magus → magus-marketing split left
1199
- // video-editing and image-generate in this state).
1200
- let migrateTo: string | undefined;
1201
- if (plugin.movedTo) {
1202
- const choice = await modal.select(
1203
- "Plugin moved",
1204
- `${plugin.name} is no longer published by "${plugin.marketplace}", but "${plugin.movedTo}" still publishes it.`,
1205
- [
1206
- {
1207
- label: `Migrate to ${plugin.name}@${plugin.movedTo}`,
1208
- value: "migrate",
1209
- },
1210
- { label: "Remove it entirely", value: "remove" },
1211
- ],
1212
- );
1213
- if (choice === null) return;
1214
- if (choice === "migrate") migrateTo = plugin.movedTo;
1215
- }
1196
+ // No "it moved, migrate there?" branch. It offered to uninstall this
1197
+ // plugin and install the same NAME from another marketplace, on the
1198
+ // strength of a name match with no history behind it — and for
1199
+ // `claudish`, dual-published to two marketplaces on purpose, the target
1200
+ // was already installed. An orphan is removed; where else a name happens
1201
+ // to appear is not this screen's guess to make.
1216
1202
 
1217
1203
  try {
1218
1204
  // Remove from all scopes — try all to clean up stale references
1219
1205
  const scopes: PluginScope[] = ["user", "project", "local"];
1220
- const removedFrom: PluginScope[] = [];
1206
+ // Nothing tallies which scopes actually held it. The migrate dialog
1207
+ // that consumed that tally is gone with the `moved` inference, and
1208
+ // collecting it for no reader is how a value goes stale unnoticed.
1221
1209
  for (const scope of scopes) {
1222
1210
  try {
1223
1211
  modal.loading(
1224
1212
  `Removing ${plugin.name} from ${scope}…\nclaude plugin uninstall ${plugin.id} --scope ${scope}`,
1225
1213
  );
1226
- const wasInstalled =
1227
- scope === "user"
1228
- ? plugin.userScope?.enabled
1229
- : scope === "project"
1230
- ? plugin.projectScope?.enabled
1231
- : plugin.localScope?.enabled;
1232
1214
  await cliUninstallPlugin(plugin.id, scope, state.projectPath);
1233
- if (wasInstalled) removedFrom.push(scope);
1234
1215
  } catch {
1235
1216
  // Ignore errors for scopes where it doesn't exist
1236
1217
  }
1237
1218
  }
1238
1219
 
1239
- if (migrateTo) {
1240
- // Reinstall under the new namespace in every scope it occupied, so
1241
- // the migration does not quietly change where the plugin is enabled.
1242
- const newId = `${plugin.name}@${migrateTo}`;
1243
- for (const scope of removedFrom.length > 0
1244
- ? removedFrom
1245
- : ["project" as PluginScope]) {
1246
- modal.loading(
1247
- `Installing ${newId} (${scope})…\nclaude plugin install ${newId} --scope ${scope}`,
1248
- );
1249
- // The migrated id is a plugin magus has never recorded a
1250
- // version for, so going through the read-back is what stops the
1251
- // rename leaving it permanently version-less — a plugin with no
1252
- // recorded version never reports an available update.
1253
- await installPluginInScope(
1254
- newId,
1255
- scope,
1256
- state.projectPath || process.cwd(),
1257
- );
1258
- }
1259
- }
1260
-
1261
1220
  modal.hideModal();
1262
1221
  fetchData();
1263
1222
  } catch (error) {
@@ -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";
@@ -269,6 +269,94 @@ export const MODEL_BADGE = {
269
269
 
270
270
  export type ModelBadgeKind = keyof typeof MODEL_BADGE;
271
271
 
272
+ /**
273
+ * Fill, ink and page-text for the report's state badges — one set per page.
274
+ *
275
+ * Every value here is LIFTED from `MODEL_BADGE` above rather than invented, so
276
+ * the contrast measurements recorded there carry over. Ink on fill, and the
277
+ * page text on its own ground, both measured here:
278
+ *
279
+ * update `sonnet` green ink/fill 5.82 light, 4.80 dark
280
+ * text 6.68 on cream, 9.44 on #1C1C1E
281
+ * deprecated `kangaroo` red earth label 4.86 light on its own bar
282
+ * fix `fable` purple label 4.86 light on its own bar
283
+ *
284
+ * `text` is the tone for a heading or glyph drawn ON THE PAGE, which is not the
285
+ * same question as ink on a fill: on cream it is the deep member of the family,
286
+ * on near-black the pale one. Using the fill for both put a mid-amber heading on
287
+ * a dark page, where it reads as dirt.
288
+ *
289
+ * Green, not ochre, because green is what an update wears elsewhere in magus
290
+ * and the report was the odd one out. It is a like-for-like swap inside the same
291
+ * measured system: the green fill separates from the page by 1.15 on cream and
292
+ * 3.42 on near-black, against the ochre's 1.15 and 3.32.
293
+ *
294
+ * Amber is NOT freed up by this — `unknown` still wears it, and that is the
295
+ * distinction worth keeping: green says "there is a newer version", amber says
296
+ * "we could not tell".
297
+ *
298
+ * Purple carries "fix required" because the warm band is spoken for and the
299
+ * measured set has no blue. Hue, not meaning — the badge says the word.
300
+ */
301
+ const REPORT_BADGE = {
302
+ update: {
303
+ light: { fill: "#D6EEDD", ink: "#166534", text: "#166534" },
304
+ dark: { fill: "#12804F", ink: "#EFFFF6", text: "#4FD98F" },
305
+ },
306
+ deprecated: {
307
+ light: { fill: "#F6D6D0", ink: "#8E2F26", text: "#8E2F26" },
308
+ dark: { fill: "#A83A2E", ink: "#FFEFEC", text: "#E07A6C" },
309
+ },
310
+ fix: {
311
+ light: { fill: "#E9DCF5", ink: "#6B21A8", text: "#6B21A8" },
312
+ dark: { fill: "#7B4CD6", ink: "#F6F0FF", text: "#A87BE8" },
313
+ },
314
+ } as const;
315
+
316
+ export type ReportBadgeKind = keyof typeof REPORT_BADGE;
317
+
318
+ export interface ReportInk {
319
+ /** The badge's fill. */
320
+ fill: string;
321
+ /** Ink for the word written on `fill`. */
322
+ ink: string;
323
+ /** The tone for a heading or glyph on the page itself. */
324
+ text: string;
325
+ }
326
+
327
+ /** Badge colours for one report state, for the resolved page. */
328
+ export function reportBadge(kind: ReportBadgeKind): ReportInk {
329
+ return REPORT_BADGE[kind][detected];
330
+ }
331
+
332
+ /**
333
+ * The unfilled part of a bar — `░` — for the page this run resolved.
334
+ *
335
+ * A track is CHROME. It exists so the eye can see how long the bar could have
336
+ * been, and it must never compete with the fill. "Quiet" is only definable
337
+ * against a page, which is why this lives here and not beside the ramps.
338
+ *
339
+ * It was a single dark grey, `darken(brand.muted, 0.68)` = #222429, chosen
340
+ * against a near-black terminal and applied on both. MEASURED against the two
341
+ * reference grounds:
342
+ *
343
+ * #222429 (was) this table
344
+ * dark 1.10:1 quiet 1.10:1 quiet
345
+ * cream 14.54:1 LOUDEST 1.24:1 quiet
346
+ *
347
+ * 14.54:1 is higher contrast than the body text. On a light terminal the empty
348
+ * half of every step bar was the most prominent thing in the report — a dense
349
+ * stipple that read as hatched texture and buried a 1 ms step's actual fill.
350
+ * The light value steps from the page TOWARD `brand.muted` rather than scaling
351
+ * a grey, so it stays in the page's own hue instead of laying cold grey on warm
352
+ * cream.
353
+ */
354
+ const BAR_TRACK = { light: "#E0E2C3", dark: "#222429" } as const;
355
+
356
+ export function barTrack(): string {
357
+ return BAR_TRACK[detected];
358
+ }
359
+
272
360
  export interface ModelInk {
273
361
  bg: string;
274
362
  fg: string;
@@ -308,6 +396,60 @@ export function trackFill(): string {
308
396
  return TRACK_FILL[detected];
309
397
  }
310
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
+
311
453
  /**
312
454
  * Effort's colour ramp — TEXT only, never a fill, so a row never paints two blocks.
313
455
  *
@@ -1,10 +1,6 @@
1
- import { execFile } from "node:child_process";
2
1
  import { constants, accessSync, statSync } from "node:fs";
3
2
  import { access, realpath } from "node:fs/promises";
4
3
  import { join } from "node:path";
5
- import { promisify } from "node:util";
6
-
7
- const execFileAsync = promisify(execFile);
8
4
 
9
5
  /**
10
6
  * A command as an executable plus its already-separated arguments.
@@ -70,19 +66,28 @@ function quoteToken(token: string): string {
70
66
  }
71
67
 
72
68
  /**
73
- * Check if a command exists in PATH
74
- * Uses execFile (not exec) to avoid shell injection vulnerabilities
75
- * @param command - Command name to check
69
+ * Check if a command exists in PATH.
70
+ *
71
+ * Async only for its callers' sake — it spawns nothing. This used to run
72
+ * `execFileAsync("which", [command])`, a real `which(1)` process with no
73
+ * timeout, on every one of its dozen call sites. Answering "is bun on PATH?"
74
+ * does not need a process, and under load that process is what stalls:
75
+ * `detectToolchains` calls this per toolchain, and in a 103-file parallel test
76
+ * run the spawn queue backed up until the test failed at **20,007 ms** — after
77
+ * the per-test timeout had already been raised from 5s to 20s to accommodate
78
+ * the earlier 5s failures. Raising the limit moved the goalposts; removing the
79
+ * spawn removes the stall.
80
+ *
81
+ * {@link whichSync} is also STRICTER than `which(1)`, which is a bonus rather
82
+ * than a risk: it stats the candidate and checks the execute bit, so a dangling
83
+ * symlink answers null here instead of reporting the link's own path. That is
84
+ * the exact failure {@link resolveExecutable} was written to catch afterwards.
85
+ *
86
+ * @param command - Command name to check, or an explicit path
76
87
  * @returns Path to command if found, null otherwise
77
88
  */
78
89
  export async function which(command: string): Promise<string | null> {
79
- try {
80
- // Use execFile with argument array to prevent command injection
81
- const { stdout } = await execFileAsync("which", [command]);
82
- return stdout.trim() || null;
83
- } catch {
84
- return null;
85
- }
90
+ return whichSync(command);
86
91
  }
87
92
 
88
93
  /**
@@ -1,86 +0,0 @@
1
- /**
2
- * An orphaned plugin that merely changed marketplace is not deprecated.
3
- *
4
- * Splitting `magus` into `magus` + `magus-marketing` left `video-editing@magus`
5
- * and `nanobanana@magus` installed under a namespace that no longer lists them. They rendered as bare
6
- * "deprecated", whose only action is deletion — which drops a plugin that is
7
- * still published, just under a different id.
8
- */
9
-
10
- import { describe, expect, test } from "bun:test";
11
- import type { LocalMarketplace } from "../services/local-marketplace.js";
12
-
13
- /**
14
- * Mirrors `findPluginInOtherMarketplace` in plugin-manager.ts, which is module
15
- * -private. Kept in lockstep with it; the behaviour, not the binding, is what
16
- * these tests pin.
17
- */
18
- function findPluginInOtherMarketplace(
19
- pluginName: string,
20
- currentMarketplace: string,
21
- localMarketplaces: Map<string, LocalMarketplace>,
22
- ): string | undefined {
23
- for (const [name, mp] of localMarketplaces) {
24
- if (name === currentMarketplace) continue;
25
- if (mp.plugins.some((p) => p.name === pluginName)) return name;
26
- }
27
- return undefined;
28
- }
29
-
30
- const mp = (name: string, plugins: string[]): [string, LocalMarketplace] => [
31
- name,
32
- {
33
- name,
34
- description: "",
35
- plugins: plugins.map((p) => ({
36
- name: p,
37
- version: "1.0.0",
38
- description: "",
39
- })),
40
- },
41
- ];
42
-
43
- describe("moved-marketplace detection", () => {
44
- test("finds a plugin that moved to a sibling marketplace", () => {
45
- const marketplaces = new Map([
46
- mp("magus", ["dev", "terminal"]),
47
- mp("magus-marketing", ["image-generate", "video-editing"]),
48
- ]);
49
-
50
- expect(
51
- findPluginInOtherMarketplace("video-editing", "magus", marketplaces),
52
- ).toBe("magus-marketing");
53
- });
54
-
55
- test("a genuinely retired plugin reports no destination", () => {
56
- // `conductor` was removed at magus v8.0.0 and republished nowhere.
57
- const marketplaces = new Map([
58
- mp("magus", ["dev"]),
59
- mp("magus-marketing", ["video-editing"]),
60
- ]);
61
-
62
- expect(
63
- findPluginInOtherMarketplace("conductor", "magus", marketplaces),
64
- ).toBeUndefined();
65
- });
66
-
67
- test("does not propose the marketplace it is already installed from", () => {
68
- const marketplaces = new Map([mp("magus", ["dev"])]);
69
- expect(findPluginInOtherMarketplace("dev", "magus", marketplaces)).toBe(
70
- undefined,
71
- );
72
- });
73
-
74
- test("a renamed plugin is not matched — the name is the identity", () => {
75
- // nanobanana became image-generate. Names differ, so this correctly
76
- // reports no destination rather than guessing at a rename.
77
- const marketplaces = new Map([
78
- mp("magus", ["dev"]),
79
- mp("magus-marketing", ["image-generate"]),
80
- ]);
81
-
82
- expect(
83
- findPluginInOtherMarketplace("nanobanana", "magus", marketplaces),
84
- ).toBeUndefined();
85
- });
86
- });