magus-cli 7.10.0 → 7.10.2

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.
@@ -0,0 +1,345 @@
1
+ /**
2
+ * Blind tests, contract C18: Homebrew owns an installed tool whose file lies
3
+ * in `<brew prefix>/Cellar/<short name>/<version>/...`, whatever installer the
4
+ * spec declares. A pinned spec is not adopted (brew takes no version).
5
+ *
6
+ * Written from the spec only, through a scripted fake `ToolProbeIO`.
7
+ */
8
+ import { describe, expect, test } from "bun:test";
9
+ import {
10
+ type ExecResult,
11
+ type FetchJsonResult,
12
+ type ToolProbeIO,
13
+ classifyTool,
14
+ probeTool,
15
+ } from "../../services/tool-probe.js";
16
+ import type { BinRequirement } from "../../types/index.js";
17
+
18
+ interface FakeSetup {
19
+ exec?: Record<string, Partial<ExecResult>>;
20
+ fetch?: Record<string, FetchJsonResult>;
21
+ files?: Record<string, string>;
22
+ real?: Record<string, string>;
23
+ onPath?: Record<string, string>;
24
+ env?: Record<string, string>;
25
+ }
26
+
27
+ function fakeIO(setup: FakeSetup): ToolProbeIO & { calls: string[] } {
28
+ const calls: string[] = [];
29
+ return {
30
+ calls,
31
+ exec: async (command) => {
32
+ const key = [command.cmd, ...command.args].join(" ");
33
+ calls.push(key);
34
+ const h = setup.exec?.[key];
35
+ if (h === undefined) {
36
+ return {
37
+ code: null,
38
+ stdout: "",
39
+ stderr: "",
40
+ timedOut: false,
41
+ missing: true,
42
+ };
43
+ }
44
+ return {
45
+ code: 0,
46
+ stdout: "",
47
+ stderr: "",
48
+ timedOut: false,
49
+ missing: false,
50
+ ...h,
51
+ };
52
+ },
53
+ fetchJson: async (url) => {
54
+ const r = setup.fetch?.[url];
55
+ return r ?? { ok: false, reason: "returned 404" };
56
+ },
57
+ readJson: async (file) => {
58
+ const t = setup.files?.[file];
59
+ if (t === undefined) return null;
60
+ try {
61
+ return JSON.parse(t);
62
+ } catch {
63
+ return null;
64
+ }
65
+ },
66
+ readText: async (file) => setup.files?.[file] ?? null,
67
+ readlink: async () => null,
68
+ realpath: async (file) => setup.real?.[file] ?? null,
69
+ resolveExecutable: async (name) => setup.onPath?.[name] ?? null,
70
+ env: setup.env ?? {},
71
+ homedir: "/Users/j",
72
+ };
73
+ }
74
+
75
+ const PREFIX = "/opt/homebrew";
76
+ const CELLAR = { "/opt/homebrew/Cellar": "/opt/homebrew/Cellar" };
77
+ const BREW_PREFIX_OK: Record<string, Partial<ExecResult>> = {
78
+ "brew --prefix": { stdout: `${PREFIX}\n` },
79
+ };
80
+
81
+ const OUTDATED_NONE = JSON.stringify({ formulae: [], casks: [] });
82
+ const outdatedBehind = (name: string, installed: string, current: string) =>
83
+ JSON.stringify({
84
+ formulae: [
85
+ {
86
+ name,
87
+ installed_versions: [installed],
88
+ current_version: current,
89
+ pinned: false,
90
+ },
91
+ ],
92
+ casks: [],
93
+ });
94
+
95
+ /** A tool whose PATH entry resolves into a Homebrew keg. */
96
+ function kegWorld(
97
+ bin: string,
98
+ short: string,
99
+ version: string,
100
+ outdatedKey: string,
101
+ outdatedStdout: string,
102
+ extraExec: Record<string, Partial<ExecResult>> = BREW_PREFIX_OK,
103
+ ): FakeSetup {
104
+ const file = `${PREFIX}/Cellar/${short}/${version}/bin/${bin}`;
105
+ const onPath = `${PREFIX}/bin/${bin}`;
106
+ return {
107
+ onPath: { [bin]: onPath },
108
+ real: { [onPath]: file, [file]: file, ...CELLAR },
109
+ exec: {
110
+ ...extraExec,
111
+ [`brew outdated --json=v2 ${outdatedKey}`]: { stdout: outdatedStdout },
112
+ },
113
+ };
114
+ }
115
+
116
+ const MNEMEX: BinRequirement = {
117
+ name: "mnemex",
118
+ via: "brew",
119
+ formula: "madappgang/tap/mnemex",
120
+ };
121
+
122
+ describe("C18 case 1: tap formula, brew spec, current", () => {
123
+ test("managed, version from the keg, state current", async () => {
124
+ const f = await probeTool(
125
+ MNEMEX,
126
+ fakeIO(
127
+ kegWorld(
128
+ "mnemex",
129
+ "mnemex",
130
+ "0.36.2",
131
+ "madappgang/tap/mnemex",
132
+ OUTDATED_NONE,
133
+ ),
134
+ ),
135
+ );
136
+ expect(f.managed).toBe(true);
137
+ expect(f.installed).toBe("0.36.2");
138
+ expect(classifyTool(MNEMEX, f).state).toBe("current");
139
+ });
140
+ });
141
+
142
+ describe("C18 case 2: tap formula behind", () => {
143
+ test("state upgrade, latest from brew", async () => {
144
+ const f = await probeTool(
145
+ MNEMEX,
146
+ fakeIO(
147
+ kegWorld(
148
+ "mnemex",
149
+ "mnemex",
150
+ "0.36.2",
151
+ "madappgang/tap/mnemex",
152
+ outdatedBehind("madappgang/tap/mnemex", "0.36.2", "0.37.0"),
153
+ ),
154
+ ),
155
+ );
156
+ expect(f.managed).toBe(true);
157
+ expect(f.latest).toBe("0.37.0");
158
+ expect(classifyTool(MNEMEX, f).state).toBe("upgrade");
159
+ });
160
+ });
161
+
162
+ describe("C18 case 3: bun spec, file in a Homebrew keg", () => {
163
+ const spec: BinRequirement = {
164
+ name: "claudish",
165
+ via: "bun",
166
+ package: "claudish",
167
+ };
168
+ test("adopted by brew: brew upgrade command, never bun", async () => {
169
+ const f = await probeTool(
170
+ spec,
171
+ fakeIO(
172
+ kegWorld(
173
+ "claudish",
174
+ "claudish",
175
+ "10.5.0",
176
+ "claudish",
177
+ outdatedBehind("claudish", "10.5.0", "10.6.0"),
178
+ ),
179
+ ),
180
+ );
181
+ expect(f.managed).toBe(true);
182
+ expect(f.installed).toBe("10.5.0");
183
+ expect(f.latest).toBe("10.6.0");
184
+ expect(f.upgradeCommand).toEqual({
185
+ cmd: "brew",
186
+ args: ["upgrade", "claudish"],
187
+ });
188
+ expect(f.upgradeCommand?.cmd).not.toBe("bun");
189
+ expect(classifyTool(spec, f).state).toBe("upgrade");
190
+ });
191
+ });
192
+
193
+ describe("C18 case 4: npm spec, file in a Homebrew keg", () => {
194
+ const spec: BinRequirement = {
195
+ name: "claudish",
196
+ via: "npm",
197
+ package: "claudish",
198
+ };
199
+ test("current, upgrade command is brew", async () => {
200
+ const f = await probeTool(
201
+ spec,
202
+ fakeIO(
203
+ kegWorld("claudish", "claudish", "10.5.0", "claudish", OUTDATED_NONE),
204
+ ),
205
+ );
206
+ expect(f.upgradeCommand?.cmd).toBe("brew");
207
+ expect(classifyTool(spec, f).state).toBe("current");
208
+ });
209
+ });
210
+
211
+ describe("C18 case 5: a pinned spec is not adopted by Homebrew", () => {
212
+ const spec: BinRequirement = {
213
+ name: "claudish",
214
+ via: "bun",
215
+ package: "claudish",
216
+ version: "10.4.0",
217
+ };
218
+ test("unknown, no brew command, note mentions the pin", async () => {
219
+ const f = await probeTool(
220
+ spec,
221
+ fakeIO(
222
+ kegWorld(
223
+ "claudish",
224
+ "claudish",
225
+ "10.5.0",
226
+ "claudish",
227
+ outdatedBehind("claudish", "10.5.0", "10.6.0"),
228
+ ),
229
+ ),
230
+ );
231
+ expect(f.upgradeCommand?.cmd).not.toBe("brew");
232
+ const s = classifyTool(spec, f);
233
+ expect(s.state).toBe("unknown");
234
+ expect(s.note ?? "").toContain("10.4.0");
235
+ });
236
+ });
237
+
238
+ describe("C18 case 6: a directory merely named Cellar is not Homebrew's", () => {
239
+ const spec: BinRequirement = {
240
+ name: "claudish",
241
+ via: "bun",
242
+ package: "claudish",
243
+ };
244
+ test("not adopted: external, no upgrade command", async () => {
245
+ const file = "/Users/u/projects/Cellar/claudish/1.0.0/bin/claudish";
246
+ const f = await probeTool(
247
+ spec,
248
+ fakeIO({
249
+ onPath: { claudish: file },
250
+ real: { [file]: file, ...CELLAR },
251
+ exec: BREW_PREFIX_OK,
252
+ }),
253
+ );
254
+ expect(f.managed).not.toBe(true);
255
+ expect(f.upgradeCommand).toBeUndefined();
256
+ expect(classifyTool(spec, f).state).toBe("external");
257
+ });
258
+ });
259
+
260
+ describe("C18 case 7: Homebrew unavailable", () => {
261
+ const spec: BinRequirement = {
262
+ name: "claudish",
263
+ via: "bun",
264
+ package: "claudish",
265
+ };
266
+ const world = (exec: Record<string, Partial<ExecResult>>): FakeSetup =>
267
+ kegWorld(
268
+ "claudish",
269
+ "claudish",
270
+ "10.5.0",
271
+ "claudish",
272
+ outdatedBehind("claudish", "10.5.0", "10.6.0"),
273
+ exec,
274
+ );
275
+ test("brew --prefix missing: not adopted", async () => {
276
+ const f = await probeTool(spec, fakeIO(world({})));
277
+ expect(f.upgradeCommand?.cmd).not.toBe("brew");
278
+ expect(classifyTool(spec, f).state).not.toBe("upgrade");
279
+ });
280
+ test("brew --prefix exits non-zero: not adopted", async () => {
281
+ const f = await probeTool(
282
+ spec,
283
+ fakeIO(world({ "brew --prefix": { code: 1, stderr: "boom" } })),
284
+ );
285
+ expect(f.upgradeCommand?.cmd).not.toBe("brew");
286
+ expect(classifyTool(spec, f).state).not.toBe("upgrade");
287
+ });
288
+ });
289
+
290
+ describe("C18 case 8: a non-tap brew spec still works", () => {
291
+ test("tmux current, installed 3.8", async () => {
292
+ const spec: BinRequirement = {
293
+ name: "tmux",
294
+ via: "brew",
295
+ formula: "tmux",
296
+ check: "tmux -V",
297
+ };
298
+ const f = await probeTool(
299
+ spec,
300
+ fakeIO(kegWorld("tmux", "tmux", "3.8", "tmux", OUTDATED_NONE)),
301
+ );
302
+ expect(f.managed).toBe(true);
303
+ expect(f.installed).toBe("3.8");
304
+ expect(classifyTool(spec, f).state).toBe("current");
305
+ });
306
+ });
307
+
308
+ describe("C18 case 9: bun ownership still wins over a brew spec", () => {
309
+ test("file in bun's global tree: bun command, current", async () => {
310
+ const root = "/Users/j/.bun/install/global/node_modules";
311
+ const pkgDir = `${root}/mnemex`;
312
+ const file = `${pkgDir}/dist/index.js`;
313
+ const onPath = "/Users/j/.bun/bin/mnemex";
314
+ const f = await probeTool(
315
+ MNEMEX,
316
+ fakeIO({
317
+ env: { HOMEBREW_PREFIX: PREFIX },
318
+ onPath: { mnemex: onPath },
319
+ real: {
320
+ [onPath]: file,
321
+ [file]: file,
322
+ [pkgDir]: pkgDir,
323
+ [root]: root,
324
+ ...CELLAR,
325
+ },
326
+ files: {
327
+ [`${pkgDir}/package.json`]: JSON.stringify({
328
+ name: "mnemex",
329
+ version: "0.36.2",
330
+ }),
331
+ },
332
+ exec: BREW_PREFIX_OK,
333
+ fetch: {
334
+ "https://registry.npmjs.org/-/package/mnemex/dist-tags": {
335
+ ok: true,
336
+ body: { latest: "0.36.2" },
337
+ },
338
+ },
339
+ }),
340
+ );
341
+ expect(f.managed).toBe(true);
342
+ expect(f.upgradeCommand?.cmd).toBe("bun");
343
+ expect(classifyTool(MNEMEX, f).state).toBe("current");
344
+ });
345
+ });
@@ -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,11 @@ 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("installed outside npm");
217
+ expect(row.status?.note).not.toContain("/");
215
218
  // Enter still follows how it IS installed (pnpm).
