magus-cli 7.10.0 → 7.10.1

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "magus-cli",
3
- "version": "7.10.0",
3
+ "version": "7.10.1",
4
4
  "description": "TUI tool for managing Claude Code plugins, MCPs, and configuration",
5
5
  "type": "module",
6
6
  "main": "src/main.tsx",
@@ -65,8 +65,8 @@
65
65
  "typescript": "^5.6.3"
66
66
  },
67
67
  "optionalDependencies": {
68
- "magus-cli-darwin-arm64": "7.10.0",
69
- "magus-cli-darwin-x64": "7.10.0",
70
- "magus-cli-linux-x64": "7.10.0"
68
+ "magus-cli-darwin-arm64": "7.10.1",
69
+ "magus-cli-darwin-x64": "7.10.1",
70
+ "magus-cli-linux-x64": "7.10.1"
71
71
  }
72
72
  }
@@ -361,11 +361,11 @@ describe("C15 (c) — a crush binary that npm does not own", () => {
361
361
  expect(facts.managed).not.toBe(true);
362
362
  });
363
363
 
364
- test("R9.7 classifyTool keeps the not-managed veto for it (unknown / not-managed)", async () => {
364
+ test("R9.7 classifyTool keeps the not-managed veto for it (external / not-managed)", async () => {
365
365
  const { facts } = await probe(CRUSH, elsewhere());
366
366
  const status = classifyTool(CRUSH, facts);
367
367
  expect({ state: status.state, reason: status.reason }).toEqual({
368
- state: "unknown",
368
+ state: "external",
369
369
  reason: "not-managed",
370
370
  });
371
371
  });
@@ -526,7 +526,7 @@ describe("C15 (e) — a claude binary outside the native layout and outside node
526
526
  expect(facts.managed).not.toBe(true);
527
527
  const status = classifyTool(CLAUDE, facts);
528
528
  expect({ state: status.state, reason: status.reason }).toEqual({
529
- state: "unknown",
529
+ state: "external",
530
530
  reason: "not-managed",
531
531
  });
532
532
  });
@@ -197,7 +197,7 @@ describe("buildToolRow — which installer a catalogue row is judged through", (
197
197
  expect(row.installCommand).toEqual(cliToolInstall(codex));
198
198
  });
199
199
 
200
- test("detected pnpm → the catalogue's via, and a not-managed row naming the real path", () => {
200
+ test("detected pnpm → the catalogue's via, and a not-managed row in the user's terms", () => {
201
201
  const opencode = catalogue("opencode");
202
202
  const install = { primary: "pnpm" as const, all: ["pnpm" as const] };
203
203
  const spec = specForCatalogTool(opencode, null, install);
@@ -210,8 +210,13 @@ describe("buildToolRow — which installer a catalogue row is judged through", (
210
210
  owner: "npm (root /Users/j/.local/lib/node_modules)",
211
211
  });
212
212
  const row = buildToolRow(opencode, spec, f, install);
213
+ expect(row.status?.state).toBe("external");
213
214
  expect(row.status?.reason).toBe("not-managed");
214
- expect(row.status?.note).toContain("/Users/j/.local/share/pnpm/");
215
+ // No path and no installer directory: the user acts on neither.
216
+ expect(row.status?.note).toBe(
217
+ "installed outside npm, so magus leaves it as is",
218
+ );
219
+ expect(row.status?.note).not.toContain("/");
215
220
  // Enter still follows how it IS installed (pnpm).
216
221
  expect(row.updateCommand?.cmd).toBe("pnpm");
217
222
  });
@@ -249,16 +254,28 @@ describe("cliToolStatusText — the row's words", () => {
249
254
  primary: "npm",
250
255
  all: ["npm"],
251
256
  });
252
- const f = facts("crush", { managed: false, owner: "npm (root /l)" });
257
+ const f = facts("crush", { managed: null });
253
258
  const row = buildToolRow(tool, spec, f);
254
259
  const text = cliToolStatusText(row);
255
260
  expect(text.tone).toBe("warning");
256
- expect(text.suffix).toBe(" ? not managed by npm");
257
- // The note (with its path) is for the detail pane's "Why", not the row.
261
+ expect(text.suffix).toBe(" ? owner not known");
262
+ // The note is for the detail pane's "Why", not the row.
258
263
  expect(row.status?.note).toBe(classifyTool(spec, f).note);
259
264
  expect(text.suffix).not.toContain("/");
260
265
  });
261
266
 
267
+ test("external renders muted, naming the installer it is outside of", () => {
268
+ const tool = catalogue("crush");
269
+ const spec = specForCatalogTool(tool, null, {
270
+ primary: "npm",
271
+ all: ["npm"],
272
+ });
273
+ const row = buildToolRow(tool, spec, facts("crush", { managed: false }));
274
+ const text = cliToolStatusText(row);
275
+ expect(text.tone).toBe("muted");
276
+ expect(text.suffix).toBe(" · outside npm");
277
+ });
278
+
262
279
  test("upgrade names the target once, with one v", () => {
263
280
  const tool = catalogue("mnemex");
264
281
  const spec = specForCatalogTool(tool, null, {
@@ -287,12 +304,14 @@ describe("countToolRows — header counts from states", () => {
287
304
  const rows = [
288
305
  buildToolRow(tool, spec, facts("mnemex", { behind: false })),
289
306
  buildToolRow(tool, spec, facts("mnemex", { behind: true })),
307
+ buildToolRow(tool, spec, facts("mnemex", { managed: null })),
308
+ // Another installer's: installed, and not "unverified".
290
309
  buildToolRow(tool, spec, facts("mnemex", { managed: false })),
291
310
  buildToolRow(tool, spec, facts("mnemex", { present: false })),
292
311
  buildToolRow(tool, spec, undefined),
293
312
  ];
294
313
  expect(countToolRows(rows)).toEqual({
295
- installed: 3,
314
+ installed: 4,
296
315
  updates: 1,
297
316
  unverified: 1,
298
317
  });
@@ -226,7 +226,7 @@ describe("classifyTool — every rule of §4.3, in order", () => {
226
226
  );
227
227
  });
228
228
 
229
- test("6. not managed → unknown, naming both paths", () => {
229
+ test("6. not managed → external, in the user's terms", () => {
230
230
  const s = classifyTool(
231
231
  spec({ name: "crush" }),
232
232
  facts({
@@ -237,16 +237,24 @@ describe("classifyTool — every rule of §4.3, in order", () => {
237
237
  owner: "npm (root /Users/j/.local/lib/node_modules)",
238
238
  }),
239
239
  );
240
- expect(s).toMatchObject({ state: "unknown", reason: "not-managed" });
241
- expect(s.note).toContain("/Users/j/.nvm/versions/node/v24.14.0");
242
- expect(s.note).toContain("npm (root /Users/j/.local/lib/node_modules)");
243
- expect(s.note).toContain("second copy");
240
+ expect(s).toMatchObject({ state: "external", reason: "not-managed" });
241
+ expect(s.note).toBe("installed outside npm, so magus leaves it as is");
242
+ expect(s.note).not.toContain("/");
244
243
  });
245
244
 
246
- test("6. managed === null (owner undeterminable) is also a veto", () => {
245
+ test("6. managed === null (owner undeterminable) is also a veto, as unknown", () => {
247
246
  expect(
248
- classifyTool(spec(), facts({ managed: null, behind: true })).reason,
249
- ).toBe("not-managed");
247
+ classifyTool(spec(), facts({ managed: null, behind: true })),
248
+ ).toMatchObject({ state: "unknown", reason: "not-managed" });
249
+ });
250
+
251
+ test("6. brew is named as the user knows it", () => {
252
+ expect(
253
+ classifyTool(
254
+ spec({ via: "brew" }),
255
+ facts({ managed: false, behind: true }),
256
+ ).note,
257
+ ).toBe("installed outside Homebrew, so magus leaves it as is");
250
258
  });
251
259
 
252
260
  test("7. held back → unknown, with the hold as the note", () => {
@@ -364,7 +372,7 @@ describe("classifyTool — every rule of §4.3, in order", () => {
364
372
  ];
365
373
  for (const [s, f] of cases) {
366
374
  const status = classifyTool(spec(s), facts(f));
367
- expect(["unknown", "upgrade"]).toContain(status.state);
375
+ expect(["unknown", "upgrade", "external"]).toContain(status.state);
368
376
  expect(status.note?.length ?? 0).toBeGreaterThan(0);
369
377
  }
370
378
  });
@@ -866,11 +874,11 @@ describe("probeTool — ownership decides managed", () => {
866
874
  const [f] = await probeTools([hostile], io);
867
875
  expect(io.calls).toEqual(["npm root -g"]);
868
876
  expect(f?.installed).toBeNull();
869
- // Cannot tell ⇒ unknown, so it is reported and never applied. The file
870
- // is outside npm's tree, so the reason is ownership (R9.9), not the
871
- // unread version that follows from it.
877
+ // Another installer's file ⇒ external, so it is reported and never
878
+ // applied. The reason is ownership (R9.9), not the unread version that
879
+ // follows from it.
872
880
  expect(classifyTool(hostile, f!)).toMatchObject({
873
- state: "unknown",
881
+ state: "external",
874
882
  reason: "not-managed",
875
883
  });
876
884
 
@@ -502,7 +502,7 @@ describe("planIsBehind — the --check gate", () => {
502
502
  const p = plan({
503
503
  bins: planBinUpdates(
504
504
  [{ name: "crush", via: "npm", sources: [] }],
505
- [toolFacts("crush", { managed: false })],
505
+ [toolFacts("crush", { managed: null })],
506
506
  ),
507
507
  });
508
508
  expect(p.bins[0]!.action).toBe("unknown");
@@ -510,6 +510,19 @@ describe("planIsBehind — the --check gate", () => {
510
510
  expect(planIsBehind(p)).toBe(false);
511
511
  });
512
512
 
513
+ test("an EXTERNAL tool (another installer's) is no work, and not unknown", () => {
514
+ const p = plan({
515
+ bins: planBinUpdates(
516
+ [{ name: "crush", via: "npm", sources: [] }],
517
+ [toolFacts("crush", { managed: false, behind: true })],
518
+ ),
519
+ });
520
+ expect(p.bins[0]!.action).toBe("external");
521
+ expect(planHasWork(p)).toBe(false);
522
+ expect(planIsBehind(p)).toBe(false);
523
+ expect(summarizePlan(p)).toMatchObject({ unknownTools: 0, kept: 1 });
524
+ });
525
+
513
526
  test("a MISSING binary does fail the gate", () => {
514
527
  const p = plan({
515
528
  bins: planBinUpdates(
@@ -946,11 +959,13 @@ describe("planHasWork / summarizePlan", () => {
946
959
  { name: "crush", via: "npm", sources: [] },
947
960
  { name: "claude", via: "npm", sources: [] },
948
961
  { name: "tmux", via: "brew", sources: [] },
962
+ { name: "zsh", via: "brew", sources: [] },
949
963
  ],
950
964
  [
951
- toolFacts("crush", { managed: false }),
952
- toolFacts("claude", { managed: false }),
965
+ toolFacts("crush", { managed: null }),
966
+ toolFacts("claude", { managed: null }),
953
967
  toolFacts("tmux", { behind: false }),
968
+ toolFacts("zsh", { managed: false }),
954
969
  ],
955
970
  ),
956
971
  });
@@ -959,6 +974,7 @@ describe("planHasWork / summarizePlan", () => {
959
974
  unknownTools: 2,
960
975
  unknown: 3,
961
976
  current: 1,
977
+ kept: 1,
962
978
  });
963
979
  });
964
980
  });
@@ -90,6 +90,7 @@ type ActionKey =
90
90
  | "refresh"
91
91
  | "stale-load"
92
92
  | "remove"
93
+ | "external"
93
94
  | "unknown";
94
95
 
95
96
  const ACTIONS: Record<ActionKey, ActionStyle> = {
@@ -111,6 +112,9 @@ const ACTIONS: Record<ActionKey, ActionStyle> = {
111
112
  // dead weight Claude Code refuses to load, and the row is work, not a shrug.
112
113
  // The plugin table shows the group's DEPRECATED chip, not this label.
113
114
  remove: { glyph: "✗", label: "REMOVE", color: brand.danger },
115
+ // A tool another installer put on PATH. Settled, so chrome like CURRENT,
116
+ // and the same word the summary already prints for settled rows.
117
+ external: { glyph: "=", label: "KEPT", color: brand.muted, chrome: true },
114
118
  // Warning, not danger: "cannot tell" is not a failure, and red UNKNOWN read
115
119
  // as "broken". The summary line and the TUI already use warning for it.
116
120
  unknown: { glyph: "?", label: "UNKNOWN", color: brand.warning },
@@ -453,6 +457,7 @@ function binDetail(item: BinUpdateItem, plan: UpdatePlan): string {
453
457
  // plain one reads identically to before.
454
458
  item.adopted ? ` ${dim(`(${item.adopted})`)}` : ""
455
459
  }`;
460
+ case "external":
456
461
  case "unknown":
457
462
  return `${item.installed ?? "—"}${
458
463
  item.note ? ` ${dim(`(${item.note})`)}` : ""
@@ -927,15 +932,18 @@ function worktreeLabel(names: readonly string[]): string {
927
932
  * The worktree rows: after a setup (`results`), one `✓` line for every
928
933
  * worktree set up and a `✗` line for each failure; on a read-only run
929
934
  * (`results` null), one `⚠` line for the ones `magus update` would set up.
930
- * A worktree that needs a choice gets its own `⚠` line either way. Empty for
931
- * an empty list: a repository with nothing to say prints nothing.
935
+ *
936
+ * A worktree that needs a choice gets no line. magus cannot set it up from
937
+ * here, and a line per worktree on every run is noise about folders the user
938
+ * may never open: old branches, agent and bench worktrees. The `setup`
939
+ * plugin says so when Claude Code starts in one, and magus run there asks.
940
+ * Empty when there is nothing to say, blank line included.
932
941
  */
933
942
  export function worktreeLines(
934
943
  worktrees: readonly UnsetWorktree[],
935
944
  results: readonly WorktreeSetupResult[] | null,
936
945
  ): string[] {
937
- if (worktrees.length === 0) return [];
938
- const lines = [""];
946
+ const lines: string[] = [];
939
947
  if (results === null) {
940
948
  const auto = worktrees.filter((w) => w.autoActivate).map((w) => w.name);
941
949
  if (auto.length > 0) {
@@ -965,14 +973,5 @@ export function worktreeLines(
965
973
  }
966
974
  }
967
975
  }
968
- for (const w of worktrees) {
969
- if (!w.autoActivate) {
970
- lines.push(
971
- warn(
972
- `Worktree ${bold(w.name)} is missing this project's plugins. Run magus update there to choose a profile.`,
973
- ),
974
- );
975
- }
976
- }
977
- return lines;
976
+ return lines.length > 0 ? ["", ...lines] : [];
978
977
  }
@@ -58,7 +58,18 @@ import {
58
58
 
59
59
  // ── Types ────────────────────────────────────────────────────────────────────
60
60
 
61
- export type ToolState = "install" | "current" | "upgrade" | "unknown";
61
+ /**
62
+ * `external` is a determined fact, not a failure to check: the tool is on
63
+ * PATH, and something other than the installer magus would use put it there
64
+ * (bun, an official installer, the OS). magus leaves it as is, so it is
65
+ * neither work nor "could not be checked".
66
+ */
67
+ export type ToolState =
68
+ | "install"
69
+ | "current"
70
+ | "upgrade"
71
+ | "external"
72
+ | "unknown";
62
73
 
63
74
  export type ToolReason =
64
75
  | "absent"
@@ -215,16 +226,38 @@ function withFailure(text: string, failure: string | undefined): string {
215
226
  return failure ? `${text} (${failure})` : text;
216
227
  }
217
228
 
218
- /** The `not-managed` sentence. Names the file and its owner, never the version. */
229
+ /** The installer as the user knows it. */
230
+ export function installerWord(via: ManagedTool["via"]): string {
231
+ return via === "brew" ? "Homebrew" : via;
232
+ }
233
+
234
+ /**
235
+ * The `not-managed` sentence, in the user's terms: who did not install it,
236
+ * and what magus does about it. Never the path or the installer's directory:
237
+ * the user acts on neither.
238
+ */
219
239
  function notManagedNote(spec: ManagedTool, facts: ToolFacts): string {
240
+ const pin = pinOf(spec);
241
+ if (facts.managed === false && pin) {
242
+ return `installed outside ${installerWord(spec.via)}, so magus cannot move it to the pin ${pin}`;
243
+ }
220
244
  return facts.managed === false
221
- ? `on PATH at ${facts.resolvedPath ?? "?"}, which ${facts.owner ?? spec.via} does not own — upgrading through ${spec.via} would install a second copy`
245
+ ? `installed outside ${installerWord(spec.via)}, so magus leaves it as is`
222
246
  : withFailure(
223
- `could not tell whether ${spec.via} owns ${facts.resolvedPath ?? spec.name}`,
247
+ `could not tell whether ${installerWord(spec.via)} installed it`,
224
248
  facts.failure,
225
249
  );
226
250
  }
227
251
 
252
+ /**
253
+ * Another installer's file is `external`, settled, only while nothing is
254
+ * asked of it. With a pin the profile declares a version magus cannot deliver
255
+ * without a second copy, so that is still a warning: `unknown`.
256
+ */
257
+ function externalOr(pin: string | undefined): ToolState {
258
+ return pin ? "unknown" : "external";
259
+ }
260
+
228
261
  /**
229
262
  * THE tool classifier. Pure. The first matching rule wins, and each branch
230
263
  * returns, so reading top to bottom is reading the precedence.
@@ -271,7 +304,7 @@ export function classifyTool(spec: ManagedTool, facts: ToolFacts): ToolStatus {
271
304
  if (installed === null && facts.managed === false) {
272
305
  return {
273
306
  ...base,
274
- state: "unknown",
307
+ state: externalOr(pin),
275
308
  reason: "not-managed",
276
309
  target: null,
277
310
  note: notManagedNote(spec, facts),
@@ -317,11 +350,12 @@ export function classifyTool(spec: ManagedTool, facts: ToolFacts): ToolStatus {
317
350
  };
318
351
  }
319
352
 
320
- // 7. The veto: the upgrade would not replace the file PATH runs.
353
+ // 7. The veto: the upgrade would not replace the file PATH runs. Known to be
354
+ // another installer's file: `external`. Not known either way: `unknown`.
321
355
  if (facts.managed !== true) {
322
356
  return {
323
357
  ...base,
324
- state: "unknown",
358
+ state: facts.managed === false ? externalOr(pin) : "unknown",
325
359
  reason: "not-managed",
326
360
  target: null,
327
361
  note: notManagedNote(spec, facts),
@@ -1235,8 +1235,10 @@ export function summarizePlan(plan: UpdatePlan): {
1235
1235
  unknownPlugins: number;
1236
1236
  unknownTools: number;
1237
1237
  /**
1238
- * No longer published by their marketplace, and kept on purpose: moved, or
1239
- * still required. A determined fact, so not part of `unknown`.
1238
+ * Left as they are, on purpose. Plugins no longer published by their
1239
+ * marketplace and kept (moved, or still required), and tools another
1240
+ * installer put on PATH (`external`). Determined facts, so not part of
1241
+ * `unknown`.
1240
1242
  */
1241
1243
  kept: number;
1242
1244
  } {
@@ -1244,8 +1246,11 @@ export function summarizePlan(plan: UpdatePlan): {
1244
1246
  plan.plugins.filter((p) => p.action === a).length;
1245
1247
  const bins = (a: BinUpdateAction) =>
1246
1248
  plan.bins.filter((b) => b.action === a).length;
1247
- const kept = plan.plugins.filter(isKeptDeprecated).length;
1248
- const unknownPlugins = count("unknown") - kept;
1249
+ const keptPlugins = plan.plugins.filter(isKeptDeprecated).length;
1250
+ const unknownPlugins = count("unknown") - keptPlugins;
1251
+ // A tool another installer put on PATH is left as it is: settled, like a
1252
+ // kept plugin, and never "could not be checked".
1253
+ const kept = keptPlugins + bins("external");
1249
1254
  const unknownTools = bins("unknown");
1250
1255
  return {
1251
1256
  installed: count("install") + bins("install"),
@@ -29,6 +29,7 @@ import {
29
29
  type ToolReason,
30
30
  type ToolStatus,
31
31
  classifyTool,
32
+ installerWord,
32
33
  } from "../../services/tool-probe.js";
33
34
  import { binCommandForState } from "../../services/toolchain.js";
34
35
  import type { Presence } from "../../shared/plugin/hooks/lib/requires-probe.js";
@@ -246,7 +247,9 @@ export function cliToolEnterAction(row: CliToolRow): CliToolEnterAction | null {
246
247
  command,
247
248
  };
248
249
  }
249
- if (status.state === "unknown") {
250
+ // Another installer's file, or one magus could not place: still offered,
251
+ // as an explicit choice, with the note beside it (see the renderer).
252
+ if (status.state === "unknown" || status.state === "external") {
250
253
  return {
251
254
  kind: "update-anyway",
252
255
  label: "update anyway",
@@ -280,13 +283,14 @@ export interface CliToolStatusText {
280
283
  * line, the same string `magus update` prints.
281
284
  *
282
285
  * Every label is at most 19 cells, so the row suffix (" ? " + label) stays
283
- * within 24. Keyed by reason alone — plus the spec's installer, which is a
284
- * word, never a path.
286
+ * within 24. Keyed by reason alone.
285
287
  */
286
- function unknownLabel(reason: ToolReason, via: ManagedTool["via"]): string {
288
+ function unknownLabel(reason: ToolReason): string {
287
289
  switch (reason) {
290
+ // Only the undetermined case: a file known to be another installer's
291
+ // is `external`, not `unknown`.
288
292
  case "not-managed":
289
- return `not managed by ${via}`;
293
+ return "owner not known";
290
294
  case "version-unreadable":
291
295
  return "version unreadable";
292
296
  case "latest-unavailable":
@@ -321,9 +325,14 @@ export function cliToolStatusText(row: CliToolRow): CliToolStatusText {
321
325
  switch (status.state) {
322
326
  case "unknown":
323
327
  return {
324
- suffix: ` ? ${unknownLabel(status.reason, row.spec.via)}`,
328
+ suffix: ` ? ${unknownLabel(status.reason)}`,
325
329
  tone: "warning",
326
330
  };
331
+ case "external":
332
+ return {
333
+ suffix: ` · outside ${installerWord(row.spec.via)}`,
334
+ tone: "muted",
335
+ };
327
336
  case "upgrade":
328
337
  return {
329
338
  suffix: status.target
@@ -250,6 +250,10 @@ export function renderCliToolRow(
250
250
  } else if (status?.state === "unknown") {
251
251
  icon = "?";
252
252
  iconColor = theme.colors.warning;
253
+ } else if (status?.state === "external") {
254
+ // Installed, and left alone on purpose: present, not a finding.
255
+ icon = "●";
256
+ iconColor = theme.colors.muted;
253
257
  } else {
254
258
  icon = "●";
255
259
  iconColor = theme.colors.success;
@@ -353,6 +357,8 @@ export function renderCliToolDetail(
353
357
  </text>
354
358
  ) : state === "upgrade" ? (
355
359
  <text fg={theme.colors.warning}>{"● Update available"}</text>
360
+ ) : state === "external" ? (
361
+ <text fg={theme.colors.muted}>{"● Installed, kept as is"}</text>
356
362
  ) : state === "unknown" ? (
357
363
  // A version that was read but cannot be checked for updates is
358
364
  // still a version: say "installed", and let Why say the rest.
@@ -365,12 +371,17 @@ export function renderCliToolDetail(
365
371
  <text fg={theme.colors.success}>{"● Up to date"}</text>
366
372
  )}
367
373
  </box>
368
- {state === "unknown" && status?.note ? (
374
+ {(state === "unknown" || state === "external") && status?.note ? (
369
375
  <box flexDirection="row">
370
376
  <text fg={theme.colors.muted} flexShrink={0}>
371
377
  {"Why "}
372
378
  </text>
373
- <text fg={theme.colors.warning} minWidth={0}>
379
+ <text
380
+ fg={
381
+ state === "external" ? theme.colors.muted : theme.colors.warning
382
+ }
383
+ minWidth={0}
384
+ >
374
385
  {wrapValue(status.note, width)}
375
386
  </text>
376
387
  </box>