216
219
  expect(row.updateCommand?.cmd).toBe("pnpm");
217
220
  });
@@ -249,16 +252,28 @@ describe("cliToolStatusText — the row's words", () => {
249
252
  primary: "npm",
250
253
  all: ["npm"],
251
254
  });
252
- const f = facts("crush", { managed: false, owner: "npm (root /l)" });
255
+ const f = facts("crush", { managed: null });
253
256
  const row = buildToolRow(tool, spec, f);
254
257
  const text = cliToolStatusText(row);
255
258
  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.
259
+ expect(text.suffix).toBe(" ? owner not known");
260
+ // The note is for the detail pane's "Why", not the row.
258
261
  expect(row.status?.note).toBe(classifyTool(spec, f).note);
259
262
  expect(text.suffix).not.toContain("/");
260
263
  });
261
264
 
265
+ test("external renders muted, naming the installer it is outside of", () => {
266
+ const tool = catalogue("crush");
267
+ const spec = specForCatalogTool(tool, null, {
268
+ primary: "npm",
269
+ all: ["npm"],
270
+ });
271
+ const row = buildToolRow(tool, spec, facts("crush", { managed: false }));
272
+ const text = cliToolStatusText(row);
273
+ expect(text.tone).toBe("muted");
274
+ expect(text.suffix).toBe(" · outside npm");
275
+ });
276
+
262
277
  test("upgrade names the target once, with one v", () => {
263
278
  const tool = catalogue("mnemex");
264
279
  const spec = specForCatalogTool(tool, null, {
@@ -287,12 +302,14 @@ describe("countToolRows — header counts from states", () => {
287
302
  const rows = [
288
303
  buildToolRow(tool, spec, facts("mnemex", { behind: false })),
289
304
  buildToolRow(tool, spec, facts("mnemex", { behind: true })),
305
+ buildToolRow(tool, spec, facts("mnemex", { managed: null })),
306
+ // Another installer's: installed, and not "unverified".
290
307
  buildToolRow(tool, spec, facts("mnemex", { managed: false })),
291
308
  buildToolRow(tool, spec, facts("mnemex", { present: false })),
292
309
  buildToolRow(tool, spec, undefined),
293
310
  ];
294
311
  expect(countToolRows(rows)).toEqual({
295
- installed: 3,
312
+ installed: 4,
296
313
  updates: 1,
297
314
  unverified: 1,
298
315
  });
@@ -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");
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");
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,11 @@ 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":
461
+ // Kept as is: a missing version is not a finding, so no "—" stands in.
462
+ return [item.installed, item.note ? dim(`(${item.note})`) : ""]
463
+ .filter(Boolean)
464
+ .join(" ");
456
465
  case "unknown":
457
466
  return `${item.installed ?? "—"}${
458
467
  item.note ? ` ${dim(`(${item.note})`)}` : ""
@@ -927,15 +936,18 @@ function worktreeLabel(names: readonly string[]): string {
927
936
  * The worktree rows: after a setup (`results`), one `✓` line for every
928
937
  * worktree set up and a `✗` line for each failure; on a read-only run
929
938
  * (`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.
939
+ *
940
+ * A worktree that needs a choice gets no line. magus cannot set it up from
941
+ * here, and a line per worktree on every run is noise about folders the user
942
+ * may never open: old branches, agent and bench worktrees. The `setup`
943
+ * plugin says so when Claude Code starts in one, and magus run there asks.
944
+ * Empty when there is nothing to say, blank line included.
932
945
  */
933
946
  export function worktreeLines(
934
947
  worktrees: readonly UnsetWorktree[],
935
948
  results: readonly WorktreeSetupResult[] | null,
936
949
  ): string[] {
937
- if (worktrees.length === 0) return [];
938
- const lines = [""];
950
+ const lines: string[] = [];
939
951
  if (results === null) {
940
952
  const auto = worktrees.filter((w) => w.autoActivate).map((w) => w.name);
941
953
  if (auto.length > 0) {
@@ -965,14 +977,5 @@ export function worktreeLines(
965
977
  }
966
978
  }
967
979
  }
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;
980
+ return lines.length > 0 ? ["", ...lines] : [];
978
981
  }